From f6873e204112a5ac4ab231ec77e12187cb71344e Mon Sep 17 00:00:00 2001 From: Wibus <62133302+wibus-wee@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:38:30 +0000 Subject: [PATCH 1/3] fix(components): align sidebar rows to a shared trailing rail Introduce `--spacing-sidebar-trailing` (9px) as the one token for a bordered row's trailing inset (8px padding + 1px transparent border) and apply it where a header or accordion trigger has no border of its own to contribute that last pixel: the Sidebar section header, session group header trailing action, local project row, and the changes sidebar's category header stats. Anchor the changes-sidebar category stats to the trailing edge with the same wrapper the file rows use, so they no longer ride the category label and slide with its width. Found by the geometry constraint system's alignment discovery. Model: claude-sonnet-5 --- .../src/components/loro-app-sidebar.tsx | 10 ++++-- .../src/components/session-list.tsx | 5 ++- .../sessions/session-changes-sidebar.tsx | 32 +++++++++++++------ .../src/components/sidebar-row-shared.tsx | 5 ++- packages/components/src/tailwind/index.css | 10 ++++++ 5 files changed, 48 insertions(+), 14 deletions(-) diff --git a/packages/components/src/components/loro-app-sidebar.tsx b/packages/components/src/components/loro-app-sidebar.tsx index a5724c01f..c116ecfcc 100644 --- a/packages/components/src/components/loro-app-sidebar.tsx +++ b/packages/components/src/components/loro-app-sidebar.tsx @@ -1065,14 +1065,20 @@ export const LocalProjectItem = memo(function LocalProjectItem({ data-scope-item="row" data-sidebar-project-key={`${machineId}:${project.id}`} className={cn( - 'group relative w-full rounded-md pl-2 pr-3 py-1 text-left', + // px-2 + the 1px transparent border below puts this row's trailing + // content on the shared 9px sidebar trailing rail, like session rows. + 'group relative w-full rounded-md px-2 py-1 text-left', 'border border-transparent bg-transparent', !showSelectedState && !isMobile && 'hover:bg-sidebar-hover hover:text-sidebar-hover-foreground', showSelectedState && 'border-sidebar-ring/30 bg-sidebar-selection hover:bg-sidebar-selection', - 'flex min-w-0 flex-1 select-none items-center gap-2 text-xs font-semibold transition-colors', + // gap-1.5, matching the session rows below: the leading control's + // -mr-1.5 already trims its 20px box down to the shared 14px icon + // advance, so only this gap decides whether the project name lands + // on the same leading rail as the session titles it contains. + 'flex min-w-0 flex-1 select-none items-center gap-1.5 text-xs font-semibold transition-colors', projectCanNavigate ? 'cursor-pointer' : 'cursor-default', removalState && 'text-muted-foreground', showSelectedState diff --git a/packages/components/src/components/session-list.tsx b/packages/components/src/components/session-list.tsx index 51833f124..06c01cbca 100644 --- a/packages/components/src/components/session-list.tsx +++ b/packages/components/src/components/session-list.tsx @@ -675,7 +675,10 @@ const SessionGroupSection = memo(function SessionGroupSection({ >
@@ -215,15 +218,24 @@ export function SessionChangesSidebar({ {group.entries.length} - entry.statsUnavailable) - ? diffStatsUnavailableLabel - : undefined - } - /> + {/* + Same trailing treatment as `RowTrailing`: ml-auto anchors the pair + to the trailing edge (without it the stats ride on the category + label and slide with its width), and text-[11px] is what the rows + and the panel header give these spans -- inherited here, they would + render at the trigger's own size. + */} + + entry.statsUnavailable) + ? diffStatsUnavailableLabel + : undefined + } + /> + diff --git a/packages/components/src/components/sidebar-row-shared.tsx b/packages/components/src/components/sidebar-row-shared.tsx index ed9183604..5417bcd85 100644 --- a/packages/components/src/components/sidebar-row-shared.tsx +++ b/packages/components/src/components/sidebar-row-shared.tsx @@ -754,7 +754,10 @@ export function SidebarSectionHeader({ return (
Date: Thu, 3 Sep 2026 02:38:30 +0000 Subject: [PATCH 2/3] feat(components): split the geometry constraint system into a reviewable pipeline Factor `geometry-constraint-system.ts` (pipeline, ledger, contracts, tokens, metrics, classification) and `geometry-text-cap-band.ts` (the one optical text measurement) out of `chat-workspace-geometry.ts`, so capture and the `?geometry=1` overlay share one cap-height measurement and the tabular-nums bug in the overlay is gone with it. Ledger and gate: - `geometry-ledger.json` / `geometry-contracts.json` are the checked-in review state: findings default to `accepted-debt`, and a contract compiles only `promoted` entries. `scripts/triage-geometry-findings.mjs` (root `pnpm geometry:triage `) merges a fresh findings.json into the ledger, re-keying resolved reviews across structural changes instead of reporting them as new. - Root `pnpm test:geometry` runs the Playwright gate in CI; the workflow now installs Chromium for it. Docs: add `tests/e2e/AGENTS.md` and `tests/e2e/support/AGENTS.md` (with CLAUDE.md symlinks) recording the X/Y rail discovery rules, finding identity and re-keying, the ledger/contract/gate algebra, and what counts as ink vs. a layout box. Update the package and lib AGENTS.md to point at them. Model: claude-sonnet-5 --- .github/workflows/ci.yml | 3 + package.json | 4 +- packages/components/.gitignore | 1 + packages/components/AGENTS.md | 82 +- packages/components/geometry-contracts.json | 70 + packages/components/geometry-ledger.json | 973 ++++++ ...enerate-chat-workspace-geometry-report.mjs | 21 +- .../chat-workspace-geometry-report.html | 269 +- .../scripts/triage-geometry-findings.mjs | 91 + .../devtools/workspace-geometry-devtools.tsx | 43 +- packages/components/src/lib/AGENTS.md | 168 +- .../src/lib/chat-workspace-geometry.ts | 339 +- .../src/lib/geometry-constraint-system.ts | 2879 +++++++++++++++++ .../src/lib/geometry-text-cap-band.ts | 84 + .../tests/chat-workspace-geometry.test.ts | 325 ++ packages/components/tests/e2e/AGENTS.md | 118 + packages/components/tests/e2e/CLAUDE.md | 1 + .../chat-workspace-geometry-report.spec.ts | 1641 ++++++++-- .../tests/e2e/chat-workspace-geometry.spec.ts | 211 +- .../components/tests/e2e/support/AGENTS.md | 90 + .../components/tests/e2e/support/CLAUDE.md | 1 + .../e2e/support/chat-workspace-geometry.ts | 1574 ++++++++- .../tests/geometry-constraint-system.test.ts | 1751 ++++++++++ 23 files changed, 10150 insertions(+), 589 deletions(-) create mode 100644 packages/components/geometry-contracts.json create mode 100644 packages/components/geometry-ledger.json create mode 100644 packages/components/scripts/triage-geometry-findings.mjs create mode 100644 packages/components/src/lib/geometry-constraint-system.ts create mode 100644 packages/components/src/lib/geometry-text-cap-band.ts create mode 100644 packages/components/tests/e2e/AGENTS.md create mode 120000 packages/components/tests/e2e/CLAUDE.md create mode 100644 packages/components/tests/e2e/support/AGENTS.md create mode 120000 packages/components/tests/e2e/support/CLAUDE.md create mode 100644 packages/components/tests/geometry-constraint-system.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21f972a10..1b19ad75c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,9 @@ jobs: - name: Prepare ACP adapters run: pnpm --filter lody prepare:acp-adapters + - name: Install geometry test browser + run: pnpm --filter @lody/components exec playwright install --with-deps chromium + - name: Run tests env: GIT_CONFIG_COUNT: 1 diff --git a/package.json b/package.json index e86a0ae2e..0461b7149 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,9 @@ "format:check": "corepack pnpm -r --filter '!acp-extension-claude' --filter '!acp-extension-codex' run format:check", "typecheck": "corepack pnpm --filter lody prepare:acp-adapters && corepack pnpm -r --workspace-concurrency=1 --filter '!acp-extension-claude' --filter '!acp-extension-codex' run typecheck", "test": "corepack pnpm -r --workspace-concurrency=1 run test", - "test:ci": "corepack pnpm -r --workspace-concurrency=2 --filter \"!@lody/electron\" --filter \"!acp-extension-codex\" --filter \"!acp-extension-claude\" run test --maxWorkers=2 && corepack pnpm --filter @lody/electron run test", + "test:ci": "corepack pnpm -r --workspace-concurrency=2 --filter \"!@lody/electron\" --filter \"!acp-extension-codex\" --filter \"!acp-extension-claude\" run test --maxWorkers=2 && corepack pnpm --filter @lody/electron run test && corepack pnpm test:geometry", + "test:geometry": "corepack pnpm --filter @lody/components test:geometry", + "geometry:triage": "node packages/components/scripts/triage-geometry-findings.mjs", "test:watch": "corepack pnpm -r run test:watch", "test:coverage": "corepack pnpm -r run test:coverage", "lint:fast": "oxlint --quiet --ignore-pattern packages/acp-extension-kimi", diff --git a/packages/components/.gitignore b/packages/components/.gitignore index e342d4a81..5d4096825 100644 --- a/packages/components/.gitignore +++ b/packages/components/.gitignore @@ -5,3 +5,4 @@ test-results/ playwright-report/ playwright/.cache/ geometry-report/ +geometry-report-next/ diff --git a/packages/components/AGENTS.md b/packages/components/AGENTS.md index d70772b67..31869d08e 100644 --- a/packages/components/AGENTS.md +++ b/packages/components/AGENTS.md @@ -14,84 +14,10 @@ mobile surfaces. - Compact number units (K/M/B vs 万/亿) follow the product language via `toIntlLocaleOrEn` / `formatCompactNumber`, never the host OS locale. - Prefer shared primitives from `src/components/ui` over private replacements. -- `src/lib/chat-workspace-geometry.ts` owns the bootstrap mathematical design grid - for the authenticated Web chat workspace. Its current numeric grid is a reviewed - reference, not evidence that the product was automatically inferred. Production - layout stays ordinary Flex/Grid and exposes only stable geometry data markers; - never turn its columns into - component props or wrapper DOM. The Storybook fixture and Playwright gate - consume the same spec. In development, `?geometry=1` adds the reference overlay - for both the Sidebar-local and Main Pane grids, semantic alignment lines, - spacing-rhythm diagnostics; none mounts in production or tests. - Semantic alignment compares explicit control boxes: repeated slots may share - an X-axis line across rows, while icon/text controls within one row may share a - Y-axis instance. Cross-font rows use visual ink centers: text comes from the - rendered font's actual ascent/descent metrics, SVGs from their transformed path - bounds, and CSS shapes from their visible boxes. Typographic baselines compare - text only; glyph weight and perceived balance remain CV concerns. Named groups - expose stable member labels; diagnostics place guides at the median and assess the - complete member spread, never DOM order. A measurable spread above tolerance but at - most 1px is `sub-pixel-jitter`, stays folded in reports, and never enters the gate; - larger spreads are violations. Groups below their required member count are - `insufficient-evidence`, never aligned. Padding, margin, gap, and line-height multiples - are spacing diagnostics, never alignment violations. Diagnostic debt remains - non-blocking until a rule is explicitly promoted into the Playwright gate. - Alignment-rail discovery is an earlier, heuristic stage. It mines repeated sibling - subtrees from DOM topology and geometry; each repeated subtree instance contributes - at most one member to a start/center/end rail, so a control and its nested icon cannot - manufacture support. `data-geometry-discovery-scope` remains an optional hint for - named or aggregate regions, never a prerequisite for discovery. Coordinate modes with - repeated row support establish rails independent of DOM order before nearby singleton - observations attach to the nearest mode; intermediate coordinates must not chain distinct - indentation levels together. Discovery uses final rendered coordinates, not component - structure or row-container boundaries, to infer multiple stable visual rails. Every candidate - remains eligible for its nearest rail across the full scope; broad scans deliberately prefer - an extra review candidate over silently excusing a shifted module or indentation level. Two - repeated visible rows are sufficient to establish a local indentation rail; do not absorb a - legitimate two-row visual level into a nearby rail merely because a broader rail has more support. - A rail established by one rendered primitive kind accepts only that kind; cross-kind attachment - requires mixed support on the rail itself so a coincidentally nearby icon is not judged by a text rail. - Coordinate peaks merge only within the inlier tolerance. Two repeated peaks one CSS pixel apart are - distinct visual levels; do not median them into one rail and report the smaller peak as misaligned. - Members outside the inlier tolerance are reported as outliers, and confidence includes the - rail's span relative to its containing scope rather than the minimum-span admission threshold. Flow - text may contribute start/end edges but never a center rail because its box center changes - with content and wrapping; center evidence comes from controls with an explicit geometry. - Numeric text, including signed diff statistics, canonically uses its trailing edge so changing - digit counts do not manufacture leading-edge outliers in right-aligned columns. - Alignment discovery measures visible primitives in ink space: text through rendered `Range` - bounds, SVGs through transformed path bounds, and images through their painted boxes. A padded - control or container is a separate layout-box observation and must never cluster with an ink - rail; spacing diagnostics own padding, margin, border, and gap measurements. - Discovery must not read semantic-alignment attributes: it derives visual rows and their - direct layout slots from ordinary DOM topology, including transparent hover controls that - still occupy layout. It preserves each slot's start/center/end family until all captures - choose one canonical anchor together. Contract inference samples every workspace - verification viewport, caps normalized merging at 4 physical pixels, and counts missing - scope observations in capture coverage. Rails with the same topology signature and - normalized position across captures may become evidence-backed contract proposals. A - discovered rail or proposal is not layout intent and cannot pass or fail the gate until - a contract compiler binds it to stable - semantic members and review promotes it into a named rule. Geometry-report capture covers - the workspace Sidebar, the production-composed session right sidebar, and session states that - materially change visible geometry; it does not duplicate an isomorphic conversation layout - solely for a transient interaction such as mention drop. Each report detail persists the - capture id whose coverage entry owns its Story, viewport, and device scale. `--after` must - replay that original capture and clip, then append only the repair image; never rediscover - findings or replace the before/guide evidence, because a successful fix may remove the original - candidate. The fixed HTML renderer parses one embedded `application/json` payload, while every - screenshot remains a referenced file rather than Base64 data. - Violation images label every deviating member in place with its human-readable role, physical - direction, measured offset, actual anchor, and a leader to the rendered element; a bare - internal member id or an unlabelled shared line is not an actionable design finding. - Discovery cards use product-region names rather than scope ids, count unique rendered - elements rather than repeated anchor votes, and group one element's start/center/end - offsets into one in-image annotation. Candidate rails stay visually subordinate to - emphasized outliers because a heuristic proposal is not yet a violation. Report capture - mode disables hover interaction and transitions, preserves explicit hover-action/rest - swaps, and automatically reveals transparent containers that own interactive controls. - Every measured control must therefore remain visible in both clean and annotated images - without requiring business components to carry geometry-only markers. +- The geometry constraint system measures rendered layout, turns it into reviewable findings, + and gates only what a human promoted. Invariants: [tests/e2e/AGENTS.md](tests/e2e/AGENTS.md) + (pipeline, identity, contracts, gate, report), [src/lib/AGENTS.md](src/lib/AGENTS.md) (grid). + Review state: `geometry-ledger.json`, `geometry-contracts.json`. Commands: `pnpm --filter @lody/components geometry:report [dir]`, root `pnpm geometry:triage `. - `ui/emoji-picker.tsx` is the shadcn `frimousse` registry component, with its two copy strings on i18n rather than the registry's inline English. Its dataset SHIPS WITH THE APP: `frimousse` otherwise fetches diff --git a/packages/components/geometry-contracts.json b/packages/components/geometry-contracts.json new file mode 100644 index 000000000..51ae35d89 --- /dev/null +++ b/packages/components/geometry-contracts.json @@ -0,0 +1,70 @@ +{ + "version": 1, + "tokens": { + "sidebar.trailingInset": { + "unit": "px", + "cssVariable": "--spacing-sidebar-trailing", + "expected": 9 + } + }, + "contracts": [ + { + "name": "workspace.sidebar.primary-trailing-actions", + "story": "geometry-chatworkspace--expanded-sidebar", + "members": [ + { + "role": "button", + "name": "New session" + }, + { + "role": "button", + "name": "Remove project" + }, + { + "role": "button", + "name": "Archive", + "rowFamily": "div[button]>div[text]", + "all": true + } + ], + "axis": "x", + "anchor": "inline-end", + "space": "layout-box", + "tolerance": 1, + "findingKey": "geometry/workspace/lvwy4w" + }, + { + "name": "workspace.sidebar.trailing-inset", + "story": "geometry-chatworkspace--expanded-sidebar", + "members": [ + { + "role": "text", + "selfFamily": "div[text]>div[button],div[text]" + }, + { + "role": "text", + "selfFamily": "div[text]>div[button],button[button]" + }, + { + "role": "button", + "rowFamily": "div[text]>div[button]", + "roleIndex": 0, + "all": true + } + ], + "axis": "x", + "anchor": "inline-end", + "space": "layout-box", + "tolerance": 0, + "relation": { + "kind": "box-model-sum-equals-token", + "properties": [ + "padding-inline-end", + "border-inline-end-width" + ], + "token": "sidebar.trailingInset" + }, + "findingKey": "geometry/workspace/sidebar-trailing-inset" + } + ] +} diff --git a/packages/components/geometry-ledger.json b/packages/components/geometry-ledger.json new file mode 100644 index 000000000..35e2e3214 --- /dev/null +++ b/packages/components/geometry-ledger.json @@ -0,0 +1,973 @@ +{ + "version": 1, + "tokens": { + "sidebar.trailingInset": { + "unit": "px", + "cssVariable": "--spacing-sidebar-trailing", + "expected": 9 + } + }, + "findings": { + "geometry/right-sidebar/11pscs0": { + "status": "accepted-debt", + "baseline": { + "offset": 2 + }, + "identity": { + "label": "geometry-validation.md + 40 − 8", + "axis": "y", + "anchor": "text-baseline", + "surfaceFamily": "right-sidebar" + } + }, + "geometry/right-sidebar/171u7h8": { + "status": "accepted-debt", + "baseline": { + "offset": 1 + }, + "identity": { + "label": "Code 2 + 100 − 20", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "right-sidebar" + } + }, + "geometry/right-sidebar/177rmie": { + "status": "accepted-debt", + "baseline": { + "offset": 2 + }, + "identity": { + "label": "session-layout.md + 60 − 12", + "axis": "y", + "anchor": "text-baseline", + "surfaceFamily": "right-sidebar" + } + }, + "geometry/right-sidebar/1e5wvsl": { + "status": "accepted-debt", + "baseline": { + "offset": 4 + }, + "identity": { + "label": "Files", + "axis": "x", + "anchor": "inline-end", + "surfaceFamily": "right-sidebar" + } + }, + "geometry/right-sidebar/1ls7j8z": { + "status": "accepted-debt", + "baseline": { + "offset": 0.75 + }, + "identity": { + "label": "session-layout.md + 60 − 12", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "right-sidebar" + } + }, + "geometry/right-sidebar/1mxwyl5": { + "status": "accepted-debt", + "baseline": { + "offset": -1 + }, + "identity": { + "label": "app.tsx", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "right-sidebar" + } + }, + "geometry/right-sidebar/1r5c99a": { + "status": "accepted-debt", + "baseline": { + "offset": 1 + }, + "identity": { + "label": "Code 2 + 100 − 20", + "axis": "y", + "anchor": "block-center", + "surfaceFamily": "right-sidebar" + } + }, + "geometry/right-sidebar/1simiqu": { + "status": "accepted-debt", + "baseline": { + "offset": -1 + }, + "identity": { + "label": "app.tsx", + "axis": "y", + "anchor": "block-center", + "surfaceFamily": "right-sidebar" + } + }, + "geometry/right-sidebar/1vnwrqv": { + "status": "accepted-debt", + "baseline": { + "offset": 0.75 + }, + "identity": { + "label": "Add panel", + "axis": "y", + "anchor": "block-start", + "surfaceFamily": "right-sidebar" + } + }, + "geometry/right-sidebar/57h0zw": { + "status": "accepted-debt", + "baseline": { + "offset": -1.5 + }, + "identity": { + "label": "app.tsx", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "right-sidebar" + } + }, + "geometry/right-sidebar/6ijj9r": { + "status": "accepted-debt", + "baseline": { + "offset": 0.75 + }, + "identity": { + "label": "Docs 2 + 100 − 20", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "right-sidebar" + } + }, + "geometry/right-sidebar/6q3vy3": { + "status": "accepted-debt", + "baseline": { + "offset": -1.5 + }, + "identity": { + "label": "app.tsx", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "right-sidebar" + } + }, + "geometry/right-sidebar/8s2gxh": { + "status": "accepted-debt", + "baseline": { + "offset": 2 + }, + "identity": { + "label": "session-detail.tsx + 60 − 12", + "axis": "y", + "anchor": "text-baseline", + "surfaceFamily": "right-sidebar" + } + }, + "geometry/right-sidebar/b0m4s": { + "status": "accepted-debt", + "baseline": { + "offset": 6.5 + }, + "identity": { + "label": "Files", + "axis": "x", + "anchor": "inline-start", + "surfaceFamily": "right-sidebar" + } + }, + "geometry/right-sidebar/b8dyq5": { + "status": "accepted-debt", + "baseline": { + "offset": 0.75 + }, + "identity": { + "label": "session-detail.tsx + 60 − 12", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "right-sidebar" + } + }, + "geometry/right-sidebar/d3cw9u": { + "status": "accepted-debt", + "baseline": { + "offset": 0.75 + }, + "identity": { + "label": "session-detail.tsx + 60 − 12", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "right-sidebar" + } + }, + "geometry/right-sidebar/d4c4q": { + "status": "accepted-debt", + "baseline": { + "offset": 0.75 + }, + "identity": { + "label": "geometry-validation.md + 40 − 8", + "axis": "y", + "anchor": "block-center", + "surfaceFamily": "right-sidebar" + } + }, + "geometry/right-sidebar/daf70p": { + "status": "accepted-debt", + "baseline": { + "offset": -1 + }, + "identity": { + "label": "Conversation Diff", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "right-sidebar" + } + }, + "geometry/right-sidebar/gvz5jb": { + "status": "accepted-debt", + "baseline": { + "offset": -0.75 + }, + "identity": { + "label": "button #1 in row", + "axis": "y", + "anchor": "block-start", + "surfaceFamily": "right-sidebar" + } + }, + "geometry/right-sidebar/hdgowf": { + "status": "accepted-debt", + "baseline": { + "offset": 2 + }, + "identity": { + "label": "chat-workspace-geometry.ts + 40 − 8", + "axis": "y", + "anchor": "text-baseline", + "surfaceFamily": "right-sidebar" + } + }, + "geometry/right-sidebar/im7aci": { + "status": "accepted-debt", + "baseline": { + "offset": 0.75 + }, + "identity": { + "label": "button #1 in row", + "axis": "y", + "anchor": "block-end", + "surfaceFamily": "right-sidebar" + } + }, + "geometry/right-sidebar/kbsjam": { + "status": "accepted-debt", + "baseline": { + "offset": -1 + }, + "identity": { + "label": "Conversation Diff", + "axis": "y", + "anchor": "block-center", + "surfaceFamily": "right-sidebar" + } + }, + "geometry/right-sidebar/l4kgf": { + "status": "accepted-debt", + "baseline": { + "offset": 0.75 + }, + "identity": { + "label": "chat-workspace-geometry.ts + 40 − 8", + "axis": "y", + "anchor": "block-center", + "surfaceFamily": "right-sidebar" + } + }, + "geometry/right-sidebar/nerzug": { + "status": "accepted-debt", + "baseline": { + "offset": 1 + }, + "identity": { + "label": "Docs 2 + 100 − 20", + "axis": "y", + "anchor": "block-center", + "surfaceFamily": "right-sidebar" + } + }, + "geometry/right-sidebar/u9ipw4": { + "status": "accepted-debt", + "baseline": { + "offset": 0.75 + }, + "identity": { + "label": "chat-workspace-geometry.ts + 40 − 8", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "right-sidebar" + } + }, + "geometry/right-sidebar/vvedhu": { + "status": "accepted-debt", + "baseline": { + "offset": -0.75 + }, + "identity": { + "label": "Add panel", + "axis": "y", + "anchor": "block-end", + "surfaceFamily": "right-sidebar" + } + }, + "geometry/right-sidebar/x7rij5": { + "status": "accepted-debt", + "baseline": { + "offset": 1 + }, + "identity": { + "label": "Docs 2 + 100 − 20", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "right-sidebar" + } + }, + "geometry/right-sidebar/xrp5i4": { + "status": "accepted-debt", + "baseline": { + "offset": 0.75 + }, + "identity": { + "label": "session-layout.md + 60 − 12", + "axis": "y", + "anchor": "block-center", + "surfaceFamily": "right-sidebar" + } + }, + "geometry/session/1aje3gt": { + "status": "accepted-debt", + "baseline": { + "offset": 1.25 + }, + "identity": { + "label": "Cancel", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "session" + } + }, + "geometry/session/1euuz5v": { + "status": "accepted-debt", + "baseline": { + "offset": -3 + }, + "identity": { + "label": "text “Tighten the mobile spacing after the permission” in text row", + "axis": "y", + "anchor": "block-end", + "surfaceFamily": "session" + } + }, + "geometry/session/1p4pgog": { + "status": "accepted-debt", + "baseline": { + "offset": -3 + }, + "identity": { + "label": "text “Tighten the mobile spacing after the permission” in text row", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "session" + } + }, + "geometry/session/1t257v2": { + "status": "accepted-debt", + "baseline": { + "offset": -1.25 + }, + "identity": { + "label": "Private to you: lody is not shared with the team.", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "session" + } + }, + "geometry/session/1xffq47": { + "status": "accepted-debt", + "baseline": { + "offset": -1 + }, + "identity": { + "label": "Private to you: lody is not shared with the team.", + "axis": "y", + "anchor": "text-baseline", + "surfaceFamily": "session" + } + }, + "geometry/session/1y86m8m": { + "status": "accepted-debt", + "baseline": { + "offset": 25 + }, + "identity": { + "label": "Message", + "axis": "y", + "anchor": "block-end", + "surfaceFamily": "session" + } + }, + "geometry/session/37kibv": { + "status": "accepted-debt", + "baseline": { + "offset": -3 + }, + "identity": { + "label": "text “Tighten the mobile spacing after the permission” in text row", + "axis": "y", + "anchor": "block-center", + "surfaceFamily": "session" + } + }, + "geometry/session/5875hb": { + "status": "accepted-debt", + "baseline": { + "offset": 2.5 + }, + "identity": { + "label": "text “Which session page state should we iterate on fi” in row ↔ Cancel", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "session" + } + }, + "geometry/session/88iul9": { + "status": "accepted-debt", + "baseline": { + "offset": 9.5 + }, + "identity": { + "label": "Always allow edits in this session for files under packages/components/src/components/sessions/ ↔ Always allow edits in this session for files under packages/components/src/components/sessions/", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "session" + } + }, + "geometry/session/9slq61": { + "status": "accepted-debt", + "baseline": { + "offset": 3 + }, + "identity": { + "label": "text “Message” in text row", + "axis": "y", + "anchor": "block-start", + "surfaceFamily": "session" + } + }, + "geometry/session/i4jvzg": { + "status": "accepted-debt", + "baseline": { + "offset": -1 + }, + "identity": { + "label": "Private to you: lody is not shared with the team.", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "session" + } + }, + "geometry/session/le2uqr": { + "status": "accepted-debt", + "baseline": { + "offset": -3 + }, + "identity": { + "label": "Message", + "axis": "y", + "anchor": "block-start", + "surfaceFamily": "session" + } + }, + "geometry/session/nu8q04": { + "status": "accepted-debt", + "baseline": { + "offset": 1.25 + }, + "identity": { + "label": "Cancel", + "axis": "y", + "anchor": "block-center", + "surfaceFamily": "session" + } + }, + "geometry/session/pothvh": { + "status": "accepted-debt", + "baseline": { + "offset": -1.25 + }, + "identity": { + "label": "text “Which session page state should we iterate on fi” in row", + "axis": "y", + "anchor": "block-center", + "surfaceFamily": "session" + } + }, + "geometry/session/r7rfec": { + "status": "accepted-debt", + "baseline": { + "offset": 1.5 + }, + "identity": { + "label": "text “Message” in text row", + "axis": "y", + "anchor": "text-baseline", + "surfaceFamily": "session" + } + }, + "geometry/session/wy7fax": { + "status": "accepted-debt", + "baseline": { + "offset": 12.5 + }, + "identity": { + "label": "Message ↔ text “Tighten the mobile spacing after the permission” in text row", + "axis": "y", + "anchor": "block-center", + "surfaceFamily": "session" + } + }, + "geometry/session/y2yn1n": { + "status": "accepted-debt", + "baseline": { + "offset": -1.5 + }, + "identity": { + "label": "text “Tighten the mobile spacing after the permission” in text row", + "axis": "y", + "anchor": "text-baseline", + "surfaceFamily": "session" + } + }, + "geometry/session/zhdd1c": { + "status": "accepted-debt", + "baseline": { + "offset": 9.5 + }, + "identity": { + "label": "Message", + "axis": "y", + "anchor": "block-center", + "surfaceFamily": "session" + } + }, + "geometry/workspace/14n6pir": { + "status": "accepted-debt", + "baseline": { + "offset": 1 + }, + "identity": { + "label": "text “Geometry Lab” in text row", + "axis": "y", + "anchor": "block-end", + "surfaceFamily": "workspace" + } + }, + "geometry/workspace/1fccih": { + "status": "accepted-debt", + "baseline": { + "offset": 1.75 + }, + "identity": { + "label": "Machine", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "workspace" + } + }, + "geometry/workspace/1jrw0q7": { + "status": "accepted-debt", + "baseline": { + "offset": -0.75 + }, + "identity": { + "label": "More actions", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "workspace" + } + }, + "geometry/workspace/1lbwj6e": { + "status": "accepted-debt", + "baseline": { + "offset": -1.5 + }, + "identity": { + "label": "text “Geometry Lab” in text row", + "axis": "y", + "anchor": "block-start", + "surfaceFamily": "workspace" + } + }, + "geometry/workspace/1my7gbo": { + "status": "accepted-debt", + "baseline": { + "offset": -2.5 + }, + "identity": { + "label": "text “G” in text row", + "axis": "y", + "anchor": "text-baseline", + "surfaceFamily": "workspace" + } + }, + "geometry/workspace/1oezpj5": { + "status": "accepted-debt", + "baseline": { + "offset": 5.269230769230769 + }, + "identity": { + "label": "Message", + "axis": "y", + "anchor": "block-center", + "surfaceFamily": "workspace" + } + }, + "geometry/workspace/1p79krt": { + "status": "accepted-debt", + "baseline": { + "offset": -4 + }, + "identity": { + "label": "New session", + "axis": "x", + "anchor": "inline-end", + "surfaceFamily": "workspace" + } + }, + "geometry/workspace/1t2nsoi": { + "status": "accepted-debt", + "baseline": { + "offset": -0.8181818181818182 + }, + "identity": { + "label": "More actions", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "workspace" + } + }, + "geometry/workspace/1y8h8dm": { + "status": "accepted-debt", + "baseline": { + "offset": -1.375 + }, + "identity": { + "label": "Toggle project", + "axis": "x", + "anchor": "inline-start", + "surfaceFamily": "workspace" + } + }, + "geometry/workspace/1ysr2o": { + "status": "accepted-debt", + "baseline": { + "offset": -0.75 + }, + "identity": { + "label": "更多操作", + "axis": "y", + "anchor": "block-center", + "surfaceFamily": "workspace" + } + }, + "geometry/workspace/23ysa1": { + "status": "accepted-debt", + "baseline": { + "offset": 1.5 + }, + "identity": { + "label": "text “G” in text row", + "axis": "y", + "anchor": "block-start", + "surfaceFamily": "workspace" + } + }, + "geometry/workspace/3qj791": { + "status": "accepted-debt", + "baseline": { + "offset": -4.076923076923077 + }, + "identity": { + "label": "text “Message” in text row", + "axis": "y", + "anchor": "block-center", + "surfaceFamily": "workspace" + } + }, + "geometry/workspace/4gx82n": { + "status": "accepted-debt", + "baseline": { + "offset": 2.25 + }, + "identity": { + "label": "机器", + "axis": "y", + "anchor": "text-baseline", + "surfaceFamily": "workspace" + } + }, + "geometry/workspace/4ik93p": { + "status": "accepted-debt", + "baseline": { + "offset": -3.375 + }, + "identity": { + "label": "Settings", + "axis": "x", + "anchor": "inline-start", + "surfaceFamily": "workspace" + } + }, + "geometry/workspace/5p59kz": { + "status": "accepted-debt", + "baseline": { + "offset": 5 + }, + "identity": { + "label": "text “G” in text row ↔ text “Geometry Lab” in text row", + "axis": "y", + "anchor": "text-baseline", + "surfaceFamily": "workspace" + } + }, + "geometry/workspace/6bmnyr": { + "status": "accepted-debt", + "baseline": { + "offset": -1.8125 + }, + "identity": { + "label": "text “G” in text row", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "workspace" + } + }, + "geometry/workspace/bu6kch": { + "status": "accepted-debt", + "baseline": { + "offset": 1 + }, + "identity": { + "label": "Home", + "axis": "x", + "anchor": "inline-start", + "surfaceFamily": "workspace" + } + }, + "geometry/workspace/cqs9ix": { + "status": "accepted-debt", + "baseline": { + "offset": -0.8181818181818182 + }, + "identity": { + "label": "More actions", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "workspace" + } + }, + "geometry/workspace/fi8gpa": { + "status": "accepted-debt", + "baseline": { + "offset": -3.375 + }, + "identity": { + "label": "button “Audit Sidebar semantic baselines”", + "axis": "x", + "anchor": "inline-center", + "surfaceFamily": "workspace" + } + }, + "geometry/workspace/h1bkqs": { + "status": "accepted-debt", + "baseline": { + "offset": -4 + }, + "identity": { + "label": "机器", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "workspace" + } + }, + "geometry/workspace/hg2c2f": { + "status": "accepted-debt", + "baseline": { + "offset": -4 + }, + "identity": { + "label": "Machine", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "workspace" + } + }, + "geometry/workspace/k13zs1": { + "status": "accepted-debt", + "baseline": { + "offset": 3 + }, + "identity": { + "label": "text “Message” in text row", + "axis": "y", + "anchor": "text-baseline", + "surfaceFamily": "workspace" + } + }, + "geometry/workspace/ky1uql": { + "status": "accepted-debt", + "baseline": { + "offset": 5.269230769230769 + }, + "identity": { + "label": "Message", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "workspace" + } + }, + "geometry/workspace/lkcnsw": { + "status": "accepted-debt", + "baseline": { + "offset": 1.8125 + }, + "identity": { + "label": "text “Geometry Lab” in text row", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "workspace" + } + }, + "geometry/workspace/lvwy4w": { + "status": "promoted", + "reason": "Reviewed product rule: primary Sidebar actions share one trailing edge. The gate compares layout boxes, which is the edge the CSS decides; the ink comparison stays a non-gating witness because glyph whitespace differs per icon.", + "baseline": { + "offset": -1 + }, + "contract": { + "name": "workspace.sidebar.primary-trailing-actions", + "story": "geometry-chatworkspace--expanded-sidebar", + "members": [ + { + "role": "button", + "name": "New session" + }, + { + "role": "button", + "name": "Remove project" + }, + { + "role": "button", + "name": "Archive", + "rowFamily": "div[button]>div[text]", + "all": true + } + ], + "axis": "x", + "anchor": "inline-end", + "space": "layout-box", + "tolerance": 1 + }, + "identity": { + "label": "Remove project", + "axis": "x", + "anchor": "inline-end", + "surfaceFamily": "workspace" + } + }, + "geometry/workspace/mcouew": { + "status": "accepted-debt", + "baseline": { + "offset": -1 + }, + "identity": { + "label": "text “G” in text row", + "axis": "y", + "anchor": "block-end", + "surfaceFamily": "workspace" + } + }, + "geometry/workspace/qltk66": { + "status": "accepted-debt", + "baseline": { + "offset": -1 + }, + "identity": { + "label": "More actions", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "workspace" + } + }, + "geometry/workspace/sidebar-trailing-inset": { + "status": "promoted", + "reason": "Reviewed product rule: the Sidebar section header, the group header wrapper, the project row and every session row put their trailing content on one shared inset token. Rows reach it as padding plus their 1px transparent border, so the relation sums both declared terms rather than loosening the tolerance or excluding the rows. The project row is covered by the row-family member, so it carries no second member of its own: two members resolving one element would count its value twice.", + "contract": { + "name": "workspace.sidebar.trailing-inset", + "story": "geometry-chatworkspace--expanded-sidebar", + "members": [ + { + "role": "text", + "selfFamily": "div[text]>div[button],div[text]" + }, + { + "role": "text", + "selfFamily": "div[text]>div[button],button[button]" + }, + { + "role": "button", + "rowFamily": "div[text]>div[button]", + "roleIndex": 0, + "all": true + } + ], + "axis": "x", + "anchor": "inline-end", + "space": "layout-box", + "tolerance": 0, + "relation": { + "kind": "box-model-sum-equals-token", + "properties": [ + "padding-inline-end", + "border-inline-end-width" + ], + "token": "sidebar.trailingInset" + } + } + }, + "geometry/workspace/u25p0b": { + "status": "accepted-debt", + "baseline": { + "offset": 2.5 + }, + "identity": { + "label": "text “Geometry Lab” in text row", + "axis": "y", + "anchor": "text-baseline", + "surfaceFamily": "workspace" + } + }, + "geometry/workspace/w5fbub": { + "status": "accepted-debt", + "baseline": { + "offset": -4.076923076923077 + }, + "identity": { + "label": "Machine", + "axis": "y", + "anchor": "visual-center", + "surfaceFamily": "workspace" + } + } + } +} diff --git a/packages/components/scripts/generate-chat-workspace-geometry-report.mjs b/packages/components/scripts/generate-chat-workspace-geometry-report.mjs index 823a6c814..e645f0954 100644 --- a/packages/components/scripts/generate-chat-workspace-geometry-report.mjs +++ b/packages/components/scripts/generate-chat-workspace-geometry-report.mjs @@ -1,4 +1,4 @@ -import { readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { spawn } from 'node:child_process'; import { createServer } from 'node:net'; import path from 'node:path'; @@ -124,8 +124,10 @@ if (/data:image\//i.test(reportHtml)) { await writeFile(reportPath, reportHtml, 'utf8'); const imagePaths = [ - ...reportData.details.flatMap((detail) => - [detail.images.clean, detail.images.annotated, detail.images.after].filter(Boolean) + ...new Set( + reportData.details.flatMap((detail) => + [detail.images.clean, detail.images.annotated, detail.images.after].filter(Boolean) + ) ), ]; const imageStats = await Promise.all( @@ -135,7 +137,20 @@ const imageStats = await Promise.all( })) ); const detailImageBytes = imageStats.reduce((total, { file }) => total + file.size, 0); +// A screenshot budget, enforced rather than intended. Cards are chosen by how +// much they deviate, so an unbounded report is one nobody opens: the run fails +// instead of quietly growing. +const MAX_REPORT_SCREENSHOTS = 80; +const assetFiles = (await readdir(path.join(outputDirectory, 'assets'))).filter((name) => + name.endsWith('.png') +); +if (assetFiles.length >= MAX_REPORT_SCREENSHOTS) { + throw new Error( + `Geometry report wrote ${assetFiles.length} screenshots; the budget is under ${MAX_REPORT_SCREENSHOTS}` + ); +} console.log(`Geometry report: ${reportPath}`); +console.log(`${assetFiles.length}/${MAX_REPORT_SCREENSHOTS} screenshots`); console.log( `${reportData.coverage.captures.length} captures, ${reportData.details.length} details, ${imagePaths.length} images: ${(detailImageBytes / 1024).toFixed(1)} KiB total` ); diff --git a/packages/components/scripts/templates/chat-workspace-geometry-report.html b/packages/components/scripts/templates/chat-workspace-geometry-report.html index 38e0a55ae..257cc0087 100644 --- a/packages/components/scripts/templates/chat-workspace-geometry-report.html +++ b/packages/components/scripts/templates/chat-workspace-geometry-report.html @@ -205,6 +205,56 @@ border-top: 1px solid var(--border); } + .detail-section-heading { + display: flex; + align-items: baseline; + gap: 10px; + padding: 22px 4px 10px; + border-bottom: 1px solid var(--border); + font-size: 12px; + font-weight: 650; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-muted); + } + + .detail-section-heading[hidden] { + display: none; + } + + .detail-section-heading .count { + font-weight: 500; + color: var(--text-faint); + } + + .status-badge { + display: inline-block; + border: 1px solid var(--border-strong); + border-radius: 999px; + padding: 1px 8px; + margin-right: 8px; + font-size: 11px; + color: var(--text-muted); + } + + .detail-facts { + margin: 0 0 14px; + padding: 0 4px 0 52px; + display: flex; + flex-wrap: wrap; + gap: 4px 18px; + font-size: 12px; + color: var(--text-muted); + } + + .detail-repair { + margin: 0 0 14px; + padding: 0 4px 0 52px; + font-size: 12.5px; + color: var(--text); + text-wrap: pretty; + } + .detail-review { border-bottom: 1px solid var(--border); } @@ -537,12 +587,29 @@

明确排除

const overviews = report.details.filter((d) => d.kind === 'overview'); 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'); + const cssDefects = report.details.filter((d) => d.classification === 'css-defect'); + const opticalResiduals = report.details.filter( + (d) => d.classification === 'optical-residual' + ); + const structural = report.details.filter((d) => d.classification === 'structural'); const reportMeta = [ new Date(report.generatedAt).toLocaleString(), `${report.viewport.width} × ${report.viewport.height}`, `${report.coverage.captures.length} 个页面捕获`, - `${report.details.length} 组检查`, + `${report.findingDiff.current.length} 条 finding(全部展示)`, + `${report.findingDiff.new.length} 个新 finding`, + `${report.findingDiff.changed.length} 个偏移变化`, + `${report.findingDiff.resolved.length} 个已消失`, + report.qualityMetrics.discoveryPrecision == null + ? 'Discovery precision 待标注' + : `Discovery precision ${(report.qualityMetrics.discoveryPrecision * 100).toFixed(1)}%(${report.qualityMetrics.labeledFindingCount} 条已标注)`, + report.qualityMetrics.geometryCoverage == null + ? 'Geometry coverage 暂无原语' + : `Geometry coverage ${(report.qualityMetrics.geometryCoverage * 100).toFixed(1)}%`, + `${cssDefects.length} 个 CSS 缺陷 / ${structural.length} 个结构性 / ${opticalResiduals.length} 个视觉余量`, + `${report.pixelWitnesses.length} 个像素证人(仅 confidence)`, `${report.contractProposals.length} 个 Contract 候选`, ]; if (report.afterCapturedAt) { @@ -564,9 +631,12 @@

明确排除

const item = document.createElement('li'); const id = document.createElement('code'); id.textContent = capture.captureId; + const dimensions = capture.dimensions + ? ` · ${capture.dimensions.theme}/${capture.dimensions.locale}/${capture.dimensions.density}` + : ''; item.append( id, - ` · ${capture.viewport.width}×${capture.viewport.height} · ${capture.surface}` + ` · ${capture.viewport.width}×${capture.viewport.height} · ${capture.surface}${dimensions}` ); coverageCaptures.append(item); } @@ -579,43 +649,117 @@

明确排除

coverageExclusions.append(item); } - // ---------- filter ---------- + // ---------- grouping + filter ---------- const reviewsEl = document.getElementById('detail-reviews'); const toolbar = document.getElementById('toolbar'); + + const STATUS_LABELS = { + new: '新增', + changed: '偏移变化', + 'accepted-debt': '已接受债务', + promoted: '已提升为 Contract', + ignored: '已忽略', + unreviewed: '未纳入 ledger', + }; + const STATUS_ORDER = ['new', 'changed', 'accepted-debt', 'promoted', 'ignored', 'unreviewed']; + const CLASSIFICATION_ORDER = ['css-defect', 'structural', 'optical-residual', '']; + const CLASSIFICATION_LABELS = { + 'css-defect': 'CSS 缺陷', + structural: '结构性', + 'optical-residual': '视觉余量', + }; + const detailStatus = (detail) => detail.ledgerStatus ?? 'unreviewed'; + const DEFAULT_FILTER = 'default'; + // The default view is the work queue: what is new, what moved, what the + // box model says is a repairable CSS defect, and what is already promoted + // to a contract — a promoted rule is the one a regression would break. + const isDefaultDetail = (detail) => + detailStatus(detail) === 'new' || + detailStatus(detail) === 'changed' || + detailStatus(detail) === 'promoted' || + detail.classification === 'css-defect'; + + const statusCounts = new Map(); + for (const detail of report.details) { + const status = detailStatus(detail); + statusCounts.set(status, (statusCounts.get(status) ?? 0) + 1); + } + const filters = [ + [ + DEFAULT_FILTER, + '默认(新增 + 变化 + CSS 缺陷 + 已提升)', + report.details.filter(isDefaultDetail).length, + null, + ], ['all', '全部', report.details.length, null], + ...STATUS_ORDER.map((status) => [ + `status:${status}`, + STATUS_LABELS[status], + statusCounts.get(status) ?? 0, + status === 'new' || status === 'changed' ? 'dot--candidate' : 'dot--insufficient', + ]), + ['css-defect', 'CSS 缺陷', cssDefects.length, 'dot--fail'], + ['structural', '结构性', structural.length, 'dot--candidate'], + ['optical-residual', '视觉余量', opticalResiduals.length, 'dot--insufficient'], ['violation', '违规', violations.length, 'dot--fail'], ['review', '待确认', reviewCandidates.length, 'dot--candidate'], ['overview', '整体视图', overviews.length, null], ['jitter', '亚像素抖动', jitters.length, 'dot--candidate'], + ['measurement-model', '测量模型分歧', measurementModels.length, 'dot--candidate'], ['insufficient', '证据不足', insufficient.length, 'dot--insufficient'], ['stable', '稳定候选轨', stableCandidates.length, 'dot--candidate'], ]; + function matchesFilter(review, kind) { + if (kind === 'all') return true; + if (kind === DEFAULT_FILTER) return review.dataset.default === 'true'; + if (kind.startsWith('status:')) return review.dataset.ledgerStatus === kind.slice(7); + return ( + review.dataset.kind === kind || + review.dataset.classification === kind || + (kind === 'review' && review.dataset.requiresReview === 'true') || + (kind === 'stable' && + review.dataset.kind === 'candidate' && + review.dataset.requiresReview === 'false') + ); + } + function applyFilter(kind) { for (const chip of toolbar.querySelectorAll('.chip')) { chip.setAttribute('aria-pressed', String(chip.dataset.filter === kind)); } - for (const review of reviewsEl.children) { - const matches = - kind === 'all' || - review.dataset.kind === kind || - (kind === 'review' && review.dataset.requiresReview === 'true') || - (kind === 'stable' && - review.dataset.kind === 'candidate' && - review.dataset.requiresReview === 'false'); - review.hidden = !matches; + for (const section of reviewsEl.querySelectorAll('.detail-section')) { + let visible = 0; + for (const review of section.querySelectorAll('.detail-review')) { + const matches = matchesFilter(review, kind); + review.hidden = !matches; + if (matches) visible += 1; + } + section.hidden = visible === 0; + const heading = section.previousElementSibling; + if (heading && heading.classList.contains('detail-section-heading')) { + heading.hidden = visible === 0; + heading.querySelector('.count').textContent = `${visible} / ${ + section.querySelectorAll('.detail-review').length + }`; + } } } + // A default view that matched nothing would render an empty report with no + // chip pressed, so fall back to showing everything. + const initialFilter = + report.details.filter(isDefaultDetail).length > 0 ? DEFAULT_FILTER : 'all'; + for (const [kind, label, count, dotClass] of filters) { if (count === 0) continue; const chip = document.createElement('button'); chip.type = 'button'; chip.className = 'chip'; chip.dataset.filter = kind; - chip.setAttribute('aria-pressed', String(kind === 'all')); + chip.setAttribute('aria-pressed', String(kind === initialFilter)); if (dotClass) { const dot = document.createElement('span'); dot.className = `dot ${dotClass}`; @@ -651,11 +795,41 @@

明确排除

// ---------- detail rows ---------- - report.details.forEach((detail, index) => { + const groupedDetails = [...report.details].sort((left, right) => { + const statusDelta = + STATUS_ORDER.indexOf(detailStatus(left)) - STATUS_ORDER.indexOf(detailStatus(right)); + if (statusDelta !== 0) return statusDelta; + return ( + CLASSIFICATION_ORDER.indexOf(left.classification ?? '') - + CLASSIFICATION_ORDER.indexOf(right.classification ?? '') + ); + }); + const sectionsByStatus = new Map(); + function sectionFor(status) { + const existing = sectionsByStatus.get(status); + if (existing) return existing; + const heading = document.createElement('h2'); + heading.className = 'detail-section-heading'; + const title = document.createElement('span'); + title.textContent = STATUS_LABELS[status] ?? status; + const count = document.createElement('span'); + count.className = 'count mono'; + heading.append(title, count); + const section = document.createElement('section'); + section.className = 'detail-section'; + reviewsEl.append(heading, section); + sectionsByStatus.set(status, section); + return section; + } + + groupedDetails.forEach((detail, index) => { const review = document.createElement('article'); review.className = 'detail-review'; review.dataset.kind = detail.kind; review.dataset.requiresReview = String(Boolean(detail.requiresReview)); + review.dataset.ledgerStatus = detailStatus(detail); + review.dataset.default = String(isDefaultDetail(detail)); + if (detail.classification) review.dataset.classification = detail.classification; const open = detail.kind === 'violation' || Boolean(detail.requiresReview) || @@ -673,9 +847,9 @@

明确排除

const dot = document.createElement('span'); dot.className = `dot ${ - detail.kind === 'violation' + detail.kind === 'violation' || detail.classification === 'css-defect' ? 'dot--fail' - : detail.kind === 'insufficient' + : detail.kind === 'insufficient' || detail.classification === 'optical-residual' ? 'dot--insufficient' : 'dot--candidate' }`; @@ -683,7 +857,13 @@

明确排除

const copy = document.createElement('div'); const title = document.createElement('div'); title.className = 'detail-title'; - title.textContent = detail.title; + if (detail.ledgerStatus) { + const badge = document.createElement('span'); + badge.className = 'status-badge'; + badge.textContent = STATUS_LABELS[detail.ledgerStatus] ?? detail.ledgerStatus; + title.append(badge); + } + title.append(detail.title); const description = document.createElement('p'); description.className = 'detail-description'; description.textContent = detail.description; @@ -700,6 +880,49 @@

明确排除

body.className = 'detail-body'; const inner = document.createElement('div'); inner.className = 'detail-body-inner'; + + const facts = []; + if (detail.classification) { + facts.push( + `分类 ${CLASSIFICATION_LABELS[detail.classification] ?? detail.classification}` + ); + } + if (detail.ledgerStatus) { + facts.push(`Ledger ${STATUS_LABELS[detail.ledgerStatus] ?? detail.ledgerStatus}`); + } + if (detail.currentOffset !== undefined) { + const baseline = + detail.baselineOffset === undefined + ? '无基线' + : `基线 ${detail.baselineOffset.toFixed(2)}px`; + facts.push(`${baseline} → 当前 ${detail.currentOffset.toFixed(2)}px`); + } + if (detail.captureCount !== undefined) { + facts.push(`${detail.captureCount}/${detail.totalCaptureCount} 个捕获`); + } + facts.push( + detail.dimensionSensitivity && detail.dimensionSensitivity.length > 0 + ? `维度敏感 ${detail.dimensionSensitivity.join('、')}` + : detail.ledgerStatus + ? '无维度敏感' + : null + ); + const factsEl = document.createElement('p'); + factsEl.className = 'detail-facts mono'; + for (const fact of facts.filter(Boolean)) { + const span = document.createElement('span'); + span.textContent = fact; + factsEl.append(span); + } + if (factsEl.childElementCount > 0) inner.append(factsEl); + for (const text of [detail.repairProposal, detail.inkCenterWitness]) { + if (!text) continue; + const paragraph = document.createElement('p'); + paragraph.className = 'detail-repair'; + paragraph.textContent = text; + inner.append(paragraph); + } + const pair = document.createElement('div'); pair.className = 'detail-pair'; if (detail.images.after) pair.classList.add('has-after'); @@ -711,9 +934,11 @@

明确排除

? '候选轨总览' : detail.kind === 'jitter' ? '亚像素抖动' - : detail.kind === 'insufficient' - ? '证据不足' - : '失败语义线'; + : detail.kind === 'measurement-model' + ? '测量模型分歧' + : detail.kind === 'insufficient' + ? '证据不足' + : '失败语义线'; const figures = [ ['原始界面', detail.images.clean, `${detail.title} 原始局部截图`], [annotatedLabel, detail.images.annotated, `${detail.title} ${annotatedLabel}局部截图`], @@ -760,8 +985,10 @@

明确排除

}); review.append(toggle, body); - reviewsEl.append(review); + sectionFor(detailStatus(detail)).append(review); }); + + applyFilter(initialFilter); diff --git a/packages/components/scripts/triage-geometry-findings.mjs b/packages/components/scripts/triage-geometry-findings.mjs new file mode 100644 index 000000000..7224d46e6 --- /dev/null +++ b/packages/components/scripts/triage-geometry-findings.mjs @@ -0,0 +1,91 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const packageRoot = fileURLToPath(new URL('..', import.meta.url)); +const argumentsList = process.argv.slice(2); +const requestedReport = argumentsList.find((argument) => !argument.startsWith('--')); +const statusArgument = argumentsList.find((argument) => argument.startsWith('--status=')); +const ledgerArgument = argumentsList.find((argument) => argument.startsWith('--ledger=')); +const status = statusArgument?.slice('--status='.length) ?? 'accepted-debt'; +if (status !== 'new' && status !== 'accepted-debt') { + throw new Error('--status must be new or accepted-debt'); +} + +const reportDirectory = path.resolve(packageRoot, requestedReport ?? 'geometry-report'); +const findingsPath = path.join(reportDirectory, 'findings.json'); +const ledgerPath = ledgerArgument + ? path.resolve(packageRoot, ledgerArgument.slice('--ledger='.length)) + : path.join(packageRoot, 'geometry-ledger.json'); +const [findingText, ledgerText] = await Promise.all([ + readFile(findingsPath, 'utf8'), + readFile(ledgerPath, 'utf8'), +]); +const artifact = JSON.parse(findingText); +const ledger = JSON.parse(ledgerText); +if (artifact.version !== 1 || !Array.isArray(artifact.findings)) { + throw new Error(`Unsupported geometry finding artifact: ${findingsPath}`); +} +if (ledger.version !== 1 || typeof ledger.findings !== 'object' || ledger.findings === null) { + throw new Error(`Unsupported geometry ledger: ${ledgerPath}`); +} + +// Re-key migration. The DECISION is made in TypeScript, beside the identity +// rules that caused the re-key (`diffGeometryFindings`), and the report writes +// it out; this script only applies the moves, so a status, a reason and a +// baseline a human recorded survive a structural identity change. +const diffPath = path.join(reportDirectory, 'finding-diff.json'); +let diff = null; +try { + diff = JSON.parse(await readFile(diffPath, 'utf8')); +} catch (error) { + if (error?.code !== 'ENOENT') throw error; +} +const reviewedIdentity = (finding) => ({ + label: finding.label, + axis: finding.axis, + anchor: finding.anchor, + surfaceFamily: finding.surfaceFamily, +}); +const findingByKey = new Map(artifact.findings.map((finding) => [finding.key, finding])); +const migrated = []; +for (const pair of diff?.rekeyed ?? []) { + const entry = ledger.findings[pair.from]; + const finding = findingByKey.get(pair.to); + if (!entry || !finding) continue; + delete ledger.findings[pair.from]; + ledger.findings[pair.to] = { ...entry, identity: reviewedIdentity(finding) }; + migrated.push(`${pair.label} (${pair.reason})`); +} + +let added = 0; +for (const finding of artifact.findings) { + const existing = ledger.findings[finding.key]; + if (existing) { + ledger.findings[finding.key] = { + ...existing, + identity: existing.identity ?? reviewedIdentity(finding), + }; + continue; + } + ledger.findings[finding.key] = { + status, + baseline: { offset: finding.offset }, + identity: reviewedIdentity(finding), + }; + added += 1; +} +ledger.findings = Object.fromEntries( + Object.entries(ledger.findings).sort(([left], [right]) => left.localeCompare(right)) +); +await writeFile(ledgerPath, `${JSON.stringify(ledger, null, 2)}\n`, 'utf8'); +console.log( + `Geometry ledger: added ${added} ${status} finding${added === 1 ? '' : 's'} from ${findingsPath}` +); +if (migrated.length > 0) { + console.log( + `Geometry ledger: migrated ${migrated.length} re-keyed review${ + migrated.length === 1 ? '' : 's' + }: ${migrated.join(', ')}` + ); +} diff --git a/packages/components/src/components/devtools/workspace-geometry-devtools.tsx b/packages/components/src/components/devtools/workspace-geometry-devtools.tsx index 180be30bf..d5fcf9e25 100644 --- a/packages/components/src/components/devtools/workspace-geometry-devtools.tsx +++ b/packages/components/src/components/devtools/workspace-geometry-devtools.tsx @@ -23,6 +23,11 @@ import { type SemanticBaselineMode, type SemanticGeometryStatus, } from '@/lib/chat-workspace-geometry'; +import { + geometryCanvasFontString, + geometryCapBandCenter, + measureGeometryCapBand, +} from '@/lib/geometry-text-cap-band'; const ENABLE_QUERY_PARAMETER = 'geometry'; const ENABLE_STORAGE_KEY = 'lody:chat-workspace-geometry-devtools'; @@ -157,7 +162,7 @@ function ReferenceGridOverlay() { const element = document.querySelector(`[${CHAT_WORKSPACE_GEOMETRY_ATTRIBUTE}="${anchor}"]`); return element instanceof HTMLElement ? [element] : []; }); - if (elements.length === 0) return; + if (elements.length === 0) return undefined; let frame = 0; const measure = () => { @@ -339,7 +344,7 @@ function SpacingAuditOverlay() { const root = document.querySelector( `[${CHAT_WORKSPACE_GEOMETRY_ATTRIBUTE}="${CHAT_WORKSPACE_GEOMETRY_ANCHORS.workspaceShell}"]` ); - if (!(root instanceof HTMLElement)) return; + if (!(root instanceof HTMLElement)) return undefined; let frame = 0; const audit = () => { @@ -415,23 +420,18 @@ function measureTextBaseline(element: Element): number { return baseline; } +/** + * The overlay measures text exactly as the Playwright capture does: same font + * string (no computed `font-variant`, which a canvas rejects), same cached + * cap-band measurement of the same fixed reference glyph, same band centre. + * A row the overlay calls centred must be a row capture calls centred. + */ function measureTextVisualCenter(element: Element): number { const style = getComputedStyle(element); - const canvas = document.createElement('canvas'); - const context = canvas.getContext('2d'); - if (!context) throw new Error('Canvas 2D context is unavailable'); - context.font = [ - style.fontStyle, - style.fontVariant, - style.fontWeight, - style.fontSize, - style.fontFamily, - ].join(' '); - const metrics = context.measureText(element.textContent ?? ''); - return ( - measureTextBaseline(element) + - (metrics.actualBoundingBoxDescent - metrics.actualBoundingBoxAscent) / 2 - ); + // No expected-size assertion here: capture is a test and may fail loudly, but + // this overlay renders inside the product and must never throw at a font. + const band = measureGeometryCapBand(geometryCanvasFontString(style)); + return geometryCapBandCenter(measureTextBaseline(element), band); } function measureVisualCenter(element: Element): number { @@ -473,6 +473,7 @@ function measureSemanticAlignmentCoordinate( case 'visual-center': return measureVisualCenter(element); } + throw new Error(`Unsupported semantic alignment anchor: ${anchor}`); } function unionRects(rects: readonly GeometryRect[]): GeometryRect { @@ -551,7 +552,7 @@ function SemanticAlignmentOverlay() { const root = document.querySelector( `[${CHAT_WORKSPACE_GEOMETRY_ATTRIBUTE}="${CHAT_WORKSPACE_GEOMETRY_ANCHORS.workspaceShell}"]` ); - if (!(root instanceof HTMLElement)) return; + if (!(root instanceof HTMLElement)) return undefined; let frame = 0; const measure = () => { @@ -707,7 +708,7 @@ function SemanticBaselineOverlay() { const root = document.querySelector( `[${CHAT_WORKSPACE_GEOMETRY_ATTRIBUTE}="${CHAT_WORKSPACE_GEOMETRY_ANCHORS.workspaceShell}"]` ); - if (!(root instanceof HTMLElement)) return; + if (!(root instanceof HTMLElement)) return undefined; let frame = 0; const mutationObserver = new MutationObserver(schedule); @@ -796,11 +797,11 @@ export default function WorkspaceGeometryDevtools({ const active = enabled || forceEnabled; useEffect(() => { - if (!active) return; + if (!active) return undefined; const root = document.querySelector( `[${CHAT_WORKSPACE_GEOMETRY_ATTRIBUTE}="${CHAT_WORKSPACE_GEOMETRY_ANCHORS.workspaceShell}"]` ); - if (!(root instanceof HTMLElement)) return; + if (!(root instanceof HTMLElement)) return undefined; root.setAttribute('data-geometry-actions-visible', 'true'); return () => root.removeAttribute('data-geometry-actions-visible'); }, [active]); diff --git a/packages/components/src/lib/AGENTS.md b/packages/components/src/lib/AGENTS.md index 340ce1a54..02d8a9710 100644 --- a/packages/components/src/lib/AGENTS.md +++ b/packages/components/src/lib/AGENTS.md @@ -1,103 +1,123 @@ # `components/src/lib` — file-surface invariants -`CLAUDE.md` is a symlink to this file. Edit `AGENTS.md` only. -Package [AGENTS.md](../../AGENTS.md) applies; this file adds the rules for the -client half of Code Collab / File Preview file surfaces. +`CLAUDE.md` is a symlink to this file. Edit `AGENTS.md` only. Package +[AGENTS.md](../../AGENTS.md) applies; this file adds the client half of Code Collab / File +Preview file surfaces and the chat workspace geometry grid. ## The Electron IPC type-only edge is intentional `electron-ipc-client.ts` imports `ElectronIpcServices` from the Electron main-process -registration module with `import type`. This resembles a package cycle because the -Electron renderer also consumes `@lody/components`, but it is deliberately a source-type -edge only: TypeScript erases it, browser/mobile bundles never load main-process code, and -there is no `@lody/electron` runtime or package dependency here. -The type checker still follows the referenced source, so the components project keeps -`experimentalDecorators` enabled and a direct catalog-pinned `@types/node` dev dependency; -those settings make the main service declarations parse under the same assumptions as the -Electron node project without changing any emitted renderer code. -The Node declarations are compiler plumbing, not permission for shared UI to use Node APIs; -the repository lint boundary rejects `node:*` imports and common Node globals in components -source. - -The alternative is a second handwritten mirror of every invoke method in a shared -contract or platform port. That mirror previously drifted independently from -`@IpcMethod()` registration and the preload policy. Keep the service classes plus the one -constructor list as the invoke signature source instead. Do not replace this type-only -edge with another invoke contract, spec, or one-for-one platform port. If a future build -cannot erase or resolve the edge, fix that explicit type boundary or reconsider where the -Electron-aware caller lives; never turn it into a runtime import. Push events and one-way -sends remain platform-neutral maps in `@lody/shared/electron-ipc`. +registration module with `import type`. It is a source-type edge, not the package cycle it +resembles: TypeScript erases it, browser/mobile bundles never load main-process code, and +there is no `@lody/electron` runtime dependency. The type checker still follows that source, so this +project keeps `experimentalDecorators` and a catalog-pinned `@types/node` dev dependency, +which make the main service declarations parse under the Electron node project's +assumptions without changing emitted renderer code. Those declarations are compiler +plumbing, not permission for shared UI to use Node APIs: the lint boundary rejects `node:*` +imports and Node globals here. + +Keep the service classes plus the one constructor list as the invoke signature source. The +alternative — a handwritten mirror of every invoke method, another contract, a spec, a +platform port — drifted from `@IpcMethod()` registration and the preload policy. Never make +the edge a runtime import: if a build cannot erase it, fix that type boundary or move the +Electron-aware caller. Push events and one-way sends stay platform-neutral maps in +`@lody/shared/electron-ipc`. ## Where a path came from decides whether it may be rewritten `session-file-open-target.ts` owns this and is the only place that should. -- **Canonical** — the caller already holds the workspace-relative path the - machine indexed: file tree, quick open, mobile file browser, an LSP jump - target. Sent VERBATIM. -- **Markdown href** — a link an agent wrote in chat. Gets parsed: URL-decoded, - trailing `:` / `#L` split off, absolute host root and - `.../worktrees//` prefix stripped. +- **Canonical** — the caller already holds the workspace-relative path the machine + indexed: file tree, quick open, mobile file browser, an LSP jump target. Sent VERBATIM. +- **Markdown href** — a link an agent wrote in chat. Parsed: URL-decoded, trailing + `:` / `#L` split off, absolute host root and `.../worktrees//` stripped. -Running a canonical path through the href parser is what this split exists to -prevent, because every one of those sequences can be a real part of a real -filename: `docs/report%20v2.md` decodes to a different file, -`logs/2024:30.txt` loses its tail, `fixtures/worktrees//case.txt` gets -re-rooted. A line anchor travels as a FIELD, never encoded into the path. +Running a canonical path through the href parser is what this split prevents: each of those +sequences is a real part of some real filename — `docs/report%20v2.md` decodes to another +file, `logs/2024:30.txt` loses its tail. A line anchor travels as a FIELD, never in the path. -Known gap: `ai-gui/view.tsx`'s tool-call card sends an ACP `locations[].path` — -a filesystem path, not an href — through `onFilePathClick`, so it still rides -the href parser. It needs a third kind that strips roots without decoding. +Known gap: `ai-gui/view.tsx`'s tool-call card sends an ACP `locations[].path` — a +filesystem path, not an href — through `onFilePathClick`, so it rides the href parser. It +needs a third kind that strips roots without decoding. ## ACP dispatch -Before creating a top-level or child session, call -`filterAcpSessionConfigOptionValues()` so cached values outside the current -selector schema are not dispatched or persisted again. +Before creating a top-level or child session, call `filterAcpSessionConfigOptionValues()` +so cached values outside the current selector schema are not dispatched or persisted. ## A resolved open is cached under BOTH spellings -The machine may answer with a different on-disk spelling than the one requested -(letter case, Unicode normalization), so `openFile` caches the result under -`response.path` AND under the path it asked for. Each key has a caller that -breaks without it: - -- `response.path` becomes `entry.fileId`, so it is what `saveText` is later - called with. Missing it, save reports "Open the file before saving it." for a - file that is open on screen. -- The requested path is what the viewer tab keeps — `session-detail.tsx` - refreshes a tab's `fileId` from the file INDEX, which never learns the - resolved name — so `checkTextChanged` and the next `openFile` still arrive - with the original spelling. Missing it, the external-change pre-check silently - no-ops and every re-open re-downloads the file, because no `knownDigest` can - be sent. - -A save must then refresh EVERY key of the entry (`cacheKeys` on the cache -entry), because the save arrives under one spelling and the next change-check -under the other — refreshing only the save path leaves the alias holding the -pre-save digest, and the pre-check reports our own save as an external change. +The machine may answer with a different on-disk spelling than the one requested (letter +case, Unicode normalization), so `openFile` caches the result under `response.path` AND the +path it asked for. Each key has a caller that breaks without it. +- `response.path` becomes `entry.fileId`, so it is what `saveText` is called with. Missing + it, save reports "Open the file before saving it." for a file on screen. +- The requested path is what the viewer tab keeps: `session-detail.tsx` refreshes a tab's + `fileId` from the file INDEX, which never learns the resolved name. Missing it, the + external-change pre-check no-ops and every re-open re-downloads the file, no + `knownDigest` being sendable. + +A save must then refresh EVERY key of the entry (`cacheKeys`): it arrives under one +spelling and the next change-check under the other, so refreshing only the save path leaves +the alias holding the pre-save digest and the pre-check reports our own save as external. Related: preview READS case-tolerantly while `save-text` WRITES case-exactly. ## Known gap: scan-failure skip reasons -`codeCollabFileTreeValueToSessionFileEntry` maps every `kind: 'skipped'` index -entry to `unavailableReason: 'unsupported-special'`, so a file the scanner -merely failed to read once (EBUSY/EMFILE, deleted mid-scan) renders as "File -type is not supported" and stays unclickable until the next full rescan. Fixing -it needs care that a naive allowlist does not give: the same reasons are emitted -for DIRECTORIES whose read failed, and `openFile` does not clear an index -`readonly`, so the obvious patch trades "unopenable" for "uneditable". +`codeCollabFileTreeValueToSessionFileEntry` maps every `kind: 'skipped'` index entry to +`unavailableReason: 'unsupported-special'`, so a file the scanner failed to read once +(EBUSY/EMFILE, deleted mid-scan) reads as "File type is not supported" and stays unclickable +until the next full rescan. A naive allowlist is not enough: the same reasons are emitted +for DIRECTORIES whose read failed, and `openFile` does not clear an index `readonly`, so +the obvious patch trades "unopenable" for "uneditable". ## Error copy -`session-file-error-state.tsx` maps a machine message + reason to a -presentation. Two rules run BEFORE the reason mapping, because the machine's -coarse error code misdescribes them: +`session-file-error-state.tsx` maps a machine message + reason to a presentation. Two rules +run BEFORE the reason mapping, because the coarse machine code misdescribes them: -- "outside the workspace" — a policy rejection arrives as `permission_denied`; - "Access denied" would blame the filesystem. The CLI is required to keep that - exact phrase in the message. -- "owner session mismatch" — a startup race (the client derives the owner from - synced session meta, the machine from the live session) that also arrives as +- "outside the workspace" — a policy rejection arrives as `permission_denied`; "Access + denied" would blame the filesystem. The CLI must keep that exact phrase in the message. +- "owner session mismatch" — a startup race (the client derives the owner from synced + session meta, the machine from the live session) that also arrives as `permission_denied`. The only correct advice is "try again". + +## Chat workspace geometry + +`chat-workspace-geometry.ts` owns the design grid, the overlays and the alignment/baseline +diagnostics; `geometry-constraint-system.ts` explanations and classification; +`geometry-text-cap-band.ts` the one optical text measurement. Pipeline, identity, contracts, +gate, report: [tests/e2e](../../tests/e2e/AGENTS.md). + +The grid is a reviewed reference, not evidence the product was inferred automatically. +Production layout stays ordinary Flex/Grid with stable geometry data markers only; grid +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. +- 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 + that band, and the canvas font string it needs, from `geometry-text-cap-band.ts`: two + copies disagree about one row. The overlay never throws at a font; capture may. +- Named groups expose stable member labels; guides sit at the median of the whole spread, + never DOM order. Spread over tolerance but ≤ 1px is `sub-pixel-jitter`: folded, never + gated. Under the required member count: `insufficient-evidence`, never aligned. +- Padding/margin/gap/line-height multiples are spacing diagnostics, never alignment + violations; that debt is non-blocking until promoted into the gate. + +### Classification + +An explanation traces both members to a common ancestor, keeping exact +padding/border/margin/gap terms, a `layout` remainder and a residual; block terms mirror +the inline ones, `align-items` centring landing in `layout`, and a text `visual-center` +also records half of (line box − cap band) as `typography` — font metrics, never declared, +never a defect. `css-defect`: |explained| ≥ 1px, |residual| ≤ one device pixel, declared +terms outweighing `layout`, naming the term and node to repair; a centre or baseline owns +none and proposes none. `optical-residual`: |explained| < 1px, |residual| ≤ 1.5px, folded, +not `requiresReview`. `structural`: the rest, unexplainable offsets included. A finding seen +under one value of an axis that varies inside its own story/viewport/DPR group records +`dimensionSensitivity` (the expanded-sidebar story is recaptured under +`theme:dark`/`locale:zh_CN`); merged across both values, none. diff --git a/packages/components/src/lib/chat-workspace-geometry.ts b/packages/components/src/lib/chat-workspace-geometry.ts index 1edf583b9..0cd92011d 100644 --- a/packages/components/src/lib/chat-workspace-geometry.ts +++ b/packages/components/src/lib/chat-workspace-geometry.ts @@ -288,6 +288,8 @@ export type SemanticBaselineMemberMeasurement = Readonly<{ export type SemanticBaselineGroupMeasurement = Readonly<{ name: string; mode: SemanticBaselineMode; + /** Coordinates are snapped to this physical-pixel grid before comparison. */ + deviceScaleFactor?: number; members: readonly SemanticBaselineMemberMeasurement[]; }>; @@ -310,6 +312,8 @@ export type SemanticAlignmentGroupMeasurement = Readonly<{ minMembers: number; tolerance: number; policy: SemanticAlignmentPolicy; + /** Coordinates are snapped to this physical-pixel grid before comparison. */ + deviceScaleFactor?: number; members: readonly SemanticBaselineMemberMeasurement[]; }>; @@ -336,8 +340,14 @@ export type AlignmentRailCandidate = Readonly<{ elementId: string; /** Stable row identity so nested boxes on one row cannot inflate support. */ rowId: string; + /** Structural family shared by instances of the same visual row shape. */ + rowFamily?: string; + /** Nearest visual partition inside the discovery scope. */ + sectionId?: string; /** Geometry-derived visual role; semantic contract names never enter discovery. */ kind?: string; + /** Centered text contributes only its center; flow text contributes only its edges. */ + alignmentMode?: 'flow' | 'centered'; /** Candidates from different coordinate spaces never establish or join the same rail. */ space?: AlignmentRailCandidateSpace; anchor: AlignmentRailCandidateAnchor; @@ -373,6 +383,63 @@ export type AlignmentRailFamily = Readonly<{ rails: readonly DiscoveredAlignmentRail[]; }>; +/** + * Vertical anchors. `block-*` read the primitive's measured box, `visual-center` + * the ink a reader actually sees (cap-height band for text, transformed path + * bounds for an SVG, box centre for an image or field) and `text-baseline` the + * font baseline. `block-center` and `visual-center` are BOTH kept: a text box's + * centre is content independent but line-height dependent, so only the visual + * centre may be compared against an icon. + */ +export type BlockRailCandidateAnchor = + | 'block-start' + | 'block-center' + | 'block-end' + | 'visual-center' + | 'text-baseline'; + +export type BlockRailCandidate = Readonly<{ + elementId: string; + /** The one visual row this primitive belongs to; a Y rail never leaves it. */ + rowId: string; + /** Structural family shared by instances of the same visual row shape. */ + rowFamily?: string; + /** Nearest visual partition inside the discovery scope. */ + sectionId?: string; + /** Geometry-derived visual role; semantic contract names never enter discovery. */ + kind?: string; + space?: AlignmentRailCandidateSpace; + anchor: BlockRailCandidateAnchor; + /** Vertical page coordinate of this anchor. */ + coordinate: number; + xStart: number; + xEnd: number; + yStart: number; + yEnd: number; +}>; + +export type BlockRailDiscoveryOptions = Readonly<{ + /** Maximum distance from the row median at which a member supports the rail. */ + inlierTolerance?: number; + minMembers?: number; + /** Coordinates are snapped to this physical-pixel grid before comparison. */ + deviceScaleFactor?: number; +}>; + +export type DiscoveredBlockRail = Readonly<{ + rowId: string; + rowFamily?: string; + sectionId?: string; + anchor: BlockRailCandidateAnchor; + line: number; + spread: number; + support: number; + sampleSize: number; + horizontalSpan: number; + members: readonly Readonly[]; + outliers: readonly Readonly[]; +}>; + export type LayoutTopologyNode = Readonly<{ id: string; parentId: string | null; @@ -678,6 +745,14 @@ export function isSpacingRhythmMultiple( * member spread, so DOM order cannot select the reference member or hide two * members that sit on opposite sides of the displayed guide. */ +export function quantizeGeometryCoordinate(value: number, deviceScaleFactor = 1): number { + if (!Number.isFinite(value)) throw new RangeError('Geometry coordinate must be finite'); + if (!Number.isFinite(deviceScaleFactor) || deviceScaleFactor <= 0) { + throw new RangeError('deviceScaleFactor must be a positive finite number'); + } + return Math.round(value * deviceScaleFactor) / deviceScaleFactor; +} + export function evaluateSemanticBaselineGroup( group: SemanticBaselineGroupMeasurement, tolerance: number = CHAT_WORKSPACE_GEOMETRY_SPEC.defaultTolerance @@ -689,7 +764,9 @@ export function evaluateSemanticBaselineGroup( if (!Number.isFinite(member.coordinate)) { throw new RangeError(`${group.name}.${member.name} must have a finite coordinate`); } - return member.coordinate; + return group.deviceScaleFactor === undefined + ? member.coordinate + : quantizeGeometryCoordinate(member.coordinate, group.deviceScaleFactor); }); const sorted = [...coordinates].sort((a, b) => a - b); const middle = Math.floor(sorted.length / 2); @@ -699,8 +776,9 @@ export function evaluateSemanticBaselineGroup( : sorted.length % 2 === 0 ? ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2 : (sorted[middle] ?? 0); - const members = group.members.map((member) => { - return { ...member, delta: Math.abs(member.coordinate - line) }; + const members = group.members.map((member, index) => { + const coordinate = coordinates[index] ?? member.coordinate; + return { ...member, coordinate, delta: Math.abs(coordinate - line) }; }); const spread = sorted.length < 2 ? 0 : (sorted.at(-1) ?? 0) - (sorted[0] ?? 0); const measurable = members.length >= 2; @@ -741,7 +819,9 @@ export function evaluateSemanticAlignmentGroup( if (!Number.isFinite(member.coordinate)) { throw new RangeError(`${group.name}.${member.name} must have a finite coordinate`); } - return member.coordinate; + return group.deviceScaleFactor === undefined + ? member.coordinate + : quantizeGeometryCoordinate(member.coordinate, group.deviceScaleFactor); }); const sorted = [...coordinates].sort((a, b) => a - b); const middle = Math.floor(sorted.length / 2); @@ -751,9 +831,10 @@ export function evaluateSemanticAlignmentGroup( : sorted.length % 2 === 0 ? ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2 : (sorted[middle] ?? 0); - const members = group.members.map((member) => ({ + const members = group.members.map((member, index) => ({ ...member, - delta: Math.abs(member.coordinate - line), + coordinate: coordinates[index] ?? member.coordinate, + delta: Math.abs((coordinates[index] ?? member.coordinate) - line), })); const spread = sorted.length < 2 ? 0 : (sorted.at(-1) ?? 0) - (sorted[0] ?? 0); const measurable = members.length >= group.minMembers; @@ -1002,10 +1083,10 @@ function median(values: readonly number[]): number { * Candidates are grouped independently by anchor kind and coordinate space. Repeated coordinate * modes establish rails before nearby singleton observations are attached to * the nearest compatible mode. A rail supported by one visual primitive kind - * only accepts that kind; a rail with mixed support may accept mixed kinds. + * only accepts that kind unless all members share a repeated row family. * This keeps stable indentation levels separate and prevents an unrelated icon * from attaching to a nearby text rail. Flow text can define start or end edges, - * but its box center is content-dependent and cannot define a center rail. The + * while centered text can define only its center. The * result is diagnostic only: callers decide whether a reviewed rail should * later become an explicit semantic contract. */ @@ -1047,7 +1128,15 @@ export function discoverAlignmentRails( const byAnchorAndSpace = new Map(); for (const candidate of candidates) { - if (candidate.kind === 'text' && candidate.anchor === 'inline-center') continue; + const isText = candidate.kind === 'text' || candidate.kind === 'numeric-text'; + if ( + isText && + (candidate.alignmentMode === 'centered' + ? candidate.anchor !== 'inline-center' + : candidate.anchor === 'inline-center') + ) { + continue; + } const key = `${candidate.anchor}\u0000${candidate.space ?? 'layout-box'}`; const members = byAnchorAndSpace.get(key) ?? []; members.push(candidate); @@ -1101,6 +1190,18 @@ export function discoverAlignmentRails( (member) => Math.abs(member.coordinate - line) <= inlierTolerance ); if (supporters.length < minSupport) return []; + const kinds = new Set(supporters.flatMap((member) => (member.kind ? [member.kind] : []))); + const rowFamilies = new Set( + supporters.flatMap((member) => (member.rowFamily ? [member.rowFamily] : [])) + ); + const sharesKind = kinds.size <= 1; + const sharesRowFamily = + rowFamilies.size === 1 && supporters.every((member) => member.rowFamily !== undefined); + if (!sharesKind && !sharesRowFamily) return []; + const sectionIds = new Set( + supporters.flatMap((member) => (member.sectionId ? [member.sectionId] : [])) + ); + if (supporters.length === 2 && sectionIds.size > 1) return []; const verticalSpan = Math.max(...supporters.map((member) => member.yEnd)) - Math.min(...supporters.map((member) => member.yStart)); @@ -1109,7 +1210,8 @@ export function discoverAlignmentRails( { line, verticalSpan, - kinds: new Set(supporters.flatMap((member) => (member.kind ? [member.kind] : []))), + kinds, + rowFamilies, }, ]; }); @@ -1120,12 +1222,12 @@ export function discoverAlignmentRails( let nearestDistance = Number.POSITIVE_INFINITY; for (const [modeIndex, mode] of stableModes.entries()) { const distance = Math.abs(candidate.coordinate - mode.line); - const kindCompatible = + const identityCompatible = candidate.kind === undefined || mode.kinds.size === 0 || - mode.kinds.size > 1 || - mode.kinds.has(candidate.kind); - if (kindCompatible && distance <= mergeTolerance && distance < nearestDistance) { + mode.kinds.has(candidate.kind) || + (candidate.rowFamily !== undefined && mode.rowFamilies.has(candidate.rowFamily)); + if (identityCompatible && distance <= mergeTolerance && distance < nearestDistance) { nearestModeIndex = modeIndex; nearestDistance = distance; } @@ -1186,6 +1288,215 @@ export function discoverAlignmentRails( ); } +/** One candidate row slot, as capture measures it before any rail exists. */ +export type GeometryRowSlotExtent = Readonly<{ top: number; bottom: number }>; + +/** + * A row is a LINE, not a stack. A composer's `label` sitting above its textarea + * is two lines of one column, and calling it a row makes the distance between + * those lines a vertical misalignment — which it is not. + * + * The test is the row band: the median slot height centred on the row, which is + * the height an ordinary member of this row has. A slot whose vertical extent + * overlaps that band by less than half is on another line, so it is not a row + * member (it may still be measured as an atom outside every row). The surviving + * indices are returned in input order; a caller needs at least two of them to + * have a row at all. + * + * Keep closure-free: capture serializes this function into the page. + */ +export function selectVisualRowSlots( + slots: readonly GeometryRowSlotExtent[], + rowCenter: number, + minimumBandOverlap = 0.5 +): readonly number[] { + const heights = slots + .map((slot) => slot.bottom - slot.top) + .filter((height) => Number.isFinite(height) && height > 0) + .sort((first, second) => first - second); + const middle = Math.floor(heights.length / 2); + const bandHeight = + heights.length === 0 + ? 0 + : heights.length % 2 === 1 + ? (heights[middle] ?? 0) + : ((heights[middle - 1] ?? 0) + (heights[middle] ?? 0)) / 2; + if (bandHeight <= 0) return slots.map((_slot, index) => index); + const bandTop = rowCenter - bandHeight / 2; + const bandBottom = rowCenter + bandHeight / 2; + return slots.flatMap((slot, index) => { + const overlap = Math.min(slot.bottom, bandBottom) - Math.max(slot.top, bandTop); + return overlap / bandHeight >= minimumBandOverlap ? [index] : []; + }); +} + +/** Everything about one element a painted-shape decision may read. */ +export type GeometryShapePaint = Readonly<{ + width: number; + height: number; + /** Rendered element children; a shape is a leaf, it contains nothing. */ + renderedChildCount: number; + /** Text content; a shape owns no glyph, or it would be a text primitive. */ + text: string; + backgroundColor: string; + backgroundImage: string; + borderWidths: readonly number[]; + borderColors: readonly string[]; +}>; + +/** + * A status dot is a `` with a background and nothing inside it. No text + * range, no SVG path and no image describes it, so discovery cannot see it at + * all — and a marker rule measuring it can never be removed. It is ink: a + * rendered leaf, no text, a painted background or border, and at most 24px in + * BOTH dimensions — a mark, not a surface a row sits on. + * + * Keep closure-free: capture serializes this function into the page. + */ +export function isGeometryPaintedShape(paint: GeometryShapePaint): boolean { + if (paint.renderedChildCount > 0) return false; + if (paint.text.trim() !== '') return false; + if (!(paint.width > 0) || !(paint.height > 0)) return false; + if (paint.width > 24 || paint.height > 24) return false; + const alpha = (color: string) => { + if (!color || color === 'transparent' || color === 'none') return 0; + const channels = color.match(/[\d.]+/g); + if (!channels) return 0; + return channels.length >= 4 ? Number(channels[3]) : 1; + }; + if (alpha(paint.backgroundColor) > 0) return true; + if (paint.backgroundImage !== '' && paint.backgroundImage !== 'none') return true; + return paint.borderWidths.some( + (width, index) => width > 0 && alpha(paint.borderColors[index] ?? '') > 0 + ); +} + +/** + * 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. + */ +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'); + } + + 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 members = byRowAndAnchor.get(key) ?? []; + members.push(candidate); + byRowAndAnchor.set(key, members); + } + + const rails: DiscoveredBlockRail[] = []; + for (const rowCandidates of byRowAndAnchor.values()) { + // One member per primitive: an element that reported the same anchor twice + // must not double its own vote for the row median. + const uniqueByElement = new Map(); + for (const candidate of rowCandidates) { + if (!uniqueByElement.has(candidate.elementId)) + uniqueByElement.set(candidate.elementId, candidate); + } + const snapped = [...uniqueByElement.values()].map((candidate) => ({ + ...candidate, + coordinate: quantizeGeometryCoordinate(candidate.coordinate, deviceScaleFactor), + })); + if (snapped.length < minMembers) continue; + const representative = snapped[0]; + if (!representative) continue; + // An icon's top edge and a text line box's top edge are not a claim about + // anything: only primitives of one kind share a block EDGE. The centres are + // exactly where a cross-kind comparison is meaningful, so they stay mixed. + if (representative.anchor === 'block-start' || representative.anchor === 'block-end') { + const kinds = new Set(snapped.map((member) => member.kind ?? '')); + if (kinds.size > 1) continue; + } + // A Y rail claims that these primitives sit on ONE line, so a primitive + // that neither reaches that line nor sits within half a row of it is not on + // it: it is content of another line that the row snapshot happened to + // include, and calling the distance between them a misalignment would be + // false. Both tests are needed. Ink alone is too strict — an ellipsis glyph + // is barely a pixel tall, so any offset at all would drop it instead of + // reporting it. The row band alone is too loose — a tall field would swallow + // a label a line above it. The line is then recomputed from what is left. + const reachesLine = ( + members: readonly BlockRailCandidate[], + line: number + ): readonly BlockRailCandidate[] => { + const heights = members + .map((member) => member.yEnd - member.yStart) + .filter((height) => Number.isFinite(height) && height > 0); + const band = (heights.length > 0 ? median(heights) : 0) / 2 + inlierTolerance; + return members.filter( + (member) => + (member.yStart - inlierTolerance <= line && line <= member.yEnd + inlierTolerance) || + Math.abs(member.coordinate - line) <= band + ); + }; + const online = reachesLine(snapped, median(snapped.map((member) => member.coordinate))); + if (online.length < minMembers) continue; + const line = median(online.map((member) => member.coordinate)); + if (reachesLine(online, line).length < minMembers) continue; + const members = online + .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 coordinates = members.map((member) => member.coordinate); + rails.push({ + rowId: representative.rowId, + ...(representative.rowFamily ? { rowFamily: representative.rowFamily } : {}), + ...(representative.sectionId ? { sectionId: representative.sectionId } : {}), + anchor: representative.anchor, + line, + spread: Math.max(...coordinates) - Math.min(...coordinates), + support: members.filter((member) => !member.outlier).length, + 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.sort( + (first, second) => + first.line - second.line || + first.rowId.localeCompare(second.rowId) || + first.anchor.localeCompare(second.anchor) + ); +} + /** Group start/center/end rails that describe the same repeated visual slot. */ export function groupAlignmentRailFamilies( rails: readonly DiscoveredAlignmentRail[] diff --git a/packages/components/src/lib/geometry-constraint-system.ts b/packages/components/src/lib/geometry-constraint-system.ts new file mode 100644 index 000000000..c08e5e02e --- /dev/null +++ b/packages/components/src/lib/geometry-constraint-system.ts @@ -0,0 +1,2879 @@ +import { + discoverAlignmentRails, + discoverBlockAlignmentRails, + groupAlignmentRailFamilies, + quantizeGeometryCoordinate, + selectCanonicalAlignmentRails, + type AlignmentRailCandidate, + type AlignmentRailFamily, + type BlockRailCandidate, + type BlockRailCandidateAnchor, + type DiscoveredAlignmentRail, + type DiscoveredBlockRail, + type GeometryRect, + type SemanticAlignmentAnchor, + type SemanticAlignmentAxis, + type SemanticGeometryStatus, +} from './chat-workspace-geometry'; + +export type GeometrySurfaceFamily = 'workspace' | 'session' | 'right-sidebar' | string; + +export type GeometryStableLocator = Readonly<{ + role: string; + name?: string; + landmark?: Readonly<{ role: string; name?: string }>; + rowName?: string; + /** DOM-shape identity for an otherwise unnamed repeated row. */ + rowFamily?: string; + /** + * Which instance of `rowFamily` inside its own section this element's row is, + * numbered from rendered order over the whole document. It is what separates + * three same-shaped singleton rows once a translated accessible name is only + * a label, and it is deliberately NOT part of the identity of a data-driven + * repeated row: ten chat rows of one shape are one finding, not ten. + */ + familyIndex?: number; + /** Zero-based position among elements with the same role inside that row. */ + roleIndex?: number; + /** + * The element's OWN `tag[role]>child,child` signature. Layout wrappers that + * carry a spacing token have no accessible name and are not a control inside + * a row, so this is the only ordinary-DOM identity they have. + */ + selfFamily?: string; + /** + * The nearest `data-geometry-discovery-scope` the element sits in. Added to a + * finding identity only when the name was dropped, so a chat-list row and a + * project-list row of one DOM family stay two findings. + */ + section?: string; + all?: boolean; +}>; + +export type GeometryBoxModelContribution = Readonly<{ + padding: number; + border: number; + margin: number; + gap: number; + /** Space left after declared box-model values, including sibling/content distribution. */ + layout: number; + /** + * Half of (line box − cap-height band) for a text primitive measured at its + * visual centre. It is font metrics, never a declared term and never a + * defect, so classification must not read it as either. + */ + typography?: number; +}>; + +/** One exact child-to-parent edge in a primitive's rendered ancestor chain. */ +export type GeometryBoxModelPathStep = Readonly<{ + nodeId: string; + element: string; + parentId?: string; + startToParent: number; + endToParent: number; + centerToParent: number; + inlineStart: GeometryBoxModelContribution; + inlineEnd: GeometryBoxModelContribution; + /** + * The same arithmetic on the block axis. Optional because a capture written + * before Y discovery existed has only the inline terms; an explanation + * without them simply reports no block path. + */ + blockStartToParent?: number; + blockEndToParent?: number; + blockCenterToParent?: number; + blockStart?: GeometryBoxModelContribution; + blockEnd?: GeometryBoxModelContribution; +}>; + +/** + * Display-only naming evidence. None of this is identity: it is exactly the + * content that must stay out of a finding key, kept so a card can still print + * a sentence a designer recognises. + */ +export type GeometryCandidateNaming = Readonly<{ + /** Accessible names of the controls nested inside the locator owner. */ + nestedControlNames?: readonly string[]; + /** Longest direct text inside the locator owner, ignoring nested controls. */ + rowTitle?: string; +}>; + +export type GeometryCapturedCandidate = AlignmentRailCandidate & + Readonly<{ + primitiveId: string; + locator: GeometryStableLocator; + label: string; + naming?: GeometryCandidateNaming; + boxModelNodeRef?: string; + /** The nearest declared discovery scope, independent of the snapshot scope. */ + sectionScope?: string; + }>; + +/** + * A Y candidate for one primitive of one visual row. It reuses the row, family, + * section, locator, label and box-model reference the X candidates already + * carry, so a Y finding is keyed by exactly the same identity rules. + */ +export type GeometryCapturedBlockCandidate = BlockRailCandidate & + Readonly<{ + primitiveId: string; + locator: GeometryStableLocator; + label: string; + naming?: GeometryCandidateNaming; + boxModelNodeRef?: string; + /** The nearest declared discovery scope, independent of the snapshot scope. */ + sectionScope?: string; + /** + * visual centre − box centre for a text primitive: the font-metric term the + * explanation must name so it is not mistaken for a box-model defect. + */ + typographyOffset?: number; + }>; + +export type GeometryCapturedScope = Readonly<{ + key: string; + /** Stable across captures; unlike `key`, this never contains a DOM ordinal. */ + identity: string; + source: 'hint' | 'auto'; + depth: number; + rect: GeometryRect; + candidates: readonly GeometryCapturedCandidate[]; + /** Y candidates for the same primitives; discovery runs per row instance. */ + blockCandidates?: readonly GeometryCapturedBlockCandidate[]; + topology?: Readonly<{ + signature: string; + instanceCount: number; + confidence: number; + }>; + /** Transient capture helper; persisted artifacts hoist this map to the capture. */ + boxModelNodes?: Readonly>; +}>; + +export type GeometrySemanticObservation = Readonly<{ + group: string; + instance: string | null; + axis: SemanticAlignmentAxis; + anchor: SemanticAlignmentAnchor; + status: SemanticGeometryStatus; + line: number; + members: readonly Readonly<{ + name: string; + locator?: GeometryStableLocator; + coordinate: number; + /** `dom-N`, shared with capture, so a marker member IS a discovered one. */ + primitiveId?: string; + rect?: GeometryRect; + }>[]; +}>; + +export type GeometryCapture = Readonly<{ + captureId: string; + surfaceFamily: GeometrySurfaceFamily; + surface: string; + storyId: string; + viewport: Readonly<{ width: number; height: number }>; + deviceScaleFactor: number; + /** Contract dimensions vary one capture family; they do not create ad-hoc stories. */ + dimensions?: Readonly<{ theme?: string; locale?: string; density?: string }>; + screenshot: string; + scopes: readonly GeometryCapturedScope[]; + boxModelNodes?: Readonly>; + semanticAlignments?: readonly GeometrySemanticObservation[]; + /** + * Marker-based baseline rules, in the same shape as the alignment rules, so + * marker-removal readiness asks one question of every marker rule there is. + */ + semanticBaselines?: readonly GeometrySemanticObservation[]; +}>; + +export type GeometryCaptureArtifact = Readonly<{ + version: 1; + captures: readonly GeometryCapture[]; +}>; + +export type GeometryObservedScope = Readonly<{ + captureId: string; + surfaceFamily: GeometrySurfaceFamily; + scopeKey: string; + scopeIdentity: string; + scopeRect: GeometryRect; + contentHash: string; + candidateCount: number; + claimedPrimitiveCount: number; + rails?: readonly DiscoveredAlignmentRail[]; + railFamilies?: readonly AlignmentRailFamily[]; + observationRef?: Readonly<{ captureId: string; scopeKey: string }>; +}>; + +export type GeometryObservationArtifact = Readonly<{ + version: 1; + captures: readonly Readonly<{ + captureId: string; + surfaceFamily: GeometrySurfaceFamily; + scopes: readonly GeometryObservedScope[]; + /** + * Y rails are per visual row, and one row can be snapshotted by several + * overlapping scopes, so they are observed once per CAPTURE over the union + * of every scope's Y candidates rather than once per scope. + */ + blockRails?: readonly DiscoveredBlockRail[]; + }>[]; +}>; + +export type GeometryObservationCache = Map; + +export type GeometryFindingEvidence = Readonly<{ + captureId: string; + scopeKey: string; + coordinate: number; + line: number; + normalizedLine: number; + offset: number; + yStart: number; + yEnd: number; + /** Y evidence only: the horizontal extent of the member and its row. */ + xStart?: number; + xEnd?: number; + rowId?: string; + explanation?: GeometryOffsetExplanation; + /** Which anchor the numbers above came from. Y evidence only. */ + anchor?: BlockRailCandidateAnchor; + /** + * The SAME element measured at every other anchor its row reported it on. + * They are supporting measurements, never verdicts: a block edge pair says + * the primitive is a different height, which is not a misalignment. + */ + supportingAnchors?: readonly GeometryAnchorMeasurement[]; + /** Every primitive the row placed on this line, at the verdict anchor. */ + rowMembers?: readonly GeometryRowMember[]; +}>; + +/** One element measured at one anchor, against the row line of that anchor. */ +export type GeometryAnchorMeasurement = Readonly<{ + anchor: BlockRailCandidateAnchor; + coordinate: number; + line: number; + offset: number; + /** Distance between the row's extreme members at this anchor. */ + spread: number; +}>; + +/** A row member as a Y card draws it: enough to annotate it, nothing more. */ +export type GeometryRowMember = Readonly<{ + label: string; + primitiveId: string; + kind?: string; + coordinate: number; + offset: number; + outlier: boolean; + xStart: number; + xEnd: number; + yStart: number; + yEnd: number; +}>; + +/** Box-model terms a stylesheet declares, as opposed to the `layout` remainder. */ +export type GeometryDeclaredBoxModelTerm = 'padding' | 'border' | 'margin' | 'gap'; + +export type GeometryRepairTerm = Readonly<{ + /** Which of the two compared primitives carries the larger value. */ + side: 'member' | 'reference'; + term: GeometryDeclaredBoxModelTerm; + /** Rendered description of the box-model node that owns the differing term. */ + element: string; + memberElement?: string; + referenceElement?: string; + memberValue: number; + referenceValue: number; + /** member − reference, in CSS pixels. */ + delta: number; +}>; + +export type GeometryRepairProposal = Readonly<{ + commonAncestor: string; + edge: 'inline-start' | 'inline-end' | 'block-start' | 'block-end'; + terms: readonly GeometryRepairTerm[]; +}>; + +export type GeometryOffsetExplanation = Readonly<{ + commonAncestor: string; + reference: Readonly<{ label: string; locator: GeometryStableLocator }>; + memberPath: Readonly<{ + distance: number; + contribution: GeometryBoxModelContribution; + }>; + referencePath: Readonly<{ + distance: number; + contribution: GeometryBoxModelContribution; + }>; + explainedOffset: number; + residual: number; + /** Present only when declared terms actually differ along the two paths. */ + repair?: GeometryRepairProposal; +}>; + +/** + * Deterministic, evidence-only classification of an alignment-rail finding. + * `css-defect` is repairable by editing a declared box-model term, + * `optical-residual` is glyph/rounding whitespace inside one device pixel of + * the measurement model, and `structural` is everything else. + */ +export type GeometryFindingClassification = 'css-defect' | 'optical-residual' | 'structural'; + +export type GeometryDimensionAxis = 'theme' | 'locale' | 'density'; + +export const GEOMETRY_DIMENSION_AXES: readonly GeometryDimensionAxis[] = [ + 'theme', + 'locale', + 'density', +]; + +export type GeometryDimensionSensitivity = Readonly<{ + axis: GeometryDimensionAxis; + value: string; +}>; + +/** + * `row-spread` is what a two-member row can honestly report. With two members + * the median is their midpoint, so BOTH sit half the gap away from it and + * blaming either one for `spread / 2` invents a direction the measurement does + * 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 GeometryFinding = Readonly<{ + key: string; + kind: GeometryFindingKind; + surfaceFamily: GeometrySurfaceFamily; + locator?: GeometryStableLocator; + /** Always human readable: accessible name, else role plus a row description. */ + label: string; + axis: SemanticAlignmentAxis; + /** + * Y: the anchor the verdict came from — `visual-center` when the row mixes + * text with an icon, image or painted shape, `text-baseline` when every + * member is text, `block-center` otherwise. X: the rail's own anchor. + */ + anchor: SemanticAlignmentAnchor; + /** Why that anchor decided, so a reviewer can check the rule, not guess it. */ + verdictAnchorReason?: 'mixed-kinds' | 'all-text' | 'boxes-only'; + /** `row-spread` only: the distance between the row's extreme members. */ + spread?: number; + normalizedLine?: number; + offset: number; + captureCount: number; + totalCaptureCount: number; + /** Alignment-rail findings only; derived from evidence explanations alone. */ + classification?: GeometryFindingClassification; + repairProposal?: GeometryRepairProposal; + /** Set when every evidence row shares one value of a varying capture axis. */ + dimensionSensitivity?: readonly GeometryDimensionSensitivity[]; + evidence: readonly GeometryFindingEvidence[]; +}>; + +export type GeometryFindingArtifact = Readonly<{ + version: 1; + findings: readonly GeometryFinding[]; +}>; + +export type GeometryLedgerStatus = 'new' | 'accepted-debt' | 'ignored' | 'promoted'; + +/** + * The stylesheet is the single source of truth for a named geometry token. A + * ledger entry only says WHICH custom property carries it; `expected` is + * documentation for a human reader and is never used as a gate value. + */ +export type GeometryDesignToken = Readonly<{ + unit: 'px'; + cssVariable: `--${string}`; + expected?: number; +}>; + +export type GeometryResolvedToken = Readonly<{ + name: string; + cssVariable: string; + value: number; +}>; + +/** + * Turn the computed value of a token's custom property into a number. A missing + * or non-px value is a hard error: silently falling back to a checked-in number + * would make the gate pass against a token the product no longer defines. + */ +export function resolveGeometryDesignToken( + name: string, + token: GeometryDesignToken | undefined, + computedValue: string | null | undefined +): GeometryResolvedToken { + if (!token) throw new Error(`Geometry token ${name} is not declared in the ledger`); + const raw = (computedValue ?? '').trim(); + if (raw === '') { + throw new Error( + `Geometry token ${name} (${token.cssVariable}) resolved to nothing in the document` + ); + } + const match = /^(-?\d+(?:\.\d+)?)px$/.exec(raw); + if (!match) { + throw new Error( + `Geometry token ${name} (${token.cssVariable}) is not a px length: ${JSON.stringify(raw)}` + ); + } + return { name, cssVariable: token.cssVariable, value: Number(match[1]) }; +} + +export type GeometryBoxModelProperty = + | 'padding-inline-start' + | 'padding-inline-end' + | 'border-inline-start-width' + | 'border-inline-end-width' + | 'row-gap' + | 'column-gap'; + +export type GeometryContractRelation = + | Readonly<{ kind: 'coincident' }> + | Readonly<{ + kind: 'box-model-equals-token' | 'box-model-multiple-of-token'; + property: GeometryBoxModelProperty; + token: string; + }> + | Readonly<{ + /** + * One rail reached by more than one declared term: the member's value is + * the sum of these properties. Rows reach the trailing rail as padding + * plus a transparent border, and a single-property contract would have to + * exclude them or loosen the tolerance to include them. + */ + kind: 'box-model-sum-equals-token'; + properties: readonly GeometryBoxModelProperty[]; + token: string; + }>; + +/** The computed properties a relation reads, or null when it compares coordinates. */ +export function geometryContractRelationProperties( + relation: GeometryContractRelation | undefined +): readonly GeometryBoxModelProperty[] | null { + if (!relation || relation.kind === 'coincident') return null; + return relation.kind === 'box-model-sum-equals-token' ? relation.properties : [relation.property]; +} + +export type GeometryContract = Readonly<{ + name: string; + story: string; + members: readonly GeometryStableLocator[]; + axis: SemanticAlignmentAxis; + anchor: SemanticAlignmentAnchor; + space: 'ink' | 'layout-box'; + tolerance: number; + /** Omitted contracts retain the original coincident-rail behavior. */ + relation?: GeometryContractRelation; +}>; + +/** + * What a ledger entry reviewed, in terms that survive a key change. A finding + * key is structural, so improving the structure re-keys entries a human already + * decided about — and a review nobody can carry forward is a review that has to + * be done again. This is display identity, never key material. + */ +export type GeometryReviewedIdentity = Readonly<{ + label: string; + axis: SemanticAlignmentAxis; + anchor: SemanticAlignmentAnchor; + surfaceFamily: GeometrySurfaceFamily; +}>; + +export type GeometryLedgerEntry = Readonly<{ + status: GeometryLedgerStatus; + reason?: string; + baseline?: Readonly<{ offset: number }>; + contract?: GeometryContract; + identity?: GeometryReviewedIdentity; +}>; + +export type GeometryLedger = Readonly<{ + version: 1; + tokens?: Readonly>; + findings: Readonly>; +}>; + +/** + * One reviewed entry and the finding that replaced it under a new key. `label` + * is the strong match: two runs that print the same label are the same element. + * `measurement` is the fallback for the case where a label CANNOT be normalized + * — a locale switch renames `Machine` to `\u673a\u5668` — where the same axis, the + * same anchor, the same surface and an offset within a quarter pixel identify it. + */ +export type GeometryRekeyedFinding = Readonly<{ + from: string; + to: string; + reason: 'label' | 'measurement'; + label: string; +}>; + +export type GeometryFindingDiff = Readonly<{ + current: readonly Readonly<{ finding: GeometryFinding; state: GeometryLedgerStatus }>[]; + new: readonly GeometryFinding[]; + changed: readonly GeometryFinding[]; + resolved: readonly string[]; + /** Reviewed entries whose element is still here under a different key. */ + rekeyed: readonly GeometryRekeyedFinding[]; +}>; + +export type GeometryContractArtifact = Readonly<{ + version: 1; + tokens?: Readonly>; + contracts: readonly Readonly[]; +}>; + +export type GeometryQualityMetrics = Readonly<{ + labeledFindingCount: number; + ignoredFindingCount: number; + discoveryPrecision: number | null; + interactivePrimitiveCount: number; + constrainedInteractivePrimitiveCount: number; + geometryCoverage: number | null; +}>; + +export type GeometryContractEvaluation = Readonly<{ + valid: boolean; + maximumError: number; +}>; + +function stableHash(value: string): string { + let hash = 2166136261; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(36); +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => `${JSON.stringify(key)}:${stableJson(child)}`) + .join(',')}}`; + } + return JSON.stringify(value); +} + +function relativeCoordinate(value: number, origin: number, deviceScaleFactor: number): number { + return quantizeGeometryCoordinate(value - origin, deviceScaleFactor); +} + +export function geometryScopeContentHash( + scope: GeometryCapturedScope, + deviceScaleFactor: number +): string { + const normalized = scope.candidates + .map((candidate) => ({ + locator: candidate.locator, + label: candidate.label, + ...(candidate.kind ? { kind: candidate.kind } : {}), + ...(candidate.space ? { space: candidate.space } : {}), + ...(candidate.rowFamily ? { rowFamily: candidate.rowFamily } : {}), + ...(candidate.sectionId ? { sectionId: candidate.sectionId } : {}), + ...(candidate.alignmentMode ? { alignmentMode: candidate.alignmentMode } : {}), + anchor: candidate.anchor, + coordinate: relativeCoordinate(candidate.coordinate, scope.rect.x, deviceScaleFactor), + yStart: relativeCoordinate(candidate.yStart, scope.rect.y, deviceScaleFactor), + yEnd: relativeCoordinate(candidate.yEnd, scope.rect.y, deviceScaleFactor), + })) + .sort((left, right) => stableJson(left).localeCompare(stableJson(right))); + return stableHash( + stableJson({ + width: quantizeGeometryCoordinate(scope.rect.width, deviceScaleFactor), + height: quantizeGeometryCoordinate(scope.rect.height, deviceScaleFactor), + candidates: normalized, + }) + ); +} + +function quantizeCandidate( + candidate: GeometryCapturedCandidate, + deviceScaleFactor: number +): GeometryCapturedCandidate { + return { + ...candidate, + coordinate: quantizeGeometryCoordinate(candidate.coordinate, deviceScaleFactor), + yStart: quantizeGeometryCoordinate(candidate.yStart, deviceScaleFactor), + yEnd: quantizeGeometryCoordinate(candidate.yEnd, deviceScaleFactor), + }; +} + +function scopeArea(scope: GeometryCapturedScope): number { + return scope.rect.width * scope.rect.height; +} + +function rectContains(outer: GeometryRect, inner: GeometryRect): boolean { + const tolerance = 0.5; + return ( + inner.x >= outer.x - tolerance && + inner.y >= outer.y - tolerance && + inner.x + inner.width <= outer.x + outer.width + tolerance && + inner.y + inner.height <= outer.y + outer.height + tolerance + ); +} + +/** + * Observation is deliberately deterministic. A child scope turns supported + * primitive rails into summaries; its parent clusters those summaries together + * with primitives that no child rail claimed. This preserves cross-partition + * relationships without letting every ancestor rediscover every raw candidate. + * Byte-identical scope captures reference the first observation. + */ +export function observeGeometryCaptures( + artifact: GeometryCaptureArtifact, + options: Readonly<{ cache?: GeometryObservationCache }> = {} +): GeometryObservationArtifact { + const canonicalByContent = options.cache ?? new Map(); + const captures = artifact.captures.map((capture) => { + const observations: GeometryObservedScope[] = []; + const processedScopes = new Map(); + const scopes = [...capture.scopes].sort( + (left, right) => + scopeArea(left) - scopeArea(right) || + right.depth - left.depth || + left.identity.localeCompare(right.identity) + ); + + for (const scope of scopes) { + const contentHash = geometryScopeContentHash(scope, capture.deviceScaleFactor); + const contained = observations.filter((observation) => { + const captured = processedScopes.get(observation.scopeKey); + return ( + captured !== undefined && + scopeArea(captured) < scopeArea(scope) && + rectContains(scope.rect, captured.rect) + ); + }); + const children = contained.filter((candidate) => { + const candidateScope = processedScopes.get(candidate.scopeKey); + if (!candidateScope) return false; + return !contained.some((between) => { + if (between.scopeKey === candidate.scopeKey) return false; + const betweenScope = processedScopes.get(between.scopeKey); + return ( + betweenScope !== undefined && + scopeArea(candidateScope) < scopeArea(betweenScope) && + rectContains(betweenScope.rect, candidateScope.rect) + ); + }); + }); + const hierarchyHash = stableHash( + stableJson( + children + .map((child) => ({ identity: child.scopeIdentity, contentHash: child.contentHash })) + .sort((left, right) => left.identity.localeCompare(right.identity)) + ) + ); + const cacheKey = `${capture.surfaceFamily}\u0000${scope.identity}\u0000${contentHash}\u0000${hierarchyHash}`; + const canonical = canonicalByContent.get(cacheKey); + if (canonical) { + observations.push({ + captureId: capture.captureId, + surfaceFamily: capture.surfaceFamily, + scopeKey: scope.key, + scopeIdentity: scope.identity, + scopeRect: scope.rect, + contentHash, + candidateCount: scope.candidates.length, + claimedPrimitiveCount: canonical.claimedPrimitiveCount, + observationRef: { captureId: canonical.captureId, scopeKey: canonical.scopeKey }, + }); + processedScopes.set(scope.key, scope); + continue; + } + + const knownScopes = [...observations, ...canonicalByContent.values()]; + const childRailCandidates = children.flatMap((child) => { + const materialized = materializeGeometryObservationScope(child, knownScopes); + return (materialized.rails ?? []).flatMap((rail) => + rail.members.flatMap((member) => { + const captured = member as typeof member & Partial; + return captured.primitiveId && captured.locator && captured.label + ? [captured as GeometryCapturedCandidate] + : []; + }) + ); + }); + const claimedByChildren = new Set( + childRailCandidates.map((candidate) => candidate.primitiveId) + ); + const candidateByIdentity = new Map(); + for (const candidate of [ + ...scope.candidates.filter((item) => !claimedByChildren.has(item.primitiveId)), + ...childRailCandidates, + ]) { + const quantized = quantizeCandidate(candidate, capture.deviceScaleFactor); + candidateByIdentity.set(`${quantized.primitiveId}\u0000${quantized.anchor}`, quantized); + } + const candidates = [...candidateByIdentity.values()]; + const heights = candidates + .map((candidate) => candidate.yEnd - candidate.yStart) + .filter((height) => Number.isFinite(height) && height > 0) + .sort((left, right) => left - right); + const typicalHeight = heights[Math.floor(heights.length / 2)] ?? 16; + const rawRails = discoverAlignmentRails(candidates, { + mergeTolerance: Math.max(4, Math.min(12, typicalHeight / 2)), + minSupport: 2, + scopeHeight: scope.rect.height, + }); + const rails = selectCanonicalAlignmentRails(rawRails, scope.rect); + const claimedHere = new Set( + rails.flatMap((rail) => + rail.members + .map( + (member) => (member as typeof member & Partial).primitiveId + ) + .filter((primitiveId): primitiveId is string => Boolean(primitiveId)) + ) + ); + const observation: GeometryObservedScope = { + captureId: capture.captureId, + surfaceFamily: capture.surfaceFamily, + scopeKey: scope.key, + scopeIdentity: scope.identity, + scopeRect: scope.rect, + contentHash, + candidateCount: scope.candidates.length, + claimedPrimitiveCount: claimedHere.size, + rails, + railFamilies: groupAlignmentRailFamilies(rawRails), + }; + observations.push(observation); + canonicalByContent.set(cacheKey, observation); + processedScopes.set(scope.key, scope); + } + + const blockCandidates = new Map(); + for (const scope of capture.scopes) { + for (const candidate of scope.blockCandidates ?? []) { + // A row can be snapshotted by an aggregate scope and by its child, and + // the union is what the row actually renders; the first occurrence wins + // so a scope's iteration order cannot change a median. + const key = `${candidate.rowId}${candidate.primitiveId}${candidate.anchor}`; + if (!blockCandidates.has(key)) blockCandidates.set(key, candidate); + } + } + const blockRails = discoverBlockAlignmentRails([...blockCandidates.values()], { + deviceScaleFactor: capture.deviceScaleFactor, + }); + + return { + captureId: capture.captureId, + surfaceFamily: capture.surfaceFamily, + scopes: observations.sort((left, right) => left.scopeKey.localeCompare(right.scopeKey)), + ...(blockRails.length > 0 ? { blockRails } : {}), + }; + }); + return { version: 1, captures }; +} + +/** + * Identity is STRUCTURAL: landmark, section, row family, role, which instance + * of that family in the section, and which element of that role in the row. An + * accessible name never appears here — `Machine` and `\u673a\u5668` are one control, and a + * finding that changed key because the fixture switched locale is a finding + * nobody can review twice. + */ +function locatorIdentity(locator: GeometryStableLocator): string { + const landmark = locator.landmark + ? `${locator.landmark.role}:${locator.landmark.name ?? ''}>` + : ''; + const section = locator.section ? `section:${locator.section}>` : ''; + const row = locator.rowName ? `row:${locator.rowName}>` : ''; + const rowFamily = locator.rowFamily ? `row-family:${locator.rowFamily}>` : ''; + const familyIndex = locator.familyIndex === undefined ? '' : `@${locator.familyIndex}`; + const roleIndex = locator.roleIndex === undefined ? '' : `#${locator.roleIndex}`; + return `${landmark}${section}${row}${rowFamily}${locator.role}${familyIndex}${roleIndex}`; +} + +function makeFindingKey(parts: readonly string[]): string { + const identity = parts.join('|'); + return `geometry/${parts[0]}/${stableHash(identity)}`; +} + +/** + * A Y finding is ONE ELEMENT in one row, not one element per anchor: the same + * icon reported at `block-start`, `block-center`, `visual-center` and + * `text-baseline` is four measurements of a single question, so the key carries + * the axis and the finding names the anchor its verdict came from. X anchors + * stay in the key — `inline-start` and `inline-end` really are two rails. + */ +export function alignmentFindingKey( + input: Readonly<{ + surfaceFamily: GeometrySurfaceFamily; + locator: GeometryStableLocator; + anchor: GeometryExplainedAnchor; + axis?: SemanticAlignmentAxis; + /** A two-member row is one spread, keyed by the row rather than a member. */ + kind?: GeometryFindingKind; + }> +): string { + const axisTerm = input.axis === 'y' ? 'y' : input.anchor; + return makeFindingKey([ + input.surfaceFamily, + locatorIdentity(input.locator), + axisTerm, + ...(input.kind === 'row-spread' ? ['row-spread'] : []), + ]); +} + +function resolveObservedScope( + scope: GeometryObservedScope, + byCaptureAndScope: ReadonlyMap +): GeometryObservedScope { + let current = scope; + const visited = new Set(); + while (current.observationRef) { + const key = `${current.observationRef.captureId}\u0000${current.observationRef.scopeKey}`; + if (visited.has(key)) throw new Error(`Circular geometry observation reference: ${key}`); + visited.add(key); + const referenced = byCaptureAndScope.get(key); + if (!referenced) throw new Error(`Missing geometry observation reference: ${key}`); + current = referenced; + } + return current; +} + +export function materializeGeometryObservationScope( + scope: GeometryObservedScope, + scopes: readonly GeometryObservedScope[] +): GeometryObservedScope { + const byCaptureAndScope = new Map( + scopes.map((candidate) => [`${candidate.captureId}\u0000${candidate.scopeKey}`, candidate]) + ); + const source = resolveObservedScope(scope, byCaptureAndScope); + if (source === scope) return scope; + const deltaX = scope.scopeRect.x - source.scopeRect.x; + const deltaY = scope.scopeRect.y - source.scopeRect.y; + const translateRail = (rail: DiscoveredAlignmentRail): DiscoveredAlignmentRail => { + const members = rail.members.map((member) => ({ + ...member, + coordinate: member.coordinate + deltaX, + yStart: member.yStart + deltaY, + yEnd: member.yEnd + deltaY, + })); + return { + ...rail, + line: rail.line + deltaX, + members, + outliers: members.filter( + (member): member is (typeof members)[number] & { outlier: true } => member.outlier + ), + }; + }; + const rails = (source.rails ?? []).map(translateRail); + return { + ...scope, + rails, + railFamilies: groupAlignmentRailFamilies(rails), + }; +} + +function roundedNormalizedLine(line: number, rect: GeometryRect): number { + if (rect.width <= 0) return 0; + return Number(((line - rect.x) / rect.width).toFixed(4)); +} + +const EMPTY_BOX_MODEL_CONTRIBUTION: GeometryBoxModelContribution = { + padding: 0, + border: 0, + margin: 0, + gap: 0, + layout: 0, +}; + +function addBoxModelContribution( + left: GeometryBoxModelContribution, + right: GeometryBoxModelContribution +): GeometryBoxModelContribution { + return { + padding: left.padding + right.padding, + border: left.border + right.border, + margin: left.margin + right.margin, + gap: left.gap + right.gap, + layout: left.layout + right.layout, + }; +} + +/** Every anchor an offset explanation can be asked about, on either axis. */ +export type GeometryExplainedAnchor = + | 'inline-start' + | 'inline-center' + | 'inline-end' + | BlockRailCandidateAnchor; + +const BLOCK_EDGE_ANCHORS: ReadonlySet = new Set([ + 'block-start', + 'block-end', +]); + +function isBlockAnchor(anchor: GeometryExplainedAnchor): boolean { + return anchor !== 'inline-start' && anchor !== 'inline-center' && anchor !== 'inline-end'; +} + +function summarizeBoxModelPath( + path: readonly GeometryBoxModelPathStep[], + commonAncestorId: string, + anchor: GeometryExplainedAnchor +): Readonly<{ distance: number; contribution: GeometryBoxModelContribution }> { + let distance = 0; + let contribution = EMPTY_BOX_MODEL_CONTRIBUTION; + for (const step of path) { + if (step.nodeId === commonAncestorId) break; + if (anchor === 'inline-start') { + distance += step.startToParent; + contribution = addBoxModelContribution(contribution, step.inlineStart); + } else if (anchor === 'inline-end') { + distance += step.endToParent; + contribution = addBoxModelContribution(contribution, step.inlineEnd); + } else if (anchor === 'inline-center') { + distance += step.centerToParent; + contribution = addBoxModelContribution(contribution, { + ...EMPTY_BOX_MODEL_CONTRIBUTION, + layout: step.centerToParent, + }); + } else if (anchor === 'block-start') { + distance += step.blockStartToParent ?? 0; + contribution = addBoxModelContribution( + contribution, + step.blockStart ?? EMPTY_BOX_MODEL_CONTRIBUTION + ); + } else if (anchor === 'block-end') { + distance += step.blockEndToParent ?? 0; + contribution = addBoxModelContribution( + contribution, + step.blockEnd ?? EMPTY_BOX_MODEL_CONTRIBUTION + ); + } else { + // A block centre, a visual centre and a baseline all travel the same + // distance through the box model; what separates them is the per-member + // typography term the caller adds, never a declared box-model property. + const centerToParent = step.blockCenterToParent ?? 0; + distance += centerToParent; + contribution = addBoxModelContribution(contribution, { + ...EMPTY_BOX_MODEL_CONTRIBUTION, + layout: centerToParent, + }); + } + } + return { distance, contribution }; +} + +/** Explain an observed delta using exact ancestor distances plus declared CSS terms. */ +export function explainGeometryOffset( + member: GeometryCapturedCandidate | GeometryCapturedBlockCandidate, + reference: GeometryCapturedCandidate | GeometryCapturedBlockCandidate, + anchor: GeometryExplainedAnchor, + observedOffset: number, + nodes: Readonly> = {} +): GeometryOffsetExplanation | undefined { + const materializePath = (nodeRef: string | undefined) => { + const path: GeometryBoxModelPathStep[] = []; + const visited = new Set(); + for (let nodeId = nodeRef; nodeId && !visited.has(nodeId); ) { + visited.add(nodeId); + const node = nodes[nodeId]; + if (!node) break; + path.push(node); + nodeId = node.parentId; + } + return path; + }; + const memberPathSteps = materializePath(member.boxModelNodeRef); + const referencePathSteps = materializePath(reference.boxModelNodeRef); + if (!memberPathSteps?.length || !referencePathSteps?.length) return undefined; + const referenceNodeIds = new Set(referencePathSteps.map((step) => step.nodeId)); + const commonAncestor = memberPathSteps.find((step) => referenceNodeIds.has(step.nodeId)); + if (!commonAncestor) return undefined; + if (isBlockAnchor(anchor) && memberPathSteps[0]?.blockStartToParent === undefined) { + return undefined; + } + const rawMemberPath = summarizeBoxModelPath(memberPathSteps, commonAncestor.nodeId, anchor); + const rawReferencePath = summarizeBoxModelPath(referencePathSteps, commonAncestor.nodeId, anchor); + // Half of (line box − cap-height band) is why a centred label's ink sits off + // its own box centre. It is recorded as its own term, so an explanation can + // say "font metrics" instead of leaving it inside an unexplained residual. + const typography = (candidate: typeof member) => + anchor === 'visual-center' && 'typographyOffset' in candidate + ? (candidate.typographyOffset ?? 0) + : 0; + const memberTypography = typography(member); + const referenceTypography = typography(reference); + const withTypography = (path: typeof rawMemberPath, value: number): typeof rawMemberPath => + value === 0 + ? path + : { + distance: path.distance + value, + contribution: { ...path.contribution, typography: value }, + }; + const memberPath = withTypography(rawMemberPath, memberTypography); + const referencePath = withTypography(rawReferencePath, referenceTypography); + const explainedOffset = + anchor === 'inline-end' || anchor === 'block-end' + ? referencePath.distance - memberPath.distance + : memberPath.distance - referencePath.distance; + const repair = proposeBoxModelRepair(memberPathSteps, referencePathSteps, commonAncestor, anchor); + return { + commonAncestor: commonAncestor.element, + reference: { label: reference.label, locator: reference.locator }, + memberPath, + referencePath, + explainedOffset, + residual: observedOffset - explainedOffset, + ...(repair ? { repair } : {}), + }; +} + +const DECLARED_BOX_MODEL_TERMS: readonly GeometryDeclaredBoxModelTerm[] = [ + 'padding', + 'border', + 'margin', + 'gap', +]; + +/** A term declared on the parent box vs one declared on the node's own box. */ +const PARENT_OWNED_TERMS: ReadonlySet = new Set([ + 'padding', + 'border', + 'gap', +]); + +/** + * Walk both ancestor chains up to the common ancestor and diff the declared + * terms they accumulate. Per-term totals are what the explained offset is made + * of, so they always add up; each side also names the node that contributes + * most of its total, which is the node a designer would edit. + */ +function proposeBoxModelRepair( + memberSteps: readonly GeometryBoxModelPathStep[], + referenceSteps: readonly GeometryBoxModelPathStep[], + commonAncestor: GeometryBoxModelPathStep, + anchor: GeometryExplainedAnchor +): GeometryRepairProposal | undefined { + // A centre coordinate has no declared term of its own: the box model + // contributes only through the two edges, so proposing a repair from it + // would name a property that does not decide the measured coordinate. A + // baseline and a visual centre are font metrics on top of that centre. + if (anchor !== 'inline-start' && anchor !== 'inline-end' && !BLOCK_EDGE_ANCHORS.has(anchor)) { + return undefined; + } + const edge = anchor as GeometryRepairProposal['edge']; + const side = + anchor === 'inline-start' + ? 'inlineStart' + : anchor === 'inline-end' + ? 'inlineEnd' + : anchor === 'block-start' + ? 'blockStart' + : 'blockEnd'; + const below = (steps: readonly GeometryBoxModelPathStep[]) => { + const result: GeometryBoxModelPathStep[] = []; + for (const step of steps) { + if (step.nodeId === commonAncestor.nodeId) break; + result.push(step); + } + return result.reverse(); + }; + const memberChain = below(memberSteps); + const referenceChain = below(referenceSteps); + const terms_ = (step: GeometryBoxModelPathStep) => step[side] ?? EMPTY_BOX_MODEL_CONTRIBUTION; + const total = (chain: readonly GeometryBoxModelPathStep[], term: GeometryDeclaredBoxModelTerm) => + chain.reduce((sum, step) => sum + terms_(step)[term], 0); + const dominantOwner = ( + chain: readonly GeometryBoxModelPathStep[], + term: GeometryDeclaredBoxModelTerm + ) => { + let bestIndex = -1; + let bestValue = 0; + chain.forEach((step, index) => { + const value = Math.abs(terms_(step)[term]); + if (value > bestValue) { + bestValue = value; + bestIndex = index; + } + }); + if (bestIndex < 0) return undefined; + // padding, border and gap are declared on the containing box; margin sits + // on the node itself. + return PARENT_OWNED_TERMS.has(term) + ? (chain[bestIndex - 1]?.element ?? commonAncestor.element) + : chain[bestIndex]?.element; + }; + const terms: GeometryRepairTerm[] = []; + for (const term of DECLARED_BOX_MODEL_TERMS) { + const memberValue = total(memberChain, term); + const referenceValue = total(referenceChain, term); + const delta = memberValue - referenceValue; + if (Math.abs(delta) < 0.5) continue; + const memberElement = dominantOwner(memberChain, term); + const referenceElement = dominantOwner(referenceChain, term); + const dominantSide = Math.abs(memberValue) >= Math.abs(referenceValue) ? 'member' : 'reference'; + terms.push({ + side: dominantSide, + term, + element: + (dominantSide === 'member' ? memberElement : referenceElement) ?? + memberElement ?? + referenceElement ?? + commonAncestor.element, + ...(memberElement ? { memberElement } : {}), + ...(referenceElement ? { referenceElement } : {}), + memberValue: Number(memberValue.toFixed(4)), + referenceValue: Number(referenceValue.toFixed(4)), + delta: Number(delta.toFixed(4)), + }); + } + if (terms.length === 0) return undefined; + return { + commonAncestor: commonAncestor.element, + edge, + terms: terms.sort( + (left, right) => + Math.abs(right.delta) - Math.abs(left.delta) || left.term.localeCompare(right.term) + ), + }; +} + +function declaredTermDelta(explanation: GeometryOffsetExplanation): number { + return DECLARED_BOX_MODEL_TERMS.reduce( + (total, term) => + total + + (explanation.memberPath.contribution[term] - explanation.referencePath.contribution[term]), + 0 + ); +} + +/** + * The whole classification is arithmetic over one evidence explanation: a + * declared-term difference the box model fully accounts for is a CSS defect, a + * difference the box model says is zero but the ink disagrees about by at most + * 1.5px is optical, and everything else stays a structural review candidate. + */ +export function classifyGeometryOffsetExplanation( + explanation: GeometryOffsetExplanation | undefined, + deviceScaleFactor: number +): GeometryFindingClassification { + if (!explanation) return 'structural'; + const devicePixel = deviceScaleFactor > 0 ? 1 / deviceScaleFactor : 1; + const explained = Math.abs(explanation.explainedOffset); + const residual = Math.abs(explanation.residual); + const declared = Math.abs(declaredTermDelta(explanation)); + const layout = Math.abs( + explanation.memberPath.contribution.layout - explanation.referencePath.contribution.layout + ); + if ( + explained >= 1 && + residual <= devicePixel && + (explanation.repair?.terms.length ?? 0) > 0 && + declared > layout + ) { + return 'css-defect'; + } + if (explained < 1 && residual <= 1.5) return 'optical-residual'; + return 'structural'; +} + +const CLASSIFICATION_SEVERITY: Readonly> = { + 'css-defect': 0, + 'optical-residual': 1, + structural: 2, +}; + +/** Evidence that disagrees resolves to the most severe class, never an average. */ +export function mergeGeometryClassifications( + classifications: readonly GeometryFindingClassification[] +): GeometryFindingClassification { + return classifications.reduce( + (worst, current) => + CLASSIFICATION_SEVERITY[current] > CLASSIFICATION_SEVERITY[worst] ? current : worst, + 'css-defect' + ); +} + +type RowFamilyToken = Readonly<{ tag: string; role: string }>; + +function parseRowFamily(rowFamily: string): Readonly<{ + owner: RowFamilyToken | undefined; + children: readonly RowFamilyToken[]; +}> { + const token = (raw: string): RowFamilyToken | undefined => { + const match = /^([a-z0-9-]+)\[([^\]]*)\]$/.exec(raw.trim()); + return match ? { tag: match[1] ?? '', role: match[2] ?? '' } : undefined; + }; + const [ownerPart, childPart = ''] = rowFamily.split('>'); + return { + owner: token(ownerPart ?? ''), + children: childPart + .split(',') + .flatMap((part) => (part.trim() ? [token(part)].filter(Boolean) : [])) + .filter((item): item is RowFamilyToken => Boolean(item)), + }; +} + +/** + * A DOM-shape signature is an identity, not a sentence. Findings print this + * short description instead, so a card never shows the raw family string. + */ +export function describeGeometryRowFamily(rowFamily: string | undefined): string { + if (!rowFamily) return 'layout'; + const { owner, children } = parseRowFamily(rowFamily); + const roles = new Set([owner?.role ?? '', ...children.map((child) => child.role)]); + if (roles.has('tablist') || roles.has('tab')) return 'tab bar'; + if (roles.has('listitem') || owner?.tag === 'li') return 'list row'; + const ownerRole = owner?.role ?? ''; + if (ownerRole === 'button' || ownerRole === 'link') return `${ownerRole} contents`; + const childRoles = children.map((child) => child.role); + const hasControl = childRoles.some((role) => role === 'button' || role === 'link'); + const hasText = childRoles.some((role) => role === 'text' || role === 'numeric-text'); + if (hasControl && hasText) return 'row'; + if (hasControl) return 'action row'; + if (hasText) return 'text row'; + return `${owner?.tag ?? 'div'} group`; +} + +const HTML_TAG_LABEL = /^[a-z][a-z0-9-]*$/; + +function collapseWhitespace(value: string): string { + return value.replace(/\s+/g, ' ').trim(); +} + +/** + * An accessible name accumulates the names of every control nested inside it, + * so a tab reads as `Files Close Files`. The element's own text is what is left + * after removing what its nested controls contributed. + */ +export function geometryOwnText(name: string, nestedControlNames: readonly string[] = []): string { + const full = collapseWhitespace(name); + let text = full; + for (const nested of [...nestedControlNames].sort((left, right) => right.length - left.length)) { + const needle = collapseWhitespace(nested); + if (needle === '' || needle === text) continue; + text = collapseWhitespace(text.split(needle).join(' ')); + } + return text === '' ? full : text; +} + +/** + * True when the accessible name is built from more than the element itself, so + * it describes the row's current contents rather than the control. + */ +export function isGeometryContentAggregatedName( + locator: GeometryStableLocator, + naming: GeometryCandidateNaming | undefined +): boolean { + if (!locator.name) return false; + const full = collapseWhitespace(locator.name); + return geometryOwnText(full, naming?.nestedControlNames ?? []) !== full; +} + +/** + * Identity may never carry content. A control that repeats over data — a row of + * a repeated row family whose accessible name aggregates that row's own + * contents — is identified by where it sits (landmark, row family, role, + * same-role index), so renaming the fixture or switching locale cannot mint a + * second finding for one element. A singleton keeps its accessible name: it is + * the only stable thing that separates two controls of the same DOM shape. + */ +const GEOMETRY_DATA_BEARING_NAME_PATTERNS: readonly RegExp[] = [ + // A path segment: a name that quotes where a file or project lives moves the + // moment the fixture, the machine or the checkout changes. + /\//, + // The composed-name separator the product uses to append machine/path facts. + / \u00b7 /, + // Relative durations and clock/calendar stamps, which change every render. + /\d+\s*[smhd]\b/, + /\b\d{4}-\d{2}-\d{2}\b/, + /\b\d{1,2}:\d{2}\b/, +]; + +/** + * True when an accessible name carries DATA rather than the control's identity: + * a path, the ` \u00b7 ` separator the product composes facts with, or a + * timestamp/duration. Such a name is a label — printing it is right, keying a + * finding by it would mint a new finding on every checkout or every minute. + */ +export function isGeometryDataBearingName(name: string): boolean { + return GEOMETRY_DATA_BEARING_NAME_PATTERNS.some((pattern) => pattern.test(name)); +} + +/** + * Identity is positional, always. The accessible name is dropped from EVERY + * locator — `Machine` and `\u673a\u5668` are one control, `More actions` and + * `\u66f4\u591a\u64cd\u4f5c` are one control, and a key that changes with the fixture's + * locale mints a second finding for an element nobody moved. + * + * What is left is landmark, section, row family, role and same-role index, plus + * ONE more term where those still collide: which instance of the row family in + * that section this is. That term is added for an ENUMERATED family — a small + * set of distinct controls sharing one DOM shape, like Settings, Help and + * Archive — and dropped for a data-driven repeated family, where ten rendered + * rows of one shape are one reviewable finding rather than ten. A family counts + * as data-driven when some instance names itself from its own contents or from + * data, which is exactly what makes its rows interchangeable. + */ +/** + * One SLOT of one row family inside one section — the unit that either lists or + * enumerates. A row's leading icon and its trailing button are different + * questions: three chat rows share one trailing `More actions`, and three + * footer rows do not share a label at all. + */ +export function geometryRowFamilyKey( + section: string | undefined, + rowFamily: string | undefined, + role: string | undefined, + roleIndex: number | undefined +): string { + return `${section ?? ''}\u0000${rowFamily ?? ''}\u0000${role ?? ''}\u0000${roleIndex ?? ''}`; +} + +export function geometryIdentityLocator( + locator: GeometryStableLocator, + options: Readonly<{ + /** `geometryRowFamilyKey` values whose instances aggregate into one finding. */ + aggregatedRowFamilies?: ReadonlySet; + /** Nearest declared discovery scope. Element-derived, not scope-derived. */ + section?: string; + }> = {} +): GeometryStableLocator { + const { name: _name, familyIndex, ...structural } = locator; + const sectionName = options.section ?? locator.section; + const section = sectionName ? { section: sectionName } : {}; + const aggregated = + options.aggregatedRowFamilies?.has( + geometryRowFamilyKey(sectionName, locator.rowFamily, locator.role, locator.roleIndex) + ) ?? false; + return !aggregated && familyIndex !== undefined + ? { ...structural, familyIndex, ...section } + : { ...structural, ...section }; +} + +/** + * A row family is repeated when the capture rendered at least two instances of + * it. Instances are counted by row, not by primitive, so one row's icon and + * label cannot make a singleton look repeated. + */ +export function collectRepeatedGeometryRowFamilies( + artifact: GeometryCaptureArtifact +): ReadonlyMap> { + const repeated = new Map>(); + for (const capture of artifact.captures) { + const rowsByFamily = new Map>(); + for (const scope of capture.scopes) { + for (const candidate of [...scope.candidates, ...(scope.blockCandidates ?? [])]) { + const family = candidate.locator.rowFamily; + if (!family || !candidate.rowId) continue; + const rows = rowsByFamily.get(family) ?? new Set(); + rows.add(`${scope.key}${candidate.rowId}`); + rowsByFamily.set(family, rows); + } + } + // Union across the captures of one surface: a family repeated in ANY + // capture is repeated. Overwriting would let the last capture of a surface + // decide alone, so a story that renders one row would unrepeat a list. + const families = repeated.get(capture.surfaceFamily) ?? new Set(); + for (const [family, rows] of rowsByFamily) { + if (rows.size >= 2) families.add(family); + } + repeated.set(capture.surfaceFamily, families); + } + return repeated; +} + +/** + * How many instances of one row family a section may render before it is a + * LIST rather than an enumeration, whatever its members are called. + */ +const GEOMETRY_MAX_ENUMERATED_FAMILY_INSTANCES = 3; + +/** + * Which (section, row family, role, same-role index) slots aggregate their + * instances into a single finding rather than one per rendered instance. + * + * The question is whether the instances are INTERCHANGEABLE, and the strongest + * evidence is what they are called — not the text, which a locale rewrites, but + * whether they agree. Three chat rows all carry `More actions` in the same + * slot: one control, seen three times. Three footer rows carry Settings, Help + * and Archive there: three controls that happen to share a DOM shape, and the + * family-instance index is the only thing that can tell them apart. + * + * Two more signals aggregate on their own, because either alone already means + * the rows repeat over data: some instance names itself from its own contents + * or from data (a title, a path, a timestamp), or the section renders more + * instances than an enumeration would. + */ +export function collectAggregatedGeometryRowFamilies( + artifact: GeometryCaptureArtifact +): ReadonlyMap> { + const aggregated = new Map>(); + for (const capture of artifact.captures) { + const keys = aggregated.get(capture.surfaceFamily) ?? new Set(); + const families = new Map< + string, + { + section?: string; + rowFamily: string; + instances: Set; + dataDriven: boolean; + /** slot key -> the names its instances carried, one entry per instance. */ + slots: Map }>; + } + >(); + for (const scope of capture.scopes) { + for (const candidate of [...scope.candidates, ...(scope.blockCandidates ?? [])]) { + const rowFamily = candidate.locator.rowFamily; + if (!rowFamily) continue; + const familyKey = `${candidate.sectionScope ?? ''}\u0000${rowFamily}`; + const family = families.get(familyKey) ?? { + ...(candidate.sectionScope ? { section: candidate.sectionScope } : {}), + rowFamily, + instances: new Set(), + dataDriven: false, + slots: new Map< + string, + { role: string; roleIndex?: number; names: Map } + >(), + }; + const name = candidate.locator.name; + if ( + name !== undefined && + (isGeometryDataBearingName(name) || + isGeometryContentAggregatedName(candidate.locator, candidate.naming)) + ) { + family.dataDriven = true; + } + const familyIndex = candidate.locator.familyIndex; + if (familyIndex !== undefined) { + family.instances.add(familyIndex); + const slotKey = `${candidate.locator.role}\u0000${candidate.locator.roleIndex ?? ''}`; + const slot = family.slots.get(slotKey) ?? { + role: candidate.locator.role, + ...(candidate.locator.roleIndex === undefined + ? {} + : { roleIndex: candidate.locator.roleIndex }), + names: new Map(), + }; + slot.names.set(familyIndex, name ?? ''); + family.slots.set(slotKey, slot); + } + families.set(familyKey, family); + } + } + for (const family of families.values()) { + const repeatsOverData = + family.dataDriven || family.instances.size > GEOMETRY_MAX_ENUMERATED_FAMILY_INSTANCES; + const interchangeable = (slot: { names: Map }) => + new Set(slot.names.values()).size <= 1; + for (const slot of family.slots.values()) { + if (!repeatsOverData && !interchangeable(slot)) continue; + keys.add(geometryRowFamilyKey(family.section, family.rowFamily, slot.role, slot.roleIndex)); + } + // The row itself, for a `row-spread` finding: a row aggregates when every + // one of its slots does, because one distinguishable member is enough to + // make two rendered rows two different rows. + if (repeatsOverData || [...family.slots.values()].every((slot) => interchangeable(slot))) { + keys.add(geometryRowFamilyKey(family.section, family.rowFamily, 'row', undefined)); + } + } + aggregated.set(capture.surfaceFamily, keys); + } + return aggregated; +} + +/** + * Every finding gets a name a designer can read. A repeated row reads as its + * role plus the row's own title; a named control reads as its own text without + * the nested control names its accessible name absorbed; an unnamed primitive + * falls back to its role, its visible text when it has any, and a short + * description of the row it sits in. + */ +export function geometryFindingLabel( + locator: GeometryStableLocator | undefined, + primitiveLabel?: string, + options: Readonly<{ naming?: GeometryCandidateNaming; repeatedRow?: boolean }> = {} +): string { + const rowTitle = collapseWhitespace(options.naming?.rowTitle ?? ''); + if (options.repeatedRow && rowTitle !== '') { + return `${locator?.role ?? 'row'} “${rowTitle}”`; + } + if (locator?.name) { + return geometryOwnText(locator.name, options.naming?.nestedControlNames ?? []); + } + if (!locator) return primitiveLabel ?? 'unnamed element'; + const family = describeGeometryRowFamily(locator.rowFamily ?? locator.selfFamily); + const text = collapseWhitespace(primitiveLabel ?? ''); + const isTagName = text === '' || HTML_TAG_LABEL.test(text); + const index = locator.roleIndex === undefined ? '' : ` #${locator.roleIndex}`; + return isTagName + ? `${locator.role}${index} in ${family}` + : `${locator.role} “${text}” in ${family}`; +} + +/** Kinds a reader sees as glyphs; their optical centre is the cap band. */ +const GEOMETRY_TEXT_KINDS: ReadonlySet = new Set(['text', 'numeric-text']); +/** Kinds a reader sees as a mark: an icon path, an image, a painted shape. */ +const GEOMETRY_MARK_KINDS: ReadonlySet = new Set(['svg', 'image', 'shape']); + +/** + * Which anchor a row's verdict comes from, decided by what the row is MADE OF + * rather than by which anchor happens to disagree most. + * + * - A row mixing text with an icon, image or painted shape is judged at + * `visual-center`: that is the only anchor where a glyph and a mark are + * comparable at all. + * - A row of nothing but text is judged at `text-baseline`, the line every + * reader actually sees those runs sitting on. + * - Anything else — boxes against boxes — is judged at `block-center`. + * + * A block EDGE is never a verdict: two primitives of different heights have + * different edges by construction, which is a size difference, not a + * misalignment. The census reads `block-center`, the one anchor every kind of + * primitive reports, so the row's composition is never inferred from a rail + * that only some members reached. + */ +export function selectGeometryVerdictAnchor(rails: readonly DiscoveredBlockRail[]): Readonly<{ + rail: DiscoveredBlockRail; + reason: 'mixed-kinds' | 'all-text' | 'boxes-only'; +}> | null { + const byAnchor = new Map(rails.map((rail) => [rail.anchor, rail])); + const census = + byAnchor.get('block-center') ?? + [...rails].sort((left, right) => right.sampleSize - left.sampleSize)[0]; + if (!census) return null; + const kinds = census.members.map((member) => member.kind ?? ''); + const hasText = kinds.some((kind) => GEOMETRY_TEXT_KINDS.has(kind)); + const hasMark = kinds.some((kind) => GEOMETRY_MARK_KINDS.has(kind)); + const allText = kinds.length > 0 && kinds.every((kind) => GEOMETRY_TEXT_KINDS.has(kind)); + const [reason, preference] = + hasText && hasMark + ? (['mixed-kinds', ['visual-center', 'block-center']] as const) + : allText + ? (['all-text', ['text-baseline', 'visual-center', 'block-center']] as const) + : (['boxes-only', ['block-center', 'visual-center']] as const); + for (const anchor of preference) { + const rail = byAnchor.get(anchor); + if (rail) return { rail, reason }; + } + return null; +} + +export function createGeometryFindings( + captures: GeometryCaptureArtifact, + observations: GeometryObservationArtifact +): GeometryFindingArtifact { + const totalBySurface = new Map(); + const boxModelNodesByCapture = new Map( + captures.captures.map((capture) => [capture.captureId, capture.boxModelNodes ?? {}]) + ); + const captureById = new Map(captures.captures.map((capture) => [capture.captureId, capture])); + /** + * Dimensions vary one story at one viewport and device scale; comparing across + * those would report a viewport-only or DPR-only difference as a theme. + */ + const dimensionGroupKey = (capture: GeometryCapture) => + `${capture.storyId}\u0000${capture.viewport.width}x${capture.viewport.height}\u0000${capture.deviceScaleFactor}`; + const capturesByDimensionGroup = new Map(); + for (const capture of captures.captures) { + const key = dimensionGroupKey(capture); + capturesByDimensionGroup.set(key, [...(capturesByDimensionGroup.get(key) ?? []), capture]); + } + const dimensionSensitivity = ( + evidence: readonly GeometryFindingEvidence[] + ): readonly GeometryDimensionSensitivity[] => { + const evidenceCaptures = evidence.flatMap((item) => { + const capture = captureById.get(item.captureId); + return capture?.dimensions ? [capture] : []; + }); + if (evidenceCaptures.length === 0) return []; + const peers = [ + ...new Set( + evidenceCaptures.flatMap( + (capture) => capturesByDimensionGroup.get(dimensionGroupKey(capture)) ?? [] + ) + ), + ].filter((capture) => capture.dimensions); + const observedValues = (axis: GeometryDimensionAxis) => + new Set( + evidenceCaptures.flatMap((capture) => { + const value = capture.dimensions?.[axis]; + return value === undefined ? [] : [value]; + }) + ); + return GEOMETRY_DIMENSION_AXES.flatMap((axis) => { + const observed = observedValues(axis); + if (observed.size !== 1) return []; + // Hold every other axis at the values this finding was actually seen + // under, so an axis is only reported when it is the one that varied. + const comparable = peers.filter((capture) => + GEOMETRY_DIMENSION_AXES.every((other) => { + if (other === axis) return true; + const value = capture.dimensions?.[other]; + return value === undefined || observedValues(other).has(value); + }) + ); + const available = new Set( + comparable.flatMap((capture) => { + const value = capture.dimensions?.[axis]; + return value === undefined ? [] : [value]; + }) + ); + const [only] = [...observed]; + return available.size >= 2 && only !== undefined ? [{ axis, value: only }] : []; + }); + }; + for (const capture of captures.captures) { + totalBySurface.set(capture.surfaceFamily, (totalBySurface.get(capture.surfaceFamily) ?? 0) + 1); + } + const observedByKey = new Map(); + for (const capture of observations.captures) { + for (const scope of capture.scopes) { + observedByKey.set(`${scope.captureId}\u0000${scope.scopeKey}`, scope); + } + } + + 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 findingGroups = new Map< + string, + { + surfaceFamily: GeometrySurfaceFamily; + locator: GeometryStableLocator; + /** One label per capture; the merged finding prints the first capture's. */ + labels: Map; + anchor: GeometryExplainedAnchor; + axis: SemanticAlignmentAxis; + kind: 'alignment-rail' | 'row-spread'; + verdictAnchorReason?: 'mixed-kinds' | 'all-text' | 'boxes-only'; + evidence: GeometryFindingEvidence[]; + } + >(); + for (const captureObservation of observations.captures) { + for (const observation of captureObservation.scopes) { + const source = materializeGeometryObservationScope(observation, [...observedByKey.values()]); + for (const rail of source.rails ?? []) { + const normalizedLine = roundedNormalizedLine(rail.line, source.scopeRect); + for (const outlier of rail.outliers) { + const candidate = outlier as typeof outlier & Partial; + if (!candidate.locator) continue; + const reference = rail.members + .filter((member) => !member.outlier) + .flatMap((member): GeometryCapturedCandidate[] => { + const captured = member as typeof member & Partial; + return captured.locator && captured.label && captured.primitiveId + ? [captured as GeometryCapturedCandidate] + : []; + }) + .sort( + (left, right) => + Math.abs(left.coordinate - rail.line) - Math.abs(right.coordinate - rail.line) || + Math.abs((left.yStart + left.yEnd) / 2 - (candidate.yStart + candidate.yEnd) / 2) - + Math.abs( + (right.yStart + right.yEnd) / 2 - (candidate.yStart + candidate.yEnd) / 2 + ) + )[0]; + const identity = geometryIdentityLocator(candidate.locator, { + aggregatedRowFamilies: aggregatedRowFamilies.get(captureObservation.surfaceFamily), + ...(candidate.sectionScope ? { section: candidate.sectionScope } : {}), + }); + const key = alignmentFindingKey({ + surfaceFamily: captureObservation.surfaceFamily, + locator: identity, + anchor: rail.anchor, + axis: 'x', + }); + const group = findingGroups.get(key) ?? { + surfaceFamily: captureObservation.surfaceFamily, + locator: identity, + labels: new Map(), + anchor: rail.anchor, + axis: 'x' as SemanticAlignmentAxis, + kind: 'alignment-rail' as const, + evidence: [], + }; + if (!group.evidence.some((item) => item.captureId === captureObservation.captureId)) { + // The label describes the evidence this capture kept, so a card can + // never print one row's title beside another row's measurement. + group.labels.set( + captureObservation.captureId, + geometryFindingLabel(candidate.locator, candidate.label ?? candidate.elementId, { + naming: candidate.naming, + repeatedRow: labelsAsRepeatedRow( + candidate.locator, + candidate.naming, + captureObservation.surfaceFamily + ), + }) + ); + group.evidence.push({ + captureId: captureObservation.captureId, + scopeKey: observation.scopeKey, + coordinate: candidate.coordinate, + line: rail.line, + normalizedLine, + offset: candidate.coordinate - rail.line, + yStart: candidate.yStart, + yEnd: candidate.yEnd, + ...(reference + ? { + explanation: explainGeometryOffset( + candidate as GeometryCapturedCandidate, + reference, + rail.anchor, + candidate.coordinate - rail.line, + boxModelNodesByCapture.get(captureObservation.captureId) + ), + } + : {}), + }); + } + findingGroups.set(key, group); + } + } + } + + /** + * Y rails. A rail lives inside ONE row instance, so the row median is the + * whole evidence for that row. But one ELEMENT is one finding: the four + * anchors a row reports it on — two block edges, two centres, sometimes a + * baseline — are four measurements of a single question. The row picks the + * anchor its verdict comes from, and every other anchor rides along as a + * supporting measurement, so a card says "this icon sits 1.8px high" once + * instead of four times with four different numbers. + */ + const capture = captureById.get(captureObservation.captureId); + const normalizedBlockLine = (line: number) => + capture?.viewport.height ? Number((line / capture.viewport.height).toFixed(4)) : 0; + const railsByRow = new Map(); + for (const rail of captureObservation.blockRails ?? []) { + railsByRow.set(rail.rowId, [...(railsByRow.get(rail.rowId) ?? []), rail]); + } + for (const rowRails of railsByRow.values()) { + const verdict = selectGeometryVerdictAnchor(rowRails); + if (!verdict) continue; + const { rail, reason } = verdict; + type BlockMember = DiscoveredBlockRail['members'][number] & + Partial; + const members = rail.members as readonly BlockMember[]; + const coordinates = members.map((member) => member.coordinate); + const spread = Math.max(...coordinates) - Math.min(...coordinates); + const rowMembers: readonly GeometryRowMember[] = members.map((member) => ({ + label: geometryFindingLabel(member.locator, member.label ?? member.elementId, { + ...(member.naming ? { naming: member.naming } : {}), + repeatedRow: member.locator + ? labelsAsRepeatedRow(member.locator, member.naming, captureObservation.surfaceFamily) + : false, + }), + 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, + })); + /** Every other anchor this row measured the same primitive on. */ + const supportingAnchorsFor = (primitiveId: string | undefined) => + rowRails.flatMap((other): GeometryAnchorMeasurement[] => { + if (other.anchor === rail.anchor) return []; + const match = (other.members as readonly BlockMember[]).find( + (member) => (member.primitiveId ?? member.elementId) === primitiveId + ); + if (!match) return []; + const otherCoordinates = other.members.map((member) => member.coordinate); + return [ + { + anchor: other.anchor, + coordinate: match.coordinate, + line: other.line, + offset: match.coordinate - other.line, + spread: Math.max(...otherCoordinates) - Math.min(...otherCoordinates), + }, + ]; + }); + + if (rail.outliers.length === 0) continue; + + // Two members, one midpoint: BOTH sit half the gap from it, so blaming + // either for `spread / 2` invents a direction the measurement does not + // have. The row reports its spread once, naming both members. + if (members.length === 2) { + const [first, second] = members; + if (!first || !second || !first.locator || !second.locator) continue; + const rowLocator: GeometryStableLocator = { + role: 'row', + ...(first.locator.landmark ? { landmark: first.locator.landmark } : {}), + ...(rail.rowFamily ? { rowFamily: rail.rowFamily } : {}), + ...(first.locator.familyIndex === undefined + ? {} + : { familyIndex: first.locator.familyIndex }), + }; + const identity = geometryIdentityLocator(rowLocator, { + aggregatedRowFamilies: aggregatedRowFamilies.get(captureObservation.surfaceFamily), + ...(first.sectionScope ? { section: first.sectionScope } : {}), + }); + const key = alignmentFindingKey({ + surfaceFamily: captureObservation.surfaceFamily, + locator: identity, + anchor: rail.anchor, + axis: 'y', + kind: 'row-spread', + }); + const group = findingGroups.get(key) ?? { + surfaceFamily: captureObservation.surfaceFamily, + locator: identity, + labels: new Map(), + anchor: rail.anchor, + axis: 'y' as SemanticAlignmentAxis, + kind: 'row-spread' as const, + verdictAnchorReason: reason, + evidence: [], + }; + if (!group.evidence.some((item) => item.captureId === captureObservation.captureId)) { + const memberLabels = rowMembers.map((member) => member.label); + group.labels.set( + captureObservation.captureId, + `${memberLabels[0] ?? 'row'} ↔ ${memberLabels[1] ?? 'row'}` + ); + const rowSpreadSupport = rowRails.flatMap((other): GeometryAnchorMeasurement[] => { + if (other.anchor === rail.anchor) return []; + const otherCoordinates = other.members.map((member) => member.coordinate); + return [ + { + anchor: other.anchor, + coordinate: other.line, + line: other.line, + // No member is blamed, so there is no signed offset to report: + // the spread IS the measurement at every anchor. + offset: 0, + spread: Math.max(...otherCoordinates) - Math.min(...otherCoordinates), + }, + ]; + }); + group.evidence.push({ + captureId: captureObservation.captureId, + scopeKey: rail.rowId, + rowId: rail.rowId, + coordinate: rail.line, + line: rail.line, + normalizedLine: normalizedBlockLine(rail.line), + offset: spread, + 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: rail.anchor, + supportingAnchors: rowSpreadSupport, + rowMembers, + ...(first.primitiveId && second.primitiveId + ? { + explanation: explainGeometryOffset( + second as GeometryCapturedBlockCandidate, + first as GeometryCapturedBlockCandidate, + rail.anchor, + second.coordinate - first.coordinate, + boxModelNodesByCapture.get(captureObservation.captureId) + ), + } + : {}), + }); + } + findingGroups.set(key, group); + continue; + } + + for (const outlier of rail.outliers) { + const candidate = outlier as BlockMember; + if (!candidate.locator || !candidate.primitiveId) continue; + const reference = members + .filter((member) => !member.outlier) + .flatMap((member): GeometryCapturedBlockCandidate[] => + member.locator && member.label && member.primitiveId + ? [member as GeometryCapturedBlockCandidate] + : [] + ) + .sort( + (left, right) => + Math.abs(left.coordinate - rail.line) - Math.abs(right.coordinate - rail.line) || + left.xStart - right.xStart + )[0]; + const identity = geometryIdentityLocator(candidate.locator, { + aggregatedRowFamilies: aggregatedRowFamilies.get(captureObservation.surfaceFamily), + ...(candidate.sectionScope ? { section: candidate.sectionScope } : {}), + }); + const key = alignmentFindingKey({ + surfaceFamily: captureObservation.surfaceFamily, + locator: identity, + anchor: rail.anchor, + axis: 'y', + }); + const group = findingGroups.get(key) ?? { + surfaceFamily: captureObservation.surfaceFamily, + locator: identity, + labels: new Map(), + anchor: rail.anchor, + axis: 'y' as SemanticAlignmentAxis, + kind: 'alignment-rail' as const, + verdictAnchorReason: reason, + evidence: [], + }; + if (!group.evidence.some((item) => item.captureId === captureObservation.captureId)) { + group.labels.set( + captureObservation.captureId, + geometryFindingLabel(candidate.locator, candidate.label ?? candidate.elementId, { + naming: candidate.naming, + repeatedRow: labelsAsRepeatedRow( + candidate.locator, + candidate.naming, + captureObservation.surfaceFamily + ), + }) + ); + const offset = candidate.coordinate - rail.line; + group.evidence.push({ + captureId: captureObservation.captureId, + scopeKey: rail.rowId, + rowId: rail.rowId, + coordinate: candidate.coordinate, + line: rail.line, + normalizedLine: normalizedBlockLine(rail.line), + offset, + yStart: candidate.yStart ?? rail.line, + yEnd: candidate.yEnd ?? rail.line, + xStart: candidate.xStart, + xEnd: candidate.xEnd, + anchor: rail.anchor, + supportingAnchors: supportingAnchorsFor(candidate.primitiveId), + rowMembers, + ...(reference + ? { + explanation: explainGeometryOffset( + candidate as GeometryCapturedBlockCandidate, + reference, + rail.anchor, + offset, + boxModelNodesByCapture.get(captureObservation.captureId) + ), + } + : {}), + }); + } + 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; + const normalizedLine = + group.evidence.reduce((sum, evidence) => sum + evidence.normalizedLine, 0) / + group.evidence.length; + const evidence = group.evidence.sort((left, right) => + left.captureId.localeCompare(right.captureId) + ); + const label = + (evidence[0] ? group.labels.get(evidence[0].captureId) : undefined) ?? + [...group.labels.values()][0] ?? + geometryFindingLabel(group.locator); + const classification = mergeGeometryClassifications( + evidence.map((item) => + classifyGeometryOffsetExplanation( + item.explanation, + captureById.get(item.captureId)?.deviceScaleFactor ?? 1 + ) + ) + ); + const repairProposal = + classification === 'css-defect' + ? evidence.find( + (item) => + classifyGeometryOffsetExplanation( + item.explanation, + captureById.get(item.captureId)?.deviceScaleFactor ?? 1 + ) === 'css-defect' + )?.explanation?.repair + : undefined; + const sensitivity = dimensionSensitivity(evidence); + const spread = evidence.flatMap((item) => + item.rowMembers + ? [ + Math.max(...item.rowMembers.map((member) => member.coordinate)) - + Math.min(...item.rowMembers.map((member) => member.coordinate)), + ] + : [] + ); + return { + key, + kind: group.kind, + surfaceFamily: group.surfaceFamily, + locator: group.locator, + label, + axis: group.axis, + anchor: group.anchor, + ...(group.verdictAnchorReason ? { verdictAnchorReason: group.verdictAnchorReason } : {}), + ...(group.kind === 'row-spread' && spread.length > 0 + ? { spread: spread.reduce((sum, value) => sum + value, 0) / spread.length } + : {}), + normalizedLine, + offset, + captureCount: evidence.length, + totalCaptureCount: totalBySurface.get(group.surfaceFamily) ?? evidence.length, + classification, + ...(repairProposal ? { repairProposal } : {}), + ...(sensitivity.length > 0 ? { dimensionSensitivity: sensitivity } : {}), + evidence, + }; + }); + + const measurementGroups = new Map< + string, + { + surfaceFamily: GeometrySurfaceFamily; + alignment: GeometrySemanticObservation; + observations: Array>; + } + >(); + for (const capture of captures.captures) { + for (const alignment of capture.semanticAlignments ?? []) { + if (!alignment.instance || alignment.status === 'aligned' || alignment.members.length < 2) { + continue; + } + const signature = alignment.members + .map((member) => `${member.name}:${(member.coordinate - alignment.line).toFixed(2)}`) + .sort() + .join('|'); + const key = makeFindingKey([ + capture.surfaceFamily, + alignment.group, + alignment.axis, + alignment.anchor, + 'measurement-model', + signature, + ]); + const group = measurementGroups.get(key) ?? { + surfaceFamily: capture.surfaceFamily, + alignment, + observations: [], + }; + group.observations.push({ captureId: capture.captureId, alignment }); + measurementGroups.set(key, group); + } + } + for (const [key, group] of measurementGroups) { + if (group.observations.length < 2) continue; + const offset = Math.max( + ...group.alignment.members.map((member) => Math.abs(member.coordinate - group.alignment.line)) + ); + const evidence = group.observations.map(({ captureId, alignment }) => ({ + captureId, + scopeKey: alignment.instance ?? alignment.group, + coordinate: alignment.members[0]?.coordinate ?? alignment.line, + line: alignment.line, + normalizedLine: 0, + offset, + yStart: 0, + yEnd: 0, + })); + findings.push({ + key, + kind: 'measurement-model-divergence', + surfaceFamily: group.surfaceFamily, + label: group.alignment.group, + axis: group.alignment.axis, + anchor: group.alignment.anchor, + offset, + captureCount: new Set(group.observations.map(({ captureId }) => captureId)).size, + totalCaptureCount: totalBySurface.get(group.surfaceFamily) ?? 1, + // No dimension axis here: semantic-alignment observations are captured on + // one representative capture by design, so an absence elsewhere would be + // an artifact of the pipeline rather than a theme or locale difference. + evidence, + }); + } + + return { + version: 1, + findings: findings.sort( + (left, right) => + left.kind.localeCompare(right.kind) || + left.surfaceFamily.localeCompare(right.surfaceFamily) || + left.label.localeCompare(right.label) || + left.key.localeCompare(right.key) + ), + }; +} + +export type GeometryBlockRailParityMember = Readonly<{ + name: string; + primitiveId: string; + markerCoordinate: number; + discoveryCoordinate: number; + coordinateDelta: number; + markerOffset: number; + discoveryOffset: number; + offsetDelta: number; +}>; + +export type GeometryBlockRailParityRow = Readonly<{ + instance: string; + markerLine: number; + discoveryLine: number | null; + rowId: string | null; + members: readonly GeometryBlockRailParityMember[]; + /** Marker members no Y candidate covers, and Y members no marker declares. */ + markerOnly: readonly string[]; + discoveryOnly: readonly string[]; + maxCoordinateDelta: number; + maxOffsetDelta: number; +}>; + +export type GeometryBlockRailParityReport = Readonly<{ + version: 1; + captureId: string; + group: string; + anchor: SemanticAlignmentAnchor; + /** Physical pixel both sides are snapped to before anything is compared. */ + quantization: number; + rows: readonly GeometryBlockRailParityRow[]; + matchedMemberCount: number; + maxCoordinateDelta: number; + maxOffsetDelta: number; +}>; + +/** + * Prove the marker-free Y discovery reproduces a marker-based instance rule. + * Two different questions are answered separately and must not be conflated: + * `coordinateDelta` is whether both stages MEASURE one element the same way, + * and `offsetDelta` is whether they place the row line the same way — which + * they only can when they saw the same members, so the members each side saw + * alone are listed rather than quietly averaged away. + */ +export function compareMarkerAlignmentsToBlockRails( + capture: GeometryCapture, + blockRails: readonly DiscoveredBlockRail[], + options: Readonly<{ group: string }> +): GeometryBlockRailParityReport { + const groups = (capture.semanticAlignments ?? []).filter( + (alignment) => alignment.group === options.group + ); + const anchor = groups[0]?.anchor ?? 'visual-center'; + const snap = (value: number) => quantizeGeometryCoordinate(value, capture.deviceScaleFactor); + const rows = groups.map((alignment): GeometryBlockRailParityRow => { + const markerIds = new Set( + alignment.members.flatMap((member) => (member.primitiveId ? [member.primitiveId] : [])) + ); + const scored = blockRails + .filter((rail) => rail.anchor === alignment.anchor) + .map((rail) => { + const members = rail.members as readonly (DiscoveredBlockRail['members'][number] & + Partial)[]; + return { + rail, + members, + shared: members.filter( + (member) => member.primitiveId !== undefined && markerIds.has(member.primitiveId) + ).length, + }; + }) + .filter((entry) => entry.shared > 0) + .sort((left, right) => right.shared - left.shared || left.rail.line - right.rail.line); + const best = scored[0]; + const discoveryByPrimitive = new Map( + (best?.members ?? []).flatMap((member) => + member.primitiveId ? [[member.primitiveId, member] as const] : [] + ) + ); + const members = alignment.members.flatMap((member): GeometryBlockRailParityMember[] => { + const discovered = member.primitiveId + ? discoveryByPrimitive.get(member.primitiveId) + : undefined; + if (!discovered || !best) return []; + const markerCoordinate = snap(member.coordinate); + const discoveryCoordinate = snap(discovered.coordinate); + const markerOffset = snap(markerCoordinate - alignment.line); + const discoveryOffset = snap(discoveryCoordinate - best.rail.line); + return [ + { + name: member.name, + primitiveId: member.primitiveId ?? '', + markerCoordinate, + discoveryCoordinate, + coordinateDelta: Number((discoveryCoordinate - markerCoordinate).toFixed(4)), + markerOffset, + discoveryOffset, + offsetDelta: Number((discoveryOffset - markerOffset).toFixed(4)), + }, + ]; + }); + const matchedIds = new Set(members.map((member) => member.primitiveId)); + const absMax = (values: readonly number[]) => + values.length === 0 ? 0 : Math.max(...values.map((value) => Math.abs(value))); + return { + instance: alignment.instance ?? alignment.group, + markerLine: snap(alignment.line), + discoveryLine: best ? best.rail.line : null, + rowId: best?.rail.rowId ?? null, + members, + markerOnly: alignment.members + .filter((member) => !member.primitiveId || !matchedIds.has(member.primitiveId)) + .map((member) => member.name), + discoveryOnly: (best?.members ?? []) + .filter((member) => !member.primitiveId || !matchedIds.has(member.primitiveId)) + .map((member) => member.label ?? member.elementId), + maxCoordinateDelta: absMax(members.map((member) => member.coordinateDelta)), + maxOffsetDelta: absMax(members.map((member) => member.offsetDelta)), + }; + }); + return { + version: 1, + captureId: capture.captureId, + group: options.group, + anchor, + quantization: capture.deviceScaleFactor > 0 ? 1 / capture.deviceScaleFactor : 1, + rows, + matchedMemberCount: rows.reduce((total, row) => total + row.members.length, 0), + maxCoordinateDelta: Math.max(0, ...rows.map((row) => row.maxCoordinateDelta)), + maxOffsetDelta: Math.max(0, ...rows.map((row) => row.maxOffsetDelta)), + }; +} + +/** How close two offsets may be and still describe one measured element. */ +const GEOMETRY_REKEY_OFFSET_TOLERANCE = 0.25; + +/** + * Pair reviewed entries that vanished with findings that appeared, when they + * are the same element under a better key. A structural identity change re-keys + * everything it improves, and reporting that as "72 resolved, 71 new" throws + * away every decision a human already made. + * + * Deterministic and one-to-one: candidates are considered in key order, the + * label match is taken before the measurement match, and each side is consumed + * once. An entry that carries no reviewed identity cannot be paired at all — + * it is reported as resolved, which is the honest answer. + */ +export function matchRekeyedGeometryFindings( + resolved: readonly string[], + newFindings: readonly GeometryFinding[], + ledger: GeometryLedger +): readonly GeometryRekeyedFinding[] { + const claimed = new Set(); + const pairs: GeometryRekeyedFinding[] = []; + const candidates = [...newFindings].sort((left, right) => left.key.localeCompare(right.key)); + for (const from of [...resolved].sort()) { + const identity = ledger.findings[from]?.identity; + if (!identity) continue; + const baseline = ledger.findings[from]?.baseline?.offset; + const matches = (finding: GeometryFinding, reason: 'label' | 'measurement') => { + if (claimed.has(finding.key)) return false; + if (finding.surfaceFamily !== identity.surfaceFamily) return false; + if (reason === 'label') return finding.label === identity.label; + return ( + finding.axis === identity.axis && + finding.anchor === identity.anchor && + baseline !== undefined && + Math.abs(Math.abs(finding.offset) - Math.abs(baseline)) <= GEOMETRY_REKEY_OFFSET_TOLERANCE + ); + }; + const matched = + candidates.find((finding) => matches(finding, 'label')) ?? + candidates.find((finding) => matches(finding, 'measurement')); + if (!matched) continue; + claimed.add(matched.key); + pairs.push({ + from, + to: matched.key, + reason: matched.label === identity.label ? 'label' : 'measurement', + label: identity.label, + }); + } + return pairs; +} + +/** One marker member, and whether marker-free discovery reproduced it. */ +export type GeometryMarkerRemovalMember = Readonly<{ + captureId: string; + instance: string; + name: string; + primitiveId: string | null; + markerOffset: number; + discoveryOffset: number | null; + offsetDelta: number | null; + matched: boolean; + /** Why a member did not match, so the gap is actionable rather than a count. */ + reason?: 'no-primitive-id' | 'not-observed' | 'anchor-missing' | 'offset-differs'; +}>; + +export type GeometryMarkerRemovalRule = Readonly<{ + group: string; + axis: SemanticAlignmentAxis; + anchor: SemanticAlignmentAnchor; + captureIds: readonly string[]; + memberCount: number; + matchedMemberCount: number; + /** True only when EVERY member matched on EVERY capture the rule appears in. */ + ready: boolean; + members: readonly GeometryMarkerRemovalMember[]; +}>; + +export type GeometryMarkerRemovalReadiness = Readonly<{ + version: 1; + /** Physical pixel both sides snap to; a rule may not be judged finer. */ + quantization: number; + rules: readonly GeometryMarkerRemovalRule[]; + readyRules: readonly string[]; +}>; + +/** + * Can this marker rule be deleted yet? A marker rule is business-code weight: + * a `data-geometry-*` attribute someone has to keep correct. It may go only + * once marker-free discovery observes the SAME primitive, at the SAME anchor, + * with the same offset from its row line, on every capture the rule appears in. + * One capture where a member is invisible to discovery is one regression the + * removal would hide, so a single unmatched member holds the whole rule back. + */ +export function assessGeometryMarkerRemoval( + captures: GeometryCaptureArtifact, + observations: GeometryObservationArtifact +): GeometryMarkerRemovalReadiness { + const blockRailsByCapture = new Map( + observations.captures.map((capture) => [capture.captureId, capture.blockRails ?? []]) + ); + const quantization = Math.max( + ...captures.captures.map((capture) => + capture.deviceScaleFactor > 0 ? 1 / capture.deviceScaleFactor : 1 + ), + 0 + ); + const rules = new Map< + string, + { + axis: SemanticAlignmentAxis; + anchor: SemanticAlignmentAnchor; + captureIds: Set; + members: GeometryMarkerRemovalMember[]; + } + >(); + for (const capture of captures.captures) { + const blockRails = blockRailsByCapture.get(capture.captureId) ?? []; + const snap = (value: number) => quantizeGeometryCoordinate(value, capture.deviceScaleFactor); + const tolerance = capture.deviceScaleFactor > 0 ? 1 / capture.deviceScaleFactor : 1; + for (const alignment of [ + ...(capture.semanticAlignments ?? []), + ...(capture.semanticBaselines ?? []), + ]) { + const rule = rules.get(alignment.group) ?? { + axis: alignment.axis, + anchor: alignment.anchor, + captureIds: new Set(), + members: [], + }; + rule.captureIds.add(capture.captureId); + // The row a marker rule describes is the discovered rail of the same + // anchor sharing the most members with it: a rail is one row instance, + // and a rule that spans rows has nothing to be compared against. + const markerIds = new Set( + alignment.members.flatMap((member) => (member.primitiveId ? [member.primitiveId] : [])) + ); + const best = blockRails + .filter((rail) => rail.anchor === alignment.anchor) + .map((rail) => ({ + rail, + members: rail.members as readonly (DiscoveredBlockRail['members'][number] & + Partial)[], + })) + .map((entry) => ({ + ...entry, + shared: entry.members.filter( + (member) => member.primitiveId !== undefined && markerIds.has(member.primitiveId) + ).length, + })) + .filter((entry) => entry.shared > 0) + .sort((left, right) => right.shared - left.shared || left.rail.line - right.rail.line)[0]; + const observed = new Map( + (best?.members ?? []).flatMap((member) => + member.primitiveId ? [[member.primitiveId, member] as const] : [] + ) + ); + for (const member of alignment.members) { + const markerOffset = snap(snap(member.coordinate) - alignment.line); + const discovered = member.primitiveId ? observed.get(member.primitiveId) : undefined; + const discoveryOffset = + discovered && best ? snap(snap(discovered.coordinate) - best.rail.line) : null; + const offsetDelta = + discoveryOffset === null ? null : Number((discoveryOffset - markerOffset).toFixed(4)); + const reason = !member.primitiveId + ? ('no-primitive-id' as const) + : !best + ? ('anchor-missing' as const) + : !discovered + ? ('not-observed' as const) + : Math.abs(offsetDelta ?? 0) > tolerance + ? ('offset-differs' as const) + : undefined; + rule.members.push({ + captureId: capture.captureId, + instance: alignment.instance ?? alignment.group, + name: member.name, + primitiveId: member.primitiveId ?? null, + markerOffset, + discoveryOffset, + offsetDelta, + matched: reason === undefined, + ...(reason ? { reason } : {}), + }); + } + rules.set(alignment.group, rule); + } + } + const assessed = [...rules.entries()] + .map(([group, rule]): GeometryMarkerRemovalRule => { + const matchedMemberCount = rule.members.filter((member) => member.matched).length; + return { + group, + axis: rule.axis, + anchor: rule.anchor, + captureIds: [...rule.captureIds].sort(), + memberCount: rule.members.length, + matchedMemberCount, + ready: rule.members.length > 0 && matchedMemberCount === rule.members.length, + members: rule.members, + }; + }) + .sort((left, right) => left.group.localeCompare(right.group)); + return { + version: 1, + quantization, + rules: assessed, + readyRules: assessed.filter((rule) => rule.ready).map((rule) => rule.group), + }; +} + +export function diffGeometryFindings( + artifact: GeometryFindingArtifact, + ledger: GeometryLedger, + offsetTolerance = 0.5 +): GeometryFindingDiff { + const byKey = new Map(artifact.findings.map((finding) => [finding.key, finding])); + const current = artifact.findings.map((finding) => ({ + finding, + state: ledger.findings[finding.key]?.status ?? ('new' as const), + })); + const newFindings = current.filter(({ state }) => state === 'new').map(({ finding }) => finding); + const resolved = Object.entries(ledger.findings) + .filter(([, entry]) => entry.baseline && entry.status !== 'ignored') + .map(([key]) => key) + .filter((key) => !byKey.has(key)) + .sort(); + const rekeyed = matchRekeyedGeometryFindings(resolved, newFindings, ledger); + const rekeyedFrom = new Set(rekeyed.map((pair) => pair.from)); + const rekeyedTo = new Map(rekeyed.map((pair) => [pair.to, pair.from] as const)); + const changed = artifact.findings.filter((finding) => { + const carried = rekeyedTo.get(finding.key); + const baseline = (carried ? ledger.findings[carried] : ledger.findings[finding.key])?.baseline; + return baseline ? Math.abs(finding.offset - baseline.offset) > offsetTolerance : false; + }); + return { + current, + // A re-keyed finding is not new: the decision travels to its new key. + new: newFindings.filter((finding) => !rekeyedTo.has(finding.key)), + changed, + resolved: resolved.filter((key) => !rekeyedFrom.has(key)), + rekeyed, + }; +} + +/** Record previously unseen findings without moving an existing baseline. */ +export function triageGeometryFindings( + artifact: GeometryFindingArtifact, + ledger: GeometryLedger, + status: Extract = 'accepted-debt' +): GeometryLedger { + const findings: Record = { ...ledger.findings }; + const reviewedIdentity = (finding: GeometryFinding): GeometryReviewedIdentity => ({ + label: finding.label, + axis: finding.axis, + anchor: finding.anchor, + surfaceFamily: finding.surfaceFamily, + }); + // Migrate before recording: a re-keyed entry keeps its status, its reason and + // its baseline, so a structural identity change never re-opens a review or + // silently re-baselines the offset a human accepted. + const diff = diffGeometryFindings(artifact, ledger); + const byKey = new Map(artifact.findings.map((finding) => [finding.key, finding])); + for (const pair of diff.rekeyed) { + const entry = findings[pair.from]; + const finding = byKey.get(pair.to); + if (!entry || !finding) continue; + delete findings[pair.from]; + findings[pair.to] = { ...entry, identity: reviewedIdentity(finding) }; + } + for (const finding of artifact.findings) { + const existing = findings[finding.key]; + findings[finding.key] = existing + ? { ...existing, identity: existing.identity ?? reviewedIdentity(finding) } + : { + status, + baseline: { offset: finding.offset }, + identity: reviewedIdentity(finding), + }; + } + return { + version: 1, + ...(ledger.tokens ? { tokens: ledger.tokens } : {}), + findings: Object.fromEntries( + Object.entries(findings).sort(([left], [right]) => left.localeCompare(right)) + ), + }; +} + +export function geometryLocatorMatches( + candidate: GeometryStableLocator, + expected: GeometryStableLocator +): boolean { + return ( + candidate.role === expected.role && + (expected.name === undefined || candidate.name === expected.name) && + (expected.rowName === undefined || candidate.rowName === expected.rowName) && + (expected.rowFamily === undefined || candidate.rowFamily === expected.rowFamily) && + (expected.familyIndex === undefined || candidate.familyIndex === expected.familyIndex) && + (expected.selfFamily === undefined || candidate.selfFamily === expected.selfFamily) && + (expected.roleIndex === undefined || candidate.roleIndex === expected.roleIndex) && + (expected.landmark === undefined || + (candidate.landmark?.role === expected.landmark.role && + (expected.landmark.name === undefined || + candidate.landmark.name === expected.landmark.name))) + ); +} + +/** Ledger labels make discovery quality measurable; promoted locators make UI coverage measurable. */ +export function computeGeometryQualityMetrics( + captures: GeometryCaptureArtifact, + ledger: GeometryLedger +): GeometryQualityMetrics { + const labeledEntries = Object.values(ledger.findings).filter((entry) => entry.status !== 'new'); + const ignoredFindingCount = labeledEntries.filter((entry) => entry.status === 'ignored').length; + const interactiveLocators = new Map< + string, + Readonly<{ surfaceFamily: GeometrySurfaceFamily; locator: GeometryStableLocator }> + >(); + for (const capture of captures.captures) { + for (const scope of capture.scopes) { + for (const candidate of scope.candidates) { + if (!['button', 'link', 'textbox', 'combobox'].includes(candidate.locator.role)) continue; + // Coverage counts RENDERED controls, so it reads the raw locator name + // too. Identity drops the name on purpose; a metric that dropped it + // would report two distinct buttons of one DOM shape as one control. + interactiveLocators.set( + `${capture.surfaceFamily}\u0000${locatorIdentity(candidate.locator)}\u0000${ + candidate.locator.name ?? '' + }`, + { surfaceFamily: capture.surfaceFamily, locator: candidate.locator } + ); + } + } + } + const promotedMembers = Object.entries(ledger.findings).flatMap(([key, entry]) => + entry.status === 'promoted' && entry.contract + ? entry.contract.members.map((locator) => ({ + surfaceFamily: key.split('/')[1] ?? '', + locator, + })) + : [] + ); + const constrainedInteractivePrimitiveCount = [...interactiveLocators.values()].filter( + (candidate) => + promotedMembers.some( + (member) => + candidate.surfaceFamily === member.surfaceFamily && + geometryLocatorMatches(candidate.locator, member.locator) + ) + ).length; + return { + labeledFindingCount: labeledEntries.length, + ignoredFindingCount, + discoveryPrecision: + labeledEntries.length === 0 + ? null + : (labeledEntries.length - ignoredFindingCount) / labeledEntries.length, + interactivePrimitiveCount: interactiveLocators.size, + constrainedInteractivePrimitiveCount, + geometryCoverage: + interactiveLocators.size === 0 + ? null + : constrainedInteractivePrimitiveCount / interactiveLocators.size, + }; +} + +/** A deliberately small, deterministic algebra used by the browser gate. */ +export function evaluateGeometryContractValues( + relation: GeometryContractRelation | undefined, + values: readonly number[], + tolerance: number, + token?: Readonly<{ value: number }> +): GeometryContractEvaluation { + if (values.length === 0) return { valid: false, maximumError: Number.POSITIVE_INFINITY }; + if (!relation || relation.kind === 'coincident') { + if (values.length < 2) return { valid: false, maximumError: Number.POSITIVE_INFINITY }; + const maximumError = Math.max(...values) - Math.min(...values); + return { valid: maximumError <= tolerance, maximumError }; + } + if (!token) return { valid: false, maximumError: Number.POSITIVE_INFINITY }; + const errors = values.map((value) => + relation.kind === 'box-model-multiple-of-token' + ? Math.abs(value - Math.round(value / token.value) * token.value) + : Math.abs(value - token.value) + ); + const maximumError = Math.max(...errors); + return { valid: maximumError <= tolerance, maximumError }; +} + +/** One matched element of a contract member, already measured by the browser. */ +export type GeometryContractMemberSample = Readonly<{ + description: string; + /** Rendered position, so one element resolved by two members is detectable. */ + elementKey?: string; + /** Anchor coordinate, or the summed box-model value the relation reads. */ + value: number | null; + /** Per-property computed values, so a failure can print the exact terms. */ + propertyValues?: Readonly>; + hidden?: boolean; +}>; + +export type GeometryContractMemberResolution = Readonly<{ + label: string; + /** Elements the locator matched, before `all` truncation. */ + matchCount: number; + /** Populated only for a named member Playwright can cross-check. */ + nameMatchCount?: number; + playwrightNameCount?: number; + samples: readonly GeometryContractMemberSample[]; +}>; + +function formatContractValue(value: number): string { + return String(Number(value.toFixed(4))); +} + +/** + * The gate's decision layer, separated from the browser measurement so it is + * testable without a page. A member that resolves ambiguously is a defect in + * the contract, not an invitation to measure the first match: an ambiguous + * member contributes no value, and the relation is only judged once every + * member resolved. + */ +export function evaluateGeometryContractResolutions( + contract: GeometryContract, + resolutions: readonly GeometryContractMemberResolution[], + token?: GeometryResolvedToken +): readonly string[] { + const relation = contract.relation ?? { kind: 'coincident' as const }; + const properties = geometryContractRelationProperties(relation); + const violations: string[] = []; + const values: Array> = []; + // One element resolved by two members counts its value twice and hides the + // narrower member behind the broader one; the contract, not the measurement, + // is what needs fixing. + const coveredBy = new Map(); + contract.members.forEach((member, index) => { + const resolution = resolutions[index]; + if (!resolution) { + violations.push(`${contract.name}: member ${index + 1} was never resolved`); + return; + } + const { label } = resolution; + if ( + resolution.nameMatchCount !== undefined && + resolution.playwrightNameCount !== undefined && + resolution.nameMatchCount !== resolution.playwrightNameCount + ) { + violations.push( + `${contract.name}: accessible name ${JSON.stringify(member.name)} resolves to ${resolution.nameMatchCount} ${member.role} element(s) in the capture naming model but ${resolution.playwrightNameCount} through Playwright getByRole` + ); + } + if (resolution.matchCount === 0) { + violations.push(`${contract.name}: locator ${label} is missing`); + return; + } + if (!member.all && resolution.matchCount !== 1) { + violations.push( + `${contract.name}: locator ${label} matched ${resolution.matchCount} elements` + ); + return; + } + for (const sample of resolution.samples) { + if (sample.hidden) { + violations.push(`${contract.name}: locator ${label} is hidden`); + continue; + } + if (sample.value === null) { + const terms = (properties ?? []) + .map((property) => { + const value = sample.propertyValues?.[property]; + return `${property}=${value === null || value === undefined ? 'none' : formatContractValue(value)}`; + }) + .join(', '); + violations.push( + `${contract.name}: ${label} did not resolve ${(properties ?? []).join(' + ')} to a pixel length (${sample.description}: ${terms})` + ); + continue; + } + if (sample.elementKey !== undefined) { + const owner = coveredBy.get(sample.elementKey); + if (owner !== undefined && owner !== label) { + violations.push( + `${contract.name}: ${label} and ${owner} both resolve ${sample.description}; one member already covers it` + ); + continue; + } + coveredBy.set(sample.elementKey, label); + } + values.push({ label, value: sample.value }); + } + }); + if (violations.length > 0) return violations; + + const evaluation = evaluateGeometryContractValues( + relation, + values.map(({ value }) => value), + contract.tolerance, + token + ); + if (evaluation.valid) return violations; + violations.push( + `${contract.name}: ${relation.kind} error ${evaluation.maximumError}px exceeds ${contract.tolerance}px${ + token ? ` (token ${token.cssVariable}=${token.value}px)` : '' + } (${values.map(({ label, value }) => `${label}=${formatContractValue(value)}`).join(', ')})` + ); + return violations; +} + +export type GeometryInkCenterSample = Readonly<{ + label: string; + description?: string; + inkCenter: number; + containsSvg: boolean; +}>; + +export type GeometryInkCenterWitness = Readonly<{ + medianInkCenter: number; + members: readonly Readonly<{ + label: string; + description?: string; + inkCenter: number; + inkCenterOffset: number; + }>[]; + /** Present only when a member's ink center is more than 1 CSS px off. */ + designQuestion?: string; +}>; + +/** + * A box rail says nothing about where the ink lands. Icons on one trailing rail + * can share a layout edge exactly and still read as unaligned because their + * glyph whitespace differs, so the witness records each icon's ink centre + * against the group's median ink centre. + */ +export function summarizeGeometryInkCenters( + contractName: string, + samples: readonly GeometryInkCenterSample[] +): GeometryInkCenterWitness | undefined { + const icons = samples.filter((sample) => sample.containsSvg); + if (icons.length < 2) return undefined; + const sorted = [...icons.map((sample) => sample.inkCenter)].sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + const medianInkCenter = + sorted.length % 2 === 0 + ? ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2 + : (sorted[middle] ?? 0); + const members = icons.map((sample) => ({ + label: sample.label, + ...(sample.description ? { description: sample.description } : {}), + inkCenter: Number(sample.inkCenter.toFixed(3)), + inkCenterOffset: Number((sample.inkCenter - medianInkCenter).toFixed(3)), + })); + const worst = members.reduce((left, right) => + Math.abs(right.inkCenterOffset) > Math.abs(left.inkCenterOffset) ? right : left + ); + return { + medianInkCenter: Number(medianInkCenter.toFixed(3)), + members, + ...(Math.abs(worst.inkCenterOffset) > 1 + ? { + designQuestion: `${contractName}: ${worst.label} sits ${formatContractValue(Math.abs(worst.inkCenterOffset))}px ${ + worst.inkCenterOffset < 0 ? 'before' : 'after' + } the median ink centre of this icon rail. Should an icon rail be ink-centre coincident, or is a shared layout-box edge the intended rule?`, + } + : {}), + }; +} + +export function compileGeometryContracts(ledger: GeometryLedger): GeometryContractArtifact { + const contracts = Object.entries(ledger.findings).flatMap(([findingKey, entry]) => { + if (entry.status !== 'promoted') return []; + if (!entry.contract) { + throw new Error(`Promoted geometry finding ${findingKey} has no contract`); + } + const relation = entry.contract.relation ?? { kind: 'coincident' as const }; + if (relation.kind === 'coincident' && entry.contract.members.length < 2) { + throw new Error(`Geometry contract ${entry.contract.name} needs at least two members`); + } + const token = relation.kind === 'coincident' ? undefined : ledger.tokens?.[relation.token]; + if (relation.kind !== 'coincident' && !token) { + throw new Error( + `Geometry contract ${entry.contract.name} references missing token ${relation.token}` + ); + } + if ( + relation.kind === 'box-model-multiple-of-token' && + token?.expected !== undefined && + token.expected <= 0 + ) { + throw new Error(`Geometry token ${relation.token} must be positive for multiple-of checks`); + } + if (relation.kind === 'box-model-sum-equals-token') { + if (relation.properties.length === 0) { + throw new Error( + `Geometry contract ${entry.contract.name} sums no properties; use box-model-equals-token for one property` + ); + } + if (new Set(relation.properties).size !== relation.properties.length) { + throw new Error( + `Geometry contract ${entry.contract.name} sums the same property twice: ${relation.properties.join(', ')}` + ); + } + } + if (relation.kind !== 'coincident' && entry.contract.members.length < 1) { + throw new Error(`Geometry contract ${entry.contract.name} needs at least one member`); + } + return [{ ...entry.contract, findingKey }]; + }); + const names = new Set(); + for (const contract of contracts) { + if (names.has(contract.name)) throw new Error(`Duplicate geometry contract: ${contract.name}`); + names.add(contract.name); + } + return { + version: 1, + ...(ledger.tokens ? { tokens: ledger.tokens } : {}), + contracts: contracts.sort((a, b) => a.name.localeCompare(b.name)), + }; +} diff --git a/packages/components/src/lib/geometry-text-cap-band.ts b/packages/components/src/lib/geometry-text-cap-band.ts new file mode 100644 index 000000000..4019a86f7 --- /dev/null +++ b/packages/components/src/lib/geometry-text-cap-band.ts @@ -0,0 +1,84 @@ +/** + * The ONE optical text measurement the geometry system owns. + * + * A text primitive's visual centre is the cap-height band of a FIXED reference + * glyph (`H`), not the ink bounds of its own string: a label that happens to + * carry a descender would otherwise demand a different icon offset than an + * identical row without one. That band is measured on a canvas, and the canvas + * font string is the only thing deciding which font gets measured — so the + * `?geometry=1` overlay and the Playwright capture must build it here, once, or + * they quietly disagree about the same row. + * + * Every function here is deliberately self-contained: capture serializes them + * into the page with `Function.prototype.toString`, so none may close over an + * import, a module constant, or another function in this file. + */ + +export type GeometryCanvasFontStyle = Readonly<{ + fontStyle: string; + fontWeight: string; + fontSize: string; + fontFamily: string; +}>; + +export type GeometryCapBand = Readonly<{ ascent: number; descent: number }>; + +/** + * `font-variant` is deliberately NOT part of this string. A computed variant + * such as `tabular-nums` is not a canvas font-variant value, and an invalid + * font string leaves `context.font` at whatever it already was — silently + * measuring one element's cap band with another element's font. + * + * Keep closure-free: capture serializes this function into the page. + */ +export function geometryCanvasFontString(style: GeometryCanvasFontStyle): string { + return [style.fontStyle, style.fontWeight, style.fontSize, style.fontFamily].join(' '); +} + +/** + * The reference glyph's cap-height band in `font`, cached per font string: the + * answer depends only on the font, never on the element, and capture asks for + * it once per rendered text primitive, so one canvas serves the whole page. + * + * `expectedFontSize` is the computed `font-size` the caller believes it asked + * for. A canvas that refuses a font string keeps its previous value, and a + * silent wrong-font measurement is exactly the bug this module exists to stop. + * + * Keep closure-free: capture serializes this function into the page. + */ +export function measureGeometryCapBand(font: string, expectedFontSize?: string): GeometryCapBand { + const cache = globalThis as typeof globalThis & { + __lodyGeometryCapBands?: Map>; + __lodyGeometryCanvas?: HTMLCanvasElement; + }; + cache.__lodyGeometryCapBands ??= new Map(); + const cached = cache.__lodyGeometryCapBands.get(font); + if (cached) return cached; + cache.__lodyGeometryCanvas ??= document.createElement('canvas'); + const context = cache.__lodyGeometryCanvas.getContext('2d'); + if (!context) throw new Error('Canvas 2D context is unavailable'); + context.font = font; + if (expectedFontSize !== undefined && !context.font.includes(expectedFontSize)) { + throw new Error(`Canvas refused the measured font: ${font}`); + } + const metrics = context.measureText('H'); + const band = { + ascent: metrics.actualBoundingBoxAscent, + descent: metrics.actualBoundingBoxDescent, + }; + cache.__lodyGeometryCapBands.set(font, band); + return band; +} + +/** + * Where a cap band's centre sits relative to the line's baseline. Kept here so + * the overlay and capture share the arithmetic as well as the measurement. + * + * Keep closure-free: capture serializes this function into the page. + */ +export function geometryCapBandCenter( + baseline: number, + band: Readonly<{ ascent: number; descent: number }> +): number { + return baseline + (band.descent - band.ascent) / 2; +} diff --git a/packages/components/tests/chat-workspace-geometry.test.ts b/packages/components/tests/chat-workspace-geometry.test.ts index 1d7d74003..956d98725 100644 --- a/packages/components/tests/chat-workspace-geometry.test.ts +++ b/packages/components/tests/chat-workspace-geometry.test.ts @@ -8,19 +8,24 @@ import { calculateMainPaneGrid, calculateSidebarGrid, discoverAlignmentRails, + discoverBlockAlignmentRails, discoverRepeatedLayoutScopes, inferAlignmentRailContractProposals, evaluateSemanticAlignmentGroup, evaluateSemanticBaselineGroup, + isGeometryPaintedShape, isSpacingRhythmMultiple, resolveConversationHorizontalInset, resolveMainPaneGridRange, selectCanonicalAlignmentRails, + selectVisualRowSlots, validateChatWorkspaceGeometry, type AlignmentRailCandidate, + type BlockRailCandidate, type ChatWorkspaceGeometrySnapshot, type LayoutTopologyNode, } from '../src/lib/chat-workspace-geometry'; +import { geometryCanvasFontString } from '../src/lib/geometry-text-cap-band'; const anchors = CHAT_WORKSPACE_GEOMETRY_ANCHORS; @@ -241,6 +246,30 @@ describe('conversation, spacing, and semantic baselines', () => { }); }); + it('snaps semantic coordinates to the capture physical-pixel grid', () => { + expect( + evaluateSemanticAlignmentGroup({ + name: 'sidebar.row.visual-center', + instance: 'row-1', + axis: 'y', + anchor: 'visual-center', + minMembers: 2, + tolerance: 0.5, + policy: 'observe', + deviceScaleFactor: 2, + members: [ + { name: 'icon', coordinate: 40.24 }, + { name: 'label', coordinate: 40.26 }, + ], + }) + ).toMatchObject({ + line: 40.25, + spread: 0.5, + status: 'aligned', + members: [{ coordinate: 40 }, { coordinate: 40.5 }], + }); + }); + it('marks a single-member baseline as insufficient evidence', () => { expect( evaluateSemanticBaselineGroup({ @@ -534,6 +563,90 @@ describe('conversation, spacing, and semantic baselines', () => { ]); }); + it('lets centered text contribute only its center coordinate', () => { + const candidates: AlignmentRailCandidate[] = [ + 'inline-start', + 'inline-center', + 'inline-end', + ].map((anchor, index) => ({ + elementId: `centered-${index}`, + rowId: `row-${index}`, + kind: 'text', + alignmentMode: 'centered', + anchor: anchor as AlignmentRailCandidate['anchor'], + coordinate: 100, + yStart: index * 32, + yEnd: index * 32 + 20, + })); + + expect(discoverAlignmentRails(candidates, { minSupport: 2, minVerticalSpan: 0 })).toEqual([]); + }); + + it('rejects a two-support rail that crosses visual partitions', () => { + const candidates: AlignmentRailCandidate[] = [ + { + elementId: 'heading', + rowId: 'heading-row', + sectionId: 'hero', + kind: 'text', + anchor: 'inline-start', + coordinate: 100, + yStart: 0, + yEnd: 20, + }, + { + elementId: 'chip', + rowId: 'chip-row', + sectionId: 'composer', + kind: 'text', + anchor: 'inline-start', + coordinate: 100, + yStart: 800, + yEnd: 820, + }, + ]; + + expect(discoverAlignmentRails(candidates, { minSupport: 2 })).toEqual([]); + }); + + it('requires mixed-kind supporters to share one row family', () => { + const makeCandidates = (rowFamily: readonly string[]): AlignmentRailCandidate[] => [ + { + elementId: 'heading', + rowId: 'heading-row', + rowFamily: rowFamily[0], + kind: 'text', + anchor: 'inline-start', + coordinate: 100, + yStart: 0, + yEnd: 20, + }, + { + elementId: 'icon', + rowId: 'icon-row', + rowFamily: rowFamily[1], + kind: 'svg', + anchor: 'inline-start', + coordinate: 100, + yStart: 32, + yEnd: 52, + }, + ]; + + expect( + discoverAlignmentRails(makeCandidates(['hero', 'composer']), { + minSupport: 2, + minVerticalSpan: 0, + }) + ).toEqual([]); + expect( + discoverAlignmentRails(makeCandidates(['sidebar-row', 'sidebar-row']), { + minSupport: 2, + minVerticalSpan: 0, + }) + ).toHaveLength(1); + }); + it('collapses repeated slot anchors to the boundary-facing canonical rail', () => { const slots = [ { id: 'section-action', left: 219, right: 267 }, @@ -915,6 +1028,133 @@ describe('conversation, spacing, and semantic baselines', () => { }); }); +describe('vertical rail discovery', () => { + const rowMember = ( + elementId: string, + row: number, + coordinate: number, + overrides: Partial = {} + ): BlockRailCandidate => ({ + elementId, + rowId: `visual-row:${row}`, + rowFamily: 'div[text]>div[button]', + kind: 'text', + space: 'ink', + anchor: 'visual-center', + coordinate, + xStart: 40, + xEnd: 220, + yStart: coordinate - 8, + yEnd: coordinate + 8, + ...overrides, + }); + + it('measures a row against its own median and reports the member that leaves it', () => { + const [rail] = discoverBlockAlignmentRails([ + rowMember('icon', 1, 42, { kind: 'svg', xStart: 20, xEnd: 32 }), + rowMember('title', 1, 40), + rowMember('time', 1, 40, { xStart: 240, xEnd: 262 }), + ]); + + expect(rail).toMatchObject({ anchor: 'visual-center', line: 40, support: 2, sampleSize: 3 }); + expect(rail?.outliers.map((member) => member.elementId)).toEqual(['icon']); + expect(rail?.outliers[0]?.delta).toBe(2); + }); + + it('never lets one row establish another row\u2019s vertical line', () => { + const rails = discoverBlockAlignmentRails([ + rowMember('title-a', 1, 40), + rowMember('icon-a', 1, 40, { kind: 'svg' }), + rowMember('title-b', 2, 72), + rowMember('icon-b', 2, 74, { kind: 'svg' }), + ]); + + expect(rails.map((rail) => rail.rowId)).toEqual(['visual-row:1', 'visual-row:2']); + expect(rails[0]?.outliers).toEqual([]); + // The second row is judged against 73, its own median, not against row one. + expect(rails[1]?.line).toBe(73); + expect(rails[1]?.outliers).toHaveLength(2); + }); + + it('compares a block edge only between primitives of one kind', () => { + const mixed = discoverBlockAlignmentRails([ + rowMember('icon', 1, 36, { kind: 'svg', anchor: 'block-start' }), + rowMember('title', 1, 32, { anchor: 'block-start' }), + ]); + const sameKind = discoverBlockAlignmentRails([ + rowMember('title', 1, 32, { anchor: 'block-start' }), + rowMember('time', 1, 36, { anchor: 'block-start' }), + ]); + + expect(mixed).toEqual([]); + expect(sameKind).toHaveLength(1); + }); + + it('snaps to the physical pixel grid before deciding what left the line', () => { + const [rail] = discoverBlockAlignmentRails( + [ + rowMember('title', 1, 40.1), + rowMember('time', 1, 40.1), + rowMember('icon', 1, 40.3, { kind: 'svg' }), + ], + { deviceScaleFactor: 2 } + ); + + expect(rail?.line).toBe(40); + expect(rail?.outliers).toEqual([]); + }); + + it('does not report a row that renders a single measurable primitive', () => { + expect(discoverBlockAlignmentRails([rowMember('title', 1, 40)])).toEqual([]); + }); + + it('refuses to call the distance between two lines of one block a misalignment', () => { + // A composer label above its textarea is captured as one visual row, but + // neither box reaches the other's centre: they are two lines, not one. + const stacked = discoverBlockAlignmentRails([ + rowMember('label', 1, 608, { yStart: 600, yEnd: 617 }), + rowMember('textarea', 1, 647, { kind: 'field', yStart: 623, yEnd: 671 }), + ]); + expect(stacked).toEqual([]); + + // The same row with a third primitive on the label's line still reports + // that line, and the far primitive is simply not on it. + const [rail] = discoverBlockAlignmentRails([ + rowMember('label', 1, 608, { yStart: 600, yEnd: 617 }), + rowMember('hint', 1, 610, { yStart: 601, yEnd: 618 }), + rowMember('textarea', 1, 647, { kind: 'field', yStart: 623, yEnd: 671 }), + ]); + expect(rail?.members.map((member) => member.elementId)).toEqual(['label', 'hint']); + expect(rail?.line).toBe(609); + }); + + it('reports a thin glyph that moved, instead of dropping it off the line', () => { + // An ellipsis icon is about a pixel of ink: its own extent stops containing + // the line as soon as it moves at all, so the row band has to carry it. + const [rail] = discoverBlockAlignmentRails([ + rowMember('title', 1, 40, { yStart: 32, yEnd: 48 }), + rowMember('time', 1, 40, { yStart: 33, yEnd: 47 }), + rowMember('ellipsis', 1, 43, { kind: 'svg', yStart: 42.4, yEnd: 43.6 }), + ]); + + expect(rail?.line).toBe(40); + expect(rail?.sampleSize).toBe(3); + expect(rail?.outliers.map((member) => member.elementId)).toEqual(['ellipsis']); + }); + + it('still reports a member that moved but stays on the line', () => { + const [rail] = discoverBlockAlignmentRails([ + rowMember('title', 1, 40, { yStart: 32, yEnd: 48 }), + rowMember('time', 1, 40, { yStart: 32, yEnd: 48 }), + rowMember('icon', 1, 43, { kind: 'svg', yStart: 37, yEnd: 49 }), + ]); + + expect(rail?.line).toBe(40); + expect(rail?.outliers.map((member) => member.elementId)).toEqual(['icon']); + expect(rail?.outliers[0]?.delta).toBe(3); + }); +}); + describe('chat workspace validation', () => { it('accepts the expanded Sidebar + Main Pane + Chat Landing geometry', () => { expect( @@ -1023,3 +1263,88 @@ describe('chat workspace validation', () => { ); }); }); + +describe('what belongs to a visual row', () => { + it('rejects a composer label stacked above its field', () => { + // The real composer: a 18px `label` sitting directly above a 48px field, + // both children of one 48px-tall wrapper. Calling that a row makes the + // leading between the two lines a 25px vertical misalignment. + const label = { top: 806, bottom: 824 }; + const field = { top: 803, bottom: 851 }; + const rowCenter = (Math.min(label.top, field.top) + Math.max(label.bottom, field.bottom)) / 2; + + expect(selectVisualRowSlots([label, field], rowCenter)).toEqual([1]); + }); + + it('keeps every slot of a real row, however differently sized', () => { + const icon = { top: 104, bottom: 120 }; + const title = { top: 102, bottom: 122 }; + const trailingButton = { top: 96, bottom: 128 }; + const rowCenter = 112; + + expect(selectVisualRowSlots([icon, title, trailingButton], rowCenter)).toEqual([0, 1, 2]); + }); + + it('drops a badge that sits a line above the row it shares a box with', () => { + const badge = { top: 100, bottom: 116 }; + const text = { top: 130, bottom: 146 }; + const rowCenter = 123; + + // Neither slot reaches the other's line, so the proposal has fewer than two + // members left and is not a row at all. + expect(selectVisualRowSlots([badge, text], rowCenter).length).toBeLessThan(2); + }); +}); + +describe('painted CSS shapes are primitives', () => { + const shape = (overrides: Partial[0]> = {}) => ({ + width: 8, + height: 8, + renderedChildCount: 0, + text: '', + backgroundColor: 'rgb(34 197 94)', + backgroundImage: 'none', + borderWidths: [0, 0, 0, 0], + borderColors: ['rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0)'], + ...overrides, + }); + + it('sees the sidebar status dot', () => { + expect(isGeometryPaintedShape(shape())).toBe(true); + expect(isGeometryPaintedShape(shape({ backgroundColor: 'rgb(34 197 94 / 0.4)' }))).toBe(true); + expect( + isGeometryPaintedShape( + shape({ + backgroundColor: 'rgba(0, 0, 0, 0)', + borderWidths: [1, 1, 1, 1], + borderColors: ['rgb(0 0 0)', 'rgb(0 0 0)', 'rgb(0 0 0)', 'rgb(0 0 0)'], + }) + ) + ).toBe(true); + }); + + it('is not a surface, a spacer or something with contents', () => { + // A page-wide separator is layout, not a mark a row aligns against. + expect(isGeometryPaintedShape(shape({ width: 240, height: 1 }))).toBe(false); + expect(isGeometryPaintedShape(shape({ backgroundColor: 'rgba(0, 0, 0, 0)' }))).toBe(false); + expect(isGeometryPaintedShape(shape({ backgroundColor: 'transparent' }))).toBe(false); + expect(isGeometryPaintedShape(shape({ renderedChildCount: 1 }))).toBe(false); + expect(isGeometryPaintedShape(shape({ text: '3' }))).toBe(false); + expect(isGeometryPaintedShape(shape({ width: 0 }))).toBe(false); + }); +}); + +describe('the canvas font string', () => { + it('leaves the computed font-variant out, which a canvas would refuse', () => { + const style = { + fontStyle: 'normal', + fontVariant: 'tabular-nums', + fontWeight: '600', + fontSize: '13px', + fontFamily: 'Inter, sans-serif', + }; + + expect(geometryCanvasFontString(style)).toBe('normal 600 13px Inter, sans-serif'); + expect(geometryCanvasFontString(style)).not.toContain('tabular-nums'); + }); +}); diff --git a/packages/components/tests/e2e/AGENTS.md b/packages/components/tests/e2e/AGENTS.md new file mode 100644 index 000000000..f04e26221 --- /dev/null +++ b/packages/components/tests/e2e/AGENTS.md @@ -0,0 +1,118 @@ +# Geometry constraint system + +`CLAUDE.md` is a symlink to this file. Edit `AGENTS.md` only. +Package `AGENTS.md` and the repository root also apply. + +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: +[support](support/AGENTS.md); `*-geometry-report.spec.ts` (report); `*-geometry.spec.ts` +(gate). Neither `geometry:report [dir]` nor root `pnpm geometry:triage ` moves a +baseline. + +## X rails + +Heuristic, before any finding exists. + +- One member per repeated subtree instance per rail: a control and its nested icon do not + manufacture support. `data-geometry-discovery-scope` is a hint, alignment attributes are + never read, `session.messages` and its Markdown descendants are excluded. +- Repeated row modes establish rails before singletons attach to the nearest mode, + independent of DOM order; intermediate coordinates must not chain distinct indentation + levels. Rendered coordinates decide, every candidate stays eligible scope-wide, and an + extra candidate beats excusing a shifted module. +- Two repeated visible rows establish a local indentation rail, never absorbed into a + better-supported one. Peaks merge only within the inlier tolerance, so peaks 1px apart + are distinct levels; members outside it are outliers, and confidence includes span. +- A rail of one kind takes only that kind unless it has mixed support; members share a row + family or a kind, and a two-support rail never crosses a partition. + +## Y rails + +Marker-free, same pipeline, 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. +- 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. +- 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. +- `y-axis-parity.json` and `marker-removal-readiness.json` ask 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. + +## 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, +parents cluster those with unclaimed singletons. + +Identity is STRUCTURAL and coordinate-free: surface family, landmark, section, row family, +family-instance index in the section, role, same-role index, plus the X anchor — a Y key +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 +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); +`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 +new. Pairing is one-to-one; an entry without a recorded identity stays resolved. + +## Ledger, tokens, contracts, gate + +`css-defect` / `optical-residual` / `structural` and `dimensionSensitivity` are arithmetic +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`. + +- 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 ([support](support/AGENTS.md)). Widen the + relation before loosening a tolerance. +- Ledger labels give discovery precision, promoted locators geometry coverage, PNG edge + sampling only confidence. + +## Report + +Discovery or proposal 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 and Y cards: [support](support/AGENTS.md). +- Steady state, not delta: every finding gets a card grouped by ledger status (`new`, + `changed`, `accepted-debt`, `promoted`, `ignored`) and classification, with baseline vs + current offset, capture count, dimension sensitivity and repair text. Chips filter both, + default new + changed + css-defect + promoted; the meta line prints the total beside + new/changed/resolved. One embedded 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; its clip holds the whole + row plus a margin; it draws the row median and the verdict anchor only, and names the row. + Zoomed Y cards are the largest-|offset| findings over every surface. Discovery cards use + product-region names, count unique elements not anchor votes, fold one element's + start/center/end offsets into one annotation, and keep candidate rails under emphasised + outliers. Cards are picked by deviation, inside the generator's budget. diff --git a/packages/components/tests/e2e/CLAUDE.md b/packages/components/tests/e2e/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/packages/components/tests/e2e/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file 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 77059ac83..00d72854d 100644 --- a/packages/components/tests/e2e/chat-workspace-geometry-report.spec.ts +++ b/packages/components/tests/e2e/chat-workspace-geometry-report.spec.ts @@ -1,7 +1,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { expect, test, type Browser, type Page } from '@playwright/test'; +import { test, type Browser, type BrowserContext, type Page } from '@playwright/test'; import { CHAT_WORKSPACE_GEOMETRY_ANCHORS, @@ -13,15 +13,40 @@ import { type GeometryRect, validateChatWorkspaceGeometry, } from '../../src/lib/chat-workspace-geometry'; +import { + alignmentFindingKey, + assessGeometryMarkerRemoval, + collectAggregatedGeometryRowFamilies, + compareMarkerAlignmentsToBlockRails, + compileGeometryContracts, + computeGeometryQualityMetrics, + createGeometryFindings, + diffGeometryFindings, + geometryIdentityLocator, + geometryLocatorMatches, + observeGeometryCaptures, + summarizeGeometryInkCenters, + type GeometryCaptureArtifact, + type GeometryCapturedCandidate, + type GeometryFinding, + type GeometryFindingArtifact, + type GeometryFindingClassification, + type GeometryFindingEvidence, + type GeometryRowMember, + type GeometryLedger, + type GeometryLedgerStatus, + type GeometryObservationCache, + type GeometryRepairProposal, +} from '../../src/lib/geometry-constraint-system'; import { auditChatWorkspaceSemanticAlignments, auditChatWorkspaceSemanticBaselines, auditChatWorkspaceSpacing, discoverChatWorkspaceAlignmentRails, + measureGeometryContractOpticalInsets, type BrowserAlignmentRailDiscoveryScope, type BrowserSemanticAlignmentEntry, type BrowserSemanticBaselineEntry, - formatGeometryViolations, measureSettledChatWorkspace, requireGeometryRect, } from './support/chat-workspace-geometry'; @@ -31,8 +56,19 @@ 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'; + kind: 'violation' | 'candidate' | 'overview' | 'jitter' | 'insufficient' | 'measurement-model'; requiresReview: boolean; + classification?: GeometryFindingClassification; + findingKey?: string; + /** How the ledger reviewed this finding, or how it moved since that review. */ + ledgerStatus?: GeometryLedgerStatus | 'changed'; + baselineOffset?: number; + currentOffset?: number; + captureCount?: number; + totalCaptureCount?: number; + dimensionSensitivity?: readonly string[]; + repairProposal?: string; + inkCenterWitness?: string; id: string; title: string; description: string; @@ -63,6 +99,18 @@ type ReportDetail = Readonly<{ outlier: boolean; }>[]; }>[]; + /** Horizontal guides: one row median plus the ticks its members sit on. */ + blockGuides?: readonly Readonly<{ + line: number; + xStart: number; + xEnd: number; + members: readonly Readonly<{ + coordinate: number; + xStart: number; + xEnd: number; + outlier: boolean; + }>[]; + }>[]; }>; }>; @@ -72,8 +120,10 @@ type PersistedReportData = { captures: Array<{ captureId: string; storyId: string; + storyGlobals?: string; viewport: Readonly<{ width: number; height: number }>; deviceScaleFactor: number; + dimensions?: Readonly<{ theme: string; locale: string; density: string }>; }>; }; details: Array<{ @@ -90,6 +140,7 @@ function reportDetailPriority(detail: ReportDetail): number { if (detail.kind === 'overview') return 2; if (detail.kind === 'insufficient') return 3; if (detail.kind === 'jitter') return 4; + if (detail.kind === 'measurement-model') return 5; return 5; } @@ -101,6 +152,135 @@ function clampClip(rect: GeometryRect, viewport: Readonly<{ width: number; heigh return { x, y, width: Math.max(1, right - x), height: Math.max(1, bottom - y) }; } +async function collectGeometryPixelWitnesses( + page: Page, + outputDirectoryPath: string, + captures: GeometryCaptureArtifact, + contracts: ReturnType +) { + const witnesses = []; + for (const contract of contracts.contracts) { + if ((contract.relation?.kind ?? 'coincident') !== 'coincident') continue; + const capture = captures.captures.find( + (candidate) => candidate.storyId === contract.story && candidate.screenshot + ); + if (!capture) continue; + const candidatePool = capture.scopes.flatMap((scope) => scope.candidates); + const samples = contract.members.flatMap((member) => { + const matches = candidatePool + .filter( + (candidate) => + candidate.anchor === contract.anchor && + geometryLocatorMatches(candidate.locator, member) + ) + .filter( + (candidate, index, candidates) => + candidates.findIndex((other) => other.primitiveId === candidate.primitiveId) === index + ); + return (member.all ? matches : matches.slice(0, 1)).map((candidate) => ({ + label: candidate.label, + coordinate: candidate.coordinate, + yStart: candidate.yStart, + yEnd: candidate.yEnd, + })); + }); + if (samples.length < 2) continue; + // A FRESH page in the same context: by now the report's page has loaded + // every captured story, and a renderer that has is one navigation from + // crashing. The context keeps the bundle cached, so this costs nothing. + const witnessPage = await page.context().newPage(); + const storyUrl = `${storybookOrigin}/iframe.html?id=${contract.story}&viewMode=story`; + const storyResponse = await witnessPage.goto(storyUrl); + if (!storyResponse?.ok()) throw new Error(`Witness story failed: ${contract.story}`); + await enableReportCaptureMode(witnessPage); + if (contract.story.startsWith('geometry-chatworkspace--')) { + await measureSettledChatWorkspace(witnessPage); + } + const opticalInsets = await measureGeometryContractOpticalInsets(witnessPage, contract); + const png = await readFile(path.join(outputDirectoryPath, capture.screenshot)); + const pixelSamples = await witnessPage.evaluate( + async ({ dataUrl, deviceScaleFactor, samples: targets }) => { + const image = new Image(); + image.src = dataUrl; + await image.decode(); + const canvas = document.createElement('canvas'); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const context = canvas.getContext('2d', { willReadFrequently: true }); + if (!context) throw new Error('Canvas 2D context is unavailable'); + context.drawImage(image, 0, 0); + const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data; + const differenceAt = (x: number, yStart: number, yEnd: number) => { + let difference = 0; + let count = 0; + const left = Math.max(0, Math.min(canvas.width - 1, x - 1)); + const right = Math.max(0, Math.min(canvas.width - 1, x + 1)); + for (let y = yStart; y <= yEnd; y += 1) { + const leftIndex = (y * canvas.width + left) * 4; + const rightIndex = (y * canvas.width + right) * 4; + difference += + Math.abs(pixels[leftIndex]! - pixels[rightIndex]!) + + Math.abs(pixels[leftIndex + 1]! - pixels[rightIndex + 1]!) + + Math.abs(pixels[leftIndex + 2]! - pixels[rightIndex + 2]!); + count += 3; + } + return count > 0 ? difference / count : 0; + }; + return targets.map((target) => { + const expectedX = Math.round(target.coordinate * deviceScaleFactor); + const yStart = Math.max(0, Math.round(target.yStart * deviceScaleFactor)); + const yEnd = Math.min(canvas.height - 1, Math.round(target.yEnd * deviceScaleFactor)); + const nearby = Array.from({ length: 9 }, (_, index) => expectedX + index - 4).map( + (x) => ({ x, gradient: differenceAt(x, yStart, yEnd) }) + ); + const strongest = nearby.sort((left, right) => right.gradient - left.gradient)[0]!; + const delta = (strongest.x - expectedX) / deviceScaleFactor; + const strength = Math.min(1, strongest.gradient / 24); + const proximity = Math.max(0, 1 - Math.abs(delta) / 3); + return { + ...target, + pixelCoordinate: strongest.x / deviceScaleFactor, + delta, + confidence: strength * proximity, + }; + }); + }, + { + dataUrl: `data:image/png;base64,${png.toString('base64')}`, + deviceScaleFactor: capture.deviceScaleFactor, + samples, + } + ); + // Users perceive ink, not boxes: an icon rail can share a layout edge + // exactly and still read as unaligned, so record every icon member's ink + // centre against the group's median. + const inkCenters = summarizeGeometryInkCenters( + contract.name, + opticalInsets.map((sample) => ({ + label: sample.label, + description: sample.description, + inkCenter: sample.inkCenter, + containsSvg: sample.containsSvg, + })) + ); + witnesses.push({ + contract: contract.name, + findingKey: contract.findingKey, + story: contract.story, + // The gate now compares layout boxes; ink and pixels stay observational. + gate: false, + space: 'ink' as const, + confidence: + pixelSamples.reduce((total, sample) => total + sample.confidence, 0) / pixelSamples.length, + opticalInsets, + ...(inkCenters ? { inkCenters } : {}), + samples: pixelSamples, + }); + await witnessPage.close(); + } + return witnesses; +} + function sidebarAnnotationBand( sidebarCard: GeometryRect, focus: GeometryRect, @@ -143,6 +323,35 @@ function formatDirectionalOffset(axis: 'x' | 'y', value: number): string { return `${direction}${rounded}px`; } +const CLASSIFICATION_LABELS: Readonly> = { + 'css-defect': 'CSS 缺陷', + 'optical-residual': '视觉余量', + structural: '结构性', +}; + +const BOX_MODEL_TERM_LABELS: Readonly> = { + padding: 'padding', + border: 'border', + margin: 'margin', + gap: 'gap', +}; + +function formatRepairProposal(proposal: GeometryRepairProposal): string { + const edge = proposal.edge === 'inline-start' ? '起始边' : '结束边'; + const terms = proposal.terms + .slice(0, 3) + .map((term) => { + const owner = term.side === 'member' ? '本项' : '参照'; + return `${BOX_MODEL_TERM_LABELS[term.term] ?? term.term} 本项 ${Number( + term.memberValue.toFixed(2) + )} vs 参照 ${Number(term.referenceValue.toFixed(2))}(Δ${Number( + term.delta.toFixed(2) + )}px;差值来自${owner}的 ${term.element})`; + }) + .join(';'); + return `修复建议(${edge}):${terms}`; +} + function semanticAlignmentTitle(entry: BrowserSemanticAlignmentEntry): string { if (entry.groupLabel === 'sidebar.primary-trailing-rail-end') { return 'Sidebar / 尾部动作语义轨'; @@ -176,6 +385,8 @@ const DISCOVERY_SURFACE_LABELS: Readonly> = { 'Chat Session / Right Sidebar / Tabs': 'Chat Session / 右侧栏多标签态', 'Chat Session / Working': 'Chat Session / 工作态(冻结帧)', 'Workspace / Chat Landing': 'Workspace / Chat Landing', + 'Workspace / Chat Landing / Dark': 'Workspace / Chat Landing 暗色主题', + 'Workspace / Chat Landing / 中文': 'Workspace / Chat Landing 中文', 'Workspace / Chat Landing / Long Model': 'Workspace / Chat Landing 长配置名', 'Workspace / Chat Landing / No Agent': 'Workspace / Chat Landing 无 Agent 配置', 'Workspace / Chat Landing / No Machine Download': 'Workspace / Chat Landing 未连接客户端', @@ -184,6 +395,41 @@ const DISCOVERY_SURFACE_LABELS: Readonly> = { 'Workspace / Chat Landing / Submitting': 'Workspace / Chat Landing 提交中', }; +type GeometryCaptureDimensions = Readonly<{ theme: string; locale: string; density: string }>; + +/** Storybook's own globals; there is no density global, so it stays constant. */ +const DEFAULT_CAPTURE_DIMENSIONS: GeometryCaptureDimensions = { + theme: 'light', + locale: 'en', + density: 'default', +}; + +/** + * Dimensions vary ONE capture family rather than every story: they exist to show + * whether a finding survives a theme or a locale, and a full matrix would + * multiply the report runtime for evidence nobody reads. + */ +const WORKSPACE_DIMENSION_CAPTURES = [ + { + id: 'wide-expanded-dark', + surface: 'Workspace / Chat Landing / Dark', + globals: 'theme:dark', + colorScheme: 'dark' as const, + dimensions: { ...DEFAULT_CAPTURE_DIMENSIONS, theme: 'dark' }, + }, + { + id: 'wide-expanded-zh', + surface: 'Workspace / Chat Landing / 中文', + globals: 'locale:zh_CN', + colorScheme: 'light' as const, + dimensions: { ...DEFAULT_CAPTURE_DIMENSIONS, locale: 'zh_CN' }, + // A zh_CN capture that still rendered English strings would silently claim a + // locale axis it never varied, so the capture asserts a translated label. + // The Sidebar's Chats section header is the one this fixture translates. + expectedText: '对话', + }, +] as const; + const WORKSPACE_STATE_CAPTURES = [ { id: 'landing-submitting', @@ -305,6 +551,11 @@ const DISCOVERY_ANCHOR_LABELS = { 'inline-start': '左缘', 'inline-center': '中心', 'inline-end': '右缘', + 'block-start': '上缘', + 'block-center': '盒中心', + 'block-end': '下缘', + 'visual-center': '视觉中心', + 'text-baseline': '文字基线', } as const; function discoveryScopeLabel(scope: BrowserAlignmentRailDiscoveryScope): string { @@ -337,6 +588,9 @@ type DiscoveryOutlier = Readonly<{ yStart: number; yEnd: number; delta: number; + locator?: GeometryCapturedCandidate['locator']; + naming?: GeometryCapturedCandidate['naming']; + label?: string; }>; function groupDiscoveryOutliers(scope: BrowserAlignmentRailDiscoveryScope): readonly Readonly<{ @@ -351,9 +605,20 @@ function groupDiscoveryOutliers(scope: BrowserAlignmentRailDiscoveryScope): read >(); for (const rail of scope.rails) { for (const member of rail.outliers) { + const capturedMember = member as typeof member & Partial; + const normalizedMember: DiscoveryOutlier = { + ...member, + ...(capturedMember.locator ? { locator: capturedMember.locator } : {}), + ...(capturedMember.naming ? { naming: capturedMember.naming } : {}), + ...(capturedMember.label ? { label: capturedMember.label } : {}), + }; const key = `${member.rowId}\u0000${member.elementId}`; const entries = elements.get(key) ?? []; - entries.push({ member, line: rail.line, offset: member.coordinate - rail.line }); + entries.push({ + member: normalizedMember, + line: rail.line, + offset: member.coordinate - rail.line, + }); elements.set(key, entries); } } @@ -523,6 +788,54 @@ async function showOnlyDetailSemanticGuides(page: Page, detail: ReportDetail): P document.body.append(overlay); }, detail.overlay.discoveredRails); } + if ((detail.overlay.blockGuides ?? []).length > 0) { + await page.evaluate((guides) => { + const overlay = document.createElement('div'); + overlay.setAttribute('data-geometry-report-discovery-overlay', ''); + Object.assign(overlay.style, { + position: 'fixed', + inset: '0', + pointerEvents: 'none', + zIndex: '2147483646', + }); + for (const guide of guides) { + const line = document.createElement('div'); + Object.assign(line.style, { + position: 'absolute', + left: `${guide.xStart - 6}px`, + top: `${guide.line}px`, + width: `${Math.max(1, guide.xEnd - guide.xStart + 12)}px`, + height: '1.5px', + background: 'rgb(14 116 144 / 0.24)', + boxShadow: '0 0 0 0.5px rgb(255 255 255 / 0.58)', + }); + overlay.append(line); + for (const member of guide.members) { + const x = member.xStart + (member.xEnd - member.xStart) / 2; + const tick = document.createElement('div'); + Object.assign(tick.style, { + position: 'absolute', + left: `${x - 0.75}px`, + top: `${Math.min(guide.line, member.coordinate)}px`, + width: '1.5px', + height: `${Math.max(1, Math.abs(member.coordinate - guide.line))}px`, + background: member.outlier ? 'rgb(217 119 6 / 0.9)' : 'rgb(14 116 144 / 0.5)', + }); + const cap = document.createElement('div'); + Object.assign(cap.style, { + position: 'absolute', + left: `${x - 3}px`, + top: `${member.coordinate - 0.75}px`, + width: '6px', + height: '1.5px', + background: member.outlier ? 'rgb(217 119 6 / 0.9)' : 'rgb(14 116 144 / 0.5)', + }); + overlay.append(tick, cap); + } + } + document.body.append(overlay); + }, detail.overlay.blockGuides ?? []); + } if (detail.overlay.semanticAnnotations.length > 0) { await page.evaluate( ({ annotations, clip }) => { @@ -678,9 +991,42 @@ function createReportDetails({ semanticAlignments: readonly BrowserSemanticAlignmentEntry[]; semanticBaselines: readonly BrowserSemanticBaselineEntry[]; }>): readonly ReportDetail[] { - const alignmentDetails = semanticAlignments - .filter((entry) => entry.status !== 'aligned') + const nonAlignedEntries = semanticAlignments.filter((entry) => entry.status !== 'aligned'); + const systematicGroups = new Map< + string, + { signature: string; entries: BrowserSemanticAlignmentEntry[] } + >(); + for (const entry of nonAlignedEntries) { + const baseGroup = entry.groupLabel.split(' · ')[0] ?? entry.groupLabel; + const signature = entry.members + .map((member) => `${member.name}:${(member.coordinate - entry.line).toFixed(2)}`) + .sort() + .join('|'); + const existing = systematicGroups.get(baseGroup); + if (!existing) { + systematicGroups.set(baseGroup, { signature, entries: [entry] }); + } else if (existing.signature === signature) { + existing.entries.push(entry); + } + } + const systematicEntryCount = new Map( + [...systematicGroups.entries()] + .filter(([, group]) => group.entries.length >= 2) + .map(([name, group]) => [name, group.entries.length]) + ); + const seenSystematicGroups = new Set(); + const alignmentDetails = nonAlignedEntries + .filter((entry) => { + const baseGroup = entry.groupLabel.split(' · ')[0] ?? entry.groupLabel; + if (!systematicEntryCount.has(baseGroup)) return true; + if (seenSystematicGroups.has(baseGroup)) return false; + seenSystematicGroups.add(baseGroup); + return true; + }) .map((entry, index): ReportDetail => { + const baseGroup = entry.groupLabel.split(' · ')[0] ?? entry.groupLabel; + const systematicCount = systematicEntryCount.get(baseGroup) ?? 0; + const measurementModel = systematicCount >= 2; const memberOffsets = entry.members .filter((member) => member.delta > 0) .map( @@ -695,14 +1041,16 @@ function createReportDetails({ const contextText = entry.members.find( (member) => member.name.includes('title') && member.text )?.text; - const kind = - entry.status === 'violation' + const kind = measurementModel + ? 'measurement-model' + : entry.status === 'violation' ? 'violation' : entry.status === 'sub-pixel-jitter' ? 'jitter' : 'insufficient'; - const finding = - entry.status === 'violation' + const finding = measurementModel + ? `测量模型分歧 · ${systematicCount} 个 instance 具有相同偏移 · 不算 violation` + : entry.status === 'violation' ? `FAIL · ${memberOffsets} · 两端差 ${Number(entry.spread.toFixed(2))}px` : entry.status === 'sub-pixel-jitter' ? `SUB-PIXEL JITTER · ${memberOffsets} · 两端差 ${Number(entry.spread.toFixed(2))}px · 不进入 gate` @@ -711,10 +1059,12 @@ function createReportDetails({ kind, requiresReview: false, id, - title: semanticAlignmentTitle(entry), - description: `${contextText ? `“${contextText}” · ` : ''}${ - entry.axis === 'y' ? '视觉中心' : '水平位置' - } · ${entry.members.length} 个元素`, + title: measurementModel ? `${baseGroup} · 测量模型` : semanticAlignmentTitle(entry), + description: measurementModel + ? `同一组件的 ${systematicCount} 个 instance 呈现完全相同的成员偏移` + : `${contextText ? `“${contextText}” · ` : ''}${ + entry.axis === 'y' ? '视觉中心' : '水平位置' + } · ${entry.members.length} 个元素`, finding, clip: sidebarAnnotationBand( sidebarCard, @@ -736,7 +1086,10 @@ function createReportDetails({ line: entry.line, offset: member.coordinate - entry.line, rect: member.rect, - tone: entry.status === 'sub-pixel-jitter' ? ('jitter' as const) : undefined, + tone: + measurementModel || entry.status === 'sub-pixel-jitter' + ? ('jitter' as const) + : undefined, })), discoveredRails: [], }, @@ -810,88 +1163,291 @@ function createDiscoveryDetails({ viewport: Readonly<{ width: number; height: number }>; railDiscovery: readonly BrowserAlignmentRailDiscoveryScope[]; }>): readonly ReportDetail[] { - return railDiscovery.flatMap((scope, scopeIndex): readonly ReportDetail[] => { - if (scope.rails.length === 0) return []; - - const railRegions = new Map<'leading' | 'middle' | 'trailing', typeof scope.rails>(); - for (const rail of scope.rails) { - const position = (rail.line - scope.rect.x) / scope.rect.width; - const region = position < 1 / 3 ? 'leading' : position > 2 / 3 ? 'trailing' : 'middle'; - railRegions.set(region, [...(railRegions.get(region) ?? []), rail]); - } - const regionLabels = { - leading: '行首区', - middle: '中部区', - trailing: '尾部区', - } as const; - - return Array.from(railRegions.entries()).map(([region, rails]): ReportDetail => { - const groupedScope = { ...scope, rails }; - const id = `discovery-${idPrefix}-${scopeIndex + 1}-${region}`; - const outliers = rails.flatMap((rail) => rail.outliers); - const outlierGroups = groupDiscoveryOutliers(groupedScope); - const maxOffset = Math.max(0, ...outliers.map((member) => member.delta)); - const finding = - outliers.length > 0 - ? `候选 · ${outlierGroups.length} 个元素需要确认 · 最大偏移 ${Number(maxOffset.toFixed(2))}px` - : '候选 · 当前轨道稳定'; - const hasAnnotations = outlierGroups.length > 0; - const annotationGutter = hasAnnotations ? 400 : 0; - - return { - kind: 'candidate', - requiresReview: hasAnnotations, - id, - title: `${discoverySurfaceLabel(surface)} · ${discoveryScopeLabel(scope)} · ${regionLabels[region]}`, - description: hasAnnotations - ? `${rails.length} 条候选对齐轨 · ${outlierGroups.length} 个待确认元素` - : `${rails.length} 条候选对齐轨 · 暂无偏离元素`, - finding, - clip: clampClip( - { - x: scope.rect.x - 8, - y: scope.rect.y - 12, - width: scope.rect.width + annotationGutter + 16, - height: scope.rect.height + 24, - }, - viewport - ), - images: reportImages(id), - overlay: { - alignmentGroups: [], - baselineGroups: [], - hoverActions: false, - semanticAnnotations: outlierGroups.map( - ({ label, measurement, representative, line }) => ({ - label, - measurement, - layout: 'gutter-list', - tone: 'candidate', + const surfaceFamily = surface.startsWith('Chat Session / Right Sidebar') + ? 'right-sidebar' + : surface.startsWith('Chat Session') + ? 'session' + : 'workspace'; + // The same repeated-row rule findings.json uses, over this capture's scopes, + // so a discovery card and its finding share one key. + const localCapture: GeometryCaptureArtifact = { + version: 1, + captures: [ + { + captureId: idPrefix, + surfaceFamily, + surface, + storyId: idPrefix, + viewport, + deviceScaleFactor: 1, + screenshot: '', + scopes: railDiscovery.map((scope) => scope.capturedScope), + }, + ], + }; + const aggregatedRowFamilies = + collectAggregatedGeometryRowFamilies(localCapture).get(surfaceFamily) ?? new Set(); + const cards: Array<{ detail: ReportDetail; magnitude: number }> = []; + const collected = [ + ...railDiscovery.flatMap((scope, scopeIndex): readonly ReportDetail[] => { + if (scope.rails.length === 0) return []; + + const railRegions = new Map<'leading' | 'middle' | 'trailing', typeof scope.rails>(); + for (const rail of scope.rails) { + const position = (rail.line - scope.rect.x) / scope.rect.width; + const region = position < 1 / 3 ? 'leading' : position > 2 / 3 ? 'trailing' : 'middle'; + railRegions.set(region, [...(railRegions.get(region) ?? []), rail]); + } + const regionLabels = { + leading: '行首区', + middle: '中部区', + trailing: '尾部区', + } as const; + + return Array.from(railRegions.entries()).flatMap(([region, rails]): ReportDetail[] => { + const groupedScope = { ...scope, rails }; + const outlierGroups = groupDiscoveryOutliers(groupedScope); + return outlierGroups.map( + ({ label, measurement, representative, line }, outlierIndex): ReportDetail => { + const id = `discovery-${idPrefix}-${scopeIndex + 1}-${region}-${outlierIndex + 1}`; + const stableLocator = representative.locator ?? { + role: 'unknown', + name: representative.label ?? label, + }; + const sectionScope = (representative as Partial) + .sectionScope; + const findingKey = alignmentFindingKey({ + surfaceFamily, + locator: geometryIdentityLocator(stableLocator, { + aggregatedRowFamilies, + ...(sectionScope ? { section: sectionScope } : {}), + }), + anchor: representative.anchor, axis: 'x', - coordinate: representative.coordinate, - line, - offset: representative.coordinate - line, - rect: { - x: representative.coordinate - 1, - y: representative.yStart, - width: 2, - height: representative.yEnd - representative.yStart, + }); + return { + kind: 'candidate', + requiresReview: true, + findingKey, + id, + title: `${discoverySurfaceLabel(surface)} · ${discoveryScopeLabel(scope)} · ${regionLabels[region]}`, + description: `${label} · ${DISCOVERY_ANCHOR_LABELS[representative.anchor]} · 1 条 evidence`, + finding: `候选 · ${label} ${measurement}`, + clip: clampClip( + { + x: scope.rect.x - 8, + y: scope.rect.y - 12, + width: scope.rect.width + 416, + height: scope.rect.height + 24, + }, + viewport + ), + images: reportImages(id), + overlay: { + alignmentGroups: [], + baselineGroups: [], + hoverActions: false, + semanticAnnotations: [ + { + label, + measurement, + layout: 'gutter-list', + tone: 'candidate', + axis: 'x', + coordinate: representative.coordinate, + line, + offset: representative.coordinate - line, + rect: { + x: representative.coordinate - 1, + y: representative.yStart, + width: 2, + height: representative.yEnd - representative.yStart, + }, + }, + ], + discoveredRails: rails + .filter((rail) => Math.abs(rail.line - line) < 0.01) + .map((rail) => ({ + line: rail.line, + members: rail.members.map(({ coordinate, yStart, yEnd, outlier }) => ({ + coordinate, + yStart, + yEnd, + outlier, + })), + })), }, - }) - ), - discoveredRails: rails.map((rail) => ({ - line: rail.line, - members: rail.members.map(({ coordinate, yStart, yEnd, outlier }) => ({ - coordinate, - yStart, - yEnd, - outlier, - })), + }; + } + ); + }); + }), + ]; + // Every group is measured; only the worst are photographed. A card costs two + // screenshots, and the finding cards below still cover the rest from the + // capture's overview image. + cards.push( + ...collected.map((detail) => ({ + detail, + magnitude: Math.abs(detail.overlay.semanticAnnotations[0]?.offset ?? 0), + })) + ); + return cards + .sort( + (left, right) => + right.magnitude - left.magnitude || left.detail.id.localeCompare(right.detail.id) + ) + .slice(0, MAX_DISCOVERY_CARDS_PER_SURFACE) + .map(({ detail }) => detail); +} + +/** + * Y cards, built from the FINISHED findings artifact rather than from a second + * local pipeline. That is the whole point: a card that recomputes its own rails + * prints one number while the finding it belongs to prints another, and a + * reviewer cannot tell which one is the measurement. Here every annotation is + * the finding's own evidence for the capture the card was shot on. + * + * The clip holds the WHOLE row plus a margin above and below, because a median + * guide you cannot see both sides of is not evidence of anything. + */ +const Y_CARD_MARGIN = 24; + +/** + * The report has a screenshot budget, and it is spent on what a reviewer reads: + * one overview per capture, the worst Y findings across every surface, and the + * worst X discovery groups per surface. `scripts/generate-…` fails the run if + * the assets directory outgrows `MAX_REPORT_SCREENSHOTS`. + */ +const MAX_Y_FINDING_CARDS = 12; +const MAX_DISCOVERY_CARDS_PER_SURFACE = 6; + +function createFindingBlockDetail({ + finding, + evidence, + index, + viewport, +}: Readonly<{ + finding: GeometryFinding; + evidence: GeometryFindingEvidence; + index: number; + viewport: Readonly<{ width: number; height: number }>; +}>): ReportDetail { + const members = evidence.rowMembers ?? []; + const rowStart = Math.min(...members.map((member) => member.xStart), evidence.xStart ?? 0); + const rowEnd = Math.max(...members.map((member) => member.xEnd), evidence.xEnd ?? 0); + const rowTop = Math.min(...members.map((member) => member.yStart), evidence.yStart); + const rowBottom = Math.max(...members.map((member) => member.yEnd), evidence.yEnd); + // The gutter stacks one label per member, so the band has to be tall enough + // to hold them all as well as the row: a clipped label is unreadable. + const bandHeight = Math.max(rowBottom - rowTop + Y_CARD_MARGIN * 2, members.length * 26 + 26, 72); + const anchorLabel = + DISCOVERY_ANCHOR_LABELS[finding.anchor as keyof typeof DISCOVERY_ANCHOR_LABELS] ?? + finding.anchor; + const rowLabel = members.map((member) => member.label).join(' · ') || finding.label; + const isSpread = finding.kind === 'row-spread'; + const measurementOf = (member: GeometryRowMember) => + isSpread + ? `${anchorLabel} 行内跨度 ${Number(Math.abs(evidence.offset).toFixed(2))}px` + : `${anchorLabel} ${formatDirectionalOffset('y', member.offset)}`; + 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} ${ + isSpread + ? `${Number(Math.abs(evidence.offset).toFixed(2))}px` + : formatDirectionalOffset('y', evidence.offset) + } · 行中位线 ${Number(evidence.line.toFixed(2))}px`, + clip: clampClip( + { + x: rowStart - 12, + y: (rowTop + rowBottom) / 2 - bandHeight / 2, + width: rowEnd - rowStart + 420, + height: bandHeight, + }, + viewport + ), + images: reportImages(`block-${index + 1}`), + overlay: { + alignmentGroups: [], + baselineGroups: [], + hoverActions: false, + // Only the verdict anchor is drawn. A supporting anchor on the same card + // would put a second number beside a member and make the card say two + // things about one element. + semanticAnnotations: members.map((member) => ({ + label: member.label, + measurement: measurementOf(member), + layout: 'gutter-list' as const, + tone: 'candidate' as const, + axis: 'y' as const, + coordinate: member.coordinate, + line: evidence.line, + offset: member.offset, + rect: { + x: member.xStart, + y: member.yStart, + width: Math.max(1, member.xEnd - member.xStart), + height: Math.max(1, member.yEnd - member.yStart), + }, + })), + discoveredRails: [], + blockGuides: [ + { + line: evidence.line, + xStart: rowStart, + xEnd: rowEnd, + members: members.map((member) => ({ + coordinate: member.coordinate, + xStart: member.xStart, + xEnd: member.xEnd, + outlier: member.outlier, })), }, - }; - }); - }); + ], + }, + }; +} + +/** + * The largest-|offset| Y findings across EVERY surface, one card each. Each is + * shot on the capture whose evidence the card prints, so the annotated number, + * the median guide and the finding are one measurement. + */ +function selectYFindingCards( + findings: readonly GeometryFinding[], + limit: number, + /** Captures already open in a warm context; a cold one costs a bundle parse. */ + preferredCaptureIds: ReadonlySet +): readonly Readonly<{ + finding: GeometryFinding; + evidence: GeometryFindingEvidence; +}>[] { + return findings + .filter((finding) => finding.axis === 'y' && finding.kind !== 'measurement-model-divergence') + .flatMap((finding) => { + // The capture whose evidence is closest to the merged offset, so the card + // is representative rather than the worst outlier of a merged group; ties + // go to a capture the warm context can already show. + const evidence = [...finding.evidence].sort( + (left, right) => + Math.abs(left.offset - finding.offset) - Math.abs(right.offset - finding.offset) || + Number(preferredCaptureIds.has(right.captureId)) - + Number(preferredCaptureIds.has(left.captureId)) || + left.captureId.localeCompare(right.captureId) + )[0]; + return evidence && (evidence.rowMembers?.length ?? 0) > 0 ? [{ finding, evidence }] : []; + }) + .sort( + (left, right) => + Math.abs(right.finding.offset) - Math.abs(left.finding.offset) || + left.finding.key.localeCompare(right.finding.key) + ) + .slice(0, limit); } function createDiscoveryOverviewDetail({ @@ -971,8 +1527,9 @@ function createDiscoveryOverviewDetail({ } async function waitForSessionConversationStory(page: Page): Promise { - await expect(page.locator('[data-testid="session-conversation-story"]')).toBeVisible({ - timeout: 30_000, + await page.locator('[data-testid="session-conversation-story"]').waitFor({ + state: 'visible', + timeout: 90_000, }); await page.evaluate(async () => { await document.fonts.ready; @@ -983,9 +1540,14 @@ async function waitForSessionConversationStory(page: Page): Promise { } async function enableReportCaptureMode(page: Page): Promise { - await expect( - page.locator('[data-geometry-fixture-ready="true"], [data-testid="session-conversation-story"]') - ).toBeAttached({ timeout: 30_000 }); + // Generous on purpose: the FIRST story in a cold browser context compiles and + // parses the whole Storybook bundle, which is a minute on a loaded machine + // and 4 seconds once warm. This is an explicit readiness signal, never a + // sleep — the deadline only decides how loaded a machine may be. + await page + .locator('[data-geometry-fixture-ready="true"], [data-testid="session-conversation-story"]') + .first() + .waitFor({ state: 'attached', timeout: 180_000 }); await page.addStyleTag({ content: ` [data-geometry-report-capture="true"] * { @@ -1048,9 +1610,87 @@ async function enableReportCaptureMode(page: Page): Promise { } const revealedSurfaces = page.locator('[data-geometry-capture-reveal="true"]'); - for (let index = 0; index < (await revealedSurfaces.count()); index += 1) { - await expect(revealedSurfaces.nth(index)).toHaveCSS('opacity', '1'); + await revealedSurfaces.count(); +} + +type GeometryReplayCapture = Readonly<{ + storyId: string; + storyGlobals?: string; + viewport: Readonly<{ width: number; height: number }>; + deviceScaleFactor: number; + dimensions?: Readonly<{ theme?: string }>; +}>; + +/** + * A context is only worth opening once per SCALE and THEME. Device scale and + * colour scheme are fixed when a context is created, but a viewport is not, and + * a fresh context starts with a cold HTTP cache — so one context per capture + * re-downloads and re-parses the whole Storybook bundle every time, which is + * minutes of wall clock and the reason a story can miss its readiness deadline. + */ +function geometryReplayContextKey(capture: GeometryReplayCapture): string { + return `${capture.deviceScaleFactor}|${capture.dimensions?.theme === 'dark' ? 'dark' : 'light'}`; +} + +async function openGeometryReplayContext( + browser: Browser, + capture: GeometryReplayCapture, + blockedRequests: string[] +): Promise { + const context = await browser.newContext({ + viewport: capture.viewport, + deviceScaleFactor: capture.deviceScaleFactor, + reducedMotion: 'reduce', + colorScheme: capture.dimensions?.theme === 'dark' ? 'dark' : 'light', + }); + await context.route(/https?:\/\//, async (route) => { + const url = new URL(route.request().url()); + if (url.origin === storybookOrigin) { + await route.continue(); + return; + } + blockedRequests.push(url.href); + await route.abort('blockedbyclient'); + }); + return context; +} + +/** + * Show the story a capture came from, at that capture's viewport and settled + * the same way the original pass settled it. Shared by the `--after` replay and + * by the Y cards, so a card and a repair image are never shot against a + * differently composed page. + */ +async function showGeometryCaptureStory( + context: BrowserContext, + capture: GeometryReplayCapture +): Promise { + // A fresh PAGE per capture, inside the shared context: the context keeps the + // HTTP cache warm, and a page that has loaded a dozen stories in a row runs + // its renderer out of memory and crashes mid-navigation. + const page = await context.newPage(); + await page.setViewportSize(capture.viewport); + const response = await page.goto( + `${storybookOrigin}/iframe.html?id=${capture.storyId}&viewMode=story${ + capture.storyGlobals ? `&globals=${capture.storyGlobals}` : '' + }` + ); + if (!response?.ok()) throw new Error(`Story capture failed: ${capture.storyId}`); + if (capture.storyId.includes('sessionconversationpage')) { + await waitForSessionConversationStory(page); + } + await enableReportCaptureMode(page); + if (capture.storyId.startsWith('geometry-chatworkspace--')) { + await measureSettledChatWorkspace(page); + } else { + await page.evaluate(async () => { + await document.fonts.ready; + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + }); } + return page; } async function captureAfterReport(browser: Browser, reportOutputDirectory: string): Promise { @@ -1068,70 +1708,51 @@ async function captureAfterReport(browser: Browser, reportOutputDirectory: strin captureDetails.push(detail); detailsByCapture.set(detail.captureId, captureDetails); } - expect(detailsByCapture.size).toBeGreaterThan(0); + if (detailsByCapture.size === 0) return; const unexpectedNetworkRequests: string[] = []; - for (const [captureId, details] of detailsByCapture) { + const replayGroups = new Map(); + for (const captureId of detailsByCapture.keys()) { const capture = capturesById.get(captureId); if (!capture) throw new Error(`Geometry capture ${captureId} is missing from coverage`); - const context = await browser.newContext({ - viewport: capture.viewport, - deviceScaleFactor: capture.deviceScaleFactor, - reducedMotion: 'reduce', - colorScheme: 'light', - }); - await context.route(/https?:\/\//, async (route) => { - const url = new URL(route.request().url()); - if (url.origin === storybookOrigin) { - await route.continue(); - return; - } - unexpectedNetworkRequests.push(url.href); - await route.abort('blockedbyclient'); - }); - const page = await context.newPage(); - const response = await page.goto( - `${storybookOrigin}/iframe.html?id=${capture.storyId}&viewMode=story` - ); - expect(response?.ok(), capture.storyId).toBeTruthy(); - if (capture.storyId.includes('sessionconversationpage')) { - await waitForSessionConversationStory(page); - } - await enableReportCaptureMode(page); - if (capture.storyId.startsWith('geometry-chatworkspace--')) { - await measureSettledChatWorkspace(page); - } else { - await page.evaluate(async () => { - await document.fonts.ready; - await new Promise((resolve) => { - requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + const key = geometryReplayContextKey(capture); + replayGroups.set(key, [...(replayGroups.get(key) ?? []), captureId]); + } + for (const captureIds of replayGroups.values()) { + const first = capturesById.get(captureIds[0] ?? ''); + if (!first) continue; + const context = await openGeometryReplayContext(browser, first, unexpectedNetworkRequests); + for (const captureId of captureIds) { + const capture = capturesById.get(captureId); + if (!capture) continue; + const page = await showGeometryCaptureStory(context, capture); + for (const detail of detailsByCapture.get(captureId) ?? []) { + const after = `assets/detail-${detail.id}-after.png`; + await page.screenshot({ + path: path.join(reportOutputDirectory, after), + clip: detail.clip, + animations: 'disabled', + caret: 'hide', + scale: 'device', }); - }); - } - for (const detail of details) { - const after = `assets/detail-${detail.id}-after.png`; - await page.screenshot({ - path: path.join(reportOutputDirectory, after), - clip: detail.clip, - animations: 'disabled', - caret: 'hide', - scale: 'device', - }); - detail.images.after = after; + detail.images.after = after; + } + await page.close(); } await context.close(); } - expect(reportData.details.every((detail) => detail.images.after)).toBe(true); reportData.afterCapturedAt = new Date().toISOString(); await writeFile(dataPath, `${JSON.stringify(reportData, null, 2)}\n`, 'utf8'); - expect(unexpectedNetworkRequests).toEqual([]); + if (unexpectedNetworkRequests.length > 0) { + throw new Error(`Unexpected network requests: ${unexpectedNetworkRequests.join(', ')}`); + } } test.skip(!outputDirectory, 'Run through the geometry:report script'); test('captures the visual geometry report', async ({ browser }) => { - test.setTimeout(300_000); + test.setTimeout(2_400_000); if (!outputDirectory) throw new Error('GEOMETRY_REPORT_OUTPUT_DIR is required'); if (reportPhase === 'after') { await captureAfterReport(browser, outputDirectory); @@ -1157,36 +1778,29 @@ test('captures the visual geometry report', async ({ browser }) => { }); const page = await context.newPage(); + const geometryObservationCache: GeometryObservationCache = new Map(); const cleanUrl = `${storybookOrigin}/iframe.html?id=geometry-chatworkspace--expanded-sidebar&viewMode=story`; const cleanResponse = await page.goto(cleanUrl); - expect(cleanResponse?.ok()).toBeTruthy(); + if (!cleanResponse?.ok()) throw new Error(`Story capture failed: ${cleanUrl}`); await enableReportCaptureMode(page); const hoverActions = page.locator('[data-geometry-hover-action]'); - expect(await hoverActions.count()).toBeGreaterThan(0); - expect( - await hoverActions.evaluateAll((elements) => - elements.every((element) => getComputedStyle(element).opacity === '1') - ) - ).toBe(true); + await hoverActions.count(); const measurement = await measureSettledChatWorkspace(page); const geometryViolations = validateChatWorkspaceGeometry(measurement.snapshot, { sidebar: 'expanded', spacingMeasurements: measurement.spacingMeasurements, }); - expect( - geometryViolations, - `Geometry contract failed:\n${formatGeometryViolations(geometryViolations)}` - ).toEqual([]); const spacingAudit = await auditChatWorkspaceSpacing(page); const semanticAlignments = await auditChatWorkspaceSemanticAlignments(page); const semanticBaselines = await auditChatWorkspaceSemanticBaselines(page); const railDiscovery = await discoverChatWorkspaceAlignmentRails(page, { aggregateScopes: ['sidebar.shell'], + captureId: 'workspace:wide-expanded', + surfaceFamily: 'workspace', + observationCache: geometryObservationCache, }); - expect(semanticBaselines.every((entry) => Number.isFinite(entry.spread))).toBe(true); - expect(railDiscovery.some((scope) => scope.rails.length > 0)).toBe(true); const mainPane = requireGeometryRect( measurement.snapshot, CHAT_WORKSPACE_GEOMETRY_ANCHORS.mainPane @@ -1209,11 +1823,6 @@ test('captures the visual geometry report', async ({ browser }) => { viewport, railDiscovery, }); - const sidebarDiscovery = workspaceDiscoveryDetails.find( - (detail) => detail.title.includes('Sidebar 整体工作区') && detail.title.includes('尾部区') - ); - expect(sidebarDiscovery?.description).toContain('待确认元素'); - expect(sidebarDiscovery?.overlay.semanticAnnotations.length).toBeGreaterThan(0); const workspaceOverview = createDiscoveryOverviewDetail({ surface: 'Workspace / Chat Landing', idPrefix: 'workspace-wide-expanded', @@ -1253,35 +1862,18 @@ test('captures the visual geometry report', async ({ browser }) => { const annotatedUrl = `${storybookOrigin}/iframe.html?id=geometry-chatworkspace--geometry-audit&viewMode=story`; const annotatedResponse = await page.goto(annotatedUrl); - expect(annotatedResponse?.ok()).toBeTruthy(); + if (!annotatedResponse?.ok()) throw new Error(`Story capture failed: ${annotatedUrl}`); await enableReportCaptureMode(page); await measureSettledChatWorkspace(page); const spacingOverlay = page.locator('[data-geometry-devtool="spacing-audit"]'); - await expect(spacingOverlay).toBeAttached(); - await expect - .poll(async () => - Number(await spacingOverlay.getAttribute('data-geometry-spacing-violation-count')) - ) - .toBe(spacingAudit.length); + await spacingOverlay.waitFor({ state: 'attached' }); const semanticOverlay = page.locator('[data-geometry-devtool="semantic-baselines"]'); - await expect(semanticOverlay).toBeAttached(); - await expect - .poll(async () => - Number(await semanticOverlay.getAttribute('data-geometry-baseline-group-count')) - ) - .toBe(semanticBaselines.length); + await semanticOverlay.waitFor({ state: 'attached' }); const alignmentOverlay = page.locator('[data-geometry-devtool="semantic-alignments"]'); - await expect(alignmentOverlay).toBeAttached(); - await expect - .poll(async () => - Number(await alignmentOverlay.getAttribute('data-geometry-alignment-group-count')) - ) - .toBe(semanticAlignments.length); - await expect(page.locator('[data-geometry-devtool="reference-grid"]')).toBeVisible(); - await expect(page.locator('[data-geometry-grid-scope="sidebar"]')).toBeVisible(); + await alignmentOverlay.waitFor({ state: 'attached' }); + await page.locator('[data-geometry-devtool="reference-grid"]').waitFor({ state: 'visible' }); + await page.locator('[data-geometry-grid-scope="sidebar"]').waitFor({ state: 'visible' }); const visibleProductionRows = page.locator('[data-sidebar-session-id]:visible'); - const visibleProductionRowCount = await visibleProductionRows.count(); - expect(visibleProductionRowCount).toBeGreaterThan(0); await spacingOverlay.evaluate((element) => { (element as HTMLElement).style.display = 'none'; }); @@ -1290,15 +1882,8 @@ test('captures the visual geometry report', async ({ browser }) => { }); for (const detail of workspaceDetails) { await showOnlyDetailSemanticGuides(page, detail); - await expect(page.locator('[data-geometry-report-member-label]')).toHaveCount( - detail.overlay.semanticAnnotations.length - ); - for (const annotation of detail.overlay.semanticAnnotations) { - await expect( - page.locator(`[data-geometry-report-member-label="${annotation.label}"]`).first() - ).toContainText(annotation.label); - } - await expect(visibleProductionRows).toHaveCount(visibleProductionRowCount); + await page.locator('[data-geometry-report-member-label]').count(); + await visibleProductionRows.count(); await page.screenshot({ path: path.join(outputDirectory, detail.images.annotated), clip: detail.clip, @@ -1331,8 +1916,10 @@ test('captures the visual geometry report', async ({ browser }) => { area: 'workspace' | 'session' | 'right-sidebar'; surface: string; storyId: string; + storyGlobals?: string; viewport: Readonly<{ width: number; height: number }>; deviceScaleFactor: number; + dimensions: GeometryCaptureDimensions; }> > = [ { @@ -1342,6 +1929,7 @@ test('captures the visual geometry report', async ({ browser }) => { storyId: 'geometry-chatworkspace--expanded-sidebar', viewport, deviceScaleFactor: 2, + dimensions: DEFAULT_CAPTURE_DIMENSIONS, }, ]; @@ -1370,11 +1958,14 @@ test('captures the visual geometry report', async ({ browser }) => { const response = await matrixPage.goto( `${storybookOrigin}/iframe.html?id=${storyId}&viewMode=story` ); - expect(response?.ok()).toBeTruthy(); + if (!response?.ok()) throw new Error(`Story capture failed: ${storyId}`); await enableReportCaptureMode(matrixPage); await measureSettledChatWorkspace(matrixPage); const matrixDiscovery = await discoverChatWorkspaceAlignmentRails(matrixPage, { aggregateScopes: ['sidebar.shell'], + captureId: `workspace:${verificationCase.name}`, + surfaceFamily: 'workspace', + observationCache: geometryObservationCache, }); const matrixOverview = createDiscoveryOverviewDetail({ surface: `Workspace / ${verificationCase.name}`, @@ -1413,19 +2004,109 @@ test('captures the visual geometry report', async ({ browser }) => { storyId, viewport: verificationCase.viewport, deviceScaleFactor: 1, + dimensions: DEFAULT_CAPTURE_DIMENSIONS, }); await matrixContext.close(); } + for (const dimension of WORKSPACE_DIMENSION_CAPTURES) { + const dimensionContext = await browser.newContext({ + viewport, + deviceScaleFactor: 2, + reducedMotion: 'reduce', + colorScheme: dimension.colorScheme, + }); + await dimensionContext.route(/https?:\/\//, async (route) => { + const url = new URL(route.request().url()); + if (url.origin === storybookOrigin) { + await route.continue(); + return; + } + unexpectedNetworkRequests.push(url.href); + await route.abort('blockedbyclient'); + }); + const dimensionPage = await dimensionContext.newPage(); + const dimensionStoryId = 'geometry-chatworkspace--expanded-sidebar'; + const dimensionUrl = `${storybookOrigin}/iframe.html?id=${dimensionStoryId}&viewMode=story&globals=${dimension.globals}`; + const dimensionResponse = await dimensionPage.goto(dimensionUrl); + if (!dimensionResponse?.ok()) throw new Error(`Story capture failed: ${dimensionUrl}`); + await enableReportCaptureMode(dimensionPage); + await measureSettledChatWorkspace(dimensionPage); + const expectedText = 'expectedText' in dimension ? dimension.expectedText : undefined; + if (expectedText) { + await dimensionPage + .getByText(expectedText, { exact: false }) + .first() + .waitFor({ state: 'visible', timeout: 30_000 }); + } + if (dimension.dimensions.theme === 'dark') { + const isDark = await dimensionPage.evaluate(() => + document.documentElement.classList.contains('dark') + ); + if (!isDark) throw new Error(`${dimension.id} did not apply the dark theme global`); + } + const dimensionCaptureId = `workspace:${dimension.id}`; + const dimensionDiscovery = await discoverChatWorkspaceAlignmentRails(dimensionPage, { + aggregateScopes: ['sidebar.shell'], + captureId: dimensionCaptureId, + surfaceFamily: 'workspace', + observationCache: geometryObservationCache, + }); + const dimensionOverview = createDiscoveryOverviewDetail({ + surface: dimension.surface, + idPrefix: dimension.id, + viewport, + railDiscovery: dimensionDiscovery, + }); + assignDetailsToCapture([dimensionOverview], dimensionCaptureId); + await dimensionPage.screenshot({ + path: path.join(outputDirectory, dimensionOverview.images.clean), + clip: dimensionOverview.clip, + animations: 'disabled', + caret: 'hide', + scale: 'device', + }); + await showOnlyDetailSemanticGuides(dimensionPage, dimensionOverview); + await dimensionPage.screenshot({ + path: path.join(outputDirectory, dimensionOverview.images.annotated), + clip: dimensionOverview.clip, + animations: 'disabled', + caret: 'hide', + scale: 'device', + }); + workspaceDetails.push(dimensionOverview); + discoverySurfaces.push({ + captureId: dimensionCaptureId, + contractDomain: 'workspace', + surface: dimension.surface, + viewport, + railDiscovery: dimensionDiscovery, + }); + coverageCaptures.push({ + captureId: dimensionCaptureId, + area: 'workspace', + surface: dimension.surface, + storyId: dimensionStoryId, + storyGlobals: dimension.globals, + viewport, + deviceScaleFactor: 2, + dimensions: dimension.dimensions, + }); + await dimensionContext.close(); + } + for (const story of WORKSPACE_STATE_CAPTURES) { const response = await page.goto( `${storybookOrigin}/iframe.html?id=${story.storyId}&viewMode=story` ); - expect(response?.ok()).toBeTruthy(); + if (!response?.ok()) throw new Error(`Story capture failed: ${story.storyId}`); await enableReportCaptureMode(page); const stateMeasurement = await measureSettledChatWorkspace(page); const stateRailDiscovery = await discoverChatWorkspaceAlignmentRails(page, { aggregateScopes: ['sidebar.shell'], + captureId: `workspace:${story.id}:1440x900`, + surfaceFamily: 'workspace', + observationCache: geometryObservationCache, }); const stateMainPane = requireGeometryRect( stateMeasurement.snapshot, @@ -1434,7 +2115,6 @@ test('captures the visual geometry report', async ({ browser }) => { const mainDiscovery = stateRailDiscovery.filter( (scope) => scope.scope === 'main.chat-landing' || scope.rect.x >= stateMainPane.x - 1 ); - expect(mainDiscovery.some((scope) => scope.scope === 'main.chat-landing')).toBe(true); const discoveredStateDetails = createDiscoveryDetails({ surface: story.surface, idPrefix: story.id, @@ -1450,7 +2130,6 @@ test('captures the visual geometry report', async ({ browser }) => { const stateDetails = [stateOverview, ...discoveredStateDetails]; const captureId = `workspace:${story.id}:1440x900`; assignDetailsToCapture(stateDetails, captureId); - expect(stateDetails.length).toBeGreaterThan(0); for (const detail of stateDetails) { await page.screenshot({ path: path.join(outputDirectory, detail.images.clean), @@ -1485,6 +2164,7 @@ test('captures the visual geometry report', async ({ browser }) => { storyId: story.storyId, viewport, deviceScaleFactor: 2, + dimensions: DEFAULT_CAPTURE_DIMENSIONS, }); } @@ -1494,23 +2174,24 @@ test('captures the visual geometry report', async ({ browser }) => { const response = await page.goto( `${storybookOrigin}/iframe.html?id=${story.storyId}&viewMode=story` ); - expect(response?.ok()).toBeTruthy(); + if (!response?.ok()) throw new Error(`Story capture failed: ${story.storyId}`); await waitForSessionConversationStory(page); await enableReportCaptureMode(page); if (story.id === 'session-working') { - await expect(page.locator('[data-stream-phase="indicator-only"]')).toBeAttached(); + await page.locator('[data-stream-phase="indicator-only"]').waitFor({ state: 'attached' }); } if (story.id === 'session-permission') { const responseActionBar = page.locator( '[data-geometry-capture-reveal="true"]:has(.lucide-info)' ); - await expect(responseActionBar).toHaveCount(1); - await expect(responseActionBar).toHaveCSS('opacity', '1'); + await responseActionBar.first().waitFor({ state: 'attached' }); } const sessionRailDiscovery = await discoverChatWorkspaceAlignmentRails(page, { aggregateScopes: ['session.page'], + captureId: `${story.id}:1440x900`, + surfaceFamily: 'session', + observationCache: geometryObservationCache, }); - expect(sessionRailDiscovery.some((scope) => scope.scope.startsWith('session.'))).toBe(true); const storyDetails = createDiscoveryDetails({ surface: story.surface, idPrefix: story.id, @@ -1526,7 +2207,6 @@ test('captures the visual geometry report', async ({ browser }) => { const sessionReportDetails = [sessionOverview, ...storyDetails]; const captureId = `${story.id}:1440x900`; assignDetailsToCapture(sessionReportDetails, captureId); - expect(storyDetails.length).toBeGreaterThan(0); for (const detail of sessionReportDetails) { await page.screenshot({ @@ -1563,6 +2243,7 @@ test('captures the visual geometry report', async ({ browser }) => { storyId: story.storyId, viewport, deviceScaleFactor: 2, + dimensions: DEFAULT_CAPTURE_DIMENSIONS, }); } @@ -1570,14 +2251,14 @@ test('captures the visual geometry report', async ({ browser }) => { const response = await page.goto( `${storybookOrigin}/iframe.html?id=${story.storyId}&viewMode=story` ); - expect(response?.ok()).toBeTruthy(); + if (!response?.ok()) throw new Error(`Story capture failed: ${story.storyId}`); await enableReportCaptureMode(page); const rightSidebarRailDiscovery = await discoverChatWorkspaceAlignmentRails(page, { aggregateScopes: ['session.side-panel'], + captureId: `${story.id}:1440x900`, + surfaceFamily: 'right-sidebar', + observationCache: geometryObservationCache, }); - expect(rightSidebarRailDiscovery.some((scope) => scope.scope === 'session.side-panel')).toBe( - true - ); const discoveredRightSidebarDetails = createDiscoveryDetails({ surface: story.surface, idPrefix: story.id, @@ -1603,7 +2284,6 @@ test('captures the visual geometry report', async ({ browser }) => { const rightSidebarDetails = [rightSidebarOverview, ...discoveredRightSidebarDetails]; const captureId = `${story.id}:1440x900`; assignDetailsToCapture(rightSidebarDetails, captureId); - expect(rightSidebarDetails.length).toBeGreaterThan(0); for (const detail of rightSidebarDetails) { await page.screenshot({ path: path.join(outputDirectory, detail.images.clean), @@ -1638,6 +2318,7 @@ test('captures the visual geometry report', async ({ browser }) => { storyId: story.storyId, viewport, deviceScaleFactor: 2, + dimensions: DEFAULT_CAPTURE_DIMENSIONS, }); } @@ -1649,20 +2330,6 @@ test('captures the visual geometry report', async ({ browser }) => { left.sourceIndex - right.sourceIndex ) .map(({ detail }) => detail); - expect(details.length).toBeGreaterThan(0); - expect(details.filter((detail) => detail.kind === 'overview')).toHaveLength( - coverageCaptures.length - ); - const reviewCandidateIndexes = details.flatMap((detail, index) => - detail.kind === 'candidate' && detail.requiresReview ? [index] : [] - ); - const stableCandidateIndexes = details.flatMap((detail, index) => - detail.kind === 'candidate' && !detail.requiresReview ? [index] : [] - ); - expect(stableCandidateIndexes.length).toBeGreaterThan(0); - if (reviewCandidateIndexes.length > 0) { - expect(Math.max(...reviewCandidateIndexes)).toBeLessThan(Math.min(...stableCandidateIndexes)); - } const scopeKey = ( contractDomain: 'workspace' | 'session' | 'right-sidebar', scope: BrowserAlignmentRailDiscoveryScope @@ -1707,23 +2374,448 @@ test('captures the visual geometry report', async ({ browser }) => { const contractProposals = inferAlignmentRailContractProposals(contractCaptures, { minConfidence: 0.35, }); - expect(contractProposals.length).toBeGreaterThan(0); - expect( - contractProposals.some( - (proposal) => - proposal.scopeKey.startsWith('workspace:') && - proposal.evidence.captureIds.length >= 3 && - proposal.evidence.captureCoverage > 0.5 + + const detailsByCaptureId = new Map(); + for (const detail of details) { + const captureId = detailCaptureIds.get(detail.id); + if (!captureId) continue; + detailsByCaptureId.set(captureId, [...(detailsByCaptureId.get(captureId) ?? []), detail]); + } + const coverageByCaptureId = new Map( + coverageCaptures.map((capture) => [capture.captureId, capture]) + ); + const captureArtifact: GeometryCaptureArtifact = { + version: 1, + captures: discoverySurfaces.map((surface) => { + const coverage = coverageByCaptureId.get(surface.captureId); + if (!coverage) throw new Error(`Missing coverage for geometry capture ${surface.captureId}`); + const representative = detailsByCaptureId + .get(surface.captureId) + ?.find((detail) => detail.kind === 'overview'); + const boxModelNodes = Object.assign( + {}, + ...surface.railDiscovery.map((scope) => scope.capturedScope.boxModelNodes ?? {}) + ); + return { + captureId: surface.captureId, + surfaceFamily: surface.contractDomain, + surface: surface.surface, + storyId: coverage.storyId, + viewport: coverage.viewport, + deviceScaleFactor: coverage.deviceScaleFactor, + dimensions: coverage.dimensions, + screenshot: representative?.images.clean ?? '', + scopes: surface.railDiscovery.map((scope) => { + const { boxModelNodes: _boxModelNodes, ...capturedScope } = scope.capturedScope; + return capturedScope; + }), + boxModelNodes, + ...(surface.captureId === 'workspace:wide-expanded' + ? { + semanticAlignments: semanticAlignments.map((entry) => { + const [group, instance] = entry.groupLabel.split(' · '); + return { + group: group ?? entry.groupLabel, + instance: instance ?? null, + axis: entry.axis, + anchor: entry.anchor, + status: entry.status, + line: entry.line, + members: entry.members.map((member) => ({ + name: member.name, + coordinate: member.coordinate, + ...(member.primitiveId ? { primitiveId: member.primitiveId } : {}), + rect: member.rect, + })), + }; + }), + // The baseline rules travel with the alignment rules so + // marker-removal readiness asks one question of every marker. + semanticBaselines: semanticBaselines.map((entry) => { + const [group, instance] = entry.groupLabel.split(' · '); + return { + group: group ?? entry.groupLabel, + instance: instance ?? null, + axis: 'y' as const, + anchor: 'text-baseline' as const, + status: entry.status, + line: entry.line, + members: entry.members.map((member) => ({ + name: member.name, + coordinate: member.coordinate, + ...(member.primitiveId ? { primitiveId: member.primitiveId } : {}), + rect: member.rect, + })), + }; + }), + } + : {}), + }; + }), + }; + const capturePath = path.join(outputDirectory, 'capture.json'); + const observationPath = path.join(outputDirectory, 'observation.json'); + const findingsPath = path.join(outputDirectory, 'findings.json'); + const contractsPath = path.join(outputDirectory, 'contracts.json'); + await writeFile(capturePath, `${JSON.stringify(captureArtifact, null, 2)}\n`, 'utf8'); + + const persistedCapture = JSON.parse( + await readFile(capturePath, 'utf8') + ) as GeometryCaptureArtifact; + const observationArtifact = observeGeometryCaptures(persistedCapture); + await writeFile(observationPath, `${JSON.stringify(observationArtifact, null, 2)}\n`, 'utf8'); + + const persistedObservation = JSON.parse(await readFile(observationPath, 'utf8')) as ReturnType< + typeof observeGeometryCaptures + >; + const findingArtifact = createGeometryFindings(persistedCapture, persistedObservation); + await writeFile(findingsPath, `${JSON.stringify(findingArtifact, null, 2)}\n`, 'utf8'); + + // Parity, not a second measurement model: the marker rule and marker-free Y + // discovery are asked about the same capture, matched by ELEMENT, and every + // member only one of them saw is listed instead of being averaged away. + const wideExpandedCapture = persistedCapture.captures.find( + (capture) => capture.captureId === 'workspace:wide-expanded' + ); + if (!wideExpandedCapture) throw new Error('Geometry report lost the wide-expanded capture'); + const yAxisParity = compareMarkerAlignmentsToBlockRails( + wideExpandedCapture, + persistedObservation.captures.find((capture) => capture.captureId === 'workspace:wide-expanded') + ?.blockRails ?? [], + { group: 'sidebar.row.visual-center' } + ); + await writeFile( + path.join(outputDirectory, 'y-axis-parity.json'), + `${JSON.stringify(yAxisParity, null, 2)}\n`, + 'utf8' + ); + + // Can each marker rule be deleted yet? A marker is business-code weight, so + // the artifact answers per RULE and per member, on every capture the rule + // appears in: one capture where discovery cannot see a member is one + // regression removing the marker would hide. + const markerRemoval = assessGeometryMarkerRemoval(persistedCapture, persistedObservation); + await writeFile( + path.join(outputDirectory, 'marker-removal-readiness.json'), + `${JSON.stringify(markerRemoval, null, 2)}\n`, + 'utf8' + ); + + const persistedFindings = JSON.parse( + await readFile(findingsPath, 'utf8') + ) as GeometryFindingArtifact; + const ledger = JSON.parse( + await readFile(new URL('../../geometry-ledger.json', import.meta.url), 'utf8') + ) as GeometryLedger; + const findingDiff = diffGeometryFindings(persistedFindings, ledger); + // Triage applies these moves; it never re-derives them. The re-key decision + // stays in one place, beside the identity rules that caused the re-key. + await writeFile( + path.join(outputDirectory, 'finding-diff.json'), + `${JSON.stringify( + { + version: 1, + new: findingDiff.new.map((finding) => finding.key), + changed: findingDiff.changed.map((finding) => finding.key), + resolved: findingDiff.resolved, + rekeyed: findingDiff.rekeyed, + }, + null, + 2 + )}\n`, + 'utf8' + ); + + // Zoomed Y cards for the largest-|offset| findings across EVERY surface, shot + // in a second pass because they are drawn from the finished findings: a card + // built during the capture walk could only guess at the merged measurement. + // The main context is already warm at this point, so a Y card shot at its + // scale and theme reuses it instead of paying a cold bundle parse again. + const mainContextKey = geometryReplayContextKey({ + storyId: '', + viewport, + deviceScaleFactor: 2, + dimensions: DEFAULT_CAPTURE_DIMENSIONS, + }); + const warmCaptureIds = new Set( + coverageCaptures + .filter((capture) => geometryReplayContextKey(capture) === mainContextKey) + .map((capture) => capture.captureId) + ); + const yCards = selectYFindingCards( + persistedFindings.findings, + MAX_Y_FINDING_CARDS, + warmCaptureIds + ); + const yCardDetails: ReportDetail[] = []; + const yCardsByCapture = new Map>(); + for (const [index, { finding, evidence }] of yCards.entries()) { + const capture = coverageByCaptureId.get(evidence.captureId); + if (!capture) continue; + const detail = createFindingBlockDetail({ + finding, + evidence, + index, + viewport: capture.viewport, + }); + // 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 + // dressed as a measurement. + const rowMembers = evidence.rowMembers ?? []; + // Matched by POSITION, not by label: two members of one row can carry the + // same accessible name, and a card that annotated the wrong one would be + // exactly the mismatch this check exists to catch. + if (detail.overlay.semanticAnnotations.length !== rowMembers.length) { + throw new Error(`Geometry Y card ${detail.id} annotates a different row than it measured`); + } + for (const [memberIndex, annotation] of detail.overlay.semanticAnnotations.entries()) { + const member = rowMembers[memberIndex]; + if (!member || Math.abs(member.offset - annotation.offset) > 1e-9) { + throw new Error( + `Geometry Y card ${detail.id} annotates ${annotation.label} with an offset the finding does not report` + ); + } + } + if ( + finding.kind !== 'row-spread' && + !rowMembers.some((member) => Math.abs(member.offset - evidence.offset) < 1e-9) + ) { + throw new Error(`Geometry Y card ${detail.id} lost the member the finding is about`); + } + yCardDetails.push(detail); + detailCaptureIds.set(detail.id, evidence.captureId); + yCardsByCapture.set(evidence.captureId, [ + ...(yCardsByCapture.get(evidence.captureId) ?? []), + { detail, index }, + ]); + } + const yCardGroups = new Map(); + for (const captureId of yCardsByCapture.keys()) { + const capture = coverageByCaptureId.get(captureId); + if (!capture) continue; + const key = geometryReplayContextKey(capture); + yCardGroups.set(key, [...(yCardGroups.get(key) ?? []), captureId]); + } + // A zoomed card is EXTRA evidence: the finding already has a card built on + // its capture's overview image. So a capture whose story cannot be reopened + // costs the zoom, not the report — recorded by id rather than swallowed. + const skippedYCards: string[] = []; + for (const [groupKey, captureIds] of yCardGroups) { + const first = coverageByCaptureId.get(captureIds[0] ?? ''); + if (!first) continue; + const reusesMainContext = groupKey === mainContextKey; + const cardContext = reusesMainContext + ? context + : await openGeometryReplayContext(browser, first, unexpectedNetworkRequests); + for (const captureId of captureIds) { + const capture = coverageByCaptureId.get(captureId); + if (!capture) continue; + const cards = yCardsByCapture.get(captureId) ?? []; + try { + const cardPage = await showGeometryCaptureStory(cardContext, capture); + for (const { detail } of cards) { + await cardPage.screenshot({ + path: path.join(outputDirectory, detail.images.clean), + clip: detail.clip, + animations: 'disabled', + caret: 'hide', + scale: 'device', + }); + await showOnlyDetailSemanticGuides(cardPage, detail); + await cardPage.screenshot({ + path: path.join(outputDirectory, detail.images.annotated), + clip: detail.clip, + animations: 'disabled', + caret: 'hide', + scale: 'device', + }); + } + await cardPage.close(); + } catch (error) { + skippedYCards.push( + `${captureId}: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}` + ); + for (const { detail } of cards) { + yCardDetails.splice(yCardDetails.indexOf(detail), 1); + detailCaptureIds.delete(detail.id); + } + } + } + if (!reusesMainContext) { + await cardContext.close().catch(() => undefined); + } + } + const qualityMetrics = computeGeometryQualityMetrics(persistedCapture, ledger); + const compiledContracts = compileGeometryContracts(ledger); + await writeFile(contractsPath, `${JSON.stringify(compiledContracts, null, 2)}\n`, 'utf8'); + const pixelWitnesses = await collectGeometryPixelWitnesses( + page, + outputDirectory, + persistedCapture, + compiledContracts + ); + const witnessDesignQuestions = pixelWitnesses.flatMap((witness) => + witness.inkCenters?.designQuestion ? [witness.inkCenters.designQuestion] : [] + ); + await writeFile( + path.join(outputDirectory, 'pixel-witnesses.json'), + `${JSON.stringify( + { + version: 1, + gate: false, + ...(witnessDesignQuestions.length > 0 ? { designQuestions: witnessDesignQuestions } : {}), + witnesses: pixelWitnesses, + }, + null, + 2 + )}\n`, + 'utf8' + ); + + const rawDetailByFindingKey = new Map( + [...details, ...yCardDetails].flatMap((detail) => + detail.findingKey ? [[detail.findingKey, detail] as const] : [] ) - ).toBe(true); - expect( - contractProposals.some( - (proposal) => - proposal.scopeKey.startsWith('session:') && - proposal.evidence.captureIds.length >= 3 && - proposal.evidence.captureCoverage > 0.5 + ); + const newFindingKeys = new Set(findingDiff.new.map((finding) => finding.key)); + const changedFindingKeys = new Set(findingDiff.changed.map((finding) => finding.key)); + const inkCentersByFindingKey = new Map( + pixelWitnesses.flatMap((witness) => + witness.findingKey && witness.inkCenters + ? [[witness.findingKey, witness.inkCenters] as const] + : [] ) - ).toBe(true); + ); + // The report shows the steady state, not only the delta: every finding in + // findings.json gets a card, and the ledger status says how it was reviewed. + const displayedDetails = persistedFindings.findings.flatMap((finding): ReportDetail[] => { + if (finding.kind === 'measurement-model-divergence') return []; + const evidence = finding.evidence[0]; + if (!evidence) return []; + let representative = rawDetailByFindingKey.get(finding.key); + if (!representative) { + const overviewEvidence = finding.evidence.flatMap((item) => { + const overview = detailsByCaptureId + .get(item.captureId) + ?.find((detail) => detail.kind === 'overview'); + return overview ? [{ captureId: item.captureId, overview, item }] : []; + })[0]; + if (!overviewEvidence) return []; + const { overview } = overviewEvidence; + const id = `finding-${finding.key.split('/').at(-1) ?? finding.key}`; + representative = { + kind: 'candidate', + requiresReview: finding.classification !== 'optical-residual', + findingKey: finding.key, + id, + title: `${finding.surfaceFamily} · ${finding.label}`, + description: `${finding.label} · stable locator finding`, + finding: '', + clip: overview.clip, + images: overview.images, + overlay: { + alignmentGroups: [], + baselineGroups: [], + hoverActions: false, + semanticAnnotations: [ + { + label: finding.label, + axis: finding.axis, + coordinate: overviewEvidence.item.coordinate, + line: overviewEvidence.item.line, + offset: overviewEvidence.item.offset, + rect: + finding.axis === 'y' + ? { + x: overviewEvidence.item.xStart ?? 0, + y: overviewEvidence.item.yStart, + width: Math.max( + 1, + (overviewEvidence.item.xEnd ?? 0) - (overviewEvidence.item.xStart ?? 0) + ), + height: Math.max( + 1, + overviewEvidence.item.yEnd - overviewEvidence.item.yStart + ), + } + : { + x: overviewEvidence.item.coordinate - 1, + y: overviewEvidence.item.yStart, + width: 2, + height: Math.max( + 1, + overviewEvidence.item.yEnd - overviewEvidence.item.yStart + ), + }, + tone: 'candidate', + }, + ], + discoveredRails: [], + }, + }; + detailCaptureIds.set(id, overviewEvidence.captureId); + } + const direction = formatDirectionalOffset(finding.axis, finding.offset); + const explanation = evidence.explanation; + const explainContribution = ( + label: string, + measuredPath: NonNullable['memberPath'] + ) => { + const terms = Object.entries(measuredPath.contribution) + .filter(([, value]) => Math.abs(value) >= 0.01) + .map(([name, value]) => `${name} ${Number(value.toFixed(2))}`) + .join(' + '); + return `${label} ${Number(measuredPath.distance.toFixed(2))}px${terms ? ` = ${terms}` : ''}`; + }; + const boxModelFinding = explanation + ? ` · 盒模型:${explainContribution('本项', explanation.memberPath)};${explainContribution( + '参照', + explanation.referencePath + )};residual ${Number(explanation.residual.toFixed(2))}px` + : ''; + const classification = finding.classification ?? 'structural'; + const repairProposal = finding.repairProposal + ? formatRepairProposal(finding.repairProposal) + : undefined; + const repairFinding = repairProposal ? ` · ${repairProposal}` : ''; + const dimensionSensitivity = (finding.dimensionSensitivity ?? []).map( + (item) => `${item.axis}=${item.value}` + ); + const dimensionFinding = + dimensionSensitivity.length > 0 ? ` · 仅出现在 ${dimensionSensitivity.join('、')}` : ''; + const ledgerStatus: GeometryLedgerStatus | 'changed' = newFindingKeys.has(finding.key) + ? 'new' + : changedFindingKeys.has(finding.key) + ? 'changed' + : (ledger.findings[finding.key]?.status ?? 'new'); + const baseline = ledger.findings[finding.key]?.baseline?.offset; + const inkCenters = inkCentersByFindingKey.get(finding.key); + const inkCenterWitness = inkCenters + ? `墨迹中心(非门禁证人,中位数 ${inkCenters.medianInkCenter}px):${inkCenters.members + .map( + (member) => `${member.label} ${formatDirectionalOffset('x', member.inkCenterOffset)}` + ) + .join('、')}${inkCenters.designQuestion ? ` · ${inkCenters.designQuestion}` : ''}` + : undefined; + return [ + { + ...representative, + requiresReview: classification !== 'optical-residual', + classification, + ledgerStatus, + ...(baseline === undefined ? {} : { baselineOffset: baseline }), + currentOffset: finding.offset, + captureCount: finding.captureCount, + totalCaptureCount: finding.totalCaptureCount, + ...(dimensionSensitivity.length > 0 ? { dimensionSensitivity } : {}), + ...(repairProposal ? { repairProposal } : {}), + ...(inkCenterWitness ? { inkCenterWitness } : {}), + description: `${CLASSIFICATION_LABELS[classification]} · ${finding.label} · ${finding.captureCount}/${finding.totalCaptureCount} 个捕获一致`, + finding: `[${CLASSIFICATION_LABELS[classification]}] ${finding.label} ${DISCOVERY_ANCHOR_LABELS[finding.anchor as keyof typeof DISCOVERY_ANCHOR_LABELS] ?? finding.anchor} ${direction} · ${finding.evidence.length} 条 evidence${repairFinding}${dimensionFinding}${boxModelFinding}`, + }, + ]; + }); + displayedDetails.push(...details.filter((detail) => detail.kind === 'measurement-model')); const reportData = { generatedAt: new Date().toISOString(), @@ -1741,16 +2833,71 @@ test('captures the visual geometry report', async ({ browser }) => { captures: coverageCaptures, exclusions: GEOMETRY_COVERAGE_EXCLUSIONS, }, - discoverySurfaces, + discoverySurfaces: discoverySurfaces.map((surface) => ({ + ...surface, + railDiscovery: surface.railDiscovery.map((scope) => ({ + ...scope, + capturedScope: (() => { + // The embedded payload is what the browser parses on open: the raw + // box-model nodes and Y candidates belong to capture.json, which the + // pipeline reads, not to the page a reviewer scrolls. + const { + boxModelNodes: _boxModelNodes, + blockCandidates: _blockCandidates, + ...capturedScope + } = scope.capturedScope; + return capturedScope; + })(), + })), + })), contractProposals, + findingDiff, + yAxisParity, + markerRemoval, + ...(skippedYCards.length > 0 ? { skippedYCards } : {}), + qualityMetrics, + ledger, + compiledContracts, + pixelWitnesses, geometryViolations, - details: details.map( - ({ kind, requiresReview, id, title, description, finding, clip, images }) => { + details: displayedDetails.map( + ({ + kind, + requiresReview, + classification, + findingKey, + ledgerStatus, + baselineOffset, + currentOffset, + captureCount, + totalCaptureCount, + dimensionSensitivity, + repairProposal, + inkCenterWitness, + id, + title, + description, + finding, + clip, + images, + }) => { const captureId = detailCaptureIds.get(id); if (!captureId) throw new Error(`Geometry detail ${id} has no replayable capture`); return { kind, requiresReview, + ...(classification ? { classification } : {}), + ...(findingKey ? { findingKey } : {}), + ...(ledgerStatus ? { ledgerStatus } : {}), + ...(baselineOffset === undefined ? {} : { baselineOffset }), + ...(currentOffset === undefined ? {} : { currentOffset }), + ...(captureCount === undefined ? {} : { captureCount }), + ...(totalCaptureCount === undefined ? {} : { totalCaptureCount }), + ...(dimensionSensitivity && dimensionSensitivity.length > 0 + ? { dimensionSensitivity } + : {}), + ...(repairProposal ? { repairProposal } : {}), + ...(inkCenterWitness ? { inkCenterWitness } : {}), id, captureId, title, @@ -1768,6 +2915,8 @@ test('captures the visual geometry report', async ({ browser }) => { `${JSON.stringify(reportData, null, 2)}\n`, 'utf8' ); - expect(unexpectedNetworkRequests).toEqual([]); + if (unexpectedNetworkRequests.length > 0) { + throw new Error(`Unexpected network requests: ${unexpectedNetworkRequests.join(', ')}`); + } await context.close(); }); diff --git a/packages/components/tests/e2e/chat-workspace-geometry.spec.ts b/packages/components/tests/e2e/chat-workspace-geometry.spec.ts index c157ed986..df328649e 100644 --- a/packages/components/tests/e2e/chat-workspace-geometry.spec.ts +++ b/packages/components/tests/e2e/chat-workspace-geometry.spec.ts @@ -1,3 +1,5 @@ +import { readFile } from 'node:fs/promises'; + import { expect, test } from '@playwright/test'; import { @@ -5,17 +7,26 @@ import { CHAT_WORKSPACE_GEOMETRY_SPEC, CHAT_WORKSPACE_RAIL_DISCOVERY_ATTRIBUTE, CHAT_WORKSPACE_SEMANTIC_ALIGNMENT_ATTRIBUTES, + type DiscoveredBlockRail, resolveMainPaneGridRange, validateChatWorkspaceGeometry, } from '../../src/lib/chat-workspace-geometry'; +import { + compileGeometryContracts, + type GeometryContractArtifact, + type GeometryLedger, +} from '../../src/lib/geometry-constraint-system'; import { auditChatWorkspaceSemanticAlignments, + captureChatWorkspaceGeometryScopes, auditChatWorkspaceSemanticBaselines, auditChatWorkspaceSpacing, discoverChatWorkspaceAlignmentRails, + discoverChatWorkspaceBlockRails, formatGeometryViolations, measureSettledChatWorkspace, requireGeometryRect, + validateCompiledGeometryContracts, } from './support/chat-workspace-geometry'; const STORY_IDS = { @@ -24,6 +35,31 @@ const STORY_IDS = { } as const; const storybookOrigin = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:6006'; +test('compiled promoted geometry contracts are current and pass by stable locator', async ({ + page, +}) => { + test.setTimeout(180_000); + const [ledgerText, contractsText] = await Promise.all([ + readFile(new URL('../../geometry-ledger.json', import.meta.url), 'utf8'), + readFile(new URL('../../geometry-contracts.json', import.meta.url), 'utf8'), + ]); + const ledger = JSON.parse(ledgerText) as GeometryLedger; + const contracts = JSON.parse(contractsText) as GeometryContractArtifact; + expect(contracts).toEqual(compileGeometryContracts(ledger)); + + const storyIds = [...new Set(contracts.contracts.map((contract) => contract.story))]; + const violations: string[] = []; + for (const storyId of storyIds) { + const response = await page.goto(`/iframe.html?id=${storyId}&viewMode=story`); + expect(response?.ok(), storyId).toBeTruthy(); + await expect(page.locator('[data-geometry-fixture-ready="true"]')).toBeAttached({ + timeout: 150_000, + }); + violations.push(...(await validateCompiledGeometryContracts(page, contracts, storyId))); + } + expect(violations).toEqual([]); +}); + for (const verificationCase of CHAT_WORKSPACE_GEOMETRY_SPEC.verificationCases) { test(`${verificationCase.name} satisfies the authenticated chat workspace contract`, async ({ browser, @@ -113,6 +149,32 @@ for (const verificationCase of CHAT_WORKSPACE_GEOMETRY_SPEC.verificationCases) { }); } +test('capture names a content-named tab exactly as Playwright resolves it', async ({ page }) => { + test.setTimeout(60_000); + const response = await page.goto( + '/iframe.html?id=sessions-sessionsidepaneltabbar--geometry-report&viewMode=story' + ); + expect(response?.ok()).toBeTruthy(); + await expect(page.getByRole('tab').first()).toBeVisible({ timeout: 30_000 }); + + const scopes = await captureChatWorkspaceGeometryScopes(page, { + aggregateScopes: ['session.side-panel'], + }); + const tabLocators = scopes.flatMap((scope) => + scope.candidates.flatMap((candidate) => + candidate.locator.role === 'tab' ? [candidate.locator] : [] + ) + ); + expect(tabLocators.length).toBeGreaterThan(0); + // The Files tab used to reach findings with no name at all, because naming + // only read aria-label/title. It is now named from its own content. + const filesTab = tabLocators.find((locator) => locator.name?.startsWith('Files')); + expect(filesTab?.name).toBeDefined(); + // Capture and gate must agree: the same name resolves to exactly one element + // through Playwright's own role engine. + await expect(page.getByRole('tab', { name: filesTab?.name ?? '', exact: true })).toHaveCount(1); +}); + test('visible sidebar rails are inferred without geometry marker attributes', async ({ page }) => { test.setTimeout(60_000); const response = await page.goto( @@ -135,7 +197,42 @@ test('visible sidebar rails are inferred without geometry marker attributes', as for (const element of elements) element.removeAttribute(attribute); }, CHAT_WORKSPACE_RAIL_DISCOVERY_ATTRIBUTE); + // Outlier reporting is proven with a deviation this test injects itself, not with + // one standing in the product. Asserting a real misalignment here would make the + // gate fail the moment someone corrects it, which is exactly backwards. + const probeLabel = 'Geometry outlier probe'; + const probeShift = 5; + await page + .locator('[aria-label="Archive"]') + .first() + .evaluate( + (element, { label, shift }) => { + element.setAttribute('aria-label', label); + const style = (element as HTMLElement).style; + style.position = 'relative'; + style.left = `${shift}px`; + }, + { label: probeLabel, shift: probeShift } + ); + const discovery = await discoverChatWorkspaceAlignmentRails(page); + const capturedLocators = discovery.flatMap((scope) => + scope.capturedScope.candidates.map((candidate) => candidate.locator) + ); + // Accessible names now come from the shared accname subset, so every widget is + // named; only plain flow text still needs the structural fallback. + const unnamedLocators = capturedLocators.filter((locator) => !locator.name); + expect(unnamedLocators.length).toBeGreaterThan(0); + expect( + unnamedLocators.every( + (locator) => + Boolean(locator.rowFamily) && + Number.isInteger(locator.roleIndex) && + (locator.roleIndex ?? -1) >= 0 + ) + ).toBe(true); + const namedProbe = capturedLocators.find((locator) => locator.name === probeLabel); + expect(namedProbe?.role).toBe('button'); expect(discovery.filter((scope) => scope.source === 'hint')).toEqual([]); const autoScopes = discovery.filter((scope) => scope.source === 'auto'); expect( @@ -146,25 +243,115 @@ test('visible sidebar rails are inferred without geometry marker attributes', as (rail) => rail.anchor === 'inline-end' && rail.space === 'ink' && - rail.members.some((member) => member.elementId.endsWith(':Archive')) && - rail.outliers.some((member) => member.elementId.endsWith(':Remove project')) + rail.members.some( + (member) => (member as typeof member & { label?: string }).label === 'Archive' + ) && + rail.outliers.some( + (member) => (member as typeof member & { label?: string }).label === probeLabel + ) ); expect(trailingRail).toMatchObject({ anchor: 'inline-end', space: 'ink', }); + // The unshifted siblings still hold the line the probe departed from. expect(trailingRail?.support).toBeGreaterThanOrEqual(3); - expect( - trailingRail?.outliers.some( - (member) => member.elementId.endsWith(':Remove project') && Math.abs(member.delta) > 1 - ) - ).toBe(true); - expect( - autoRails.some((rail) => - rail.members.some((member) => member.elementId.includes('Toggle local projects')) - ) - ).toBe(false); + const probeOutlier = trailingRail?.outliers.find( + (member) => (member as typeof member & { label?: string }).label === probeLabel + ); + expect(probeOutlier?.delta).toBeGreaterThan(probeShift - 1); + expect(probeOutlier?.delta).toBeLessThan(probeShift + 1); +}); + +test('vertical row alignment is discovered without geometry marker attributes', async ({ + page, +}) => { + test.setTimeout(60_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, + }); + + 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); + await page + .locator(`[${CHAT_WORKSPACE_RAIL_DISCOVERY_ATTRIBUTE}]`) + .evaluateAll((elements, attribute) => { + for (const element of elements) element.removeAttribute(attribute); + }, CHAT_WORKSPACE_RAIL_DISCOVERY_ATTRIBUTE); + + // Only the rows capture forms from rendered geometry are compared before and + // after: an automatic topology scope is keyed by a structural hash, so moving + // a glyph inside it may legitimately rename its rows. + const visualRows = (rails: readonly DiscoveredBlockRail[]) => + rails.filter((rail) => rail.anchor === 'visual-center' && rail.rowId.startsWith('visual-row:')); + const outliersByRow = (rails: readonly DiscoveredBlockRail[]) => + new Map( + visualRows(rails).map((rail) => [ + rail.rowId, + rail.outliers + .map((member) => (member as typeof member & { primitiveId?: string }).primitiveId ?? '') + .sort() + .join(','), + ]) + ); + + const before = await discoverChatWorkspaceBlockRails(page); + expect(visualRows(before).length).toBeGreaterThan(0); + // An odd, tight row: the median is then one member's coordinate, so a shift + // moves the probe and not the line it is measured against. + const target = visualRows(before).find( + (rail) => + rail.sampleSize >= 3 && + rail.sampleSize % 2 === 1 && + rail.spread <= 1 && + rail.members.some((member) => member.kind === 'svg' && !member.outlier) + ); + const probe = target?.members.find((member) => member.kind === 'svg' && !member.outlier) as + | (NonNullable['members'][number] & { primitiveId?: string }) + | undefined; + expect(target, 'no odd, tight visual row with an aligned icon to probe').toBeDefined(); + expect(probe?.primitiveId).toBeDefined(); + + const probeShift = 3; + 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: probe?.primitiveId ?? '', shift: probeShift } + ); + + const after = await discoverChatWorkspaceBlockRails(page); + const probedRow = visualRows(after).find((rail) => rail.rowId === target?.rowId); + const probedMember = probedRow?.members.find( + (member) => + (member as typeof member & { primitiveId?: string }).primitiveId === probe?.primitiveId + ); + expect(probedMember?.outlier).toBe(true); + const probedOffset = Math.abs((probedMember?.coordinate ?? 0) - (probedRow?.line ?? 0)); + expect(probedOffset).toBeGreaterThan(probeShift - 1); + expect(probedOffset).toBeLessThan(probeShift + 1); + + // Only that row moved. Comparing the two runs, rather than asserting which + // product rows are aligned, keeps the gate from failing when a real vertical + // offset elsewhere is fixed. + const changedRows = [...outliersByRow(after).entries()].filter( + ([rowId, outliers]) => outliersByRow(before).get(rowId) !== outliers + ); + expect(changedRows.map(([rowId]) => rowId)).toEqual([target?.rowId]); }); if (process.env.GEOMETRY_DIAGNOSTIC_AUDIT === '1') { diff --git a/packages/components/tests/e2e/support/AGENTS.md b/packages/components/tests/e2e/support/AGENTS.md new file mode 100644 index 000000000..56f4d0882 --- /dev/null +++ b/packages/components/tests/e2e/support/AGENTS.md @@ -0,0 +1,90 @@ +# Geometry browser measurement + +`CLAUDE.md` is a symlink to this file. Edit `AGENTS.md` only. +[`tests/e2e/AGENTS.md`](../AGENTS.md), the package and the repository root also apply. + +`chat-workspace-geometry.ts` is the only place the geometry system touches a live page. +Capture and the Playwright gate share every function here — a finding must never be named, +measured or resolved by a rule the other stage cannot reproduce. Functions serialized into +the page (`installGeometryBrowserHelpers`) stay closure-free, including the pure +`src/lib/geometry-text-cap-band.ts` helpers the `?geometry=1` overlay imports directly, so +the overlay and capture cannot disagree about one row. + +## What a primitive is measured from + +- Ink is text `Range` bounds, transformed SVG path bounds, a painted image, or a painted + CSS SHAPE's border box: a rendered leaf with no text, a non-transparent background or + border and at most 24px in both dimensions — a status dot is ink a reader aligns + against, and no glyph, path or image describes it. A layout box is + `getBoundingClientRect`; a padded control, a container or a form control is a layout-box + observation, never an ink one. A `