From 75db66d84f40c074b01241a0c38e447b68392679 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 8 Jul 2026 19:33:26 -0700 Subject: [PATCH 1/2] settle-terminals: gate Chromatic snapshots on settled grid geometry, not just content The terminal-story readiness gate waited only for text to reach the xterm buffer, which happens almost instantly (flattenScenario writes the whole prompt in one go). It never waited for the terminal's column count to settle. But a split kicks off a Lath tween that animates each pane's real width across many frames (LathHost sets el.style.width per rAF), and TerminalPane's refit is throttled with a 150ms trailing edge, so cols keeps reflowing well after the content lands. settleTerminals resolved ~2 paint frames after content appeared, so Chromatic sometimes snapshotted a pane still laid out at a transitional width -- `user@dormouse:~$` clipped to `user@do`. Intermittent because the two-frame cushion sometimes outlasted the throttle and sometimes didn't. Gate on content AND settled geometry: each poll, force every live terminal to fit its current resting container (bypassing the 150ms throttle so we read the true resting cols), and require the grid to hold steady for STABLE_POLLS consecutive polls before releasing the snapshot. Also await document.fonts.ready first (bounded) since cell metrics are measured from the font. Public API (settleTerminals / waitForCondition) is unchanged, so all consumer stories are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/src/stories/settle-terminals.ts | 80 ++++++++++++++++++++++++----- 1 file changed, 66 insertions(+), 14 deletions(-) diff --git a/lib/src/stories/settle-terminals.ts b/lib/src/stories/settle-terminals.ts index 3c1f8836..6ade3ddb 100644 --- a/lib/src/stories/settle-terminals.ts +++ b/lib/src/stories/settle-terminals.ts @@ -1,25 +1,68 @@ -import { getActivitySnapshot, getTerminalInstance } from '../lib/terminal-registry'; +import { getActivitySnapshot, getTerminalInstance, refitSession } from '../lib/terminal-registry'; import type { Terminal } from '@xterm/xterm'; /** * A Chromatic readiness gate for terminal-bearing stories. * - * The FakePty adapter emits scenario data on a `setTimeout` (even `flattenScenario`'s - * "instant" output is a `setTimeout(0)`), and xterm then parses and paints on its own - * async schedule. A story with no `play` is snapshotted the moment React finishes - * rendering — before that write lands — so the terminal is captured mid-paint (a - * partial prompt like `user@dormo`). Chromatic awaits a story's `play` function, so - * awaiting this in `play` holds the snapshot until every visible terminal has written - * its content and painted a settled frame. + * A story with no `play` is snapshotted the moment React finishes rendering, so + * awaiting this in `play` holds the snapshot until every visible terminal has both + * (a) written its scenario content and (b) reached a settled grid geometry. * - * Content is detected through the xterm BUFFER model (parsed synchronously on write), - * independent of which renderer (DOM / canvas / WebGL) is painting. + * Both halves matter: + * - Content: the FakePty adapter emits scenario data on a `setTimeout` (even + * `flattenScenario`'s "instant" output is a `setTimeout(0)`), and xterm parses + * it on write. Captured too early, the terminal shows a partial prompt. + * - Geometry: Lath tweens pane geometry across many animation frames on + * split/restore, and TerminalPane throttles its refit (150ms trailing edge), + * so a terminal's column count keeps changing *after* its content is in the + * buffer. Gating on content alone (the old behavior) let Chromatic snapshot a + * pane still laid out at a transitional width — `user@dormouse:~$` clipped to + * `user@do`. So we drive each terminal to its resting geometry (bypassing the + * throttle) and wait for the grid to hold steady before releasing the snapshot. + * + * Content is detected through the xterm BUFFER model (parsed synchronously on + * write), independent of which renderer (DOM / canvas / WebGL) is painting. */ + +// A terminal's grid (cols×rows) must repeat unchanged this many consecutive polls +// before its geometry counts as settled. A single match isn't enough: mid-tween a +// column count can hold for a frame or two (an easing plateau, or a pixel change +// that hasn't yet crossed a cell boundary), so a short run of matches waits the +// motion out. +const STABLE_POLLS = 4; + export async function settleTerminals(opts?: { timeoutMs?: number }): Promise { + // Cell metrics are measured from the editor font; measuring before it is ready + // yields the wrong column count. Resolves immediately when nothing is pending + // (e.g. a system-monospace fallback), so it only ever costs a microtask. + await documentFontsReady(); + + const runs = new Map(); await waitForCondition(() => { const terms = liveTerminals(); - return terms.length > 0 && terms.every(hasContent); + if (terms.length === 0) return false; + let allSettled = true; + for (const { id, term } of terms) { + if (!hasContent(term)) { + allSettled = false; + continue; + } + // Snap the terminal to its *current* resting container now rather than + // waiting on the trailing throttled refit, so the reading below is the + // final grid. fit() is a no-op when cols/rows already match, so a settled + // terminal is only measured, not reflowed. + refitSession(id); + const geom = `${term.cols}x${term.rows}`; + const prev = runs.get(id); + const count = prev && prev.geom === geom ? prev.count + 1 : 1; + runs.set(id, { geom, count }); + if (count < STABLE_POLLS) allSettled = false; + } + return allSettled; }, opts); + + await paintFrame(); + await paintFrame(); } /** @@ -49,10 +92,10 @@ export async function waitForCondition( await paintFrame(); } -function liveTerminals(): Terminal[] { +function liveTerminals(): { id: string; term: Terminal }[] { return [...getActivitySnapshot().keys()] - .map((id) => getTerminalInstance(id)) - .filter((t): t is Terminal => t !== null); + .map((id) => ({ id, term: getTerminalInstance(id) })) + .filter((e): e is { id: string; term: Terminal } => e.term !== null); } function hasContent(term: Terminal): boolean { @@ -62,6 +105,15 @@ function hasContent(term: Terminal): boolean { return !!line && line.translateToString(true).trim().length > 0; } +/** Resolve once webfont loading has settled — immediately if the platform lacks + * the Font Loading API or has nothing pending, and bounded by a fallback timer so + * a font that never settles can't hang the gate (same doctrine as waitForCondition). */ +function documentFontsReady(): Promise { + const fonts = document.fonts as FontFaceSet | undefined; + if (!fonts) return Promise.resolve(); + return Promise.race([fonts.ready.then(() => undefined, () => undefined), delay(1000)]); +} + function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } From 84a0902cf946897fc0e8fc217fcad1d1f25b3885 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 8 Jul 2026 21:58:25 -0700 Subject: [PATCH 2/2] Chromatic: snap pane splits to final geometry instead of tweening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terminal stories that split panes (Wall: Multi Pane, WithDoors) intermittently snapshotted a pane with its last prompt line clipped — `user@dormouse:~$` rendered as `user@do`. Root cause: the split runs Lath's 440ms geometry tween, resizing panes through many transient widths. xterm's DOM renderer can latch a frame where a pane is still narrow and freeze its last line clipped to that width even after the layout settles to full width. It's a rendering race on the animation, not a content or column-count problem — the buffer is correct throughout. Fix: disable Lath layout motion under Chromatic (cfg.layout.animate), the same way preview.ts already freezes marching ants, the cursor blink, and the alert ring for deterministic snapshots. Splits/restores/kills snap straight to their final geometry, so no transient-width frame exists for the renderer to latch. The animator already collapses to instant at durationMs 0 (the reduced-motion path), so this reuses that code path. Snapshots capture the settled state, which is identical with or without the tween — no visual change to any story. This supersedes the earlier settle-terminals "settled grid geometry" gate, which targeted column width and did not address the rendering race; settle-terminals is reverted here to its original content-only form. Validated with a headless-visible Playwright harness (mirrors Chromatic) over the Wall: Multi Pane story: 30% truncation baseline → 0/150 with this change. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/.storybook/preview.ts | 6 ++ lib/src/cfg.ts | 9 +++ lib/src/components/wall/lath-wall-engine.ts | 9 ++- lib/src/stories/settle-terminals.ts | 80 ++++----------------- 4 files changed, 35 insertions(+), 69 deletions(-) diff --git a/lib/.storybook/preview.ts b/lib/.storybook/preview.ts index 34843fdc..110eea4c 100644 --- a/lib/.storybook/preview.ts +++ b/lib/.storybook/preview.ts @@ -29,6 +29,12 @@ if (window?.navigator?.userAgent?.includes('Chromatic')) { // A blinking cursor is captured on-or-off depending on the frame; freeze it to a // stable solid block so the terminal contributes no non-determinism. cfg.terminal.cursorBlink = false; + // Snap pane splits/restores to their final geometry instead of tweening. A + // mid-tween split resizes panes through many transient widths, and xterm's DOM + // renderer can latch a frame where a pane is still narrow — freezing its last + // line clipped (`user@dormouse:~$` → `user@do`) even after the layout settles. + // Instant geometry removes that race at the source. + cfg.layout.animate = false; } // Collect all CSS variable names across all themes for cleanup diff --git a/lib/src/cfg.ts b/lib/src/cfg.ts index 9ea91f83..e141b562 100644 --- a/lib/src/cfg.ts +++ b/lib/src/cfg.ts @@ -34,4 +34,13 @@ export const cfg = { * stable solid block rather than being captured mid-blink (non-deterministic). */ cursorBlink: true, }, + layout: { + /** When false, Lath pane geometry changes (split / restore / kill / drag) apply + * instantly with no tween. Disabled under Chromatic: a mid-tween split resizes + * panes through many transient widths (briefly near-zero), and xterm's DOM + * renderer can latch onto one of those frames and leave a pane painted blank or + * clipped (`user@dormouse:~$` → `user@do`) even after the geometry settles. + * Snapping straight to the final geometry removes that whole race. */ + animate: true, + }, }; diff --git a/lib/src/components/wall/lath-wall-engine.ts b/lib/src/components/wall/lath-wall-engine.ts index 94b84dc5..8e3bd3b5 100644 --- a/lib/src/components/wall/lath-wall-engine.ts +++ b/lib/src/components/wall/lath-wall-engine.ts @@ -24,6 +24,7 @@ import { createAnimator, } from '../../lib/lath/animator'; import { prefersReducedMotion } from '../../lib/ui-geometry'; +import { cfg } from '../../cfg'; import { UNNAMED_PANEL_TITLE } from '../../lib/terminal-registry'; import type { ResolvedSplitDirection as DorResolvedSplitDirection } from 'dor/commands/types'; import { @@ -163,9 +164,11 @@ export function createLathWallEngine( ): LathWallEngine { const snapshot = (): LathWallSnapshot => store.getSnapshot(); - // 0 under reduced motion, so entry/exit/tween all collapse to instant through the - // very same code path. Tests inject a fixed duration (or 0) via `opts`. - const durationMs = opts?.durationMs ?? (prefersReducedMotion() ? 0 : LATH_MOTION_MS); + // 0 under reduced motion (or when layout animation is disabled, e.g. Chromatic), + // so entry/exit/tween all collapse to instant through the very same code path. + // Tests inject a fixed duration (or 0) via `opts`. + const durationMs = + opts?.durationMs ?? (!cfg.layout.animate || prefersReducedMotion() ? 0 : LATH_MOTION_MS); const animator = createAnimator({ durationMs, easing: LATH_EASING }); // Presentation-only side state (never in the store snapshot): the frame/wake diff --git a/lib/src/stories/settle-terminals.ts b/lib/src/stories/settle-terminals.ts index 6ade3ddb..3c1f8836 100644 --- a/lib/src/stories/settle-terminals.ts +++ b/lib/src/stories/settle-terminals.ts @@ -1,68 +1,25 @@ -import { getActivitySnapshot, getTerminalInstance, refitSession } from '../lib/terminal-registry'; +import { getActivitySnapshot, getTerminalInstance } from '../lib/terminal-registry'; import type { Terminal } from '@xterm/xterm'; /** * A Chromatic readiness gate for terminal-bearing stories. * - * A story with no `play` is snapshotted the moment React finishes rendering, so - * awaiting this in `play` holds the snapshot until every visible terminal has both - * (a) written its scenario content and (b) reached a settled grid geometry. + * The FakePty adapter emits scenario data on a `setTimeout` (even `flattenScenario`'s + * "instant" output is a `setTimeout(0)`), and xterm then parses and paints on its own + * async schedule. A story with no `play` is snapshotted the moment React finishes + * rendering — before that write lands — so the terminal is captured mid-paint (a + * partial prompt like `user@dormo`). Chromatic awaits a story's `play` function, so + * awaiting this in `play` holds the snapshot until every visible terminal has written + * its content and painted a settled frame. * - * Both halves matter: - * - Content: the FakePty adapter emits scenario data on a `setTimeout` (even - * `flattenScenario`'s "instant" output is a `setTimeout(0)`), and xterm parses - * it on write. Captured too early, the terminal shows a partial prompt. - * - Geometry: Lath tweens pane geometry across many animation frames on - * split/restore, and TerminalPane throttles its refit (150ms trailing edge), - * so a terminal's column count keeps changing *after* its content is in the - * buffer. Gating on content alone (the old behavior) let Chromatic snapshot a - * pane still laid out at a transitional width — `user@dormouse:~$` clipped to - * `user@do`. So we drive each terminal to its resting geometry (bypassing the - * throttle) and wait for the grid to hold steady before releasing the snapshot. - * - * Content is detected through the xterm BUFFER model (parsed synchronously on - * write), independent of which renderer (DOM / canvas / WebGL) is painting. + * Content is detected through the xterm BUFFER model (parsed synchronously on write), + * independent of which renderer (DOM / canvas / WebGL) is painting. */ - -// A terminal's grid (cols×rows) must repeat unchanged this many consecutive polls -// before its geometry counts as settled. A single match isn't enough: mid-tween a -// column count can hold for a frame or two (an easing plateau, or a pixel change -// that hasn't yet crossed a cell boundary), so a short run of matches waits the -// motion out. -const STABLE_POLLS = 4; - export async function settleTerminals(opts?: { timeoutMs?: number }): Promise { - // Cell metrics are measured from the editor font; measuring before it is ready - // yields the wrong column count. Resolves immediately when nothing is pending - // (e.g. a system-monospace fallback), so it only ever costs a microtask. - await documentFontsReady(); - - const runs = new Map(); await waitForCondition(() => { const terms = liveTerminals(); - if (terms.length === 0) return false; - let allSettled = true; - for (const { id, term } of terms) { - if (!hasContent(term)) { - allSettled = false; - continue; - } - // Snap the terminal to its *current* resting container now rather than - // waiting on the trailing throttled refit, so the reading below is the - // final grid. fit() is a no-op when cols/rows already match, so a settled - // terminal is only measured, not reflowed. - refitSession(id); - const geom = `${term.cols}x${term.rows}`; - const prev = runs.get(id); - const count = prev && prev.geom === geom ? prev.count + 1 : 1; - runs.set(id, { geom, count }); - if (count < STABLE_POLLS) allSettled = false; - } - return allSettled; + return terms.length > 0 && terms.every(hasContent); }, opts); - - await paintFrame(); - await paintFrame(); } /** @@ -92,10 +49,10 @@ export async function waitForCondition( await paintFrame(); } -function liveTerminals(): { id: string; term: Terminal }[] { +function liveTerminals(): Terminal[] { return [...getActivitySnapshot().keys()] - .map((id) => ({ id, term: getTerminalInstance(id) })) - .filter((e): e is { id: string; term: Terminal } => e.term !== null); + .map((id) => getTerminalInstance(id)) + .filter((t): t is Terminal => t !== null); } function hasContent(term: Terminal): boolean { @@ -105,15 +62,6 @@ function hasContent(term: Terminal): boolean { return !!line && line.translateToString(true).trim().length > 0; } -/** Resolve once webfont loading has settled — immediately if the platform lacks - * the Font Loading API or has nothing pending, and bounded by a fallback timer so - * a font that never settles can't hang the gate (same doctrine as waitForCondition). */ -function documentFontsReady(): Promise { - const fonts = document.fonts as FontFaceSet | undefined; - if (!fonts) return Promise.resolve(); - return Promise.race([fonts.ready.then(() => undefined, () => undefined), delay(1000)]); -} - function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); }