From 6e18b74e07f01c77841f6e6b08f83da15b7778b8 Mon Sep 17 00:00:00 2001 From: Leeeon233 Date: Thu, 3 Sep 2026 10:20:45 +0000 Subject: [PATCH 1/6] feat(components): make a Y rail's row geometric instead of DOM-defined MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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] --- .../src/lib/chat-workspace-geometry.ts | 306 +++++++++++++-- .../tests/chat-workspace-geometry.test.ts | 364 ++++++++++++++++++ 2 files changed, 641 insertions(+), 29 deletions(-) diff --git a/packages/components/src/lib/chat-workspace-geometry.ts b/packages/components/src/lib/chat-workspace-geometry.ts index cf8240a10..0c0f95b12 100644 --- a/packages/components/src/lib/chat-workspace-geometry.ts +++ b/packages/components/src/lib/chat-workspace-geometry.ts @@ -391,12 +391,14 @@ export type BlockRailCandidateAnchor = export type BlockRailCandidate = Readonly<{ elementId: string; - /** The one visual row this primitive belongs to; a Y rail never leaves it. */ + /** The DOM row this primitive renders in. A PRIOR, never eligibility. */ rowId: string; /** Structural family shared by instances of the same visual row shape. */ rowFamily?: string; /** Nearest visual partition inside the discovery scope. */ sectionId?: string; + /** Nearest declared discovery scope; with `rowFamily`, the DOM prior. */ + scope?: string; /** Geometry-derived visual role; semantic contract names never enter discovery. */ kind?: string; space?: AlignmentRailCandidateSpace; @@ -413,14 +415,28 @@ export type BlockRailDiscoveryOptions = Readonly<{ /** Maximum distance from the row median at which a member supports the rail. */ inlierTolerance?: number; minMembers?: number; + /** Members a rail needs when no DOM row vouches for the comparison. */ + crossFamilyMinMembers?: number; /** Coordinates are snapped to this physical-pixel grid before comparison. */ deviceScaleFactor?: number; }>; +/** + * What let these primitives be compared at all. + * + * - `row-instance`: every member renders in ONE DOM row. That prior is strong + * enough on its own, so two members and one capture are a rail. + * - `cross-family`: a geometric row put members of DIFFERENT row families or + * scopes on one line, and no DOM structure says they belong together. The + * claim is the same; only the evidence behind it is weaker. + */ +export type BlockRailEvidence = 'row-instance' | 'cross-family'; + export type DiscoveredBlockRail = Readonly<{ rowId: string; rowFamily?: string; sectionId?: string; + evidence: BlockRailEvidence; anchor: BlockRailCandidateAnchor; line: number; spread: number; @@ -431,6 +447,18 @@ export type DiscoveredBlockRail = Readonly<{ outliers: readonly Readonly[]; }>; +/** + * How much of the SMALLER of two vertical extents has to fall inside the other + * for them to be on ONE line: the same "at least half" `selectVisualRowSlots` + * asks of a row slot, so a row and a rail cannot mean different things by it. + * That function repeats the literal because capture serializes it into the + * page, where this binding does not exist; keep the two in step. + */ +export const GEOMETRY_ROW_BAND_OVERLAP = 0.5; + +/** One element's vertical extent: all a geometric row is made of. */ +export type GeometricRowExtent = Readonly<{ id: string; yStart: number; yEnd: number }>; + export type LayoutTopologyNode = Readonly<{ id: string; parentId: string | null; @@ -1299,6 +1327,8 @@ export type GeometryRowSlotExtent = Readonly<{ top: number; bottom: number }>; export function selectVisualRowSlots( slots: readonly GeometryRowSlotExtent[], rowCenter: number, + // The literal, not `GEOMETRY_ROW_BAND_OVERLAP`: capture serializes this + // function into the page, where a module binding does not exist. minimumBandOverlap = 0.5 ): readonly number[] { const heights = slots @@ -1363,38 +1393,86 @@ export function isGeometryPaintedShape(paint: GeometryShapePaint): boolean { } /** - * Discover vertical rails WITHOUT reading a single alignment marker. A Y rail - * is scoped to ONE visual row instance — the same unit the marker-based - * `instance` rules use — because two different rows share no vertical line. - * Every anchor is discovered independently, so a row can report that its boxes - * agree (`block-center`) while the ink a reader sees does not (`visual-center`). - * Cross-row aggregation belongs to finding identity, not to discovery. + * Assign vertical extents to GEOMETRIC rows: the visual lines a reader sees, + * derived from rendered geometry and nothing else. Returns one row index per + * input, in input order; an extent with no height belongs to no row (`-1`). + * + * An extent joins the row whose MEDIAN band — the median member height centred + * on the median member centre — it overlaps by at least half, and the best such + * overlap wins. The median band is what stops a chain: neighbour-to-neighbour + * transitivity would let a ladder of half-overlapping extents link two lines of + * different heights into one row, exactly as chaining intermediate coordinates + * would link two indentation levels into one X rail. */ -export function discoverBlockAlignmentRails( - candidates: readonly BlockRailCandidate[], - options: BlockRailDiscoveryOptions = {} -): readonly DiscoveredBlockRail[] { - const inlierTolerance = options.inlierTolerance ?? 0.5; - const minMembers = options.minMembers ?? 2; - const deviceScaleFactor = options.deviceScaleFactor ?? 1; - if (!Number.isFinite(inlierTolerance) || inlierTolerance < 0) { - throw new RangeError('inlierTolerance must be a finite, non-negative number'); - } - if (!Number.isInteger(minMembers) || minMembers < 2) { - throw new RangeError('minMembers must be an integer greater than one'); +export function assignGeometricRows( + extents: readonly GeometricRowExtent[], + minimumBandOverlap = GEOMETRY_ROW_BAND_OVERLAP +): readonly number[] { + const ordered = extents + .map((extent, index) => ({ extent, index })) + .filter( + ({ extent }) => + Number.isFinite(extent.yStart) && + Number.isFinite(extent.yEnd) && + extent.yEnd > extent.yStart + ) + .sort( + (first, second) => + (first.extent.yStart + first.extent.yEnd) / 2 - + (second.extent.yStart + second.extent.yEnd) / 2 || + first.extent.yStart - second.extent.yStart || + first.extent.id.localeCompare(second.extent.id) + ); + const rows: { centers: number[]; heights: number[] }[] = []; + const assignment = extents.map(() => -1); + for (const { extent, index } of ordered) { + const height = extent.yEnd - extent.yStart; + const center = (extent.yStart + extent.yEnd) / 2; + let chosen = -1; + let chosenOverlap = 0; + for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) { + const row = rows[rowIndex]; + if (!row) continue; + const bandHeight = median(row.heights); + const bandCenter = median(row.centers); + const bandStart = bandCenter - bandHeight / 2; + const bandEnd = bandCenter + bandHeight / 2; + const shared = Math.min(extent.yEnd, bandEnd) - Math.max(extent.yStart, bandStart); + const smaller = Math.min(height, bandHeight); + const ratio = smaller > 0 ? shared / smaller : 0; + if (ratio >= minimumBandOverlap && ratio > chosenOverlap) { + chosen = rowIndex; + chosenOverlap = ratio; + } + } + if (chosen < 0) { + rows.push({ centers: [center], heights: [height] }); + chosen = rows.length - 1; + } else { + const row = rows[chosen]; + if (row) { + row.centers.push(center); + row.heights.push(height); + } + } + assignment[index] = chosen; } + return assignment; +} +/** + * One rail over the members of one DOM row instance at one anchor: the rule + * that has always decided a Y rail, unchanged. Every member is vouched for by + * the same rendered row, so two of them and one capture are enough. + */ +function discoverRowInstanceRails( + candidates: readonly BlockRailCandidate[], + options: Readonly<{ inlierTolerance: number; minMembers: number; deviceScaleFactor: number }> +): DiscoveredBlockRail[] { + const { inlierTolerance, minMembers, deviceScaleFactor } = options; const byRowAndAnchor = new Map(); for (const candidate of candidates) { - if ( - !Number.isFinite(candidate.coordinate) || - !Number.isFinite(candidate.xStart) || - !Number.isFinite(candidate.xEnd) || - candidate.xEnd < candidate.xStart - ) { - throw new RangeError(`${candidate.elementId}.${candidate.anchor} has invalid geometry`); - } - const key = `${candidate.rowId}${candidate.anchor}`; + const key = `${candidate.rowId} ${candidate.anchor}`; const members = byRowAndAnchor.get(key) ?? []; members.push(candidate); byRowAndAnchor.set(key, members); @@ -1465,6 +1543,7 @@ export function discoverBlockAlignmentRails( rowId: representative.rowId, ...(representative.rowFamily ? { rowFamily: representative.rowFamily } : {}), ...(representative.sectionId ? { sectionId: representative.sectionId } : {}), + evidence: 'row-instance', anchor: representative.anchor, line, spread: Math.max(...coordinates) - Math.min(...coordinates), @@ -1479,8 +1558,177 @@ export function discoverBlockAlignmentRails( ), }); } + return rails; +} - return rails.sort( +/** + * The rails a geometric row still owes an explanation for: primitives on one + * visual line that NO DOM row put there, so nothing but the rendering says they + * belong together. + * + * Same claim, weaker evidence, so the bar is higher and explicit: three members + * at least, drawn from two or more distinct (row family, scope) pairs — one + * repeated row shape is the row-instance rule's business — at `visual-center` + * only, the one anchor a glyph and a mark are comparable at all. The line is the + * member median on the same DPR grid, and two members at least have to reach it: + * three coordinates that all disagree have a median but not a line. + */ +function discoverCrossFamilyRails( + candidates: readonly BlockRailCandidate[], + rowOfElement: ReadonlyMap, + options: Readonly<{ inlierTolerance: number; minMembers: number; deviceScaleFactor: number }> +): DiscoveredBlockRail[] { + const { inlierTolerance, minMembers, deviceScaleFactor } = options; + const byRow = new Map(); + const seen = new Set(); + for (const candidate of candidates) { + if (candidate.anchor !== 'visual-center') continue; + if (seen.has(candidate.elementId)) continue; + const row = rowOfElement.get(candidate.elementId); + if (row === undefined) continue; + seen.add(candidate.elementId); + byRow.set(row, [ + ...(byRow.get(row) ?? []), + { + ...candidate, + coordinate: quantizeGeometryCoordinate(candidate.coordinate, deviceScaleFactor), + }, + ]); + } + + const rails: DiscoveredBlockRail[] = []; + const orderedRows = [...byRow.entries()].sort(([first], [second]) => first - second); + for (const [row, rowCandidates] of orderedRows) { + if (rowCandidates.length < minMembers) continue; + const groups = new Set( + rowCandidates.map((member) => `${member.rowFamily ?? ''}\u0000${member.scope ?? ''}`) + ); + if (groups.size < 2) continue; + const line = median(rowCandidates.map((member) => member.coordinate)); + const members = rowCandidates + .map((member) => { + const delta = Math.abs(member.coordinate - line); + return { ...member, delta, outlier: delta > inlierTolerance }; + }) + .sort( + (first, second) => + first.xStart - second.xStart || + first.coordinate - second.coordinate || + first.elementId.localeCompare(second.elementId) + ); + const support = members.filter((member) => !member.outlier).length; + if (support < 2) continue; + const coordinates = members.map((member) => member.coordinate); + rails.push({ + rowId: `geometric-row:${row}`, + evidence: 'cross-family', + anchor: 'visual-center', + line, + spread: Math.max(...coordinates) - Math.min(...coordinates), + support, + sampleSize: members.length, + horizontalSpan: + Math.max(...members.map((member) => member.xEnd)) - + Math.min(...members.map((member) => member.xStart)), + members, + outliers: members.filter( + (member): member is BlockRailCandidate & { delta: number; outlier: true } => member.outlier + ), + }); + } + return rails; +} + +/** + * Discover vertical rails WITHOUT reading a single alignment marker. + * + * A Y rail's row is GEOMETRIC: the visual line a reader sees, made of every + * candidate whose vertical extent overlaps that line's median band by half. The + * DOM row is a PRIOR, never an eligibility test — before this, a control that + * rendered 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. + * + * What the DOM decides is how much evidence a rail needs: + * + * - Members of ONE DOM row instance keep the row-instance rule exactly: two + * members, any anchor, one capture, the verdict anchor chosen from what the + * row is made of. + * - What is left on a geometric row — elements no row-instance rail measures — + * is a `cross-family` rail: three members from two or more (row family, scope) + * pairs, `visual-center` only. + * + * 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 + * are exactly the rails that existed before, with the same members and the same + * lines. Every anchor is still discovered independently, so a row can report + * that its boxes agree (`block-center`) while the ink a reader sees does not + * (`visual-center`). Cross-capture aggregation belongs to finding identity, not + * to discovery. + */ +export function discoverBlockAlignmentRails( + candidates: readonly BlockRailCandidate[], + options: BlockRailDiscoveryOptions = {} +): readonly DiscoveredBlockRail[] { + const inlierTolerance = options.inlierTolerance ?? 0.5; + const minMembers = options.minMembers ?? 2; + const crossFamilyMinMembers = options.crossFamilyMinMembers ?? 3; + const deviceScaleFactor = options.deviceScaleFactor ?? 1; + if (!Number.isFinite(inlierTolerance) || inlierTolerance < 0) { + throw new RangeError('inlierTolerance must be a finite, non-negative number'); + } + if (!Number.isInteger(minMembers) || minMembers < 2) { + throw new RangeError('minMembers must be an integer greater than one'); + } + if (!Number.isInteger(crossFamilyMinMembers) || crossFamilyMinMembers <= minMembers) { + throw new RangeError('crossFamilyMinMembers must be an integer above minMembers'); + } + for (const candidate of candidates) { + if ( + !Number.isFinite(candidate.coordinate) || + !Number.isFinite(candidate.xStart) || + !Number.isFinite(candidate.xEnd) || + candidate.xEnd < candidate.xStart + ) { + throw new RangeError(`${candidate.elementId}.${candidate.anchor} has invalid geometry`); + } + } + + const rowInstanceRails = discoverRowInstanceRails(candidates, { + inlierTolerance, + minMembers, + deviceScaleFactor, + }); + // Geometric rows are formed over EVERY element, claimed or not: a row's median + // band is a fact about what is rendered on that line, and computing it from + // the leftovers alone would let the elements a row rail already explains + // change where the remaining ones are judged to sit. + const extentByElement = new Map(); + for (const candidate of candidates) { + if (extentByElement.has(candidate.elementId)) continue; + extentByElement.set(candidate.elementId, { + id: candidate.elementId, + yStart: candidate.yStart, + yEnd: candidate.yEnd, + }); + } + const extents = [...extentByElement.values()]; + const assignment = assignGeometricRows(extents); + const rowOfElement = new Map(); + for (const [index, extent] of extents.entries()) { + const row = assignment[index]; + if (row !== undefined && row >= 0) rowOfElement.set(extent.id, row); + } + const claimed = new Set( + rowInstanceRails.flatMap((rail) => rail.members.map((member) => member.elementId)) + ); + const crossFamilyRails = discoverCrossFamilyRails( + candidates.filter((candidate) => !claimed.has(candidate.elementId)), + rowOfElement, + { inlierTolerance, minMembers: crossFamilyMinMembers, deviceScaleFactor } + ); + + return [...rowInstanceRails, ...crossFamilyRails].sort( (first, second) => first.line - second.line || first.rowId.localeCompare(second.rowId) || diff --git a/packages/components/tests/chat-workspace-geometry.test.ts b/packages/components/tests/chat-workspace-geometry.test.ts index d26a949cc..45277822c 100644 --- a/packages/components/tests/chat-workspace-geometry.test.ts +++ b/packages/components/tests/chat-workspace-geometry.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +import * as chatWorkspaceGeometry from '../src/lib/chat-workspace-geometry'; import { CHAT_WORKSPACE_GEOMETRY_SPEC, CHAT_WORKSPACE_GEOMETRY_ANCHORS, @@ -7,6 +8,8 @@ import { calculateGridPlacementRect, calculateMainPaneGrid, calculateSidebarGrid, + GEOMETRY_ROW_BAND_OVERLAP, + assignGeometricRows, discoverAlignmentRails, discoverBlockAlignmentRails, discoverRepeatedLayoutScopes, @@ -22,6 +25,8 @@ import { validateChatWorkspaceGeometry, type AlignmentRailCandidate, type BlockRailCandidate, + type GeometryRowSlotExtent, + type DiscoveredBlockRail, type ChatWorkspaceGeometrySnapshot, type LayoutTopologyNode, } from '../src/lib/chat-workspace-geometry'; @@ -1160,6 +1165,336 @@ describe('vertical rail discovery', () => { }); }); +describe('geometric rows', () => { + const extent = (id: string, yStart: number, yEnd: number) => ({ id, yStart, yEnd }); + + it('groups extents that overlap the row band by half', () => { + expect( + assignGeometricRows([extent('title', 32, 48), extent('time', 33, 47), extent('icon', 34, 50)]) + ).toEqual([0, 0, 0]); + }); + + it('keeps two lines that do not meet apart', () => { + expect(assignGeometricRows([extent('label', 600, 617), extent('field', 623, 671)])).toEqual([ + 0, 1, + ]); + }); + + it('lets a small mark share the line of the control it sits in', () => { + // The SMALLER extent decides, so a 16px icon centred inside a 32px control + // is on its line \u2014 a row is about the line, not about size. + expect(assignGeometricRows([extent('control', 24, 56), extent('icon', 32, 48)])).toEqual([ + 0, 0, + ]); + }); + + it('refuses to chain one line into the next through a half-overlapping neighbour', () => { + // Pairwise transitivity would put all three on one row: each overlaps its + // neighbour by exactly half. The MEDIAN band is what stops the ladder, the + // same reason an X rail must not chain intermediate coordinates. + expect( + assignGeometricRows([ + extent('first', 32, 48), + extent('second', 40, 56), + extent('third', 48, 64), + ]) + ).toEqual([0, 0, 1]); + }); + + it('partitions on rendered geometry alone, whatever order candidates arrive in', () => { + const extents = [ + extent('a', 32, 48), + extent('b', 33, 47), + extent('c', 100, 116), + extent('d', 101, 117), + ]; + const partition = (values: readonly ReturnType[]) => { + const rows = assignGeometricRows(values); + const grouped = new Map(); + values.forEach((value, index) => { + const row = rows[index] ?? -1; + grouped.set(row, [...(grouped.get(row) ?? []), value.id]); + }); + return [...grouped.values()].map((ids) => ids.sort().join(',')).sort(); + }; + expect(partition(extents)).toEqual(partition([...extents].reverse())); + }); + + it('leaves an extent with no height out of every row', () => { + expect(assignGeometricRows([extent('empty', 40, 40), extent('title', 32, 48)])).toEqual([ + -1, 0, + ]); + }); +}); + +describe('cross-family rails', () => { + const singleton = ( + elementId: string, + coordinate: number, + overrides: Partial = {} + ): BlockRailCandidate => ({ + elementId, + rowId: `visual-row:${elementId}`, + rowFamily: `div[${elementId}]>button`, + scope: `scope.${elementId}`, + kind: 'svg', + space: 'ink', + anchor: 'visual-center', + coordinate, + xStart: 24, + xEnd: 40, + yStart: coordinate - 8, + yEnd: coordinate + 8, + ...overrides, + }); + const crossFamily = (rails: readonly DiscoveredBlockRail[]) => + rails.filter((rail) => rail.evidence === 'cross-family'); + const rowInstance = (rails: readonly DiscoveredBlockRail[]) => + rails.filter((rail) => rail.evidence === 'row-instance'); + + it('compares three singletons that no DOM row put on one line', () => { + const [rail, ...rest] = crossFamily( + discoverBlockAlignmentRails([ + singleton('sidebar', 40), + singleton('tabs', 40, { xStart: 320, xEnd: 336 }), + singleton('info', 42, { xStart: 1200, xEnd: 1216 }), + ]) + ); + + expect(rest).toEqual([]); + expect(rail).toMatchObject({ + evidence: 'cross-family', + anchor: 'visual-center', + line: 40, + support: 2, + sampleSize: 3, + }); + expect(rail?.outliers.map((member) => member.elementId)).toEqual(['info']); + // Signed against the line, so a card can say \u2193 rather than "2px away". + expect((rail?.outliers[0]?.coordinate ?? 0) - (rail?.line ?? 0)).toBe(2); + }); + + it('needs three: two singletons agreeing are not a line', () => { + expect( + crossFamily( + discoverBlockAlignmentRails([ + singleton('sidebar', 40), + singleton('tabs', 42, { xStart: 320, xEnd: 336 }), + ]) + ) + ).toEqual([]); + }); + + it('leaves one repeated row shape to the row-instance rule', () => { + const sameFamily = crossFamily( + discoverBlockAlignmentRails([ + singleton('a', 40, { rowFamily: 'div[row]>button', scope: 'sidebar.shell' }), + singleton('b', 40, { + rowFamily: 'div[row]>button', + scope: 'sidebar.shell', + xStart: 320, + xEnd: 336, + }), + singleton('c', 43, { + rowFamily: 'div[row]>button', + scope: 'sidebar.shell', + xStart: 620, + xEnd: 636, + }), + ]) + ); + expect(sameFamily).toEqual([]); + + // One member from another scope makes it a cross-structural claim. + const [crossed] = crossFamily( + discoverBlockAlignmentRails([ + singleton('a', 40, { rowFamily: 'div[row]>button', scope: 'sidebar.shell' }), + singleton('b', 40, { + rowFamily: 'div[row]>button', + scope: 'sidebar.shell', + xStart: 320, + xEnd: 336, + }), + singleton('c', 43, { + rowFamily: 'div[row]>button', + scope: 'session.topbar', + xStart: 620, + xEnd: 636, + }), + ]) + ); + expect(crossed?.outliers.map((member) => member.elementId)).toEqual(['c']); + }); + + it('refuses singletons whose vertical extents barely meet', () => { + expect( + crossFamily( + discoverBlockAlignmentRails([ + singleton('sidebar', 40), + singleton('tabs', 40, { xStart: 320, xEnd: 336 }), + singleton('below', 52, { xStart: 1200, xEnd: 1216, yStart: 46, yEnd: 62 }), + ]) + ) + ).toEqual([]); + }); + + it('gives the DOM prior priority: a row instance spends its members', () => { + const rowMember = (elementId: string, coordinate: number, xStart: number): BlockRailCandidate => + singleton(elementId, coordinate, { + rowId: 'visual-row:sidebar', + rowFamily: 'div[row]>button', + scope: 'sidebar.shell', + xStart, + xEnd: xStart + 16, + }); + const rails = discoverBlockAlignmentRails([ + rowMember('row-a', 40, 24), + rowMember('row-b', 40, 60), + rowMember('row-c', 42, 96), + singleton('tabs', 40, { xStart: 320, xEnd: 336 }), + singleton('info', 40, { xStart: 1200, xEnd: 1216 }), + ]); + + // The row explains its own three members, so the two singletons left on that + // geometric row are not three and there is no cross-family rail at all. + expect(rowInstance(rails).map((rail) => rail.rowId)).toEqual(['visual-row:sidebar']); + expect(rowInstance(rails)[0]?.outliers.map((member) => member.elementId)).toEqual(['row-c']); + expect(crossFamily(rails)).toEqual([]); + + // A third singleton is enough, and it never takes a row member with it. + const withThird = discoverBlockAlignmentRails([ + rowMember('row-a', 40, 24), + rowMember('row-b', 40, 60), + rowMember('row-c', 42, 96), + singleton('tabs', 40, { xStart: 320, xEnd: 336 }), + singleton('info', 40, { xStart: 1200, xEnd: 1216 }), + singleton('panel', 43, { xStart: 1300, xEnd: 1316 }), + ]); + expect(crossFamily(withThird)[0]?.members.map((member) => member.elementId)).toEqual([ + 'tabs', + 'info', + 'panel', + ]); + expect(crossFamily(withThird)[0]?.outliers.map((member) => member.elementId)).toEqual([ + 'panel', + ]); + }); + + it('spends the members of a two-member row as well', () => { + const pair = (elementId: string, coordinate: number, xStart: number): BlockRailCandidate => + singleton(elementId, coordinate, { + rowId: 'visual-row:pair', + rowFamily: 'div[pair]>button', + scope: 'sidebar.shell', + xStart, + xEnd: xStart + 16, + }); + const rails = discoverBlockAlignmentRails([ + pair('pair-a', 40, 24), + pair('pair-b', 41, 60), + singleton('info', 40, { xStart: 1200, xEnd: 1216 }), + ]); + + expect(rowInstance(rails)).toHaveLength(1); + expect(crossFamily(rails)).toEqual([]); + }); + + it('reads only the visual centre', () => { + expect( + crossFamily( + discoverBlockAlignmentRails([ + singleton('sidebar', 40, { anchor: 'block-start' }), + singleton('tabs', 40, { anchor: 'block-start', xStart: 320, xEnd: 336 }), + singleton('info', 42, { anchor: 'block-start', xStart: 1200, xEnd: 1216 }), + ]) + ) + ).toEqual([]); + }); + + it('refuses a rail whose members all disagree about where the line is', () => { + expect( + crossFamily( + discoverBlockAlignmentRails([ + singleton('sidebar', 36), + singleton('tabs', 40, { xStart: 320, xEnd: 336 }), + singleton('info', 44, { xStart: 1200, xEnd: 1216 }), + ]) + ) + ).toEqual([]); + }); + + it('snaps to the physical pixel grid before deciding what left the line', () => { + const [rail] = crossFamily( + discoverBlockAlignmentRails( + [ + singleton('sidebar', 40.1), + singleton('tabs', 40.1, { xStart: 320, xEnd: 336 }), + singleton('info', 40.3, { xStart: 1200, xEnd: 1216 }), + ], + { deviceScaleFactor: 2 } + ) + ); + + expect(rail?.line).toBe(40); + expect(rail?.outliers).toEqual([]); + }); + + it('cannot change a single row-instance rail by existing', () => { + // The row-instance path is the rule that has always decided a Y rail. This + // is the parity oracle: the same rows, discovered with and without the + // singletons that only a geometric row can group, are the same rails. + const rowsOnly: readonly BlockRailCandidate[] = [ + singleton('title', 40, { + rowId: 'visual-row:1', + rowFamily: 'div[row]>button', + kind: 'text', + xStart: 40, + xEnd: 220, + }), + singleton('time', 40, { + rowId: 'visual-row:1', + rowFamily: 'div[row]>button', + kind: 'text', + xStart: 240, + xEnd: 262, + }), + singleton('icon', 42, { rowId: 'visual-row:1', rowFamily: 'div[row]>button' }), + singleton('label', 608, { + rowId: 'visual-row:2', + rowFamily: 'div[composer]', + kind: 'text', + yStart: 600, + yEnd: 617, + }), + singleton('field', 647, { + rowId: 'visual-row:2', + rowFamily: 'div[composer]', + kind: 'field', + yStart: 623, + yEnd: 671, + }), + ]; + const singletons: readonly BlockRailCandidate[] = [ + singleton('tabs', 40, { xStart: 320, xEnd: 336 }), + singleton('info', 41, { xStart: 1200, xEnd: 1216 }), + singleton('panel', 40, { xStart: 1300, xEnd: 1316 }), + ]; + + expect(rowInstance(discoverBlockAlignmentRails([...rowsOnly, ...singletons]))).toEqual( + rowInstance(discoverBlockAlignmentRails(rowsOnly)) + ); + expect( + crossFamily(discoverBlockAlignmentRails([...rowsOnly, ...singletons])).map((rail) => + rail.members.map((member) => member.elementId) + ) + ).toEqual([['tabs', 'info', 'panel']]); + }); + + it('rejects an evidence bar that would not be higher than a row instance', () => { + expect(() => discoverBlockAlignmentRails([], { crossFamilyMinMembers: 2 })).toThrow(RangeError); + }); +}); + describe('chat workspace validation', () => { it('accepts the expanded Sidebar + Main Pane + Chat Landing geometry', () => { expect( @@ -1301,6 +1636,35 @@ describe('what belongs to a visual row', () => { }); }); +describe('helpers capture serializes into the page', () => { + /** + * `installGeometryBrowserHelpers` ships these functions to the browser as + * SOURCE, where this module does not exist: a name resolved from module scope + * is a `ReferenceError` in the middle of a capture, and without this only a + * full report run would say so. + * + * Every exported name is searched for in the serialized text, which is what + * catches the realistic slip — reaching for a shared constant instead of + * repeating its literal. + */ + const moduleBindings = Object.keys(chatWorkspaceGeometry); + const moduleReferences = (fn: unknown, ownName: string) => + moduleBindings.filter( + (name) => name !== ownName && new RegExp(`\\b${name}\\b`).test(String(fn)) + ); + + it('leaves every serialized helper closure-free', () => { + expect(moduleReferences(selectVisualRowSlots, 'selectVisualRowSlots')).toEqual([]); + expect(moduleReferences(isGeometryPaintedShape, 'isGeometryPaintedShape')).toEqual([]); + }); + + it('sees a shared constant a serialized helper would fail on', () => { + const closesOver = (slots: readonly GeometryRowSlotExtent[]) => + slots.length > GEOMETRY_ROW_BAND_OVERLAP; + expect(moduleReferences(closesOver, 'closesOver')).toEqual(['GEOMETRY_ROW_BAND_OVERLAP']); + }); +}); + describe('painted CSS shapes are primitives', () => { const shape = (overrides: Partial[0]> = {}) => ({ width: 8, From 5c8928c4795e300f70dd3c88e49de912bc0bd4a3 Mon Sep 17 00:00:00 2001 From: Leeeon233 Date: Thu, 3 Sep 2026 10:20:45 +0000 Subject: [PATCH 2/6] feat(components): report a cross-family outlier only when a second capture repeats it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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] --- .../src/lib/geometry-constraint-system.ts | 268 ++++++++++++++++-- .../tests/geometry-constraint-system.test.ts | 225 +++++++++++++++ 2 files changed, 470 insertions(+), 23 deletions(-) diff --git a/packages/components/src/lib/geometry-constraint-system.ts b/packages/components/src/lib/geometry-constraint-system.ts index 659b3a614..f870396fb 100644 --- a/packages/components/src/lib/geometry-constraint-system.ts +++ b/packages/components/src/lib/geometry-constraint-system.ts @@ -361,7 +361,11 @@ export type GeometryDimensionSensitivity = Readonly<{ * not contain. Three members or more have a majority, so the median is a line * and a member off it is an outlier with a sign. */ -export type GeometryFindingKind = 'alignment-rail' | 'row-spread' | 'measurement-model-divergence'; +export type GeometryFindingKind = + | 'alignment-rail' + | 'row-spread' + | 'cross-family' + | 'measurement-model-divergence'; export type GeometryFinding = Readonly<{ key: string; @@ -813,9 +817,14 @@ export function observeGeometryCaptures( if (!blockCandidates.has(key)) blockCandidates.set(key, candidate); } } - const blockRails = discoverBlockAlignmentRails([...blockCandidates.values()], { - deviceScaleFactor: capture.deviceScaleFactor, - }); + const blockRails = discoverBlockAlignmentRails( + [...blockCandidates.values()].map((candidate) => + candidate.scope === undefined && candidate.sectionScope + ? { ...candidate, scope: candidate.sectionScope } + : candidate + ), + { deviceScaleFactor: capture.deviceScaleFactor } + ); return { captureId: capture.captureId, @@ -864,7 +873,12 @@ export function alignmentFindingKey( locator: GeometryStableLocator; anchor: GeometryExplainedAnchor; axis?: SemanticAlignmentAxis; - /** A two-member row is one spread, keyed by the row rather than a member. */ + /** + * A two-member row is one spread, keyed by the row rather than a member; a + * `cross-family` 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 ever enters here. + */ kind?: GeometryFindingKind; }> ): string { @@ -874,6 +888,7 @@ export function alignmentFindingKey( locatorIdentity(input.locator), axisTerm, ...(input.kind === 'row-spread' ? ['row-spread'] : []), + ...(input.kind === 'cross-family' ? ['cross-family'] : []), ]); } @@ -1640,6 +1655,129 @@ export function selectGeometryVerdictAnchor(rails: readonly DiscoveredBlockRail[ return null; } +/** + * A label reads as `role \u201crow title\u201d` exactly when the accessible name + * describes the row's contents rather than the control. That is a display + * decision now that identity never reads a name at all. + */ +function geometryRepeatedRowLabelling( + captures: GeometryCaptureArtifact +): ( + locator: GeometryStableLocator, + naming: GeometryCandidateNaming | undefined, + surfaceFamily: GeometrySurfaceFamily +) => boolean { + const repeatedRowFamilies = collectRepeatedGeometryRowFamilies(captures); + return (locator, naming, surfaceFamily) => + locator.name !== undefined && + locator.rowFamily !== undefined && + (repeatedRowFamilies.get(surfaceFamily)?.has(locator.rowFamily) ?? false) && + isGeometryContentAggregatedName(locator, naming); +} + +/** One member of a cross-family rail, labelled and identified as a finding would be. */ +export type GeometryCrossFamilyProposalMember = Readonly<{ + /** Coordinate-free structural identity: how a rail is matched across captures. */ + identity: string; + locator: GeometryStableLocator; + /** The key this member carries as a finding once a second capture agrees. */ + findingKey: string; + member: GeometryRowMember; +}>; + +/** + * One `cross-family` rail ONE capture proposes. Nothing but the rendering says + * these primitives belong on a line together, and one capture agreeing is a + * coincidence, so a single capture never promotes one: the report shows the + * proposal, and `createGeometryFindings` decides which outliers survive. + */ +export type GeometryCrossFamilyProposal = Readonly<{ + captureId: string; + surfaceFamily: GeometrySurfaceFamily; + rail: DiscoveredBlockRail; + line: number; + normalizedLine: number; + /** Positionally aligned with `rail.members`. */ + members: readonly GeometryCrossFamilyProposalMember[]; +}>; + +/** + * Label and identify every `cross-family` rail the observation proposes. ONE + * labelling pipeline: a report card prints exactly what a finding would, so the + * two can never disagree about a member's name, its offset, or the key it would + * carry. + * + * A rail with a member discovery cannot name structurally is dropped: it could + * never be recognised in the next capture, so it could never earn a finding. + */ +export function collectGeometryCrossFamilyProposals( + captures: GeometryCaptureArtifact, + observations: GeometryObservationArtifact +): readonly GeometryCrossFamilyProposal[] { + const captureById = new Map(captures.captures.map((capture) => [capture.captureId, capture])); + const aggregatedRowFamilies = collectAggregatedGeometryRowFamilies(captures); + const labelsAsRepeatedRow = geometryRepeatedRowLabelling(captures); + type RailMember = DiscoveredBlockRail['members'][number] & + Partial; + return observations.captures.flatMap((captureObservation) => { + const viewportHeight = captureById.get(captureObservation.captureId)?.viewport.height; + return (captureObservation.blockRails ?? []) + .filter((rail) => rail.evidence === 'cross-family') + .flatMap((rail): GeometryCrossFamilyProposal[] => { + const railMembers = rail.members as readonly RailMember[]; + const locators = railMembers.map((member) => member.locator); + if (locators.some((locator) => locator === undefined)) return []; + const members = railMembers.map((member, index): GeometryCrossFamilyProposalMember => { + const raw = locators[index] as GeometryStableLocator; + const locator = geometryIdentityLocator(raw, { + aggregatedRowFamilies: aggregatedRowFamilies.get(captureObservation.surfaceFamily), + ...(member.sectionScope ? { section: member.sectionScope } : {}), + }); + return { + identity: locatorIdentity(locator), + locator, + findingKey: alignmentFindingKey({ + surfaceFamily: captureObservation.surfaceFamily, + locator, + anchor: 'visual-center', + axis: 'y', + kind: 'cross-family', + }), + member: { + label: geometryFindingLabel(raw, member.label ?? member.elementId, { + ...(member.naming ? { naming: member.naming } : {}), + repeatedRow: labelsAsRepeatedRow( + raw, + member.naming, + captureObservation.surfaceFamily + ), + }), + primitiveId: member.primitiveId ?? member.elementId, + ...(member.kind ? { kind: member.kind } : {}), + coordinate: member.coordinate, + offset: member.coordinate - rail.line, + outlier: member.outlier, + xStart: member.xStart, + xEnd: member.xEnd, + yStart: member.yStart, + yEnd: member.yEnd, + }, + }; + }); + return [ + { + captureId: captureObservation.captureId, + surfaceFamily: captureObservation.surfaceFamily, + rail, + line: rail.line, + normalizedLine: viewportHeight ? Number((rail.line / viewportHeight).toFixed(4)) : 0, + members, + }, + ]; + }); + }); +} + export function createGeometryFindings( captures: GeometryCaptureArtifact, observations: GeometryObservationArtifact @@ -1714,22 +1852,8 @@ export function createGeometryFindings( } } - const repeatedRowFamilies = collectRepeatedGeometryRowFamilies(captures); const aggregatedRowFamilies = collectAggregatedGeometryRowFamilies(captures); - /** - * A label reads as `role \u201crow title\u201d` exactly when the accessible name - * describes the row's contents rather than the control. That is a display - * decision now that identity never reads a name at all. - */ - const labelsAsRepeatedRow = ( - locator: GeometryStableLocator, - naming: GeometryCandidateNaming | undefined, - surfaceFamily: GeometrySurfaceFamily - ) => - locator.name !== undefined && - locator.rowFamily !== undefined && - (repeatedRowFamilies.get(surfaceFamily)?.has(locator.rowFamily) ?? false) && - isGeometryContentAggregatedName(locator, naming); + const labelsAsRepeatedRow = geometryRepeatedRowLabelling(captures); const findingGroups = new Map< string, { @@ -1739,7 +1863,7 @@ export function createGeometryFindings( labels: Map; anchor: GeometryExplainedAnchor; axis: SemanticAlignmentAxis; - kind: 'alignment-rail' | 'row-spread'; + kind: 'alignment-rail' | 'row-spread' | 'cross-family'; verdictAnchorReason?: 'mixed-kinds' | 'all-text' | 'boxes-only'; evidence: GeometryFindingEvidence[]; } @@ -1842,6 +1966,10 @@ export function createGeometryFindings( capture?.viewport.height ? Number((line / capture.viewport.height).toFixed(4)) : 0; const railsByRow = new Map(); for (const rail of captureObservation.blockRails ?? []) { + // `cross-family` rails answer the same question with weaker evidence, so + // they never reach the one-capture path or pick a verdict anchor: they are + // aggregated across captures below, at `visual-center`, or not at all. + if (rail.evidence !== 'row-instance') continue; railsByRow.set(rail.rowId, [...(railsByRow.get(rail.rowId) ?? []), rail]); } for (const rowRails of railsByRow.values()) { @@ -2061,6 +2189,93 @@ export function createGeometryFindings( } } + /** + * `cross-family` rails. Nothing but the rendering says these primitives share + * a line, and one capture agreeing is a coincidence: the SAME member set has + * to form the rail, and the same member has to leave the line in the same + * direction, in two captures at least. The rail itself is never a finding — + * its outliers are, one finding per element, keyed structurally like every + * other Y finding and told apart from the row-instance question by its kind. + * + * Members are matched between captures by structural identity, never by a + * coordinate: a line that moved as a whole is still the same line. + */ + const railsByMemberSet = new Map(); + for (const proposal of collectGeometryCrossFamilyProposals(captures, observations)) { + const key = `${proposal.surfaceFamily}\u0000${proposal.members + .map((member) => member.identity) + .sort() + .join('\u0001')}`; + railsByMemberSet.set(key, [...(railsByMemberSet.get(key) ?? []), proposal]); + } + for (const proposals of railsByMemberSet.values()) { + if (new Set(proposals.map((proposal) => proposal.captureId)).size < 2) continue; + const outlierIdentities = [ + ...new Set( + proposals.flatMap((proposal) => + proposal.members.flatMap((member) => (member.member.outlier ? [member.identity] : [])) + ) + ), + ].sort(); + for (const identity of outlierIdentities) { + const missed = new Map< + string, + Readonly<{ + proposal: GeometryCrossFamilyProposal; + member: GeometryCrossFamilyProposalMember; + }> + >(); + for (const proposal of proposals) { + const member = proposal.members.find((candidate) => candidate.identity === identity); + if (!member?.member.outlier) continue; + if (!missed.has(proposal.captureId)) missed.set(proposal.captureId, { proposal, member }); + } + if (missed.size < 2) continue; + // One element, one direction. An element that reads high in one capture + // and low in the next is measuring something that moves, not a line it + // consistently misses. + const directions = new Set( + [...missed.values()].map(({ member }) => Math.sign(member.member.offset)) + ); + if (directions.size !== 1) continue; + const sightings = [...missed.values()].sort((left, right) => + left.proposal.captureId.localeCompare(right.proposal.captureId) + ); + const first = sightings[0]; + if (!first) continue; + const key = first.member.findingKey; + const group = findingGroups.get(key) ?? { + surfaceFamily: first.proposal.surfaceFamily, + locator: first.member.locator, + labels: new Map(), + anchor: 'visual-center' as GeometryExplainedAnchor, + axis: 'y' as SemanticAlignmentAxis, + kind: 'cross-family' as const, + evidence: [], + }; + for (const { proposal, member } of sightings) { + if (group.evidence.some((item) => item.captureId === proposal.captureId)) continue; + group.labels.set(proposal.captureId, member.member.label); + group.evidence.push({ + captureId: proposal.captureId, + scopeKey: proposal.rail.rowId, + rowId: proposal.rail.rowId, + coordinate: member.member.coordinate, + line: proposal.line, + normalizedLine: proposal.normalizedLine, + offset: member.member.offset, + yStart: member.member.yStart, + yEnd: member.member.yEnd, + xStart: member.member.xStart, + xEnd: member.member.xEnd, + anchor: 'visual-center', + rowMembers: proposal.members.map((item) => item.member), + }); + } + findingGroups.set(key, group); + } + } + const findings: GeometryFinding[] = [...findingGroups.entries()].map(([key, group]) => { const offset = group.evidence.reduce((sum, evidence) => sum + evidence.offset, 0) / group.evidence.length; @@ -2263,7 +2478,7 @@ export function compareMarkerAlignmentsToBlockRails( alignment.members.flatMap((member) => (member.primitiveId ? [member.primitiveId] : [])) ); const scored = blockRails - .filter((rail) => rail.anchor === alignment.anchor) + .filter((rail) => rail.evidence === 'row-instance' && rail.anchor === alignment.anchor) .map((rail) => { const members = rail.members as readonly (DiscoveredBlockRail['members'][number] & Partial)[]; @@ -2435,8 +2650,15 @@ export function assessGeometryMarkerRemoval( captures: GeometryCaptureArtifact, observations: GeometryObservationArtifact ): GeometryMarkerRemovalReadiness { + // A marker rule names the members of ONE DOM row, so only a row-instance rail + // can answer whether discovery reproduced it. A `cross-family` rail reaching + // the same element is a different, weaker claim, and letting it stand in would + // report a marker as removable on evidence the marker never had. const blockRailsByCapture = new Map( - observations.captures.map((capture) => [capture.captureId, capture.blockRails ?? []]) + observations.captures.map((capture) => [ + capture.captureId, + (capture.blockRails ?? []).filter((rail) => rail.evidence === 'row-instance'), + ]) ); const quantization = Math.max( ...captures.captures.map((capture) => diff --git a/packages/components/tests/geometry-constraint-system.test.ts b/packages/components/tests/geometry-constraint-system.test.ts index f383dc96a..31547c5ec 100644 --- a/packages/components/tests/geometry-constraint-system.test.ts +++ b/packages/components/tests/geometry-constraint-system.test.ts @@ -1754,6 +1754,231 @@ describe('re-keying a reviewed finding', () => { }); }); +describe('a cross-family rail has to survive a second capture', () => { + /** + * Three singleton controls in three discovery scopes. Each renders in a DOM + * row of its own, so only a geometric row can put them on one line. + */ + const singleton = ( + scopeName: string, + coordinate: number, + xStart: number, + overrides: Partial = {} + ): GeometryCapturedBlockCandidate => ({ + elementId: `${scopeName}-icon:icon`, + primitiveId: `${scopeName}-icon`, + locator: { + role: 'button', + name: `${scopeName} action`, + landmark: { role: 'banner', name: scopeName }, + rowFamily: `div[${scopeName}]>button[svg]`, + roleIndex: 0, + }, + label: 'icon', + sectionScope: scopeName, + rowId: `visual-row:${scopeName}`, + rowFamily: `div[${scopeName}]>button[svg]`, + kind: 'svg', + space: 'ink', + anchor: 'visual-center', + coordinate, + xStart, + xEnd: xStart + 16, + yStart: coordinate - 8, + yEnd: coordinate + 8, + ...overrides, + }); + + const rowCapture = (captureId: string, lastOffset: number): GeometryCapture => ({ + ...capture(captureId, [ + { + key: 'shell', + identity: 'shell', + source: 'hint', + depth: 2, + rect: { x: 0, y: 0, width: 1440, height: 64 }, + candidates: [], + blockCandidates: [ + singleton('sidebar.shell', 40, 24), + singleton('session.topbar', 40, 320), + singleton('session.info', 40 + lastOffset, 1200), + ], + }, + ]), + viewport: { width: 1440, height: 900 }, + }); + const crossFamilyRails = (observation: ReturnType) => + (observation.captures[0]?.blockRails ?? []).filter((rail) => rail.evidence === 'cross-family'); + + it('proposes the rail in one capture and reports no finding for it', () => { + const captureArtifact = { version: 1 as const, captures: [rowCapture('one', 2)] }; + const observation = observeGeometryCaptures(captureArtifact); + + const [rail] = crossFamilyRails(observation); + expect(rail).toMatchObject({ anchor: 'visual-center', line: 40, sampleSize: 3 }); + expect(rail?.outliers.map((member) => member.elementId)).toEqual(['session.info-icon:icon']); + // Nothing but the rendering says these three share a line, and one capture + // agreeing is a coincidence: it stays a report proposal. + expect(createGeometryFindings(captureArtifact, observation).findings).toEqual([]); + }); + + it('reports the outlier once the same members line up again in a second capture', () => { + const captureArtifact = { + version: 1 as const, + captures: [rowCapture('one', 2), rowCapture('two', 2)], + }; + const findings = createGeometryFindings( + captureArtifact, + observeGeometryCaptures(captureArtifact) + ); + + expect(findings.findings).toEqual([ + expect.objectContaining({ + kind: 'cross-family', + axis: 'y', + anchor: 'visual-center', + label: 'session.info action', + offset: 2, + captureCount: 2, + }), + ]); + // The rail is not a finding; the element that leaves it is, and the whole + // row travels as its evidence so a card annotates rather than measures. + expect(findings.findings[0]?.evidence.map((item) => item.captureId)).toEqual(['one', 'two']); + expect(findings.findings[0]?.evidence[0]?.rowMembers?.map((member) => member.outlier)).toEqual([ + false, + false, + true, + ]); + }); + + it('keys the finding structurally, apart from the row-instance question', () => { + const captureArtifact = { + version: 1 as const, + captures: [rowCapture('one', 2), rowCapture('two', 2)], + }; + const [finding] = createGeometryFindings( + captureArtifact, + observeGeometryCaptures(captureArtifact) + ).findings; + const identity = geometryIdentityLocator( + { + role: 'button', + name: 'session.info action', + landmark: { role: 'banner', name: 'session.info' }, + rowFamily: 'div[session.info]>button[svg]', + roleIndex: 0, + }, + { section: 'session.info' } + ); + + expect(finding?.key).toBe( + alignmentFindingKey({ + surfaceFamily: 'workspace', + locator: identity, + anchor: 'visual-center', + axis: 'y', + kind: 'cross-family', + }) + ); + // Same element, same axis, same anchor: only the kind keeps them apart. + expect(finding?.key).not.toBe( + alignmentFindingKey({ + surfaceFamily: 'workspace', + locator: identity, + anchor: 'visual-center', + axis: 'y', + }) + ); + }); + + it('matches members between captures structurally, never by coordinate', () => { + // The whole line moved 6px down in the second capture. Same members, same + // relationship to it, so the evidence still merges. + const moved: GeometryCapture = { + ...rowCapture('two', 2), + scopes: [ + { + ...(rowCapture('two', 2).scopes[0] as GeometryCapturedScope), + blockCandidates: [ + singleton('sidebar.shell', 46, 24), + singleton('session.topbar', 46, 320), + singleton('session.info', 48, 1200), + ], + }, + ], + }; + const captureArtifact = { version: 1 as const, captures: [rowCapture('one', 2), moved] }; + const findings = createGeometryFindings( + captureArtifact, + observeGeometryCaptures(captureArtifact) + ); + + expect(findings.findings).toHaveLength(1); + expect(findings.findings[0]?.evidence.map((item) => item.line)).toEqual([40, 46]); + }); + + it('refuses an element that reads high in one capture and low in the next', () => { + const captureArtifact = { + version: 1 as const, + captures: [rowCapture('one', 2), rowCapture('two', -2)], + }; + + expect( + createGeometryFindings(captureArtifact, observeGeometryCaptures(captureArtifact)).findings + ).toEqual([]); + }); + + it('leaves a DOM row to the row-instance path and never re-explains its members', () => { + const rowMember = ( + primitiveId: string, + coordinate: number, + xStart: number + ): GeometryCapturedBlockCandidate => ({ + ...singleton('sidebar.shell', coordinate, xStart), + elementId: `${primitiveId}:label`, + primitiveId, + rowId: 'visual-row:sidebar-row', + locator: { + role: 'button', + name: `Row ${primitiveId}`, + landmark: { role: 'banner', name: 'sidebar.shell' }, + rowFamily: 'div[sidebar.shell]>button[svg]', + roleIndex: 0, + }, + }); + const withRow = (captureId: string): GeometryCapture => ({ + ...rowCapture(captureId, 2), + scopes: [ + { + ...(rowCapture(captureId, 2).scopes[0] as GeometryCapturedScope), + blockCandidates: [ + rowMember('row-a', 40, 24), + rowMember('row-b', 40, 60), + rowMember('row-c', 42, 96), + singleton('session.topbar', 40, 320), + singleton('session.info', 40, 1200), + ], + }, + ], + }); + const captureArtifact = { version: 1 as const, captures: [withRow('one'), withRow('two')] }; + const observation = observeGeometryCaptures(captureArtifact); + + // The DOM prior wins: the row explains its three members, and the two + // singletons left on that geometric row are not three. + expect( + (observation.captures[0]?.blockRails ?? []).filter( + (rail) => rail.evidence === 'row-instance' + )[0]?.sampleSize + ).toBe(3); + expect(crossFamilyRails(observation)).toEqual([]); + expect( + createGeometryFindings(captureArtifact, observation).findings.map((finding) => finding.kind) + ).toEqual(['alignment-rail']); + }); +}); + describe('marker removal readiness', () => { it('holds a rule back until discovery reproduces every member on every capture', () => { const member = ( From f6c2f18c466f47c45c34588388caeffffbb449fb Mon Sep 17 00:00:00 2001 From: Leeeon233 Date: Thu, 3 Sep 2026 10:20:45 +0000 Subject: [PATCH 3/6] feat(components): draw cross-family rails with the Y card annotator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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] --- .../chat-workspace-geometry-report.html | 30 ++- .../chat-workspace-geometry-report.spec.ts | 172 +++++++++++++++--- 2 files changed, 171 insertions(+), 31 deletions(-) diff --git a/packages/components/scripts/templates/chat-workspace-geometry-report.html b/packages/components/scripts/templates/chat-workspace-geometry-report.html index b9da69e6b..535557d8b 100644 --- a/packages/components/scripts/templates/chat-workspace-geometry-report.html +++ b/packages/components/scripts/templates/chat-workspace-geometry-report.html @@ -331,6 +331,11 @@ background: var(--candidate-soft); } + .detail-review[data-kind='cross-family-candidate'] .detail-finding { + color: var(--text-muted); + background: var(--hover); + } + .detail-review[data-kind='insufficient'] .detail-finding { color: var(--text-muted); background: var(--hover); @@ -585,6 +590,9 @@

明确排除

const reviewCandidates = candidates.filter((d) => d.requiresReview); const stableCandidates = candidates.filter((d) => !d.requiresReview); const overviews = report.details.filter((d) => d.kind === 'overview'); + const crossFamilyCandidates = report.details.filter( + (d) => d.kind === 'cross-family-candidate' + ); const jitters = report.details.filter((d) => d.kind === 'jitter'); const insufficient = report.details.filter((d) => d.kind === 'insufficient'); const measurementModels = report.details.filter((d) => d.kind === 'measurement-model'); @@ -725,6 +733,12 @@

明确排除

['overview', '整体视图', overviews.length, null], ['jitter', '亚像素抖动', jitters.length, 'dot--candidate'], ['measurement-model', '测量模型分歧', measurementModels.length, 'dot--candidate'], + [ + 'cross-family-candidate', + '跨行族候选轨', + crossFamilyCandidates.length, + 'dot--insufficient', + ], ['insufficient', '证据不足', insufficient.length, 'dot--insufficient'], ['stable', '稳定候选轨', stableCandidates.length, 'dot--candidate'], ]; @@ -956,13 +970,15 @@

明确排除

? '候选轨与偏移' : detail.kind === 'overview' ? '候选轨总览' - : detail.kind === 'jitter' - ? '亚像素抖动' - : detail.kind === 'measurement-model' - ? '测量模型分歧' - : detail.kind === 'insufficient' - ? '证据不足' - : '失败语义线'; + : detail.kind === 'cross-family-candidate' + ? '跨行族候选轨' + : detail.kind === 'jitter' + ? '亚像素抖动' + : detail.kind === 'measurement-model' + ? '测量模型分歧' + : detail.kind === 'insufficient' + ? '证据不足' + : '失败语义线'; const figures = [ ['原始界面', detail.images.clean, `${detail.title} 原始局部截图`], [annotatedLabel, detail.images.annotated, `${detail.title} ${annotatedLabel}局部截图`], diff --git a/packages/components/tests/e2e/chat-workspace-geometry-report.spec.ts b/packages/components/tests/e2e/chat-workspace-geometry-report.spec.ts index e1e4ba11e..fc40566b6 100644 --- a/packages/components/tests/e2e/chat-workspace-geometry-report.spec.ts +++ b/packages/components/tests/e2e/chat-workspace-geometry-report.spec.ts @@ -17,6 +17,7 @@ import { alignmentFindingKey, assessGeometryMarkerRemoval, collectAggregatedGeometryRowFamilies, + collectGeometryCrossFamilyProposals, compileGeometryContracts, computeGeometryQualityMetrics, createGeometryFindings, @@ -35,6 +36,7 @@ import { type GeometryRowMember, type GeometryLedger, type GeometryLedgerStatus, + type GeometryCrossFamilyProposal, type GeometryObservationCache, type GeometryRepairProposal, } from '../../src/lib/geometry-constraint-system'; @@ -70,7 +72,15 @@ const storybookOrigin = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:600 const reportPhase = process.env.GEOMETRY_REPORT_PHASE ?? 'before'; type ReportDetail = Readonly<{ - kind: 'violation' | 'candidate' | 'overview' | 'jitter' | 'insufficient' | 'measurement-model'; + kind: + | 'violation' + | 'candidate' + | 'overview' + /** A cross-family rail one capture proposes; two make its outliers findings. */ + | 'cross-family-candidate' + | 'jitter' + | 'insufficient' + | 'measurement-model'; requiresReview: boolean; classification?: GeometryFindingClassification; findingKey?: string; @@ -121,6 +131,8 @@ type ReportDetail = Readonly<{ line: number; xStart: number; xEnd: number; + /** A cross-family candidate line, drawn apart from a row's own median. */ + dashed?: boolean; members: readonly Readonly<{ coordinate: number; xStart: number; @@ -155,6 +167,7 @@ function reportDetailPriority(detail: ReportDetail): number { if (detail.kind === 'violation') return 0; if (detail.kind === 'candidate' && detail.requiresReview) return 1; if (detail.kind === 'overview') return 2; + if (detail.kind === 'cross-family-candidate') return 3; if (detail.kind === 'insufficient') return 3; if (detail.kind === 'jitter') return 4; if (detail.kind === 'measurement-model') return 5; @@ -705,7 +718,9 @@ async function showOnlyDetailSemanticGuides(page: Page, detail: ReportDetail): P top: `${guide.line}px`, width: `${Math.max(1, guide.xEnd - guide.xStart + 12)}px`, height: '1.5px', - background: 'rgb(14 116 144 / 0.24)', + ...(guide.dashed + ? { borderTop: '1.5px dashed rgb(14 116 144 / 0.42)' } + : { background: 'rgb(14 116 144 / 0.24)' }), boxShadow: '0 0 0 0.5px rgb(255 255 255 / 0.58)', }); overlay.append(line); @@ -1226,11 +1241,16 @@ function createFindingBlockDetail({ evidence, index, viewport, + detailKind = 'candidate', + idPrefix = 'block', }: Readonly<{ finding: GeometryFinding; evidence: GeometryFindingEvidence; index: number; viewport: Readonly<{ width: number; height: number }>; + /** A cross-family rail one capture proposes uses this same annotator. */ + detailKind?: ReportDetail['kind']; + idPrefix?: string; }>): ReportDetail { const members = evidence.rowMembers ?? []; const rowStart = Math.min(...members.map((member) => member.xStart), evidence.xStart ?? 0); @@ -1245,32 +1265,53 @@ function createFindingBlockDetail({ finding.anchor; const rowLabel = members.map((member) => member.label).join(' · ') || finding.label; const isSpread = finding.kind === 'row-spread'; + // A cross-family line crosses the whole capture, so its guide does too: drawn + // only between its outermost members it would read as a wide DOM row. + const isCrossFamily = finding.kind === 'cross-family'; + const isProposal = detailKind === 'cross-family-candidate'; const measurementOf = (member: GeometryRowMember) => isSpread ? `${anchorLabel} 行内跨度 ${Number(Math.abs(evidence.offset).toFixed(2))}px` : `${anchorLabel} ${formatDirectionalOffset('y', member.offset)}`; + const id = `${idPrefix}-${index + 1}`; + const kindLabel = isCrossFamily ? '跨行族几何行' : '行内垂直对齐'; + const findingLead = isProposal + ? '跨行族候选轨' + : isCrossFamily + ? '跨行族偏移' + : isSpread + ? '行内跨度' + : '候选'; + const lineLabel = isCrossFamily ? '几何行中位线' : '行中位线'; return { - kind: 'candidate', - requiresReview: true, - findingKey: finding.key, - id: `block-${index + 1}`, - title: `${finding.surfaceFamily} · 行内垂直对齐 · ${rowLabel}`, - description: `${finding.label} · ${anchorLabel} · 行内 ${members.length} 个元素`, - finding: `${isSpread ? '行内跨度' : '候选'} · ${finding.label} ${anchorLabel} ${ + kind: detailKind, + requiresReview: !isProposal, + ...(finding.key ? { findingKey: finding.key } : {}), + id, + title: `${finding.surfaceFamily} · ${kindLabel} · ${rowLabel}`, + description: `${finding.label} · ${anchorLabel} · ${members.length} 个元素`, + finding: `${findingLead} · ${finding.label} ${anchorLabel} ${ isSpread ? `${Number(Math.abs(evidence.offset).toFixed(2))}px` : formatDirectionalOffset('y', evidence.offset) - } · 行中位线 ${Number(evidence.line.toFixed(2))}px`, + } · ${lineLabel} ${Number(evidence.line.toFixed(2))}px`, clip: clampClip( - { - x: rowStart - 12, - y: (rowTop + rowBottom) / 2 - bandHeight / 2, - width: rowEnd - rowStart + 420, - height: bandHeight, - }, + isCrossFamily + ? { + x: 0, + y: (rowTop + rowBottom) / 2 - bandHeight / 2, + width: viewport.width, + height: bandHeight, + } + : { + x: rowStart - 12, + y: (rowTop + rowBottom) / 2 - bandHeight / 2, + width: rowEnd - rowStart + 420, + height: bandHeight, + }, viewport ), - images: reportImages(`block-${index + 1}`), + images: reportImages(id), overlay: { alignmentGroups: [], baselineGroups: [], @@ -1298,8 +1339,9 @@ function createFindingBlockDetail({ blockGuides: [ { line: evidence.line, - xStart: rowStart, - xEnd: rowEnd, + xStart: isCrossFamily ? 0 : rowStart, + xEnd: isCrossFamily ? viewport.width : rowEnd, + ...(isCrossFamily ? { dashed: true } : {}), members: members.map((member) => ({ coordinate: member.coordinate, xStart: member.xStart, @@ -1349,6 +1391,51 @@ function selectYFindingCards( .slice(0, limit); } +/** + * A cross-family rail one capture proposes, shaped as the finding it is not yet + * so the SAME annotator draws it. Every number the card prints is the rail's own + * measurement of that member; nothing here measures anything a second time. + */ +function crossFamilyProposalCard( + proposal: GeometryCrossFamilyProposal +): Readonly<{ finding: GeometryFinding; evidence: GeometryFindingEvidence }> { + const members = proposal.members.map((item) => item.member); + const worst = [...members].sort( + (left, right) => + Math.abs(right.offset) - Math.abs(left.offset) || + left.primitiveId.localeCompare(right.primitiveId) + )[0]; + return { + finding: { + // A proposal has no key: it is not in `findings.json`, not in the ledger. + key: '', + kind: 'cross-family', + surfaceFamily: proposal.surfaceFamily, + label: worst?.label ?? 'geometric row', + axis: 'y', + anchor: 'visual-center', + offset: worst?.offset ?? 0, + captureCount: 1, + totalCaptureCount: 1, + evidence: [], + }, + evidence: { + captureId: proposal.captureId, + scopeKey: proposal.rail.rowId, + coordinate: worst?.coordinate ?? proposal.line, + line: proposal.line, + normalizedLine: proposal.normalizedLine, + offset: worst?.offset ?? 0, + yStart: Math.min(...members.map((member) => member.yStart)), + yEnd: Math.max(...members.map((member) => member.yEnd)), + xStart: Math.min(...members.map((member) => member.xStart)), + xEnd: Math.max(...members.map((member) => member.xEnd)), + anchor: 'visual-center', + rowMembers: members, + }, + }; +} + function createDiscoveryOverviewDetail({ surface, idPrefix, @@ -2023,14 +2110,44 @@ test('captures the visual geometry report', async ({ browser }) => { .filter((capture) => geometryReplayContextKey(capture) === mainContextKey) .map((capture) => capture.captureId) ); - const yCards = selectYFindingCards( - persistedFindings.findings, - MAX_Y_FINDING_CARDS, - warmCaptureIds + // Cross-family rails a single capture proposed but no second capture + // confirmed. They are not findings, so they carry no key and never reach the + // ledger; they share the finding cards' deviation order and screenshot budget + // rather than asking for one of their own. + const confirmedCrossFamilyKeys = new Set( + persistedFindings.findings.flatMap((finding) => + finding.kind === 'cross-family' ? [finding.key] : [] + ) ); + const crossFamilyProposalCards = collectGeometryCrossFamilyProposals( + persistedCapture, + persistedObservation + ) + .filter((proposal) => + proposal.members.some( + (member) => member.member.outlier && !confirmedCrossFamilyKeys.has(member.findingKey) + ) + ) + .map((proposal) => crossFamilyProposalCard(proposal)); + const yCards = [ + ...selectYFindingCards(persistedFindings.findings, MAX_Y_FINDING_CARDS, warmCaptureIds).map( + (card) => ({ ...card, detailKind: 'candidate' as const, idPrefix: 'block' }) + ), + ...crossFamilyProposalCards.map((card) => ({ + ...card, + detailKind: 'cross-family-candidate' as const, + idPrefix: 'cross-family', + })), + ] + .sort( + (left, right) => + Math.abs(right.finding.offset) - Math.abs(left.finding.offset) || + left.finding.label.localeCompare(right.finding.label) + ) + .slice(0, MAX_Y_FINDING_CARDS); const yCardDetails: ReportDetail[] = []; const yCardsByCapture = new Map>(); - for (const [index, { finding, evidence }] of yCards.entries()) { + for (const [index, { finding, evidence, detailKind, idPrefix }] of yCards.entries()) { const capture = coverageByCaptureId.get(evidence.captureId); if (!capture) continue; const detail = createFindingBlockDetail({ @@ -2038,6 +2155,8 @@ test('captures the visual geometry report', async ({ browser }) => { evidence, index, viewport: capture.viewport, + detailKind, + idPrefix, }); // The addendum's check, made executable: an annotation on a Y card is the // finding's own evidence for that member, or the card is a second opinion @@ -2336,6 +2455,11 @@ test('captures the visual geometry report', async ({ browser }) => { ]; }); displayedDetails.push(...details.filter((detail) => detail.kind === 'measurement-model')); + // A cross-family proposal is shown as itself: no ledger status, no + // classification, no baseline — nothing has reviewed it, it is not a finding. + displayedDetails.push( + ...yCardDetails.filter((detail) => detail.kind === 'cross-family-candidate') + ); const reportData = { generatedAt: new Date().toISOString(), From d5a3bd39371ed12a11383d7ce9ce44222bff67e2 Mon Sep 17 00:00:00 2001 From: Leeeon233 Date: Thu, 3 Sep 2026 10:20:45 +0000 Subject: [PATCH 4/6] test(components): gate cross-family outlier reporting with an injected translateY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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] --- .../tests/e2e/chat-workspace-geometry.spec.ts | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/packages/components/tests/e2e/chat-workspace-geometry.spec.ts b/packages/components/tests/e2e/chat-workspace-geometry.spec.ts index c3cd49a5b..b6775d96b 100644 --- a/packages/components/tests/e2e/chat-workspace-geometry.spec.ts +++ b/packages/components/tests/e2e/chat-workspace-geometry.spec.ts @@ -457,6 +457,106 @@ test('vertical row alignment is discovered without geometry marker attributes', expect(changedRows.map(([rowId]) => rowId)).toEqual([target?.rowId]); }); +test('a cross-family geometric row reports its outlier and no DOM row', async ({ page }) => { + test.setTimeout(120_000); + const response = await page.goto( + '/iframe.html?id=geometry-chatworkspace--expanded-sidebar&viewMode=story' + ); + expect(response?.ok()).toBeTruthy(); + await expect(page.locator('[data-geometry-fixture-ready="true"]')).toBeAttached({ + timeout: 30_000, + }); + + // The discovery-scope attribute stays: it is a declared hint, and it is half + // of what sets a rail's evidence bar. The alignment markers go, so nothing a + // designer wrote can be what put these controls on one line. + const semanticAttributes = Object.values(CHAT_WORKSPACE_SEMANTIC_ALIGNMENT_ATTRIBUTES); + await page.locator('*').evaluateAll((elements, attributes) => { + for (const element of elements) { + for (const attribute of attributes) element.removeAttribute(attribute); + } + }, semanticAttributes); + + type Identified = Readonly<{ primitiveId?: string }>; + const primitiveIdOf = (member: Identified) => member.primitiveId ?? ''; + const outlierIds = (rail: DiscoveredBlockRail) => + rail.outliers + .map((member) => primitiveIdOf(member as typeof member & Identified)) + .sort() + .join(','); + const memberIds = (rail: DiscoveredBlockRail) => + rail.members + .map((member) => primitiveIdOf(member as typeof member & Identified)) + .sort() + .join(','); + const outliersByRail = (rails: readonly DiscoveredBlockRail[]) => + new Map( + rails.map((rail) => [`${rail.evidence} ${memberIds(rail)} ${rail.anchor}`, outlierIds(rail)]) + ); + const crossFamily = (rails: readonly DiscoveredBlockRail[]) => + rails.filter((rail) => rail.evidence === 'cross-family'); + const rowInstance = (rails: readonly DiscoveredBlockRail[]) => + rails.filter((rail) => rail.evidence === 'row-instance'); + + const before = await discoverChatWorkspaceBlockRails(page); + expect(crossFamily(before).length, 'no cross-family geometric row to probe').toBeGreaterThan(0); + + // Any singleton control on any cross-family row will do; naming a header icon + // would make the gate about one product surface rather than about the rule. + // An odd, tight rail: the median is then one member's coordinate, so the shift + // moves the probe and not the line it is measured against. + const probeOf = (rail: DiscoveredBlockRail) => + rail.members.find((member) => !member.outlier && member.kind === 'svg'); + const target = crossFamily(before).find( + (rail) => rail.sampleSize % 2 === 1 && rail.spread <= 1 && probeOf(rail) !== undefined + ); + expect(target, 'no tight cross-family row with an aligned icon to probe').toBeDefined(); + if (!target) return; + const probe = probeOf(target); + const probeId = probe ? primitiveIdOf(probe as typeof probe & Identified) : ''; + expect(probeId).not.toBe(''); + + const probeShift = 2; + await page.evaluate( + ({ primitiveId, shift }) => { + const index = Number(primitiveId.replace('dom-', '')) - 1; + const element = [document.body, ...document.body.querySelectorAll('*')][index]; + if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) { + throw new Error(`Geometry probe element ${primitiveId} is missing`); + } + element.style.transform = `translateY(${shift}px)`; + }, + { primitiveId: probeId, shift: probeShift } + ); + + const after = await discoverChatWorkspaceBlockRails(page); + const probed = crossFamily(after).find((rail) => memberIds(rail) === memberIds(target)); + const probedMember = probed?.members.find( + (member) => primitiveIdOf(member as typeof member & Identified) === probeId + ); + expect(probedMember?.outlier).toBe(true); + const probedOffset = (probedMember?.coordinate ?? 0) - (probed?.line ?? 0); + expect(probedOffset).toBeGreaterThan(probeShift - 1); + expect(probedOffset).toBeLessThan(probeShift + 1); + + // Only the injected element left its rail, and it is the only element added to + // that rail's outliers. Comparing the two runs, rather than asserting which + // product rows are aligned, keeps the gate from failing when a real offset + // elsewhere is fixed. + const changed = [...outliersByRail(after).entries()].filter( + ([key, outliers]) => outliersByRail(before).get(key) !== outliers + ); + expect(changed.map(([key]) => key)).toEqual([ + `cross-family ${memberIds(target)} ${target.anchor}`, + ]); + expect(probed ? outlierIds(probed) : '').toBe( + [...new Set([...outlierIds(target).split(',').filter(Boolean), probeId])].sort().join(',') + ); + // No product DOM row was touched: the DOM prior decides the evidence bar, and + // a cross-family probe must not reach a row-instance rail at all. + expect(rowInstance(after).length).toBe(rowInstance(before).length); +}); + if (process.env.GEOMETRY_DIAGNOSTIC_AUDIT === '1') { test('geometry audit exposes every guide and diagnostic without hover', async ({ page }) => { test.setTimeout(60_000); From bba9347dd4026e22d5e80525ae259a97b895ee25 Mon Sep 17 00:00:00 2001 From: Leeeon233 Date: Thu, 3 Sep 2026 10:20:45 +0000 Subject: [PATCH 5/6] docs(components): record the geometric-row Y invariant 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] --- packages/components/src/lib/AGENTS.md | 10 ++- packages/components/tests/e2e/AGENTS.md | 96 ++++++++++++------------- 2 files changed, 55 insertions(+), 51 deletions(-) diff --git a/packages/components/src/lib/AGENTS.md b/packages/components/src/lib/AGENTS.md index 02d8a9710..058ffdec2 100644 --- a/packages/components/src/lib/AGENTS.md +++ b/packages/components/src/lib/AGENTS.md @@ -95,8 +95,14 @@ Production layout stays ordinary Flex/Grid with stable geometry data markers onl columns are never props or wrapper DOM. Fixture and gate share one spec. `?geometry=1` adds the overlay, alignment lines and spacing diagnostics; dev only. -- Explicit control boxes: repeated slots share an X line across rows, icon/text controls - in one row a Y instance. +- Explicit control boxes: repeated slots share an X line across rows. +- A Y rail's row is GEOMETRIC: `assignGeometricRows` puts an extent on the line whose MEDIAN + band it overlaps by half — to that band, never neighbour to neighbour, or a ladder of + half-overlaps chains two lines of different heights into one, as chaining intermediate + coordinates would merge two indentation levels. The DOM row is a PRIOR setting the EVIDENCE + BAR, never eligibility, which as a gate hid every cross-structural line by construction. + An atom no row accepted is measured on Y under its own row id, or the DOM would still gate + a layer earlier; alone in that row, it can only reach the cross-family bar. - Cross-font rows compare ink centres — a cap-height band from a fixed reference glyph, transformed SVG path bounds, a painted CSS shape's box; baselines compare text only ([tests/e2e/support](../../tests/e2e/support/AGENTS.md)). The overlay and the capture take diff --git a/packages/components/tests/e2e/AGENTS.md b/packages/components/tests/e2e/AGENTS.md index 35e39864a..88f5f8742 100644 --- a/packages/components/tests/e2e/AGENTS.md +++ b/packages/components/tests/e2e/AGENTS.md @@ -7,9 +7,9 @@ Measures rendered geometry, turns it into findings, gates what a human promoted. `src/lib/chat-workspace-geometry.ts` (spec, grid, discovery) and `geometry-constraint-system.ts` (pipeline, ledger, contracts, tokens, metrics); grid and classification: [src/lib](../../src/lib/AGENTS.md); what a primitive, a row and a name ARE, -and the shared capture plan: [support](support/AGENTS.md); `*-geometry-report.spec.ts` -(report), `*-geometry.spec.ts` (gate). Neither `geometry:report` nor `geometry:triage` -moves a baseline. +and the capture plan: [support](support/AGENTS.md); `*-report.spec.ts` (report), +`*-geometry.spec.ts` (gate). Neither `geometry:report` nor `geometry:triage` moves a +baseline. ## X rails @@ -30,32 +30,34 @@ Heuristic, before any finding exists. ## Y rails -Marker-free, same pipeline, over the anchors [support](support/AGENTS.md) lists. +Marker-free, over the anchors [support](support/AGENTS.md) lists. -- A Y rail is ONE row instance, observed once per CAPTURE over the union of every scope's - Y candidates, so an aggregate scope and its child cannot measure a row differently. Line - = row median after snapping to the capture DPR grid, which every coordinate on both axes - passes; past the inlier tolerance a member is an outlier with a direction (↑/↓). Under - two members, no rail. +- A Y rail's row is GEOMETRIC, not DOM — the DOM row sets the EVIDENCE BAR, not eligibility + ([src/lib](../../src/lib/AGENTS.md)) — over every scope's Y candidates, once per CAPTURE. + `row-instance` (one DOM row): two members, any anchor, one capture. `cross-family` (≥2 row + families or scopes): three at `visual-center` and a second capture missing the line alike; + one capture only PROPOSES, outside the ledger. ONE element, ONE rail, DOM prior WINS, and + the key carries `cross-family`. Line = row median on the capture DPR grid, which every + coordinate on both axes passes; past the tolerance a member is an outlier with a direction + (↑/↓). - Only `visual-center` compares kinds; `block-center` is content independent but line-height dependent, so both stay. A block EDGE rail takes one kind: an icon top and a line-box top claim nothing. -- ONE element is ONE finding. The row picks the verdict anchor from what it is MADE OF — - `visual-center` mixing text with an icon, image or painted shape, `text-baseline` when - every member is text, else `block-center` — and offset, classification and repair come - from it alone. Other anchors are supporting measurements; every row member travels with - the evidence, so a card annotates rather than measures. +- ONE element is ONE finding. A `row-instance` rail picks its verdict anchor from what the + row is MADE OF — `visual-center` mixing text with a mark, `text-baseline` for all text, + else `block-center` — and offset, classification and repair come from it alone. Other + anchors are supporting measurements; every row member travels with the evidence. - Exactly two members is one `row-spread` naming both, never two outliers at half the gap: - their median is their midpoint, so a signed offset would invent a direction. Three or more - have a majority, so their median is a line. + their median is their midpoint, so a signed offset would invent a direction. Three have a + majority. - `marker-removal-readiness.json` asks whether discovery has replaced the markers ([support](support/AGENTS.md)). The gate proves outlier reporting with its OWN injected - `translateY`, diffing before and after, asserting no product row. + `translateY` on BOTH bars, diffed before and after, asserting no product row. ## Pipeline and finding identity `capture.json` → `observation.json` → `findings.json`, each stage reading only the previous; -content hashes reuse byte-identical observations. Child scopes summarise supported rails, +content hashes reuse byte-identical observations. Child scopes summarise supported rails; parents cluster those with unclaimed singletons. Identity is STRUCTURAL and coordinate-free: surface family, landmark, section, row family, @@ -63,16 +65,14 @@ family-instance index in the section, role, same-role index, plus the X anchor carries the axis instead, its anchor being a verdict. **The accessible name is ALWAYS a label, never key material**: a locale switch, a renamed fixture or another checkout must not mint a second finding for one element. The family-instance index tells apart three -same-shaped singleton rows (Settings, Help, Archive) and is DROPPED where the (section, row -family) aggregates — more instances than an enumeration renders, or an instance named from -its contents or from DATA (a `/` path segment, a space-padded `·`, a duration or date -token) — so ten chat rows stay one finding. Section is element-derived: a task row and a +same-shaped singleton rows and is DROPPED where the (section, row family) aggregates — more +instances than an enumeration renders, or an instance named from its contents or from DATA — +so ten chat rows stay one finding. Section is element-derived: a task row and a chat row of one family are two findings. Findings merge evidence across captures keeping the first capture's label; repeated instance rules with identical member offsets are measurement-model divergences, not repeated violations. -A structural improvement RE-KEYS reviews, and a decision nobody carries forward is made -twice. Each entry records the identity it reviewed (label, axis, anchor, surface); +A structural improvement RE-KEYS reviews; a decision nobody carries forward is made twice. Each entry records the identity it reviewed (label, axis, anchor, surface); `diffGeometryFindings` pairs a resolved key with a new one as `rekeyed` — same label, or, where a locale makes labels unmatchable, same axis + anchor + surface with |offset| within 0.25px — and triage MOVES status, reason and baseline there rather than report resolved and @@ -84,42 +84,40 @@ new. Pairing is one-to-one; an entry without a recorded identity stays resolved. over the evidence explanations and never alter a verdict; thresholds, terms and axes sit beside the code ([src/lib](../../src/lib/AGENTS.md)). Review lives in checked-in `geometry-ledger.json`; `geometry-contracts.json` compiles ONLY `promoted` entries, each -declaring `ink` or `layout-box` — `new`, `debt`, `wont-fix`, `fixed`, `ignored` compile none. +declaring `ink` or `layout-box`; no other status compiles. - A baseline is EXECUTED, not printed: the gate reruns the pipeline over the whole capture plan with no screenshots, and fails when a finding's |offset| passes |baseline| plus one device pixel (1/DPR of its COARSEST capture) or when a finding has no ledger entry - (`geometry:triage`, like a lockfile). `ignored` opts out; `promoted` belongs to the - contract check. Offsets are means over the WHOLE plan, so a baseline belongs to the + (`geometry:triage`, like a lockfile). `ignored` opts out, `promoted` belongs to the + contract check, and offsets are means over the WHOLE plan, so a baseline belongs to the platform that recorded it: re-baseline where CI runs, never trim the plan for speed. -- `debt` and `wont-fix` are two decisions, not one word; `triage` records `debt` rather - than guess. `geometry:verify-fix ` reruns that gate and only - then moves a finding back inside one device pixel to `fixed` at its new baseline, the - strictest entry there is. -- Two contract members never cover one element twice; member resolution, the ink witness - and named tokens (the ledger records only the `--spacing-*` property): see - [support](support/AGENTS.md). Relations are a small deterministic algebra there; widen - the relation before loosening a tolerance. -- Ledger labels give discovery precision, promoted locators coverage, PNG edges only - confidence. +- `debt` and `wont-fix` are two decisions, not one word; `triage` records `debt` rather than + guess. `geometry:verify-fix ` reruns that gate and only then + moves a finding back inside one device pixel to `fixed` at its new baseline, the strictest + entry there is. +- Two contract members never cover one element twice; member resolution, the ink witness and + named tokens (the ledger records only the `--spacing-*` property): see + [support](support/AGENTS.md). Relations are a small deterministic algebra there; widen the + relation before loosening a tolerance. +- Ledger labels give discovery precision, promoted locators coverage, PNG edges confidence. ## Report -Discovery or proposal presence is never a report assertion; coverage: -[support](support/AGENTS.md). +Discovery presence is never a report assertion; coverage: [support](support/AGENTS.md). -- Each detail persists the capture id owning its Story, viewport and scale; `--after` - replays that capture and clip and appends only the repair image, never rediscovering - findings or replacing evidence. Replay: [support](support/AGENTS.md). +- Each detail persists the capture id owning its Story, viewport and scale; `--after` replays + that capture and clip and appends only the repair image, never rediscovering findings or + replacing evidence. Replay: [support](support/AGENTS.md). - Steady state, not delta: every finding gets a card grouped by ledger status and classification, with baseline vs current offset, capture count, dimension sensitivity and - repair text. Cards FOLD by `repairGroup` — one per repair, naming the findings it stands + repair. Cards FOLD by `repairGroup` — one per repair, naming the findings it stands for, never across statuses; folding merges no finding and moves no key. Chips filter both, default new + changed + css-defect + promoted minus wont-fix/fixed; the meta line totals new/changed/resolved. One JSON payload, one renderer, images as files. -- Violation images label each deviating member in place with role, physical direction, - measured offset, actual anchor and a leader to it. A Y card comes from the FINISHED - findings, never a second pipeline printing another number: each annotation IS that - finding's evidence for that member, asserted before the shot. Card clips, zoomed Y cards - and discovery cards: [support](support/AGENTS.md). Cards are picked by deviation, inside - the generator's budget. +- Violation images label each deviating member in place with role, direction, offset, actual + anchor and a leader to it. A Y card comes from the FINISHED findings, never a second + pipeline printing another number: each annotation IS that finding's evidence for that + member, asserted before the shot. Card clips, zoomed Y cards and discovery cards: + [support](support/AGENTS.md). Cards, proposals included, are picked by deviation inside the + generator's budget. From 5940ec34cd63fcecdeb702affa9f2300349e94da Mon Sep 17 00:00:00 2001 From: Leeeon233 Date: Thu, 3 Sep 2026 10:58:35 +0000 Subject: [PATCH 6/6] fix(components): measure an atom outside every row on the Y axis too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:`, 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] --- .../e2e/support/chat-workspace-geometry.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/packages/components/tests/e2e/support/chat-workspace-geometry.ts b/packages/components/tests/e2e/support/chat-workspace-geometry.ts index 3df51da57..11c7ac06f 100644 --- a/packages/components/tests/e2e/support/chat-workspace-geometry.ts +++ b/packages/components/tests/e2e/support/chat-workspace-geometry.ts @@ -1471,6 +1471,61 @@ export async function captureChatWorkspaceGeometryScopes( yStart: rect.y, yEnd: rect.y + rect.height, }; + // An atom is measured on the Y axis too. A row REJECTED it, and a + // rejected slot is still an atom: leaving it out here is the DOM row + // acting as an eligibility test one layer before discovery, so a + // control rendering outside every detected row could not be compared + // to anything vertically no matter what the rules downstream said. + // Its `rowId` is its own, so it can never reach the two-member + // row-instance bar by itself; a geometric row is what may group it. + const atomAnchors = blockAnchorsOf(primitive.element, primitive.kind, rect); + const atomBlockCommon = { + ...common, + // Its OWN row, named after the primitive rather than after its + // coordinates. Two atoms whose centre and left edge happen to + // round alike would otherwise share the coordinate-derived row id + // 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. + rowId: `visual-atom:${primitive.primitiveId}`, + elementId: `${primitive.primitiveId}:${primitive.label}`, + xStart: rect.x, + xEnd: rect.x + rect.width, + ...(atomAnchors.typographyOffset === 0 + ? {} + : { typographyOffset: atomAnchors.typographyOffset }), + }; + blockCandidates.push( + { + ...atomBlockCommon, + anchor: 'block-start' as const, + coordinate: atomAnchors.blockStart, + }, + { + ...atomBlockCommon, + anchor: 'block-center' as const, + coordinate: atomAnchors.blockCenter, + }, + { + ...atomBlockCommon, + anchor: 'block-end' as const, + coordinate: atomAnchors.blockEnd, + }, + { + ...atomBlockCommon, + anchor: 'visual-center' as const, + coordinate: atomAnchors.visualCenter, + }, + ...(atomAnchors.textBaseline === null + ? [] + : [ + { + ...atomBlockCommon, + anchor: 'text-baseline' as const, + coordinate: atomAnchors.textBaseline, + }, + ]) + ); return [ { ...common, anchor: 'inline-start' as const, coordinate: rect.x }, {