From a3ff1914f5c33d1391ae3676f7f9d12730529c9c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 29 Aug 2026 22:40:16 -0700 Subject: [PATCH] Hold the first-run mountain filled until continue The looping mark timeline fades after 90% of a period and redraws. First-run welcome should play once and hold the filled silhouette until continue, and auto-advance must not land mid fade or on a second draw-in. --- src/tui/welcome.test.ts | 122 +++++++++++++++++++++++++++++++++++++++- src/tui/welcome.ts | 38 +++++++++---- 2 files changed, 148 insertions(+), 12 deletions(-) diff --git a/src/tui/welcome.test.ts b/src/tui/welcome.test.ts index a133f99a..5ffc7f56 100644 --- a/src/tui/welcome.test.ts +++ b/src/tui/welcome.test.ts @@ -1,9 +1,18 @@ import { describe, expect, test } from "bun:test"; import { PRODUCT_NAME } from "../branding.js"; +import { MARK_PERIOD_SECONDS, markFrame, markText, renderMark } from "./mark-anim.js"; import { MARK_LARGE, MARK_MID, MARK_SMALL } from "./mark-shape.js"; import { createHarness } from "./harness.js"; -import { resolveWelcomeMarkGrid, runWelcome, WELCOME_LINE } from "./welcome.js"; +import { stringWidth } from "./view/height.js"; +import { + resolveWelcomeLine, + resolveWelcomeMarkGrid, + runWelcome, + WELCOME_AUTO_ADVANCE_MS, + WELCOME_LINE, + welcomeMarkStill, +} from "./welcome.js"; describe("WELCOME_LINE", () => { test("names the product as the local software factory", () => { @@ -74,3 +83,114 @@ describe("runWelcome", () => { } }); }); + +describe("welcomeMarkStill", () => { + test("animates through fill, then freezes the full frame", () => { + const fillMs = 0.76 * MARK_PERIOD_SECONDS * 1000; + expect(welcomeMarkStill(fillMs - 1)).toBe(false); + expect(welcomeMarkStill(fillMs)).toBe(true); + expect(welcomeMarkStill(0.95 * MARK_PERIOD_SECONDS * 1000)).toBe(true); + expect(welcomeMarkStill(MARK_PERIOD_SECONDS * 1000 + 900)).toBe(true); + }); +}); + +describe("WELCOME_AUTO_ADVANCE_MS", () => { + test("lands on the held filled frame, not fade-out or a second draw-in", () => { + const seconds = WELCOME_AUTO_ADVANCE_MS / 1000; + expect(seconds).toBeGreaterThanOrEqual(0.76 * MARK_PERIOD_SECONDS); + expect(seconds).toBeLessThan(MARK_PERIOD_SECONDS); + + const still = welcomeMarkStill(WELCOME_AUTO_ADVANCE_MS); + const frame = markFrame(seconds, still); + expect(still).toBe(true); + expect(frame).toEqual({ drawProg: 1, fillProg: 1, alpha: 1 }); + + // Looping math at this delay must also still be the full hold — never the + // fade (90–100%) or the wrapped second draw-in. + const looping = markFrame(seconds, false); + expect(looping.alpha).toBe(1); + expect(looping.fillProg).toBe(1); + expect(looping.drawProg).toBe(1); + expect(seconds).toBeLessThan(0.9 * MARK_PERIOD_SECONDS + 1e-9); + }); +}); + +describe("resolveWelcomeLine", () => { + test("keeps the full factory sentence or hides it, never a mid-word slice", () => { + expect(resolveWelcomeLine(80)).toBe(WELCOME_LINE); + expect(resolveWelcomeLine(stringWidth(WELCOME_LINE))).toBe(WELCOME_LINE); + + const truncated = WELCOME_LINE.slice(0, 39); + expect(truncated).toContain("facto"); + expect(truncated).not.toBe(WELCOME_LINE); + + const narrow = resolveWelcomeLine(40); + expect(narrow === "" || narrow === WELCOME_LINE).toBe(true); + expect(narrow).not.toBe(truncated); + expect(narrow.includes("facto") && !narrow.includes("factory")).toBe(false); + }); +}); + +describe("runWelcome hold and cancel", () => { + test("paints a still full mark after fill instead of fading", async () => { + const fadeMs = 0.95 * MARK_PERIOD_SECONDS * 1000; + // First `now()` is mount (`startedAt`); later samples are elapsed fadeMs. + let samples = 0; + const harness = await createHarness({ width: 80, height: 30 }); + const done = runWelcome({ + createRenderer: async () => harness.renderer, + autoAdvanceMs: 60_000, + now: () => (samples++ === 0 ? 0 : fadeMs), + }); + try { + await harness.renderOnce(); + await harness.renderOnce(); + const frame = harness.captureCharFrame(); + const held = markText(renderMark({ nowMs: fadeMs, still: true, grid: MARK_LARGE })); + const fading = markText(renderMark({ nowMs: fadeMs, still: false, grid: MARK_LARGE })); + const mountain = (text: string) => (text.match(/[▁▂▃▄▅▆▇█]/g) ?? []).length; + expect(mountain(frame)).toBe(mountain(held)); + expect(mountain(held)).toBeGreaterThan(mountain(fading)); + } finally { + harness.pressKey("Ctrl+C"); + await Promise.race([done, new Promise((r) => setTimeout(r, 50))]); + harness.destroy(); + } + }); + + test("cancels on Ctrl+D without continuing", async () => { + const harness = await createHarness({ width: 80, height: 30 }); + const done = runWelcome({ + createRenderer: async () => harness.renderer, + autoAdvanceMs: 60_000, + now: () => 2_000, + }); + try { + await harness.renderOnce(); + harness.pressKey("d", { ctrl: true }); + await expect(done).resolves.toBe(false); + } finally { + harness.destroy(); + } + }); + + test("narrow terminals do not paint a sliced factory sentence", async () => { + const harness = await createHarness({ width: 42, height: 30 }); + const done = runWelcome({ + createRenderer: async () => harness.renderer, + autoAdvanceMs: 60_000, + now: () => 2_000, + }); + try { + await harness.renderOnce(); + await harness.renderOnce(); + const frame = harness.captureCharFrame(); + expect(frame).not.toContain("software facto"); + expect(frame.includes("facto") && !frame.includes("factory")).toBe(false); + } finally { + harness.pressKey("Ctrl+C"); + await Promise.race([done, new Promise((r) => setTimeout(r, 50))]); + harness.destroy(); + } + }); +}); diff --git a/src/tui/welcome.ts b/src/tui/welcome.ts index 54611471..6fee7aa0 100644 --- a/src/tui/welcome.ts +++ b/src/tui/welcome.ts @@ -37,8 +37,27 @@ const MARK_TIERS: readonly MarkGrid[] = [MARK_LARGE, MARK_MID, MARK_SMALL]; /** Rows reserved under the mark for the product line, hint, and breathing room. */ const BELOW_MARK_ROWS = 5; -/** Hold after one full mark period before auto-advancing. */ -const HOLD_AFTER_PERIOD_MS = 900; +/** Fill completes at this fraction of one mark period (full-frame hold starts). */ +const MARK_FILL_END = 0.76; + +/** Last instant of the full-frame hold; fade starts after this. */ +const MARK_HOLD_END = 0.9; + +/** + * Auto-advance at the end of the full-frame hold so setup never opens on a + * fade or a second draw-in. + */ +export const WELCOME_AUTO_ADVANCE_MS = Math.round(MARK_HOLD_END * MARK_PERIOD_SECONDS * 1000); + +/** Freeze the mountain on its filled frame once fill completes. */ +export function welcomeMarkStill(elapsedMs: number): boolean { + return elapsedMs / 1000 >= MARK_FILL_END * MARK_PERIOD_SECONDS; +} + +/** Full product line when it fits; otherwise hide it rather than slicing words. */ +export function resolveWelcomeLine(columns: number): string { + return stringWidth(WELCOME_LINE) > columns ? "" : WELCOME_LINE; +} /** Paint cadence while the mountain draws. */ const PAINT_TICK_MS = 80; @@ -47,8 +66,8 @@ export interface WelcomeConfig { /** Renderer factory override for headless mounting in tests. */ readonly createRenderer?: () => Promise; /** - * Auto-advance delay after mount. Defaults to one mark period plus a short - * hold so the silhouette finishes drawing before setup opens. + * Auto-advance delay after mount. Defaults to the end of the full-frame + * hold (`WELCOME_AUTO_ADVANCE_MS`) so setup opens on a filled silhouette. */ readonly autoAdvanceMs?: number; /** Injected clock for mark animation (and tests). */ @@ -87,8 +106,7 @@ export async function runWelcome(config: WelcomeConfig = {}): Promise { const now = config.now ?? Date.now; const startedAt = now(); - const autoAdvanceMs = - config.autoAdvanceMs ?? Math.round(MARK_PERIOD_SECONDS * 1000) + HOLD_AFTER_PERIOD_MS; + const autoAdvanceMs = config.autoAdvanceMs ?? WELCOME_AUTO_ADVANCE_MS; const margin = resolveSideMargin(renderer.width || 80); @@ -192,16 +210,14 @@ export async function runWelcome(config: WelcomeConfig = {}): Promise { markRows.forEach((row, index) => { row.visible = grid !== null && index >= offset; }); - line.content = - stringWidth(WELCOME_LINE) > columns - ? WELCOME_LINE.slice(0, Math.max(0, columns - 1)) - : WELCOME_LINE; + line.content = resolveWelcomeLine(columns); }; const paint = (): void => { if (settled || grid === null) return; try { - const chunks = markChunks(grid, now() - startedAt, false); + const elapsed = now() - startedAt; + const chunks = markChunks(grid, elapsed, welcomeMarkStill(elapsed)); const offset = MARK_LARGE.rows - grid.rows; markRows.forEach((row, index) => { if (!row.visible) return;