Skip to content

feat(components): close the geometry flywheel — executable ledger, fixed status, source-pointed repairs - #353

Draft
wibus-wee wants to merge 4 commits into
feat/geometry-constraint-systemfrom
geometry-flywheel-ratchet-and-repair
Draft

feat(components): close the geometry flywheel — executable ledger, fixed status, source-pointed repairs#353
wibus-wee wants to merge 4 commits into
feat/geometry-constraint-systemfrom
geometry-flywheel-ratchet-and-repair

Conversation

@wibus-wee

Copy link
Copy Markdown
Member

Related issue

Same-repository branch; no intake Issue (.github/AGENTS.md: same-repo
branches are not subject to the external contribution template).

Post (A Baseline Nobody Runs Is Just a Comment)

Every mature UI codebase has a drawer. Inside it are the two-pixel problems: an icon sitting slightly high against its label, a tab whose left edge doesn't quite line up with the one above it, a padding that's 12 where its neighbours are 10. Nobody ships a bug for these. Nobody refuses a release over them. They accumulate, and after a couple of years the product has a faint blurriness that everyone can feel and nobody can name.

We built a system that finds them automatically. It renders the app across a matrix of stories, viewports and device scales; measures every rendered box, glyph and SVG path; clusters the coordinates into alignment rails; and reports every element sitting off one. Each finding gets an identity that is structural and coordinate-free — surface, section, row family, role, index, anchor — so that the same misalignment is recognisably the same finding tomorrow, in a different locale, on a renamed fixture. A human triages each one exactly once, and that verdict lands in a ledger checked into the repo.

That was the easy half. This post is about the other half: what happened when we tried to make the ledger do something.

The ledger that asserted nothing

The ledger had 76 findings in it. Seventy-four were marked as accepted debt, each with the offset a human had measured and agreed to live with. Two had been promoted into hard contracts. And CI checked... the two contracts.

Not the seventy-four. There was no assertion anywhere that could fail because a finding drifted past the number a reviewer had signed off on. Worse, there was no assertion that could fail because a new finding appeared — a fresh misalignment could land and the build stayed green. The system remembered yesterday perfectly and had no way at all to refuse tomorrow.

This shape is extremely common and worth naming, because it isn't really about pixels. Lint baseline files, # type: ignore inventories, snapshot allowlists, "known failures" lists — all of them are review state, and review state is worth exactly what it costs to violate. If violating it costs nothing, you haven't recorded a decision; you've written a comment with unusually good formatting.

So the ledger got two assertions. The first is a ratchet: rerun the measurement pipeline and fail if any finding's |offset| exceeds its reviewed |baseline|. The second is a completeness check: fail if any measured finding has no ledger entry at all, and name the triage command in the failure message, the way a lockfile check names install. Together they say: this can get better, it can stay the same, and it cannot get worse or grow in the dark.

The one subtlety worth stealing is the tolerance. Comparing floating-point geometry needs slack, and the honest amount is one device pixel — but a finding is often merged across captures at different device scales, and a measurement taken at 1× is simply less precise than one taken at 2×. So the tolerance is 1/DPR of the coarsest capture that contributed evidence. A finding seen at both 1× and 2× is only as precise as the 1× observation, and pretending otherwise would make the gate flaky in a way that's almost impossible to debug six months later.

An un-executed number has no environment

Here is what happened the first time that ratchet actually ran.

It went red — not because anything had regressed, but because a batch of those carefully reviewed baselines had been recorded on macOS, and CI runs Linux. Of the 76 reviewed findings, only 27 reproduced on the new host at all; the rest were text-baseline and text-versus-icon rows that simply don't exist under different font rendering. Of the 27 that did reproduce, twelve were outside their baselines on arrival. One row's visual-center went from 1.75 to −4. Another went from 5.27 to 12.5. One pair had its inline-start and inline-end values cleanly swapped.

None of that was new breakage. All of it had been sitting in the file the entire time, invisible, because a number nobody executes has no environment attached to it. The moment you make review state executable it stops being a description of the product and becomes a description of the product plus the machine that measured it — and you find out immediately whether you ever knew which machine that was.

