Skip to content

Let scene cards take a form, an outline and a colour - #1498

Merged
milanb17 merged 30 commits into
mb/animations-component-redesign-e33a0dfrom
mb/scene-card-forms
Nov 4, 2026
Merged

milanb17 merged 30 commits into
mb/animations-component-redesign-e33a0dfrom
mb/scene-card-forms

Conversation

@milanb17

@milanb17 milanb17 commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #1483 (the references and the scene API live there).

An animation's whiteboard.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. This gives cards three presentational options and leaves the kinds out of the API: the typings and the authoring instructions say which form fits what, and two references draw their cards that way.

request-across-layers: a window for the client, bars for the API and service, a teal cylinder for Postgres

cache-fill-evict: the cache as a teal drum holding its items, Postgres as a cylinder

The options

On SceneCardOptions (actor, store, table), and as data-form, data-outline and data-color on a hand-made wb-node:

Option Values Drawn as
form box (default), pill, cut, cylinder, drum, stack, window, bars border radii, corner-shape: bevel, a lid pseudo-element for the cylinder and the drum, a box-shadow stack, a title-bar rule, side rules
outline solid (default), dashed, dotted border style
color ink, accent, teal, violet border colour at rest (and the card's dot); a frame's tone takes over while set

teal and violet join the stage theme as --wb-teal (the software map's storage colour) and --wb-violet (the merged purple), so both follow light and dark. Highlights, tones, enter/exit/only and the card's content work unchanged on every form; the stack keeps its echoes under the highlight ring.

The option is called form rather than shape because lint reserves "shape" in identifiers.

Suggested defaults, not a schema

whiteboard.d.ts and the scene section of animation.md suggest: window for a client, bars for a service or worker of this codebase, cylinder for a database, drum for a cache, stack for a queue or topic, pill for a start or end, cut for a branch, outline: "dashed" for a third-party service, teal for stores and violet for queues. Nothing in the runtime knows those words.

The request-across-layers and cache-fill-evict references use them; their READMEs say so.

Verification

  • Every animation browser test, including the references harness stepping all nine references through every frame: 30 passed. The stage kit variables test passes with the two new colours.
  • pnpm lint, pnpm format and the canvas typecheck (which holds whiteboard.d.ts to the runtime's types) are clean.
  • The screenshots are frames the harness saved (VITE_ANIMATION_SHOTS=1), dark theme. Not checked: light theme and an isolated Desktop. The screenshots live on pr-assets/scene-card-forms, as pr-assets/home-pr-pills did; delete that branch with this PR.

Second commit: a frame's remaining time along its step underline

While an animation plays, the marker under the step showing is drawn left to right over the frame's duration, on a wash of the full underline, so the one mark that says "this step" also says how much of it is left. Frames without labels sweep a ring around the current dot. Designed first on the "Play timer" page of the Animations Scratchpad Paper file.

  • Only while playing: a frame reached by hand draws its underline whole, as before.
  • Pause holds the line where it is and Play resumes from there; a jump to another frame forgets it; Replay starts over.
  • Time up with the stage still settling holds the line full.
  • The strip and dots draw it with a CSS animation from inline custom properties (--frame-timer-fill, --frame-timer-ms, --frame-timer-rest), so nothing ticks in React. Reduced motion shows the whole underline at once, as today.
  • Two browser tests in blocks.browser.test.tsx: play runs the clock on the current step, pause holds it with a fraction between 0 and 1, a hand step drops it; and an unlabelled animation sweeps its current dot.

Third commit: steady height, ported from #1459

#1459 could not merge onto the animations branch mechanically, since the block it changed had split into a frame player, a clip player and a paint-in. Its changes are ported here by hand: the hidden measuring copy (measure / slotSizes on init, measured back), contentBottom and measureFrames in the runtime, the block's measuredHeight rule with its one-second fallback and remeasure triggers (stage, scenario, theme, width, slot heights), and the caption row keeping its tallest caption, now in StopCaption for both players. Clips are not measured. Its tests come along; the animation and block browser suites pass (97). #1459 can be closed once this merges.

Fourth commit: an expanded animation scales up

Expand gave the stage the overlay's width but never made the drawing bigger. Now the stage keeps its inline width fullscreen and the block scales the whole canvas, code peeks included, to fit the room the controls leave, at most 2x, centred; a room smaller than the inline stage scrolls as before. The measured-height logic is untouched because the stage still lays out at one width. One browser test expands a block in a wide host and checks the stage scales above 1x, keeps its inline layout width, and its footprint grows with it.

Seen while checking, not from these commits: a scene with four declared columns folded to two rows at a 614px stage keeps a 150px stage height, so the second row is clipped, inline and expanded alike. Reproduced on the commit before the steady-height port. Worth its own look.

Fifth to seventh commits: scene layout, pacing, groups, change marks and a legend

  • Cards size to their content, 140 to 300px, centred in their columns, so a folded scene no longer stretches a card into a slab around a line of text.
  • Edges are straight and level: a fanned pair's offset moves the whole line, and scene rows centre their cards so an edge between a short card and a tall one runs level.
  • Play replays a still frame: a new stage's first frame, or the frame after a jump or Replay, is shown again with its motion when Play reaches it. A send's chip travels at half its old speed, 900 to 2400 ms, after a short lead. One browser test covers the replay.
  • s.group({ id, at, title, color }) draws a washed, titled box behind the cards in its cells; hidden when the scene folds.
  • change: "added" | "modified" | "removed" on a card marks what the review did to that part: dot and left stripe in the change colour, struck title when removed. A frame's tone still colours the card over it.
  • s.legend([...]) draws one row under the scene, a card-shaped glyph with the entry's marks and a label each.
  • The typings and animation.md describe all of it; request-across-layers groups its server, marks the API as modified and carries a legend. The cylinder form's text sits centred in its body.

Later commits: layout room, routing, the state-only dot, an architecture reference

  • Storage-form labels centre in their drum or cylinder; the card dot shows only with a tone or change mark.
  • A new reference, video-platform-architecture: four grouped layers, forms, a dashed CDN and a legend, with upload and playback scenarios.
  • An edge bows clear of a card by the card's reach across the line, not its longest side, and a card near the line is passed toward the stage's middle; a frame hides other frames' chips.
  • A grouped scene opens its rows and columns to 72px so the layers stand apart and cross-layer edges can be followed.
  • Fullscreen, the stage always lays out at its inline width and scales to fit, down to 0.4x beside the tour pane, so it never folds, loses its groups, or measures a folded height.

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
A playing animation gave no sign of when the next frame would come. Now
the marker under the step showing is drawn left to right over the frame's
duration, on a wash of the full underline: the one mark that says "this
step" also says how much of it is left. A frame without labels sweeps a
ring around its dot instead.

The frame player keeps the clock: play starts it from where a pause left
the same frame, the cleanup of each run notes how far it got, and a frame
reached by hand has none. Time up with the stage still settling holds the
line full; replay starts over. The strip and the dots draw it with a CSS
animation from inline custom properties, so nothing ticks in React, and
reduced motion shows the whole underline at once as before.

Agent-Session: cbacb871-2558-4fe4-9272-77524bb21683
Agent-Session: b315a4f8-1333-461c-9be6-366ceb231b7f
Agent-Session: 62100749-c77c-4d19-ad48-c6d4ba1e867b
Ports #1459 onto the animations branch, where the
block it changed had since split into a frame player, a clip player and
a paint-in.

An animation with `height: "auto"` changed height as the reader stepped,
because the stage resized to its current frame, and the caption row moved
when one caption wrapped and the next did not. Now a hidden copy of the
stage, transparent and inert at the same width, shows every frame of the
scenario as instant jumps within one task and reports the tallest; the
visible stage takes the larger of that and its current frame. The copy is
made again when the stage, scenario, theme, width or a slot's content
height changes, gets the visible stage's slot heights, and posts nothing
but `measured`. A new stage keeps its starting height until measured, or
for a second. Every stop's caption shares one grid cell with only the
current one visible, in both players.

What a frame's handlers leave behind in the copy never reaches the stage
the reader sees: the copy is thrown away. Clips have one timeline and are
not measured.

Agent-Session: cbacb871-2558-4fe4-9272-77524bb21683
Agent-Session: b315a4f8-1333-461c-9be6-366ceb231b7f
Agent-Session: 62100749-c77c-4d19-ad48-c6d4ba1e867b
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
The scene grid stretched every card to fill its column, so a scene folded
to two columns drew 640px cards around a line of text and left its edges
only the column gap. A card is now as wide as its content, between 140px
and 300px, and sits mid-column; the edges take the room it gives up. A
table keeps its column, and a scene's own CSS still sets any width it
wants, as the cache reference does for its end cards.

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
Two cards linked both ways got their edges fanned 9px apart by moving
only the curve's control point, so each of the pair bowed; now the offset
moves the whole line, ends and control alike, and a pair is two straight
parallel lines. Scene rows centre their cards instead of top-aligning
them, so an edge between a short card and a tall one runs level.

A frame drawn without its motion, a new stage's first or the one after a
jump or Replay, never moved when Play reached it: the reader waited out
its duration on a still picture. Play now shows such a frame again with
its motion. A send's chip travels at half its old speed, 900 to 2400 ms,
after a short lead, so it can be read and fills more of its frame.

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
…itself

Three things an agent could not say in a scene. `s.group({ id, at, title,
color })` draws a washed, titled box behind the cards in its cells, a
boundary such as a service or a layer; it hides when the scene folds,
since its cells no longer sit together. `change: "added" | "modified" |
"removed"` on a card marks what the change under review did to that
part, as its dot and a stripe down its left edge in the change colour at
rest, struck through when removed; a frame's tone still colours the whole
card over it. `s.legend([...])` draws one row under the scene with a
glyph per entry, a card drawn with the entry's own marks at 0.4 scale,
and its label.

The typings and the instructions describe all three, the request
reference groups its server, marks the API as modified and carries a
legend, and the cylinder's text sits centred in its body rather than
high under the lid.

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
Agent-Session: b315a4f8-1333-461c-9be6-366ceb231b7f
…tate

A cylinder's or drum's text sits in the middle of it, as a label on a
tank does, and a pill's too. The dot at a card's title was a tone
indicator that showed grey on every card, so it read as a mark with no
meaning; it now shows only when a tone or a change mark gives it one,
in that colour.

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
Agent-Session: b315a4f8-1333-461c-9be6-366ceb231b7f
Agent-Session: 7bc797d9-b46c-48dc-b281-4fce85dc15ad
A scene that shows what the new scene marks are for: the client, the
edge, our services and our storage as four groups, each card in the form
of its kind, the stores in teal, the queue in violet, the CDN dashed as
the one part we do not run, and a legend that decodes them. An upload
walks down through the layers and a playback walks back out through the
CDN, one send per frame.

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
Agent-Session: b315a4f8-1333-461c-9be6-366ceb231b7f
Agent-Session: 7bc797d9-b46c-48dc-b281-4fce85dc15ad
Agent-Session: b5f9f841-35c5-4ec1-95a8-6485f64d254b
…' chips

An edge that crossed a card bowed clear of it by half the card's longest
side, so a horizontal edge over a wide card arced 150px up and out of the
stage. The bow now uses the card's reach across the line, its extent
along the normal, and a card sitting dead on the line is passed on the
side nearer the body's middle rather than always to the left.

A chip another frame sent could stay on its edge when the reader stepped
or switched scenario before it faded. Showing a frame now hides every
other frame's chip, except one told to stay.

Agent-Session: cbacb871-2558-4fe4-9272-77524bb21683
Agent-Session: 62100749-c77c-4d19-ad48-c6d4ba1e867b
Agent-Session: ef04727a-5f7d-4c56-a793-c4f98a02f605
Agent-Session: 534581db-1710-40c6-b043-ce052bd9e80d
Agent-Session: b315a4f8-1333-461c-9be6-366ceb231b7f
Agent-Session: 7bc797d9-b46c-48dc-b281-4fce85dc15ad
Agent-Session: b5f9f841-35c5-4ec1-95a8-6485f64d254b
Groups bleed into the gaps around their cells, so with the scene's 28px
rows the layers touched and every edge between them was squeezed through
a slot too narrow to follow. A scene with groups now opens its rows and
columns to 72px and its vertical padding to 32px, and the gap a row's
labels ask for keeps that floor. A card within 12px of an edge's line is
passed on the side nearer the stage's middle, so a top-row edge dips into
the drawing rather than arcing over it.

Agent-Session: cbacb871-2558-4fe4-9272-77524bb21683
Agent-Session: 62100749-c77c-4d19-ad48-c6d4ba1e867b
Agent-Session: b315a4f8-1333-461c-9be6-366ceb231b7f
Agent-Session: 7bc797d9-b46c-48dc-b281-4fce85dc15ad
Agent-Session: b5f9f841-35c5-4ec1-95a8-6485f64d254b
Agent-Session: 32e129b9-586e-4fd2-8961-e7224c5978ad
Beside the tour's pane the stage column is narrower than the inline
column, and the stage took that width: a scene folded to one column, its
groups hid, and its height grew past 1200px. That height could then be
what the block measured and kept. Fullscreen, the stage now always lays
out at its inline width and scales to fit the room, down to 0.4x, so
the drawing and its measurement are the same ones the reader saw inline.

Agent-Session: cbacb871-2558-4fe4-9272-77524bb21683
Agent-Session: 62100749-c77c-4d19-ad48-c6d4ba1e867b
Agent-Session: b315a4f8-1333-461c-9be6-366ceb231b7f
Agent-Session: 7bc797d9-b46c-48dc-b281-4fce85dc15ad
Agent-Session: b5f9f841-35c5-4ec1-95a8-6485f64d254b
Agent-Session: 32e129b9-586e-4fd2-8961-e7224c5978ad
The stage sized itself from the bottom of every visible element, and in
Electron's Chromium a zoomed element's getBoundingClientRect comes back
in unzoomed units, so a legend glyph at zoom 0.4 read as sitting 2.5x
further down than it was. An animation with a legend got a stage more
than twice its drawing's height, with the blank below. The glyph is now
a 50x32 card scaled by a transform inside a 20x13 box, which measures
where it is drawn.

Agent-Session: cbacb871-2558-4fe4-9272-77524bb21683
Agent-Session: 62100749-c77c-4d19-ad48-c6d4ba1e867b
Agent-Session: b315a4f8-1333-461c-9be6-366ceb231b7f
Agent-Session: 7bc797d9-b46c-48dc-b281-4fce85dc15ad
Agent-Session: b5f9f841-35c5-4ec1-95a8-6485f64d254b
Agent-Session: 32e129b9-586e-4fd2-8961-e7224c5978ad
Scaled to 0.4, the glyph's 1px borders, lid and rule marks and change
stripe thinned to under half a pixel and the dashed third-party glyph
vanished. They are drawn 2.5x heavier so they land at a pixel.

Agent-Session: cbacb871-2558-4fe4-9272-77524bb21683
Agent-Session: 62100749-c77c-4d19-ad48-c6d4ba1e867b
Agent-Session: b315a4f8-1333-461c-9be6-366ceb231b7f
Agent-Session: 7bc797d9-b46c-48dc-b281-4fce85dc15ad
Agent-Session: b5f9f841-35c5-4ec1-95a8-6485f64d254b
Agent-Session: 32e129b9-586e-4fd2-8961-e7224c5978ad
Painting cards over their group box gave them a z-index, which also put
them over the edge and chip layers that had painted above them, so a
travelling chip went behind the cards it passed. The two layers now sit
above the cards, as before the groups.

Agent-Session: cbacb871-2558-4fe4-9272-77524bb21683
Agent-Session: 62100749-c77c-4d19-ad48-c6d4ba1e867b
Agent-Session: b315a4f8-1333-461c-9be6-366ceb231b7f
Agent-Session: 7bc797d9-b46c-48dc-b281-4fce85dc15ad
Agent-Session: b5f9f841-35c5-4ec1-95a8-6485f64d254b
Agent-Session: 32e129b9-586e-4fd2-8961-e7224c5978ad
A send's chip faded out a quarter second after arriving, so what it
carried was gone before it could be read, and it reappeared on a step
back. It now waits at the head until the frame changes, when the next
frame hides every other frame's chips; `stay: false` fades it on arrival
for a chip that must not cover its target.

Agent-Session: cbacb871-2558-4fe4-9272-77524bb21683
Agent-Session: 62100749-c77c-4d19-ad48-c6d4ba1e867b
Agent-Session: b315a4f8-1333-461c-9be6-366ceb231b7f
Agent-Session: 7bc797d9-b46c-48dc-b281-4fce85dc15ad
Agent-Session: b5f9f841-35c5-4ec1-95a8-6485f64d254b
Agent-Session: 32e129b9-586e-4fd2-8961-e7224c5978ad
One row per layer and every hop between neighbouring cells: the app over
the gateway with the CDN beside it, the three services under the edge
row, each store under the service that uses it. No edge crosses a card;
the one curve is the CDN's fetch from the blob store.

Agent-Session: cbacb871-2558-4fe4-9272-77524bb21683
Agent-Session: 62100749-c77c-4d19-ad48-c6d4ba1e867b
Agent-Session: b315a4f8-1333-461c-9be6-366ceb231b7f
Agent-Session: 7bc797d9-b46c-48dc-b281-4fce85dc15ad
Agent-Session: b5f9f841-35c5-4ec1-95a8-6485f64d254b
Agent-Session: 32e129b9-586e-4fd2-8961-e7224c5978ad
A row of two cards could not sit centred under a row of one on whole
columns. A scene may now declare twice the columns and place every card
two wide; the fold rule counts the columns the cards make, not the ones
declared, and only a card wider than that unit takes a folded row. The
video platform reference puts its edge layer centred under its client
this way.

Agent-Session: cbacb871-2558-4fe4-9272-77524bb21683
Agent-Session: 62100749-c77c-4d19-ad48-c6d4ba1e867b
Agent-Session: b315a4f8-1333-461c-9be6-366ceb231b7f
Agent-Session: 7bc797d9-b46c-48dc-b281-4fce85dc15ad
Agent-Session: b5f9f841-35c5-4ec1-95a8-6485f64d254b
Agent-Session: 32e129b9-586e-4fd2-8961-e7224c5978ad
Agent-Session: c2a12499-97dd-4d8c-a7ef-f76a01d39f01
A chip's ride tweens a progress value from where it is to 1. After it had
ridden once, a timeline built again, as changing scenario does, tweened
it 1 to 1, so from then on the chip appeared at its head without moving.
The rebuild resets every ride to 0 first.

Agent-Session: cbacb871-2558-4fe4-9272-77524bb21683
Agent-Session: 62100749-c77c-4d19-ad48-c6d4ba1e867b
Agent-Session: b315a4f8-1333-461c-9be6-366ceb231b7f
Agent-Session: 7bc797d9-b46c-48dc-b281-4fce85dc15ad
Agent-Session: b5f9f841-35c5-4ec1-95a8-6485f64d254b
Agent-Session: 32e129b9-586e-4fd2-8961-e7224c5978ad
Agent-Session: c2a12499-97dd-4d8c-a7ef-f76a01d39f01
A frame drawn without motion, on load or by a jump, landed its sends at
their heads, so a fresh board showed the first frame's chip already
arrived. The chips now sit at their origins, visible and ready to be
sent; Play or a step forward rides them. A chip told to stay keeps its
head.

Agent-Session: cbacb871-2558-4fe4-9272-77524bb21683
Agent-Session: 62100749-c77c-4d19-ad48-c6d4ba1e867b
Agent-Session: b315a4f8-1333-461c-9be6-366ceb231b7f
Agent-Session: 7bc797d9-b46c-48dc-b281-4fce85dc15ad
Agent-Session: b5f9f841-35c5-4ec1-95a8-6485f64d254b
Agent-Session: 32e129b9-586e-4fd2-8961-e7224c5978ad
Agent-Session: c2a12499-97dd-4d8c-a7ef-f76a01d39f01
A send's chip showed whichever had happened last: it waited at its
origin when the frame was drawn still, sat at the head after a ride, and
was hidden after a jump. Same frame, three pictures.

A frame is a state, so its messages now rest as labels on their edges,
which take the marker colour, in place of the edges' own labels. However
the reader reaches the frame, that is what it shows. Stepping into the
frame rides a chip from the sender while the label is quiet; at the head
the chip dissolves into the label. The send option `stay` goes, since
a message no longer has a head to wait at; a raw travel keeps it.

A sent message's label no longer dodges its chip's swept path, since it
is quiet while the chip rides, so it stays by its edge at rest.

Agent-Session: cbacb871-2558-4fe4-9272-77524bb21683
Agent-Session: 62100749-c77c-4d19-ad48-c6d4ba1e867b
Agent-Session: b315a4f8-1333-461c-9be6-366ceb231b7f
Agent-Session: 7bc797d9-b46c-48dc-b281-4fce85dc15ad
Agent-Session: b5f9f841-35c5-4ec1-95a8-6485f64d254b
Agent-Session: 32e129b9-586e-4fd2-8961-e7224c5978ad
Agent-Session: c2a12499-97dd-4d8c-a7ef-f76a01d39f01
Agent-Session: 01a1a36e-2277-7060-97f7-bf84f55e1905
The queue, state machine and request timing references drew every part
as a plain box. The producer and workers are now bars, the queue a
violet stack, the start and final states pills, and the request's stops
a window, bars, a drum and a teal cylinder, as the scene references and
the instructions already do.

Agent-Session: cbacb871-2558-4fe4-9272-77524bb21683
Agent-Session: 62100749-c77c-4d19-ad48-c6d4ba1e867b
Agent-Session: b315a4f8-1333-461c-9be6-366ceb231b7f
Agent-Session: 7bc797d9-b46c-48dc-b281-4fce85dc15ad
Agent-Session: 32e129b9-586e-4fd2-8961-e7224c5978ad
Agent-Session: c2a12499-97dd-4d8c-a7ef-f76a01d39f01
Agent-Session: 01a1a36e-2277-7060-97f7-bf84f55e1905
Agent-Session: 62100749-c77c-4d19-ad48-c6d4ba1e867b
@milanb17
milanb17 added this pull request to stack #1505 November 4, 2026 02:36
The fold's fit test counted the columns a half-step scene's cards make,
but the fold still drew the declared count, so a six-column scene folded
to three squeezed columns with its placement and groups thrown away. A
scene now folds in the columns its cards make, and keeps its layers: each
declared row wraps into lines of fewer cards, balanced and centred on
half-step tracks, a wider part takes a line of its own, and a group spans
the lines its cards wrap into or hides when another card shares one. The
measuring copy runs a scene's due layout before it measures, so it sizes
the folded layout. VITE_ANIMATION_WIDTH shows the references narrow.

Agent-Session: 62100749-c77c-4d19-ad48-c6d4ba1e867b
Folding a scene when the block narrows reflowed the drawing: rows mixed,
groups vanished, edges crossed. A scene now keeps its declared columns at
any width. It sets its own minimum width from the columns its cards make
and their gap, the stage reports the width it needs with its size, and
the block keeps the stage that wide and scrolls it sideways in a narrower
block. The responsive option and the fold classes go.

Agent-Session: 62100749-c77c-4d19-ad48-c6d4ba1e867b
Agent-Session: 62100749-c77c-4d19-ad48-c6d4ba1e867b
Narrow, the strip became a four-column grid of wrapped labels, two or
more rows deep. It now stays one row at any width, scrolls sideways when
its steps do not fit, and brings the step showing into view without
scrolling the page.

Agent-Session: 62100749-c77c-4d19-ad48-c6d4ba1e867b
…amp captions

The stage now reports how far its content reaches past its right edge,
whatever drew it, and the block keeps the stage at least that wide and
scrolls it in a narrower block; a scene's minimum width is one such
content. The block title no longer wraps below the narrow breakpoint but
truncates as every diagram title does, and a step's caption stops at
three lines.

Agent-Session: 62100749-c77c-4d19-ad48-c6d4ba1e867b
Agent-Session: cbacb871-2558-4fe4-9272-77524bb21683
Agent-Session: ffed49d6-ca86-4940-b93c-e8eb7e0de440
The wash bled 18px above its cells and the title sat 4px under that
edge, so a micro-caps title ended at the card's top and a highlighted
card's ring covered it. The top bleed is 28px now.

Agent-Session: 62100749-c77c-4d19-ad48-c6d4ba1e867b
Agent-Session: cbacb871-2558-4fe4-9272-77524bb21683
Agent-Session: ffed49d6-ca86-4940-b93c-e8eb7e0de440
…-e33a0d' into mb/scene-card-forms

Agent-Session: 62100749-c77c-4d19-ad48-c6d4ba1e867b
Agent-Session: cbacb871-2558-4fe4-9272-77524bb21683
Agent-Session: ffed49d6-ca86-4940-b93c-e8eb7e0de440
Agent-Session: 7bc797d9-b46c-48dc-b281-4fce85dc15ad
@milanb17
milanb17 merged commit 5cb7810 into main Nov 4, 2026
3 checks passed
@milanb17
milanb17 deleted the mb/scene-card-forms branch November 4, 2026 04:55
milanb17 added a commit that referenced this pull request Nov 4, 2026
Stacked on #1498 (the clip block and the reference animations live on
that stack, so this is based on `mb/scene-card-forms`).

After publishing an animation an agent had no way to see what it drew:
`session_get` says only whether the script ran, so agents fell back to
driving the screen. `session_render({ sessionId, targetId, frame })`
shows one frame of an animation (or a clip at one marker; omitted, the
first) on the whiteboard open in Desktop and returns it as a PNG image,
with the error the stage reported, if any. The PNG is also written to
`<home>/animation-renders/<session>/<block>/<key>.png` and named in the
result, for agents that read files rather than images (`whiteboard api
session_render` prints the JSON without the base64). Step 5 of "Work in
files" in `animation.md` tells agents to render each frame they changed
after publishing, instead of taking screenshots.

What the agent gets back, from a scene animation and a clip published
through `whiteboard api` into a dev Desktop:

![An animation at its third frame: the API has asked Postgres and the
cache
missed](https://raw.githubusercontent.com/devdotfast/whiteboard/pr-assets/render-animation-frames/render-animation-query.png)

![The same animation at its last frame: the client has its order and the
cache is
filled](https://raw.githubusercontent.com/devdotfast/whiteboard/pr-assets/render-animation-frames/render-animation-reply.png)

![A clip at its second marker: the request chip sits under the API, the
progress line at the
marker](https://raw.githubusercontent.com/devdotfast/whiteboard/pr-assets/render-animation-frames/render-clip-accepted.png)

```json
{"sessionId":"2d67401e-…","targetId":"block-1","type":"animation","frame":{"key":"query","title":"Postgres answers","position":2,"count":4},"clipped":false,"error":null,"file":"…/animation-renders/2d67401e-…/block-1/query.png"}
```

Over MCP the same call answers an `image` content block (the PNG) ahead
of that text.

## How a frame is rendered: Desktop captures its own canvas

Two ways were weighed. (A) The attached Desktop shows the frame on the
open board and captures the block's rectangle with Electron's
`capturePage`. (B) The review server renders the stage headless. This PR
does A:

- A frame as the reader sees it exists only in Desktop. The code peeks
and embedded blocks a stage shows are native Monaco editors and canvas
components that Desktop's host bridge lays over the stage; a headless
renderer would have to rebuild that bridge or draw something else.
- The headless server has no browser. `whiteboard server start` with an
attached Desktop renders through the same relay `session_open` uses;
without one the route answers 409.
- The costs, and how they show: the board must be open in Desktop (the
error says to call `session_open`); the window must be on screen (a
covered or minimized window paints nothing and throttles its timers, so
the canvas fails fast with a message saying so rather than waiting out
the relay); the reader sees the frame change; a figure taller than the
window is captured as far as it fits, with `clipped: true`.

## The pieces

- **Protocol**: verb `renderAnimation {reviewId, targetId, frame}`;
`ReviewCanvasHandle.renderAnimation` and `ReviewRenderedFrame`.
- **Canvas**: `animation/stage-renderer.ts` keeps the stage blocks on a
board by id. `StageBlock.show(key)` waits for the stage to boot and
paint in, switches to a scenario that has the frame when the one showing
does not, picks the stop with the new `PickedBy` `"render"` (playback
stops; a clip pauses), waits for `settled` or the clip's `marker`,
scrolls the figure into view, waits for it to hold still, and reports
its rectangle in CSS px. The stage port now notes the frame or marker
last asked for, and the block notes what the stage reports showing, so a
render waits until they agree.
- **Fork**: `reviewCanvasPart.ts` answers the verb for the board it
shows (main window only), scales the rectangle by the window's zoom and
calls `hostService.getScreenshot(rect, { format: "png", maxEdge: 1600
})`. The native host's `getScreenshot` gained those two options; JPEG
stays the default, and an empty capture answers `undefined`.
- **Server**: `review-api/animation-render.ts` holds the query and
result schemas and `renderTarget`, which checks the frame against the
block's frames or markers (naming them when it is wrong) and picks the
first scenario's first frame when omitted. `GET /:id/render` in
`http.ts`; `relayReviewCallbacks.render` in `review-server-core.ts`. The
`/control/result` bound is 16 MiB so the PNG can ride back.
- **Agent boundary**: the tool is listed with animation authoring
(`session_checkout` beside it); `public-tools.ts` writes the file;
`mcp.ts` returns the image block; `agent-cli.ts` keeps the base64 out of
stdout.

## Verification

- Node: `animation-render.test.ts` (target resolution, the route with a
stub Desktop, the tool listing, the file and the image split),
`render-relay.test.ts` (the relay callback: result parsing, Desktop's
refusal and a missing Desktop as 409s), plus the existing authoring,
public-tools, agent-client and authoring-capabilities tests.
- Browser: `stage-renderer.browser.test.tsx` mounts the retry-backoff
reference, renders `a3fail` (only in its second scenario), checks the
reported rectangle against the figure and that Playwright's capture of
it is a non-empty PNG; lands a clip on a marker at rest; reports unknown
stops and blocks. The clip, paint, blocks and references suites pass
(100 tests).
- Desktop: `pnpm desktop:build` and `desktop:run` under an isolated
`DEV_REVIEW_HOME`, animations enabled, a scene animation and a clip
published through `whiteboard api`. `session_render` returned the
captures above, exact to the figure, for frames in either direction and
for markers, and the errors for a frame the block lacks and for a hidden
window. Not checked: dark theme, a zoomed window, secondary windows.
- `pnpm lint`, `pnpm format`, both typechecks and the fork's
`typecheck-client` are clean.

The screenshots live on `pr-assets/render-animation-frames`; delete that
branch with this PR.
milanb17 added a commit that referenced this pull request Nov 4, 2026
Since #1498, a hand-made animation whose `wb-node` elements are placed
absolutely and take a `data-form` draws every node in normal flow: the
nodes stack and overlap, and the SVG arrows drawn at fixed coordinates
point at empty space. `animation.md` invites `data-form` on a `wb-node`
in a hand-made layout, so this is the documented use.

The cause is the kit's `:is(.wb-card, .wb-node)[data-form] { position:
relative; }`, which weighs two classes (0,2,0) and beats an agent's
`.node { position: absolute }` (0,1,0).

## The fix

The form, change, outline and colour rules in `animation-stage-kit.ts`
now match through `:where(.wb-card, .wb-node)[…]`, so they weigh one
class:

- They tie with the base `.wb-card, .wb-node` rule and win by coming
after it, so a form's padding, radius and `::before`/`::after` still
draw when the agent sets nothing.
- An animation's own CSS comes after the kit (it is in the body, the kit
in the head), so any single-class rule an agent writes wins.
- The change marks move with them, so the kit keeps its order among
these rules: a colour still sets the border after a change stripe, as
before.
- Tones and highlights keep their two-class weight and still take over
during a frame.

The scene's own cards are unchanged. `.wb-scene > .wb-card` keeps them
relative, and the legend glyphs have their own two-class rule. The
resting frames of `request-across-layers` and `cache-fill-evict`
(window, bars, cylinder, drum, change stripe, legend) are byte-identical
before and after.

## Test

`blocks.browser.test.tsx` adds a 720×420 `.architecture` layout with two
`node wb-node` cards (`data-form="bars"`) at `#a{left:12px;top:10px}`
and `#b{left:262px;top:162px}`. The stage reports through the code
slot's width whether both kept their position. The test fails on main
and passes with the fix.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants