diff --git a/.changeset/loud-donkeys-repeat.md b/.changeset/loud-donkeys-repeat.md new file mode 100644 index 00000000..f30ffb80 --- /dev/null +++ b/.changeset/loud-donkeys-repeat.md @@ -0,0 +1,5 @@ +--- +"@sketchi/cli": patch +--- + +Paint a legible pencil mark on root help and stop dropping colour on terminals that omit `COLORFGBG`. The lockup is a half-block pixel icon and `sketchi` wordmark; root help collapses to `START HERE` and `WORK WITH A DIAGRAM`. Pipes, `NO_COLOR`, JSON and non-UTF-8 locales still render plain text with no block art. diff --git a/apps/cli/src/__fixtures__/help/root.txt b/apps/cli/src/__fixtures__/help/root.txt index c2509cfc..e1b8dc27 100644 --- a/apps/cli/src/__fixtures__/help/root.txt +++ b/apps/cli/src/__fixtures__/help/root.txt @@ -1,18 +1,8 @@ - ╱██╲ - ╱████╲ - ╱████╱ - ╱████╱ - ╱████╱ - ╱___╱ - ◢ - - sketchi - describe it. sketchi draws it. + sketchi + describe it. sketchi draws it. START HERE - generate Start the short wizard, or pass --prompt for direct generation. - -EXAMPLE + sketchi generate interactive sketchi generate --prompt "Map release approval with pass and revise branches" Writes .png in this directory. No account or API key needed. @@ -22,9 +12,4 @@ WORK WITH A DIAGRAM export Write PNG, Excalidraw, or scene bytes. share Create an encrypted Excalidraw link. -GO DEEPER - sketchi docs Complete command map and automation contracts. - sketchi generate --help Every generation option. - sketchi --help Targeted help for any command. - -Automation: pass --prompt and add --output json. Version: sketchi --version +sketchi docs for every command. Add --output json for automation. diff --git a/apps/cli/src/help-brand.test.ts b/apps/cli/src/help-brand.test.ts index 63a189a3..592772b1 100644 --- a/apps/cli/src/help-brand.test.ts +++ b/apps/cli/src/help-brand.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import stringWidth from "string-width"; import { renderRootHelp, @@ -7,31 +8,82 @@ import { } from "./help-brand.js"; describe("CLI help brand", () => { - it("renders a readable plain fallback without escape sequences", () => { + it("falls back to the plain word without block art or escape sequences", () => { const output = renderRootHelp({ colors: "none", background: "dark" }); - expect(output).toContain("╱████╲"); - expect(output).toContain("◢"); - expect(output).toContain("sketchi"); + expect(output).toContain(" sketchi\n"); + expect(output).toContain("describe it. sketchi draws it."); expect(output).toContain("START HERE"); expect(output).toContain("WORK WITH A DIAGRAM"); + expect(output).not.toMatch(/[▀▄█]/u); expect(output).not.toContain("\u001b"); }); - it("styles the complete help hierarchy with a background-aware palette", () => { - const dark = renderRootHelp({ colors: "truecolor", background: "dark" }); + it("paints the pencil tile and wordmark when the terminal has colour", () => { + const output = renderRootHelp({ + colors: "truecolor", + background: "dark", + unicode: true, + width: 80, + }); + const lockup = output.split("\n\n")[0] ?? ""; + + // Plate, barrel, wood, graphite, ferrule and eraser all reach the screen. + for (const material of [ + "38;2;158;124;140", + "38;2;250;248;247", + "38;2;214;186;152", + "38;2;58;50;54", + "38;2;186;190;196", + "38;2;222;152;158", + ]) { + expect(lockup).toContain(material); + } + expect(lockup).toContain("48;2;"); + expect(lockup.split("\n")).toHaveLength(8); + expect(output).toContain("\u001b[38;2;195;154;172m\u001b[1mSTART HERE"); + }); + + it("keeps the light palette legible against its own background", () => { const light = renderRootHelp({ colors: "truecolor", background: "light", + unicode: true, + width: 80, }); - expect(dark).toContain("\u001b[38;2;195;154;172m"); - expect(dark).toContain("\u001b[38;2;246;241;231m\u001b[1msketchi"); - expect(dark).toContain("\u001b[38;2;195;154;172m\u001b[1mSTART HERE"); + expect(light).toContain("38;2;143;112;127"); expect(light).toContain("\u001b[38;2;26;23;18m\u001b[1msketchi"); }); - it("uses a strict ASCII pencil and wraps descriptions at narrow widths", () => { + it("keeps every lockup line inside the terminal width", () => { + for (const width of [32, 46, 52, 80, 120]) { + const output = renderRootHelp({ + colors: "truecolor", + background: "dark", + unicode: true, + width, + }); + + for (const line of output.split("\n")) { + expect(stringWidth(line)).toBeLessThanOrEqual(width); + } + } + }); + + it("drops the tile but keeps the wordmark on narrow terminals", () => { + const output = renderRootHelp({ + colors: "truecolor", + background: "dark", + unicode: true, + width: 46, + }); + + expect(output).toMatch(/[▀▄█]/u); + expect(output).not.toContain("48;2;"); + }); + + it("wraps descriptions and drops block art without UTF-8", () => { const output = renderRootHelp({ colors: "none", background: "dark", @@ -39,9 +91,8 @@ describe("CLI help brand", () => { width: 36, }); - expect(output).toContain("/####\\"); - expect(output).not.toContain("█"); - expect(output).not.toContain("◢"); + expect(output).toContain(" sketchi\n"); + expect(output).not.toMatch(/[▀▄█]/u); expect(output.split("\n").every((line) => [...line].length <= 36)).toBe( true, ); diff --git a/apps/cli/src/help-brand.ts b/apps/cli/src/help-brand.ts index ae50f9aa..372b819c 100644 --- a/apps/cli/src/help-brand.ts +++ b/apps/cli/src/help-brand.ts @@ -2,28 +2,72 @@ import { Chalk } from "chalk"; import stringWidth from "string-width"; import wrapAnsi from "wrap-ansi"; -// These silhouettes follow the down-left nib and up-right barrel of the -// product icon in apps/web/public/icon.svg. The ASCII version is deliberately -// structural rather than a transliteration of box-drawing characters. -const UNICODE_PENCIL = [ - " ╱██╲", - " ╱████╲", - " ╱████╱", - " ╱████╱", - " ╱████╱", - " ╱___╱", - " ◢", +// A 16x16 pixel icon drawn for the terminal, painted two pixel rows per cell +// with half blocks. The product icon in apps/web/public/icon.svg is a hairline +// pencil outline that dissolves below ~64px, so this redraws the same subject +// as solid material bands — graphite nib, wood bevel, barrel, ferrule, eraser — +// on the brand plate. Legend: space plate cut-out, p plate, k graphite, w wood, +// c barrel, f ferrule, e eraser. +const PENCIL_TILE = [ + " pppppppppppp ", + " pppppppppppppp ", + "pppppppppppeeppp", + "ppppppppppfeeepp", + "pppppppppcffeepp", + "ppppppppcccffppp", + "pppppppcccccpppp", + "ppppppcccccppppp", + "pppppcccccpppppp", + "ppppwccccppppppp", + "pppwwwccpppppppp", + "pppkwwwppppppppp", + "pppkkwpppppppppp", + "pppppppppppppppp", + " pppppppppppppp ", + " pppppppppppp ", ] as const; -const ASCII_PENCIL = [ - " /##\\", - " /####\\", - " /####/", - " /####/", - " /####/", - " /___/", - " /_", -] as const; +// 8 rows tall so it pairs with the tile at four half-block cells. +const WORDMARK_GLYPHS: Readonly> = { + s: ["....", "....", ".###", "#...", ".##.", "...#", "###.", "...."], + k: ["#...", "#...", "#..#", "#.#.", "##..", "#.#.", "#..#", "...."], + e: ["....", "....", ".##.", "#..#", "####", "#...", ".###", "...."], + t: [".#.", ".#.", "###", ".#.", ".#.", ".#.", ".##", "..."], + c: ["....", "....", ".###", "#...", "#...", "#...", ".###", "...."], + h: ["#...", "#...", "#.#.", "##.#", "#..#", "#..#", "#..#", "...."], + i: ["#", ".", "#", "#", "#", "#", "#", "."], +}; + +const TILE_MATERIALS = { + p: { + dark: { red: 158, green: 124, blue: 140, ansi256: 138 }, + light: { red: 143, green: 112, blue: 127, ansi256: 96 }, + }, + c: { + dark: { red: 250, green: 248, blue: 247, ansi256: 231 }, + light: { red: 250, green: 248, blue: 247, ansi256: 231 }, + }, + w: { + dark: { red: 214, green: 186, blue: 152, ansi256: 180 }, + light: { red: 214, green: 186, blue: 152, ansi256: 180 }, + }, + k: { + dark: { red: 58, green: 50, blue: 54, ansi256: 237 }, + light: { red: 58, green: 50, blue: 54, ansi256: 237 }, + }, + f: { + dark: { red: 186, green: 190, blue: 196, ansi256: 250 }, + light: { red: 186, green: 190, blue: 196, ansi256: 250 }, + }, + e: { + dark: { red: 222, green: 152, blue: 158, ansi256: 175 }, + light: { red: 222, green: 152, blue: 158, ansi256: 175 }, + }, +} as const satisfies Record>; + +const TILE_WIDTH = 16; +const LOCKUP_GAP = 2; +const TAGLINE = "describe it. sketchi draws it."; const BRAND = { dark: { red: 195, green: 154, blue: 172, ansi256: 181 }, @@ -102,19 +146,112 @@ function description(text: string, options: HelpBrandOptions): string { return styled(text, MUTED[options.background], options); } +function paint( + color: Color, + options: HelpBrandOptions, +): (text: string) => string { + const chalk = new Chalk({ level: options.colors === "truecolor" ? 3 : 2 }); + return options.colors === "truecolor" + ? chalk.rgb(color.red, color.green, color.blue) + : chalk.ansi256(color.ansi256); +} + +function tileColor( + pixel: string, + options: HelpBrandOptions, +): Color | undefined { + const material = TILE_MATERIALS[pixel as keyof typeof TILE_MATERIALS]; + return material === undefined ? undefined : material[options.background]; +} + +// Two pixel rows per cell: the upper half block carries the top pixel as +// foreground and the lower pixel as background, so square pixels stay square. +function tileRows(options: HelpBrandOptions): readonly string[] { + const rows: string[] = []; + for (let y = 0; y < PENCIL_TILE.length; y += 2) { + let row = ""; + for (let x = 0; x < TILE_WIDTH; x += 1) { + const top = tileColor(PENCIL_TILE[y]?.[x] ?? " ", options); + const bottom = tileColor(PENCIL_TILE[y + 1]?.[x] ?? " ", options); + if (top === undefined && bottom === undefined) row += " "; + else if (top === undefined) row += paint(bottom as Color, options)("▄"); + else if (bottom === undefined) row += paint(top, options)("▀"); + else row += paintPair(top, bottom, options); + } + rows.push(row); + } + return rows; +} + +function paintPair( + top: Color, + bottom: Color, + options: HelpBrandOptions, +): string { + const chalk = new Chalk({ level: options.colors === "truecolor" ? 3 : 2 }); + const background = + options.colors === "truecolor" + ? chalk.bgRgb(bottom.red, bottom.green, bottom.blue) + : chalk.bgAnsi256(bottom.ansi256); + return options.colors === "truecolor" + ? background.rgb(top.red, top.green, top.blue)("▀") + : background.ansi256(top.ansi256)("▀"); +} + +function wordmarkRows(options: HelpBrandOptions): readonly string[] { + const pixels = Array.from({ length: 8 }, (_, y) => + [..."sketchi"] + .map( + (character, index) => + `${WORDMARK_GLYPHS[character]?.[y] ?? ""}${index === 6 ? "" : "."}`, + ) + .join(""), + ); + const ink = FOREGROUND[options.background]; + const rows: string[] = []; + for (let y = 0; y < pixels.length; y += 2) { + let row = ""; + for (let x = 0; x < (pixels[y]?.length ?? 0); x += 1) { + const top = pixels[y]?.[x] === "#"; + const bottom = pixels[y + 1]?.[x] === "#"; + row += top && bottom ? "█" : top ? "▀" : bottom ? "▄" : " "; + } + rows.push(options.colors === "none" ? row : paint(ink, options)(row)); + } + return rows; +} + +const WORDMARK_WIDTH = 30; + function brandLockup(options: HelpBrandOptions): string { - const pencil = options.unicode === false ? ASCII_PENCIL : UNICODE_PENCIL; const width = Math.max(32, options.width ?? 80); - const indent = width < 40 ? " " : " "; + const tagline = wrappedLine(" ", description(TAGLINE, options), width); + + // The mark is a colour asset. Pipes, NO_COLOR and non-UTF-8 locales get the + // word itself rather than block art that would be noise there. + if (options.colors === "none" || options.unicode === false) { + return [ + ` ${styled("sketchi", FOREGROUND[options.background], options, "bold")}`, + tagline, + ].join("\n"); + } + + const wordmark = wordmarkRows(options); + if (width < 2 + TILE_WIDTH + LOCKUP_GAP + WORDMARK_WIDTH) { + return [...wordmark.map((row) => ` ${row}`), "", tagline].join("\n"); + } + + // Centre the four-cell wordmark against the eight-cell tile. + const tile = tileRows(options); + const offset = (tile.length - wordmark.length) / 2; + const gap = " ".repeat(LOCKUP_GAP); return [ - ...pencil.map((line) => styled(line, BRAND[options.background], options)), + ...tile.map((row, index) => { + const beside = wordmark[index - offset]; + return ` ${row}${beside === undefined ? "" : `${gap}${beside}`}`; + }), "", - `${indent}${styled("sketchi", FOREGROUND[options.background], options, "bold")}`, - wrappedLine( - indent, - description("describe it. sketchi draws it.", options), - width, - ), + tagline, ].join("\n"); } @@ -157,14 +294,11 @@ export function renderRootHelp(options: HelpBrandOptions): string { brandLockup(options), "", heading("START HERE", options), - action( - "generate", - "Start the short wizard, or pass --prompt for direct generation.", - options, + wrappedLine( + ` ${command("sketchi generate", options)} `, + description("interactive", options), width, ), - "", - heading("EXAMPLE", options), wrappedLine(" ", command(example, options), width), wrappedLine( " ", @@ -181,33 +315,10 @@ export function renderRootHelp(options: HelpBrandOptions): string { action("export", "Write PNG, Excalidraw, or scene bytes.", options, width), action("share", "Create an encrypted Excalidraw link.", options, width), "", - heading("GO DEEPER", options), - action( - "sketchi docs", - "Complete command map and automation contracts.", - options, - width, - 26, - ), - action( - "sketchi generate --help", - "Every generation option.", - options, - width, - 26, - ), - action( - "sketchi --help", - "Targeted help for any command.", - options, - width, - 26, - ), - "", wrappedLine( "", description( - "Automation: pass --prompt and add --output json. Version: sketchi --version", + "sketchi docs for every command. Add --output json for automation.", options, ), width, @@ -291,13 +402,16 @@ export function terminalPaletteForBackground( }; } +// COLORFGBG is a courtesy hint that only a minority of terminals set, so its +// absence must not disable colour — it only means we cannot detect a light +// background and fall back to the dark palette. function terminalPalette( colors: Exclude, ): TerminalPalette { const colorForegroundBackground = process.env["COLORFGBG"]; const background = colorForegroundBackground?.split(";").at(-1); if (!background || !/^\d+$/u.test(background)) { - return { background: "dark", readable: false }; + return { background: "dark", readable: true }; } return terminalPaletteForBackground(Number(background), colors); } diff --git a/apps/cli/src/help.test.ts b/apps/cli/src/help.test.ts index 04ebd46b..d56c8793 100644 --- a/apps/cli/src/help.test.ts +++ b/apps/cli/src/help.test.ts @@ -217,10 +217,16 @@ describe("golden product help", () => { ).toContain("\u001b[38;5;255m\u001b[1msketchi"); }); + // COLORFGBG is set by a minority of terminals, so an absent or malformed + // value must still paint: it only costs us light-background detection. it.each([undefined, "unknown", "15;"])( - "uses terminal-default text for unknown background %s", + "paints the dark palette when the background is unknown (%s)", (colorForegroundBackground) => { - expect(ttyHelp({ colorForegroundBackground })).not.toContain("\u001b"); + const output = ttyHelp({ colorForegroundBackground }); + + expect(output).toContain("\u001b[38;2;158;124;140m"); + expect(output).toContain("\u001b[38;2;195;154;172m\u001b[1mSTART HERE"); + expect(output).not.toContain("\u001b[38;2;143;112;127m"); }, );