The fix is unglamorous: re-baseline onto the platform the gate actually runs on, and write down every single value you moved, so that "we re-recorded twelve baselines" can never quietly become "we relaxed the gate until it passed." That distinction is the entire difference between a ratchet and a rubber stamp, and it lives in a changelog, not in the code.

Debt needs an exit

The second problem was in the vocabulary. accepted-debt was two decisions wearing one word: "this is wrong, we'll fix it later" and "this is not actually a defect." Any queue built from that status mixes work with non-work, which is a reliable way to make sure nobody ever works the queue.

But the bigger gap was at the other end. There was no status meaning done. A finding you'd driven back onto its line had nowhere to go — it stayed marked as debt, still carrying the loose baseline from before the fix. The system protected the debt and refused to protect the repair that had just discharged it. Fix a two-pixel offset, and nothing stopped it drifting back to two pixels the next week.

So: split the ambiguous status into two honest ones, and add fixed, whose baseline is the offset it was just measured at — near zero, which makes it the strictest row in the whole file. A repair, once made, becomes the tightest constraint the system has, rather than the loosest.

Two details made this work rather than merely exist.

The only way in is through the gate. The command that marks something fixed reruns the entire ratchet, not a targeted check on the one finding. "Did this repair land?" and "did it cost something somewhere else?" are the same measurement, taken once. A verification path that's cheaper than the gate is a verification path that disagrees with the gate.

Migration doesn't guess. All 74 existing entries moved to debt, none to wont-fix. The distinction is a human review; inventing it during a migration would have written down 74 decisions nobody made, in a file whose entire value is that its contents were decided by someone.

There's also a trap here that generalises to any state machine layered over a set of guarantees. In the first draft, a promoted finding — one backed by a hard contract — was allowed to transition to fixed. That sounds fine and is in fact terrible: promoted entries are what compile into contracts, so marking one as fixed would remove the tightest rule in the file, and the build would stay green while doing it. Any transition that can silently weaken your strongest guarantee needs to be blocked explicitly, because by construction nothing downstream will notice.

A bug report a machine can act on

These reports are increasingly read by agents, not people, and that changes what "a good bug report" means.

A finding used to describe its element as div[role=button][data-slot=…]. A human can paste that into devtools and go hunting. An agent handed that string and told to fix the padding has been given a sentence with no verb — it names a rendered artefact, not a place in the source.

React 19 removed _debugSource, so there's no file and no line number to recover. But there is still a fiber tree, and in a dev build it's readable from the DOM: every rendered node carries a __reactFiber$… key. Walk fiber.return upward, skip host nodes, unwrap the wrapper objects React puts in type's place — memo stores the inner component on type.type, forwardRef on type.render — prefer an explicit displayName, and take the first ancestor whose type is a function. That name, plus the element's verbatim class attribute, is the whole pointer. On our tree, 3389 of 3429 measured nodes come back with one.

The report line goes from "trailing edge: padding is 12 here against 10 on the reference" to a component name, a CSS property that actually exists on that edge, a delta, and the class list to grep for. That's the difference between a symptom and a work order.

The principle underneath it matters more than the trick: evidence must never become identity. The component name and the class list are attached to findings as labels, and they are rigorously excluded from the finding's key — the same rule that already applied to accessible names. If a rename or a locale switch could change a key, then every refactor mints phantom "new" findings and silently orphans every review attached to the old ones. Anything you gather to make a report legible is, almost by definition, exactly the sort of thing that changes for reasons unrelated to the defect. Keep it out of the fingerprint.

The honest limitation: the first function component above a node is frequently a library primitive rather than one of ours. Still a better pointer than a bare DOM string, still not the file you wanted.

Count defects, not symptoms

The report deduplicated cards by label prefix. So one wrong padding applied to ten rows produced ten cards, and the length of the queue measured blast radius rather than defect count. A queue like that can't be prioritised, because its biggest item is whatever touched the most elements, not whatever is most wrong.

The fix is to give each finding a repair identity: the owning component, the box-model term, the edge, and the node the delta actually comes from. Findings sharing one repair identity are one job. The report folds them into a single card that names every finding it stands for, and the verification command can close an entire group in one run.

