From 6e18b74e07f01c77841f6e6b08f83da15b7778b8 Mon Sep 17 00:00:00 2001 From: Leeeon233 Date: Thu, 3 Sep 2026 10:20:45 +0000 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 }, { From 07cfd6429511ae6bcf953417c593ec7713c609f1 Mon Sep 17 00:00:00 2001 From: Leeeon233 Date: Thu, 3 Sep 2026 13:07:06 +0000 Subject: [PATCH 07/10] fix(components): make geometric row membership symmetric MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Overlaps the row's median band by half" divided by the SMALLER of the two extents, so it only ever asked whether the shorter one was covered. A 44px landing `

` covers a 17px sidebar row's band completely and joined that row at "100%" — while the band covered barely a third of the heading — and the heading's visual centre then read 12px off a line it was never on. Divide by the LARGER instead: the overlap has to cover half the member AND half the band. The threshold is unchanged, still `GEOMETRY_ROW_BAND_OVERLAP`. The fixture is that capture verbatim — the landing heading and the four sidebar row primitives beside it, with the coordinates `workspace:wide-expanded` measured — and it fails under the old rule. No output moves: over the whole capture plan, all 798 row-instance rails are byte-identical (they group by DOM row, so a geometric row cannot reach them), all 12 cross-family rails keep the same member sets, lines and outlier counts, and findings stay at 27 with `new=0 changed=0 resolved=48 rekeyed=0`. What this removes is a latent false positive: a rail-level comparison over geometric rows reported this heading, and only this heading, in 8 of 32 rows. Model: claude-opus-5[1m] --- .../src/lib/chat-workspace-geometry.ts | 31 ++++++++++++------- .../tests/chat-workspace-geometry.test.ts | 17 ++++++++++ 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/packages/components/src/lib/chat-workspace-geometry.ts b/packages/components/src/lib/chat-workspace-geometry.ts index 0c0f95b12..53f17a230 100644 --- a/packages/components/src/lib/chat-workspace-geometry.ts +++ b/packages/components/src/lib/chat-workspace-geometry.ts @@ -448,11 +448,11 @@ export type DiscoveredBlockRail = 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. + * How much of BOTH 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; @@ -1398,11 +1398,13 @@ export function isGeometryPaintedShape(paint: GeometryShapePaint): boolean { * 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. + * on the median member centre — it overlaps by at least half OF BOTH, 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. + * Half of BOTH is what stops the other direction: an extent far taller than the + * row covers its band completely without being on the row's line at all. */ export function assignGeometricRows( extents: readonly GeometricRowExtent[], @@ -1438,8 +1440,13 @@ export function assignGeometricRows( 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; + // SYMMETRIC: the overlap has to cover half the member AND half the band, + // which is what dividing by the LARGER of the two says. Dividing by the + // smaller one let a 44px heading join a 17px row at "100%" — it covered + // that band completely while the band covered a third of it — and the + // heading's centre then read 12px off a line it was never on. + const span = Math.max(height, bandHeight); + const ratio = span > 0 ? shared / span : 0; if (ratio >= minimumBandOverlap && ratio > chosenOverlap) { chosen = rowIndex; chosenOverlap = ratio; diff --git a/packages/components/tests/chat-workspace-geometry.test.ts b/packages/components/tests/chat-workspace-geometry.test.ts index 45277822c..bef990363 100644 --- a/packages/components/tests/chat-workspace-geometry.test.ts +++ b/packages/components/tests/chat-workspace-geometry.test.ts @@ -1188,6 +1188,23 @@ describe('geometric rows', () => { ]); }); + it('keeps a tall heading out of the narrow row it spans', () => { + // `workspace:wide-expanded`, verbatim: the landing `

` and the sidebar + // row beside it. The heading covers the row's 10.5px median band whole, so + // an overlap measured against the SMALLER extent read 100% and put it on + // that line — where its centre then sat 12px "off" a line it was never on. + const landing = [ + extent('h1', 362, 406), + extent('row-title', 363, 380), + extent('row-time', 364, 379), + extent('row-archive', 366.75, 377.25), + extent('row-avatar', 367.5, 376.5), + ]; + const rows = assignGeometricRows(landing); + expect(rows[0]).not.toBe(rows[1]); + expect(new Set(rows.slice(1)).size).toBe(1); + }); + 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 From 7ca616230bde6432f6b4d3e6594330ba353876da Mon Sep 17 00:00:00 2001 From: Lody Dev <62133302+wibus-wee@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:31:25 +0000 Subject: [PATCH 08/10] feat(components): capture the workspace, session and side panel together MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No capture rendered more than ONE region header. The Sidebar's lives in `geometry-chatworkspace--*`, the Session tab bar's in `sessions-sessionconversationpage--*`, the right panel's in `sessions-sessionsidepaneltabbar--*`, and a geometric row is per CAPTURE — so "do these three headers share a line?" was not a question discovery could answer wrongly. It was a question nothing could ask. `WorkspaceSessionSidePanel` composes them through the production shell that positions them: `WebWorkspaceFrame` → `DesktopSessionDetailLayout` → `SessionTabBar` + `SessionSidePanelTabBar`, over the Sidebar, agent-config and session fixtures this story file already declares. 1440×900 at 2×, in `GEOMETRY_SESSION_STATE_CAPTURES`, so the report and the gate walk the same entry. No marker, and no attribute beyond the discovery scope the side panel already declares in its own story. Determinism: `DesktopSessionDetailLayout` persists its split under an `autoSaveId`, so a size left by a previous drag would move every measured x here. The key is cleared during render rather than in an effect, which would race the first paint. Settling is the existing `measureSettledChatWorkspace` (the story id starts with `geometry-chatworkspace--`); no new wait, no sleep. It measures 63 row-instance rails and one cross-family rail, none of which existed before, and the 20 pre-existing captures are untouched: their 798 row-instance rails stay byte-identical. Nine findings follow from rows nobody had measured — the sidebar and side panel at this composition's widths — and `geometry:triage` records them, seven as new `debt` plus two re-keyed reviews carried over from resolved keys. Model: claude-opus-5[1m] --- packages/components/geometry-ledger.json | 96 +++++++++++- .../stories/ChatWorkspaceGeometry.stories.tsx | 139 ++++++++++++++++++ .../e2e/support/geometry-capture-plan.ts | 24 ++- 3 files changed, 252 insertions(+), 7 deletions(-) diff --git a/packages/components/geometry-ledger.json b/packages/components/geometry-ledger.json index 6864c2841..f7dc830c0 100644 --- a/packages/components/geometry-ledger.json +++ b/packages/components/geometry-ledger.json @@ -344,15 +344,51 @@ "surfaceFamily": "right-sidebar" } }, - "geometry/session/1aje3gt": { + "geometry/session/10cevl5": { "status": "debt", "baseline": { - "offset": 1.25 + "offset": -1.5 }, "identity": { - "label": "Cancel", + "label": "Toggle project", + "axis": "x", + "anchor": "inline-start", + "surfaceFamily": "session" + } + }, + "geometry/session/1939a5y": { + "status": "debt", + "baseline": { + "offset": 5 + }, + "identity": { + "label": "text “G” in text row ↔ text “Geometry Lab” in text row", "axis": "y", - "anchor": "visual-center", + "anchor": "text-baseline", + "surfaceFamily": "session" + } + }, + "geometry/session/1a0fdze": { + "status": "debt", + "baseline": { + "offset": -4 + }, + "identity": { + "label": "New session", + "axis": "x", + "anchor": "inline-end", + "surfaceFamily": "session" + } + }, + "geometry/session/1adqfq9": { + "status": "debt", + "baseline": { + "offset": -3.5 + }, + "identity": { + "label": "button “Audit Sidebar semantic baselines”", + "axis": "x", + "anchor": "inline-center", "surfaceFamily": "session" } }, @@ -464,13 +500,37 @@ "surfaceFamily": "session" } }, - "geometry/session/i4jvzg": { + "geometry/session/bamjpu": { + "status": "debt", + "baseline": { + "offset": -4 + }, + "identity": { + "label": "Machine", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "session" + } + }, + "geometry/session/hx7un1": { + "status": "debt", + "baseline": { + "offset": 1.25 + }, + "identity": { + "label": "More actions", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "session" + } + }, + "geometry/session/kvmavf": { "status": "debt", "baseline": { "offset": -1 }, "identity": { - "label": "Private to you: lody is not shared with the team.", + "label": "More actions", "axis": "y", "anchor": "visual-center", "surfaceFamily": "session" @@ -488,6 +548,18 @@ "surfaceFamily": "session" } }, + "geometry/session/n45o1m": { + "status": "debt", + "baseline": { + "offset": -1.5 + }, + "identity": { + "label": "app.tsx", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "session" + } + }, "geometry/session/nu8q04": { "status": "debt", "baseline": { @@ -524,6 +596,18 @@ "surfaceFamily": "session" } }, + "geometry/session/wvh8i8": { + "status": "debt", + "baseline": { + "offset": -3.5 + }, + "identity": { + "label": "Settings", + "axis": "x", + "anchor": "inline-start", + "surfaceFamily": "session" + } + }, "geometry/session/wy7fax": { "status": "debt", "baseline": { diff --git a/packages/components/src/stories/ChatWorkspaceGeometry.stories.tsx b/packages/components/src/stories/ChatWorkspaceGeometry.stories.tsx index 5e99d3f80..f5b3b21f6 100644 --- a/packages/components/src/stories/ChatWorkspaceGeometry.stories.tsx +++ b/packages/components/src/stories/ChatWorkspaceGeometry.stories.tsx @@ -26,11 +26,18 @@ import { import WorkspaceGeometryDevtools from '@/components/devtools/workspace-geometry-devtools'; import { LocalProjectItem } from '@/components/loro-app-sidebar'; import { LoroSidebar } from '@/components/loro-sidebar'; +import { DesktopSessionDetailLayout } from '@/components/sessions/desktop-session-detail-layout'; import { DesktopMachineMenu, DesktopPermissionModeButton, DesktopRunConfigMenu, } from '@/components/sessions/desktop-run-config-menu'; +import { + SessionSidePanelTabBar, + type SessionSidePanelOption, + type SessionSidePanelTabItem, +} from '@/components/sessions/session-side-panel-tab-bar'; +import { SessionTabBar } from '@/components/sessions/session-tab-bar'; import type { AcpConfigOptionSelector, AcpConfigOptionValue, @@ -456,6 +463,134 @@ function ChatWorkspaceGeometryFixture({ ); } +/** + * The three region headers — the workspace Sidebar's, the Session tab bar's and + * the right panel's — render in three separate stories, so no capture has ever + * held more than one of them. A geometric row is per capture, so discovery + * could not compare them even in principle. This composition puts all three on + * one page through the production shell that positions them: + * `WebWorkspaceFrame` → `DesktopSessionDetailLayout` → `SessionTabBar` + + * `SessionSidePanelTabBar`. No markers, and no attribute beyond the discovery + * scope the side panel already declares in its own story. + */ +const GEOMETRY_SIDE_PANEL_TABS: SessionSidePanelTabItem[] = [ + { id: 'files', label: 'Files', kind: 'files', closeable: true }, + { id: 'changes', label: 'All Changes', kind: 'changes', closeable: true }, + { + id: 'file:src/app.tsx', + label: 'app.tsx', + kind: 'file', + filePath: 'src/app.tsx', + closeable: true, + }, +]; +const GEOMETRY_SIDE_PANEL_OPTIONS: SessionSidePanelOption[] = [ + { id: 'browser', label: 'Browser', kind: 'browser' }, + { id: 'pr', label: 'PR', kind: 'pr' }, +]; +const geometryTabSessions: SessionMeta[] = geometryLocalSessions.slice(0, 2); + +/** `DesktopSessionDetailLayout` persists its split under this `autoSaveId`. */ +const GEOMETRY_PANEL_STORAGE_KEY = 'react-resizable-panels:session-detail-panels'; + +function ChatWorkspaceSessionGeometryFixture() { + const store = useMemo(() => { + const nextStore = createStore(); + nextStore.set( + agentConfigMetaCacheAtom, + Object.fromEntries( + geometryAgentConfigs.map((config) => [getAgentConfigRoomId(config.id), config]) + ) + ); + // Cleared during render, not in an effect: a persisted split from a + // previous drag would silently change every measured x in this capture, + // and an effect would race the first paint the capture may already have. + globalThis.localStorage?.removeItem(GEOMETRY_PANEL_STORAGE_KEY); + return nextStore; + }, []); + const [activeSidePanelTabId, setActiveSidePanelTabId] = useState( + 'file:src/app.tsx' + ); + const parentSession = geometryTabSessions[0]; + const childSession = geometryTabSessions[1]; + if (!parentSession || !childSession) throw new Error('Geometry tab fixture is missing a session'); + const sidebarCard = ( + } + sessionListProps={sidebarSessionListProps} + /> + ); + return ( + + +
+ + {}} + onNewTab={() => {}} + /> + } + chatSurfaces={} + terminalDock={null} + secondaryPanel={ +
+ {}} + onTabClose={() => {}} + closeTabLabel={(tabLabel) => `Close ${tabLabel}`} + /> +
+ } + sidebarOpen + onSidebarCollapse={() => {}} + deleteConfirmDialog={null} + /> +
+
+
+
+ ); +} + const meta = { title: 'Geometry/ChatWorkspace', component: ChatWorkspaceGeometryFixture, @@ -498,6 +633,10 @@ export const PastedText: Story = { args: { landingScenario: 'pasted-text' }, }; +export const WorkspaceSessionSidePanel: StoryObj = { + render: () => , +}; + export const GeometryAudit: Story = { render: (args) => ( <> diff --git a/packages/components/tests/e2e/support/geometry-capture-plan.ts b/packages/components/tests/e2e/support/geometry-capture-plan.ts index 757698635..13abff565 100644 --- a/packages/components/tests/e2e/support/geometry-capture-plan.ts +++ b/packages/components/tests/e2e/support/geometry-capture-plan.ts @@ -183,11 +183,33 @@ export const GEOMETRY_SESSION_STATE_CAPTURES: readonly GeometryCapturePlanEntry[ dimensions: DEFAULT_CAPTURE_DIMENSIONS, aggregateScopes: ['session.page'], }, + /** + * The one capture that holds all THREE region headers at once — the workspace + * Sidebar's, the Session tab bar's and the right panel's. Every other capture + * renders exactly one of them, and a geometric row is per CAPTURE, so without + * this the question "do these three headers share a line?" cannot be measured + * at all, whatever discovery does downstream. + */ + { + captureId: 'workspace-session:1440x900', + detailId: 'workspace-session', + area: 'session', + surface: 'Workspace / Session + Side Panel', + storyId: 'geometry-chatworkspace--workspace-session-side-panel', + viewport: WIDE_VIEWPORT, + deviceScaleFactor: 2, + dimensions: DEFAULT_CAPTURE_DIMENSIONS, + aggregateScopes: ['sidebar.shell', 'session.side-panel'], + }, ]; export const GEOMETRY_RIGHT_SIDEBAR_STATE_CAPTURES: readonly GeometryCapturePlanEntry[] = ( [ - ['session-right-sidebar-changes', 'Changes', 'sessions-sessionsidepaneltabbar--geometry-report'], + [ + 'session-right-sidebar-changes', + 'Changes', + 'sessions-sessionsidepaneltabbar--geometry-report', + ], ['session-right-sidebar-tabs', 'Tabs', 'sessions-sessionsidepaneltabbar--unified-tabs'], ['session-right-sidebar-empty', 'Empty', 'sessions-sessionsidepaneltabbar--empty-state'], ] as const From f556461a84172f1a951b5a122d6d4273f131da9c Mon Sep 17 00:00:00 2001 From: Lody Dev <62133302+wibus-wee@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:31:25 +0000 Subject: [PATCH 09/10] test(components): probe a cross-family row where the shift keeps it a row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symmetric membership made the old probe unsound: it picked the first aligned icon, which on the landing chip rail is the member whose height IS the median band. Moving that member drags the band, a shorter member then falls off the row, and the rail vanishes instead of reporting the injection — the gate failed with `probedMember` undefined rather than with an offset. Pick the TALLEST member that still clears the overlap after the shift, and say the arithmetic — `(band - shift) / height >= GEOMETRY_ROW_BAND_OVERLAP` — in the test rather than trusting a particular icon to survive. The band height is a median, so the tallest member never sets it and moving it leaves the row intact. Model: claude-opus-5[1m] --- .../tests/e2e/chat-workspace-geometry.spec.ts | 41 ++++++++++++++++--- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/packages/components/tests/e2e/chat-workspace-geometry.spec.ts b/packages/components/tests/e2e/chat-workspace-geometry.spec.ts index b6775d96b..3d9cb4772 100644 --- a/packages/components/tests/e2e/chat-workspace-geometry.spec.ts +++ b/packages/components/tests/e2e/chat-workspace-geometry.spec.ts @@ -9,6 +9,7 @@ import { CHAT_WORKSPACE_RAIL_DISCOVERY_ATTRIBUTE, CHAT_WORKSPACE_SEMANTIC_ALIGNMENT_ATTRIBUTES, type DiscoveredBlockRail, + GEOMETRY_ROW_BAND_OVERLAP, resolveMainPaneGridRange, validateChatWorkspaceGeometry, } from '../../src/lib/chat-workspace-geometry'; @@ -501,22 +502,52 @@ test('a cross-family geometric row reports its outlier and no DOM row', async ({ const before = await discoverChatWorkspaceBlockRails(page); expect(crossFamily(before).length, 'no cross-family geometric row to probe').toBeGreaterThan(0); + const probeShift = 2; // 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'); + // + // Row membership is SYMMETRIC, so the probe also has to still be ON the row + // once it has moved, or the rail loses the member instead of reporting it. + // A member keeps half of both extents when `(band - shift) / member` clears + // the overlap, which is what this asks before choosing it — the gate states + // the arithmetic rather than trusting a particular icon to survive. + const rowBand = (rail: DiscoveredBlockRail) => { + const heights = rail.members + .map((member) => member.yEnd - member.yStart) + .filter((height) => height > 0) + .sort((first, second) => first - second); + const middle = Math.floor(heights.length / 2); + if (heights.length === 0) return 0; + return heights.length % 2 === 1 + ? (heights[middle] ?? 0) + : ((heights[middle - 1] ?? 0) + (heights[middle] ?? 0)) / 2; + }; + // The TALLEST such member, because the band height is a MEDIAN: moving the + // member that currently sets it drags the whole band, and a shorter member + // then falls off the row — the rail vanishes instead of reporting anything. + const probeOf = (rail: DiscoveredBlockRail) => { + const band = rowBand(rail); + return [...rail.members] + .sort((first, second) => second.yEnd - second.yStart - (first.yEnd - first.yStart)) + .find((member) => { + const height = member.yEnd - member.yStart; + return ( + !member.outlier && + height > band && + (band - probeShift) / height >= GEOMETRY_ROW_BAND_OVERLAP + ); + }); + }; 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(); + expect(target, 'no tight cross-family row with a probe that survives the shift').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; From 644c77b3b14ce95afb9803081dffe6e418c69dd9 Mon Sep 17 00:00:00 2001 From: Lody Dev <62133302+wibus-wee@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:31:25 +0000 Subject: [PATCH 10/10] docs(components): record the symmetric geometric-row rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Row membership is half of BOTH extents, not half of the smaller one. Names the case that forced it — a 44px heading covering a 17px row's band whole — beside the chaining rule it sits next to, since the two guard opposite directions of the same test. Model: claude-opus-5[1m] --- packages/components/src/lib/AGENTS.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/components/src/lib/AGENTS.md b/packages/components/src/lib/AGENTS.md index 058ffdec2..420f4d6a0 100644 --- a/packages/components/src/lib/AGENTS.md +++ b/packages/components/src/lib/AGENTS.md @@ -97,12 +97,12 @@ the overlay, alignment lines and spacing diagnostics; dev only. - 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. + band it overlaps by half OF BOTH. To that band, never neighbour to neighbour, or half-overlaps + chain two lines of different heights into one, as chaining intermediate X coordinates would + merge indentation levels. Of BOTH, or an extent far taller covers the band whole while + sitting off its line — a 44px heading joined a 17px row that way. The DOM row is a PRIOR + setting the EVIDENCE BAR, never eligibility; as a gate it hid every cross-structural line by + construction. An atom no row accepted is measured on Y under its own row id. - 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