From bfefa2a866f9568c98224b21c8fc90213f80c867 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Tue, 3 Nov 2026 12:19:33 -0500 Subject: [PATCH 01/29] Let scene cards take a form, an outline and a colour An animation's scene draws every card as the same box, so a reader cannot tell a database from the service in front of it or a third-party API from one of ours without reading each title. Cards (and a hand-made wb-node) now take `form`, `outline` and `color`: - form: box, pill, cut, cylinder, drum, stack, window or bars, drawn by the stage kit with borders and pseudo-elements so tones, highlights and the card's content keep working. - outline: solid, dashed or dotted; dashed reads as outside the system. - color: ink, accent, teal or violet at rest; a frame's tone takes over while set. teal and violet join the stage theme from the software map's storage colour and the merged purple. The kinds stay out of the API: the typings and the authoring instructions suggest which form fits a client, a service, a database, a cache, a queue or a third party, and the request-across-layers and cache-fill-evict references draw their cards that way. Agent-Session: cbacb871-2558-4fe4-9272-77524bb21683 Agent-Session: b315a4f8-1333-461c-9be6-366ceb231b7f Agent-Session: 62100749-c77c-4d19-ad48-c6d4ba1e867b --- .../review/app/src/animation/stage-runtime.ts | 32 +++++++++++ packages/review/instructions/animation.md | 6 +- .../animations/cache-fill-evict/README.md | 2 +- .../cache-fill-evict/animation.html | 19 ++++++- .../request-across-layers/README.md | 4 +- .../request-across-layers/animation.html | 23 +++++++- packages/review/instructions/whiteboard.d.ts | 31 +++++++++++ packages/review/src/animation-stage-kit.ts | 55 +++++++++++++++++++ 8 files changed, 163 insertions(+), 9 deletions(-) diff --git a/packages/review/app/src/animation/stage-runtime.ts b/packages/review/app/src/animation/stage-runtime.ts index 7e49e139b..75abfc086 100644 --- a/packages/review/app/src/animation/stage-runtime.ts +++ b/packages/review/app/src/animation/stage-runtime.ts @@ -69,6 +69,22 @@ type PaintHandler = (paint: PaintChange) => FrameResult; type Tone = "accent" | "added" | "removed" | "modified" | "muted"; +/** A card's silhouette; whiteboard.css draws each. */ +type Form = + | "box" + | "pill" + | "cut" + | "cylinder" + | "drum" + | "stack" + | "window" + | "bars"; + +type Outline = "solid" | "dashed" | "dotted"; + +/** A resting border colour; a tone takes over while one is set. */ +type Color = "ink" | "accent" | "teal" | "violet"; + /** Where a scene part sits: 1-based column and row, with optional row and * column spans. A wide peek spans columns: [1, 2, 1, 4] is row 2, four wide. */ export type Cell = [ @@ -85,6 +101,14 @@ export interface SceneCardOptions { /** The second line under the title; scene.on changes it per frame. */ note?: string; tone?: Tone; + /** What the card is, by silhouette: a cylinder for a database, a drum + * for a cache, a stack for a queue, a window for a client, bars for a + * service of this codebase. Default box. */ + form?: Form; + /** The border's line; dashed for a service outside the system. */ + outline?: Outline; + /** The border's colour at rest, for grouping cards of a kind. */ + color?: Color; /** Show from this frame on, hide from this one on, or only on these. */ enter?: string; exit?: string; @@ -2689,6 +2713,14 @@ export function stageRuntime(port: MessagePort): void { el.id = options.id; if (options.code !== undefined) el.dataset.wbOpen = options.code; + + if (options.form && options.form !== "box") + el.dataset.form = options.form; + + if (options.outline && options.outline !== "solid") + el.dataset.outline = options.outline; + + if (options.color) el.dataset.color = options.color; place(el, options.at); applyLifecycle(el, options); diff --git a/packages/review/instructions/animation.md b/packages/review/instructions/animation.md index b793ad910..d4b8baacf 100644 --- a/packages/review/instructions/animation.md +++ b/packages/review/instructions/animation.md @@ -28,8 +28,8 @@ For a few services, processes or stores with messages moving between them, use ` ```html ', + }, + ], + }); + + const iframe = (await settled(() => container.querySelector("iframe")))!; + const stage = iframe.parentElement!; + + // Six 50px rows, though the reader is on the one-row frame. + expect(await settled(() => stage.offsetHeight >= 300)).toBe(true); + const tallest = stage.offsetHeight; + await act(async () => container .querySelector("button[aria-label='Next frame']")! .click(), ); - expect( - await settled( - () => stage.offsetHeight === 148 && stage.offsetHeight < expanded, - ), - ).toBe(true); - await act(async () => - container - .querySelector( - "button[aria-label='Previous frame']", - )! - .click(), + expect(await settled(() => text(container).includes("Tall"))).toBe(true); + expect(await settled(() => stage.offsetHeight !== tallest, 500)).toBe( + false, ); - expect(await settled(() => stage.offsetHeight === expanded)).toBe(true); expect(container.querySelector("[role='alert']")).toBeNull(); }); @@ -1497,7 +1524,10 @@ describe("block components", () => { .click(), ); expect(await settled(() => stages.count("ready") === boots)).toBe(true); - expect(document.querySelectorAll("iframe")).toHaveLength(1); + // The hidden copy that measures the frames goes once it has. + expect( + await settled(() => document.querySelectorAll("iframe").length === 1), + ).toBe(true); } expect(await settled(slot)).toBeTruthy(); @@ -1607,11 +1637,9 @@ describe("block components", () => { width() === "100px" || width() === "280px" ? width() : undefined, ), ).toBe("280px"); - expect( - await settled( - () => iframe.getBoundingClientRect().height < expandedHeight - 30, - ), - ).toBe(true); + // The stage keeps the wide frame's height; the script checked above that + // the future content takes no room. + expect(iframe.getBoundingClientRect().height).toBe(expandedHeight); expect(container.querySelector("[role='alert']")).toBeNull(); }); From d065b9b3c7f95d944519424dc72164e63ebbfb65 Mon Sep 17 00:00:00 2001 From: Milan Bhandari Date: Tue, 3 Nov 2026 13:55:57 -0500 Subject: [PATCH 04/29] Scale an expanded animation up to the room it is given Expand put the animation in the fullscreen overlay and let its stage take the overlay's width, so a scene spread out while its cards, text and arrows stayed their inline size. Now the stage keeps the width it had inline, so the author's layout and the measured height hold, and the block scales it up to fit the room the controls leave, at most 2x, centred. A room smaller than the inline stage scrolls as before. Code peeks drawn over the stage scale with it; their slot heights are read from layout, unscaled, so the stage sizes them as it did. Agent-Session: cbacb871-2558-4fe4-9272-77524bb21683 Agent-Session: 62100749-c77c-4d19-ad48-c6d4ba1e867b Agent-Session: 01a1a2b2-83cc-7700-a32e-af3103dae5ea Agent-Session: ef04727a-5f7d-4c56-a793-c4f98a02f605 Agent-Session: 534581db-1710-40c6-b043-ce052bd9e80d --- .../app/src/animation/animation-block.tsx | 235 ++++++++++++------ .../review/app/src/blocks.browser.test.tsx | 51 ++++ 2 files changed, 214 insertions(+), 72 deletions(-) diff --git a/packages/review/app/src/animation/animation-block.tsx b/packages/review/app/src/animation/animation-block.tsx index 8577cc896..f38af9a9b 100644 --- a/packages/review/app/src/animation/animation-block.tsx +++ b/packages/review/app/src/animation/animation-block.tsx @@ -86,6 +86,10 @@ const STAGE_MIN_HEIGHT = 120; // its current frame's height instead. const MEASURE_WAIT = 1000; +// Fullscreen, the stage keeps its inline layout and grows to fit the room, +// at most this much: past 2x a drawing made for a column reads as a poster. +const STAGE_MAX_SCALE = 2; + let animeSource: Promise | undefined; // anime.js's source, for the stage: its own chunk, fetched by the first @@ -279,6 +283,23 @@ function StageBlock({ // figure's height so the document keeps its place. const [expanded, setExpanded] = useState(false); const [held, setHeld] = useState(0); + + // The stage's inline width, which it keeps fullscreen, scaled to the room. + const [inlineWidth, setInlineWidth] = useState(0); + + const [room, setRoom] = useState<{ width: number; height: number } | null>( + null, + ); + + const roomRef = useRef(null); + + // What the figure leaves behind when it moves to the overlay: a placeholder + // of its height, and the width its stage was laid out at. + const holdInline = () => { + setHeld(figureRef.current?.offsetHeight ?? 0); + setInlineWidth(canvasRef.current?.clientWidth ?? 0); + }; + const openRef = useRef(null); const closeRef = useRef(null); @@ -356,7 +377,7 @@ function StageBlock({ if (!tourOpen) { captureUiEvent(session, "tour_started", { steps: tour.stops.length }); - if (!expanded) setHeld(figureRef.current?.offsetHeight ?? 0); + if (!expanded) holdInline(); } setExpanded(false); @@ -594,6 +615,38 @@ function StageBlock({ const fullscreen = (tourOpen || expanded) && portalTarget !== null; + // Fullscreen, the room the controls leave the stage, inside its padding. + useLayoutEffect(() => { + const element = roomRef.current; + + if (!fullscreen || !element) { + setRoom(null); + + return; + } + + const read = () => { + const style = getComputedStyle(element); + + setRoom({ + width: + element.clientWidth - + Number.parseFloat(style.paddingLeft) - + Number.parseFloat(style.paddingRight), + height: + element.clientHeight - + Number.parseFloat(style.paddingTop) - + Number.parseFloat(style.paddingBottom), + }); + }; + + read(); + const observer = new ResizeObserver(read); + observer.observe(element); + + return () => observer.disconnect(); + }, [fullscreen]); + // Going fullscreen draws a new canvas; a resize changes its width. useLayoutEffect(() => { const canvas = canvasRef.current; @@ -707,6 +760,26 @@ function StageBlock({ followFrame(); }, [frameAnchor, tour]); + const stageHeight = + node.height !== "auto" + ? node.height + : measuredHeight || measureLate + ? Math.max(contentHeight, measuredHeight) + : STAGE_MIN_HEIGHT; + + // Fullscreen, the stage keeps its inline width and scales up to fit the + // room, never down: a room too small for it scrolls, as before. + const scale = + fullscreen && room && inlineWidth + ? Math.min( + STAGE_MAX_SCALE, + Math.max( + 1, + Math.min(room.width / inlineWidth, room.height / stageHeight), + ), + ) + : 1; + const figure = (
{ - setHeld(figureRef.current?.offsetHeight ?? 0); + holdInline(); setExpanded(true); }} > @@ -777,80 +850,89 @@ function StageBlock({ )}
-