← Files get-fableARCHIVED FILE
assets/skills/claude-code/design-sync/storybook/compare.mjs
45 KB · Oct 5, 2026 · 18:31 UTC
#!/usr/bin/env node
// Capture harness for matching self-contained previews (<Name>.html rendering
// from _ds_bundle.js) against the repo's own storybook render — the fidelity
// ground truth. This script captures the TWO TRUE IMAGES per story and pairs
// them; it does NOT judge visual similarity and computes no similarity
// heuristics (pixel diffs and text/font scores mislead whenever layout or
// framing legitimately differs — the agent's eyes on the real screenshots are
// the judge). Grading is the working agent's job: Read the sheet PNG
// (storybook | preview), decide match/close/mismatch per story, and record it
// in the grade file (see GRADE FILES below). The only verdicts this script
// emits are factual: the story didn't render in storybook (sb-error), no
// preview cell pairs with the story (unpaired), the cell threw (error).
//
// Per paired story it captures, at full native resolution:
// <out>/_screenshots/compare/raw/<base>__sb.png storybook root screenshot
// <out>/_screenshots/compare/raw/<base>__ds.png preview cell screenshot
// and a sheet PNG per component (<out>/_screenshots/compare/<group>__<Name>.png,
// storybook | preview per story; images are shrunk to fit — the raw/ PNGs are
// the full-resolution authority when in doubt). Sheets and shots are transient
// (package-build wipes <out>).
//
// ALL state is campaign-local and gitignored (.design-sync/.cache/compare/):
// <Name>.grade.json the grading agent's verdicts (see GRADE FILES below)
// <Name>.json capture facts — pairing, shot paths, srcSha,
// spot-check anchors. Reconstructible.
// Nothing is committed — CROSS-MACHINE carry-forward is derived from the
// uploaded project instead (lib/remote-diff.mjs vs its _ds_sync.json):
// a component unchanged vs the upload was already verified at upload time.
//
// LIFECYCLE — one invariant: grades follow the user's SOURCES. The grade
// key is the build-stamped sourceKey (story files, owned preview source,
// story set, preview-affecting config, committed forks — lib/sync-hashes.mjs).
// Styling, bundle, and pipeline-internal churn (compiled bytes, generated
// html, toolchain) never invalidate: artifact churn on source-stable
// components is verified by a sampled [SPOT_CHECK], not wholesale re-grading.
// - Grade key unchanged + fully graded match/close → skipped outright
// ("carried forward"); no capture.
// - Grade key changed → recapture, grade cleared, re-grade from the fresh
// sheet. [STORY_CHANGED] marks the stories whose contract moved (an owned
// preview must be edited); without it, re-grading is usually all that's
// needed. Screenshot bytes are never compared — pixel jitter is irrelevant.
// - [SPOT_CHECK]: full runs re-capture a couple of carried-forward
// components (grades kept) when shared inputs changed, so the lockstep
// assumption keeps earning trust. --spot-check N tunes it (0 disables);
// --spot-check-components A,B names the picks explicitly with the same
// semantics, and is honored on scoped runs too (the §7 step-4 audit).
// - --force recaptures everything AND clears all grades (fresh verdicts) —
// for systemic re-verification, not casual sheet regeneration.
//
// GRADE FILES — written by the agent after Reading the images:
// {"stories": {"<story name>": {"verdict": "match|close|mismatch", "note": "…"}}}
//
// Safe for parallel subagents when scoped via --components to disjoint sets:
// per-component outputs don't collide, and the aggregate report
// (<out>/.compare-report.json, full runs only) is skipped.
//
// Usage:
// node storybook/compare.mjs --out ./ds-bundle \
// --storybook-static .design-sync/sb-reference \
// [--components Button,Tabs] [--max-stories 6] [--force] [--spot-check 2]
// [--spot-check-components Button,Tabs]
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { escapeHtml, exportName, hypothesisLine } from '../lib/common.mjs';
import { KEY_RECIPE, gradeKeyFrom, renderHashFor, sbBaseShaFor } from '../lib/sync-hashes.mjs';
import { serveDir } from './http-serve.mjs';
const argv = process.argv.slice(2);
const flag = (n, d) => { const i = argv.indexOf(`--${n}`); return i < 0 ? d : argv[i + 1]; };
// Unconsumed argv is silently dead otherwise — `--components A B` runs only A.
{
const VALUE_FLAGS = ['out', 'components', 'max-stories', 'storybook-static', 'spot-check', 'spot-check-components'];
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--force') continue;
if (argv[i].startsWith('--') && VALUE_FLAGS.includes(argv[i].slice(2))) { i++; continue; }
console.error(`(unrecognized argument "${argv[i]}" — ignored; multi-component scoping is comma-separated: --components A,B)`);
}
}
const OUT = flag('out') && resolve(flag('out'));
const ONLY = flag('components') ? new Set(flag('components').split(',').map((s) => s.trim()).filter(Boolean)) : null;
const SPOT_PICKS = flag('spot-check-components') ? flag('spot-check-components').split(',').map((s) => s.trim()).filter(Boolean) : [];
const MAX_STORIES = Number(flag('max-stories', '6')) || 6;
const FORCE = argv.includes('--force');
if (!OUT || !existsSync(join(OUT, '.stories-map.json'))) {
console.error('usage: node storybook/compare.mjs --out <ds-bundle> --storybook-static <dir> [--components A,B]');
console.error('(requires <out>/.stories-map.json — run package-build.mjs first)');
process.exit(2);
}
const manifest = JSON.parse(readFileSync(join(OUT, '.stories-map.json'), 'utf8'));
// A manifest stamped under a different recipe can't vouch for its keys —
// drop them so every consumer below (key derivation, shim, capture-json
// provenance) falls back to the render-contract keying, same as the other
// recipe-gated consumers (remote-diff, preview-rebuild).
if (manifest.keyRecipe !== KEY_RECIPE) for (const c of manifest.components ?? []) delete c.sourceKey;
const SB = resolve(flag('storybook-static', manifest.storybookStatic ?? ''));
if (!SB || !existsSync(join(SB, 'iframe.html'))) {
console.error(`[SB_REFERENCE_MISSING] ${SB || '(unset)'} has no iframe.html — build the reference storybook first (npx storybook build -o .design-sync/sb-reference) and pass --storybook-static.`);
process.exit(2);
}
const comps = manifest.components.filter((c) => c.stories.length && (!ONLY || ONLY.has(c.name) || SPOT_PICKS.includes(c.name)));
// A valid pick must not mask a mistyped --components scope — [ZERO_MATCH]
// checks the scope on its own before picks widen comps.
if (ONLY && !comps.some((c) => ONLY.has(c.name))) {
console.error(`[ZERO_MATCH] none of ${[...ONLY].join(', ')} have stories in .stories-map.json`);
process.exit(2);
}
if (!comps.length) {
console.error(ONLY ? `[ZERO_MATCH] none of ${[...ONLY].join(', ')} have stories in .stories-map.json` : '[ZERO_MATCH] no components with stories — compare needs the storybook shape');
process.exit(2);
}
let pw;
try { pw = await import('playwright'); }
catch {
console.error('[NO_CHROMIUM] compare requires playwright — npm i playwright && npx playwright install chromium');
process.exit(2);
}
const squash = (s) => String(s ?? '').replace(/[^a-z0-9]/gi, '').toLowerCase();
// Input fingerprinting for the skip rule: BASE = the whole reference
// storybook + everything shared in the bundle (by exclusion, so a new asset
// dir is automatically covered — no list to maintain); per-component adds its
// preview js, its component dir, and its story set. Hashing is IO-bound — a
// second or two even for big builds, paid once per run. Same machine +
// unchanged inputs ⇒ identical renders, so skipping capture is sound; any
// instability (e.g. cross-machine sb rebuild) just forces a recapture, never
// a stale verdict. Dot-entries are excluded everywhere: they're converter/
// compare scratch that changes every run.
function hashFile(h, p, label) {
h.update(label);
try { h.update(readFileSync(p)); } catch { h.update('∅'); }
}
function hashDir(h, dir, prefix, skip) {
let entries;
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { h.update('∅'); return; }
for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
if (e.name.startsWith('.') || skip?.has(e.name)) continue;
if (e.isDirectory()) hashDir(h, join(dir, e.name), `${prefix}${e.name}/`);
else hashFile(h, join(dir, e.name), `${prefix}${e.name}`);
}
}
// The grade key is the sourceKey package-build STAMPED into the manifest —
// the same value it wrote into the uploaded _ds_sync.json sidecar, so local
// grade carry-forward and remote verified-by-upload can never disagree, and
// the key always describes the artifacts this build produced. Styling,
// bundle, and pipeline internals are NOT part of it — only the user's
// sources re-grade. A manifest from pre-sourceKey scripts falls back to the
// old render-contract key (unknown ⇒ today's behavior).
const oldGradeKeyFor = (c) => gradeKeyFrom(renderHashFor(OUT, c, { stories: c.stories, srcSha: c.srcSha ?? null }));
const gradeKeyFor = (c) => (c.sourceKey ? gradeKeyFrom(c.sourceKey) : oldGradeKeyFor(c));
// Recorded to power the [REFERENCE_STALE?] hint and the driver's
// reference-drift spot-check trigger. Not a skip input.
const SB_BASE_SHA = sbBaseShaFor(SB);
const outH = createHash('sha256');
hashDir(outH, OUT, 'out/', new Set(['_screenshots', '_preview', 'components', '_ds_sync.json']));
const OUT_BASE_SHA = outH.digest('hex');
const { srv: sbSrv, port: sbPort } = await serveDir(SB);
const { srv: outSrv, port: outPort } = await serveDir(OUT);
const cmpDir = join(OUT, '_screenshots', 'compare');
const rawDir = join(cmpDir, 'raw');
const cacheDir = resolve('.design-sync', '.cache', 'compare');
mkdirSync(rawDir, { recursive: true });
mkdirSync(cacheDir, { recursive: true });
// Self-defending: even a sloppy `git add .design-sync` can't commit the cache.
writeFileSync(join(resolve('.design-sync', '.cache'), '.gitignore'), '*\n');
// Pre-pass (no browser): each component's contract state, computed once.
const pre = new Map();
const migrationPool = [];
for (const c of comps) {
const gradeKey = gradeKeyFor(c);
let prevCapture = null;
let grade = null;
try { prevCapture = JSON.parse(readFileSync(join(cacheDir, `${c.name}.json`), 'utf8')); } catch { /* first capture */ }
try { grade = JSON.parse(readFileSync(join(cacheDir, `${c.name}.grade.json`), 'utf8')); } catch { /* ungraded */ }
// Adoption shim — pre-recipe capture jsons carry old-recipe gradeKeys;
// without adoption the first post-flip run would clear every grade.
// Removable once pre-recipe local state has aged out.
if (c.sourceKey && prevCapture && prevCapture.gradeKey !== gradeKey && (prevCapture.keyRecipe ?? 0) !== KEY_RECIPE) {
const adopt = () => {
prevCapture = { ...prevCapture, gradeKey, sourceKey: c.sourceKey, keyRecipe: KEY_RECIPE };
writeFileSync(join(cacheDir, `${c.name}.json`), JSON.stringify(prevCapture, null, 2));
};
if (prevCapture.gradeKey === oldGradeKeyFor(c)) {
// The artifacts are byte-identical to the verified capture — adopt.
adopt();
} else if (prevCapture.srcSha != null && c.srcSha != null && prevCapture.srcSha === c.srcSha &&
!existsSync(resolve('.design-sync', 'previews', `${c.name}.tsx`))) {
// Artifacts churned but story sources provably didn't (and no OWNED
// preview, which srcSha can't vouch for) — adopt; sampled below. A
// null srcSha means story-source resolution FAILED — identity unknown
// is not evidence of stability, so null===null must not adopt.
adopt();
migrationPool.push(c.name);
}
// else: no evidence of source stability — normal rules, re-grades once.
}
// Grade keys must equal story names EXACTLY (spaces and all) — a PascalCase
// or export-style key silently never joins, surfacing much later as a
// confusing "awaiting grade".
if (grade?.stories) {
const known = new Set(c.stories.map((s) => s.name));
const unknown = Object.keys(grade.stories).filter((k) => !known.has(k));
if (unknown.length) {
console.error(` (grade key(s) matching no story for ${c.name}: ${unknown.slice(0, 4).join(', ')} — keys must equal story names exactly, e.g. ${JSON.stringify(c.stories[0]?.name ?? '')})`);
}
}
const storyNames = c.stories.slice(0, MAX_STORIES).map((s) => s.name);
const fullyGraded = !!grade?.stories && storyNames.length > 0 &&
storyNames.every((n) => ['match', 'close'].includes(grade.stories[n]?.verdict));
pre.set(c.name, { gradeKey, prevCapture, grade, fullyGraded });
}
// Spot checks — the lockstep assumption (shared rebuilds render the same
// preview↔story relationship on both sides) should keep earning trust, not
// be trusted blindly after the first sync. On full runs, re-capture a couple
// of carried-forward components whose shared inputs changed since their
// capture, WITHOUT clearing their grades: the agent confirms the fresh sheet
// still matches the recorded verdicts. Their contracts are unchanged, so a
// divergence can't be a component bug — it's systemic by construction. And
// because a systemic failure shows up in any component, a RANDOM sample is
// the right pick: no rotation state, no filesystem assumptions.
const SPOT_CHECK_N = Number(flag('spot-check', '2'));
const spotChecks = new Set();
// Manual picks (--spot-check-components A,B): the sampler's semantics —
// re-capture, grades KEPT, confirm the fresh sheet against the recorded
// verdicts — but with explicit names, and honored on scoped runs where the
// sampler is off (the §7 step-4 audit names its picks explicitly).
// A pick that isn't a fully-graded carried-forward component falls through
// to the normal rules — captured, graded fresh — which is what that state
// needs anyway (there are no trusted verdicts to confirm against).
if (FORCE && SPOT_PICKS.length) {
console.error(' (--force demands fresh verdicts everywhere — --spot-check-components picks are captured and re-graded like everything else)');
}
for (const name of SPOT_PICKS) {
const p = pre.get(name);
// Unknown names warn even under --force — a typo should never be silent.
if (!p) { console.error(`(spot-check pick "${name}" has no stories in .stories-map.json — ignored)`); continue; }
if (FORCE) continue;
if (p.fullyGraded && p.prevCapture?.gradeKey === p.gradeKey) spotChecks.add(name);
else console.error(` (spot-check pick ${name} is not a fully-graded carried-forward component — captured under the normal rules instead)`);
}
if (spotChecks.size) {
console.error(`◉ [SPOT_CHECK] re-verifying ${spotChecks.size} requested carried-forward component(s): ${[...spotChecks].join(', ')} — grades kept; Read their fresh sheets and confirm they still match the recorded grades. Divergence remediation scales with the churned set: a couple of components diverge — re-grade just those; widespread — stop, diagnose, then --force a full pass.`);
}
if (!ONLY && !FORCE && SPOT_CHECK_N > 0) {
const candidates = comps.filter((c) => {
const p = pre.get(c.name);
return !spotChecks.has(c.name) && p.fullyGraded && p.prevCapture?.gradeKey === p.gradeKey &&
(p.prevCapture.sbBaseSha !== SB_BASE_SHA || p.prevCapture.outBaseSha !== OUT_BASE_SHA);
});
for (let i = candidates.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[candidates[i], candidates[j]] = [candidates[j], candidates[i]];
}
const sampled = candidates.slice(0, SPOT_CHECK_N).map((c) => c.name);
for (const n of sampled) spotChecks.add(n);
if (sampled.length) {
console.error(`◉ [SPOT_CHECK] re-verifying ${sampled.length} carried-forward component(s) after shared-input changes: ${sampled.join(', ')} — Read their fresh sheets and confirm they still match the recorded grades. Divergence remediation scales with the churned set: a couple of components diverge — re-grade just those; widespread — stop, diagnose, then --force a full pass.`);
}
}
// Migration canary: adopted-on-evidence components get a one-time sampled
// confirmation — min(5, pool), portal pick mandatory (the render check never
// exercises a single-mode card's non-primary stories). The rest carries on
// trust — an uncapped check would be the storm adoption exists to avoid.
if (migrationPool.length && !FORCE) {
const eligible = migrationPool.filter((n) => {
const p = pre.get(n);
return p.fullyGraded && p.prevCapture?.gradeKey === p.gradeKey && !spotChecks.has(n);
});
const picks = new Set(eligible.filter((n) => pre.get(n).prevCapture?.portal).slice(0, 1));
const rest = eligible.filter((n) => !picks.has(n));
for (let i = rest.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[rest[i], rest[j]] = [rest[j], rest[i]];
}
for (const n of rest) { if (picks.size >= 5) break; picks.add(n); }
for (const n of picks) spotChecks.add(n);
console.error(`◉ [SPOT_CHECK] grade-key migration: adopted ${migrationPool.length} component(s) whose artifacts churned while their sources held${picks.size ? `; re-verifying ${picks.size} of them: ${[...picks].join(', ')} — grades kept; Read their fresh sheets and confirm they still match the recorded grades` : ''}.`);
}
const report = [];
const blockedHosts = new Set();
let warnedStaleRef = false;
let browser;
try {
browser = await pw.chromium.launch(
process.env.DS_CHROMIUM_PATH ? { executablePath: process.env.DS_CHROMIUM_PATH } : {},
);
const sbPage = await browser.newPage({ viewport: { width: 900, height: 700 } });
const dsPage = await browser.newPage({ viewport: { width: 900, height: 700 } });
// Sandboxed shells (Claude Code's Bash sandbox, CI egress policies) are
// inherited by this browser: external story assets (CDN images/fonts) fail
// to load on BOTH panels, so grades can pass while claude.ai/design users
// see different output — the same oracle-blind class as missing fonts.
// Track failed external requests and warn loudly at the end.
for (const p of [sbPage, dsPage]) {
p.on('requestfailed', (r) => {
if (r.failure()?.errorText === 'net::ERR_ABORTED') return; // benign (navigation aborts)
try {
const u = new URL(r.url());
if (u.hostname !== '127.0.0.1' && u.hostname !== 'localhost') blockedHosts.add(u.hostname);
} catch { /* non-URL request */ }
});
}
// Render stabilization — for GRADING comparability, not hashing (grades
// are keyed to contracts, never to pixels): reduced-motion and a frozen
// Date (timers still run — setFixedTime, not install) make both panels
// show the same settled frame — same date rendered on both sides, spinners
// at a consistent state — so the agent judges content, not animation
// timing. Verification-only: shipped previews are untouched and fully
// animated.
for (const p of [sbPage, dsPage]) {
await p.emulateMedia({ reducedMotion: 'reduce' }).catch(() => {});
await p.clock?.setFixedTime(new Date('2030-01-15T12:00:00Z')).catch(() => {});
}
// Fast-forward finite animations, reset infinite ones (spinners) to their
// initial state — playwright-native, no CSS injection that could strand
// fill-mode entrance animations at opacity 0.
const SHOT = { animations: 'disabled', timeout: 8_000 };
// Webfont activation and image decode can land after networkidle — settle
// both before shooting so neither panel is caught mid-font-swap or with
// undecoded images (the sheets must show the settled rendering).
async function settleRender(page) {
await page.evaluate(() => Promise.all([
document.fonts?.ready,
...[...document.images].map((i) => i.decode().catch(() => {})),
])).catch(() => {});
}
let dsErrs = [];
dsPage.on('pageerror', (e) => dsErrs.push(String(e).split('\n')[0]));
// Capture one storybook story: the true root screenshot. Storybook 7+
// renders into #storybook-root; v6 into #root. CSS-in-JS runtimes often
// inject <style>/<script> as the first root child and waitForSelector
// locks onto the first match — wait for CONTENT, not any child.
const SB_ROOT = '#storybook-root, #root';
const SB_CONTENT = `:is(${SB_ROOT}) > :not(style,script,link,meta,template)`;
async function captureStory(id) {
try {
await sbPage.goto(`http://127.0.0.1:${sbPort}/iframe.html?id=${encodeURIComponent(id)}&viewMode=story`, { waitUntil: 'networkidle', timeout: 20_000 });
} catch { /* fall through to the selector wait — slow asset ≠ broken story */ }
const loaded = await sbPage.waitForSelector(SB_CONTENT, { timeout: 8_000 }).then(() => true).catch(() => false);
if (!loaded) {
// .sb-errordisplay is always present as a display:none template — only
// report its text when it's actually visible.
const err = await sbPage.evaluate(() => {
const e = document.querySelector('.sb-errordisplay');
return e && getComputedStyle(e).display !== 'none' ? e.textContent?.slice(0, 160) : 'no storybook root content';
}).catch(() => '?');
return { err };
}
await settleRender(sbPage);
let png = null;
try {
const el = await sbPage.$(SB_ROOT);
png = await el.screenshot(SHOT);
} catch { /* full-page fallback below */ }
if (!png || png.length < 200) {
try { png = await sbPage.screenshot({ ...SHOT, fullPage: false }); } catch { /* keep null */ }
}
return { png };
}
for (const c of comps) {
const gradePath = join(cacheDir, `${c.name}.grade.json`);
const capturePath = join(cacheDir, `${c.name}.json`);
const { gradeKey, prevCapture, fullyGraded } = pre.get(c.name);
// Mutable: the clear block below nulls it, so a non-null grade further
// down is always one that survived this capture.
let { grade } = pre.get(c.name);
// Skip rule — fully graded + grade key unchanged ⇒ the judgment those
// grades encode is still valid: same story contract, same preview source.
// Styling, bundle, and storybook rebuilds alone don't invalidate (both
// sides consume the same CSS and compiled code — lockstep). No capture;
// sheets may have been wiped by a rebuild, but a graded component doesn't
// need them (--force regenerates everything). Spot-check picks are
// captured anyway — grades kept — so the lockstep claim gets re-verified.
if (!FORCE && fullyGraded && prevCapture?.gradeKey === gradeKey && !spotChecks.has(c.name)) {
// Refresh the pendingGrade bit: grading happens AFTER capture, so a
// component graded since its last capture still carries pending:true
// in its json — without this, a post-grading re-run (the grade →
// re-verify → clean loop) could never report it done.
if (prevCapture.pendingGrade !== false) {
writeFileSync(capturePath, JSON.stringify({ ...prevCapture, pendingGrade: false }, null, 2));
prevCapture.pendingGrade = false;
}
report.push({ ...prevCapture, skipped: true });
console.error(`↻ [COMPARE] ${c.name}: sources unchanged & fully graded — carried forward (--force to recapture)`);
continue;
}
// The bundle changed but the reference storybook didn't — if the DS
// source changed, the reference is stale and the sheets you're about to
// grade would show the OLD design. Warn once.
if (!warnedStaleRef && prevCapture &&
prevCapture.sbBaseSha === SB_BASE_SHA && prevCapture.outBaseSha !== OUT_BASE_SHA) {
warnedStaleRef = true;
console.error('! [REFERENCE_STALE?] the bundle changed but .design-sync/sb-reference did not — if the DS source changed, rebuild the reference first (a stale reference makes compare grade against the OLD design)');
}
// Capture feasibility BEFORE touching the grade: a missing build artifact
// makes gradeKeyFor hash '∅' — a phantom "contract change" that would
// destroy a valid grade and then error out without producing a new sheet.
const rel = `components/${c.group}/${c.name}/${c.name}.html`;
if (!existsSync(join(OUT, rel))) {
report.push({ name: c.name, group: c.group, verdict: 'error', reason: `${rel} missing — run package-build.mjs` });
console.error(`✗ [COMPARE] ${c.name}: ${rel} missing`);
continue;
}
// Clear the old grade only when the render contract it judged actually
// changed (or on --force, where the point is a fresh verdict). A PARTIAL
// grade on an unchanged contract stays — those verdicts are still valid;
// the component is only being recaptured because it isn't fully graded
// yet. Styling/bundle changes never reach this branch (not in the key).
if (grade && (FORCE || prevCapture?.gradeKey !== gradeKey)) {
rmSync(gradePath, { force: true });
grade = null;
console.error(` (grade cleared for ${c.name} — ${FORCE ? '--force requested fresh verdicts' : 'contract changed'}; re-grade from the fresh sheet)`);
}
dsErrs = [];
let pageErr = null;
// Both sides capture at the card's declared viewport when the html
// carries one (single-mode cards declare the size the product renders
// at), else the default — same artifact the product reads, no separate
// config plumbing.
const vpMatch = /viewport="(\d+)x(\d+)"/.exec(readFileSync(join(OUT, rel), 'utf8').split('\n', 1)[0] ?? '');
const vp = vpMatch
? { width: Math.min(+vpMatch[1], 2000), height: Math.min(+vpMatch[2], 2000) }
: { width: 900, height: 700 };
await sbPage.setViewportSize(vp);
await dsPage.setViewportSize(vp);
try {
await dsPage.goto(`http://127.0.0.1:${outPort}/${rel}`, { waitUntil: 'networkidle', timeout: 20_000 });
} catch (e) {
// networkidle timeout ≠ broken page — a hanging asset connection still
// leaves the DOM rendered; settle and proceed like the sb side does.
if (/Timeout/i.test(String(e.message ?? e))) console.error(` (networkidle timeout on ${c.name} — capturing after settle)`);
else pageErr = e.message.split('\n')[0];
}
// previewKind: 'module' (compiled .design-sync/previews/<Name>.tsx, cells
// keyed by export name) vs 'fallback' (the floor card — no compiled
// preview module). Fallback still renders, but the
// fix for a mismatch lives in the .tsx, so surface the kind loudly.
const pv = pageErr ? null : await dsPage.evaluate(() => {
const kind = document.querySelector('script[src*="_preview/"]') ? 'module' : 'fallback';
// Module previews list every export in __dsCells (capture happens
// per-story via ?story=, so pairing must not depend on the default
// render mode — a single-mode card shows only one story). Fallback
// previews keep the DOM-cell path.
const dsCells = Array.isArray(window.__dsCells) ? window.__dsCells.slice() : null;
const cells = dsCells
? dsCells.map((label, i) => ({ i, label }))
: [...document.querySelectorAll('section.ds-cell')].map((s, i) => {
const mount = s.querySelector('div[id^="r"]');
const box = (mount ?? s).getBoundingClientRect();
return {
i, label: s.querySelector('h4')?.textContent?.trim() ?? '',
// w/h only gate the element-vs-section screenshot fallback; text only
// feeds the cell-threw error message. Neither is a similarity signal.
w: Math.round(box.width), h: Math.round(box.height),
text: (mount?.textContent ?? '').trim().slice(0, 200),
caught: (mount?.textContent ?? '').trim().startsWith('⚠'),
};
});
// Portal content (Dialog/Tooltip/Toast) renders outside the cells —
// cell crops would miss it, so pair shots fall back to full-page. Only
// counts foreign body children that actually hold content; empty
// injected containers (toast roots, style mounts) don't trip it.
const portal = [...document.body.children].some((el) =>
!el.matches('.ds-grid, .ds-single, section, script, style, link, h4, div[id]') &&
(el.childElementCount > 0 || (el.textContent ?? '').trim().length > 0));
return { kind, cells, portal, perStory: !!dsCells, mode: window.__dsMode ?? 'grid' };
}).catch((e) => { pageErr = String(e).split('\n')[0]; return null; });
if (pageErr || !pv) {
report.push({ name: c.name, group: c.group, verdict: 'error', reason: `preview page failed: ${pageErr}` });
console.error(`✗ [COMPARE] ${c.name}: preview page failed — ${pageErr}`);
continue;
}
// Pair stories → cells: squashed export-name equality first. The order
// fallback (covers renamed/dedup-suffixed exports) engages only when the
// leftover counts agree 1:1 — otherwise structurally-unrelated extras
// (an authored Preview export, a Variants grid, a fallthrough Default)
// would mispair with stories whose exports were dropped at generation
// time, hiding genuinely-unpaired stories behind wrong sheets.
const stories = c.stories.slice(0, MAX_STORIES);
if (c.stories.length > MAX_STORIES) {
console.error(` [STORY_CAP] ${c.name}: comparing first ${MAX_STORIES} of ${c.stories.length} stories — pass --max-stories ${c.stories.length} to cover all`);
}
const cellByLabel = new Map(pv.cells.map((cell) => [squash(cell.label), cell]));
const usedCells = new Set();
const pairs = stories.map((s) => {
// Exact emitted-label first (the generator dedupes colliding keys to
// "Default"/"Default2" — fuzzy matching maps both stories to one cell);
// squash fallback covers hand-owned previews with renamed exports.
const cell =
(s.emitted != null ? cellByLabel.get(squash(s.emitted)) : undefined) ??
cellByLabel.get(squash(s.exportKey ?? exportName(s.name)));
if (cell && !usedCells.has(cell.i)) { usedCells.add(cell.i); return { story: s, cell, pairedBy: 'name' }; }
return { story: s, cell: null, pairedBy: null };
});
const freeCells = pv.cells.filter((cell) => !usedCells.has(cell.i));
const unmatched = pairs.filter((p) => !p.cell);
if (unmatched.length && unmatched.length === freeCells.length) {
for (const p of unmatched) {
const cell = freeCells.shift();
p.cell = cell; p.pairedBy = 'order'; usedCells.add(cell.i);
}
}
// Cells for stories beyond MAX_STORIES are explained by the cap — don't
// report them as unexplained extras.
const overCap = new Set(c.stories.slice(MAX_STORIES).map((s) => squash(s.emitted ?? s.exportKey ?? exportName(s.name))));
const extraCells = pv.cells
.filter((cell) => !usedCells.has(cell.i) && !overCap.has(squash(cell.label)))
.map((cell) => cell.label);
if (extraCells.length) {
// Logged (not just recorded) so §7's triage-by-log flow can see it —
// an owned export whose story was deleted upstream shows up here.
console.error(` (extra cells not matching any story for ${c.name}: ${extraCells.join(', ')})`);
}
// Overlay/portal content in a grid card paints over sibling cells in the
// PRODUCT too (the app renders this same html whole) — single-story cards
// are the fix, not a harness workaround.
if (pv.portal && pv.mode !== 'single') {
console.error(` [PORTAL?] ${c.name}: overlay/portal content renders outside its cells — consider cfg.overrides ${c.name}: {"cardMode": "single"}`);
}
// Capture. Module previews: navigate ?story=<export> per story — each
// story renders ALONE (no portal stacking, shared radio-group names,
// focus contention, or container-measurement interference) at the full
// capture viewport, mirroring how storybook frames the reference side.
// Fallback previews: cell crops from the grid page.
await settleRender(dsPage);
const cellLocators = pv.perStory ? [] : await dsPage.$$('section.ds-cell');
async function cellShot(cell) {
if (pv.portal) return dsPage.screenshot({ ...SHOT, fullPage: false });
const sec = cellLocators[cell.i];
const mount = sec ? await sec.$('div[id^="r"]') : null;
try {
if (mount && cell.w >= 4 && cell.h >= 4) return await mount.screenshot(SHOT);
if (sec) return await sec.screenshot(SHOT);
} catch { /* fall through */ }
return dsPage.screenshot({ ...SHOT, fullPage: false });
}
async function storyShot(label) {
try {
await dsPage.goto(`http://127.0.0.1:${outPort}/${rel}?story=${encodeURIComponent(label)}`, { waitUntil: 'networkidle', timeout: 20_000 });
} catch (e) {
// Hanging asset connection ≠ broken story — settle and capture anyway.
if (!/Timeout/i.test(String(e.message ?? e))) {
return { png: null, caught: true, text: String(e.message ?? e).split('\n')[0] };
}
}
await settleRender(dsPage);
const info = await dsPage.evaluate(() => {
const t = (document.getElementById('r0')?.textContent ?? document.body.textContent ?? '').trim();
return { caught: t.startsWith('⚠'), text: t.slice(0, 200) };
}).catch(() => ({ caught: false, text: '' }));
const png = await dsPage.screenshot({ ...SHOT, fullPage: false }).catch(() => null);
return { png, ...info };
}
const storyResults = [];
for (const p of pairs) {
const sb = await captureStory(p.story.id);
// Keyed by story ID — names can repeat across a component's story files.
const base = `${c.group}__${c.name}__${squash(p.story.id) || squash(p.story.name) || 'story'}`;
if (sb.png) writeFileSync(join(rawDir, `${base}__sb.png`), sb.png);
if (sb.err) {
storyResults.push({ story: p.story.name, id: p.story.id, verdict: 'sb-error', reasons: [sb.err] });
continue;
}
if (!p.cell) {
storyResults.push({
story: p.story.name, id: p.story.id, verdict: 'unpaired',
reasons: [pv.kind === 'fallback'
? 'preview is the floor card (no compiled preview) — author this story in .design-sync/previews/' + c.name + '.tsx'
: `no cell labeled ${p.story.exportKey ?? exportName(p.story.name)} — the .tsx export for this story is missing or renamed`],
});
continue;
}
let dsPng = null;
let caught = false;
let caughtText = '';
if (pv.perStory) {
const shot = await storyShot(p.cell.label);
dsPng = shot.png;
caught = shot.caught;
caughtText = shot.text;
} else {
try { dsPng = await cellShot(p.cell); } catch { /* leave null */ }
caught = !!p.cell.caught;
caughtText = p.cell.text ?? '';
}
if (dsPng) writeFileSync(join(rawDir, `${base}__ds.png`), dsPng);
storyResults.push({
story: p.story.name, id: p.story.id, export: p.cell.label, pairedBy: p.pairedBy,
// Factual error only (the story threw). Visual judgment belongs to the
// grading agent — record it in the .grade.json, not here.
verdict: caught ? 'error' : 'needs-grade',
reasons: caught ? [`story threw: ${caughtText.slice(0, 120)}`] : [],
sbShot: sb.png ? `_screenshots/compare/raw/${base}__sb.png` : null,
dsShot: dsPng ? `_screenshots/compare/raw/${base}__ds.png` : null,
});
}
// Dedup: per-story navigation re-fires module-load errors once per visit.
if (dsErrs.length) storyResults.push({ story: '(page)', verdict: 'error', reasons: [...new Set(dsErrs)].slice(0, 3) });
// [STORY_CHANGED] — the story FILE (srcSha) moved since the last capture.
// This is the signal that an OWNED preview must be edited; a recapture
// without it means lockstep re-rendering or styling/preview changes,
// where re-grading the fresh sheet is usually all that's needed.
// File-level granularity on purpose: the story module compiles whole, so
// its file hash IS the contract. A capture json without the field at all
// (foreign or hand-edited — this harness always writes it, null when
// unknown) is treated as "unknown", never as "changed": comparing absence
// against a present hash would fire a spurious [STORY_CHANGED] for every
// component.
const srcChanged = !!(prevCapture && prevCapture.srcSha !== undefined &&
(prevCapture.srcSha ?? null) !== (c.srcSha ?? null) && (prevCapture.srcSha || c.srcSha));
for (const r of storyResults) r.storyChanged = srcChanged;
// Ownership is by location: a file in .design-sync/previews/ is the
// user's, whatever its content. (A modified file in the generated cache
// gets its own loud per-build warning from package-build — not re-warned
// here.)
const ownedPreview = existsSync(resolve('.design-sync', 'previews', `${c.name}.tsx`));
const storyChanged = storyResults.filter((r) => r.storyChanged).map((r) => r.story);
if (storyChanged.length) {
console.error(`! [STORY_CHANGED] ${c.name}: ${storyChanged.join(', ')} — the story itself changed${ownedPreview
? `; preview is OWNED (.design-sync/previews/${c.name}.tsx) — update it to mirror the new story`
: '; preview is generated and re-derives on the next full package-build'}`);
}
// Sheet: the two true images side by side per story — the artifact the
// grading agent Reads. Images shrink to fit the sheet; the raw/ PNGs are
// the full-resolution authority when the sheet is too small to judge.
const rows = storyResults.map((r) => {
const base = `${c.group}__${c.name}__${squash(r.id ?? r.story) || 'story'}`;
const img = (suffix) => existsSync(join(rawDir, `${base}__${suffix}.png`))
? `<img src="./raw/${base}__${suffix}.png" style="max-width:480px;max-height:420px;display:block">`
: '<div style="width:480px;height:80px;display:flex;align-items:center;justify-content:center;color:#999">(no shot)</div>';
const color = r.verdict === 'needs-grade' ? '#555' : '#d33';
return `<tr><td style="vertical-align:top;padding:8px;font:600 14px system-ui">${escapeHtml(r.story)}<br><span style="color:${color}">${r.verdict}</span><br><span style="font-weight:400;font-size:12px;color:#555">${(r.reasons ?? []).map(escapeHtml).join('<br>')}</span></td>` +
`<td style="vertical-align:top;padding:8px;border-left:1px solid #eee">${img('sb')}</td>` +
`<td style="vertical-align:top;padding:8px;border-left:1px solid #eee">${img('ds')}</td></tr>`;
}).join('\n');
const sheetHtml = `<!doctype html><html><head><meta charset="utf-8"></head><body style="margin:0;background:#fff;width:1180px;font-family:system-ui">` +
`<div style="font:600 18px system-ui;padding:10px">${escapeHtml(c.name)} — storybook (left) vs preview (right)${pv.kind === 'fallback' ? ' — ⚠ FALLBACK preview (no compiled .tsx)' : ''}${pv.portal && !pv.perStory ? ' — portal content: preview side is full-page' : ''}</div>` +
`<table style="border-collapse:collapse"><tr style="font:600 13px system-ui;color:#555"><td style="padding:8px">story</td><td style="padding:8px">storybook</td><td style="padding:8px">preview</td></tr>${rows}</table></body></html>`;
writeFileSync(join(cmpDir, `.sheet-${c.group}__${c.name}.html`), sheetHtml);
try {
await dsPage.goto(`http://127.0.0.1:${outPort}/_screenshots/compare/.sheet-${c.group}__${c.name}.html`, { waitUntil: 'networkidle', timeout: 15_000 });
await dsPage.evaluate(() => Promise.all([...document.images].map((i) => i.decode().catch(() => {}))));
await dsPage.screenshot({ path: join(cmpDir, `${c.group}__${c.name}.png`), fullPage: true });
} catch (e) { console.error(` (sheet skipped for ${c.name} — ${String(e).split('\n')[0]})`); }
const counts = { 'needs-grade': 0, error: 0, unpaired: 0, 'sb-error': 0 };
for (const r of storyResults) counts[r.verdict] = (counts[r.verdict] ?? 0) + 1;
// pendingGrade: the post-capture grade state, written here so consumers
// (the resync driver) read one bit instead of re-implementing this
// harness's verdict vocabulary. The clear block above nulls `grade`, so
// non-null here means the grade survived this capture.
const gradable = storyResults.filter((r) => r.story !== '(page)');
const pendingGrade = !(gradable.length > 0 && gradable.every((r) => ['match', 'close'].includes(grade?.stories?.[r.story]?.verdict)));
const entry = {
name: c.name, group: c.group, previewKind: pv.kind, portal: pv.portal, pendingGrade,
srcSha: c.srcSha ?? null,
sourceKey: c.sourceKey ?? null, keyRecipe: c.sourceKey ? KEY_RECIPE : undefined,
sbBaseSha: SB_BASE_SHA, outBaseSha: OUT_BASE_SHA, gradeKey, counts, extraCells, stories: storyResults,
sheet: `_screenshots/compare/${c.group}__${c.name}.png`,
grade: `.design-sync/.cache/compare/${c.name}.grade.json`,
};
writeFileSync(capturePath, JSON.stringify(entry, null, 2));
report.push(entry);
const summary = Object.entries(counts).filter(([, n]) => n).map(([k, n]) => `${n} ${k}`).join(', ');
const mark = counts.error || counts.unpaired || counts['sb-error'] ? '✗' : '○';
// Grade keys verbatim — graders must use these EXACT strings (the story
// display names), not export names; a mismatched key never joins.
const keyHint = counts['needs-grade']
? ` — grade keys: ${storyResults.filter((r) => r.verdict === 'needs-grade').map((r) => JSON.stringify(r.story)).join(', ')}`
: '';
console.error(`${mark} [COMPARE] ${c.name}: ${summary}${pv.kind === 'fallback' ? ' (fallback preview)' : ''}${keyHint}`);
// Printed only when a signature matches — never a hint without its
// corroborating error.
if (counts.error) {
const firstErr = storyResults.find((r) => r.verdict === 'error')?.reasons?.[0];
const hyp = hypothesisLine(firstErr);
if (firstErr && hyp) {
console.error(` first error: ${firstErr}`);
console.error(hyp);
}
}
}
} finally {
await browser?.close().catch(() => {});
sbSrv.close();
outSrv.close();
}
// .sb-state.json (the driver's reference-drift baseline) is deliberately NOT
// written here: a scoped run verifies only its own components, so writing
// the new reference hash would consume the drift signal on behalf of the
// whole carried set. The driver owns that state (resync.mjs).
// Aggregate only on full runs — parallel --components invocations must not
// clobber each other's view of the world. Grade files are joined in so the
// report answers "what's still ungraded / what did the grader say".
const hard = report.filter((r) => !r.skipped && (r.verdict === 'error' || (r.counts && (r.counts.error || r.counts.unpaired || r.counts['sb-error']))));
if (!ONLY) {
// Prune state for components that left the sync (excluded, renamed, story
// files deleted) — stale jsons read as phantom worklist entries. Full runs
// only: a scoped run must never touch other components' state.
const live = new Set(manifest.components.map((c) => c.name));
for (const f of readdirSync(cacheDir)) {
// Dot-entries are harness state (.sb-state.json), not component jsons.
if (f.startsWith('.')) continue;
const m = /^(.+?)(\.grade)?\.json$/.exec(f);
if (!m || live.has(m[1])) continue;
rmSync(join(cacheDir, f), { force: true });
console.error(` (pruned stale ${f} — component no longer in the sync)`);
}
const withGrades = report.map((r) => {
if (!r.grade) return r;
try { return { ...r, grades: JSON.parse(readFileSync(join(cacheDir, `${r.name}.grade.json`), 'utf8')) }; }
catch { return { ...r, grades: null }; }
});
writeFileSync(join(OUT, '.compare-report.json'), JSON.stringify({ components: withGrades }, null, 2) + '\n');
// Subagent learnings left unmerged are insight lost to the next sync. Nag on
// every full (orchestrator-facing) run so the fold into NOTES.md can't be
// overlooked — the skill treats this line as an upload blocker. Scoped runs
// skip it: a subagent's own in-progress learnings file is expected.
try {
const pendingLearnings = readdirSync(resolve('.design-sync', 'learnings')).filter((f) => f.endsWith('.md'));
if (pendingLearnings.length) {
console.error(`[LEARNINGS_UNMERGED] ${pendingLearnings.length} file(s) in .design-sync/learnings/ — promote [GENERAL] bullets into .design-sync/NOTES.md, then delete the folder. Do not upload while this prints.`);
}
} catch { /* no learnings dir — nothing pending */ }
}
if (blockedHosts.size) {
console.error(`! [ASSETS_BLOCKED] external assets failed to load during capture: ${[...blockedHosts].slice(0, 8).join(', ')}${blockedHosts.size > 8 ? ', …' : ''}. If this shell sandboxes network egress, BOTH panels rendered without these assets and grades can falsely pass while claude.ai/design users see different output. Re-run package-validate.mjs and compare.mjs --force from a shell with egress to these hosts (approve running the command without the sandbox when prompted, or add the hosts to the sandbox allowlist).`);
}
const skipped = report.filter((r) => r.skipped);
const pending = report.filter((r) => !r.skipped && r.counts?.['needs-grade'] && !hard.includes(r));
console.error(`\ncompare: ${report.length} component(s) — ${skipped.length} carried forward unchanged, ${report.length - skipped.length} captured, ${hard.length} with factual failures, ${pending.length} awaiting your grade`);
console.error('Grade from the true images: Read each _screenshots/compare/<group>__<Name>.png sheet (raw/ PNGs are the full-res authority), then Write the verdicts to .design-sync/.cache/compare/<Name>.grade.json (a recapture clears the old grade — its contract changed).');
process.exit(hard.length ? 1 : 0);
SHA-256: 03aee2718e6e7715ad2b35e8bf57b722f894430089e72e78601595539babdd25