From 545e26de062a81a4dd2ce45703ce30f91dcf70ee Mon Sep 17 00:00:00 2001 From: Wibus <62133302+wibus-wee@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:00:43 +0000 Subject: [PATCH 1/4] feat(components): mine layout deviations from visual repetition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The authored contracts only find what a reviewer already wrote down, and they name their members by DOM shape — row family, role, accessible name. That hides the defects worth finding: a layout bug comes from two code paths rendering one visual thing differently, so it correlates with the structural difference, and a structural key files the two paths into different groups and never compares them. So group by what renders alike and mine the expected edge from what the run's best-supported level actually does. Nobody writes down that the indent step is 26px; it is counted. Bias is recall — candidates are ranked, never filtered — so a legitimate indent ladder comes back too and sorts below the stray, whose level has no company. Not wired to the capture run or the gate: a recall-first pass that blocks CI has one natural remedy, raising its thresholds, which destroys the recall it exists for. Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) --- .../src/lib/geometry-discovery/AGENTS.md | 48 +++ .../src/lib/geometry-discovery/CLAUDE.md | 1 + .../geometry-discovery/visual-repetition.ts | 311 ++++++++++++++++++ ...ometry-discovery-visual-repetition.test.ts | 118 +++++++ 4 files changed, 478 insertions(+) create mode 100644 packages/components/src/lib/geometry-discovery/AGENTS.md create mode 120000 packages/components/src/lib/geometry-discovery/CLAUDE.md create mode 100644 packages/components/src/lib/geometry-discovery/visual-repetition.ts create mode 100644 packages/components/tests/geometry-discovery-visual-repetition.test.ts diff --git a/packages/components/src/lib/geometry-discovery/AGENTS.md b/packages/components/src/lib/geometry-discovery/AGENTS.md new file mode 100644 index 000000000..6cb3f07c0 --- /dev/null +++ b/packages/components/src/lib/geometry-discovery/AGENTS.md @@ -0,0 +1,48 @@ +# `components/src/lib/geometry-discovery` — heuristic layout discovery + +`CLAUDE.md` is a symlink to this file. Edit `AGENTS.md` only. Package +[AGENTS.md](../../../AGENTS.md) and [src/lib/AGENTS.md](../AGENTS.md) apply. + +The authored path — `geometry-contracts.json`, its compiled contracts and the ratchet in +[tests/e2e](../../../tests/e2e/AGENTS.md) — only finds what a reviewer already wrote down, +and names its members by DOM shape. This directory is the other half: expectations are +MINED from what the product repeatedly renders, and nobody writes the number down. + +Not yet wired to the capture run or the gate. It is a report, and it stays one until its +findings have been triaged once: a recall-first pass that blocks CI has exactly one natural +remedy, raising its thresholds, which destroys the recall it exists for. + +## Grouping is visual. Never structural. + +Atoms are grouped by what RENDERS alike — the geometry-derived primitive kind, folded where +the difference is not painted (`link`/`button`, `numeric-text`/`text`), and quantised height, +never content-sized width. Never key a group on row family, role, accessible name, or DOM +ancestry. + +The reason is not purity. A layout defect almost always comes from two code paths rendering +one visual thing differently — the sidebar's 26px tree indent slot and the mobile screen's +32px one are separate constants in separate files. So the defect CORRELATES with the +structural difference, and a structural key files the two paths into different groups and +never compares them: the more real the bug, the more reliably it is hidden. The reader +perceives a column because pixels line up, not because elements share a tag. + +`VisualAtom.id` exists to name a finding across runs and must stay out of grouping. The +moment identity decides who is compared with whom, that blindness is back. + +## Levels grow to an ANCHOR, not to a neighbour + +A coordinate joins a level by distance to the level's anchor. Single linkage would let a +run of intermediate values walk one level into the next and merge two indentation depths +into one expectation — the merged level then reads as internally perfect and the deviation +disappears. Same failure the geometric row band avoids on Y. + +## Recall is the bias, and ranking is not filtering + +Nothing is dropped for looking weak. Candidates carry a `score` and are sorted; no +threshold removes one. A legitimate indent ladder therefore comes back as deviations too, +because without being told which level was intended it has to — it ranks low since `score` +falls as a level's own support rises, so a value two boxes share outranks one forty share. + +A missed misalignment is invisible forever; a false one costs a triage glance. Any tie +breaks towards reporting more. Series orientation is measured rather than declared, and +only the axis perpendicular to the run carries expectations worth mining. diff --git a/packages/components/src/lib/geometry-discovery/CLAUDE.md b/packages/components/src/lib/geometry-discovery/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/packages/components/src/lib/geometry-discovery/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/packages/components/src/lib/geometry-discovery/visual-repetition.ts b/packages/components/src/lib/geometry-discovery/visual-repetition.ts new file mode 100644 index 000000000..40906ca67 --- /dev/null +++ b/packages/components/src/lib/geometry-discovery/visual-repetition.ts @@ -0,0 +1,311 @@ +/** + * Heuristic discovery of layout deviations from visual repetition alone. + * + * The authored path (`geometry-contracts.json`) can only find what someone + * already wrote down, and it names its members by DOM shape — role, accessible + * name, row family. That is the wrong ground truth twice over. A layout bug + * almost always comes from two code paths rendering the same visual thing + * differently, so the defect CORRELATES with the DOM difference: grouping by + * DOM shape files the two paths into separate families and never compares + * them. And a reader perceives a column of avatars as a column because the + * pixels line up, not because the elements share a tag. + * + * So nothing here reads structure. Atoms are grouped by what they look like, + * a repeating series is whatever renders as a repeating series, and the + * expected coordinate is mined from what the majority of that series actually + * does. Nobody writes down that the indent step is 26px; it is counted. + * + * The bias is recall. A missed misalignment is invisible forever, while a + * false one costs a triage glance, so nothing is dropped for looking weak — + * candidates are ranked, not filtered, and every tie is broken towards + * reporting more. + */ + +/** + * A rendered box. `id` exists so a finding can be named across runs and never + * takes part in grouping: the moment identity decides who is compared with + * whom, the DOM blindness described above is back. + */ +export type VisualAtom = Readonly<{ + id: string; + /** Geometry-derived primitive kind, never a semantic contract name. */ + kind: string; + xStart: number; + xEnd: number; + yStart: number; + yEnd: number; +}>; + +export type VisualDeviationMeasure = 'start' | 'end' | 'center' | 'pitch'; + +export type VisualDeviation = Readonly<{ + atomId: string; + /** The visual signature whose series this atom deviates inside. */ + signature: string; + axis: 'x' | 'y'; + measure: VisualDeviationMeasure; + value: number; + /** Median of the best-supported level in the same series. */ + expected: number; + delta: number; + /** How many series members share this atom's value. */ + peerSupport: number; + /** How many share the level it deviates from. */ + dominantSupport: number; + seriesSize: number; + /** Higher is more suspicious. Ranking only; never a pass/fail threshold. */ + score: number; +}>; + +export type VisualRepetitionOptions = Readonly<{ + /** + * Shortest run that can carry an expectation at all. Two boxes agreeing is + * a coincidence; three is the weakest thing that can be called usual. + */ + minimumSeriesLength?: number; + /** + * How far apart two coordinates may be and still count as the same level. + * Sits at measurement noise (1/devicePixelRatio of the coarsest capture), + * not at a design tolerance — a real indent step is an order of magnitude + * above it. + */ + levelTolerance?: number; + /** Heights within this distance describe the same kind of box. */ + heightTolerance?: number; + /** + * A gap this many times the series median ends the series. Purely visual + * locality: it separates two lists that happen to render alike, without + * severing a list that a date header interrupts. + */ + seriesBreakRatio?: number; +}>; + +const DEFAULTS = { + minimumSeriesLength: 3, + levelTolerance: 1, + heightTolerance: 1, + seriesBreakRatio: 3, +} as const; + +function median(values: readonly number[]): number { + const sorted = [...values].sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? (sorted[middle - 1]! + sorted[middle]!) / 2 : sorted[middle]!; +} + +/** + * `link` versus `button` is a tag difference and `numeric-text` versus `text` + * is a content difference; neither is visible. Folding them widens each group, + * which is the direction that finds more. + */ +function normalizeKind(kind: string): string { + if (kind === 'link') return 'button'; + if (kind === 'numeric-text') return 'text'; + return kind; +} + +/** + * Width is left out on purpose. A row's label is as wide as its text, so + * keying on width would split one visual series into one group per string + * length and leave nothing to compare. + */ +function visualSignature(atom: VisualAtom, heightTolerance: number): string { + const height = atom.yEnd - atom.yStart; + const bucket = heightTolerance > 0 ? Math.round(height / heightTolerance) : height; + return `${normalizeKind(atom.kind)}|h${bucket}`; +} + +type Level = { readonly values: number[]; readonly atoms: VisualAtom[]; anchor: number }; + +/** + * Levels grow by distance to the level's anchor, never to its nearest member. + * Single linkage would let a chain of intermediate coordinates walk one level + * into the next and quietly merge two indentation depths into one expectation + * — the merged level then looks internally perfect and the deviation vanishes. + */ +function buildLevels( + entries: readonly { readonly atom: VisualAtom; readonly value: number }[], + tolerance: number +): Level[] { + const levels: Level[] = []; + for (const entry of [...entries].sort((left, right) => left.value - right.value)) { + const existing = levels.find((level) => Math.abs(entry.value - level.anchor) <= tolerance); + if (existing) { + existing.values.push(entry.value); + existing.atoms.push(entry.atom); + continue; + } + levels.push({ values: [entry.value], atoms: [entry.atom], anchor: entry.value }); + } + return levels; +} + +/** + * Every member of every non-dominant level is reported. In a legitimate indent + * ladder that means the whole indented half comes back as deviations — which + * is the accepted cost of not knowing in advance which of the two levels was + * intended. They rank low because they have each other: score falls as a + * level's own support rises, so a value only two boxes share outranks one + * forty boxes share, and the stray middle row sorts above the ladder. + */ +function scoreLevels( + levels: readonly Level[], + seriesSize: number, + signature: string, + axis: 'x' | 'y', + measure: VisualDeviationMeasure, + levelTolerance: number +): VisualDeviation[] { + if (levels.length < 2) return []; + const dominant = levels.reduce((best, level) => + level.atoms.length > best.atoms.length ? level : best + ); + const dominantSupport = dominant.atoms.length; + const expected = median(dominant.values); + const deviations: VisualDeviation[] = []; + for (const level of levels) { + if (level === dominant) continue; + const peerSupport = level.atoms.length; + for (const [index, atom] of level.atoms.entries()) { + const value = level.values[index]!; + const delta = Math.abs(value - expected); + if (delta <= levelTolerance) continue; + deviations.push({ + atomId: atom.id, + signature, + axis, + measure, + value, + expected, + delta, + peerSupport, + dominantSupport, + seriesSize, + score: (delta * dominantSupport) / peerSupport, + }); + } + } + return deviations; +} + +/** + * Splits one signature group into runs that read as a single series. Members + * are ordered along the series axis and cut where the gap jumps far past the + * run's own median gap. + */ +function splitIntoSeries( + atoms: readonly VisualAtom[], + axis: 'x' | 'y', + breakRatio: number, + minimumLength: number +): VisualAtom[][] { + const center = (atom: VisualAtom) => + axis === 'y' ? (atom.yStart + atom.yEnd) / 2 : (atom.xStart + atom.xEnd) / 2; + const ordered = [...atoms].sort((left, right) => center(left) - center(right)); + const gaps: number[] = []; + for (let index = 1; index < ordered.length; index += 1) { + gaps.push(center(ordered[index]!) - center(ordered[index - 1]!)); + } + if (gaps.length === 0) return []; + const typicalGap = median(gaps); + const series: VisualAtom[][] = []; + let current: VisualAtom[] = [ordered[0]!]; + for (let index = 1; index < ordered.length; index += 1) { + const gap = gaps[index - 1]!; + if (typicalGap > 0 && gap > typicalGap * breakRatio) { + series.push(current); + current = []; + } + current.push(ordered[index]!); + } + series.push(current); + return series.filter((run) => run.length >= minimumLength); +} + +/** + * Orientation is measured, not declared: whichever axis the boxes spread along + * is the series axis, and the expectations worth mining are the ones + * perpendicular to it. Mining the series axis itself would only rediscover + * that a list advances down the page. + */ +function seriesAxis(atoms: readonly VisualAtom[]): 'x' | 'y' { + const xs = atoms.map((atom) => (atom.xStart + atom.xEnd) / 2); + const ys = atoms.map((atom) => (atom.yStart + atom.yEnd) / 2); + const spread = (values: readonly number[]) => Math.max(...values) - Math.min(...values); + return spread(ys) >= spread(xs) ? 'y' : 'x'; +} + +export function mineVisualDeviations( + atoms: readonly VisualAtom[], + options: VisualRepetitionOptions = {} +): readonly VisualDeviation[] { + const minimumSeriesLength = options.minimumSeriesLength ?? DEFAULTS.minimumSeriesLength; + const levelTolerance = options.levelTolerance ?? DEFAULTS.levelTolerance; + const heightTolerance = options.heightTolerance ?? DEFAULTS.heightTolerance; + const seriesBreakRatio = options.seriesBreakRatio ?? DEFAULTS.seriesBreakRatio; + + const groups = new Map(); + for (const atom of atoms) { + const signature = visualSignature(atom, heightTolerance); + const group = groups.get(signature); + if (group) group.push(atom); + else groups.set(signature, [atom]); + } + + const deviations: VisualDeviation[] = []; + for (const [signature, group] of groups) { + if (group.length < minimumSeriesLength) continue; + const axis = seriesAxis(group); + for (const run of splitIntoSeries(group, axis, seriesBreakRatio, minimumSeriesLength)) { + const edges = + axis === 'y' + ? ([ + ['x', 'start', (atom: VisualAtom) => atom.xStart], + ['x', 'end', (atom: VisualAtom) => atom.xEnd], + ['x', 'center', (atom: VisualAtom) => (atom.xStart + atom.xEnd) / 2], + ] as const) + : ([ + ['y', 'start', (atom: VisualAtom) => atom.yStart], + ['y', 'end', (atom: VisualAtom) => atom.yEnd], + ['y', 'center', (atom: VisualAtom) => (atom.yStart + atom.yEnd) / 2], + ] as const); + + for (const [edgeAxis, measure, read] of edges) { + const entries = run.map((atom) => ({ atom, value: read(atom) })); + deviations.push( + ...scoreLevels( + buildLevels(entries, levelTolerance), + run.length, + signature, + edgeAxis, + measure, + levelTolerance + ) + ); + } + + // Irregular spacing is the same kind of defect seen along the series + // axis, and the series is already ordered, so it costs nothing to mine. + const along = (atom: VisualAtom) => + axis === 'y' ? (atom.yStart + atom.yEnd) / 2 : (atom.xStart + atom.xEnd) / 2; + const pitchEntries = run.slice(1).map((atom, index) => ({ + atom, + value: along(atom) - along(run[index]!), + })); + if (pitchEntries.length >= minimumSeriesLength) { + deviations.push( + ...scoreLevels( + buildLevels(pitchEntries, levelTolerance), + run.length, + signature, + axis, + 'pitch', + levelTolerance + ) + ); + } + } + } + + return deviations.sort((left, right) => right.score - left.score); +} diff --git a/packages/components/tests/geometry-discovery-visual-repetition.test.ts b/packages/components/tests/geometry-discovery-visual-repetition.test.ts new file mode 100644 index 000000000..34e897998 --- /dev/null +++ b/packages/components/tests/geometry-discovery-visual-repetition.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; + +import { + mineVisualDeviations, + type VisualAtom, +} from '../src/lib/geometry-discovery/visual-repetition'; + +/** + * Rows of one visual kind stacked at a fixed pitch, each placed at the left + * edge the caller asks for. Fixtures are synthetic coordinates only; nothing + * here reads a real capture. + */ +function stackedRows( + lefts: readonly number[], + options?: { kinds?: readonly string[] } +): VisualAtom[] { + return lefts.map((left, index) => ({ + id: `row-${index}`, + kind: options?.kinds?.[index] ?? 'image', + xStart: left, + xEnd: left + 24, + yStart: 100 + index * 80, + yEnd: 124 + index * 80, + })); +} + +function leftEdgeDeviations(atoms: readonly VisualAtom[]) { + return mineVisualDeviations(atoms).filter( + (deviation) => deviation.axis === 'x' && deviation.measure === 'start' + ); +} + +describe('mineVisualDeviations', () => { + it('reports the rows a series leaves off its own left edge', () => { + // The reported shape: the run starts and ends on one edge and a block in + // the middle sits elsewhere. Nothing declares which edge is correct; the + // better-supported one wins by count. + const atoms = stackedRows([100, 100, 100, 143, 143, 143, 143, 100, 100, 100]); + + const deviations = leftEdgeDeviations(atoms); + + expect(deviations.map((deviation) => deviation.atomId).sort()).toEqual([ + 'row-3', + 'row-4', + 'row-5', + 'row-6', + ]); + expect(deviations[0]).toMatchObject({ expected: 100, value: 143, delta: 43 }); + }); + + it('stays silent on a series that agrees with itself', () => { + expect(leftEdgeDeviations(stackedRows([100, 100, 100, 100, 100, 100]))).toEqual([]); + }); + + it('does not let intermediate coordinates chain two edges into one level', () => { + // Single linkage would walk 100 through to 126 one step at a time and call + // the whole run a single edge, which is exactly how a real indentation + // difference disappears. Levels grow by distance to their anchor instead. + const atoms = stackedRows([100, 101, 102, 103, 104, 105, 106, 126]); + + const deviations = leftEdgeDeviations(atoms); + + expect(deviations.length).toBeGreaterThan(0); + expect(deviations.map((deviation) => deviation.atomId)).toContain('row-7'); + }); + + it('compares boxes that differ only by tag, because that difference is not visible', () => { + // `link` and `button` are the same painted box. Splitting on them would + // file two code paths into separate groups and never compare them — the + // blindness this whole pass exists to remove. + const atoms = stackedRows([100, 100, 100, 100, 137], { + kinds: ['button', 'link', 'button', 'link', 'link'], + }); + + expect(leftEdgeDeviations(atoms).map((deviation) => deviation.atomId)).toEqual(['row-4']); + }); + + it('does not compare two lists that merely render alike', () => { + // Each list agrees with itself at its own left edge. The gap between them + // is far past the pitch inside either, so they are two series and neither + // becomes evidence about the other. + const first = stackedRows([100, 100, 100, 100]); + const second = stackedRows([300, 300, 300, 300]).map((atom, index) => ({ + ...atom, + id: `second-${index}`, + yStart: atom.yStart + 900, + yEnd: atom.yEnd + 900, + })); + + expect(leftEdgeDeviations([...first, ...second])).toEqual([]); + }); + + it('ranks a value few boxes share above one many share', () => { + // An indent ladder comes back as deviations too — without being told which + // level was intended, it has to. It sorts below the stray because a level + // with company is less suspicious than a level without. + const atoms = stackedRows([100, 100, 100, 100, 100, 100, 100, 100, 112, 126, 126, 126]); + + const deviations = leftEdgeDeviations(atoms); + + expect(deviations[0]?.atomId).toBe('row-8'); + expect(deviations[0]?.peerSupport).toBe(1); + }); + + it('mines irregular spacing along the series axis', () => { + const atoms = stackedRows([100, 100, 100, 100, 100, 100]); + const shifted = atoms.map((atom, index) => + index >= 3 ? { ...atom, yStart: atom.yStart + 31, yEnd: atom.yEnd + 31 } : atom + ); + + const pitch = mineVisualDeviations(shifted).filter( + (deviation) => deviation.measure === 'pitch' + ); + + expect(pitch.map((deviation) => deviation.atomId)).toEqual(['row-3']); + expect(pitch[0]).toMatchObject({ expected: 80, value: 111 }); + }); +}); From f9c988a97ce8e7d25d9879afe2130bec4a2082b2 Mon Sep 17 00:00:00 2001 From: Wibus <62133302+wibus-wee@users.noreply.github.com> Date: Sat, 5 Sep 2026 03:08:52 +0000 Subject: [PATCH 2/4] test(components): add geometry capture probe Model: gpt-5 --- .../components/tests/e2e/scratch-dump.spec.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 packages/components/tests/e2e/scratch-dump.spec.ts diff --git a/packages/components/tests/e2e/scratch-dump.spec.ts b/packages/components/tests/e2e/scratch-dump.spec.ts new file mode 100644 index 000000000..f2e3784fa --- /dev/null +++ b/packages/components/tests/e2e/scratch-dump.spec.ts @@ -0,0 +1,24 @@ +import { writeFile } from 'node:fs/promises'; + +import { test } from '@playwright/test'; + +import type { GeometryObservationCache } from '../../src/lib/geometry-constraint-system'; +import { + GEOMETRY_REPRESENTATIVE_CAPTURE, + runGeometryCapturePlan, +} from './support/geometry-capture-plan'; + +test('dump one real capture', async ({ browser }) => { + test.setTimeout(600_000); + const observationCache: GeometryObservationCache = new Map(); + const blockedRequests: string[] = []; + const capture = await runGeometryCapturePlan(browser, [GEOMETRY_REPRESENTATIVE_CAPTURE], { + observationCache, + blockedRequests, + }); + await writeFile( + '/tmp/geom-run/capture-one.json', + `${JSON.stringify(capture, null, 2)}\n`, + 'utf8' + ); +}); From e9e90b2cca4e8212c82a69420da37cfd7aeeeda1 Mon Sep 17 00:00:00 2001 From: Wibus <62133302+wibus-wee@users.noreply.github.com> Date: Sat, 5 Sep 2026 10:38:09 +0000 Subject: [PATCH 3/4] feat(components): wire visual repetition into geometry report Model: gpt-5 --- ...enerate-chat-workspace-geometry-report.mjs | 2 +- .../scripts/geometry-report-budget.mjs | 2 + .../chat-workspace-geometry-report.html | 180 ++++++++++++++++++ .../src/lib/geometry-discovery/AGENTS.md | 16 +- .../lib/geometry-discovery/visual-capture.ts | 65 +++++++ .../geometry-discovery/visual-repetition.ts | 7 +- .../chat-workspace-geometry-report.spec.ts | 29 ++- .../tests/e2e/geometry-visual-capture.spec.ts | 63 ++++++ .../components/tests/e2e/support/AGENTS.md | 4 + .../e2e/support/chat-workspace-geometry.ts | 5 + .../geometry-discovery-visual-capture.test.ts | 104 ++++++++++ 11 files changed, 469 insertions(+), 8 deletions(-) create mode 100644 packages/components/scripts/geometry-report-budget.mjs create mode 100644 packages/components/src/lib/geometry-discovery/visual-capture.ts create mode 100644 packages/components/tests/e2e/geometry-visual-capture.spec.ts create mode 100644 packages/components/tests/geometry-discovery-visual-capture.test.ts diff --git a/packages/components/scripts/generate-chat-workspace-geometry-report.mjs b/packages/components/scripts/generate-chat-workspace-geometry-report.mjs index e645f0954..5998134d7 100644 --- a/packages/components/scripts/generate-chat-workspace-geometry-report.mjs +++ b/packages/components/scripts/generate-chat-workspace-geometry-report.mjs @@ -3,6 +3,7 @@ import { spawn } from 'node:child_process'; import { createServer } from 'node:net'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { MAX_REPORT_SCREENSHOTS } from './geometry-report-budget.mjs'; const packageRoot = fileURLToPath(new URL('..', import.meta.url)); const templatePath = path.join( @@ -140,7 +141,6 @@ const detailImageBytes = imageStats.reduce((total, { file }) => total + file.siz // A screenshot budget, enforced rather than intended. Cards are chosen by how // much they deviate, so an unbounded report is one nobody opens: the run fails // instead of quietly growing. -const MAX_REPORT_SCREENSHOTS = 80; const assetFiles = (await readdir(path.join(outputDirectory, 'assets'))).filter((name) => name.endsWith('.png') ); diff --git a/packages/components/scripts/geometry-report-budget.mjs b/packages/components/scripts/geometry-report-budget.mjs new file mode 100644 index 000000000..9deea821b --- /dev/null +++ b/packages/components/scripts/geometry-report-budget.mjs @@ -0,0 +1,2 @@ +/** Exclusive limit, shared by screenshot selection and final artifact validation. */ +export const MAX_REPORT_SCREENSHOTS = 80; diff --git a/packages/components/scripts/templates/chat-workspace-geometry-report.html b/packages/components/scripts/templates/chat-workspace-geometry-report.html index 535557d8b..b391da9a1 100644 --- a/packages/components/scripts/templates/chat-workspace-geometry-report.html +++ b/packages/components/scripts/templates/chat-workspace-geometry-report.html @@ -434,6 +434,37 @@ color: var(--text); } + .visual-candidates { + margin: 28px 0; + } + .visual-candidates details { + padding: 12px 0; + border-bottom: 1px solid var(--border); + } + .visual-candidates summary { + cursor: pointer; + } + .visual-candidates .evidence { + color: var(--text-muted); + font-size: 13px; + overflow-wrap: anywhere; + } + .visual-overlay { + position: relative; + margin-top: 12px; + } + .visual-overlay img { + display: block; + width: 100%; + } + .visual-overlay svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + } + /* ---------- lightbox ---------- */ dialog.lightbox { @@ -559,6 +590,25 @@

明确排除

+
+

Visual repetition candidates · 未分诊

+

+ + 完整 discovery JSON +

+ 排序仅供 review;不进入 ledger / gate。红框为候选,绿框为 dominant,橙框为 + peers,绿线为预期坐标;pitch 是间距,仅标记目标。 +

+
+ +
+ +

Existing rails

@@ -580,6 +630,136 @@

明确排除