Skip to content

Keep an animation's height steady as the reader steps through frames - #1459

Closed
milanb17 wants to merge 3 commits into
mainfrom
mb/frame-nav-height-issue-a2b5b7
Closed

milanb17 wants to merge 3 commits into
mainfrom
mb/frame-nav-height-issue-a2b5b7

Conversation

@milanb17

@milanb17 milanb17 commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

What

An animation with height: "auto" changed height as you stepped through its frames, because the stage resized to fit whatever the current frame showed. The caption row also changed height when one caption wrapped and another didn't.

  • Measuring copy (animation-block.tsx, stage-runtime.ts, stage-protocol.ts): next to the visible stage, the block loads a hidden copy of the stage, transparent and inert, at the same width. It starts with measure: true, shows every frame of the current scenario as an instant jump within one task, and reports the tallest as measured. Then it's removed. The visible stage uses the larger of that height and its current frame's height. The copy is made again when the stage, scenario, theme, width or a slot's content height changes. It receives the visible stage's slot heights, and it posts nothing but measured.
  • Caption row: every frame's caption sits in one grid cell, with only the current one visible, so the row keeps the height of the longest caption. Only the visible caption is included when the text is copied.

Why a separate copy

The first version measured inside the visible stage by running each frame's handlers within a single task. Handlers assume their frame is the one showing, so their leftovers reached the frame on screen: a later frame's finished whiteboard.animate motion, and setTimeout work that landed after the stage had stepped back. The copy does the same single-task pass, but it's thrown away afterwards, so what a frame leaves behind never reaches the stage the reader sees.

Reviewer notes

  • Height is kept per scenario: picking another scenario measures again. This was a deliberate choice.
  • A new stage keeps its 120px starting height until the measurement arrives, or for one second at most, then grows straight to its tallest frame. Without this it would grow twice: once to the current frame, then again to the tallest. The measurement arrives at about the same time as the visible stage's first size.
  • Work a handler defers (timers, for example) isn't measured, because the copy measures in one pass. If that work makes a frame taller, the stage grows when the reader reaches it, as it did before.
  • Test changes in blocks.browser.test.tsx:
    • The auto-size test now starts on the short frame and expects full height before the reader steps.
    • A new test covers content built by frame handlers.
    • The fullscreen test waits for the measuring copy to go before counting iframes.
    • The scene test no longer expects the stage to shrink on its last frame.

Checked

  • blocks.browser.test.tsx (61 tests), pnpm lint and typecheck all pass.
  • In a dev Desktop, I stepped through every frame of a real handler-built animation ("A run streams, pauses for approval, then resumes"). The height held.

An auto-height stage now lays out every frame of the current scenario
when it loads, frame handlers included, within one task so none of them
paints, and sizes itself to the tallest. It measures again when the
width, theme, fonts, images, a slot's size or the scenario change.

Every frame's caption shares one grid cell, so the controls row keeps
the height of the longest caption.

Agent-Session: b5f9f841-35c5-4ec1-95a8-6485f64d254b
Agent-Session: d7e3a290-82a4-4f66-9b3c-7a52a51add18
Agent-Session: 3bc0a8d7-44d9-4b00-8484-66b3718cf367
Running each frame's handlers inside the stage the reader sees let their
leftovers reach the frame on screen: a later frame's finished motion,
and deferred work that landed after the stage had stepped back. A hidden
copy of the stage now shows every frame of the scenario as a reader
would, reports the tallest, and goes away. The visible stage takes that
height, or its current frame's if that is taller.

The copy is made again when the stage, scenario, theme, width or a
slot's content height changes. It posts nothing but its measurement.

The scene test no longer expects the stage to shrink on its last frame;
it keeps the tallest frame's height by design.

Agent-Session: b5f9f841-35c5-4ec1-95a8-6485f64d254b
Agent-Session: d7e3a290-82a4-4f66-9b3c-7a52a51add18
Agent-Session: 3bc0a8d7-44d9-4b00-8484-66b3718cf367
The measuring copy stepped through frames as a reader would, which took
about a second on an eleven-frame animation, so the stage grew once
after it appeared. The copy is thrown away, so it can show every frame
as an instant jump within one task; what a frame leaves behind does not
matter there. It now reports about when the visible stage first does.

A new stage keeps its first height until that measurement arrives, or
for a second at most, so it grows once, straight to its tallest frame.

Agent-Session: b5f9f841-35c5-4ec1-95a8-6485f64d254b
Agent-Session: d7e3a290-82a4-4f66-9b3c-7a52a51add18
Agent-Session: 3bc0a8d7-44d9-4b00-8484-66b3718cf367
milanb17 added a commit that referenced this pull request Nov 3, 2026
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
milanb17 added a commit that referenced this pull request Nov 4, 2026
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](https://raw.githubusercontent.com/devdotfast/whiteboard/pr-assets/scene-card-forms/scene-request-across-layers.png)

![cache-fill-evict: the cache as a teal drum holding its items, Postgres
as a
cylinder](https://raw.githubusercontent.com/devdotfast/whiteboard/pr-assets/scene-card-forms/scene-cache-fill-evict.png)

## 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.
@milanb17 milanb17 closed this Nov 4, 2026
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.

1 participant