diff --git a/docs/webcmd-weaver.svg b/docs/webcmd-weaver.svg new file mode 100644 index 000000000..b9eaa3091 --- /dev/null +++ b/docs/webcmd-weaver.svg @@ -0,0 +1,23 @@ + + Weaver — the webcmd mascot + + + + + + + + + + + + + + + + + + + + + diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 70bbff5fd..3fb42f4e1 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -16,6 +16,109 @@ */ import { mkdirSync, writeFileSync, existsSync } from 'node:fs'; + +// ── Weaver, the webcmd mascot ────────────────────────────────────────────── +// +// Deliberately duplicated from src/mascot.ts: this script must run with no +// build step and no imports from the source tree, so it cannot reuse the +// module. src/mascot.test.ts asserts the two copies stay identical. + +const MASCOT_FRAME_HEIGHT = 9; +const BLANK = ' '.repeat(18); +const THREAD = ' ││ '; +const LEGS_TOP = [' ▓▓ ▓▓ ', ' ▓▓ ▓▓ ']; +const LEGS_BOTTOM = [' ▓▓ ▓▓ ▓▓ ▓▓ ', ' ▓▓ ▓▓ ▓▓ ▓▓ ']; +const HEAD = ' ██████████ '; +const EYES_OPEN = ' ▓▓██ ● ● ██▓▓ '; +const EYES_SHUT = ' ▓▓██ ─ ─ ██▓▓ '; +const MOUTH = ' ▓▓██ ▾ ██▓▓ '; +const BELLY = ' ██████████ '; + +function weaverSprite(blink, stance) { + return [LEGS_TOP[stance], HEAD, blink ? EYES_SHUT : EYES_OPEN, MOUTH, BELLY, LEGS_BOTTOM[stance]]; +} + +function frame(sprite, dropped) { + const rows = [...Array.from({ length: dropped }, () => THREAD), ...sprite]; + while (rows.length < MASCOT_FRAME_HEIGHT) rows.push(BLANK); + return rows; +} + +export const WEAVER_FRAMES = [ + frame(weaverSprite(false, 1), 1), + frame(weaverSprite(false, 1), 2), + frame(weaverSprite(false, 0), 3), + frame(weaverSprite(false, 1), 3), + frame(weaverSprite(false, 0), 3), + frame(weaverSprite(true, 0), 3), + frame(weaverSprite(false, 0), 3), +]; + +const ACCENT = '\u001b[38;2;86;197;255m'; +const SHADE = '\u001b[38;2;0;107;154m'; +const WHITE = '\u001b[97m'; +const RESET = '\u001b[0m'; +const GLYPH_COLOR = { '█': ACCENT, '│': ACCENT, '▓': SHADE, '▾': ACCENT, '●': WHITE, '─': WHITE }; + +function paintRow(row) { + let out = ''; + let run = ''; + let runColor; + const flush = () => { + if (run === '') return; + out += runColor === undefined ? run : `${runColor}${run}${RESET}`; + run = ''; + }; + for (const glyph of row) { + const color = GLYPH_COLOR[glyph]; + if (color !== runColor) { + flush(); + runColor = color; + } + run += glyph; + } + flush(); + return out; +} + +/** Accent text, unless the user asked for no color. */ +function accent(text) { + return process.env.NO_COLOR ? text : `${ACCENT}${text}${RESET}`; +} + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Greet the install with Weaver. + * + * Animates only on an interactive terminal — npm sometimes pipes lifecycle + * script output, and in-place redraw needs a real cursor. Everywhere else the + * final frame is printed once, so the mascot still shows up without leaving + * escape sequences in a log. CI is already excluded by the caller. + */ +async function playWeaver() { + if (process.env.WEBCMD_NO_MASCOT) return; + + const color = !process.env.NO_COLOR; + const paint = (rows) => rows.map(color ? paintRow : (row) => row).join('\n'); + + if (!process.stdout.isTTY) { + process.stdout.write(`${paint(WEAVER_FRAMES[WEAVER_FRAMES.length - 1])}\n`); + return; + } + + process.stdout.write('\u001b[?25l'); // Hide cursor + try { + for (let i = 0; i < WEAVER_FRAMES.length; i++) { + if (i > 0) process.stdout.write(`\u001b[${MASCOT_FRAME_HEIGHT}A\u001b[J`); + process.stdout.write(`${paint(WEAVER_FRAMES[i])}\n`); + if (i < WEAVER_FRAMES.length - 1) await sleep(90); + } + } finally { + process.stdout.write('\u001b[?25h'); // Show cursor + } +} + import { join } from 'node:path'; import { homedir } from 'node:os'; @@ -73,7 +176,7 @@ function ensureDir(dir) { // ── Main ─────────────────────────────────────────────────────────────────── -function main() { +async function main() { // Skip in CI environments if (process.env.CI || process.env.CONTINUOUS_INTEGRATION) { return; @@ -85,6 +188,10 @@ function main() { return; } + await playWeaver(); + console.log(` ${accent('Webcmd installed.')} Weaver crawls a site once so your agents never have to rediscover it.`); + console.log(''); + const shell = detectShell(); if (!shell) { // Cannot determine shell; silently skip @@ -154,4 +261,4 @@ function main() { } -main(); +main().catch(() => {}); diff --git a/src/brand.ts b/src/brand.ts index f755f7479..8ec1ce7d9 100644 --- a/src/brand.ts +++ b/src/brand.ts @@ -7,3 +7,13 @@ export const ENV_PREFIX = 'WEBCMD'; export const DAEMON_HEADER_NAME = 'X-Webcmd'; export const EXTENSION_PACKAGE_NAME = 'webcmd-extension'; export const EXTENSION_ARTIFACT_PREFIX = 'webcmd-extension'; + +/** + * Brand accent. Single source of truth shared by the help banner and the + * mascot; matches `colors.light` in docs/docs.json and the stroke color in + * docs/webcmd*.svg. + */ +export const ACCENT_RGB = { r: 0x56, g: 0xc5, b: 0xff } as const; + +/** Deeper brand blue, matching `colors.primary`/`colors.dark` in docs/docs.json. */ +export const SHADE_RGB = { r: 0x00, g: 0x6b, b: 0x9a } as const; diff --git a/src/command-presentation.ts b/src/command-presentation.ts index 5b5916e93..0d397d5ae 100644 --- a/src/command-presentation.ts +++ b/src/command-presentation.ts @@ -1,4 +1,5 @@ -import { CLI_COMMAND } from './brand.js'; +import { ACCENT_RGB, CLI_COMMAND } from './brand.js'; +import { MASCOT_WIDTH, WEAVER, renderRows } from './mascot.js'; import { JSON_FORMAT_ALIAS_HELP, OUTPUT_FORMAT_HELP, OUTPUT_FORMATS } from './command-surface.js'; import type { Arg } from './registry.js'; @@ -80,8 +81,8 @@ export interface FormatRootHelpOptions { columns?: number; } -/** Banner from ascii.md — WEB (white) + CMD (#56C5FF) when color is on. */ -export const ROOT_HELP_BANNER_PARTS = [ +/** Wordmark rows — WEB (white) + CMD (#56C5FF) when color is on. */ +const WORDMARK_PARTS = [ ['ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ', 'ㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤㅤ'], ['██╗ ██╗███████╗██████╗ ', '██████╗███╗ ███╗██████╗'], ['██║ ██║██╔════╝██╔══██╗', '██╔════╝████╗ ████║██╔══██╗'], @@ -91,8 +92,22 @@ export const ROOT_HELP_BANNER_PARTS = [ [' ╚══╝╚══╝ ╚══════╝╚═════╝ ', '╚═════╝╚═╝ ╚═╝╚═════╝'], ] as const; +/** + * Weaver stands to the left of the wordmark. The banner's first row is a + * Hangul-filler spacer that is already ~60 display columns wide, so the mascot + * column is empty there and the sprite starts on row 1 — that keeps the spacer + * row exactly as wide as it was before the mascot existed. + */ +const MASCOT_COLUMN: readonly string[] = ['', ...WEAVER.map((row) => `${row} `)]; + +const MASCOT_PAD = ' '.repeat(MASCOT_WIDTH + 2); + +/** Banner rows as `[mascot, web, cmd]` so each column can be painted separately. */ +export const ROOT_HELP_BANNER_PARTS: readonly (readonly [string, string, string])[] = + WORDMARK_PARTS.map(([web, cmd], row) => [MASCOT_COLUMN[row] ?? MASCOT_PAD, web, cmd] as const); + export const ROOT_HELP_BANNER = ROOT_HELP_BANNER_PARTS - .map(([web, cmd]) => `${web}${cmd}`) + .map(([mascot, web, cmd]) => `${mascot}${web}${cmd}`) .join('\n'); interface RootHelpCommandSection { @@ -109,9 +124,6 @@ const ROOT_HELP_COMMAND_SECTIONS: readonly RootHelpCommandSection[] = [ { title: 'COMPLETION', order: ['completion'] }, ]; -/** Brand accent from docs theme (`colors.light`). */ -const ACCENT_RGB = { r: 0x56, g: 0xc5, b: 0xff } as const; - const ANSI = { reset: '\u001b[0m', bold: '\u001b[1m', @@ -123,7 +135,8 @@ const ANSI = { function formatRootHelpBanner(color: boolean): string { if (!color) return ROOT_HELP_BANNER; return ROOT_HELP_BANNER_PARTS - .map(([web, cmd]) => `${paint(web, true, ANSI.white)}${paint(cmd, true, ANSI.accent)}`) + .map(([mascot, web, cmd]) => + `${renderRows([mascot], { color: true })}${paint(web, true, ANSI.white)}${paint(cmd, true, ANSI.accent)}`) .join('\n'); } diff --git a/src/hosted/setup.ts b/src/hosted/setup.ts index df54d1445..76b7ad37b 100644 --- a/src/hosted/setup.ts +++ b/src/hosted/setup.ts @@ -4,7 +4,8 @@ import { constants, existsSync } from 'node:fs'; import { homedir } from 'node:os'; import { access, realpath, stat } from 'node:fs/promises'; import { isAbsolute } from 'node:path'; -import { CLI_COMMAND, ENV_PREFIX } from '../brand.js'; +import { CLI_COMMAND, ENV_PREFIX, PRODUCT_DISPLAY_NAME } from '../brand.js'; +import { animateWeaver } from '../mascot.js'; import { ArgumentError, getErrorMessage, toEnvelope } from '../errors.js'; import { formatErrorEnvelope } from '../output.js'; import { writeToStream } from '../stream-write.js'; @@ -104,7 +105,13 @@ export async function runHostedSetup(io: SetupIo = {}): Promise { } const interactive = canPrompt(io); - await write('Webcmd setup\n'); + // Weaver greets first-run setup. Gated on the output actually being a + // terminal rather than on `interactive`: an injected `question` makes + // prompting possible without the stream being able to move a cursor. + if (canAnimate(io)) { + await animateWeaver(write, { animate: true, color: !process.env.NO_COLOR }); + } + await write(`${PRODUCT_DISPLAY_NAME} setup\n`); if ((parsed.chromeProfile || parsed.importChromeCookies !== undefined || parsed.syncToChrome) && parsed.browser && parsed.browser.kind !== 'chrome') { throw new ArgumentError( @@ -152,6 +159,12 @@ export async function runHostedSetup(io: SetupIo = {}): Promise { } } +/** Whether the output stream can render an in-place animation. */ +function canAnimate(io: SetupIo): boolean { + if (io.isTTY !== undefined) return io.isTTY; + return process.stdin.isTTY === true && process.stdout.isTTY === true; +} + function canPrompt(io: SetupIo): boolean { if (io.isTTY !== undefined) return io.isTTY; if (io.question) return true; diff --git a/src/mascot.test.ts b/src/mascot.test.ts new file mode 100644 index 000000000..e7e3d6c72 --- /dev/null +++ b/src/mascot.test.ts @@ -0,0 +1,140 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { + MASCOT_FRAME_HEIGHT, + MASCOT_HEIGHT, + MASCOT_WIDTH, + WEAVER, + WEAVER_FRAMES, + animateWeaver, + renderWeaver, +} from './mascot.js'; + +const ANSI_RE = /\u001b\[[0-9;?]*[A-Za-z]/g; + +function recorder(): { chunks: string[]; write: (chunk: string) => void } { + const chunks: string[] = []; + return { chunks, write: (chunk: string) => void chunks.push(chunk) }; +} + +describe('weaver geometry', () => { + it('is a rectangle — every resting row is exactly MASCOT_WIDTH', () => { + expect(WEAVER).toHaveLength(MASCOT_HEIGHT); + for (const row of WEAVER) expect([...row]).toHaveLength(MASCOT_WIDTH); + }); + + it('keeps every animation frame the same size', () => { + // The redraw moves the cursor up by a constant, so a frame of a different + // height would tear the animation instead of replacing it. + for (const frame of WEAVER_FRAMES) { + expect(frame).toHaveLength(MASCOT_FRAME_HEIGHT); + for (const row of frame) expect([...row]).toHaveLength(MASCOT_WIDTH); + } + }); + + it('stays narrow enough for the 80-column help banner', () => { + // The WEB CMD wordmark takes ~52 columns; the mascot plus its gutter must + // fit in what is left. + expect(MASCOT_WIDTH + 2).toBeLessThanOrEqual(28); + }); + + it('blinks and shifts stance across the idle frames', () => { + const rendered = WEAVER_FRAMES.map((frame) => frame.join('\n')); + expect(new Set(rendered).size).toBeGreaterThan(1); + expect(rendered.some((frame) => frame.includes('─ ─'))).toBe(true); + expect(rendered.some((frame) => frame.includes('● ●'))).toBe(true); + }); +}); + +describe('weaver color', () => { + it('emits no escapes when color is off', () => { + expect(renderWeaver({ color: false })).not.toMatch(/\u001b\[/); + }); + + it('is purely additive — stripping ANSI restores the plain render', () => { + // command-presentation.test.ts asserts this for the whole help screen, and + // the mascot is part of the help banner. + const plain = renderWeaver({ color: false }); + const colored = renderWeaver({ color: true }); + + expect(colored).toMatch(/\u001b\[/); + expect(colored.replace(ANSI_RE, '')).toBe(plain); + }); + + it('paints the body in the brand accent and the legs in the shade', () => { + const colored = renderWeaver({ color: true }); + expect(colored).toContain('\u001b[38;2;86;197;255m'); + expect(colored).toContain('\u001b[38;2;0;107;154m'); + }); +}); + +describe('animateWeaver', () => { + it('writes one static frame and no escapes when animation is off', async () => { + const { chunks, write } = recorder(); + await animateWeaver(write, { animate: false, color: false }); + + expect(chunks).toHaveLength(1); + expect(chunks[0]).not.toMatch(/\u001b\[/); + expect(chunks[0]).toBe(`${WEAVER_FRAMES[WEAVER_FRAMES.length - 1].join('\n')}\n`); + }); + + it('plays every frame and restores the cursor', async () => { + const { chunks, write } = recorder(); + await animateWeaver(write, { animate: true, color: false, sleep: async () => {} }); + + expect(chunks[0]).toBe('\u001b[?25l'); + expect(chunks[chunks.length - 1]).toBe('\u001b[?25h'); + + const frames = chunks.filter((chunk) => chunk.includes('█')); + expect(frames).toHaveLength(WEAVER_FRAMES.length); + }); + + it('rewinds by exactly one frame height between frames', async () => { + const { chunks, write } = recorder(); + await animateWeaver(write, { animate: true, color: false, sleep: async () => {} }); + + const rewinds = chunks.filter((chunk) => chunk === `\u001b[${MASCOT_FRAME_HEIGHT}A\u001b[J`); + expect(rewinds).toHaveLength(WEAVER_FRAMES.length - 1); + }); + + it('restores the cursor even when a write throws', async () => { + const chunks: string[] = []; + let failed = false; + const write = (chunk: string): void => { + chunks.push(chunk); + // Fail once, on the first frame, after the cursor has been hidden. + if (!failed && chunk.includes('█')) { + failed = true; + throw new Error('stream closed'); + } + }; + + await expect(animateWeaver(write, { animate: true, color: false })).rejects.toThrow('stream closed'); + expect(chunks[chunks.length - 1]).toBe('\u001b[?25h'); + }); +}); + +describe('postinstall copy', () => { + // scripts/postinstall.js must run with no build step, so it cannot import + // src/mascot.ts and keeps its own copy of the art. This is the guard against + // the two drifting apart. + const source = readFileSync(join(import.meta.dirname, '..', 'scripts', 'postinstall.js'), 'utf8'); + + it('inlines every sprite row verbatim', () => { + const rows = new Set(WEAVER_FRAMES.flat().filter((row) => row.trim() !== '')); + expect(rows.size).toBeGreaterThan(0); + for (const row of rows) expect(source).toContain(row); + }); + + it('inlines the same frame height', () => { + expect(source).toContain(`const MASCOT_FRAME_HEIGHT = ${MASCOT_FRAME_HEIGHT};`); + }); + + it('inlines the same palette', () => { + // Match the SGR bodies, not the escape prefix: this file spells the escape + // as a real control character while postinstall.js spells it in source form. + expect(source).toContain('[38;2;86;197;255m'); + expect(source).toContain('[38;2;0;107;154m'); + }); +}); diff --git a/src/mascot.ts b/src/mascot.ts new file mode 100644 index 000000000..1981f0b4d --- /dev/null +++ b/src/mascot.ts @@ -0,0 +1,208 @@ +/** + * Weaver — the webcmd mascot. + * + * A chunky block spider. webcmd crawls a site once and weaves what it learns + * into local memory, so the character is a picture of the product's own pitch: + * explore once, execute forever. + * + * The art is drawn with filled block glyphs rather than box-drawing outlines so + * that the silhouette survives with color stripped — piped output, NO_COLOR, + * and CI logs all still show a character. Color is layered on top and is always + * *purely additive*: stripping ANSI from a colored render yields the plain + * render byte for byte. `command-presentation.test.ts` asserts that property for + * the whole help screen, and the mascot is part of the help banner. + * + * Glyphs carry palette roles, which is what keeps color additive: + * █ body accent ▓ leg shade + * ● eye white ─ blink white + * ▾ mouth accent │ thread accent + */ + +import { ACCENT_RGB, SHADE_RGB } from './brand.js'; + +/** Display width of every mascot row. */ +export const MASCOT_WIDTH = 18; +/** Row count of the resting sprite. */ +export const MASCOT_HEIGHT = 6; +/** Row count of every animation frame (sprite plus headroom for the thread). */ +export const MASCOT_FRAME_HEIGHT = 9; + +const BLANK = ' '.repeat(MASCOT_WIDTH); +const THREAD = ' ││ '; + +/** Top leg pair, alternated to make the idle loop breathe. */ +const LEGS_TOP = [ + ' ▓▓ ▓▓ ', + ' ▓▓ ▓▓ ', +] as const; + +/** Bottom leg row, alternated in step with {@link LEGS_TOP}. */ +const LEGS_BOTTOM = [ + ' ▓▓ ▓▓ ▓▓ ▓▓ ', + ' ▓▓ ▓▓ ▓▓ ▓▓ ', +] as const; + +const HEAD = ' ██████████ '; +const EYES_OPEN = ' ▓▓██ ● ● ██▓▓ '; +const EYES_SHUT = ' ▓▓██ ─ ─ ██▓▓ '; +const MOUTH = ' ▓▓██ ▾ ██▓▓ '; +const BELLY = ' ██████████ '; + +export interface SpriteOptions { + /** Eyes closed. */ + blink?: boolean; + /** Which of the two leg positions to stand in. */ + stance?: 0 | 1; +} + +/** The six-row sprite in a given pose. */ +export function weaverSprite({ blink = false, stance = 0 }: SpriteOptions = {}): string[] { + return [ + LEGS_TOP[stance], + HEAD, + blink ? EYES_SHUT : EYES_OPEN, + MOUTH, + BELLY, + LEGS_BOTTOM[stance], + ]; +} + +/** Weaver at rest — the pose used wherever a single static mascot is shown. */ +export const WEAVER: readonly string[] = Object.freeze(weaverSprite()); + +/** + * Pad a sprite into a full-height frame, hanging from `dropped` rows of thread. + * Every frame is {@link MASCOT_FRAME_HEIGHT} rows so an in-place redraw can move + * the cursor up by a constant. + */ +function frame(sprite: readonly string[], dropped: number): string[] { + const rows = [ + ...Array.from({ length: dropped }, () => THREAD), + ...sprite, + ]; + while (rows.length < MASCOT_FRAME_HEIGHT) rows.push(BLANK); + return rows; +} + +/** + * The install/setup animation: Weaver drops in on a thread, lands with a + * bounce, then blinks and settles. + */ +export const WEAVER_FRAMES: readonly (readonly string[])[] = Object.freeze([ + frame(weaverSprite({ stance: 1 }), 1), + frame(weaverSprite({ stance: 1 }), 2), + frame(weaverSprite({ stance: 0 }), 3), + frame(weaverSprite({ stance: 1 }), 3), + frame(weaverSprite({ stance: 0 }), 3), + frame(weaverSprite({ blink: true, stance: 0 }), 3), + frame(weaverSprite({ stance: 0 }), 3), +].map((rows) => Object.freeze(rows))); + +// ── Color ────────────────────────────────────────────────────────────────── + +const RESET = '\u001b[0m'; +const ACCENT = `\u001b[38;2;${ACCENT_RGB.r};${ACCENT_RGB.g};${ACCENT_RGB.b}m`; +const SHADE = `\u001b[38;2;${SHADE_RGB.r};${SHADE_RGB.g};${SHADE_RGB.b}m`; +const WHITE = '\u001b[97m'; + +/** Glyph → color. Glyphs absent here are left uncolored. */ +const GLYPH_COLOR: Readonly> = Object.freeze({ + '█': ACCENT, + '│': ACCENT, + '▓': SHADE, + '▾': ACCENT, + '●': WHITE, + '─': WHITE, +}); + +/** + * Color one row, coalescing runs of same-colored glyphs so a sprite costs a + * handful of escape sequences rather than one per character. + */ +function paintRow(row: string): string { + let out = ''; + let run = ''; + let runColor: string | undefined; + + const flush = (): void => { + if (run === '') return; + out += runColor === undefined ? run : `${runColor}${run}${RESET}`; + run = ''; + }; + + for (const glyph of row) { + const color = GLYPH_COLOR[glyph]; + if (color !== runColor) { + flush(); + runColor = color; + } + run += glyph; + } + flush(); + return out; +} + +export interface RenderOptions { + color: boolean; +} + +/** Render arbitrary mascot rows. Color is additive — stripping ANSI restores `rows`. */ +export function renderRows(rows: readonly string[], { color }: RenderOptions): string { + return (color ? rows.map(paintRow) : [...rows]).join('\n'); +} + +/** Render Weaver at rest. */ +export function renderWeaver(options: RenderOptions): string { + return renderRows(WEAVER, options); +} + +// ── Animation ────────────────────────────────────────────────────────────── + +export interface AnimateOptions extends RenderOptions { + /** Milliseconds between frames. Ignored when `animate` is false. */ + frameMs?: number; + /** + * Play the frames. When false the final frame is written once with no + * escapes and no timers, which is what piped output and CI logs should get. + */ + animate: boolean; + /** Injectable for tests. */ + sleep?: (ms: number) => Promise; +} + +const defaultSleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Play the drop-in animation, redrawing in place through `write`. + * + * Takes a writer rather than a stream so callers with an injected I/O seam + * (`hosted/setup.ts`) can animate through it and assert on the bytes in tests. + * + * Redraw uses the same escapes as `tui.ts`: hide the cursor, then move up a + * fixed number of rows and clear forward. The row count is a constant because + * every frame is `MASCOT_FRAME_HEIGHT` tall. + */ +export async function animateWeaver( + write: (chunk: string) => void | Promise, + { frameMs = 90, animate, color, sleep = defaultSleep }: AnimateOptions, +): Promise { + const frames = WEAVER_FRAMES; + + if (!animate) { + await write(`${renderRows(frames[frames.length - 1] as readonly string[], { color })}\n`); + return; + } + + await write('\u001b[?25l'); // Hide cursor + try { + for (const [index, rows] of frames.entries()) { + if (index > 0) await write(`\u001b[${MASCOT_FRAME_HEIGHT}A\u001b[J`); + await write(`${renderRows(rows, { color })}\n`); + if (index < frames.length - 1) await sleep(frameMs); + } + } finally { + // Always restore the cursor, even if a write throws mid-animation. + await write('\u001b[?25h'); + } +}