build-phase: textures skip the size floor, --force needs the user's words

The first live run forced past the plates gate with 'single-file HTML
delivery requires embedded CSS/SVG'. That is not a reason the comp's
authority moves for; the script now refuses a --force whose reason does
not quote the user, and new-work.md says a single-file deliverable inlines
the plate as a data URI. Texture plates are judged on palette and grain,
not size or structure, since they tile.

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 34ef9ac2c4
commit 63dd7faa0e
3 changed files with 31 additions and 11 deletions
+2 -4
View File
@@ -98,17 +98,15 @@ When an approved comp exists, it is a spatial contract, not a mood board: only t
`node {{scripts_path}}/build-phase.mjs start --comp <approved comp>`
Then, in order, each closed by `node {{scripts_path}}/build-phase.mjs advance` (exit 2 means the gate failed and printed why; fix that and advance again; write nothing for a later phase while an earlier gate is open):
Then, in order, each closed by `node {{scripts_path}}/build-phase.mjs advance` (every script below lives under `{{scripts_path}}/` and runs with `node`; exit 2 means the gate failed and printed why; fix that and advance again; write nothing for a later phase while an earlier gate is open):
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. <!-- rule:skill-plates-before-page -->
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 -->
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.
Force a gate only with `--force --reason`, and only when the user has downgraded the comp's authority in words; the record travels to the reviewer.
### Code-led
No comp and no apology for it: the ambition lives in the direction contract's FIRST VIEWPORT block and the named signature interaction, and the finish reviewer audits those promises in behavior. The chosen decision comp rides to the finish review as the critique reference.
+22 -4
View File
@@ -115,14 +115,21 @@ export function gatePlates(state, { specPath = SPEC_PATH } = {}) {
if (!file || !fs.existsSync(file)) { reasons.push(`plate missing for ${r.id}: expected ${file || '(no path)'}; produce it from comp-spec.mjs --crop ${r.id} with generate-image.mjs --plate`); plates.push({ id: r.id, file, status: 'missing' }); continue; }
let img;
try { img = decodePng(fs.readFileSync(file)); } catch (e) { reasons.push(`plate ${file} is not a decodable PNG: ${e.message}`); plates.push({ id: r.id, file, status: 'unreadable' }); continue; }
const minW = r.px.w * 1.5;
if (img.width < minW) reasons.push(`plate ${file} is ${img.width}px wide; the comp region is ${r.px.w}px and a shipping plate needs at least 1.5x (${Math.round(minW)}px). Regenerate at asset size, do not crop the comp.`);
// A texture tiles, so it owes no size floor and no structural match:
// it is judged on palette and grain only. Every other plate must be at
// least 1.5x the region (capped at 1536px, the largest size the
// generators emit; past that the region is a full-bleed field the page
// scales) and read as the region under object-fit: cover.
const isTexture = r.kind === 'texture';
const minW = Math.min(1536, r.px.w * 1.5);
if (!isTexture && img.width < minW) reasons.push(`plate ${file} is ${img.width}px wide; the comp region is ${r.px.w}px and a shipping plate needs at least ${Math.round(minW)}px. Regenerate at asset size, do not crop the comp.`);
let score = null;
if (comp) {
const ref = crop(comp, r.px.x, r.px.y, r.px.w, r.px.h);
const res = compare({ comp: ref, build: img, align: 'cover', spec: null, kind: r.kind });
score = res.whole;
if (score.overall < PLATE_MIN) reasons.push(`plate ${file} scores ${(score.overall * 100).toFixed(0)}% against the comp region ${r.id} (structure ${(score.structure * 100).toFixed(0)}%, color ${(score.color * 100).toFixed(0)}%, detail ${(score.detail * 100).toFixed(0)}%); it does not read as the same region. Regenerate with the crop as --ref and the comp-spec plate prompt.`);
const effective = isTexture ? 0.5 * score.color + 0.5 * Math.min(1, score.detail / 0.6) : score.overall;
if (effective < PLATE_MIN) reasons.push(`plate ${file} scores ${(effective * 100).toFixed(0)}% against the comp region ${r.id} (structure ${(score.structure * 100).toFixed(0)}%, color ${(score.color * 100).toFixed(0)}%, detail ${(score.detail * 100).toFixed(0)}%); it does not read as the same ${isTexture ? 'material' : 'region'}. Regenerate with the crop as --ref and the comp-spec plate prompt${isTexture ? ', or crop a clean patch of the comp region and tile it' : ''}.`);
}
plates.push({ id: r.id, file, status: 'ok', size: `${img.width}x${img.height}`, score: score ? score.overall : null });
}
@@ -167,6 +174,13 @@ export function runGate(state, phase, opts = {}) {
return gate(state, opts);
}
/** Reasons a gate may be forced past. The user downgrading the comp's authority
* in words is the only one; the parent quotes it. A reason that does not name
* the user is a model talking itself past its own gate, and it is refused. */
export function forceAllowed(reason) {
return typeof reason === 'string' && /\buser\b|\bthey (said|asked|told)\b|\bpaul\b/i.test(reason) && reason.trim().length > 20;
}
export function advance(state, { force = false, reason = null, gateOpts = {} } = {}) {
const phase = state.phase;
const idx = PHASES.indexOf(phase);
@@ -176,8 +190,12 @@ export function advance(state, { force = false, reason = null, gateOpts = {} } =
const gate = runGate(state, phase, gateOpts);
const { plates: _p, ...gateRecord } = gate;
p.gate = { ...gateRecord, at: now() };
if (!gate.ok && force && !forceAllowed(reason)) {
p.status = 'open';
return { ok: false, phase, reasons: [...gate.reasons, `--force refused: "${reason || ''}" does not quote the user downgrading the comp. A single-file deliverable, a missing tool, or difficulty is not a reason; embed the plate as a data URI, produce it with the harness image tool, or ask the user.`], gate };
}
if (!gate.ok && !force) { p.status = 'open'; return { ok: false, phase, reasons: gate.reasons, gate }; }
if (!gate.ok && force) p.forced = { at: now(), reason: reason || '(no reason given)', reasons: gate.reasons };
if (!gate.ok && force) p.forced = { at: now(), reason, reasons: gate.reasons };
p.status = 'closed'; p.closedAt = now();
const next = PHASES[idx + 1];
state.phase = next;
+7 -3
View File
@@ -107,12 +107,16 @@ describe('build-phase state machine (CLI)', () => {
let res = run(PHASE_SCRIPT, ['advance'], dir);
assert.equal(res.status, 2);
assert.match(res.stdout, /plate missing for art/);
// force without the user's words is refused; with them it is recorded
res = run(PHASE_SCRIPT, ['advance', '--force', '--reason', 'single-file HTML delivery requires embedded CSS'], dir);
assert.equal(res.status, 2);
assert.match(res.stdout, /--force refused/);
// comp-size crop: too small
res = run(SPEC_SCRIPT, ['--crop', 'art', '--out', 'assets/plates/art.png'], dir);
assert.equal(res.status, 0, res.stderr);
res = run(PHASE_SCRIPT, ['advance'], dir);
assert.equal(res.status, 2);
assert.match(res.stdout, /needs at least 1.5x/);
assert.match(res.stdout, /needs at least 480px/);
// 2x crop passes size and similarity
res = run(SPEC_SCRIPT, ['--crop', 'art', '--scale', '2', '--out', 'assets/plates/art.png'], dir);
assert.equal(res.status, 0, res.stderr);
@@ -150,8 +154,8 @@ describe('build-phase state machine (CLI)', () => {
assert.equal(res.status, 0, res.stdout);
assert.match(res.stdout, new RegExp(`ADVANCED ${from}`));
}
let res = run(PHASE_SCRIPT, ['advance', '--force', '--reason', 'test'], dir);
assert.equal(res.status, 0);
let res = run(PHASE_SCRIPT, ['advance', '--force', '--reason', 'single-file delivery needs CSS'], dir);
assert.equal(res.status, 0, 'responsive has no gate, so force is moot');
res = run(PHASE_SCRIPT, ['finish', '--disposition', 'fix'], dir);
assert.equal(res.status, 0);
assert.match(res.stdout, /finish fix/);