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 <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-08-28 06:13:27 +05:00
committed by Abdul Wahab
co-authored by Claude
parent 63dd7faa0e
commit f4987eebba
3 changed files with 58 additions and 7 deletions
+1 -1
View File
@@ -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 <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 <comp> --regions <file>`. 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. <!-- rule:skill-comp-spec -->
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 <id>` does one region end to end and scores it against the crop; a harness-native image tool takes the crop (`comp-spec.mjs --crop <id>`) as its input image and `comp-spec.mjs --plate-prompt <id>` 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. <!-- rule:skill-plates-before-page -->
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. <!-- rule:skill-hero-gate -->
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 `<img>`, 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. <!-- rule:skill-hero-gate -->
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.
+51 -5
View File
@@ -14,7 +14,7 @@
* responsive the other viewports
* review the finish reviewer ran; disposition recorded
*
* node build-phase.mjs start --comp <approved.png> [--breakpoint 1440x900]
* node build-phase.mjs start --comp <approved.png> [--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 <img>, 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) {
+6 -1
View File
@@ -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'), '<img src="assets/plates/art.png" alt="">');
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);
});