Two constraints kept this from being cosmetic. First, the repair identity is computed in the findings stage, not in the view layer — the report isn't the only consumer, and anything that decides meaning has to live upstream of everything that needs the meaning. Second, folding never crosses a review status: hiding an untriaged finding underneath a reviewed one is a review that never happened, and a grouping feature that can swallow un-reviewed work is worse than no grouping at all. Findings themselves never merge; keys, baselines and evidence are untouched. Only the card folds.

What it costs, and what you can't subsample

Capturing the plan — twenty stories across viewports, scales, themes and locales — takes 345.9 seconds. Turning those captures into observations and then into findings takes 1.0 second. The part of this system that reasons runs in a second; everything else is a browser starting up and laying out pages.

Which raises the obvious optimisation, and the obvious answer to it. Could the gate measure six stories instead of twenty and run four times faster? No — because a finding's offset is the mean over every capture that produced evidence for it, and that mean is precisely what the reviewed baselines record. A subset doesn't produce a cheaper version of the same numbers, it produces different numbers, against which the reviewed baselines mean nothing at all. You'd be ratcheting a quantity no human has ever looked at, which is strictly worse than not ratcheting: it has the appearance of rigour and none of the content.

If the runtime becomes a problem, the honest fix is to move the ratchet to its own CI job. It is never to shrink the input to a statistic and keep the statistic.

The point

The original slogan for this system was discovery can be clever; validation must be dumb — the measurement side is allowed heuristics, clustering and inference, but the gating side must be a mechanical comparison a human decided once and a machine repeats forever.

Working through these four gaps, I'd add a second half to it. A system that can only ever add rules isn't a type system, it's a checklist. What makes it a ratchet is having the full set of transitions: a way to record debt honestly, a way to distinguish debt from a non-defect, a way to mark a repair and then hold it to the tighter line, and a way to prove a rule has become redundant before deleting it.

The ledger currently reads 74 debt, 0 fixed. For the first time, that first number is capable of going down — and the second one, once it moves, can't quietly move back.

Details

Problem / pressure

The geometry constraint system had a flywheel that did not close. Four
specific holes:

  1. geometry-ledger.json was never executed. CI ran only
    chat-workspace-geometry.spec.ts, which checked that the two promoted
    entries still compiled into geometry-contracts.json and still held. No
    assertion could fail because a finding drifted past its reviewed
    baseline.offset, or because a finding appeared that no entry reviews.
    74 of 76 entries constrained nothing at all; the pipeline itself
    (capture → observation → findings) only ran inside the 40-minute,
    80-screenshot report, which is deliberately not in CI.
  2. accepted-debt was two decisions under one word — "wrong, fix later"
    and "not a defect" — so any queue built from it mixed work with non-work.
    There was no status for the other end either: a finding driven back to its
    line stayed accepted-debt at its old loose baseline, so the fix it had
    just received was protected by nothing.
  3. A repair ticket named a rendered DOM string. GeometryRepairTerm.element
    was div[role=button][data-slot=…] — no class, no component — so an agent
    handed a finding could not locate the source.
  4. The report deduplicated by LABEL, groupLabel.split(' · ')[0] plus a
    member-offset signature. Ten rows sharing one wrong padding produced ten
    cards, so the queue's length measured how many elements a defect touched
    rather than how many defects there were.

Summary

Four commits, one per hole.

1 — feat(components): gate the geometry ledger baseline in CI

The capture walk the report already performed is extracted to
tests/e2e/support/geometry-capture-plan.ts. GEOMETRY_CAPTURE_PLAN is now
the ONE list of the 20 captures, showGeometryCaptureStory the one way to open
and settle a story, buildGeometryCapture the one way to turn discovery into a
capture record, and runGeometryFindingPipeline the one place the three stages
run. The report walks it to take screenshots; the gate walks it to measure and
nothing else. No screenshot code reaches the gate, and no capture list is
written twice.

