mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
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:
committed by
Abdul Wahab
co-authored by
Claude
parent
63dd7faa0e
commit
f4987eebba
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user