diff --git a/package.json b/package.json
index 0461b7149..2dc09d320 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"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",
+ "geometry:verify-fix": "corepack pnpm --filter @lody/components geometry:verify-fix",
"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/geometry-ledger.json b/packages/components/geometry-ledger.json
index 35e2e3214..6864c2841 100644
--- a/packages/components/geometry-ledger.json
+++ b/packages/components/geometry-ledger.json
@@ -9,7 +9,7 @@
},
"findings": {
"geometry/right-sidebar/11pscs0": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 2
},
@@ -21,7 +21,7 @@
}
},
"geometry/right-sidebar/171u7h8": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 1
},
@@ -33,7 +33,7 @@
}
},
"geometry/right-sidebar/177rmie": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 2
},
@@ -45,9 +45,9 @@
}
},
"geometry/right-sidebar/1e5wvsl": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
- "offset": 4
+ "offset": 6.5
},
"identity": {
"label": "Files",
@@ -57,7 +57,7 @@
}
},
"geometry/right-sidebar/1ls7j8z": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 0.75
},
@@ -69,9 +69,9 @@
}
},
"geometry/right-sidebar/1mxwyl5": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
- "offset": -1
+ "offset": -1.5
},
"identity": {
"label": "app.tsx",
@@ -81,7 +81,7 @@
}
},
"geometry/right-sidebar/1r5c99a": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 1
},
@@ -93,7 +93,7 @@
}
},
"geometry/right-sidebar/1simiqu": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": -1
},
@@ -105,7 +105,7 @@
}
},
"geometry/right-sidebar/1vnwrqv": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 0.75
},
@@ -117,7 +117,7 @@
}
},
"geometry/right-sidebar/57h0zw": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": -1.5
},
@@ -129,9 +129,9 @@
}
},
"geometry/right-sidebar/6ijj9r": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
- "offset": 0.75
+ "offset": 1
},
"identity": {
"label": "Docs 2 + 100 − 20",
@@ -141,7 +141,7 @@
}
},
"geometry/right-sidebar/6q3vy3": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": -1.5
},
@@ -153,7 +153,7 @@
}
},
"geometry/right-sidebar/8s2gxh": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 2
},
@@ -165,9 +165,9 @@
}
},
"geometry/right-sidebar/b0m4s": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
- "offset": 6.5
+ "offset": 4
},
"identity": {
"label": "Files",
@@ -177,7 +177,7 @@
}
},
"geometry/right-sidebar/b8dyq5": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 0.75
},
@@ -189,7 +189,7 @@
}
},
"geometry/right-sidebar/d3cw9u": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 0.75
},
@@ -201,7 +201,7 @@
}
},
"geometry/right-sidebar/d4c4q": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 0.75
},
@@ -213,9 +213,9 @@
}
},
"geometry/right-sidebar/daf70p": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
- "offset": -1
+ "offset": -1.5
},
"identity": {
"label": "Conversation Diff",
@@ -225,7 +225,7 @@
}
},
"geometry/right-sidebar/gvz5jb": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": -0.75
},
@@ -237,7 +237,7 @@
}
},
"geometry/right-sidebar/hdgowf": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 2
},
@@ -249,7 +249,7 @@
}
},
"geometry/right-sidebar/im7aci": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 0.75
},
@@ -261,7 +261,7 @@
}
},
"geometry/right-sidebar/kbsjam": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": -1
},
@@ -273,7 +273,7 @@
}
},
"geometry/right-sidebar/l4kgf": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 0.75
},
@@ -285,7 +285,7 @@
}
},
"geometry/right-sidebar/nerzug": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 1
},
@@ -297,7 +297,7 @@
}
},
"geometry/right-sidebar/u9ipw4": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 0.75
},
@@ -309,7 +309,7 @@
}
},
"geometry/right-sidebar/vvedhu": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": -0.75
},
@@ -321,7 +321,7 @@
}
},
"geometry/right-sidebar/x7rij5": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 1
},
@@ -333,7 +333,7 @@
}
},
"geometry/right-sidebar/xrp5i4": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 0.75
},
@@ -345,7 +345,7 @@
}
},
"geometry/session/1aje3gt": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 1.25
},
@@ -357,7 +357,7 @@
}
},
"geometry/session/1euuz5v": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": -3
},
@@ -369,7 +369,7 @@
}
},
"geometry/session/1p4pgog": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": -3
},
@@ -381,9 +381,9 @@
}
},
"geometry/session/1t257v2": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
- "offset": -1.25
+ "offset": -1
},
"identity": {
"label": "Private to you: lody is not shared with the team.",
@@ -393,7 +393,7 @@
}
},
"geometry/session/1xffq47": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": -1
},
@@ -405,7 +405,7 @@
}
},
"geometry/session/1y86m8m": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 25
},
@@ -417,7 +417,7 @@
}
},
"geometry/session/37kibv": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": -3
},
@@ -429,7 +429,7 @@
}
},
"geometry/session/5875hb": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 2.5
},
@@ -441,7 +441,7 @@
}
},
"geometry/session/88iul9": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 9.5
},
@@ -453,7 +453,7 @@
}
},
"geometry/session/9slq61": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 3
},
@@ -465,7 +465,7 @@
}
},
"geometry/session/i4jvzg": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": -1
},
@@ -477,7 +477,7 @@
}
},
"geometry/session/le2uqr": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": -3
},
@@ -489,7 +489,7 @@
}
},
"geometry/session/nu8q04": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 1.25
},
@@ -501,7 +501,7 @@
}
},
"geometry/session/pothvh": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": -1.25
},
@@ -513,7 +513,7 @@
}
},
"geometry/session/r7rfec": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 1.5
},
@@ -525,7 +525,7 @@
}
},
"geometry/session/wy7fax": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 12.5
},
@@ -537,7 +537,7 @@
}
},
"geometry/session/y2yn1n": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": -1.5
},
@@ -549,7 +549,7 @@
}
},
"geometry/session/zhdd1c": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 9.5
},
@@ -561,7 +561,7 @@
}
},
"geometry/workspace/14n6pir": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 1
},
@@ -573,9 +573,9 @@
}
},
"geometry/workspace/1fccih": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
- "offset": 1.75
+ "offset": -4
},
"identity": {
"label": "Machine",
@@ -585,9 +585,9 @@
}
},
"geometry/workspace/1jrw0q7": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
- "offset": -0.75
+ "offset": -1
},
"identity": {
"label": "More actions",
@@ -597,7 +597,7 @@
}
},
"geometry/workspace/1lbwj6e": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": -1.5
},
@@ -609,7 +609,7 @@
}
},
"geometry/workspace/1my7gbo": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": -2.5
},
@@ -621,7 +621,7 @@
}
},
"geometry/workspace/1oezpj5": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 5.269230769230769
},
@@ -633,7 +633,7 @@
}
},
"geometry/workspace/1p79krt": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": -4
},
@@ -645,9 +645,9 @@
}
},
"geometry/workspace/1t2nsoi": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
- "offset": -0.8181818181818182
+ "offset": -1
},
"identity": {
"label": "More actions",
@@ -657,7 +657,7 @@
}
},
"geometry/workspace/1y8h8dm": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": -1.375
},
@@ -669,7 +669,7 @@
}
},
"geometry/workspace/1ysr2o": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": -0.75
},
@@ -681,7 +681,7 @@
}
},
"geometry/workspace/23ysa1": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 1.5
},
@@ -693,7 +693,7 @@
}
},
"geometry/workspace/3qj791": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": -4.076923076923077
},
@@ -705,7 +705,7 @@
}
},
"geometry/workspace/4gx82n": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 2.25
},
@@ -717,7 +717,7 @@
}
},
"geometry/workspace/4ik93p": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": -3.375
},
@@ -729,7 +729,7 @@
}
},
"geometry/workspace/5p59kz": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 5
},
@@ -741,7 +741,7 @@
}
},
"geometry/workspace/6bmnyr": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": -1.8125
},
@@ -753,7 +753,7 @@
}
},
"geometry/workspace/bu6kch": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 1
},
@@ -765,9 +765,9 @@
}
},
"geometry/workspace/cqs9ix": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
- "offset": -0.8181818181818182
+ "offset": -1
},
"identity": {
"label": "More actions",
@@ -777,7 +777,7 @@
}
},
"geometry/workspace/fi8gpa": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": -3.375
},
@@ -789,7 +789,7 @@
}
},
"geometry/workspace/h1bkqs": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": -4
},
@@ -801,7 +801,7 @@
}
},
"geometry/workspace/hg2c2f": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": -4
},
@@ -813,7 +813,7 @@
}
},
"geometry/workspace/k13zs1": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 3
},
@@ -825,9 +825,9 @@
}
},
"geometry/workspace/ky1uql": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
- "offset": 5.269230769230769
+ "offset": 12.5
},
"identity": {
"label": "Message",
@@ -837,7 +837,7 @@
}
},
"geometry/workspace/lkcnsw": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 1.8125
},
@@ -886,7 +886,7 @@
}
},
"geometry/workspace/mcouew": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": -1
},
@@ -898,7 +898,7 @@
}
},
"geometry/workspace/qltk66": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": -1
},
@@ -946,7 +946,7 @@
}
},
"geometry/workspace/u25p0b": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
"offset": 2.5
},
@@ -958,9 +958,9 @@
}
},
"geometry/workspace/w5fbub": {
- "status": "accepted-debt",
+ "status": "debt",
"baseline": {
- "offset": -4.076923076923077
+ "offset": -4
},
"identity": {
"label": "Machine",
diff --git a/packages/components/package.json b/packages/components/package.json
index 8f0d8e874..2094a224d 100644
--- a/packages/components/package.json
+++ b/packages/components/package.json
@@ -64,6 +64,7 @@
"test:e2e:ui": "playwright test --ui",
"test:geometry": "playwright test tests/e2e/chat-workspace-geometry.spec.ts --workers=1 --retries=0",
"geometry:report": "node scripts/generate-chat-workspace-geometry-report.mjs",
+ "geometry:verify-fix": "node scripts/verify-geometry-fix.mjs",
"geometry:report:open": "node scripts/generate-chat-workspace-geometry-report.mjs --open",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
diff --git a/packages/components/scripts/templates/chat-workspace-geometry-report.html b/packages/components/scripts/templates/chat-workspace-geometry-report.html
index 257cc0087..b9da69e6b 100644
--- a/packages/components/scripts/templates/chat-workspace-geometry-report.html
+++ b/packages/components/scripts/templates/chat-workspace-geometry-report.html
@@ -657,12 +657,23 @@
明确排除
const STATUS_LABELS = {
new: '新增',
changed: '偏移变化',
- 'accepted-debt': '已接受债务',
+ debt: '待修复债务',
+ 'wont-fix': '不修复',
+ fixed: '已修复',
promoted: '已提升为 Contract',
ignored: '已忽略',
unreviewed: '未纳入 ledger',
};
- const STATUS_ORDER = ['new', 'changed', 'accepted-debt', 'promoted', 'ignored', 'unreviewed'];
+ const STATUS_ORDER = [
+ 'new',
+ 'changed',
+ 'debt',
+ 'wont-fix',
+ 'fixed',
+ 'promoted',
+ 'ignored',
+ 'unreviewed',
+ ];
const CLASSIFICATION_ORDER = ['css-defect', 'structural', 'optical-residual', ''];
const CLASSIFICATION_LABELS = {
'css-defect': 'CSS 缺陷',
@@ -674,11 +685,15 @@ 明确排除
// 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.
+ // A `wont-fix` finding is a decision, and a `fixed` one is already done:
+ // neither is work, so neither belongs in the queue however it classifies.
const isDefaultDetail = (detail) =>
- detailStatus(detail) === 'new' ||
- detailStatus(detail) === 'changed' ||
- detailStatus(detail) === 'promoted' ||
- detail.classification === 'css-defect';
+ detailStatus(detail) !== 'wont-fix' &&
+ detailStatus(detail) !== 'fixed' &&
+ (detailStatus(detail) === 'new' ||
+ detailStatus(detail) === 'changed' ||
+ detailStatus(detail) === 'promoted' ||
+ detail.classification === 'css-defect');
const statusCounts = new Map();
for (const detail of report.details) {
@@ -698,7 +713,9 @@ 明确排除
`status:${status}`,
STATUS_LABELS[status],
statusCounts.get(status) ?? 0,
- status === 'new' || status === 'changed' ? 'dot--candidate' : 'dot--insufficient',
+ status === 'new' || status === 'changed' || status === 'debt'
+ ? 'dot--candidate'
+ : 'dot--insufficient',
]),
['css-defect', 'CSS 缺陷', cssDefects.length, 'dot--fail'],
['structural', '结构性', structural.length, 'dot--candidate'],
@@ -915,7 +932,14 @@ 明确排除
factsEl.append(span);
}
if (factsEl.childElementCount > 0) inner.append(factsEl);
- for (const text of [detail.repairProposal, detail.inkCenterWitness]) {
+ // One card per REPAIR: the findings folded into it are named, so a
+ // reviewer can see they were reviewed rather than dropped.
+ const foldedKeys = detail.repairGroupKeys ?? [];
+ const foldedText =
+ foldedKeys.length > 0
+ ? `同一处修复覆盖 ${foldedKeys.length + 1} 条 finding:${foldedKeys.join('、')}`
+ : undefined;
+ for (const text of [detail.repairProposal, foldedText, detail.inkCenterWitness]) {
if (!text) continue;
const paragraph = document.createElement('p');
paragraph.className = 'detail-repair';
diff --git a/packages/components/scripts/triage-geometry-findings.mjs b/packages/components/scripts/triage-geometry-findings.mjs
index 7224d46e6..4ec26cdbd 100644
--- a/packages/components/scripts/triage-geometry-findings.mjs
+++ b/packages/components/scripts/triage-geometry-findings.mjs
@@ -7,9 +7,12 @@ 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');
+// New entries land on `debt`, never `wont-fix`. Telling "we will fix this" from
+// "this is not a defect" IS the review; a tool that guessed would be recording
+// a decision nobody made.
+const status = statusArgument?.slice('--status='.length) ?? 'debt';
+if (status !== 'new' && status !== 'debt') {
+ throw new Error('--status must be new or debt');
}
const reportDirectory = path.resolve(packageRoot, requestedReport ?? 'geometry-report');
diff --git a/packages/components/scripts/verify-geometry-fix.mjs b/packages/components/scripts/verify-geometry-fix.mjs
new file mode 100644
index 000000000..d912a2678
--- /dev/null
+++ b/packages/components/scripts/verify-geometry-fix.mjs
@@ -0,0 +1,127 @@
+import { readFile, writeFile } from 'node:fs/promises';
+import { spawn } from 'node:child_process';
+import { createServer } from 'node:net';
+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 positional = argumentsList.filter((argument) => !argument.startsWith('--'));
+const repairGroupArgument = argumentsList.find((argument) => argument.startsWith('--repair-group='));
+const ledgerArgument = argumentsList.find((argument) => argument.startsWith('--ledger='));
+const repairGroup = repairGroupArgument?.slice('--repair-group='.length);
+const [requestedOutput, ...requestedKeys] = positional;
+
+if (!requestedOutput || (requestedKeys.length === 0 && !repairGroup)) {
+ throw new Error(
+ 'Usage: pnpm geometry:verify-fix [findingKey...] [--repair-group=] [--ledger=]'
+ );
+}
+
+const outputDirectory = path.resolve(packageRoot, requestedOutput);
+const ledgerPath = ledgerArgument
+ ? path.resolve(packageRoot, ledgerArgument.slice('--ledger='.length))
+ : path.join(packageRoot, 'geometry-ledger.json');
+
+function portIsAvailable(port) {
+ return new Promise((resolve) => {
+ const server = createServer();
+ server.once('error', () => resolve(false));
+ server.listen(port, '127.0.0.1', () => server.close(() => resolve(true)));
+ });
+}
+
+async function resolvePort() {
+ if (process.env.GEOMETRY_REPORT_PORT) return Number(process.env.GEOMETRY_REPORT_PORT);
+ for (let port = 6100; port < 6120; port += 1) {
+ if (await portIsAvailable(port)) return port;
+ }
+ throw new Error('No available Storybook port in the range 6100-6119');
+}
+
+function run(command, arguments_, options = {}) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(command, arguments_, {
+ cwd: packageRoot,
+ env: process.env,
+ stdio: 'inherit',
+ ...options,
+ });
+ child.once('error', reject);
+ child.once('exit', (code, signal) => {
+ if (code === 0) {
+ resolve();
+ return;
+ }
+ reject(
+ new Error(
+ `${command} exited with ${signal ? `signal ${signal}` : `status ${String(code)}`}`
+ )
+ );
+ });
+ });
+}
+
+// The ratchet gate IS the verification run: it walks the same capture plan with
+// no screenshots and fails if any OTHER finding regressed, so "did this fix
+// land" and "did it cost something elsewhere" are one measurement rather than
+// two runs that could disagree. The decision itself is made in TypeScript
+// beside the ratchet; this script only applies the ledger it writes out.
+const port = await resolvePort();
+const pnpmCommand = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
+await run(
+ pnpmCommand,
+ [
+ 'exec',
+ 'playwright',
+ 'test',
+ 'tests/e2e/chat-workspace-geometry.spec.ts',
+ '--grep',
+ 'reviewed ledger baseline',
+ '--workers=1',
+ '--retries=0',
+ '--reporter=line',
+ ],
+ {
+ env: {
+ ...process.env,
+ GEOMETRY_PIPELINE_OUTPUT_DIR: outputDirectory,
+ GEOMETRY_VERIFY_FIX_KEYS: requestedKeys.join(','),
+ ...(repairGroup ? { GEOMETRY_VERIFY_FIX_REPAIR_GROUP: repairGroup } : {}),
+ PLAYWRIGHT_PORT: String(port),
+ PLAYWRIGHT_BASE_URL: `http://127.0.0.1:${port}`,
+ VITE_PREVIEW_PUBLIC_BASE_DOMAIN:
+ process.env.VITE_PREVIEW_PUBLIC_BASE_DOMAIN ?? 'local.invalid',
+ ...(process.platform === 'darwin' && process.env.PLAYWRIGHT_USE_SYSTEM_CHROME == null
+ ? { PLAYWRIGHT_USE_SYSTEM_CHROME: '1' }
+ : {}),
+ },
+ }
+);
+
+const verificationPath = path.join(outputDirectory, 'fix-verification.json');
+const verification = JSON.parse(await readFile(verificationPath, 'utf8'));
+if (verification.version !== 1 || !Array.isArray(verification.verifications)) {
+ throw new Error(`Unsupported geometry fix verification: ${verificationPath}`);
+}
+
+for (const item of verification.verifications) {
+ const measured = item.resolved
+ ? 'no longer reported by any rail'
+ : `|offset| ${Math.abs(item.offset).toFixed(3)}px`;
+ console.log(
+ `${item.passed ? 'FIXED' : 'STILL OFF'} ${item.key}\n ${item.label}\n ${measured}${
+ item.reason ? `\n ${item.reason}` : ''
+ }`
+ );
+}
+
+if (!verification.passed) {
+ console.error('\nGeometry ledger unchanged: not every requested finding is fixed.');
+ process.exitCode = 1;
+} else {
+ await writeFile(ledgerPath, `${JSON.stringify(verification.ledger, null, 2)}\n`, 'utf8');
+ console.log(
+ `\nGeometry ledger: marked ${verification.verifications.length} finding(s) fixed in ${ledgerPath}`
+ );
+}
diff --git a/packages/components/src/lib/geometry-constraint-system.ts b/packages/components/src/lib/geometry-constraint-system.ts
index c08e5e02e..659b3a614 100644
--- a/packages/components/src/lib/geometry-constraint-system.ts
+++ b/packages/components/src/lib/geometry-constraint-system.ts
@@ -69,6 +69,14 @@ export type GeometryBoxModelContribution = Readonly<{
export type GeometryBoxModelPathStep = Readonly<{
nodeId: string;
element: string;
+ /**
+ * The node's `class` attribute verbatim, and the nearest function component
+ * above it in the React fiber tree. Both are LABELS on a repair ticket, so
+ * that an agent handed a finding can open a file instead of hunting a
+ * rendered DOM description; neither may ever reach a finding key.
+ */
+ className?: string;
+ component?: string;
parentId?: string;
startToParent: number;
endToParent: number;
@@ -282,6 +290,9 @@ export type GeometryRepairTerm = Readonly<{
term: GeometryDeclaredBoxModelTerm;
/** Rendered description of the box-model node that owns the differing term. */
element: string;
+ /** That node's `class` attribute and owning component: source pointers, not identity. */
+ className?: string;
+ component?: string;
memberElement?: string;
referenceElement?: string;
memberValue: number;
@@ -292,8 +303,17 @@ export type GeometryRepairTerm = Readonly<{
export type GeometryRepairProposal = Readonly<{
commonAncestor: string;
+ /** The common ancestor's own source pointers, on the same evidence footing. */
+ className?: string;
+ component?: string;
edge: 'inline-start' | 'inline-end' | 'block-start' | 'block-end';
terms: readonly GeometryRepairTerm[];
+ /**
+ * Which repair this proposal IS, independent of which element reported it.
+ * Findings never merge on it — the key is untouched — but a report folds one
+ * card per group, so ten rows sharing one wrong padding read as one ticket.
+ */
+ repairGroup?: string;
}>;
export type GeometryOffsetExplanation = Readonly<{
@@ -368,6 +388,12 @@ export type GeometryFinding = Readonly<{
/** Alignment-rail findings only; derived from evidence explanations alone. */
classification?: GeometryFindingClassification;
repairProposal?: GeometryRepairProposal;
+ /**
+ * Which REPAIR this finding belongs to, for `css-defect` findings that name
+ * one. Grouping only: the key, the evidence and the review are untouched, and
+ * a finding without a repair proposal simply has none.
+ */
+ repairGroup?: string;
/** Set when every evidence row shares one value of a varying capture axis. */
dimensionSensitivity?: readonly GeometryDimensionSensitivity[];
evidence: readonly GeometryFindingEvidence[];
@@ -378,7 +404,35 @@ export type GeometryFindingArtifact = Readonly<{
findings: readonly GeometryFinding[];
}>;
-export type GeometryLedgerStatus = 'new' | 'accepted-debt' | 'ignored' | 'promoted';
+/**
+ * How a human reviewed one finding.
+ *
+ * `accepted-debt` used to carry two different decisions under one word — "this
+ * is wrong and we will fix it later" and "this is not a defect" — so a queue
+ * built from it mixed work with non-work. They are `debt` and `wont-fix` now.
+ * `fixed` is the other end of the same flywheel: a finding measured back to
+ * zero, re-baselined there, and therefore held to the STRICTEST ratchet of
+ * all — reopening it is a regression, not a new review.
+ *
+ * Only `promoted` compiles into a contract. `new`, `debt`, `wont-fix`, `fixed`
+ * and `ignored` compile into nothing; what separates them is the ratchet,
+ * which skips `ignored` and holds every other status to its baseline.
+ */
+export type GeometryLedgerStatus =
+ | 'new'
+ | 'debt'
+ | 'wont-fix'
+ | 'fixed'
+ | 'ignored'
+ | 'promoted';
+
+/** Statuses the ratchet holds to their reviewed baseline. */
+export const GEOMETRY_RATCHETED_LEDGER_STATUSES: readonly GeometryLedgerStatus[] = [
+ 'new',
+ 'debt',
+ 'wont-fix',
+ 'fixed',
+];
/**
* The stylesheet is the single source of truth for a named geometry token. A
@@ -1088,7 +1142,7 @@ function proposeBoxModelRepair(
const dominantOwner = (
chain: readonly GeometryBoxModelPathStep[],
term: GeometryDeclaredBoxModelTerm
- ) => {
+ ): GeometryBoxModelPathStep | undefined => {
let bestIndex = -1;
let bestValue = 0;
chain.forEach((step, index) => {
@@ -1102,8 +1156,8 @@ function proposeBoxModelRepair(
// 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;
+ ? (chain[bestIndex - 1] ?? commonAncestor)
+ : chain[bestIndex];
};
const terms: GeometryRepairTerm[] = [];
for (const term of DECLARED_BOX_MODEL_TERMS) {
@@ -1111,33 +1165,57 @@ function proposeBoxModelRepair(
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 memberOwner = dominantOwner(memberChain, term);
+ const referenceOwner = dominantOwner(referenceChain, term);
const dominantSide = Math.abs(memberValue) >= Math.abs(referenceValue) ? 'member' : 'reference';
+ const owner =
+ (dominantSide === 'member' ? memberOwner : referenceOwner) ??
+ memberOwner ??
+ referenceOwner ??
+ commonAncestor;
terms.push({
side: dominantSide,
term,
- element:
- (dominantSide === 'member' ? memberElement : referenceElement) ??
- memberElement ??
- referenceElement ??
- commonAncestor.element,
- ...(memberElement ? { memberElement } : {}),
- ...(referenceElement ? { referenceElement } : {}),
+ element: owner.element,
+ ...(owner.className ? { className: owner.className } : {}),
+ ...(owner.component ? { component: owner.component } : {}),
+ ...(memberOwner ? { memberElement: memberOwner.element } : {}),
+ ...(referenceOwner ? { referenceElement: referenceOwner.element } : {}),
memberValue: Number(memberValue.toFixed(4)),
referenceValue: Number(referenceValue.toFixed(4)),
delta: Number(delta.toFixed(4)),
});
}
if (terms.length === 0) return undefined;
- return {
+ const sorted = terms.sort(
+ (left, right) =>
+ Math.abs(right.delta) - Math.abs(left.delta) || left.term.localeCompare(right.term)
+ );
+ const proposal: GeometryRepairProposal = {
commonAncestor: commonAncestor.element,
+ ...(commonAncestor.className ? { className: commonAncestor.className } : {}),
+ ...(commonAncestor.component ? { component: commonAncestor.component } : {}),
edge,
- terms: terms.sort(
- (left, right) =>
- Math.abs(right.delta) - Math.abs(left.delta) || left.term.localeCompare(right.term)
- ),
+ terms: sorted,
};
+ return { ...proposal, repairGroup: geometryRepairGroupKey(proposal) };
+}
+
+/**
+ * Repair identity: WHICH edit closes this, not which element reported it. The
+ * component that owns the edit (or, unrendered by React, the common ancestor's
+ * DOM description), the term, the edge, and the node the term sits on. It is a
+ * grouping label only — findings are never merged on it and no key reads it —
+ * so ten rows sharing one wrong padding stay ten reviewed findings and become
+ * one ticket.
+ */
+export function geometryRepairGroupKey(
+ proposal: Omit
+): string | undefined {
+ const dominant = proposal.terms[0];
+ if (!dominant) return undefined;
+ const owner = dominant.component ?? proposal.component ?? proposal.commonAncestor;
+ return makeFindingKey(['repair', owner, dominant.term, proposal.edge, dominant.element]);
}
function declaredTermDelta(explanation: GeometryOffsetExplanation): number {
@@ -2041,6 +2119,7 @@ export function createGeometryFindings(
totalCaptureCount: totalBySurface.get(group.surfaceFamily) ?? evidence.length,
classification,
...(repairProposal ? { repairProposal } : {}),
+ ...(repairProposal?.repairGroup ? { repairGroup: repairProposal.repairGroup } : {}),
...(sensitivity.length > 0 ? { dimensionSensitivity: sensitivity } : {}),
evidence,
};
@@ -2503,11 +2582,15 @@ export function diffGeometryFindings(
};
}
-/** Record previously unseen findings without moving an existing baseline. */
+/**
+ * Record previously unseen findings without moving an existing baseline. New
+ * entries land on `debt`, never `wont-fix`: telling those two apart is the
+ * review, and a tool that guessed it would be recording a decision nobody made.
+ */
export function triageGeometryFindings(
artifact: GeometryFindingArtifact,
ledger: GeometryLedger,
- status: Extract = 'accepted-debt'
+ status: Extract = 'debt'
): GeometryLedger {
const findings: Record = { ...ledger.findings };
const reviewedIdentity = (finding: GeometryFinding): GeometryReviewedIdentity => ({
@@ -2826,6 +2909,268 @@ export function summarizeGeometryInkCenters(
};
}
+/**
+ * The CSS property a repair term actually edits. `padding` and `margin` have
+ * logical longhands per edge; a border edge is a width; a gap belongs to the
+ * axis, not to one of its two edges. Guessing `gap-inline-end` would hand an
+ * agent a property that does not exist.
+ */
+export function geometryRepairCssProperty(
+ term: GeometryDeclaredBoxModelTerm,
+ edge: GeometryRepairProposal['edge']
+): string {
+ if (term === 'gap') return edge.startsWith('inline') ? 'column-gap' : 'row-gap';
+ if (term === 'border') return `border-${edge}-width`;
+ return `${term}-${edge}`;
+}
+
+export type GeometryRepairTextOptions = Readonly<{
+ maxTerms?: number;
+ /**
+ * A Tailwind class list runs to hundreds of characters, which is what an
+ * agent greps for and what makes a one-line summary unreadable. So the card
+ * body keeps it and the summary line leaves it out; neither truncates it,
+ * because half a class list greps for nothing.
+ */
+ includeClassName?: boolean;
+}>;
+
+/**
+ * One repair term as a sentence an agent can act on: which component, which
+ * rendered node inside it, which property, how far off, and the class list that
+ * most likely declares it.
+ */
+export function formatGeometryRepairTerm(
+ term: GeometryRepairTerm,
+ proposal: Pick,
+ options: GeometryRepairTextOptions = {}
+): string {
+ const owner = term.component ?? proposal.component ?? proposal.commonAncestor;
+ const property = geometryRepairCssProperty(term.term, proposal.edge);
+ const magnitude = Number(Math.abs(term.delta).toFixed(2));
+ const direction = term.delta > 0 ? '多' : '少';
+ const className =
+ term.className && options.includeClassName !== false ? `(class: ${term.className})` : '';
+ return `${owner} 里 ${term.element} 的 ${property} ${direction} ${magnitude}px${className}`;
+}
+
+/** The proposal's terms as sentences, strongest first. */
+export function formatGeometryRepairProposal(
+ proposal: GeometryRepairProposal,
+ options: GeometryRepairTextOptions = {}
+): string {
+ return proposal.terms
+ .slice(0, options.maxTerms ?? 3)
+ .map((term) => formatGeometryRepairTerm(term, proposal, options))
+ .join(';');
+}
+
+export type GeometryRatchetViolation = Readonly<{
+ kind: 'offset-regression' | 'unreviewed-finding';
+ key: string;
+ label: string;
+ surfaceFamily: GeometrySurfaceFamily;
+ axis: SemanticAlignmentAxis;
+ anchor: SemanticAlignmentAnchor;
+ status?: GeometryLedgerStatus;
+ baseline?: number;
+ current: number;
+ /** The slack allowed above `|baseline|`, in CSS pixels. */
+ tolerance?: number;
+}>;
+
+/**
+ * The coarsest device pixel this finding was measured with. A finding merged
+ * across a 2× and a 1× capture is only as precise as the 1× one, so holding it
+ * to half a pixel would fail on rounding the measurement cannot avoid.
+ */
+export function geometryFindingDevicePixel(
+ finding: GeometryFinding,
+ captures: GeometryCaptureArtifact
+): number {
+ const scaleByCapture = new Map(
+ captures.captures.map((capture) => [capture.captureId, capture.deviceScaleFactor])
+ );
+ const devicePixels = finding.evidence.map((evidence) => {
+ const scale = scaleByCapture.get(evidence.captureId);
+ return scale && scale > 0 ? 1 / scale : 1;
+ });
+ return devicePixels.length > 0 ? Math.max(...devicePixels) : 1;
+}
+
+/**
+ * The ratchet. A ledger baseline used to be a number the report PRINTED; this
+ * is what makes it a number CI enforces.
+ *
+ * Two rules, both about the ledger being complete and monotonic:
+ * every measured finding must be reviewed, and no reviewed finding may drift
+ * further from its line than the review recorded, allowing one device pixel for
+ * the rounding the measurement itself cannot avoid. `ignored` opts out — that
+ * is what ignoring one means — and `promoted` is left to the contract check
+ * that already gates it exactly, rather than being gated twice and loosely.
+ */
+export function checkGeometryLedgerRatchet(
+ artifact: GeometryFindingArtifact,
+ ledger: GeometryLedger,
+ captures: GeometryCaptureArtifact
+): readonly GeometryRatchetViolation[] {
+ const ratcheted = new Set(GEOMETRY_RATCHETED_LEDGER_STATUSES);
+ return artifact.findings.flatMap((finding): GeometryRatchetViolation[] => {
+ const identity = {
+ key: finding.key,
+ label: finding.label,
+ surfaceFamily: finding.surfaceFamily,
+ axis: finding.axis,
+ anchor: finding.anchor,
+ current: finding.offset,
+ };
+ const entry = ledger.findings[finding.key];
+ if (!entry) return [{ kind: 'unreviewed-finding', ...identity }];
+ if (!ratcheted.has(entry.status)) return [];
+ const baseline = entry.baseline?.offset;
+ if (baseline === undefined) return [];
+ const tolerance = geometryFindingDevicePixel(finding, captures);
+ if (Math.abs(finding.offset) <= Math.abs(baseline) + tolerance) return [];
+ return [
+ {
+ kind: 'offset-regression',
+ ...identity,
+ status: entry.status,
+ baseline,
+ tolerance,
+ },
+ ];
+ });
+}
+
+export type GeometryFixVerification = Readonly<{
+ key: string;
+ label: string;
+ /** Absent from findings: the rail no longer reports this element at all. */
+ resolved: boolean;
+ offset: number;
+ tolerance: number;
+ passed: boolean;
+ reason?: string;
+}>;
+
+/**
+ * Close the flywheel: a finding whose offset is now within one device pixel of
+ * its line is `fixed`, and its baseline moves to what was just measured — which
+ * makes it the STRICTEST entry in the ledger from then on. Everything else is
+ * reported and changes nothing: a verification that re-baselined a failure
+ * would be a ratchet that only ever loosens.
+ */
+export function verifyGeometryFixes(
+ artifact: GeometryFindingArtifact,
+ ledger: GeometryLedger,
+ captures: GeometryCaptureArtifact,
+ keys: readonly string[]
+): Readonly<{
+ verifications: readonly GeometryFixVerification[];
+ ledger: GeometryLedger;
+}> {
+ const byKey = new Map(artifact.findings.map((finding) => [finding.key, finding]));
+ const findings: Record = { ...ledger.findings };
+ const verifications = keys.map((key): GeometryFixVerification => {
+ const entry = ledger.findings[key];
+ const finding = byKey.get(key);
+ const label = finding?.label ?? entry?.identity?.label ?? key;
+ const refuse = (reason: string): GeometryFixVerification => ({
+ key,
+ label,
+ resolved: !finding,
+ offset: finding?.offset ?? 0,
+ tolerance: 0,
+ passed: false,
+ reason,
+ });
+ if (!entry) return refuse('no ledger entry reviews this finding');
+ // A promoted finding is gated EXACTLY by its contract. Moving it to `fixed`
+ // would leave that contract uncompiled and silently drop the tightest rule
+ // in the file, so retiring the contract has to be the deliberate step.
+ if (entry.status === 'promoted') {
+ return refuse('a promoted finding is gated by its contract; retire the contract first');
+ }
+ if (!finding) {
+ // The element is no longer measured off any line: nothing left to allow.
+ findings[key] = { ...entry, status: 'fixed', baseline: { offset: 0 } };
+ return { key, label, resolved: true, offset: 0, tolerance: 0, passed: true };
+ }
+ const tolerance = geometryFindingDevicePixel(finding, captures);
+ const passed = Math.abs(finding.offset) <= tolerance;
+ if (passed) {
+ findings[key] = { ...entry, status: 'fixed', baseline: { offset: finding.offset } };
+ }
+ return {
+ key,
+ label,
+ resolved: false,
+ offset: finding.offset,
+ tolerance,
+ passed,
+ ...(passed
+ ? {}
+ : {
+ reason: `|offset| ${Math.abs(finding.offset).toFixed(3)}px exceeds one device pixel (${tolerance.toFixed(3)}px)`,
+ }),
+ };
+ });
+ if (verifications.some((verification) => !verification.passed)) {
+ return { verifications, ledger };
+ }
+ return {
+ verifications,
+ ledger: {
+ version: 1,
+ ...(ledger.tokens ? { tokens: ledger.tokens } : {}),
+ findings: Object.fromEntries(
+ Object.entries(findings).sort(([left], [right]) => left.localeCompare(right))
+ ),
+ },
+ };
+}
+
+/** Every finding key the artifact assigns to one repair group. */
+export function geometryFindingKeysInRepairGroup(
+ artifact: GeometryFindingArtifact,
+ repairGroup: string
+): readonly string[] {
+ return artifact.findings
+ .filter((finding) => finding.repairGroup === repairGroup)
+ .map((finding) => finding.key)
+ .sort();
+}
+
+export function formatGeometryRatchetViolations(
+ violations: readonly GeometryRatchetViolation[]
+): string {
+ return violations
+ .map((violation) =>
+ violation.kind === 'unreviewed-finding'
+ ? [
+ `unreviewed finding ${violation.key}`,
+ ` ${violation.surfaceFamily} · ${violation.label} · ${violation.axis}/${violation.anchor}`,
+ ` measured ${violation.current.toFixed(3)}px and no ledger entry reviews it.`,
+ ' Run `pnpm geometry:report ` then `pnpm geometry:triage ` and commit the ledger.',
+ ].join('\n')
+ : [
+ `regressed finding ${violation.key}`,
+ ` ${violation.surfaceFamily} · ${violation.label} · ${violation.axis}/${violation.anchor} (${violation.status})`,
+ ` baseline ${(violation.baseline ?? 0).toFixed(3)}px → current ${violation.current.toFixed(3)}px`,
+ ` allowed |offset| ≤ ${(Math.abs(violation.baseline ?? 0) + (violation.tolerance ?? 0)).toFixed(3)}px (baseline + ${(violation.tolerance ?? 0).toFixed(3)}px device pixel)`,
+ ].join('\n')
+ )
+ .join('\n\n');
+}
+
+/**
+ * Only `promoted` compiles. `new`, `debt`, `wont-fix`, `fixed` and `ignored`
+ * produce no contract at all — a status is a REVIEW, and a review that started
+ * gating the product without a human writing the contract would be a rule
+ * nobody wrote. A `fixed` finding is held to its own near-zero baseline by the
+ * ratchet; promoting it to a contract is a separate, deliberate step.
+ */
export function compileGeometryContracts(ledger: GeometryLedger): GeometryContractArtifact {
const contracts = Object.entries(ledger.findings).flatMap(([findingKey, entry]) => {
if (entry.status !== 'promoted') return [];
diff --git a/packages/components/src/lib/geometry-react-fiber.ts b/packages/components/src/lib/geometry-react-fiber.ts
new file mode 100644
index 000000000..0a45582a7
--- /dev/null
+++ b/packages/components/src/lib/geometry-react-fiber.ts
@@ -0,0 +1,67 @@
+/**
+ * Which component wrote a rendered element. A geometry finding names a DOM
+ * description a designer recognises but an agent cannot open; the React fiber
+ * still remembers the component, so a repair ticket can point at source.
+ *
+ * Everything here is EVIDENCE, never identity: a finding key must not move
+ * because a component was renamed, so nothing in this file may reach a key.
+ * React 19 dropped `_debugSource`, so a component NAME is the whole pointer —
+ * there is no file or line to read, and guessing one would be worse than none.
+ *
+ * Keep these functions closure-free: capture serializes them into the page.
+ */
+
+/** A fiber, as much of one as reading a component name needs. */
+export type GeometryReactFiberLike = Readonly<{
+ type?: unknown;
+ return?: GeometryReactFiberLike | null;
+}>;
+
+/**
+ * The component name a fiber `type` carries, unwrapping the `memo` and
+ * `forwardRef` objects React puts in `type`'s place. A host element (`'div'`)
+ * and an anonymous function have no name to give and return undefined, so the
+ * walk keeps going up rather than reporting a blank.
+ */
+export function geometryReactFiberComponentName(
+ fiber: GeometryReactFiberLike | null | undefined,
+ maxDepth = 64
+): string | undefined {
+ const typeName = (type: unknown, unwrapDepth: number): string | undefined => {
+ if (!type || unwrapDepth > 8) return undefined;
+ if (typeof type === 'function') {
+ const named = type as Readonly<{ displayName?: unknown; name?: unknown }>;
+ const display = typeof named.displayName === 'string' ? named.displayName : undefined;
+ const intrinsic = typeof named.name === 'string' ? named.name : undefined;
+ const resolved = display ?? intrinsic;
+ return resolved && resolved.length > 0 ? resolved : undefined;
+ }
+ if (typeof type !== 'object') return undefined;
+ const wrapper = type as Readonly<{ displayName?: unknown; render?: unknown; type?: unknown }>;
+ if (typeof wrapper.displayName === 'string' && wrapper.displayName.length > 0) {
+ return wrapper.displayName;
+ }
+ // `forwardRef` keeps the component on `render`, `memo` on `type`.
+ return typeName(wrapper.render, unwrapDepth + 1) ?? typeName(wrapper.type, unwrapDepth + 1);
+ };
+ let node = fiber;
+ for (let depth = 0; node && depth < maxDepth; depth += 1) {
+ const name = typeName(node.type, 0);
+ if (name) return name;
+ node = node.return;
+ }
+ return undefined;
+}
+
+/**
+ * The fiber React attached to a rendered node. The key carries a per-renderer
+ * suffix, so it is found by prefix rather than spelled out.
+ */
+export function geometryElementReactFiber(element: Element): GeometryReactFiberLike | undefined {
+ for (const key of Object.keys(element)) {
+ if (!key.startsWith('__reactFiber$')) continue;
+ const fiber = (element as unknown as Record)[key];
+ if (fiber && typeof fiber === 'object') return fiber as GeometryReactFiberLike;
+ }
+ return undefined;
+}
diff --git a/packages/components/tests/chat-workspace-geometry.test.ts b/packages/components/tests/chat-workspace-geometry.test.ts
index 956d98725..d26a949cc 100644
--- a/packages/components/tests/chat-workspace-geometry.test.ts
+++ b/packages/components/tests/chat-workspace-geometry.test.ts
@@ -26,6 +26,11 @@ import {
type LayoutTopologyNode,
} from '../src/lib/chat-workspace-geometry';
import { geometryCanvasFontString } from '../src/lib/geometry-text-cap-band';
+import {
+ geometryElementReactFiber,
+ geometryReactFiberComponentName,
+ type GeometryReactFiberLike,
+} from '../src/lib/geometry-react-fiber';
const anchors = CHAT_WORKSPACE_GEOMETRY_ANCHORS;
@@ -1348,3 +1353,57 @@ describe('the canvas font string', () => {
expect(geometryCanvasFontString(style)).not.toContain('tabular-nums');
});
});
+
+describe('React component names as repair evidence', () => {
+ const fiber = (type: unknown, parent?: GeometryReactFiberLike): GeometryReactFiberLike => ({
+ type,
+ return: parent ?? null,
+ });
+
+ it('walks up to the nearest function component', () => {
+ function SidebarRowShared() {
+ return null;
+ }
+ const host = fiber('span', fiber('div', fiber(SidebarRowShared)));
+
+ expect(geometryReactFiberComponentName(host)).toBe('SidebarRowShared');
+ });
+
+ it('unwraps forwardRef and memo, and prefers an explicit displayName', () => {
+ function Inner() {
+ return null;
+ }
+ const forwarded = { $$typeof: Symbol.for('react.forward_ref'), render: Inner };
+ const memoized = { $$typeof: Symbol.for('react.memo'), type: forwarded };
+ const renamed = { $$typeof: Symbol.for('react.memo'), type: Inner, displayName: 'SidebarRow' };
+
+ expect(geometryReactFiberComponentName(fiber(memoized))).toBe('Inner');
+ expect(geometryReactFiberComponentName(fiber(renamed))).toBe('SidebarRow');
+ });
+
+ it('returns nothing rather than a blank when no ancestor names a component', () => {
+ expect(geometryReactFiberComponentName(fiber('div', fiber('span')))).toBeUndefined();
+ expect(geometryReactFiberComponentName(fiber(() => null))).toBeUndefined();
+ expect(geometryReactFiberComponentName(undefined)).toBeUndefined();
+ expect(geometryReactFiberComponentName(null)).toBeUndefined();
+ });
+
+ it('stops walking instead of looping on a cyclic return chain', () => {
+ const cycle: { type: unknown; return: unknown } = { type: 'div', return: null };
+ cycle.return = cycle;
+
+ expect(geometryReactFiberComponentName(cycle as GeometryReactFiberLike, 8)).toBeUndefined();
+ });
+
+ it('finds the fiber React attached under its per-renderer key', () => {
+ function Row() {
+ return null;
+ }
+ const attached = { __reactFiber$abc123: fiber(Row) } as unknown as Element;
+ const bare = {} as unknown as Element;
+
+ expect(geometryReactFiberComponentName(geometryElementReactFiber(attached))).toBe('Row');
+ // A node React never rendered simply contributes no component name.
+ expect(geometryElementReactFiber(bare)).toBeUndefined();
+ });
+});
diff --git a/packages/components/tests/e2e/AGENTS.md b/packages/components/tests/e2e/AGENTS.md
index f04e26221..35e39864a 100644
--- a/packages/components/tests/e2e/AGENTS.md
+++ b/packages/components/tests/e2e/AGENTS.md
@@ -6,10 +6,10 @@ 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.
+classification: [src/lib](../../src/lib/AGENTS.md); what a primitive, a row and a name ARE,
+and the shared capture plan: [support](support/AGENTS.md); `*-geometry-report.spec.ts`
+(report), `*-geometry.spec.ts` (gate). Neither `geometry:report` nor `geometry:triage`
+moves a baseline.
## X rails
@@ -48,9 +48,9 @@ Marker-free, same pipeline, over the anchors [support](support/AGENTS.md) lists.
- 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.
+- `marker-removal-readiness.json` asks whether discovery has replaced the markers
+ ([support](support/AGENTS.md)). The gate proves outlier reporting with its OWN injected
+ `translateY`, diffing before and after, asserting no product row.
## Pipeline and finding identity
@@ -83,16 +83,25 @@ new. Pairing is one-to-one; an entry without a recorded identity stays resolved.
`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`.
-
+`geometry-ledger.json`; `geometry-contracts.json` compiles ONLY `promoted` entries, each
+declaring `ink` or `layout-box` — `new`, `debt`, `wont-fix`, `fixed`, `ignored` compile none.
+
+- A baseline is EXECUTED, not printed: the gate reruns the pipeline over the whole capture
+ plan with no screenshots, and fails when a finding's |offset| passes |baseline| plus one
+ device pixel (1/DPR of its COARSEST capture) or when a finding has no ledger entry
+ (`geometry:triage`, like a lockfile). `ignored` opts out; `promoted` belongs to the
+ contract check. Offsets are means over the WHOLE plan, so a baseline belongs to the
+ platform that recorded it: re-baseline where CI runs, never trim the plan for speed.
+- `debt` and `wont-fix` are two decisions, not one word; `triage` records `debt` rather
+ than guess. `geometry:verify-fix ` reruns that gate and only
+ then moves a finding back inside one device pixel to `fixed` at its new baseline, the
+ strictest entry there is.
- Two contract members never cover one element twice; member resolution, the ink witness
and named tokens (the ledger records only the `--spacing-*` property): see
- [support](support/AGENTS.md).
-- Relations are a small deterministic algebra ([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.
+ [support](support/AGENTS.md). Relations are a small deterministic algebra there; widen
+ the relation before loosening a tolerance.
+- Ledger labels give discovery precision, promoted locators coverage, PNG edges only
+ confidence.
## Report
@@ -101,18 +110,16 @@ Discovery or proposal presence is never a report assertion; coverage:
- 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.
+ findings or replacing evidence. Replay: [support](support/AGENTS.md).
+- Steady state, not delta: every finding gets a card grouped by ledger status and
+ classification, with baseline vs current offset, capture count, dimension sensitivity and
+ repair text. Cards FOLD by `repairGroup` — one per repair, naming the findings it stands
+ for, never across statuses; folding merges no finding and moves no key. Chips filter
+ both, default new + changed + css-defect + promoted minus wont-fix/fixed; the meta line
+ totals new/changed/resolved. One JSON payload, one renderer, images as files.
- Violation images label each deviating member in place with role, physical direction,
measured offset, actual anchor and a leader to it. A Y card comes from the FINISHED
findings, never a second pipeline printing another number: each annotation IS that
- finding's evidence for that member, asserted before the shot; 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.
+ finding's evidence for that member, asserted before the shot. Card clips, zoomed Y cards
+ and discovery cards: [support](support/AGENTS.md). Cards are picked by deviation, inside
+ the generator's budget.
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 8550fb3be..e1e4ba11e 100644
--- a/packages/components/tests/e2e/chat-workspace-geometry-report.spec.ts
+++ b/packages/components/tests/e2e/chat-workspace-geometry-report.spec.ts
@@ -21,6 +21,7 @@ import {
computeGeometryQualityMetrics,
createGeometryFindings,
diffGeometryFindings,
+ formatGeometryRepairProposal,
geometryIdentityLocator,
geometryLocatorMatches,
observeGeometryCaptures,
@@ -38,10 +39,7 @@ import {
type GeometryRepairProposal,
} from '../../src/lib/geometry-constraint-system';
import {
- auditChatWorkspaceSemanticAlignments,
- auditChatWorkspaceSemanticBaselines,
auditChatWorkspaceSpacing,
- discoverChatWorkspaceAlignmentRails,
measureGeometryContractOpticalInsets,
type BrowserAlignmentRailDiscoveryScope,
type BrowserSemanticAlignmentEntry,
@@ -49,6 +47,23 @@ import {
measureSettledChatWorkspace,
requireGeometryRect,
} from './support/chat-workspace-geometry';
+import {
+ DEFAULT_CAPTURE_DIMENSIONS,
+ GEOMETRY_REPRESENTATIVE_CAPTURE,
+ GEOMETRY_RIGHT_SIDEBAR_STATE_CAPTURES,
+ GEOMETRY_SESSION_STATE_CAPTURES,
+ GEOMETRY_WORKSPACE_DIMENSION_CAPTURES,
+ GEOMETRY_WORKSPACE_MATRIX_CAPTURES,
+ GEOMETRY_WORKSPACE_STATE_CAPTURES,
+ buildGeometryCapture,
+ enableGeometryCaptureMode,
+ geometryReplayContextKey,
+ observeGeometryPlanEntry,
+ openGeometryReplayContext,
+ showGeometryCaptureStory,
+ type GeometryCapturePlanEntry,
+ type GeometryPlanObservation,
+} from './support/geometry-capture-plan';
const outputDirectory = process.env.GEOMETRY_REPORT_OUTPUT_DIR;
const storybookOrigin = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:6006';
@@ -67,6 +82,9 @@ type ReportDetail = Readonly<{
totalCaptureCount?: number;
dimensionSensitivity?: readonly string[];
repairProposal?: string;
+ /** The repair this card stands for, and the findings it folded into it. */
+ repairGroup?: string;
+ repairGroupKeys?: readonly string[];
inkCenterWitness?: string;
id: string;
title: string;
@@ -191,7 +209,7 @@ async function collectGeometryPixelWitnesses(
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);
+ await enableGeometryCaptureMode(witnessPage);
if (contract.story.startsWith('geometry-chatworkspace--')) {
await measureSettledChatWorkspace(witnessPage);
}
@@ -328,27 +346,18 @@ const CLASSIFICATION_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}`;
+/**
+ * One executable sentence per term — which component, which node, which CSS
+ * property, how far off, and the class list that most likely declares it. The
+ * old text printed the two box-model totals and a rendered DOM description,
+ * which told a reviewer the size of the problem and an agent nothing about
+ * where to make the edit.
+ */
+function formatRepairProposal(
+ proposal: GeometryRepairProposal,
+ options?: Parameters[1]
+): string {
+ return `修复建议:${formatGeometryRepairProposal(proposal, options)}`;
}
function semanticAlignmentTitle(entry: BrowserSemanticAlignmentEntry): string {
@@ -394,115 +403,6 @@ 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',
- surface: 'Workspace / Chat Landing / Submitting',
- storyId: 'geometry-chatworkspace--submission-pending',
- },
- {
- id: 'landing-no-machine-download',
- surface: 'Workspace / Chat Landing / No Machine Download',
- storyId: 'geometry-chatworkspace--no-machine-download',
- },
- {
- id: 'landing-no-machine-starting',
- surface: 'Workspace / Chat Landing / No Machine Starting',
- storyId: 'geometry-chatworkspace--no-machine-starting',
- },
- {
- id: 'landing-no-agent',
- surface: 'Workspace / Chat Landing / No Agent',
- storyId: 'geometry-chatworkspace--no-agent-config',
- },
- {
- id: 'landing-long-model',
- surface: 'Workspace / Chat Landing / Long Model',
- storyId: 'geometry-chatworkspace--long-model',
- },
- {
- id: 'landing-pasted-text',
- surface: 'Workspace / Chat Landing / Pasted Text',
- storyId: 'geometry-chatworkspace--pasted-text',
- },
-] as const;
-
-const SESSION_STATE_CAPTURES = [
- {
- id: 'session-idle',
- surface: 'Chat Session / Idle',
- storyId: 'sessions-sessionconversationpage--desktop-idle',
- },
- {
- id: 'session-working',
- surface: 'Chat Session / Working',
- storyId: 'sessions-sessionconversationpage--desktop-working-settled',
- },
- {
- id: 'session-permission',
- surface: 'Chat Session / Permission',
- storyId: 'sessions-sessionconversationpage--desktop-permission-approval',
- },
- {
- id: 'session-question',
- surface: 'Chat Session / Agent Question',
- storyId: 'sessions-sessionconversationpage--desktop-agent-question',
- },
-] as const;
-
-const RIGHT_SIDEBAR_STATE_CAPTURES = [
- {
- id: 'session-right-sidebar-changes',
- surface: 'Chat Session / Right Sidebar / Changes',
- storyId: 'sessions-sessionsidepaneltabbar--geometry-report',
- },
- {
- id: 'session-right-sidebar-tabs',
- surface: 'Chat Session / Right Sidebar / Tabs',
- storyId: 'sessions-sessionsidepaneltabbar--unified-tabs',
- },
- {
- id: 'session-right-sidebar-empty',
- surface: 'Chat Session / Right Sidebar / Empty',
- storyId: 'sessions-sessionsidepaneltabbar--empty-state',
- },
-] as const;
-
const GEOMETRY_COVERAGE_EXCLUSIONS = [
{
surface: 'Chat Session / Mention Drop',
@@ -1525,173 +1425,6 @@ function createDiscoveryOverviewDetail({
};
}
-async function waitForSessionConversationStory(page: Page): Promise {
- await page.locator('[data-testid="session-conversation-story"]').waitFor({
- state: 'visible',
- timeout: 90_000,
- });
- await page.evaluate(async () => {
- await document.fonts.ready;
- await new Promise((resolve) => {
- requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
- });
- });
-}
-
-async function enableReportCaptureMode(page: Page): Promise {
- // 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"] * {
- pointer-events: none !important;
- animation: none !important;
- transition: none !important;
- }
- [data-geometry-actions-visible="true"] [data-geometry-hover-action] {
- opacity: 1 !important;
- pointer-events: none !important;
- }
- [data-geometry-actions-visible="true"] [data-geometry-hover-rest] {
- opacity: 0 !important;
- pointer-events: none !important;
- }
- [data-geometry-report-capture="true"] [data-geometry-capture-reveal="true"] {
- opacity: 1 !important;
- pointer-events: none !important;
- }
- `,
- });
- const workspace = page.locator(
- `[${CHAT_WORKSPACE_GEOMETRY_ATTRIBUTE}="${CHAT_WORKSPACE_GEOMETRY_ANCHORS.workspaceShell}"]`
- );
- await page.locator('body').evaluate((element) => {
- element.setAttribute('data-geometry-report-capture', 'true');
- element.setAttribute('data-geometry-actions-visible', 'true');
-
- const interactiveSelector =
- 'button, [role="button"], a[href], input, select, textarea, summary, [tabindex]:not([tabindex="-1"])';
- for (const candidate of element.querySelectorAll('*')) {
- if (candidate.closest('[data-geometry-hover-rest]')) continue;
- const style = getComputedStyle(candidate);
- const rect = candidate.getBoundingClientRect();
- const occupiesViewport =
- rect.width > 0 &&
- rect.height > 0 &&
- rect.right >= 0 &&
- rect.bottom >= 0 &&
- rect.left <= innerWidth &&
- rect.top <= innerHeight;
- const ownsInteraction =
- candidate.matches(interactiveSelector) || candidate.querySelector(interactiveSelector);
- if (
- Number.parseFloat(style.opacity) <= 0.01 &&
- style.display !== 'none' &&
- style.visibility !== 'hidden' &&
- occupiesViewport &&
- ownsInteraction
- ) {
- candidate.setAttribute('data-geometry-capture-reveal', 'true');
- }
- }
- });
- if ((await workspace.count()) > 0) {
- await workspace.evaluate((element) => {
- element.setAttribute('data-geometry-report-capture', 'true');
- element.setAttribute('data-geometry-actions-visible', 'true');
- });
- }
-
- const revealedSurfaces = page.locator('[data-geometry-capture-reveal="true"]');
- 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 {
const dataPath = path.join(reportOutputDirectory, 'report-data.json');
const reportData = JSON.parse(await readFile(dataPath, 'utf8')) as PersistedReportData;
@@ -1758,30 +1491,20 @@ test('captures the visual geometry report', async ({ browser }) => {
return;
}
- const viewport = { width: 1440, height: 900 };
- const context = await browser.newContext({
- viewport,
- deviceScaleFactor: 2,
- reducedMotion: 'reduce',
- colorScheme: 'light',
- });
+ const viewport = GEOMETRY_REPRESENTATIVE_CAPTURE.viewport;
const unexpectedNetworkRequests: string[] = [];
- 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 context = await openGeometryReplayContext(
+ browser,
+ GEOMETRY_REPRESENTATIVE_CAPTURE,
+ unexpectedNetworkRequests
+ );
const page = await context.newPage();
const geometryObservationCache: GeometryObservationCache = new Map();
- const cleanUrl = `${storybookOrigin}/iframe.html?id=geometry-chatworkspace--expanded-sidebar&viewMode=story`;
+ const cleanUrl = `${storybookOrigin}/iframe.html?id=${GEOMETRY_REPRESENTATIVE_CAPTURE.storyId}&viewMode=story`;
const cleanResponse = await page.goto(cleanUrl);
if (!cleanResponse?.ok()) throw new Error(`Story capture failed: ${cleanUrl}`);
- await enableReportCaptureMode(page);
+ await enableGeometryCaptureMode(page);
const hoverActions = page.locator('[data-geometry-hover-action]');
await hoverActions.count();
@@ -1792,14 +1515,16 @@ test('captures the visual geometry report', async ({ browser }) => {
});
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,
- });
+ const planObservations: GeometryPlanObservation[] = [];
+ const representativeObservation = await observeGeometryPlanEntry(
+ page,
+ GEOMETRY_REPRESENTATIVE_CAPTURE,
+ geometryObservationCache
+ );
+ planObservations.push(representativeObservation);
+ const semanticAlignments = representativeObservation.semanticAlignments ?? [];
+ const semanticBaselines = representativeObservation.semanticBaselines ?? [];
+ const railDiscovery = representativeObservation.railDiscovery;
const mainPane = requireGeometryRect(
measurement.snapshot,
CHAT_WORKSPACE_GEOMETRY_ANCHORS.mainPane
@@ -1862,7 +1587,7 @@ 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);
if (!annotatedResponse?.ok()) throw new Error(`Story capture failed: ${annotatedUrl}`);
- await enableReportCaptureMode(page);
+ await enableGeometryCaptureMode(page);
await measureSettledChatWorkspace(page);
const spacingOverlay = page.locator('[data-geometry-devtool="spacing-audit"]');
await spacingOverlay.waitFor({ state: 'attached' });
@@ -1892,87 +1617,36 @@ test('captures the visual geometry report', async ({ browser }) => {
});
}
- const discoverySurfaces: Array<
- Readonly<{
- captureId: string;
- contractDomain: 'workspace' | 'session' | 'right-sidebar';
- surface: string;
- viewport: Readonly<{ width: number; height: number }>;
- railDiscovery: readonly BrowserAlignmentRailDiscoveryScope[];
- }>
- > = [
- {
- captureId: 'workspace:wide-expanded',
- contractDomain: 'workspace',
- surface: 'Workspace / Chat Landing',
- viewport,
- railDiscovery,
- },
- ];
- const coverageCaptures: Array<
- Readonly<{
- captureId: string;
- area: 'workspace' | 'session' | 'right-sidebar';
- surface: string;
- storyId: string;
- storyGlobals?: string;
- viewport: Readonly<{ width: number; height: number }>;
- deviceScaleFactor: number;
- dimensions: GeometryCaptureDimensions;
- }>
- > = [
- {
- captureId: 'workspace:wide-expanded',
- area: 'workspace',
- surface: 'Workspace / Chat Landing',
- storyId: 'geometry-chatworkspace--expanded-sidebar',
- viewport,
- deviceScaleFactor: 2,
- dimensions: DEFAULT_CAPTURE_DIMENSIONS,
- },
- ];
+ // One context per device scale and theme, exactly as the gate walk opens
+ // them: a viewport can be set on an open context, a device scale cannot, and
+ // a fresh context re-parses the whole Storybook bundle.
+ const planContexts = new Map([
+ [geometryReplayContextKey(GEOMETRY_REPRESENTATIVE_CAPTURE), context],
+ ]);
+ const planContextFor = async (entry: GeometryCapturePlanEntry) => {
+ const key = geometryReplayContextKey(entry);
+ const existing = planContexts.get(key);
+ if (existing) return existing;
+ const opened = await openGeometryReplayContext(browser, entry, unexpectedNetworkRequests);
+ planContexts.set(key, opened);
+ return opened;
+ };
- for (const verificationCase of CHAT_WORKSPACE_GEOMETRY_SPEC.verificationCases) {
- if (verificationCase.name === 'wide-expanded') continue;
- const matrixContext = await browser.newContext({
- viewport: verificationCase.viewport,
- deviceScaleFactor: 1,
- reducedMotion: 'reduce',
- colorScheme: 'light',
- });
- await matrixContext.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 matrixPage = await matrixContext.newPage();
- const storyId =
- verificationCase.sidebar === 'expanded'
- ? 'geometry-chatworkspace--expanded-sidebar'
- : 'geometry-chatworkspace--collapsed-sidebar';
- const response = await matrixPage.goto(
- `${storybookOrigin}/iframe.html?id=${storyId}&viewMode=story`
+ for (const entry of GEOMETRY_WORKSPACE_MATRIX_CAPTURES) {
+ const matrixPage = await showGeometryCaptureStory(await planContextFor(entry), entry);
+ const matrixObservation = await observeGeometryPlanEntry(
+ matrixPage,
+ entry,
+ geometryObservationCache
);
- 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,
- });
+ planObservations.push(matrixObservation);
const matrixOverview = createDiscoveryOverviewDetail({
- surface: `Workspace / ${verificationCase.name}`,
- idPrefix: `workspace-${verificationCase.name}`,
- viewport: verificationCase.viewport,
- railDiscovery: matrixDiscovery,
+ surface: entry.surface,
+ idPrefix: entry.detailId,
+ viewport: entry.viewport,
+ railDiscovery: matrixObservation.railDiscovery,
});
- assignDetailsToCapture([matrixOverview], `workspace:${verificationCase.name}`);
+ assignDetailsToCapture([matrixOverview], entry.captureId);
await matrixPage.screenshot({
path: path.join(outputDirectory, matrixOverview.images.clean),
clip: matrixOverview.clip,
@@ -1989,75 +1663,24 @@ test('captures the visual geometry report', async ({ browser }) => {
scale: 'device',
});
workspaceDetails.push(matrixOverview);
- discoverySurfaces.push({
- captureId: `workspace:${verificationCase.name}`,
- contractDomain: 'workspace',
- surface: `Workspace / ${verificationCase.name}`,
- viewport: verificationCase.viewport,
- railDiscovery: matrixDiscovery,
- });
- coverageCaptures.push({
- captureId: `workspace:${verificationCase.name}`,
- area: 'workspace',
- surface: `Workspace / ${verificationCase.name}`,
- storyId,
- viewport: verificationCase.viewport,
- deviceScaleFactor: 1,
- dimensions: DEFAULT_CAPTURE_DIMENSIONS,
- });
- await matrixContext.close();
+ await matrixPage.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,
- });
+ for (const entry of GEOMETRY_WORKSPACE_DIMENSION_CAPTURES) {
+ const dimensionPage = await showGeometryCaptureStory(await planContextFor(entry), entry);
+ const dimensionObservation = await observeGeometryPlanEntry(
+ dimensionPage,
+ entry,
+ geometryObservationCache
+ );
+ planObservations.push(dimensionObservation);
const dimensionOverview = createDiscoveryOverviewDetail({
- surface: dimension.surface,
- idPrefix: dimension.id,
- viewport,
- railDiscovery: dimensionDiscovery,
+ surface: entry.surface,
+ idPrefix: entry.detailId,
+ viewport: entry.viewport,
+ railDiscovery: dimensionObservation.railDiscovery,
});
- assignDetailsToCapture([dimensionOverview], dimensionCaptureId);
+ assignDetailsToCapture([dimensionOverview], entry.captureId);
await dimensionPage.screenshot({
path: path.join(outputDirectory, dimensionOverview.images.clean),
clip: dimensionOverview.clip,
@@ -2074,39 +1697,19 @@ test('captures the visual geometry report', async ({ browser }) => {
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();
+ await dimensionPage.close();
}
- for (const story of WORKSPACE_STATE_CAPTURES) {
- const response = await page.goto(
- `${storybookOrigin}/iframe.html?id=${story.storyId}&viewMode=story`
+ for (const entry of GEOMETRY_WORKSPACE_STATE_CAPTURES) {
+ const statePage = await showGeometryCaptureStory(await planContextFor(entry), entry);
+ const stateMeasurement = await measureSettledChatWorkspace(statePage);
+ const stateObservation = await observeGeometryPlanEntry(
+ statePage,
+ entry,
+ geometryObservationCache
);
- 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,
- });
+ planObservations.push(stateObservation);
+ const stateRailDiscovery = stateObservation.railDiscovery;
const stateMainPane = requireGeometryRect(
stateMeasurement.snapshot,
CHAT_WORKSPACE_GEOMETRY_ANCHORS.mainPane
@@ -2115,22 +1718,21 @@ test('captures the visual geometry report', async ({ browser }) => {
(scope) => scope.scope === 'main.chat-landing' || scope.rect.x >= stateMainPane.x - 1
);
const discoveredStateDetails = createDiscoveryDetails({
- surface: story.surface,
- idPrefix: story.id,
- viewport,
+ surface: entry.surface,
+ idPrefix: entry.detailId,
+ viewport: entry.viewport,
railDiscovery: mainDiscovery,
});
const stateOverview = createDiscoveryOverviewDetail({
- surface: story.surface,
- idPrefix: story.id,
- viewport,
+ surface: entry.surface,
+ idPrefix: entry.detailId,
+ viewport: entry.viewport,
railDiscovery: stateRailDiscovery,
});
const stateDetails = [stateOverview, ...discoveredStateDetails];
- const captureId = `workspace:${story.id}:1440x900`;
- assignDetailsToCapture(stateDetails, captureId);
+ assignDetailsToCapture(stateDetails, entry.captureId);
for (const detail of stateDetails) {
- await page.screenshot({
+ await statePage.screenshot({
path: path.join(outputDirectory, detail.images.clean),
clip: detail.clip,
animations: 'disabled',
@@ -2139,8 +1741,8 @@ test('captures the visual geometry report', async ({ browser }) => {
});
}
for (const detail of stateDetails) {
- await showOnlyDetailSemanticGuides(page, detail);
- await page.screenshot({
+ await showOnlyDetailSemanticGuides(statePage, detail);
+ await statePage.screenshot({
path: path.join(outputDirectory, detail.images.annotated),
clip: detail.clip,
animations: 'disabled',
@@ -2149,66 +1751,37 @@ test('captures the visual geometry report', async ({ browser }) => {
});
}
workspaceDetails.push(...stateDetails);
- discoverySurfaces.push({
- captureId,
- contractDomain: 'workspace',
- surface: story.surface,
- viewport,
- railDiscovery: stateRailDiscovery,
- });
- coverageCaptures.push({
- captureId,
- area: 'workspace',
- surface: story.surface,
- storyId: story.storyId,
- viewport,
- deviceScaleFactor: 2,
- dimensions: DEFAULT_CAPTURE_DIMENSIONS,
- });
+ await statePage.close();
}
const sessionDetails: ReportDetail[] = [];
- for (const story of SESSION_STATE_CAPTURES) {
- const response = await page.goto(
- `${storybookOrigin}/iframe.html?id=${story.storyId}&viewMode=story`
+ for (const entry of GEOMETRY_SESSION_STATE_CAPTURES) {
+ const sessionPage = await showGeometryCaptureStory(await planContextFor(entry), entry);
+ const sessionObservation = await observeGeometryPlanEntry(
+ sessionPage,
+ entry,
+ geometryObservationCache
);
- if (!response?.ok()) throw new Error(`Story capture failed: ${story.storyId}`);
- await waitForSessionConversationStory(page);
- await enableReportCaptureMode(page);
- if (story.id === 'session-working') {
- 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 responseActionBar.first().waitFor({ state: 'attached' });
- }
- const sessionRailDiscovery = await discoverChatWorkspaceAlignmentRails(page, {
- aggregateScopes: ['session.page'],
- captureId: `${story.id}:1440x900`,
- surfaceFamily: 'session',
- observationCache: geometryObservationCache,
- });
+ planObservations.push(sessionObservation);
+ const sessionRailDiscovery = sessionObservation.railDiscovery;
const storyDetails = createDiscoveryDetails({
- surface: story.surface,
- idPrefix: story.id,
- viewport,
+ surface: entry.surface,
+ idPrefix: entry.detailId,
+ viewport: entry.viewport,
railDiscovery: sessionRailDiscovery,
});
const sessionOverview = createDiscoveryOverviewDetail({
- surface: story.surface,
- idPrefix: story.id,
- viewport,
+ surface: entry.surface,
+ idPrefix: entry.detailId,
+ viewport: entry.viewport,
railDiscovery: sessionRailDiscovery,
});
const sessionReportDetails = [sessionOverview, ...storyDetails];
- const captureId = `${story.id}:1440x900`;
- assignDetailsToCapture(sessionReportDetails, captureId);
+ assignDetailsToCapture(sessionReportDetails, entry.captureId);
for (const detail of sessionReportDetails) {
- await page.screenshot({
+ await sessionPage.screenshot({
path: path.join(outputDirectory, detail.images.clean),
clip: detail.clip,
animations: 'disabled',
@@ -2217,8 +1790,8 @@ test('captures the visual geometry report', async ({ browser }) => {
});
}
for (const detail of sessionReportDetails) {
- await showOnlyDetailSemanticGuides(page, detail);
- await page.screenshot({
+ await showOnlyDetailSemanticGuides(sessionPage, detail);
+ await sessionPage.screenshot({
path: path.join(outputDirectory, detail.images.annotated),
clip: detail.clip,
animations: 'disabled',
@@ -2228,50 +1801,32 @@ test('captures the visual geometry report', async ({ browser }) => {
}
sessionDetails.push(...sessionReportDetails);
- discoverySurfaces.push({
- captureId,
- contractDomain: 'session',
- surface: story.surface,
- viewport,
- railDiscovery: sessionRailDiscovery,
- });
- coverageCaptures.push({
- captureId,
- area: 'session',
- surface: story.surface,
- storyId: story.storyId,
- viewport,
- deviceScaleFactor: 2,
- dimensions: DEFAULT_CAPTURE_DIMENSIONS,
- });
+ await sessionPage.close();
}
- for (const story of RIGHT_SIDEBAR_STATE_CAPTURES) {
- const response = await page.goto(
- `${storybookOrigin}/iframe.html?id=${story.storyId}&viewMode=story`
+ for (const entry of GEOMETRY_RIGHT_SIDEBAR_STATE_CAPTURES) {
+ const sidebarPage = await showGeometryCaptureStory(await planContextFor(entry), entry);
+ const sidebarObservation = await observeGeometryPlanEntry(
+ sidebarPage,
+ entry,
+ geometryObservationCache
);
- 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,
- });
+ planObservations.push(sidebarObservation);
+ const rightSidebarRailDiscovery = sidebarObservation.railDiscovery;
const discoveredRightSidebarDetails = createDiscoveryDetails({
- surface: story.surface,
- idPrefix: story.id,
- viewport,
+ surface: entry.surface,
+ idPrefix: entry.detailId,
+ viewport: entry.viewport,
railDiscovery: rightSidebarRailDiscovery,
});
const sidePanelScope = rightSidebarRailDiscovery.find(
(scope) => scope.scope === 'session.side-panel'
);
- if (!sidePanelScope) throw new Error(`${story.storyId} did not expose session.side-panel`);
+ if (!sidePanelScope) throw new Error(`${entry.storyId} did not expose session.side-panel`);
const rightSidebarOverview = createDiscoveryOverviewDetail({
- surface: story.surface,
- idPrefix: story.id,
- viewport,
+ surface: entry.surface,
+ idPrefix: entry.detailId,
+ viewport: entry.viewport,
railDiscovery: rightSidebarRailDiscovery,
clip: {
x: sidePanelScope.rect.x - 8,
@@ -2281,10 +1836,9 @@ test('captures the visual geometry report', async ({ browser }) => {
},
});
const rightSidebarDetails = [rightSidebarOverview, ...discoveredRightSidebarDetails];
- const captureId = `${story.id}:1440x900`;
- assignDetailsToCapture(rightSidebarDetails, captureId);
+ assignDetailsToCapture(rightSidebarDetails, entry.captureId);
for (const detail of rightSidebarDetails) {
- await page.screenshot({
+ await sidebarPage.screenshot({
path: path.join(outputDirectory, detail.images.clean),
clip: detail.clip,
animations: 'disabled',
@@ -2293,8 +1847,8 @@ test('captures the visual geometry report', async ({ browser }) => {
});
}
for (const detail of rightSidebarDetails) {
- await showOnlyDetailSemanticGuides(page, detail);
- await page.screenshot({
+ await showOnlyDetailSemanticGuides(sidebarPage, detail);
+ await sidebarPage.screenshot({
path: path.join(outputDirectory, detail.images.annotated),
clip: detail.clip,
animations: 'disabled',
@@ -2303,24 +1857,29 @@ test('captures the visual geometry report', async ({ browser }) => {
});
}
sessionDetails.push(...rightSidebarDetails);
- discoverySurfaces.push({
- captureId,
- contractDomain: 'right-sidebar',
- surface: story.surface,
- viewport,
- railDiscovery: rightSidebarRailDiscovery,
- });
- coverageCaptures.push({
- captureId,
- area: 'right-sidebar',
- surface: story.surface,
- storyId: story.storyId,
- viewport,
- deviceScaleFactor: 2,
- dimensions: DEFAULT_CAPTURE_DIMENSIONS,
- });
+ await sidebarPage.close();
}
+ // Both derived from the ONE walk above: a surface the report draws and a
+ // capture the pipeline reads can never be two different lists.
+ const discoverySurfaces = planObservations.map((observation) => ({
+ captureId: observation.entry.captureId,
+ contractDomain: observation.entry.area,
+ surface: observation.entry.surface,
+ viewport: observation.entry.viewport,
+ railDiscovery: observation.railDiscovery,
+ }));
+ const coverageCaptures = planObservations.map(({ entry }) => ({
+ captureId: entry.captureId,
+ area: entry.area,
+ surface: entry.surface,
+ storyId: entry.storyId,
+ ...(entry.storyGlobals ? { storyGlobals: entry.storyGlobals } : {}),
+ viewport: entry.viewport,
+ deviceScaleFactor: entry.deviceScaleFactor,
+ dimensions: entry.dimensions,
+ }));
+
const details = [...workspaceDetails, ...sessionDetails]
.map((detail, sourceIndex) => ({ detail, sourceIndex }))
.sort(
@@ -2385,72 +1944,14 @@ test('captures the visual geometry report', async ({ browser }) => {
);
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,
- })),
- };
- }),
- }
- : {}),
- };
- }),
+ captures: planObservations.map((observation) =>
+ buildGeometryCapture(
+ observation,
+ detailsByCaptureId
+ .get(observation.entry.captureId)
+ ?.find((detail) => detail.kind === 'overview')?.images.clean ?? ''
+ )
+ ),
};
const capturePath = path.join(outputDirectory, 'capture.json');
const observationPath = path.join(outputDirectory, 'observation.json');
@@ -2666,10 +2167,43 @@ test('captures the visual geometry report', async ({ browser }) => {
: []
)
);
+ const findingLedgerStatus = (finding: GeometryFinding): GeometryLedgerStatus | 'changed' =>
+ newFindingKeys.has(finding.key)
+ ? 'new'
+ : changedFindingKeys.has(finding.key)
+ ? 'changed'
+ : (ledger.findings[finding.key]?.status ?? 'new');
+ /**
+ * Cards fold by REPAIR, not by label. Ten rows that share one wrong padding
+ * are ten reviewed findings — the keys, the baselines and the ratchet are
+ * untouched — and exactly one ticket, so the report shows one card and names
+ * the findings it stands for. Folding never crosses a ledger status: a `new`
+ * finding hidden under a reviewed one would be a review that never happened.
+ */
+ const repairGroupKey = (finding: GeometryFinding) =>
+ finding.repairGroup ? `${finding.repairGroup}\u0000${findingLedgerStatus(finding)}` : undefined;
+ const repairGroups = new Map();
+ for (const finding of persistedFindings.findings) {
+ const key = repairGroupKey(finding);
+ if (!key) continue;
+ repairGroups.set(key, [...(repairGroups.get(key) ?? []), finding]);
+ }
+ const repairGroupLead = new Map(
+ [...repairGroups.entries()].map(([key, members]) => [
+ key,
+ [...members].sort(
+ (left, right) =>
+ Math.abs(right.offset) - Math.abs(left.offset) || left.key.localeCompare(right.key)
+ )[0]?.key,
+ ])
+ );
+
// 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 groupKey = repairGroupKey(finding);
+ if (groupKey && repairGroupLead.get(groupKey) !== finding.key) return [];
const evidence = finding.evidence[0];
if (!evidence) return [];
let representative = rawDetailByFindingKey.get(finding.key);
@@ -2757,17 +2291,21 @@ test('captures the visual geometry report', async ({ browser }) => {
const repairProposal = finding.repairProposal
? formatRepairProposal(finding.repairProposal)
: undefined;
- const repairFinding = repairProposal ? ` · ${repairProposal}` : '';
+ // The collapsed summary line drops the class list the card body keeps: a
+ // Tailwind class list is hundreds of characters and would swamp it.
+ const repairFinding = finding.repairProposal
+ ? ` · ${formatRepairProposal(finding.repairProposal, { includeClassName: false })}`
+ : '';
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 ledgerStatus = findingLedgerStatus(finding);
+ const foldedKeys = (groupKey ? (repairGroups.get(groupKey) ?? []) : [])
+ .map((member) => member.key)
+ .filter((key) => key !== finding.key)
+ .sort();
const baseline = ledger.findings[finding.key]?.baseline?.offset;
const inkCenters = inkCentersByFindingKey.get(finding.key);
const inkCenterWitness = inkCenters
@@ -2790,6 +2328,8 @@ test('captures the visual geometry report', async ({ browser }) => {
...(dimensionSensitivity.length > 0 ? { dimensionSensitivity } : {}),
...(repairProposal ? { repairProposal } : {}),
...(inkCenterWitness ? { inkCenterWitness } : {}),
+ ...(finding.repairGroup ? { repairGroup: finding.repairGroup } : {}),
+ ...(foldedKeys.length > 0 ? { repairGroupKeys: foldedKeys } : {}),
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}`,
},
@@ -2852,6 +2392,8 @@ test('captures the visual geometry report', async ({ browser }) => {
totalCaptureCount,
dimensionSensitivity,
repairProposal,
+ repairGroup,
+ repairGroupKeys,
inkCenterWitness,
id,
title,
@@ -2876,6 +2418,8 @@ test('captures the visual geometry report', async ({ browser }) => {
? { dimensionSensitivity }
: {}),
...(repairProposal ? { repairProposal } : {}),
+ ...(repairGroup ? { repairGroup } : {}),
+ ...(repairGroupKeys && repairGroupKeys.length > 0 ? { repairGroupKeys } : {}),
...(inkCenterWitness ? { inkCenterWitness } : {}),
id,
captureId,
@@ -2897,5 +2441,7 @@ test('captures the visual geometry report', async ({ browser }) => {
if (unexpectedNetworkRequests.length > 0) {
throw new Error(`Unexpected network requests: ${unexpectedNetworkRequests.join(', ')}`);
}
- await context.close();
+ for (const planContext of planContexts.values()) {
+ await planContext.close().catch(() => undefined);
+ }
});
diff --git a/packages/components/tests/e2e/chat-workspace-geometry.spec.ts b/packages/components/tests/e2e/chat-workspace-geometry.spec.ts
index df328649e..c3cd49a5b 100644
--- a/packages/components/tests/e2e/chat-workspace-geometry.spec.ts
+++ b/packages/components/tests/e2e/chat-workspace-geometry.spec.ts
@@ -1,4 +1,5 @@
-import { readFile } from 'node:fs/promises';
+import { mkdir, readFile, writeFile } from 'node:fs/promises';
+import path from 'node:path';
import { expect, test } from '@playwright/test';
@@ -12,10 +13,20 @@ import {
validateChatWorkspaceGeometry,
} from '../../src/lib/chat-workspace-geometry';
import {
+ checkGeometryLedgerRatchet,
compileGeometryContracts,
+ formatGeometryRatchetViolations,
+ geometryFindingKeysInRepairGroup,
+ verifyGeometryFixes,
type GeometryContractArtifact,
type GeometryLedger,
+ type GeometryObservationCache,
} from '../../src/lib/geometry-constraint-system';
+import {
+ GEOMETRY_CAPTURE_PLAN,
+ runGeometryCapturePlan,
+ runGeometryFindingPipeline,
+} from './support/geometry-capture-plan';
import {
auditChatWorkspaceSemanticAlignments,
captureChatWorkspaceGeometryScopes,
@@ -29,6 +40,23 @@ import {
validateCompiledGeometryContracts,
} from './support/chat-workspace-geometry';
+/**
+ * Set to write `capture.json` / `observation.json` / `findings.json` beside the
+ * ratchet run. `pnpm geometry:verify-fix` reads them; CI does not need them and
+ * does not set this, so the gate stays a measurement with no artifacts.
+ */
+const pipelineOutputDirectory = process.env.GEOMETRY_PIPELINE_OUTPUT_DIR;
+/**
+ * `pnpm geometry:verify-fix` sets these. The DECISION — is this finding fixed,
+ * and what does the ledger become — is made here in TypeScript, beside the
+ * ratchet it must not regress; the script only applies the file it writes.
+ */
+const verifyFixKeys = (process.env.GEOMETRY_VERIFY_FIX_KEYS ?? '')
+ .split(',')
+ .map((key) => key.trim())
+ .filter(Boolean);
+const verifyFixRepairGroup = process.env.GEOMETRY_VERIFY_FIX_REPAIR_GROUP;
+
const STORY_IDS = {
expanded: 'geometry-chatworkspace--expanded-sidebar',
collapsed: 'geometry-chatworkspace--collapsed-sidebar',
@@ -60,6 +88,81 @@ test('compiled promoted geometry contracts are current and pass by stable locato
expect(violations).toEqual([]);
});
+/**
+ * The ratchet. Every other test here proves a RULE; this one proves the LEDGER,
+ * which until now was a file the report printed and nothing executed: a
+ * baseline could be exceeded, and a brand new finding could appear, with every
+ * check still green.
+ *
+ * It runs the same `capture → observation → findings` pipeline the report runs,
+ * over the same capture plan, with no screenshots — the screenshots are what
+ * make the report a 40-minute review artifact, and none of them are evidence.
+ */
+test('every measured geometry finding stays inside its reviewed ledger baseline', async ({
+ browser,
+}) => {
+ test.setTimeout(1_800_000);
+ const ledger = JSON.parse(
+ await readFile(new URL('../../geometry-ledger.json', import.meta.url), 'utf8')
+ ) as GeometryLedger;
+
+ const observationCache: GeometryObservationCache = new Map();
+ const blockedRequests: string[] = [];
+ const capture = await runGeometryCapturePlan(browser, GEOMETRY_CAPTURE_PLAN, {
+ observationCache,
+ blockedRequests,
+ });
+ expect(blockedRequests, 'Geometry fixture made an external request').toEqual([]);
+ const { observation, findings } = runGeometryFindingPipeline(capture);
+
+ if (pipelineOutputDirectory) {
+ await mkdir(pipelineOutputDirectory, { recursive: true });
+ for (const [name, artifact] of [
+ ['capture.json', capture],
+ ['observation.json', observation],
+ ['findings.json', findings],
+ ] as const) {
+ await writeFile(
+ path.join(pipelineOutputDirectory, name),
+ `${JSON.stringify(artifact, null, 2)}\n`,
+ 'utf8'
+ );
+ }
+ }
+
+ const violations = checkGeometryLedgerRatchet(findings, ledger, capture);
+ expect(
+ violations,
+ `Geometry ledger ratchet failed:\n\n${formatGeometryRatchetViolations(violations)}`
+ ).toEqual([]);
+
+ if (!pipelineOutputDirectory || (verifyFixKeys.length === 0 && !verifyFixRepairGroup)) return;
+ const keys = [
+ ...new Set([
+ ...verifyFixKeys,
+ ...(verifyFixRepairGroup
+ ? geometryFindingKeysInRepairGroup(findings, verifyFixRepairGroup)
+ : []),
+ ]),
+ ];
+ expect(keys, 'No finding matches the requested keys or repair group').not.toEqual([]);
+ const verification = verifyGeometryFixes(findings, ledger, capture, keys);
+ await writeFile(
+ path.join(pipelineOutputDirectory, 'fix-verification.json'),
+ `${JSON.stringify(
+ {
+ version: 1,
+ passed: verification.verifications.every((item) => item.passed),
+ verifications: verification.verifications,
+ ledger: verification.ledger,
+ },
+ null,
+ 2
+ )}\n`,
+ 'utf8'
+ );
+});
+
for (const verificationCase of CHAT_WORKSPACE_GEOMETRY_SPEC.verificationCases) {
test(`${verificationCase.name} satisfies the authenticated chat workspace contract`, async ({
browser,
diff --git a/packages/components/tests/e2e/support/AGENTS.md b/packages/components/tests/e2e/support/AGENTS.md
index d3b9a5248..95d190b68 100644
--- a/packages/components/tests/e2e/support/AGENTS.md
+++ b/packages/components/tests/e2e/support/AGENTS.md
@@ -39,15 +39,14 @@ the overlay and capture cannot disagree about one row.
`auditChatWorkspaceSemanticAlignments` / `…Baselines` use one cap-band measurement, and
all number `[document.body, ...document.body.querySelectorAll('*')]` the same way, so a
marker member and a discovered Y candidate are comparable as ONE `dom-N` element. No
- pass may leave the document mutated; the parity artifact reports unmatched members
- rather than trusting overlapping rectangles.
+ pass may leave the document mutated.
- Accessible names come from one deliberately small accname subset
(`computeAccessibleNameInBrowser`), sized to what `getByRole(…, { name })` resolves for
- the widgets captured, so a captured name is one Playwright resolves. A content-named
- role owns the locator of the primitives inside it. Names are LABELS, never identity: a
- repeated row prints `role “row title”` (the longest direct text in it), another named
- primitive its own text minus its nested controls' names (`Files Close Files` → `Files`),
- an unnamed primitive its row family plus same-role index — never a raw family string.
+ the widgets captured, so a captured name is one Playwright resolves. A content-named role
+ owns the locator of the primitives inside it. Names are LABELS, never identity: a repeated
+ row prints `role “row title”` (its longest direct text), another named primitive its own
+ text minus its nested controls' names (`Files Close Files` → `Files`), an unnamed
+ primitive its row family plus same-role index — never a raw family string.
## Proving the markers can go
@@ -61,16 +60,35 @@ the overlay and capture cannot disagree about one row.
companion for the rule actually being retired: re-ask its marker and Y discovery about
one capture, matched by ELEMENT — `coordinateDelta` is whether they measure an element
alike, `offsetDelta` whether they place the row line alike; a member only one side saw is
- listed, never averaged away. `sidebar.row.visual-center` was the first rule proven this
- way (18/18 members, zero deltas) and its declaration, gate coverage, and product
- `data-geometry-align-*` markers are gone — discovery alone now covers those rows. Wire
- this function into the report again for the NEXT candidate; it is not part of the
- standing report run.
+ listed, never averaged away. `sidebar.row.visual-center` was proven this way (18/18
+ members, zero deltas) and its declaration, gate coverage and product
+ `data-geometry-align-*` markers are gone. Wire this function into the report again for
+ the NEXT candidate; it is not part of the standing run.
-- Replaying a capture — the `--after` repair images, the zoomed Y cards — opens ONE context
- per scale and theme, never one per capture: a viewport can be set on an open context, a
- device scale cannot, and a fresh context's cold cache re-parses the whole Storybook
- bundle, which is minutes of wall clock and a missed readiness deadline.
+## The capture plan
+
+`GEOMETRY_CAPTURE_PLAN` (`geometry-capture-plan.ts`) is the ONE list of captures: the
+report shoots it, the gate only measures it, and both open a story through
+`showGeometryCaptureStory`, so one cannot settle a page the other could not. The
+representative capture alone carries the marker-rule observations, and dropping any capture
+to make the gate cheaper moves every merged offset the ledger recorded.
+
+- Opening a capture — the plan walk, `--after` repair images, zoomed Y cards — opens ONE
+ context per scale and theme, never one per capture: a viewport can be set on an open
+ context, a device scale cannot, and a fresh context's cold cache re-parses the whole
+ Storybook bundle — minutes of wall clock. A fresh PAGE per capture inside it, though: a
+ page that loaded a dozen stories runs out of memory.
+- Every box-model node also records its `class` and the nearest function component above it
+ in the fiber tree (`geometry-react-fiber.ts`: walk `fiber.return`, unwrap `memo` and
+ `forwardRef`). Both are EVIDENCE — they turn a rendered DOM description into a file an
+ agent can open — and neither may reach a key, for the reason an accessible name may not.
+ React 19 has no `_debugSource`, so the NAME is the whole pointer; a node React never
+ rendered has none and nothing fails. `repairGroup` (component, term, edge, owning node)
+ is a label too: it folds report cards and merges no finding.
+- A card clip holds the row plus a margin, draws the row median and verdict anchor only,
+ and names it. Zoomed Y cards are the largest-|offset| findings anywhere. 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 outliers.
## Contract resolution and witnesses
diff --git a/packages/components/tests/e2e/support/chat-workspace-geometry.ts b/packages/components/tests/e2e/support/chat-workspace-geometry.ts
index 121a39a3c..3df51da57 100644
--- a/packages/components/tests/e2e/support/chat-workspace-geometry.ts
+++ b/packages/components/tests/e2e/support/chat-workspace-geometry.ts
@@ -37,6 +37,10 @@ import {
geometryCapBandCenter,
measureGeometryCapBand,
} from '../../../src/lib/geometry-text-cap-band';
+import {
+ geometryElementReactFiber,
+ geometryReactFiberComponentName,
+} from '../../../src/lib/geometry-react-fiber';
import {
evaluateGeometryContractResolutions,
geometryContractRelationProperties,
@@ -646,6 +650,8 @@ export async function installGeometryBrowserHelpers(page: Page): Promise {
`globalThis.__lodyGeometrySelectRowSlots = ${selectVisualRowSlots.toString()};`,
`globalThis.__lodyGeometryIsPaintedShape = ${isGeometryPaintedShape.toString()};`,
`globalThis.__lodyMeasureGeometryBlockAnchors = ${measureGeometryBlockAnchorsInBrowser.toString()};`,
+ `globalThis.__lodyGeometryElementFiber = ${geometryElementReactFiber.toString()};`,
+ `globalThis.__lodyGeometryFiberComponentName = ${geometryReactFiberComponentName.toString()};`,
].join('\n'),
});
}
@@ -687,6 +693,19 @@ export async function captureChatWorkspaceGeometryScopes(
}
).__lodyGeometryIsPaintedShape;
if (!isPaintedShapeStyle) throw new Error('Geometry painted-shape helper is missing');
+ const elementFiber = (
+ globalThis as typeof globalThis & {
+ __lodyGeometryElementFiber?: typeof geometryElementReactFiber;
+ }
+ ).__lodyGeometryElementFiber;
+ const fiberComponentName = (
+ globalThis as typeof globalThis & {
+ __lodyGeometryFiberComponentName?: typeof geometryReactFiberComponentName;
+ }
+ ).__lodyGeometryFiberComponentName;
+ if (!elementFiber || !fiberComponentName) {
+ throw new Error('Geometry React component-name helper is missing');
+ }
const isRendered = (element: Element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
@@ -759,6 +778,24 @@ export async function captureChatWorkspaceGeometryScopes(
.join('');
return `${element.tagName.toLowerCase()}${attributes}`;
};
+ /**
+ * The two source pointers a repair ticket needs. Both are optional and
+ * failure-tolerant: a node React never rendered, or a production build
+ * with no readable fiber, simply contributes no component name.
+ */
+ const sourcePointers = (element: Element) => {
+ const className = element.getAttribute('class')?.replace(/\s+/g, ' ').trim();
+ let component: string | undefined;
+ try {
+ component = fiberComponentName(elementFiber(element));
+ } catch {
+ component = undefined;
+ }
+ return {
+ ...(className ? { className } : {}),
+ ...(component ? { component } : {}),
+ };
+ };
const boxModelPath = (
element: Element,
boundary: Element
@@ -816,6 +853,7 @@ export async function captureChatWorkspaceGeometryScopes(
path.push({
nodeId: idByElement.get(node) ?? 'dom-unknown',
element: describeBoxModelNode(node),
+ ...sourcePointers(node),
...(parent ? { parentId: idByElement.get(parent) ?? 'dom-unknown' } : {}),
startToParent,
endToParent,
diff --git a/packages/components/tests/e2e/support/geometry-capture-plan.ts b/packages/components/tests/e2e/support/geometry-capture-plan.ts
new file mode 100644
index 000000000..757698635
--- /dev/null
+++ b/packages/components/tests/e2e/support/geometry-capture-plan.ts
@@ -0,0 +1,585 @@
+import type { Browser, BrowserContext, Page } from '@playwright/test';
+
+import {
+ CHAT_WORKSPACE_GEOMETRY_ANCHORS,
+ CHAT_WORKSPACE_GEOMETRY_ATTRIBUTE,
+ CHAT_WORKSPACE_GEOMETRY_SPEC,
+} from '../../../src/lib/chat-workspace-geometry';
+import {
+ createGeometryFindings,
+ observeGeometryCaptures,
+ type GeometryCapture,
+ type GeometryCaptureArtifact,
+ type GeometryFindingArtifact,
+ type GeometryObservationArtifact,
+ type GeometryObservationCache,
+ type GeometrySurfaceFamily,
+} from '../../../src/lib/geometry-constraint-system';
+import {
+ auditChatWorkspaceSemanticAlignments,
+ auditChatWorkspaceSemanticBaselines,
+ discoverChatWorkspaceAlignmentRails,
+ measureSettledChatWorkspace,
+ type BrowserAlignmentRailDiscoveryScope,
+ type BrowserSemanticAlignmentEntry,
+ type BrowserSemanticBaselineEntry,
+} from './chat-workspace-geometry';
+
+export const geometryStorybookOrigin = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:6006';
+
+export type GeometryCaptureDimensions = Readonly<{
+ theme: string;
+ locale: string;
+ density: string;
+}>;
+
+/** Storybook's own globals; there is no density global, so it stays constant. */
+export const DEFAULT_CAPTURE_DIMENSIONS: GeometryCaptureDimensions = {
+ theme: 'light',
+ locale: 'en',
+ density: 'default',
+};
+
+/**
+ * One capture the pipeline reads. The report shoots it and the gate only
+ * measures it: both walk THIS list, so a story the report reviews and a story
+ * the ratchet enforces can never drift apart.
+ */
+export type GeometryCapturePlanEntry = Readonly<{
+ captureId: string;
+ /** Short, capture-id-free prefix the report names its detail ids after. */
+ detailId: string;
+ /** Report grouping; also the finding `surfaceFamily`. */
+ area: GeometrySurfaceFamily & ('workspace' | 'session' | 'right-sidebar');
+ surface: string;
+ storyId: string;
+ storyGlobals?: string;
+ viewport: Readonly<{ width: number; height: number }>;
+ deviceScaleFactor: number;
+ dimensions: GeometryCaptureDimensions;
+ aggregateScopes: readonly string[];
+ /** Rendered text proving a globals-driven dimension actually applied. */
+ expectedText?: string;
+ /** Extra readiness selectors this story needs before it is measured. */
+ readySelectors?: readonly string[];
+ /**
+ * Marker-rule observations ride ONE representative capture: they exist to ask
+ * whether discovery has replaced a marker, not to be measured everywhere.
+ */
+ semanticObservations?: boolean;
+}>;
+
+const WIDE_VIEWPORT = { width: 1440, height: 900 } as const;
+
+/**
+ * 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 runtime for evidence nobody reads.
+ */
+export const GEOMETRY_WORKSPACE_DIMENSION_CAPTURES: readonly GeometryCapturePlanEntry[] = [
+ {
+ captureId: 'workspace:wide-expanded-dark',
+ detailId: 'wide-expanded-dark',
+ area: 'workspace',
+ surface: 'Workspace / Chat Landing / Dark',
+ storyId: 'geometry-chatworkspace--expanded-sidebar',
+ storyGlobals: 'theme:dark',
+ viewport: WIDE_VIEWPORT,
+ deviceScaleFactor: 2,
+ dimensions: { ...DEFAULT_CAPTURE_DIMENSIONS, theme: 'dark' },
+ aggregateScopes: ['sidebar.shell'],
+ },
+ {
+ captureId: 'workspace:wide-expanded-zh',
+ detailId: 'wide-expanded-zh',
+ area: 'workspace',
+ surface: 'Workspace / Chat Landing / 中文',
+ storyId: 'geometry-chatworkspace--expanded-sidebar',
+ storyGlobals: 'locale:zh_CN',
+ viewport: WIDE_VIEWPORT,
+ deviceScaleFactor: 2,
+ dimensions: { ...DEFAULT_CAPTURE_DIMENSIONS, locale: 'zh_CN' },
+ aggregateScopes: ['sidebar.shell'],
+ // 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: '对话',
+ },
+];
+
+export const GEOMETRY_WORKSPACE_STATE_CAPTURES: readonly GeometryCapturePlanEntry[] = (
+ [
+ ['landing-submitting', 'Submitting', 'geometry-chatworkspace--submission-pending'],
+ [
+ 'landing-no-machine-download',
+ 'No Machine Download',
+ 'geometry-chatworkspace--no-machine-download',
+ ],
+ [
+ 'landing-no-machine-starting',
+ 'No Machine Starting',
+ 'geometry-chatworkspace--no-machine-starting',
+ ],
+ ['landing-no-agent', 'No Agent', 'geometry-chatworkspace--no-agent-config'],
+ ['landing-long-model', 'Long Model', 'geometry-chatworkspace--long-model'],
+ ['landing-pasted-text', 'Pasted Text', 'geometry-chatworkspace--pasted-text'],
+ ] as const
+).map(([id, name, storyId]) => ({
+ captureId: `workspace:${id}:1440x900`,
+ detailId: id,
+ area: 'workspace' as const,
+ surface: `Workspace / Chat Landing / ${name}`,
+ storyId,
+ viewport: WIDE_VIEWPORT,
+ deviceScaleFactor: 2,
+ dimensions: DEFAULT_CAPTURE_DIMENSIONS,
+ aggregateScopes: ['sidebar.shell'],
+}));
+
+export const GEOMETRY_SESSION_STATE_CAPTURES: readonly GeometryCapturePlanEntry[] = [
+ {
+ captureId: 'session-idle:1440x900',
+ detailId: 'session-idle',
+ area: 'session',
+ surface: 'Chat Session / Idle',
+ storyId: 'sessions-sessionconversationpage--desktop-idle',
+ viewport: WIDE_VIEWPORT,
+ deviceScaleFactor: 2,
+ dimensions: DEFAULT_CAPTURE_DIMENSIONS,
+ aggregateScopes: ['session.page'],
+ },
+ {
+ captureId: 'session-working:1440x900',
+ detailId: 'session-working',
+ area: 'session',
+ surface: 'Chat Session / Working',
+ storyId: 'sessions-sessionconversationpage--desktop-working-settled',
+ viewport: WIDE_VIEWPORT,
+ deviceScaleFactor: 2,
+ dimensions: DEFAULT_CAPTURE_DIMENSIONS,
+ aggregateScopes: ['session.page'],
+ readySelectors: ['[data-stream-phase="indicator-only"]'],
+ },
+ {
+ captureId: 'session-permission:1440x900',
+ detailId: 'session-permission',
+ area: 'session',
+ surface: 'Chat Session / Permission',
+ storyId: 'sessions-sessionconversationpage--desktop-permission-approval',
+ viewport: WIDE_VIEWPORT,
+ deviceScaleFactor: 2,
+ dimensions: DEFAULT_CAPTURE_DIMENSIONS,
+ aggregateScopes: ['session.page'],
+ readySelectors: ['[data-geometry-capture-reveal="true"]:has(.lucide-info)'],
+ },
+ {
+ captureId: 'session-question:1440x900',
+ detailId: 'session-question',
+ area: 'session',
+ surface: 'Chat Session / Agent Question',
+ storyId: 'sessions-sessionconversationpage--desktop-agent-question',
+ viewport: WIDE_VIEWPORT,
+ deviceScaleFactor: 2,
+ dimensions: DEFAULT_CAPTURE_DIMENSIONS,
+ aggregateScopes: ['session.page'],
+ },
+];
+
+export const GEOMETRY_RIGHT_SIDEBAR_STATE_CAPTURES: readonly GeometryCapturePlanEntry[] = (
+ [
+ ['session-right-sidebar-changes', 'Changes', 'sessions-sessionsidepaneltabbar--geometry-report'],
+ ['session-right-sidebar-tabs', 'Tabs', 'sessions-sessionsidepaneltabbar--unified-tabs'],
+ ['session-right-sidebar-empty', 'Empty', 'sessions-sessionsidepaneltabbar--empty-state'],
+ ] as const
+).map(([id, name, storyId]) => ({
+ captureId: `${id}:1440x900`,
+ detailId: id,
+ area: 'right-sidebar' as const,
+ surface: `Chat Session / Right Sidebar / ${name}`,
+ storyId,
+ viewport: WIDE_VIEWPORT,
+ deviceScaleFactor: 2,
+ dimensions: DEFAULT_CAPTURE_DIMENSIONS,
+ aggregateScopes: ['session.side-panel'],
+}));
+
+/** The viewport matrix, minus the representative capture that opens the plan. */
+export const GEOMETRY_WORKSPACE_MATRIX_CAPTURES: readonly GeometryCapturePlanEntry[] =
+ CHAT_WORKSPACE_GEOMETRY_SPEC.verificationCases
+ .filter((verificationCase) => verificationCase.name !== 'wide-expanded')
+ .map((verificationCase) => ({
+ captureId: `workspace:${verificationCase.name}`,
+ detailId: `workspace-${verificationCase.name}`,
+ area: 'workspace' as const,
+ surface: `Workspace / ${verificationCase.name}`,
+ storyId:
+ verificationCase.sidebar === 'expanded'
+ ? 'geometry-chatworkspace--expanded-sidebar'
+ : 'geometry-chatworkspace--collapsed-sidebar',
+ viewport: verificationCase.viewport,
+ deviceScaleFactor: 1,
+ dimensions: DEFAULT_CAPTURE_DIMENSIONS,
+ aggregateScopes: ['sidebar.shell'],
+ }));
+
+export const GEOMETRY_REPRESENTATIVE_CAPTURE_ID = 'workspace:wide-expanded';
+
+export const GEOMETRY_REPRESENTATIVE_CAPTURE: GeometryCapturePlanEntry = {
+ captureId: GEOMETRY_REPRESENTATIVE_CAPTURE_ID,
+ detailId: 'workspace',
+ area: 'workspace',
+ surface: 'Workspace / Chat Landing',
+ storyId: 'geometry-chatworkspace--expanded-sidebar',
+ viewport: WIDE_VIEWPORT,
+ deviceScaleFactor: 2,
+ dimensions: DEFAULT_CAPTURE_DIMENSIONS,
+ aggregateScopes: ['sidebar.shell'],
+ semanticObservations: true,
+};
+
+export const GEOMETRY_CAPTURE_PLAN: readonly GeometryCapturePlanEntry[] = [
+ GEOMETRY_REPRESENTATIVE_CAPTURE,
+ ...GEOMETRY_WORKSPACE_MATRIX_CAPTURES,
+ ...GEOMETRY_WORKSPACE_DIMENSION_CAPTURES,
+ ...GEOMETRY_WORKSPACE_STATE_CAPTURES,
+ ...GEOMETRY_SESSION_STATE_CAPTURES,
+ ...GEOMETRY_RIGHT_SIDEBAR_STATE_CAPTURES,
+];
+
+export async function waitForSessionConversationStory(page: Page): Promise {
+ await page.locator('[data-testid="session-conversation-story"]').waitFor({
+ state: 'visible',
+ timeout: 90_000,
+ });
+ await page.evaluate(async () => {
+ await document.fonts.ready;
+ await new Promise((resolve) => {
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
+ });
+ });
+}
+
+export async function enableGeometryCaptureMode(page: Page): Promise {
+ // 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"] * {
+ pointer-events: none !important;
+ animation: none !important;
+ transition: none !important;
+ }
+ [data-geometry-actions-visible="true"] [data-geometry-hover-action] {
+ opacity: 1 !important;
+ pointer-events: none !important;
+ }
+ [data-geometry-actions-visible="true"] [data-geometry-hover-rest] {
+ opacity: 0 !important;
+ pointer-events: none !important;
+ }
+ [data-geometry-report-capture="true"] [data-geometry-capture-reveal="true"] {
+ opacity: 1 !important;
+ pointer-events: none !important;
+ }
+ `,
+ });
+ const workspace = page.locator(
+ `[${CHAT_WORKSPACE_GEOMETRY_ATTRIBUTE}="${CHAT_WORKSPACE_GEOMETRY_ANCHORS.workspaceShell}"]`
+ );
+ await page.locator('body').evaluate((element) => {
+ element.setAttribute('data-geometry-report-capture', 'true');
+ element.setAttribute('data-geometry-actions-visible', 'true');
+
+ const interactiveSelector =
+ 'button, [role="button"], a[href], input, select, textarea, summary, [tabindex]:not([tabindex="-1"])';
+ for (const candidate of element.querySelectorAll('*')) {
+ if (candidate.closest('[data-geometry-hover-rest]')) continue;
+ const style = getComputedStyle(candidate);
+ const rect = candidate.getBoundingClientRect();
+ const occupiesViewport =
+ rect.width > 0 &&
+ rect.height > 0 &&
+ rect.right >= 0 &&
+ rect.bottom >= 0 &&
+ rect.left <= innerWidth &&
+ rect.top <= innerHeight;
+ const ownsInteraction =
+ candidate.matches(interactiveSelector) || candidate.querySelector(interactiveSelector);
+ if (
+ Number.parseFloat(style.opacity) <= 0.01 &&
+ style.display !== 'none' &&
+ style.visibility !== 'hidden' &&
+ occupiesViewport &&
+ ownsInteraction
+ ) {
+ candidate.setAttribute('data-geometry-capture-reveal', 'true');
+ }
+ }
+ });
+ if ((await workspace.count()) > 0) {
+ await workspace.evaluate((element) => {
+ element.setAttribute('data-geometry-report-capture', 'true');
+ element.setAttribute('data-geometry-actions-visible', 'true');
+ });
+ }
+
+ const revealedSurfaces = page.locator('[data-geometry-capture-reveal="true"]');
+ await revealedSurfaces.count();
+}
+
+export type GeometryReplayCapture = Readonly<{
+ storyId: string;
+ storyGlobals?: string;
+ viewport: Readonly<{ width: number; height: number }>;
+ deviceScaleFactor: number;
+ dimensions?: Readonly<{ theme?: string }>;
+ expectedText?: string;
+ readySelectors?: readonly 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.
+ */
+export function geometryReplayContextKey(capture: GeometryReplayCapture): string {
+ return `${capture.deviceScaleFactor}|${capture.dimensions?.theme === 'dark' ? 'dark' : 'light'}`;
+}
+
+export 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 === geometryStorybookOrigin) {
+ 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 every other pass settles it. Shared by the gate walk, the
+ * `--after` replay and the Y cards, so a card, a repair image and a ratchet
+ * measurement are never taken against a differently composed page.
+ */
+export 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(
+ `${geometryStorybookOrigin}/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 enableGeometryCaptureMode(page);
+ for (const selector of capture.readySelectors ?? []) {
+ await page.locator(selector).first().waitFor({ state: 'attached', timeout: 60_000 });
+ }
+ if (capture.expectedText) {
+ await page
+ .getByText(capture.expectedText, { exact: false })
+ .first()
+ .waitFor({ state: 'visible', timeout: 30_000 });
+ }
+ if (capture.dimensions?.theme === 'dark') {
+ const isDark = await page.evaluate(() => document.documentElement.classList.contains('dark'));
+ if (!isDark) throw new Error(`${capture.storyId} did not apply the dark theme global`);
+ }
+ 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;
+}
+
+export type GeometryPlanObservation = Readonly<{
+ entry: GeometryCapturePlanEntry;
+ railDiscovery: readonly BrowserAlignmentRailDiscoveryScope[];
+ semanticAlignments?: readonly BrowserSemanticAlignmentEntry[];
+ semanticBaselines?: readonly BrowserSemanticBaselineEntry[];
+}>;
+
+/**
+ * Run discovery on an already-settled page for one plan entry. The gate and the
+ * report both come through here, so neither can pass discovery a scope, a
+ * capture id or a surface family the other does not.
+ */
+export async function observeGeometryPlanEntry(
+ page: Page,
+ entry: GeometryCapturePlanEntry,
+ observationCache?: GeometryObservationCache
+): Promise {
+ const railDiscovery = await discoverChatWorkspaceAlignmentRails(page, {
+ aggregateScopes: entry.aggregateScopes,
+ captureId: entry.captureId,
+ surfaceFamily: entry.area,
+ ...(observationCache ? { observationCache } : {}),
+ });
+ if (!entry.semanticObservations) return { entry, railDiscovery };
+ return {
+ entry,
+ railDiscovery,
+ semanticAlignments: await auditChatWorkspaceSemanticAlignments(page),
+ semanticBaselines: await auditChatWorkspaceSemanticBaselines(page),
+ };
+}
+
+/** One capture record, assembled the same way for the report and for the gate. */
+export function buildGeometryCapture(
+ observation: GeometryPlanObservation,
+ screenshot = ''
+): GeometryCapture {
+ const { entry, railDiscovery } = observation;
+ const boxModelNodes = Object.assign(
+ {},
+ ...railDiscovery.map((scope) => scope.capturedScope.boxModelNodes ?? {})
+ );
+ const semanticMembers = (
+ members: BrowserSemanticAlignmentEntry['members'] | BrowserSemanticBaselineEntry['members']
+ ) =>
+ members.map((member) => ({
+ name: member.name,
+ coordinate: member.coordinate,
+ ...(member.primitiveId ? { primitiveId: member.primitiveId } : {}),
+ rect: member.rect,
+ }));
+ const splitGroupLabel = (groupLabel: string) => {
+ const [group, instance] = groupLabel.split(' · ');
+ return { group: group ?? groupLabel, instance: instance ?? null };
+ };
+ return {
+ captureId: entry.captureId,
+ surfaceFamily: entry.area,
+ surface: entry.surface,
+ storyId: entry.storyId,
+ viewport: entry.viewport,
+ deviceScaleFactor: entry.deviceScaleFactor,
+ dimensions: entry.dimensions,
+ screenshot,
+ scopes: railDiscovery.map((scope) => {
+ const { boxModelNodes: _boxModelNodes, ...capturedScope } = scope.capturedScope;
+ return capturedScope;
+ }),
+ boxModelNodes,
+ ...(observation.semanticAlignments
+ ? {
+ semanticAlignments: observation.semanticAlignments.map((item) => ({
+ ...splitGroupLabel(item.groupLabel),
+ axis: item.axis,
+ anchor: item.anchor,
+ status: item.status,
+ line: item.line,
+ members: semanticMembers(item.members),
+ })),
+ // The baseline rules travel with the alignment rules so
+ // marker-removal readiness asks one question of every marker.
+ semanticBaselines: (observation.semanticBaselines ?? []).map((item) => ({
+ ...splitGroupLabel(item.groupLabel),
+ axis: 'y' as const,
+ anchor: 'text-baseline' as const,
+ status: item.status,
+ line: item.line,
+ members: semanticMembers(item.members),
+ })),
+ }
+ : {}),
+ };
+}
+
+/**
+ * Walk the plan and return the capture artifact. `onObserved` is where the
+ * report takes its screenshots; the gate passes nothing, which is the whole
+ * difference between a 40-minute review run and a CI ratchet.
+ */
+export async function runGeometryCapturePlan(
+ browser: Browser,
+ entries: readonly GeometryCapturePlanEntry[],
+ options: Readonly<{
+ observationCache?: GeometryObservationCache;
+ blockedRequests?: string[];
+ onObserved?: (page: Page, observation: GeometryPlanObservation) => Promise;
+ }> = {}
+): Promise {
+ const blockedRequests = options.blockedRequests ?? [];
+ const contexts = new Map();
+ const captures: GeometryCapture[] = [];
+ try {
+ for (const entry of entries) {
+ const contextKey = geometryReplayContextKey(entry);
+ let context = contexts.get(contextKey);
+ if (!context) {
+ context = await openGeometryReplayContext(browser, entry, blockedRequests);
+ contexts.set(contextKey, context);
+ }
+ const page = await showGeometryCaptureStory(context, entry);
+ try {
+ const observation = await observeGeometryPlanEntry(page, entry, options.observationCache);
+ captures.push(buildGeometryCapture(observation));
+ await options.onObserved?.(page, observation);
+ } finally {
+ await page.close();
+ }
+ }
+ } finally {
+ for (const context of contexts.values()) await context.close().catch(() => undefined);
+ }
+ if (blockedRequests.length > 0 && !options.blockedRequests) {
+ throw new Error(`Unexpected network requests: ${blockedRequests.join(', ')}`);
+ }
+ return { version: 1, captures };
+}
+
+export type GeometryPipelineArtifacts = Readonly<{
+ capture: GeometryCaptureArtifact;
+ observation: GeometryObservationArtifact;
+ findings: GeometryFindingArtifact;
+}>;
+
+/**
+ * `capture → observation → findings`, each stage reading only the previous.
+ * The report writes the three files; the gate keeps them in memory. Nothing
+ * else may re-derive a finding, or the ratchet would enforce a number the
+ * review never saw.
+ */
+export function runGeometryFindingPipeline(
+ capture: GeometryCaptureArtifact
+): GeometryPipelineArtifacts {
+ const observation = observeGeometryCaptures(capture);
+ return { capture, observation, findings: createGeometryFindings(capture, observation) };
+}
diff --git a/packages/components/tests/geometry-constraint-system.test.ts b/packages/components/tests/geometry-constraint-system.test.ts
index 24fa84880..f383dc96a 100644
--- a/packages/components/tests/geometry-constraint-system.test.ts
+++ b/packages/components/tests/geometry-constraint-system.test.ts
@@ -14,6 +14,14 @@ import {
evaluateGeometryContractResolutions,
evaluateGeometryContractValues,
explainGeometryOffset,
+ checkGeometryLedgerRatchet,
+ formatGeometryRatchetViolations,
+ formatGeometryRepairProposal,
+ geometryFindingDevicePixel,
+ geometryFindingKeysInRepairGroup,
+ geometryRepairCssProperty,
+ geometryRepairGroupKey,
+ verifyGeometryFixes,
geometryFindingLabel,
geometryIdentityLocator,
geometryRowFamilyKey,
@@ -30,8 +38,12 @@ import {
type GeometryCapturedCandidate,
type GeometryCapturedScope,
type GeometryContract,
+ type GeometryBoxModelPathStep,
+ type GeometryCaptureArtifact,
type GeometryFinding,
+ type GeometryFindingArtifact,
type GeometryLedger,
+ type GeometryRepairProposal,
} from '../src/lib/geometry-constraint-system';
const locator = (name: string) => ({
@@ -171,6 +183,77 @@ describe('geometry constraint artifacts', () => {
]);
});
+ it('carries the repair group onto the finding without touching its key', () => {
+ // Two rails whose outlier is off by the same declared padding, on the same
+ // component: one ticket, two reviewed findings.
+ const contribution = (padding: number) => ({
+ padding,
+ border: 0,
+ margin: 0,
+ gap: 0,
+ layout: 0,
+ });
+ const node = (
+ nodeId: string,
+ padding: number,
+ parentId?: string
+ ): GeometryBoxModelPathStep => ({
+ nodeId,
+ element: 'div[role=button]',
+ className: 'pe-3',
+ component: 'SidebarRowShared',
+ ...(parentId ? { parentId } : {}),
+ startToParent: 0,
+ endToParent: padding,
+ centerToParent: 0,
+ inlineStart: contribution(0),
+ inlineEnd: contribution(padding),
+ });
+ const measuredRail = (suffix: string) =>
+ railCandidates(suffix).map((item) => ({ ...item, boxModelNodeRef: item.primitiveId }));
+ // The outlier's own trailing padding is 4px short of its row's, which is
+ // exactly the offset the rail measured.
+ const boxModelNodes = Object.fromEntries(
+ ['a', 'b'].flatMap((suffix) => [
+ ...['one', 'two', 'three'].map(
+ (name) => [`${name}-${suffix}`, node(`${name}-${suffix}`, 14, 'root')] as const
+ ),
+ [`shifted-${suffix}`, node(`shifted-${suffix}`, 10, 'root')] as const,
+ ['root', { ...node('root', 0), element: 'aside[complementary]' }] as const,
+ ])
+ );
+ const captureArtifact = {
+ version: 1 as const,
+ captures: [
+ {
+ ...capture('one', [scope('sidebar-one', 'sidebar', 200, measuredRail('-a'))]),
+ boxModelNodes,
+ },
+ {
+ ...capture('two', [scope('sidebar-two', 'sidebar', 240, measuredRail('-b'))]),
+ boxModelNodes,
+ },
+ ],
+ };
+ const findings = createGeometryFindings(
+ captureArtifact,
+ observeGeometryCaptures(captureArtifact)
+ );
+
+ const finding = findings.findings[0];
+ expect(finding?.classification).toBe('css-defect');
+ expect(finding?.repairGroup).toBe(finding?.repairProposal?.repairGroup);
+ expect(finding?.repairGroup).toBe(geometryRepairGroupKey(finding!.repairProposal!));
+ // Grouping is evidence, so the key is exactly what it was without it.
+ expect(finding?.key).toBe(
+ alignmentFindingKey({
+ surfaceFamily: 'workspace',
+ locator: geometryIdentityLocator(finding!.locator!),
+ anchor: 'inline-end',
+ })
+ );
+ });
+
it('explains an offset as box-model arithmetic to a common ancestor', () => {
const contribution = (padding: number, border = 0) => ({
padding,
@@ -227,6 +310,8 @@ describe('geometry constraint artifacts', () => {
residual: 0,
repair: {
commonAncestor: 'aside[complementary]',
+ // Grouping evidence: the ticket this offset belongs to, never identity.
+ repairGroup: expect.stringMatching(/^geometry\/repair\//),
edge: 'inline-end',
terms: [
{
@@ -374,7 +459,7 @@ describe('geometry constraint artifacts', () => {
},
},
'geometry/workspace/resolved': {
- status: 'accepted-debt',
+ status: 'debt',
baseline: { offset: 2 },
},
},
@@ -421,7 +506,7 @@ describe('geometry constraint artifacts', () => {
);
expect(second.findings[finding.key]).toEqual({
- status: 'accepted-debt',
+ status: 'debt',
baseline: { offset: 4 },
identity: {
label: 'Shifted',
@@ -734,6 +819,7 @@ describe('geometry constraint artifacts', () => {
expect(explanation?.residual).toBe(0);
expect(explanation?.repair).toEqual({
commonAncestor: 'div[role=tabpanel]',
+ repairGroup: expect.stringMatching(/^geometry\/repair\//),
edge: 'inline-start',
terms: [
{
@@ -1554,7 +1640,7 @@ describe('re-keying a reviewed finding', () => {
version: 1,
findings: {
'geometry/workspace/old-machine': {
- status: 'accepted-debt',
+ status: 'debt',
reason: 'Reviewed: optical centring of the machine icon.',
baseline: { offset: -4 },
identity: {
@@ -1565,7 +1651,7 @@ describe('re-keying a reviewed finding', () => {
},
},
'geometry/workspace/old-zh-machine': {
- status: 'accepted-debt',
+ status: 'debt',
baseline: { offset: -4 },
identity: {
label: '机器',
@@ -1575,7 +1661,7 @@ describe('re-keying a reviewed finding', () => {
},
},
'geometry/workspace/old-unreviewed': {
- status: 'accepted-debt',
+ status: 'debt',
baseline: { offset: 9 },
},
},
@@ -1653,7 +1739,7 @@ describe('re-keying a reviewed finding', () => {
expect(migrated.findings['geometry/workspace/old-machine']).toBeUndefined();
expect(migrated.findings['geometry/workspace/new-machine']).toEqual({
- status: 'accepted-debt',
+ status: 'debt',
reason: 'Reviewed: optical centring of the machine icon.',
// The baseline travels: a structural re-key must not silently accept the
// current offset as the reviewed one.
@@ -1749,3 +1835,416 @@ describe('marker removal readiness', () => {
expect(ready.rules[0]?.matchedMemberCount).toBe(ready.rules[0]?.memberCount);
});
});
+
+describe('geometry ledger ratchet', () => {
+ const ratchetFinding = (
+ key: string,
+ offset: number,
+ captureId = 'workspace:wide-expanded'
+ ): GeometryFinding => ({
+ key,
+ kind: 'alignment-rail',
+ surfaceFamily: 'workspace',
+ locator: locator('Shifted'),
+ label: `label for ${key}`,
+ axis: 'x',
+ anchor: 'inline-end',
+ offset,
+ captureCount: 1,
+ totalCaptureCount: 1,
+ evidence: [
+ {
+ captureId,
+ scopeKey: 'sidebar.shell',
+ coordinate: offset,
+ line: 0,
+ normalizedLine: 0.5,
+ offset,
+ yStart: 0,
+ yEnd: 16,
+ },
+ ],
+ });
+
+ const captures = (scales: Readonly>): GeometryCaptureArtifact => ({
+ version: 1,
+ captures: Object.entries(scales).map(([captureId, deviceScaleFactor]) => ({
+ captureId,
+ surfaceFamily: 'workspace',
+ surface: 'Workspace',
+ storyId: 'story',
+ viewport: { width: 1440, height: 900 },
+ deviceScaleFactor,
+ screenshot: '',
+ scopes: [],
+ })),
+ });
+
+ const retina = captures({ 'workspace:wide-expanded': 2 });
+
+ it('allows a device pixel of drift above the reviewed baseline and no more', () => {
+ const ledger: GeometryLedger = {
+ version: 1,
+ findings: { 'geometry/workspace/a': { status: 'debt', baseline: { offset: -2 } } },
+ };
+
+ // 2.5 is exactly baseline + one device pixel at 2x; 2.51 is past it.
+ expect(
+ checkGeometryLedgerRatchet(
+ { version: 1, findings: [ratchetFinding('geometry/workspace/a', 2.5)] },
+ ledger,
+ retina
+ )
+ ).toEqual([]);
+ const regressed = checkGeometryLedgerRatchet(
+ { version: 1, findings: [ratchetFinding('geometry/workspace/a', 2.51)] },
+ ledger,
+ retina
+ );
+ expect(regressed).toEqual([
+ expect.objectContaining({
+ kind: 'offset-regression',
+ key: 'geometry/workspace/a',
+ label: 'label for geometry/workspace/a',
+ status: 'debt',
+ baseline: -2,
+ current: 2.51,
+ tolerance: 0.5,
+ }),
+ ]);
+ // The message has to carry enough to act on without opening the artifact.
+ const message = formatGeometryRatchetViolations(regressed);
+ expect(message).toContain('geometry/workspace/a');
+ expect(message).toContain('baseline -2.000px → current 2.510px');
+ });
+
+ it('fails a finding no ledger entry reviews and says how to record it', () => {
+ const violations = checkGeometryLedgerRatchet(
+ { version: 1, findings: [ratchetFinding('geometry/workspace/unknown', 0.1)] },
+ { version: 1, findings: {} },
+ retina
+ );
+
+ expect(violations).toEqual([
+ expect.objectContaining({ kind: 'unreviewed-finding', key: 'geometry/workspace/unknown' }),
+ ]);
+ expect(formatGeometryRatchetViolations(violations)).toContain('pnpm geometry:triage');
+ });
+
+ it('skips ignored entries and leaves promoted ones to the contract check', () => {
+ const ledger: GeometryLedger = {
+ version: 1,
+ findings: {
+ 'geometry/workspace/ignored': { status: 'ignored', baseline: { offset: 0 } },
+ 'geometry/workspace/promoted': {
+ status: 'promoted',
+ baseline: { offset: 0 },
+ contract: {
+ name: 'workspace.rail',
+ story: 'geometry--landing',
+ members: [locator('First'), locator('Second')],
+ axis: 'x',
+ anchor: 'inline-end',
+ space: 'ink',
+ tolerance: 0.5,
+ },
+ },
+ },
+ };
+
+ expect(
+ checkGeometryLedgerRatchet(
+ {
+ version: 1,
+ findings: [
+ ratchetFinding('geometry/workspace/ignored', 40),
+ ratchetFinding('geometry/workspace/promoted', 40),
+ ],
+ },
+ ledger,
+ retina
+ )
+ ).toEqual([]);
+ });
+
+ it('holds a fixed finding to its near-zero baseline', () => {
+ const ledger: GeometryLedger = {
+ version: 1,
+ findings: { 'geometry/workspace/a': { status: 'fixed', baseline: { offset: 0 } } },
+ };
+
+ expect(
+ checkGeometryLedgerRatchet(
+ { version: 1, findings: [ratchetFinding('geometry/workspace/a', 1.2)] },
+ ledger,
+ retina
+ )
+ ).toEqual([
+ expect.objectContaining({ kind: 'offset-regression', status: 'fixed', baseline: 0 }),
+ ]);
+ });
+
+ it('takes its tolerance from the coarsest capture the finding was measured on', () => {
+ const finding: GeometryFinding = {
+ ...ratchetFinding('geometry/workspace/a', 1),
+ evidence: [
+ { ...ratchetFinding('geometry/workspace/a', 1).evidence[0]!, captureId: 'retina' },
+ { ...ratchetFinding('geometry/workspace/a', 1).evidence[0]!, captureId: 'standard' },
+ ],
+ };
+ const mixed = captures({ retina: 2, standard: 1 });
+
+ expect(geometryFindingDevicePixel(finding, mixed)).toBe(1);
+ // A finding merged across 1x and 2x is only as precise as the 1x capture.
+ expect(
+ checkGeometryLedgerRatchet(
+ { version: 1, findings: [finding] },
+ { version: 1, findings: { 'geometry/workspace/a': { status: 'debt', baseline: { offset: 0 } } } },
+ mixed
+ )
+ ).toEqual([]);
+ });
+
+ it('marks a finding fixed only when it is inside one device pixel', () => {
+ const ledger: GeometryLedger = {
+ version: 1,
+ findings: {
+ 'geometry/workspace/a': { status: 'debt', baseline: { offset: -3 } },
+ 'geometry/workspace/b': { status: 'debt', baseline: { offset: 5 } },
+ },
+ };
+
+ const still = verifyGeometryFixes(
+ { version: 1, findings: [ratchetFinding('geometry/workspace/a', 1.5)] },
+ ledger,
+ retina,
+ ['geometry/workspace/a']
+ );
+ expect(still.verifications[0]).toMatchObject({ passed: false });
+ expect(still.verifications[0]?.reason).toContain('exceeds one device pixel');
+ expect(still.ledger).toBe(ledger);
+
+ const fixed = verifyGeometryFixes(
+ { version: 1, findings: [ratchetFinding('geometry/workspace/a', 0.25)] },
+ ledger,
+ retina,
+ ['geometry/workspace/a']
+ );
+ expect(fixed.verifications[0]).toMatchObject({ passed: true, offset: 0.25 });
+ expect(fixed.ledger.findings['geometry/workspace/a']).toEqual({
+ status: 'fixed',
+ baseline: { offset: 0.25 },
+ });
+ // Untouched entries keep their review.
+ expect(fixed.ledger.findings['geometry/workspace/b']).toEqual(
+ ledger.findings['geometry/workspace/b']
+ );
+ });
+
+ it('treats a finding no rail reports any more as fixed at zero', () => {
+ const ledger: GeometryLedger = {
+ version: 1,
+ findings: { 'geometry/workspace/a': { status: 'debt', baseline: { offset: -3 } } },
+ };
+
+ const result = verifyGeometryFixes({ version: 1, findings: [] }, ledger, retina, [
+ 'geometry/workspace/a',
+ ]);
+
+ expect(result.verifications[0]).toMatchObject({ passed: true, resolved: true });
+ expect(result.ledger.findings['geometry/workspace/a']).toEqual({
+ status: 'fixed',
+ baseline: { offset: 0 },
+ });
+ });
+
+ it('refuses to unpromote a finding its contract already gates', () => {
+ const ledger: GeometryLedger = {
+ version: 1,
+ findings: {
+ 'geometry/workspace/a': {
+ status: 'promoted',
+ baseline: { offset: 0 },
+ contract: {
+ name: 'workspace.rail',
+ story: 'geometry--landing',
+ members: [locator('First'), locator('Second')],
+ axis: 'x',
+ anchor: 'inline-end',
+ space: 'ink',
+ tolerance: 0.5,
+ },
+ },
+ },
+ };
+
+ // Marking it `fixed` would stop compiling the contract and quietly drop the
+ // tightest rule in the file.
+ const result = verifyGeometryFixes(
+ { version: 1, findings: [ratchetFinding('geometry/workspace/a', 0)] },
+ ledger,
+ retina,
+ ['geometry/workspace/a']
+ );
+
+ expect(result.verifications[0]).toMatchObject({ passed: false });
+ expect(result.verifications[0]?.reason).toContain('retire the contract');
+ expect(result.ledger).toBe(ledger);
+ });
+
+ it('refuses to verify a finding no ledger entry reviews', () => {
+ const result = verifyGeometryFixes(
+ { version: 1, findings: [ratchetFinding('geometry/workspace/a', 0)] },
+ { version: 1, findings: {} },
+ retina,
+ ['geometry/workspace/a']
+ );
+
+ expect(result.verifications[0]).toMatchObject({ passed: false });
+ expect(result.verifications[0]?.reason).toContain('no ledger entry');
+ });
+
+ it('compiles no contract from a fixed, debt or wont-fix entry', () => {
+ const ledger: GeometryLedger = {
+ version: 1,
+ findings: {
+ 'geometry/workspace/fixed': { status: 'fixed', baseline: { offset: 0 } },
+ 'geometry/workspace/debt': { status: 'debt', baseline: { offset: 2 } },
+ 'geometry/workspace/wont-fix': { status: 'wont-fix', baseline: { offset: 2 } },
+ },
+ };
+
+ expect(compileGeometryContracts(ledger)).toEqual({ version: 1, contracts: [] });
+ });
+});
+
+describe('geometry repair identity', () => {
+ const proposal = (
+ overrides: Partial & {
+ term?: Partial;
+ } = {}
+ ): GeometryRepairProposal => {
+ const { term, ...rest } = overrides;
+ return {
+ commonAncestor: 'div[data-slot=sidebar-row]',
+ component: 'SidebarRowShared',
+ edge: 'inline-end',
+ terms: [
+ {
+ side: 'member',
+ term: 'padding',
+ element: 'div[role=button]',
+ className: 'pe-3',
+ component: 'SidebarRowShared',
+ memberValue: 12,
+ referenceValue: 10,
+ delta: 2,
+ ...term,
+ },
+ ],
+ ...rest,
+ };
+ };
+
+ it('groups by the edit, not by the element that reported it', () => {
+ const first = geometryRepairGroupKey(proposal());
+ const sameEdit = geometryRepairGroupKey(
+ // A different row, a different label, a different measured value: the
+ // edit that closes it is the same one.
+ proposal({ term: { memberValue: 14, referenceValue: 12, delta: 2, side: 'reference' } })
+ );
+ const otherComponent = geometryRepairGroupKey(
+ proposal({ component: 'ProjectRow', term: { component: 'ProjectRow' } })
+ );
+ const otherEdge = geometryRepairGroupKey(proposal({ edge: 'inline-start' }));
+ const otherTerm = geometryRepairGroupKey(proposal({ term: { term: 'margin' } }));
+
+ expect(first).toBeDefined();
+ expect(sameEdit).toBe(first);
+ expect(otherComponent).not.toBe(first);
+ expect(otherEdge).not.toBe(first);
+ expect(otherTerm).not.toBe(first);
+ });
+
+ it('groups by the common ancestor when React rendered no component name', () => {
+ const withoutComponent = proposal({ component: undefined, term: { component: undefined } });
+
+ expect(geometryRepairGroupKey(withoutComponent)).toBeDefined();
+ expect(geometryRepairGroupKey(withoutComponent)).not.toBe(geometryRepairGroupKey(proposal()));
+ });
+
+ it('has no group when there is nothing to repair', () => {
+ expect(geometryRepairGroupKey({ ...proposal(), terms: [] })).toBeUndefined();
+ });
+
+ it('names the CSS property an agent would actually edit', () => {
+ expect(geometryRepairCssProperty('padding', 'inline-end')).toBe('padding-inline-end');
+ expect(geometryRepairCssProperty('margin', 'block-start')).toBe('margin-block-start');
+ expect(geometryRepairCssProperty('border', 'inline-start')).toBe('border-inline-start-width');
+ // A gap belongs to the axis; `gap-inline-end` is not a property.
+ expect(geometryRepairCssProperty('gap', 'inline-end')).toBe('column-gap');
+ expect(geometryRepairCssProperty('gap', 'block-end')).toBe('row-gap');
+ });
+
+ it('renders a repair a reader can act on without opening the artifact', () => {
+ expect(formatGeometryRepairProposal(proposal())).toBe(
+ 'SidebarRowShared 里 div[role=button] 的 padding-inline-end 多 2px(class: pe-3)'
+ );
+ expect(
+ formatGeometryRepairProposal(proposal({ term: { className: undefined, delta: -2 } }))
+ ).toBe('SidebarRowShared 里 div[role=button] 的 padding-inline-end 少 2px');
+ });
+
+ it('leaves the class list out where a whole class list would not fit', () => {
+ // A real Tailwind class list is hundreds of characters. The card body keeps
+ // it whole, because half of one greps for nothing; the one-line summary
+ // drops it rather than truncate it.
+ expect(formatGeometryRepairProposal(proposal(), { includeClassName: false })).toBe(
+ 'SidebarRowShared 里 div[role=button] 的 padding-inline-end 多 2px'
+ );
+ });
+
+ it('falls back to the common ancestor when React rendered no component name', () => {
+ // A node React never rendered still gets a sentence, just a less precise one.
+ expect(
+ formatGeometryRepairProposal(
+ proposal({ component: undefined, term: { component: undefined } })
+ )
+ ).toBe(
+ 'div[data-slot=sidebar-row] 里 div[role=button] 的 padding-inline-end 多 2px(class: pe-3)'
+ );
+ });
+
+ it('collects every finding one repair group covers', () => {
+ const artifact: GeometryFindingArtifact = {
+ version: 1,
+ findings: [
+ { ...ratchetLikeFinding('geometry/workspace/a'), repairGroup: 'geometry/repair/x' },
+ { ...ratchetLikeFinding('geometry/workspace/c'), repairGroup: 'geometry/repair/x' },
+ { ...ratchetLikeFinding('geometry/workspace/b'), repairGroup: 'geometry/repair/y' },
+ ratchetLikeFinding('geometry/workspace/d'),
+ ],
+ };
+
+ expect(geometryFindingKeysInRepairGroup(artifact, 'geometry/repair/x')).toEqual([
+ 'geometry/workspace/a',
+ 'geometry/workspace/c',
+ ]);
+ expect(geometryFindingKeysInRepairGroup(artifact, 'geometry/repair/missing')).toEqual([]);
+ });
+});
+
+function ratchetLikeFinding(key: string): GeometryFinding {
+ return {
+ key,
+ kind: 'alignment-rail',
+ surfaceFamily: 'workspace',
+ label: key,
+ axis: 'x',
+ anchor: 'inline-end',
+ offset: 1,
+ captureCount: 1,
+ totalCaptureCount: 1,
+ evidence: [],
+ };
+}