The new gate test reruns that pipeline and fails when

  • a finding's |offset| passes |baseline| plus one device pixel, where
    the device pixel is 1/DPR of the coarsest capture the finding merged
    (a finding merged across a 1× and a 2× capture is only as precise as the 1×
    one), or
  • a finding has no ledger entry at all, with a message pointing at
    pnpm geometry:triage the way a lockfile check points at an install.

ignored opts out; promoted is left to the contract check that already gates
it exactly, rather than being gated twice and loosely.
checkGeometryLedgerRatchet, geometryFindingDevicePixel and
formatGeometryRatchetViolations are pure and unit-tested.

2 — feat(components): split accepted-debt and close the loop with a fixed status

GeometryLedgerStatus becomes new | debt | wont-fix | fixed | ignored | promoted. All 74 entries migrate to debt — the debt/wont-fix distinction is
a review, and guessing it here would record decisions nobody made — and
triage records debt for the same reason. Only promoted compiles into a
contract; debt, wont-fix, fixed and ignored compile into nothing, now
stated in code and in tests/e2e/AGENTS.md.

New pnpm geometry:verify-fix <dir> <findingKey...>. It reruns the ratchet
gate itself — same plan, no screenshots — so "did this fix land" and "did it
cost something elsewhere" are one measurement. A finding measured back inside
one device pixel, or absent from findings entirely, becomes fixed at its new
baseline; anything else prints why and leaves the ledger alone. A fixed entry
is then the strictest row in the file, so reopening it is a ratchet failure.
The decision is made in TypeScript (verifyGeometryFixes) beside the ratchet;
the .mjs only applies the file the gate writes, exactly as triage does.

3 — feat(components): point geometry repairs at their source

Observation now records two more fields per box-model node: the class
attribute verbatim, and the nearest function component above it in the React
fiber tree. src/lib/geometry-react-fiber.ts walks fiber.return, unwrapping
the memo/forwardRef objects React puts in type's place and preferring an
explicit displayName. It is closure-free like every other function capture
serializes into the page, and adds no dependency. React 19 dropped
_debugSource, so a component NAME is the whole pointer — no file, no line.

Both fields are optional LABELS riding the repair term and proposal; neither
reaches a finding key, for the same reason an accessible name may not. A node
React never rendered contributes none and nothing fails.

Report repair text is now one executable sentence per term:

SidebarRowShared 里 div[role=button] 的 padding-inline-end 多 2px(class: pe-3)

geometryRepairCssProperty names the property that actually exists on each
edge: padding-inline-end, border-inline-start-width, and column-gap /
row-gap for a gap, which belongs to an axis rather than to one of its edges.

4 — feat(components): fold geometry cards by repair identity

A css-defect finding gets a repairGroup in the findings stage:
(component ?? common ancestor description, term, edge, dominant element).
Findings do not merge — keys, baselines, evidence and the ratchet are
untouched. The report folds cards on it, one card per repair, naming the
findings it stands for, and folds strictly inside one ledger status: a
new finding hidden under a reviewed one would be a review that never
happened. geometry:verify-fix takes --repair-group=<id> to close a whole
group in one gate run.

Before / after

Before After
pnpm test:geometry: 9 tests, 2.6 min, ledger baselines unread 10 tests, 7.2 min, every measured finding held to its baseline
A new finding appears → CI green CI fails, naming the finding and pnpm geometry:triage
74 × accepted-debt, no way to record a fix 74 × debt, plus wont-fix / fixed and geometry:verify-fix
修复建议(结束边):padding 本项 12 vs 参照 10(Δ2px;差值来自本项的 div[role=button]) SidebarRowShared 里 div[role=button] 的 padding-inline-end 多 2px(class: pe-3)
One card per finding; N rows of one defect = N cards One card per repairGroup, listing the folded finding keys
修复建议…(class: group relative flex h-7 max-w-[180px] …) inside a one-line summary the class list stays in the card body; the summary line drops it

CI time budget

Measured on this machine (Debian 12, Playwright Chromium, Storybook already
warm), with an isolated run of the plan and no screenshots:

