Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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')
);
Expand Down
2 changes: 2 additions & 0 deletions packages/components/scripts/geometry-report-budget.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/** Exclusive limit, shared by screenshot selection and final artifact validation. */
export const MAX_REPORT_SCREENSHOTS = 80;
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -559,6 +590,33 @@ <h2>明确排除</h2>
</div>
</details>

<section class="visual-candidates" aria-label="Visual repetition candidates">
<h2>Visual repetition candidates · 未分诊</h2>
<p class="meta" id="visual-summary"></p>
<label
>Capture
<select id="visual-capture-filter">
<option value="">全部</option>
</select></label
>
<label
>Evidence
<select id="visual-evidence-filter">
<option value="review">Review candidates · peer ≥ 2</option>
<option value="all">All raw deviations</option>
</select></label
>
<a href="visual-repetition.json">完整 discovery JSON</a>
<p class="meta">
排序仅供 review;不进入 ledger / gate。红框为候选,绿框为 dominant,橙框为
peers,绿线为预期坐标;pitch 是间距,仅标记目标。默认合并同一 atom 的重复边界测量;
完整原始结果见 JSON。
</p>
<div id="visual-candidates"></div>
<button type="button" id="visual-more">显示更多</button>
</section>

<h2>Existing rails</h2>
<div class="toolbar" id="toolbar" role="group" aria-label="按类型筛选"></div>

<div class="detail-reviews" id="detail-reviews"></div>
Expand All @@ -580,6 +638,160 @@ <h2>明确排除</h2>
<script>
const report = JSON.parse(document.getElementById('geometry-report-data').textContent);

