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);