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/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/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/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/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, 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. 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(), 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); 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 }, { 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 = (