const visualCaptures = report.visualRepetition?.captures ?? [];
const rawVisualCandidates = visualCaptures
.flatMap((capture) => {
const atoms = new Map(capture.atoms.map((atom) => [atom.id, atom]));
return capture.deviations.map((deviation) => ({
capture,
deviation,
atom: atoms.get(deviation.atomId),
atoms,
}));
})
.sort((a, b) => b.deviation.score - a.deviation.score);
const rankVisualCandidates = (candidates) =>
[...candidates]
.sort((a, b) => b.deviation.score - a.deviation.score)
.map((candidate, index) => ({ ...candidate, rank: index + 1 }));
const reviewVisualCandidates = rankVisualCandidates(
rawVisualCandidates
.filter(({ deviation }) => deviation.peerSupport >= 2)
.reduce((unique, candidate) => {
// Start/end/center are three views of one edge question. Keep pitch
// separate because it measures spacing along the series axis.
const { capture, deviation, atom } = candidate;
const measureFamily = deviation.measure === 'pitch' ? 'pitch' : 'edge';
const key = `${capture.captureId}\u0000${atom.id}\u0000${deviation.axis}\u0000${measureFamily}`;
const previous = unique.get(key);
if (!previous || previous.deviation.score < deviation.score) unique.set(key, candidate);
return unique;
}, new Map())
.values()
);
const visualCandidates = rankVisualCandidates(rawVisualCandidates);
document.getElementById('visual-summary').textContent =
`${visualCaptures.length} captures · ${visualCaptures.reduce((sum, capture) => sum + capture.atoms.length, 0)} atoms · ${reviewVisualCandidates.length} review candidates / ${visualCandidates.length} raw deviations · 每个 capture 独立计算,尚未跨 capture 确认`;
const visualList = document.getElementById('visual-candidates');
const px = (value) => `${Number(value.toFixed(2))}px`;
function appendVisualCandidate({ capture, deviation, atom, atoms, rank }) {
const card = document.createElement('details');
const heading = document.createElement('summary');
const signedDelta = deviation.value - deviation.expected;
heading.textContent = `#${rank} ${atom.label} · ${deviation.axis}-${deviation.measure} · ${signedDelta >= 0 ? '+' : ''}${px(signedDelta)} · ${capture.captureId}`;
card.append(heading);
// Materialize images only on expansion; retain every ranked candidate.
card.addEventListener('toggle', () => {
if (!card.open || card.dataset.rendered) return;
card.dataset.rendered = 'true';
const evidence = document.createElement('p');
evidence.className = 'evidence';
evidence.textContent = `expected ${px(deviation.expected)} · actual ${px(deviation.value)} · dominant support ${deviation.dominantSupport} · peer support ${deviation.peerSupport} · series ${deviation.seriesSize} · score ${Number(deviation.score.toFixed(2))} · ${deviation.signature}`;
const provenance = document.createElement('p');
provenance.className = 'evidence';
provenance.textContent = `${capture.captureId} · ${capture.viewport.width}×${capture.viewport.height} · DPR ${capture.deviceScaleFactor} · ${JSON.stringify(capture.dimensions ?? {})} · ${atom.id} · ${atom.scope ?? ''} · ${atom.component ?? ''} · ${atom.className ?? ''}`;
card.append(evidence, provenance);
const witnesses = document.createElement('p');
witnesses.className = 'evidence';
const labels = (ids) => ids.map((id) => `${atoms.get(id).label} (${id})`).join(' · ');
witnesses.textContent = `Dominant: ${labels(deviation.dominantAtomIds)} / Peers: ${labels(deviation.peerAtomIds)}`;
card.append(witnesses);
if (!capture.screenshot || !capture.screenshotClip) {
const missing = document.createElement('p');
missing.className = 'evidence';
missing.textContent = '此 capture 未保存整体截图;完整测量见 visual-repetition.json。';
card.append(missing);
return;
}
const clip = capture.screenshotClip;
const figure = document.createElement('div');
figure.className = 'visual-overlay';
const image = document.createElement('img');
image.src = capture.screenshot;
image.alt = `${capture.captureId} · ${atom.label}`;
const ns = 'http://www.w3.org/2000/svg';
const overlay = document.createElementNS(ns, 'svg');
overlay.setAttribute('viewBox', `${clip.x} ${clip.y} ${clip.width} ${clip.height}`);
const addBox = (member, color) => {
const box = document.createElementNS(ns, 'rect');
for (const [key, value] of Object.entries({
x: member.xStart,
y: member.yStart,
width: member.xEnd - member.xStart,
height: member.yEnd - member.yStart,
fill: 'none',
stroke: color,
'stroke-width': 2,
}))
box.setAttribute(key, String(value));
overlay.append(box);
};
deviation.dominantAtomIds.forEach((id) => addBox(atoms.get(id), '#10b981'));
deviation.peerAtomIds.forEach((id) => addBox(atoms.get(id), '#f59e0b'));
addBox(atom, '#ff4050');
if (deviation.measure !== 'pitch') {
const line = document.createElementNS(ns, 'line');
const points =
deviation.axis === 'x'
? {
x1: deviation.expected,
x2: deviation.expected,
y1: clip.y,
y2: clip.y + clip.height,
}
: {
y1: deviation.expected,
y2: deviation.expected,
x1: clip.x,
x2: clip.x + clip.width,
};
for (const [key, value] of Object.entries({
...points,
stroke: '#10b981',
'stroke-width': 1,
'stroke-dasharray': '4 3',
}))
line.setAttribute(key, String(value));
overlay.append(line);
}
figure.append(image, overlay);
card.append(figure);
});
visualList.append(card);
}
const captureFilter = document.getElementById('visual-capture-filter');
const evidenceFilter = document.getElementById('visual-evidence-filter');
for (const capture of visualCaptures) {
const option = document.createElement('option');
option.value = capture.captureId;
option.textContent = `${capture.captureId} (${capture.deviations.length})`;
captureFilter.append(option);
}
let selectedVisualCandidates = reviewVisualCandidates;
let visibleVisualCount = 0;
const visualMore = document.getElementById('visual-more');
function showMoreVisualCandidates() {
const next = selectedVisualCandidates.slice(visibleVisualCount, visibleVisualCount + 30);
next.forEach(appendVisualCandidate);
visibleVisualCount += next.length;
visualMore.hidden = visibleVisualCount === selectedVisualCandidates.length;
visualMore.textContent = `显示更多 (${visibleVisualCount}/${selectedVisualCandidates.length})`;
}
visualMore.addEventListener('click', showMoreVisualCandidates);
const updateVisualCandidates = () => {
const candidates =
evidenceFilter.value === 'all' ? visualCandidates : reviewVisualCandidates;
selectedVisualCandidates = candidates.filter(
({ capture }) => !captureFilter.value || capture.captureId === captureFilter.value
);
visibleVisualCount = 0;
visualList.replaceChildren();
showMoreVisualCandidates();
};
captureFilter.addEventListener('change', updateVisualCandidates);
evidenceFilter.addEventListener('change', updateVisualCandidates);
showMoreVisualCandidates();

