From bef185360fdfe010b9bd8f586d3135c761d8533f Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 17 Aug 2026 21:33:28 -0700 Subject: [PATCH] build-phase.mjs scaffold: the measured layout as CSS custom properties and a reference page A reference, not the page: --r--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). --- skill/reference/new-work.md | 2 +- skill/scripts/build-phase.mjs | 78 ++++++++++++++++++++++++++++++++++- tests/build-phase.test.mjs | 14 +++++++ 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/skill/reference/new-work.md b/skill/reference/new-work.md index c669fe08a..560309ad7 100644 --- a/skill/reference/new-work.md +++ b/skill/reference/new-work.md @@ -105,7 +105,7 @@ The comp-led path is a frontier-tier job: it asks the builder to hold a measured 1. **spec.** Measure the comp: `comp-spec.mjs --comp --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `comp-spec.mjs --comp --regions `. The spec carries each region's box, sampled palette, and medium; `comp-spec.mjs --print` is the build's reference from here on. Type is measured, not guessed: `font-match.mjs --measure ` reads the comp's cap height, width class, and weight off the pixels, and `font-match.mjs --rank --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. 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), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors. 2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (ink on flat ground is generated on a chroma key and keyed to alpha, so it sits on the page's own ground rather than a second paper); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `generate-image.mjs --plate ` does one region end to end and scores it against the crop; a harness-native image tool takes the crop (`comp-spec.mjs --crop `) as its input image and `comp-spec.mjs --plate-prompt ` 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. -3. **hero.** Build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `build-phase.mjs record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and 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 and no reading outstanding: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. 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. +3. **hero.** `build-phase.mjs scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r--x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an ``, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `build-phase.mjs record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and 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 and no reading outstanding: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. 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. 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, and the first viewport at common desktop widths (1280 to 1600), not only at the comp's exact size: fluid columns, no fixed-pixel grid that wraps a hundred pixels narrower. Capture `desktop.png` (1440 wide, full page) and `mobile.png` (390 wide) into `.impeccable/review/`; the gate diffs the desktop capture against the comp and refuses a first viewport that only held at the comp's width. A comp'd surface that is mobile-first was comped portrait; the plates were produced for that frame. diff --git a/skill/scripts/build-phase.mjs b/skill/scripts/build-phase.mjs index 015bac96c..11ea22394 100644 --- a/skill/scripts/build-phase.mjs +++ b/skill/scripts/build-phase.mjs @@ -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(/`); + } else if (r.kind === 'texture') { + const src = r.plate ? path.relative(dir, r.plate) : ''; + bodyParts.push(`
`); + } 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(`

${(r.text || '').replace(/

`); + } else if (r.kind === 'control') { + bodyParts.push(`
`); + } else { + bodyParts.push(`
`); + } + } + 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 ? ` \n` : ''; + const html = `\n\n\n \n Scaffold reference: ${path.basename(spec.comp)}\n${link} \n \n\n\n\n
\n${bodyParts.join('\n')}\n
\n\n\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 for each text region (cap height, width class, weight class) and font-match.mjs --rank --text "" 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 , 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 as its reference image and comp-spec.mjs --plate-prompt 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 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 --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--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 [--breakpoint WxH] | status [--json] | advance [--force --reason "..."] | record hero --build | note "" | finish --disposition '); + console.error('usage: build-phase.mjs start --comp [--breakpoint WxH] | status [--json] | advance [--force --reason "..."] | record hero --build | scaffold | note "" | finish --disposition '); 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--x/y/w/h in % of the comp; --r--cap, --r--font, --r--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 }); diff --git a/tests/build-phase.test.mjs b/tests/build-phase.test.mjs index 5a57d57d5..3e99c49ec 100644 --- a/tests/build-phase.test.mjs +++ b/tests/build-phase.test.mjs @@ -270,6 +270,20 @@ describe('build-phase state machine (CLI)', () => { assert.match(res.stdout, /ADVANCED plates -> hero/); }); + it('scaffold writes the measured layout as custom properties and a reference page', () => { + const res = run(PHASE_SCRIPT, ['scaffold'], dir); + assert.equal(res.status, 0, res.stderr + res.stdout); + assert.match(res.stdout, /SCAFFOLD/); + const css = fs.readFileSync(path.join(dir, '.impeccable', 'build', 'scaffold', 'layout.css'), 'utf8'); + assert.match(css, /--r-headline-x: [\d.]+%; --r-headline-y: [\d.]+%; --r-headline-w: [\d.]+%; --r-headline-h: [\d.]+%;/); + assert.match(css, /--r-headline-cap: [\d.]+px/); + assert.match(css, /\.r-art \{ position: absolute; left: var\(--r-art-x\)/); + const html = fs.readFileSync(path.join(dir, '.impeccable', 'build', 'scaffold', 'hero-reference.html'), 'utf8'); + assert.match(html, /class="r-art region plate"[^>]*> { const comp = makeComp(); const flat = createImage(comp.width, comp.height, [240, 237, 226, 255]);