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
+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;