feat(components): make a Y rail's row geometric, with the DOM row as an evidence prior - #352
Conversation
A Y rail was "ONE row instance", so the DOM row was an eligibility test: a control rendering outside the DOM row of the things it lines up with could not be compared to them at all, and every cross-structural misalignment was invisible by construction rather than by measurement. `session.topbar` reporting `candidateCount: 33, rails: []` is that rule, not a property of the page. The row is now GEOMETRIC. `assignGeometricRows` puts an extent on the line whose MEDIAN band it overlaps by half — to the median band, never neighbour to neighbour, because a ladder of half-overlaps would chain two lines of different heights into one, exactly as chaining intermediate coordinates would merge two indentation levels into one X rail. The threshold is the "at least half" a row band already asked of a slot, now named as `GEOMETRY_ROW_BAND_OVERLAP`. `selectVisualRowSlots` keeps the literal because capture serializes it into the page, where a module binding does not exist; a unit test rebuilds both serialized helpers outside this module so that scope is checked here rather than ten minutes into a report run. The DOM row stays, as a PRIOR that sets the evidence bar rather than eligibility. `discoverBlockAlignmentRails` returns both kinds, tagged: - `row-instance` is the rule that has always decided a Y rail, byte for byte — same grouping by (rowId, anchor), same `reachesLine` filter, same median, same tolerance. Two members, any anchor, one capture. - `cross-family` is what a geometric row still owes an explanation for: primitives no row-instance rail measures, from two or more (row family, scope) pairs, at `visual-center` only, three members at least and two of them reaching the line. Same claim, weaker evidence. ONE element, ONE rail, and the DOM prior WINS: an element a row-instance rail already measures never joins a cross-family one, so the rails that exist today keep their members and their lines. The parity oracle is a unit test — the same rows discovered with and without the singletons only a geometric row can group are deep-equal. Model: claude-opus-5[1m]
…pture repeats it A `cross-family` rail's whole support is that primitives no DOM structure relates happen to agree on a line, and one capture agreeing is a coincidence. So a capture only PROPOSES one, and an outlier becomes a finding when the SAME member set forms the rail again in another capture and the same member leaves the line in the same direction. Members are matched by structural identity, never by a coordinate: a line that moved as a whole is still the same line. - `observeGeometryCaptures` passes each Y candidate's declared scope into discovery, which is half of what decides a rail's evidence bar. - `collectGeometryCrossFamilyProposals` labels and identifies every proposed rail once, so a report card prints exactly what a finding would — one labelling pipeline, not a second one that could disagree about a member's name, its offset, or the key it would carry. The repeated-row labelling closure moves to module scope for it. - The per-row Y path takes `row-instance` rails only. A cross-family rail never picks a verdict anchor and never reaches the one-capture path; it is aggregated across captures at `visual-center` or not at all. - `GeometryFindingKind` gains `cross-family`, and `alignmentFindingKey` appends that term. The outlier is the same element a row Y finding would name, so the kind is what keeps the two questions apart. No coordinate, scope name or accessible name enters the key, and every existing key is unchanged. - Marker parity and marker-removal readiness read `row-instance` rails only: a marker rule names the members of one DOM row, so letting a weaker cross-family match stand in would report a marker as removable on evidence the marker never had. Model: claude-opus-5[1m]
A cross-family finding is a Y finding, so it takes the same card: `createFindingBlockDetail` draws its guide across the whole capture instead of between its outermost members — a line drawn only between them would read as a DOM row that happens to be wide — and dashes it, so a candidate line is never mistaken for a row median. A rail one capture proposed but no second capture confirmed becomes a `cross-family-candidate` detail. It is shaped as the finding it is not yet and passed through that SAME annotator, so every number it prints is the rail's own measurement of that member rather than a second pipeline's opinion; it carries no finding key, no ledger status and no classification, because nothing has reviewed it. Proposals share the existing deviation order and `MAX_Y_FINDING_CARDS` screenshot budget rather than asking for one of their own. Model: claude-opus-5[1m]
…d translateY Same shape as the row-alignment gate: strip every alignment marker, discover, inject a `translateY` this repository owns, discover again, and diff. The probe is any aligned icon on any tight cross-family geometric row rather than a named header control — naming one would make the gate about a product surface instead of about the rule. Asserts the injected element is the only one added to its rail's outliers, at the offset that was injected, and that no row-instance rail changed at all: the DOM prior decides the evidence bar, so a cross-family probe must not reach a DOM row. Model: claude-opus-5[1m]
Replace "A Y rail is ONE row instance" with the rule that replaced it: the row is geometric, assignment is to the row's median band rather than neighbour to neighbour, and the DOM row is a prior that sets the evidence bar instead of a gate on eligibility. Names the two bars (`row-instance`, `cross-family`), the same-family priority that keeps today's rails intact, and where a single capture's proposal lives. Model: claude-opus-5[1m]
Capture emitted Y candidates only for primitives inside a DETECTED DOM row, so the DOM row was still an eligibility test one layer before discovery: a control the row detector rejected could not be compared to anything vertically, whatever the rules downstream said. With it, the geometric row had nothing to group — at most one unclaimed element per capture, and zero cross-family rails in the whole report. An atom now reports the same five anchors a row member does. Its row id is `visual-atom:<primitiveId>`, not the coordinate-derived id its X candidates carry: two atoms whose centre and left edge round alike would otherwise share a row and be compared at the two-member bar — the one place a single capture is evidence enough — on a coincidence rather than on any structure relating them. That happened, and it minted a `row-spread` finding for the permission dialog's Submit label and icon; naming the row after the primitive removes it and changes nothing else. Each atom is therefore alone in its row and can never reach the row-instance bar by itself. Every existing row-instance rail keeps its members, its line and its key. Model: claude-opus-5[1m]
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5940ec34cd
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| row.centers.push(center); | ||
| row.heights.push(height); |
There was a problem hiding this comment.
Prevent the median band from drifting across rows
For a ladder of 16px-high extents centered at 8, 16, 20, and 24, the first two establish a band centered at 12, the third joins and moves its median center to 16, and the fourth then joins even though it has zero overlap with the first extent. Mutating the reference band after every accepted member therefore still permits neighbor-to-neighbor chaining, which can merge distinct visual lines and suppress or misassign cross-family outliers; keep the row's comparison band stable or revalidate the completed row against a non-drifting reference.
AGENTS.md reference: packages/components/src/lib/AGENTS.md:L99-L103
Useful? React with 👍 / 👎.
|
Superseded by #354, recreated under the intended author account and included in Stack #355. |
What this changes
A Y rail was "ONE row instance", so the DOM row was an eligibility test: a control
rendering outside the DOM row of the things it lines up with could not be compared to them
at all. Every cross-structural misalignment was invisible by construction rather than
by measurement.
The row is now geometric, and the DOM row is a prior that sets the evidence bar.
row-instancecross-familyvisual-centeronlycross-familyassignGeometricRowsassigns an extent to the line whose median band it overlaps byhalf — to that band, never neighbour to neighbour, because a ladder of half-overlaps would
chain two lines of different heights into one, the same failure
intermediate coordinates must not chain distinct indentation levelsnames on X.row-instancerailmeasures never joins a
cross-familyone.row still gated eligibility one layer earlier and the new path was dead code (≤1 unclaimed
element per capture, 0 cross-family rails). Its row id is
visual-atom:<primitiveId>, notthe coordinate-derived id its X candidates carry — see Findings during the work.
Acceptance evidence
Baseline:
geometry:reportongeometry-flywheel-ratchet-and-repair@faf5969(this PR'sbase). Head: the same command on this branch.
Existing findings are byte-identical.
finding-diffresolved=48is identical on both sides, so it is pre-existing ledger drift on the basebranch, not introduced here.
Rails. All 798
row-instancerails match the base exactly — samerowId,anchor,line,sampleSize,support, and per-member coordinate and outlier flag — verified railby rail, not just in aggregate. Plus 12 new
cross-familyrails.The 12 cross-family rails, and why they produce no finding. All 12 are the same
geometric row in
main.chat-landing, one per workspace capture — the landing composer'sproject chip, whose three primitives come from three different row families that no DOM row
relates:
They are aligned, so there is no outlier and therefore no finding — the rule fires,
measures, and reports agreement. Each is a
cross-family-candidatecard in the report.session.topbar— correcting the premise. ThecandidateCount: 33, rails: []in thetask description is the X rail count for that scope; this PR does not touch the X axis,
so it is 0 before and after. On the Y axis those controls were never invisible: all 11
primitives share one DOM row and already formed 3 rails, unchanged by this PR:
So the acceptance criterion "a
session.topbargeometric row forms at least one rail" holds,but it held before this PR too. The real gap the architecture change closes is elsewhere, and
the honest measured result is the 12 cross-family rails above.
Findings during the work
A serialized helper closed over a module binding.
installGeometryBrowserHelpersships
selectVisualRowSlotsto the page as source, so naming the newGEOMETRY_ROW_BAND_OVERLAPconstant in its default made every capture die withReferenceError: GEOMETRY_ROW_BAND_OVERLAP is not defined. The helper keeps the literal;a unit test now scans each serialized helper's source for every exported name, so this
fails in one second instead of ten minutes into a report run. Verified by reintroducing
the closure and watching the test fail.
Coordinate-derived row ids can collide. Giving atoms Y candidates under the
coordinate-derived id their X candidates use put the permission dialog's
Submitlabeland icon in one row purely because their centre and left edge rounded alike — minting a
row-spreadfinding at the two-member bar, the one place a single capture is evidenceenough, on a coincidence rather than on structure.
visual-atom:<primitiveId>removes itand changes nothing else. The 4.5px spread inside that button is real but is only visible
through that coincidence; catching it properly means the row detector accepting narrow
rows, which is out of scope here.
Three sibling
AGENTS.mdfiles are at the 8 KiB cap. Fitting the new invariant meantsplitting it by ownership — the contract (two bars, priority, key) in
tests/e2e, themechanism (median band, no chaining, atoms) in
src/lib— and compressing wording in thebase branch's prose. Those edits are wording only; every invariant is preserved. Worth a
look since some of that text is this base branch's. Final sizes:
tests/e2e8188,src/lib8164.Verification
pnpm --filter @lody/components typecheck— passes.pnpm lint— 0 errors.priority, a two-member row spending its members, cross-family needs three from two
families, non-overlapping extents, DPR snap,
visual-centeronly, and the parityoracle (the same rows discovered with and without the singletons only a geometric row
can group are deep-equal).
pnpm test:geometry— 11/11 pass, including this base branch'severy measured geometry finding stays inside its reviewed ledger baseline, the existingvertical row alignment is discovered without geometry marker attributes, and the newa cross-family geometric row reports its outlier and no DOM row.pnpm format— no changes.pnpm checkfails on 677 pre-existingact is not a functionfailures across 134.tsxtest files. Verified unrelated: the same tests fail identically onorigin/geometry-flywheel-ratchet-and-repairwith none of this PR's commits, and onfeat/geometry-constraint-system@5036a11. Not fixed here.Review focus
row-instancerails are untouched: theparity oracle is a unit test, and the 798-rail comparison above is the empirical check.
visual-atom:<primitiveId>for atom Y candidates is the right call, or whether theSubmitspread should be caught by widening row detection instead.tests/e2e/AGENTS.md, which touch this base branch's prose.🤖 Generated with Claude Code