const chevronSvg =
'<svg class="chevron" viewBox="0 0 16 16" fill="none" aria-hidden="true">' +
'<path d="M6 4l4 4-4 4" stroke="currentColor" stroke-width="1.5" ' +
Expand Down
56 changes: 56 additions & 0 deletions packages/components/src/lib/geometry-discovery/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# `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.

`visual-capture.ts` projects each persisted capture's complete block-candidate rectangles
into one atom per primitive, deduplicating anchors and overlapping scopes. Mine each capture
independently; DOM ordinals are capture-local references, not durable finding identities.
Labels, scopes and Fiber pointers are report evidence only and never grouping inputs.
The report writes `visual-repetition.json` beside the existing rail artifacts and displays
all ranked deviations as untriaged candidates, outside findings, ledger and gate.
Screenshot overlays reuse the owning capture's clean overview and clip; a capture without
an overview says so explicitly. Dominant/peer primitive references travel with the deviation
as witnesses, never as finding keys.
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` names a primitive within one capture 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.
1 change: 1 addition & 0 deletions packages/components/src/lib/geometry-discovery/CLAUDE.md
65 changes: 65 additions & 0 deletions packages/components/src/lib/geometry-discovery/visual-capture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { GeometryCapture, GeometryCaptureArtifact } from '../geometry-constraint-system';
import { mineVisualDeviations, type VisualAtom } from './visual-repetition';

/**
* Project one capture, never a matrix: DOM ordinals and visual series are local
* to a page. Block candidates retain the complete measured primitive rectangle,
* including primitives outside rows; anchor coordinates are not box extents.
*/
export function projectVisualAtoms(capture: GeometryCapture): VisualAtom[] {
const atoms = new Map<string, VisualAtom>();
for (const scope of capture.scopes) {
for (const candidate of scope.blockCandidates ?? []) {
if (atoms.has(candidate.primitiveId)) continue;
atoms.set(candidate.primitiveId, {
id: candidate.primitiveId,
kind: candidate.kind ?? 'unknown',
xStart: candidate.xStart,
xEnd: candidate.xEnd,
yStart: candidate.yStart,
yEnd: candidate.yEnd,
});
}
}
return [...atoms.values()];
}

/** Discovery evidence only. No finding identity, ledger status or gate verdict. */
export function discoverVisualRepetition(artifact: GeometryCaptureArtifact) {
return {
version: 1 as const,
captures: artifact.captures.map((capture) => {
const atoms = projectVisualAtoms(capture);
const evidence = new Map(
capture.scopes.flatMap((scope) =>
(scope.blockCandidates ?? []).map(
(candidate) => [candidate.primitiveId, candidate] as const
)
)
);
return {
captureId: capture.captureId,
surface: capture.surface,
storyId: capture.storyId,
viewport: capture.viewport,
deviceScaleFactor: capture.deviceScaleFactor,
dimensions: capture.dimensions,
screenshot: capture.screenshot,
atoms: atoms.map((atom) => {
const candidate = evidence.get(atom.id)!;
const node = candidate.boxModelNodeRef
? capture.boxModelNodes?.[candidate.boxModelNodeRef]
: undefined;
return {
...atom,
label: candidate.label,
scope: candidate.sectionScope,
component: node?.component,
className: node?.className,
};
}),
deviations: mineVisualDeviations(atoms),
};
}),
};
}
Loading