build-phase.mjs scaffold: the measured layout as CSS custom properties and a reference page

A reference, not the page: --r-<id>-x/y/w/h in % of the comp (plus cap height, font-size, family, weight where measured) to bind to any markup, and hero-reference.html with every region at its box and every plate placed with object-fit: contain, as a check on positions. Attacks the most common execution failure of weaker builders (badly positioned, overflowing, pushed below the fold) without dictating structure to strong ones; overlapping boxes are overlapping boxes and the gate reads pixels regardless.

AI-assisted (Claude Code).
This commit is contained in:
Paul Bakaus
2026-08-28 06:13:59 +05:00
committed by Abdul Wahab
parent 2debb7078f
commit bef185360f
3 changed files with 91 additions and 3 deletions
+76 -2
View File
@@ -371,6 +371,70 @@ export function organicClipRegions(artifactFile, spec) {
return out;
}
/**
* The scaffold: the measured layout as CSS custom properties and one
* reference page. Positions in % of the comp so the frame scales; plates at
* their boxes with object-fit: contain (never cover: cover is how the arch
* lost its left side); text slots sized from the measured cap height (cap /
* 0.70 as the em estimate when font-match gave no size) in the ranked face.
* A reference for a builder that cannot lay out to a box, and a check for
* one that can; the gate reads pixels either way.
*/
export function writeScaffold(spec, state, { dir = path.join(BUILD_DIR, 'scaffold') } = {}) {
fs.mkdirSync(dir, { recursive: true });
const W = spec.compSize.width, H = spec.compSize.height;
const pct = (v) => `${(v * 100).toFixed(3)}%`;
const vars = [':root {'];
const rules = [];
const bodyParts = [];
const fontLinks = new Set();
for (const r of spec.regions) {
if (r.kind === 'band') continue;
const b = r.box, id = r.id;
vars.push(` --r-${id}-x: ${pct(b.x)}; --r-${id}-y: ${pct(b.y)}; --r-${id}-w: ${pct(b.w)}; --r-${id}-h: ${pct(b.h)};`);
const type = r.type || {};
const cap = type.comp && type.comp.capHeightPx;
const chosen = type.chosen || null;
const fontPx = chosen && chosen.fontSizePx ? chosen.fontSizePx : (cap ? Math.round(cap / 0.7) : null);
if (cap) vars.push(` --r-${id}-cap: ${cap}px;${fontPx ? ` --r-${id}-font: ${fontPx}px;` : ''}${chosen ? ` --r-${id}-family: '${chosen.family}'; --r-${id}-weight: ${chosen.weight};` : ''}`);
if (chosen && chosen.family) fontLinks.add(`${chosen.family}:${chosen.weight}`);
rules.push(`.r-${id} { position: absolute; left: var(--r-${id}-x); top: var(--r-${id}-y); width: var(--r-${id}-w); height: var(--r-${id}-h); }`);
const label = (r.note || id).replace(/</g, '&lt;');
if (r.medium === 'raster' && r.kind !== 'texture') {
const src = r.plate ? path.relative(dir, r.plate) : '';
bodyParts.push(` <figure class="r-${id} region plate" data-region="${id}"><img src="${src}" alt="" style="width:100%;height:100%;object-fit:contain;object-position:${r.kind === 'image' ? 'center' : 'top left'}"></figure>`);
} else if (r.kind === 'texture') {
const src = r.plate ? path.relative(dir, r.plate) : '';
bodyParts.push(` <div class="r-${id} region texture" data-region="${id}" style="background-image:url('${src}');background-repeat:repeat"></div>`);
} else if (r.kind === 'text') {
const style = [fontPx ? `font-size:var(--r-${id}-font)` : '', chosen ? `font-family:var(--r-${id}-family),sans-serif;font-weight:var(--r-${id}-weight)` : '', 'line-height:1.05', 'margin:0'].filter(Boolean).join(';');
// the slot shows the region's own words when the spec has them, else its id
// at the measured size (a slot, not a caption: the note goes in a comment)
bodyParts.push(` <div class="r-${id} region text" data-region="${id}"><!-- ${label} --><p style="${style}">${(r.text || '').replace(/</g, '&lt;') || id}</p></div>`);
} else if (r.kind === 'control') {
bodyParts.push(` <div class="r-${id} region control" data-region="${id}"><!-- ${label}: rebuild the control's chrome from the crop (comp-spec.mjs --crop ${id}); its ink box, border, fill, radius, and label size are the comp's --></div>`);
} else {
bodyParts.push(` <div class="r-${id} region chrome" data-region="${id}"><!-- ${label} --></div>`);
}
}
vars.push('}');
const css = [
'/* Impeccable scaffold: the measured layout of the approved comp as custom properties. Generated by build-phase.mjs scaffold; regenerate after comp-spec.mjs --regions changes. Bind these to your own markup; positions are % of the comp frame so they scale with it. */',
...vars,
'',
`.comp-frame { position: relative; width: 100%; aspect-ratio: ${W} / ${H}; overflow: hidden; }`,
...rules,
'',
].join('\n');
const cssPath = path.join(dir, 'layout.css');
fs.writeFileSync(cssPath, css);
const link = fontLinks.size ? ` <link rel="stylesheet" href="https://fonts.googleapis.com/css2?${[...fontLinks].map((f) => { const [fam, w] = f.split(':'); return `family=${encodeURIComponent(fam).replace(/%20/g, '+')}:wght@${w}`; }).join('&')}&display=swap">\n` : '';
const html = `<!doctype html>\n<html lang="en">\n<head>\n <meta charset="utf-8">\n <title>Scaffold reference: ${path.basename(spec.comp)}</title>\n${link} <link rel="stylesheet" href="layout.css">\n <style>html,body{margin:0}body{background:${(spec.palette && spec.palette[0] && spec.palette[0].hex) || '#fff'}}.region{box-sizing:border-box}.region.text p{white-space:pre-wrap}</style>\n</head>\n<body>\n<!-- Reference only. Every region sits at its measured box inside a comp-aspect frame. Take the boxes (layout.css), keep your own semantic structure. -->\n<main class="comp-frame" style="max-width:${W}px">\n${bodyParts.join('\n')}\n</main>\n</body>\n</html>\n`;
const htmlPath = path.join(dir, 'hero-reference.html');
fs.writeFileSync(htmlPath, html);
return { dir, css: cssPath, html: htmlPath };
}
/** Fraction of grid cells with invented ink that fails the hero. */
export const INVENTED_MIN = 0.04;
@@ -744,7 +808,7 @@ export function nextInstruction(state) {
case 'comps': return `Comp round for the chosen direction${state.direction ? ` (seed ${state.direction})` : ''}: read reference/visualize.md, generate three compositional comps of the requested surface at its own viewport into ${MOCKS_DIR}/ (each with a prompt sidecar), put them in front of the user, and set "approved": true in the chosen comp's sidecar. Then build-phase.mjs advance. No page code before this closes.`;
case 'spec': return `Measure the comp: node comp-spec.mjs --comp ${state.comp} --grid, open ${path.join(BUILD_DIR, 'comp-grid.png')}, write regions.json (every illustration, photo, texture as its own plate region; every text block its own text region), run comp-spec.mjs --comp ${state.comp} --regions regions.json. Then measure the type: node font-match.mjs --measure <id> for each text region (cap height, width class, weight class) and font-match.mjs --rank <lead text region> --text "<its first words>" to choose the headline face by metrics (the USE line is the CSS; with no browser it records the catalog's nearest face, which is the choice; do not install one, and do not write a chosen face into the spec by hand). Then build-phase.mjs advance.`;
case 'plates': return 'Produce every plate in the spec (comp-spec.mjs --print lists them). Illustrations, photos, figures: node generate-image.mjs --plate <id>, one call per plate. It crops the comp region itself, sends the crop as the edit reference, sizes the plate, keys ink-on-ground to alpha, scores the result against the crop (PLATE-SCORE) and embeds the prompt; nothing else does all of that. Only when it errors (no key, no network) fall back to the harness image tool with comp-spec.mjs --crop <id> as its reference image and comp-spec.mjs --plate-prompt <id> as its prompt, then embed-prompt.mjs; do not post-process a plate with magick or write your own keying. A generation takes 30 to 90 seconds: run it with a long wait (a 90 s yield, or all plates in one command joined with &&) rather than polling an open session turn after turn. A line drawing or figure on flat ground is keyed to alpha automatically (PLATE-CHROMA): place it with a plain <img> over the page\'s own ground, never on a second paper. An opaque plate whose ground differs from the page goes in with mix-blend-mode: multiply. Textures (paper, cloth, grain): do not generate first; crop a clean patch of the comp region (comp-spec.mjs --crop <id> --raw, then cut a patch free of ink), mirror-tile it to the plate size, and save it as the plate; generate only when no clean patch exists. The gate scores a texture against its whole region box, so a texture region should be drawn around clean ground (a sample cell), not around the ink it sits under; the page tiles it wherever the material goes. Then build-phase.mjs advance. Write no page code before this passes.';
case 'hero': return `Build only the first viewport at ${state.breakpoint || 'the comp size'}. Copy the comp's words verbatim in this phase (headline, labels, table cells, footer): the user approved that comp with those words, and rewriting is a later, stated decision, never a silent one here. Set every text region's font-size from its measured cap height and its face from the ranking. Plates first: place every plate at its spec box (comp-spec.mjs --print lists boxes as percentages of the viewport) with object-fit: cover before writing a line of text or a control, capture into ${HERO_REPRO}, and run build-phase.mjs record hero (not advance) once so you see the plate regions read as match before text exists; then lay the semantic layer (text, controls, rules) over the plates from the spec's palette and boxes, capture, advance. When it fails, open the region crops it lists first, in order, then fix; do not build past the hero until it passes.`;
case 'hero': return `Run build-phase.mjs scaffold first: it writes the measured layout as CSS custom properties (.impeccable/build/scaffold/layout.css, --r-<id>-x/y/w/h in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page with every region at its box. Bind those numbers to your own markup (an element per region, its box from the properties); the reference is a check, not the page, and overlapping boxes are overlapping boxes. Build only the first viewport at ${state.breakpoint || 'the comp size'}. Copy the comp's words verbatim in this phase (headline, labels, table cells, footer): the user approved that comp with those words, and rewriting is a later, stated decision, never a silent one here. Set every text region's font-size from its measured cap height and its face from the ranking. Plates first: place every plate at its spec box (comp-spec.mjs --print lists boxes as percentages of the viewport) with object-fit: cover before writing a line of text or a control, capture into ${HERO_REPRO}, and run build-phase.mjs record hero (not advance) once so you see the plate regions read as match before text exists; then lay the semantic layer (text, controls, rules) over the plates from the spec's palette and boxes, capture, advance. When it fails, open the region crops it lists first, in order, then fix; do not build past the hero until it passes.`;
case 'sections': return 'Build the remaining sections inside the spec system (same corner language, rules, and palette; nothing the comp does not show). The hero passed with the comp\'s words verbatim; from here, content beyond the comp is yours to author at full fidelity, and any change to words the comp showed is a stated decision in your report, never silent. Then build-phase.mjs advance.';
case 'motion': return 'Add the signature interaction, reveals, and motion. Then build-phase.mjs advance.';
case 'responsive': return 'Build the other viewports (mobile first if the surface is mobile). The first viewport must hold at common desktop widths (1280 to 1600), not only at the comp\'s exact size: fluid columns, no fixed-px grid that wraps 96px narrower. Settle or disable entrance motion before capturing (an element mid-animation reads as missing). Capture desktop.png (1440 wide, full page) and mobile.png (390 wide, full page) into .impeccable/review/; the gate diffs the top of desktop.png (scaled to the comp\'s width) against the comp. Then build-phase.mjs advance.';
@@ -771,7 +835,7 @@ export function renderStatus(state) {
async function main() {
const cmd = process.argv[2];
if (!cmd || flag('help')) {
console.error('usage: build-phase.mjs start --comp <png> [--breakpoint WxH] | status [--json] | advance [--force --reason "..."] | record hero --build <png> | note "<text>" | finish --disposition <word>');
console.error('usage: build-phase.mjs start --comp <png> [--breakpoint WxH] | status [--json] | advance [--force --reason "..."] | record hero --build <png> | scaffold | note "<text>" | finish --disposition <word>');
process.exit(1);
}
if (cmd === 'start') {
@@ -815,6 +879,16 @@ async function main() {
if (flag('json')) console.log(JSON.stringify(state, null, 2)); else console.log(renderStatus(state));
return;
}
if (cmd === 'scaffold') {
const spec = loadSpec();
if (!spec) { console.error(`build-phase: no spec at ${SPEC_PATH}; run comp-spec.mjs first`); process.exit(1); }
const out = writeScaffold(spec, state);
console.log(`SCAFFOLD ${out.dir}`);
console.log(` ${out.css} one custom property set per region (--r-<id>-x/y/w/h in % of the comp; --r-<id>-cap, --r-<id>-font, --r-<id>-weight where measured); bind these to your own markup`);
console.log(` ${out.html} a reference page: every region positioned at its box inside a ${state.breakpoint || spec.compSize.width + 'x' + spec.compSize.height} frame, plates placed with object-fit: contain, text slots at the measured cap height in the ranked face`);
console.log(' The reference is a check, not the page: keep your own semantic structure and bind the numbers to it (an element per region, its box from the properties). Overlapping boxes are overlapping boxes. What the gate reads is pixels; a page that lands each region at its box passes whatever markup it uses.');
return;
}
if (cmd === 'note') {
const text = process.argv.slice(3).filter((a) => !a.startsWith('--')).join(' ');
state.phases[state.phase].notes.push({ at: now(), text });