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 = (