Phase Time
capture — 20 stories, page.evaluate per capture 345.9 s
observation + findings (pure, in Node) 1.0 s
pnpm test:geometry before (9 tests) 2.6 min
pnpm test:geometry after (10 tests) 7.2 / 8.5 / 10.5 min across three runs (ratchet ≈ 4.6-6 min)

That more than doubles the geometry gate, so it is worth saying why the plan
was not trimmed to a subset of stories. A finding's offset is the mean
over every capture that produced evidence for it
, and that is exactly the
number the ledger baselines record. Measuring 6 stories instead of 20 does not
produce a cheaper version of the same numbers; it produces different numbers,
against which the reviewed baselines mean nothing. Enforcing a subset would be
a ratchet on a quantity no human ever reviewed. The Tests job's timeout is 30
minutes and this leaves it comfortably inside; if the budget becomes a problem,
the honest fix is to split the ratchet into its own job, not to shrink the
plan.

Test plan

  • pnpm exec tsgo --noEmit — clean. Note: packages/components/tsconfig.json
    has include: ["src/**/*"], so pnpm typecheck never typechecks tests/**.
    The geometry test and spec files were typechecked with an ad-hoc project that
    adds tests/**; they are clean. Flagged below.
  • pnpm exec vitest run tests/geometry-constraint-system.test.ts tests/chat-workspace-geometry.test.ts125 passed.
  • pnpm test:geometry10 passed on the final branch (8.5 min).
  • pnpm geometry:report /tmp/geo-report — full report regenerated: 20
    captures, 27 details, 78/80 screenshots, 9.4 min. The report's
    findings.json and the gate's are identical
    — same 27 keys, zero offset
    differences — which is the check that the extracted plan really is the walk
    the report used to do inline.
  • Component pointers resolve on the real tree: 3389 of 3429 captured box-model
    nodes carry a component name and 3247 carry a class list.

Worth stating plainly: on the current tree the two css-defect findings land
in two different repair groups, so nothing actually folds today. The
mechanism is unit-tested and starts paying the moment one wrong padding shows
up on several rows — which is the shape the 74 debt entries had before the
sidebar rail work landed.

  • pnpm lint (0 errors) and pnpm format after every commit.

New unit coverage, all deterministic (no sleeps, no wall clock, fixture
artifacts only):

What Where
ratchet: one device pixel of slack and no more, message content tests/geometry-constraint-system.test.ts
ratchet: unreviewed finding names geometry:triage ditto
ratchet: ignored skipped, promoted left to the contract check ditto
ratchet: tolerance from the coarsest evidence capture ditto
ratchet: fixed held to its near-zero baseline ditto
verifyGeometryFixes refuses a promoted entry (its contract gates it exactly) ditto
verifyGeometryFixes: pass re-baselines to fixed; fail leaves the ledger identical; absent finding is a fix at 0; no ledger entry refuses ditto
compileGeometryContracts compiles nothing from debt / wont-fix / fixed ditto
triageGeometryFindings defaults to debt and never moves a baseline ditto
repairGroup survives a different label, value and dominant side; changes with component / term / edge; absent with no terms ditto
repairGroup reaches the finding through createGeometryFindings while its key is byte-identical to alignmentFindingKey ditto
geometryRepairCssProperty / formatGeometryRepairProposal sentences, including the no-component fallback and the class-list-free summary form ditto
fiber component name: walks return, unwraps memo/forwardRef, prefers displayName, returns nothing for host/anonymous, bounded on a cyclic chain, finds __reactFiber$… tests/chat-workspace-geometry.test.ts

Findings not fixed here

Recorded rather than acted on, per the task's scope limits (no discovery,
threshold, identity or report-visual changes).

  1. Ledger baselines are platform-specific, and these were recorded on a
    different one.
    Running the plan here reproduces 27 of the 76 reviewed
    findings; the other 49 (mostly text-baseline and text-vs-icon
    visual-center rows) do not appear at all under Linux font rendering. Of
    the 27, 12 baselines were moved in commit 1 so the ratchet starts green
    on the platform CI runs:

    surface anchor label was now
    right-sidebar inline-end Files 4 6.5
    right-sidebar inline-start Files 6.5 4
    right-sidebar visual-center Conversation Diff −1 −1.5
    right-sidebar visual-center Docs 2 + 100 − 20 0.75 1
    right-sidebar visual-center app.tsx −1 −1.5
    session visual-center Private to you: … −1.25 −1
    workspace visual-center Machine −4.0769… −4
    workspace visual-center Machine 1.75 −4
    workspace visual-center Message 5.2692… 12.5
    workspace visual-center More actions ×3 −0.818 / −0.818 / −0.75 −1

    The 49 unreproduced entries keep their reviews untouched — they are simply
    not enforced here. The residual risk is that GitHub's ubuntu-latest fonts
    differ from this Debian host: entries that reappear there would be checked
    against macOS baselines, and genuinely new keys would trip the
    unreviewed-finding rule. The first CI run is the measurement; the fix in
    either case is geometry:report + geometry:triage on the CI platform.
    Note the Files pair swapping inline-start and inline-end values, which
    is worth a look on its own.

  2. The report is fragile against concurrent edits. Storybook's Vite dev
    server watches the whole package, tests/** included, so saving any file
    during a report run triggers a full iframe reload and the next
    page.evaluate dies with "Execution context was destroyed". Three runs
    failed this way before the cause was clear. Not introduced here, but it
    makes the 40-minute report much more brittle than it looks.

  3. pnpm typecheck does not cover tests/**. packages/components/tsconfig.json
    includes only src/**/*, so no spec or unit test in this package is
    typechecked by pnpm check. Turning it on today surfaces a large number of
    pre-existing errors in unrelated tests, so it was left alone.

  4. groupLabel.split(' · ') is still in createReportDetails. It is
    inseparable from the measurement-model-divergence verdict it feeds — N
    instances with identical member offsets are one measurement-model
    divergence — and the single card it emits is that verdict's card, not a
    second dedup. Removing it would emit N identical cards with nothing to fold
    them. Card folding for findings is now repairGroup, which is where the
    real duplication was.

  5. Component names often resolve to a library wrapper. The walk takes the
    first typeof fiber.type === 'function' ancestor as specified, which on this
    tree is frequently a Radix internal — Primitive.div,
    Primitive.span.SlotClone — rather than a Lody component. It is still a
    better pointer than a bare DOM description, and skipping library primitives
    would be a change to the specified rule, so it is recorded rather than done.

  6. y-axis-parity.json no longer exists anywhere in the tree but was still
    named in tests/e2e/AGENTS.md and as "the parity artifact" in
    tests/e2e/support/AGENTS.md. Both references were corrected while editing
    those sections.

