gallery health: gate layout shift on load and every play step
Leo, 2026-11-01: "how about like the apparatuses for improving ui verification? let's examine what we can do better for our workflows"
The same night, Leo found these himself in the nightly, after review had passed:
- the gap above the composer shifts on load;
- the scroll rail grows in steps while history loads;
- the sidebar toggle shifts the layout;
- a tool expand pushes content up.
All four are motion. A static screenshot shows only the settled frame, so it can't catch any of them. The gallery's play steps already measured CLS, but gallery health ignored it ("quality findings are the matrix's"), and the per-PR matrix workflow is disabled. Layout shift gated nothing.
What changes
Shift measurement (stage)
- Box shift.
webviews/src/gallery/shift.tssamples the boxes of the rows a person reads on every frame and scores their moves the way CLS does. The rows are[data-row-id], the composer, menus, list and tree rows,data-gallery-track, and the entry's anchors.- Unlike the browser's layout-shift entries, it sees moves that scrolling made. Chromium subtracts a scroller's delta, which hides a bottom-pinned transcript scrolling its rows up.
- It also works in WebKit.
- Exempt: the step's target, rows after the target pushed straight down, boxes still entering (not yet still for 2 frames), and moves under 0.5 px.
- The load is measured.
frame/main.tsscores from navigation until the play starts, with both measures (cmuxGalleryLoadReport). - Waits are measured. A
waitForthat doesn't hold at once is its own measured step: older history arriving and a reply landing count. Its frame timing isn't judged. ctx.resize(width)resizes the pane the way the sidebar toggle does. Each box may move once to its new place, and a second move counts.
The gate (gallery health, the existing check, no new job)
shift-baseline.ts: a state's score is its load plus each step, taking the larger of the two measures for each.- A state whose row is 0 fails on any shift.
- A non-zero row fails past 1.25x its value.
- A state with no row only warns.
- A shift must show on every retry to count.
- Rows only go down (
--from <artifacts>).--addseeds rows for new states.
- Base reds:
test_reds.py galleryreads the newshiftedlist, so a shift the base shares is a warning, as for broken states.
Scenarios (agent-pane.shift-scenarios)
- opening a chat;
- a tool fold opening at the bottom, and mid-transcript;
- the pane narrowing and widening;
- the model menu opening;
- older history paging in on the mock daemon (debug
load_seed).
First numbers (local headless Chromium, before the baseline)
| state | shift | what moved |
|---|---|---|
| shift-scenarios#open-chat | 0 | |
| shift-scenarios#model-menu-open | 0 | |
| shift-scenarios#tool-expand-bottom | 0.0176 | rows above the opened fold move up 4 px (Leo's "tool expand pushing content up") |
| shift-scenarios#tool-expand-mid | 0.0326 | |
| shift-scenarios#pane-resize | 0.3758 | after the resize settles, rows jump 68 px a second time (the sidebar-toggle bounce) |
| shift-scenarios#older-history | 0.0039 | the composer box and bar move down 16 px while the long chat opens (Leo's "gap above the composer shifts on load") |
| new-chat-no-shift#send-to-streaming | 0.0011 | the picker moves 53 px left when Enter sends |
| tool-group-expanded (3 states) | 0 |
The non-zero rows are today's debt. They are recorded as their measured values, so the gate holds them there and they can only go down. Each one has a follow-up in the tracking issue. Everything else the CI run measures at 0 becomes a 0 row.
Baseline
shift-baseline.json is seeded from this PR's own gallery health artifacts with bun shift-baseline.ts --from <artifacts> --add. This PR renders every state, because the checker changed. It lands only with that commit in place.
Tests
webviews/test/gallery-shift.test.tscovers the scoring rules (scroll-made moves count, the target and pushed rows are exempt, the resize rule, rounding).scripts/gallery-matrix/shift-baseline.test.tscovers rows, limits, retries and lowering.tests/test_test_reds.py GalleryHealthcovers a shared shift as a base red.webviews/test/gallery-play.test.tsstill passes.
Observed / Not run
- Ran locally (Linux):
bun teston the three suites above: all pass (23 + 17 + 11).- A local
health.tson the scenario entries (table above). scripts/web-lint.sh: passes. The type-aware lint is left to CI on this host.
- Not run: the full gallery render. It is this PR's CI run.
Design spec: design/motion-and-polish.md ("No flicker, no layout shift. A surface draws its final layout in its first frame") and design/chat-transcript.md ("never translate, scale or resize the rows already on screen").
Changelog
none
Agent session: agent session
š¤ Generated with Claude Code
![View with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v3-3x.png)
![Autofix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png)
Need help on this PR? Tag @codesmith-bot with what you need.
Summary by cubic
Gallery health now gates layout shift measured on load and through every play step, so the PR that shifts the layout is the one that gets caught. The new box-shift tracker (webviews/src/gallery/shift.ts) samples the rows a person reads every frame and scores their moves CLS-style; it also sees scroll-made moves the browser's layout-shift entries subtract, and works in WebKit.
Measurement
- The load is scored from navigation until play starts; a
waitForthat doesn't hold at once is its own measured step, judged only for shift. ctx.resize(width)resizes the pane like the sidebar toggle; each box may move once, and a second move is a bounce.- Exempt: the step's target, rows after it pushed straight down, boxes still entering, and moves under 0.5 px.
The gate
shift-baseline.tsgives each state a row: a 0 row fails on any shift, a non-zero row fails past 1.25x its value, and a state with no row only warns. A shift must appear on every retry to count.- Rows only go down;
--addseeds rows for new states. The baseline ships empty and is seeded in a follow-up commit, so states only warn until then. - New
agent-pane.shift-scenariosentry covers opening a chat, tool expands at the bottom and mid-transcript, pane resize, the model menu, and older history paging in on the mock daemon.