From f4987eebba0e29c020cd73fcbbf3969d53a7b18a Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Sat, 15 Aug 2026 20:26:24 -0700 Subject: [PATCH] Hero gate refuses while a produced plate is unreferenced by the source The first live run produced a faithful carburetor plate, then drew the region in SVG and left the plate on disk. Before diffing, the hero gate now walks the artifact (or a bounded source tree) for every plate's file name or a data URI named for it. AI-assisted (Claude). Co-Authored-By: Claude --- skill/reference/new-work.md | 2 +- skill/scripts/build-phase.mjs | 56 +++++++++++++++++++++++++++++++---- tests/build-phase.test.mjs | 7 ++++- 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/skill/reference/new-work.md b/skill/reference/new-work.md index 0017481c1..c7c101d18 100644 --- a/skill/reference/new-work.md +++ b/skill/reference/new-work.md @@ -102,7 +102,7 @@ Then, in order, each closed by `node {{scripts_path}}/build-phase.mjs advance` ( 1. **spec.** Measure the comp: `comp-spec.mjs --comp --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws), and run `comp-spec.mjs --comp --regions `. The spec carries each region's box, sampled palette, and medium; `comp-spec.mjs --print` is the build's reference from here on. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icons (exact match unless the user chose an icon library), and genuine defects in the comp such as spelling errors. 2. **plates.** Every raster region ships as a plate: the region regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path. `generate-image.mjs --plate ` does one region end to end and scores it against the crop; a harness-native image tool takes the crop (`comp-spec.mjs --crop `) as its input image and `comp-spec.mjs --plate-prompt ` as its prompt, then `embed-prompt.mjs`. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason. -3. **hero.** Build only the first viewport, at the comp's own dimensions, from the spec's boxes, palette, and plates; capture it into `.impeccable/review/hero-repro.png` at those dimensions; advance. The gate runs `comp-diff.mjs`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no region missing. When it fails, open the side-by-side and the worst region pairs it names, fix those regions, recapture, and advance again; the numbers rank, the crops decide. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. +3. **hero.** Build only the first viewport, at the comp's own dimensions, from the spec's boxes, palette, and plates (every plate placed in the markup: an ``, a background image, or an inlined data URI named for it); capture it into `.impeccable/review/hero-repro.png` at those dimensions; advance. The gate first refuses while any plate is unreferenced by the source, then runs `comp-diff.mjs`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no region missing. When it fails, open the side-by-side and the worst region pairs it names, fix those regions, recapture, and advance again; the numbers rank, the crops decide. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run. 4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system. 5. **motion.** The signature interaction, reveals, and motion, orchestrated once rather than scattered. 6. **responsive.** The other viewports. A comp'd surface that is mobile-first was comped portrait; the plates were produced for that frame. diff --git a/skill/scripts/build-phase.mjs b/skill/scripts/build-phase.mjs index 173a72110..a03e4ef05 100644 --- a/skill/scripts/build-phase.mjs +++ b/skill/scripts/build-phase.mjs @@ -14,7 +14,7 @@ * responsive the other viewports * review the finish reviewer ran; disposition recorded * - * node build-phase.mjs start --comp [--breakpoint 1440x900] + * node build-phase.mjs start --comp [--breakpoint 1440x900] [--artifact index.html] * node build-phase.mjs status # human-readable, plus NEXT line * node build-phase.mjs status --json * node build-phase.mjs advance # try to close the current phase; runs its gate @@ -29,7 +29,9 @@ * at least 2x the comp region's pixel size in width, and the * plate scores >= PLATE_MIN against the comp crop (comp-diff, * detail-weighted). A missing or thin plate names itself. - * hero -> .impeccable/review/hero-repro.png exists and comp-diff overall + * hero -> every plate is referenced by a source file (the artifact + * named at start, else a bounded walk of the project), and + * .impeccable/review/hero-repro.png exists and comp-diff overall * >= HERO_MIN (default 0.72) with no region `missing`. The * score, the report path, and the attempt count are recorded. * sections / motion / responsive -> no mechanical gate; advancing records @@ -76,13 +78,14 @@ export function saveState(state, statePath = STATE_PATH) { fs.writeFileSync(statePath, JSON.stringify(state, null, 2)); } -export function newState({ comp, breakpoint = null }) { +export function newState({ comp, breakpoint = null, artifact = null }) { return { tool: 'build-phase', version: 1, startedAt: now(), comp, breakpoint, + artifact, phase: 'spec', phases: Object.fromEntries(PHASES.map((p) => [p, { status: p === 'spec' ? 'open' : 'pending', openedAt: p === 'spec' ? now() : null, closedAt: null, attempts: 0, notes: [], gate: null, forced: null }])), finish: null, @@ -136,8 +139,50 @@ export function gatePlates(state, { specPath = SPEC_PATH } = {}) { return { ok: reasons.length === 0, reasons, summary: `${plates.filter((p) => p.status === 'ok').length}/${rasterRegions.length} plates`, plates }; } -export function gateHero(state, { buildPath = HERO_REPRO, specPath = SPEC_PATH, min = HERO_MIN, outDir = path.join('.impeccable', 'review', 'diff', 'hero') } = {}) { +/** Source files that could reference a plate: bounded walk, skipping deps and build output. */ +function sourceFiles(root = '.', limit = 400) { + const out = []; + const skip = new Set(['node_modules', '.git', 'dist', 'build', 'out', '.next', '.svelte-kit', '.impeccable', 'assets', 'coverage']); + const exts = /\.(html?|css|scss|jsx?|tsx?|svelte|vue|astro|mdx?|php|erb|hbs)$/i; + const walk = (dir, depth) => { + if (out.length >= limit || depth > 6) return; + let entries = []; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } + for (const e of entries) { + if (out.length >= limit) return; + if (e.isDirectory()) { if (!skip.has(e.name) && !e.name.startsWith('.')) walk(path.join(dir, e.name), depth + 1); } + else if (exts.test(e.name)) out.push(path.join(dir, e.name)); + } + }; + walk(root, 0); + return out; +} + +/** Plates the artifact never references: a plate on disk that no source names ships nothing. */ +export function unreferencedPlates(spec, artifact = null) { + const plates = (spec?.regions || []).filter((r) => r.medium === 'raster' && r.plate); + if (!plates.length) return []; + const files = artifact && fs.existsSync(artifact) ? [artifact] : sourceFiles(); + let corpus = ''; + for (const f of files) { try { corpus += fs.readFileSync(f, 'utf8') + '\n'; } catch { /* skip */ } } + const missing = []; + for (const r of plates) { + const base = path.basename(r.plate); + const stem = base.replace(/\.[a-z0-9]+$/i, ''); + // a data URI inline copy counts when the region id or file stem is named beside it + if (corpus.includes(base) || (corpus.includes('data:image/') && (corpus.includes(stem) || corpus.includes(r.id)))) continue; + missing.push(r); + } + return missing; +} + +export function gateHero(state, { buildPath = HERO_REPRO, specPath = SPEC_PATH, min = HERO_MIN, outDir = path.join('.impeccable', 'review', 'diff', 'hero'), artifact = null } = {}) { if (!fs.existsSync(buildPath)) return { ok: false, reasons: [`no hero capture at ${buildPath}: screenshot the first viewport at the comp's own dimensions (${state.breakpoint || 'comp size'}) into that path`] }; + const specForRefs = loadSpec(specPath); + const unreferenced = unreferencedPlates(specForRefs, artifact || state.artifact || null); + if (unreferenced.length) { + return { ok: false, reasons: unreferenced.map((r) => `plate ${r.plate} (region ${r.id}) is not referenced by any source file: the page draws that region in code while the produced plate sits unused. Place the plate (an , a background-image, or an inlined data URI named for it) and recapture.`) }; + } const script = path.join(HERE, 'comp-diff.mjs'); const args = [script, '--comp', state.comp, '--build', buildPath, '--out-dir', outDir, '--label', 'hero', '--json']; const spec = loadSpec(specPath); @@ -248,7 +293,7 @@ async function main() { console.log(renderStatus(existing)); return; } - const state = newState({ comp, breakpoint }); + const state = newState({ comp, breakpoint, artifact: arg('artifact') }); saveState(state); console.log(renderStatus(state)); return; @@ -283,6 +328,7 @@ async function main() { const gateOpts = {}; if (arg('build')) gateOpts.buildPath = arg('build'); if (arg('min')) gateOpts.min = parseFloat(arg('min')); + if (arg('artifact')) gateOpts.artifact = arg('artifact'); const res = advance(state, { force: flag('force'), reason: arg('reason'), gateOpts }); saveState(state); if (!res.ok) { diff --git a/tests/build-phase.test.mjs b/tests/build-phase.test.mjs index bd6804966..c5f8309b1 100644 --- a/tests/build-phase.test.mjs +++ b/tests/build-phase.test.mjs @@ -131,8 +131,13 @@ describe('build-phase state machine (CLI)', () => { fillRect(flat, 0, 0, comp.width, 40, [19, 33, 48, 255]); fs.mkdirSync(path.join(dir, '.impeccable', 'review'), { recursive: true }); fs.writeFileSync(path.join(dir, '.impeccable', 'review', 'hero-repro.png'), encodePng(flat)); + // no source references the plate yet: refused before any diff runs let res = run(PHASE_SCRIPT, ['advance'], dir); assert.equal(res.status, 2, res.stdout); + assert.match(res.stdout, /not referenced by any source file/); + fs.writeFileSync(path.join(dir, 'index.html'), ''); + res = run(PHASE_SCRIPT, ['advance'], dir); + assert.equal(res.status, 2, res.stdout); assert.match(res.stdout, /GATE HERO FAILED/); assert.match(res.stdout, /region art is missing/); assert.ok(fs.existsSync(path.join(dir, '.impeccable', 'review', 'diff', 'hero', 'side-by-side.png'))); @@ -144,7 +149,7 @@ describe('build-phase state machine (CLI)', () => { assert.equal(res.status, 0, res.stdout); assert.match(res.stdout, /ADVANCED hero -> sections/); const state = JSON.parse(fs.readFileSync(path.join(dir, '.impeccable', 'build', 'state.json'), 'utf8')); - assert.equal(state.phases.hero.attempts, 2); + assert.equal(state.phases.hero.attempts, 3); assert.ok(state.phases.hero.gate.score >= 0.72); });