Context handoff

Instructions for reviewing agents

  • Review focus: checkGeometryLedgerRatchet / geometryFindingDevicePixel
    (the whole gate rests on them), geometry-capture-plan.ts against the
    capture walk it replaced in chat-workspace-geometry-report.spec.ts, and the
    12 moved baselines in geometry-ledger.json.
  • Decisions to challenge: running all 20 captures in CI (+4.6 min) rather
    than a subset; comparing |offset| against |baseline| + one device pixel
    taken from the coarsest capture; moving 12 baselines to this platform's
    rendering instead of leaving the gate red; folding report cards only inside
    one ledger status.
  • Plausible failures / evidence gaps: the baselines were re-recorded on
    Debian 12 and CI runs ubuntu-latest, so the first CI run may still report
    regressed or unreviewed findings; the fiber component name is only exercised
    against Storybook's dev build and often lands on a Radix wrapper; the report
    was verified by regeneration and an artifact diff against the gate, not by
    image diff.

Authoring context

  • User goal / directives: close four named gaps in the geometry constraint
    system — make the ledger executable in CI, split accepted-debt and add
    fixed, put source pointers on repair tickets, and deduplicate report cards
    by repair rather than by label — without touching discovery, thresholds,
    identity rules or report visuals.
  • Constraints / non-goals: no changes to the discovery algorithm,
    tolerances or finding identity; new fields must be labels/evidence and must
    never enter a finding key; no new dependency (explicitly no react-grab); the
    capture/observation/findings stages must be shared with the report rather
    than copied; every AGENTS.md stays under 8 KiB.
  • Risk-bearing decisions: twelve reviewed baseline.offset values were
    moved to the platform the gate runs on. Statuses, reasons and identities were
    not touched, and every moved value is listed above; without this the new gate
    would be red on arrival.
  • Destructive or irreversible behavior: geometry:verify-fix rewrites
    geometry-ledger.json in place, and only when every requested finding passed
    — a failed verification prints why and writes nothing. geometry:triage
    still never moves a baseline. Both are local developer commands; CI runs
    neither.
  • Deliberately not done or tested: the report's --after replay path and
    the pixel-witness pass were not re-verified beyond a full report
    regeneration; the ratchet was not run on GitHub Actions before this PR; the
    tests/** typecheck gap and the report's HMR fragility were recorded, not
    fixed.
  • Unknowns / confidence: high confidence in the pure logic (unit-tested)
    and in the refactor (the report regenerates and the gate passes); moderate
    confidence that the re-baselined ledger is green on ubuntu-latest, which
    the first CI run settles.
🤖 Generated with [Claude Code](https://claude.com/claude-code)

A ledger baseline was a number the report PRINTED and nothing executed:
`pnpm test:geometry` only checked that the two promoted contracts still
compiled and held, so an `accepted-debt` finding could drift arbitrarily
far from its reviewed offset, and a brand new finding could appear, with
every check green. The ledger's 74 debt entries constrained nothing.

Extract the capture walk the report already performs into
`tests/e2e/support/geometry-capture-plan.ts`: `GEOMETRY_CAPTURE_PLAN` is
now the ONE list of captures, `showGeometryCaptureStory` the one way to
open and settle a story, and `buildGeometryCapture` the one way to turn
discovery into a capture record. The report walks it to shoot its
screenshots; the gate walks it to measure and nothing else. Neither can
name a story, a viewport, a device scale or an aggregate scope the other
does not.

Add the ratchet to the gate: rerun `capture -> observation -> findings`
over that plan with no screenshots, then fail when

- a finding's |offset| passes |baseline| plus one device pixel (1/DPR of
  the coarsest capture it merged), or
- a finding has no ledger entry at all, pointing at `geometry:triage` the
  way a lockfile check points at an install.

`ignored` opts out and `promoted` is left to the contract check that
already gates it exactly. `checkGeometryLedgerRatchet` and
`geometryFindingDevicePixel` are pure and unit-tested; the failure
message carries key, label, status, baseline and current offset.

A finding offset is a mean over the whole plan, so a baseline belongs to
the platform that recorded it. These were recorded on macOS and the gate
runs on Linux, so 12 of the 27 findings this plan reproduces here are
re-baselined to the Linux rendering in this commit; the 49 entries that
no longer reproduce keep their reviews untouched.

Verified: `tsgo --noEmit`, unit tests (106 passed), `pnpm test:geometry`
(10 passed, 7.2m; the ratchet adds ~4.6m over the previous 2.6m gate).

Model: claude-opus-5[1m]
… status

`accepted-debt` carried two different decisions under one word — "this
is wrong and we will fix it later" and "this is not a defect" — so any
queue built from it mixed work with non-work, and 74 of the ledger's 76
entries sat in it. And there was no way to record the other end of the
flywheel at all: a finding whose offset had been driven back to its line
stayed `accepted-debt` at its old, loose baseline, so the fix it just
received was not protected by anything.

- `GeometryLedgerStatus` becomes `new | debt | wont-fix | fixed |
  ignored | promoted`. All 74 existing entries migrate to `debt`; the
  distinction between debt and wont-fix is a review, and guessing it
  here would record decisions nobody made. `triage` records `debt` for
  the same reason.
- `fixed` is baselined at what was measured, which makes it the
  strictest entry in the file: the ratchet then treats a reopened fix as
  the regression it is.
- Only `promoted` still compiles into a contract; `debt`, `wont-fix`,
  `fixed` and `ignored` compile into nothing, now stated in code and in
  `tests/e2e/AGENTS.md`.
- Add `pnpm geometry:verify-fix <dir> <findingKey...>`. It reruns the
  ratchet gate itself — same capture plan, no screenshots — so "did this
  fix land" and "did it cost something elsewhere" are one measurement.
  A finding measured back inside one device pixel, or gone from findings
  entirely, becomes `fixed` at its new baseline; anything else prints
  why and leaves the ledger alone. The decision is made in TypeScript
  (`verifyGeometryFixes`) beside the ratchet, and the script only
  applies the file the gate writes, exactly as triage does.
- A `promoted` finding is refused: it is gated EXACTLY by its contract,
  and moving it to `fixed` would stop compiling that contract and
  quietly drop the tightest rule in the file. Retiring a contract stays
  a deliberate step.

The report's status chips and default filter follow: the work queue is
new + changed + css-defect + promoted, minus `wont-fix` (a decision) and
`fixed` (already done).

Verified: `tsgo --noEmit`, unit tests (111 passed), lint, format.

Model: claude-opus-5[1m]
`GeometryRepairTerm.element` was a rendered DOM description —
`div[role=button][data-slot=…]` — which a designer recognises and an
agent cannot open. Nothing in a finding said which component wrote that
node, so the repair text told a reader the size of the problem and
nothing about where to make the edit.

Collect two more fields for every box-model node during observation:
the `class` attribute verbatim, and the nearest function component above
it in the React fiber tree. `geometry-react-fiber.ts` walks
`fiber.return`, unwrapping the `memo` and `forwardRef` objects React
puts in `type`'s place and preferring an explicit `displayName`; it is
closure-free like every other function capture serializes into the page,
and it introduces no dependency. React 19 dropped `_debugSource`, so a
component NAME is the whole pointer — there is no file or line to read,
and guessing one would be worse than none.

Both fields are optional and both are LABELS: they ride the repair term
and the proposal, never a finding key, for the same reason an accessible
name may not — renaming a component is not a new finding. A node React
never rendered simply contributes none and nothing fails. A report run
resolves a component for 3389 of 3429 captured nodes and a class list
for 3247.

The report's repair text becomes one executable sentence per term,
built by the pure `formatGeometryRepairProposal`:

  SidebarRowShared 里 div[role=button] 的 padding-inline-end 多 2px(class: pe-3)

`geometryRepairCssProperty` names the property that actually exists on
each edge: `padding-inline-end`, `border-inline-start-width`, and
`column-gap`/`row-gap` for a gap, which belongs to an axis rather than
to one of its two edges.

A real class list runs to hundreds of characters, so only the card body
carries it; the collapsed one-line summary omits it rather than truncate
it, because half a class list greps for nothing.

Verified: `tsgo --noEmit`, unit tests (120 passed), lint, format.

Model: claude-opus-5[1m]
The report deduplicated by LABEL: `groupLabel.split(' · ')[0]` plus a
member-offset signature, which collapses two instances of one named
marker group and nothing else. Ten discovered rows that share one wrong
padding still produced ten cards, so the queue's length measured how
many elements a defect touched rather than how many defects there were.

Give a `css-defect` finding a `repairGroup` in the FINDINGS stage — the
component that owns the edit (or, where React rendered no component, the
common ancestor's DOM description), the declared term, the edge, and the
node the term sits on. It is computed from the repair proposal, so a
second row of the same component reporting the same edit lands in the
same group whatever its label, its measured value or which of the two
compared sides dominated.

Findings do not merge. Keys, baselines, evidence and the ratchet are
untouched: `repairGroup` is a grouping LABEL, and it never reaches a
key, exactly like the component name it is built from.

The report folds cards on it — one card per repair, naming the findings
it stands for — and folds strictly inside one ledger status, because a
`new` finding hidden under a reviewed one would be a review that never
happened. The `groupLabel` signature stays where it is: it is what
decides that N instances with identical member offsets are one
measurement-model divergence, which is a verdict rather than a dedup,
and its single card is that verdict's card.

`geometry:verify-fix` takes `--repair-group=<id>` so one edit can be
verified and closed for every finding it covers in one gate run.

Verified: `tsgo --noEmit`, unit tests (125 passed), lint, format.

Model: claude-opus-5[1m]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants