The layout snapshot
Three checks that pin where things are, so a layout regression fails the build instead of waiting for someone to notice it on screen.
They exist because of a specific failure. Feature 017 pinned every colour the application resolves, in both schemes, byte-for-byte — and pinned nothing about position or size. A long worktree name drawn across its own close button shipped, was found by a person looking at the running application, and could only be closed by eye, because the baseline needed to catch it had never been captured.
The boundary below is written out in full, including the parts these checks do not hold. A gate that is quietly narrower than it looks is the exact failure this feature exists to correct, so “would this be caught?” must be answerable from this page without reading the tests.
Would this be caught?
| Change | Caught? | By what |
|---|---|---|
| A padding change | Yes | the fixture — every box moves or resizes |
| A font-size change that widens a label | Yes | the fixture, and the overflow gate if it now exceeds its box |
| A margin that pushes a control off its row | Yes | the fixture |
| A dropdown that opens in the wrong place | Yes | the fixture’s overlay pass — one state opens one |
| Text painted past the box it was given | Yes | the text-overflow gate |
| A child laid out beyond a parent that will not clip it | Yes | the containment invariant |
| A child laid out beyond a parent that does clip it | No | nothing reads draw; see the containment invariant |
| A screen quietly dropped from coverage | Yes | coverage-narrowing check |
| A colour, border, radius or shadow change | No | style_snapshot owns these |
| A widget swapped for a differently-drawn one of identical size | No | nothing reads what is painted, only where |
| A change visible only mid-animation | Almost never — see Animation | |
| A change visible only part-way down a scroll | No | everything resolves at offset zero |
| A change to a list long enough to overflow its viewport | Yes | one covered state overflows the sidebar |
| A change in rasterised pixels — anti-aliasing, hinting, subpixel placement | No | no image is compared |
| A change to the typography a user actually sees | Yes — measured against the shipped faces |
If a change you care about is not in this table, it is probably not covered. Say so in review rather than assuming; adding a row is cheap, and a wrong assumption here is what feature 017 paid for.
The three checks
They are separate because they answer different questions, and the first cannot answer the other two — that was measured, not assumed.
1. The geometry fixture — tests/layout_snapshot.rs
Resolves every element of every covered state and compares the result, byte for byte, against
tests/fixtures/layout_snapshot.txt. A failure names the element that moved and shows both
geometries.
This catches anything that moves or resizes. It is the broad one.
2. The text-overflow gate — tests/layout_text_overflow.rs
Draws each covered state and asks the renderer what it actually painted. For every piece of text, the shaped paragraph’s natural width must not exceed the bounds it was clipped to.
This exists because the fixture structurally cannot catch the defect that motivated the feature. The
sidebar label is Length::Fill, so its node is exactly the width its parent allots whether the
text fits or not — the fixture is byte-identical with and without the bug. What changes is what
gets painted, so this gate asks the renderer instead of the layout tree.
3. The containment invariant — tests/gates/containment.rs
Asserts that no layout node is laid out beyond the node that owns it, except where a wrapper is known to reveal its child by clipping.
Every box it reads is already in the fixture, so why a second check? Because a byte-compare fixture can only catch changes, never pre-existing defects. It records whatever it is shown as correct; a defect older than the fixture is regenerated into the expected value and becomes the baseline. Catching a defect that is already there needs an assertion about the numbers, not a comparison against a file.
Its limit is worth stating before its value. A child laid outside its parent is not by itself
wrong: a widget may lay the child out at full size and clip it when drawing, and the layout tree
looks identical either way. Whether the overhang reaches the screen is decided in draw, which this
check does not read. So it catches an escape from a parent that does not clip, and is blind to the
distinction otherwise — which is why CLIP_REVEALED exists and why it does not expire.
That limit was found the hard way. The check was built against BUG-001, on the belief that
Expand’s oversized child was the defect. It was not: Expand::layout is unchanged by the fix,
the child is still laid out oversized on purpose, and the real cause was that layout never re-ran,
so the clip was handed stale bounds. Every escape this check reported survived the fix. It named the
right nodes for the wrong reason — and would have named them just as loudly with nothing wrong at
all. The mechanism BUG-001 broke is covered by tests/animated_layout_relayouts.rs, which asserts
on relayout requests rather than on boxes.
It runs inside the layout_snapshot test binary so it can reuse that binary’s resolved records —
Cargo makes one binary per file directly under tests/, and a cache cannot cross processes.
What is covered
Eleven states, listed in one place: crates/micold-client/tests/support/covered_states.rs. Feature
017’s reduced parity set, plus the empty and error layouts no manual walkthrough reliably reached,
the Settings dialog, and the add-worktree dialog with its type dropdown open.
Every state resolves at one canonical window size, 1280×800, from fixed invented data. Nothing reads your workspace, configuration or session store — a fixture recording the author’s own machine would be unreproducible anywhere else, including on the same machine tomorrow.
Both colour schemes are resolved. Only light is recorded; dark is asserted structurally identical rather than duplicated, with two declared geometric exemptions that must each keep firing.
Both the base widget tree and widget-attached overlays are walked. The second pass matters: dialogs
and menus are composed in-tree and the base walk already sees them, but a picker floats its list
through Widget::overlay, and that list is laid out separately and is invisible to the base walk.
That pass ran over every state and recorded nothing for most of this feature’s life. The only
widget reached through Widget::overlay was the select’s dropdown, and no covered state opened one,
so the fixture held zero over records while the pass was documented as covering them — a check
quietly narrower than it looked, which is the failure this whole feature exists to correct, arrived
at from the opposite direction. add-worktree-dialog-type-menu-open now opens one, and
the_overlay_pass_records_something_somewhere fails if no state does.
Opening it is not a state you can set, and that survived the select becoming this library’s own
control (feature 022). Its open flag is private widget-tree state with no accessor — deliberately, so
that no screen has to hold it — so StateUnderTest::pressing causes it: a left press at the
control’s centre, the way a person opens it. That press has to be preceded by settling the dialog’s
entrance transition — a modal mounts at progress zero on purpose and swallows every event that is not
a redraw until it has appeared, so a press into a freshly built tree reaches nothing at all.
It also has to be followed by settling frames. A picker floats its list for as long as its own
visibility track says there is any of it left, and that track only moves on a redraw — so a press
with no frames after it opens a list that does not exist yet, and the fixture loses every over
record.
What is not covered
Appearance
Colour, border, radius, shadow, and every other resolved style value belong to style_snapshot.
These checks read geometry only. A widget replaced by a differently-drawn one of exactly the same
size passes all three without comment.
No pixels are compared. Anti-aliasing, hinting and subpixel placement are invisible here, by design — a pixel comparison would fail on every platform for reasons that are not regressions.
Animation
Records are taken at rest. A transition’s intermediate frames are not recorded, and deliberately so: a fixture holding a frame partway through a reveal would churn on any change to a duration or an easing curve, which is motion’s business rather than layout’s.
One exception, and it is narrow. revealing_states() pins the sidebar’s filter panel two frames
into its 90ms reveal, and the containment invariant — only that invariant — is asserted against it.
It is not recorded in the fixture and nothing checks its geometry. An invariant can be asked at a
moment no fixture would want to hold, and this one asks whether the reveal pushes any neighbour
out of the box that owns it — a defect visible only while the animation runs.
Pumping frames requires each redraw to carry a distinct Instant: a track ignores a repeat of the
frame it last advanced on, so N identical events advance it once. The pinned fraction is asserted
for exactly this reason, and it caught the apparatus reading 0.178 where it meant 0.356.
So: a change visible only mid-animation is not caught, unless it makes a node escape its parent during that one pinned reveal.
Scrolling
Everything resolves at scroll offset zero. A defect that appears only once a list is scrolled is not covered by any of the three.
Offset zero is a sampled point rather than an assumption, which it was not until
main-shell-sidebar-scrolled-to-top existed. Before it, nothing in the fixture overflowed anything:
the guarantee “every scrollable is at the top” was asserted over trees in which no element’s geometry
depended on scroll position at all — true, and about nothing. That is the same failure as the overlay
pass recording zero records, and it is worth naming as a pattern, because the two arrived
independently. A property that holds everywhere is worth exactly as much as the number of places it
can fail, and neither the code nor the fixture reports that number.
Typography
This was the largest gap and it is now closed — feature 018 ships Roboto as the application
typeface, and these checks measure against the very files it ships (assets/fonts/), both Regular
and Medium, rather than a private copy.
It did not close by itself, and the way it nearly failed is worth keeping. When 018 landed, the gate
still pointed at its own tests/fixtures/Roboto-Regular.ttf. The two files were different builds
of Roboto with different bytes — a tenth of a pixel apart over the guard’s reference string — so
the gate was measuring text against a face the application does not draw with: reproducible, stable,
and wrong. It also loaded only Regular, while TypeRole resolves weight >= 500 to Medium, so every
Medium label was measured with Regular metrics. Both are fixed; the duplicate file is deleted.
tests/layout_apparatus.rs still guards this: if a system font named Roboto ever wins the family
lookup, every measurement shifts at once. Read a mass geometry difference on one platform as a font
problem, not as a layout regression.
Path stability
An element’s identity is its depth-first index path (0/2/1), because a layout node carries no
name, type or id. Inserting a container near the root renumbers every descendant, so one correct
structural edit can produce a large diff. That is expected, not a defect. Named anchors are
re-pointed by hand as part of such a change.
Anchors
An anchor is a name for a path, so a failure reads sidebar.row.label instead of
0/0/0/2/0/0/0/2/0/0/2/0/1. Declared per covered state in tests/support/covered_states.rs.
Take the path from the resolved geometry, not from the source. Reading the widget tree and
counting children is how anchors go wrong: worktree_form.rs pushes a preview element between
the fields and the action row, so by the source the actions are child 7 of the dialog column — but
preview returns a zero-height Space when there is nothing to preview and that produces no layout
node, so they are child 6. Other zero-sized elements in the same fixture are recorded, so there is
no rule to apply here; only the resolved tree is authoritative.
Two checks run on every suite. an_anchor_whose_path_does_not_resolve_fails_naming_it catches an
anchor pointing at nothing. That is not enough on its own — an anchor can resolve and still name the
wrong element, which is a bare path that lies — so each name is additionally held to a property only
its own element satisfies: toolbar.title must measure the width of APP_NAME at type_scale::BODY
and sit before Toolbar’s full-width zero-height spacer, and every dialog.actions must be the last
child of its column with two or more controls side by side on one line. Add an anchor, add its
property.
dialog.actions deliberately has a different path in each of the five dialog states: it is the last
child of a column whose length depends on which inputs the form shows. There is no single path that
means “the actions”, which is why the check asserts the shape rather than the index.
Exemptions currently in force
Each is required to keep firing. An exemption that has stopped being needed fails and must be struck off, because a stale exemption widens a check silently — which is the failure mode these checks exist to prevent. Note what that does and does not mean: a firing exemption proves the shape is still there, not that it is still a defect. The first one below stayed green straight through the fix for the bug it was written against.
CLIP_REVEALED— seven entries, one widget. The sidebar’s collapsed filter accordion, in every state that renders a sidebar.material::Expandreports a shrunken height to its parent while its child keeps full height, so at rest a 42px child sits inside a 0px parent — then reveals it top-down by clipping to its own bounds. The overhang is the mechanism, not a defect. Unlike the other two this one does not expire; what the assertion buys is that the list cannot grow quietly, since a new entry is either a new clip-revealing wrapper or a real escape.SCROLL_CONTENT— one entry, one list. The sidebar’s scroll content inmain-shell-sidebar-scrolled-to-top, 686.8px taller than the viewport showing it. AScrollableoverhangs because its content does not fit and the user moves it; that is what scrolling is. Kept in a separate list fromCLIP_REVEALEDdeliberately — merging them would leave one list meaning “overhangs we tolerate”, which is the shapeless exemption this whole section argues against. Attribution is proven, not assumed:the_recorded_scroll_overflow_is_the_sidebar_listshows the same node is contained with three worktrees and escapes with thirty. Also does not expire — if it goes quiet the list has stopped overflowing, and the scroll sampling point is back to being asserted about nothing.- Nodes parked entirely off-window are not checked for containment.
material::NavigationDrawertranslates its inactive child by-f32::MAX / 4so the tree, node list and child list stay index-aligned without it occupying space. The exemption follows from the invariant’s own premise — a node that is nowhere paints on nothing — but it does buy silence about content pushed off-screen accidentally, which is a real defect class and a different one. KNOWN_OVERFLOWS— one chip, two paths. The tag chip labelled"Short"inside the sidebar’s collapsed filter panel wants 28.9px in the 19.2px the collapsed layout allows. Nothing paints there — the same collapsedExpandthe entry above describes — and opening the panel makes it fit exactly. Proven rather than argued:the_recorded_overflow_is_the_collapsed_filter_panelreports one overflow closed and none open. Surfaced by feature 018’s typography change, which widened the label past what the collapsed chip allows.- Two geometric differences between the schemes. Structure is identical everywhere; two boxes differ in size because a string differs in length between schemes. Each carries its reason and each must keep firing.
Working with the checks
Accepting an intended change
UPDATE_LAYOUT_SNAPSHOT=1 mise exec -- cargo test -p micold-client --test layout_snapshot
Then read the diff. It is the reviewable artefact — a reviewer who has not run the application should be able to see what moved and agree it was meant to.
The fixture is never written by a normal run. Regeneration is deliberate, and a missing fixture fails loudly rather than being recreated.
Adding a covered state
One entry in tests/support/covered_states.rs, and nothing else. That promise is enforced:
tests/layout_coverage_registry.rs scans for a second registration site and fails if it finds one.
Then regenerate the fixture, and measure what the state cost by timing the suite with and without it rather than deriving it from a resolution count. Measure warm: a first run after any edit rebuilds the test binaries, and that compile lands inside the timing. Doing it cold once put a state at 6.2s that is actually 2.1s.
The figure to expect has moved, and by a lot. The tenth state measured 2.4s and the eleventh
2.1s. The twelfth (2026-08-18, session-terminal-instance-tabs) measured 0.01s inside
layout_snapshot — 0.26s with it against 0.25s without, over three runs each — and the whole suite
moved 27.04s → 26.81s, a difference inside its own run-to-run noise. Nothing in the apparatus
changed to earn that; git log shows no functional commit to tests/support/layout.rs or
tests/layout_apparatus.rs since the eleventh was measured. The cause is not established. The
most likely subject is font loading, which the original measurements found dominant and which
depends on the host’s installed faces rather than on this repository. So treat both figures as
observations rather than as a model: measure your own state, and if it comes out near 2s rather than
near nothing, that is informative and worth recording here.
One caveat the tenth state exposed. If the screen renders a sidebar, its collapsed filter accordion is a clip-revealing wrapper, and the containment check used to need a second entry naming that state. It is now keyed by node path alone, since being a clip-reveal is a property of the widget and not of the screen — so registering really is one edit. Had that not been fixed, FR-016 would have been false in exactly the way nobody notices: the claim held for the first nine states because they were all added at once.
A second caveat, this one from the twelfth. A control that renders only above a threshold is not
covered by a state that sits below it, and nothing says so. The terminal’s instance tab strip
returns None until a session holds more than one shell instance, and the covered state that draws
the terminal’s bottom bar was built with none — so the strip was in the fixture’s scope, in the
covered set’s screen, and in no record. It was rebuilt wholesale (containers to indicator, a row
added above each tab, a fixed width for the set) and layout_snapshot.txt came out byte-identical,
which read as “nothing moved”. When you register a state, name the conditional children on the
screen and check the fixture actually contains each one; an anchor per conditional child is the
cheap way to make its absence fail rather than pass.
Reading a failure
Failures name the covered state, the element — by anchor if it has one, otherwise by path — and both
geometries. If you need to find a node from an overflow or containment report, its path is a fixture
path: look it up directly in layout_snapshot.txt.
Cost
Measured 2026-08-18 across twelve screens in two schemes: the gates are about 0.5 seconds of a
27 second suite — layout_snapshot 0.26s (the fixture, the containment invariant and the
mid-reveal pin, sharing one process), layout_text_overflow 0.15s, layout_apparatus 0.06s, and
under 0.1s for the record-format, registry and regeneration checks. Records are resolved once per
scheme and shared between checks; the naive form resolved the same views about 71 times.
These are not the numbers this section held before, which were 32s of a 37s suite with
layout_snapshot at 17.0s and eleven screens — shaping real text was measured to dominate, and on
that showing the gates were most of the suite. Both sets are real measurements on the same machine;
what changed between them is not established, and no commit to the apparatus explains it. See
Adding a covered state for the same discrepancy seen from the per-state
side, and re-measure rather than trusting either figure.
Those binaries run in parallel with the rest of the suite, so their share of the total is smaller than their sum — which is exactly why SC-006 budgets the suite rather than any one binary.
The budget is stated on the suite rather than on any one test binary — the checks share binaries, so
which binary a check lives in is an implementation detail. See SC-006 in
specs/019-layout-snapshot-parity/spec.md, which was amended once this had been measured, and
records why the original budget could not be held.