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 @@
+
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');
+ }
+}