From c75f9f108678eab7659f83f62ae83583fa8c14a6 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Fri, 28 Aug 2026 14:41:47 -0700 Subject: [PATCH] Above the bar, hero readings advise instead of block; spec escape hatches persist and announce; font-match tolerates an unwritable /tmp Paul's decision on the tenth sweep's design question: hard vetoes (missing region, contradicted plate or text, SVG illustration, clipped plate, invented ink) stay unconditional; at overall >= HERO_MIN the numeric readings (ink colour, letter-spacing, line pitch, strip heights, box positions) print as advisories with the pass and belong to the polish pass. Every sweep-10 sample closes its hero under this condition, which settles 07 without another paid round. Ninth-sweep defects: codeDrawn / container / bleed now persist into spec.json with WARN lines (an overridden refusal used to vanish from the record); font-match probes os.tmpdir() and points TMPDIR at .impeccable/tmp when the sandbox /tmp is unwritable (every ninth-sweep rank silently fell back to the catalog). AI-assisted (Claude Code). --- docs/COMP-FIDELITY.md | 8 +++++++ skill/reference/new-work.md | 2 +- skill/scripts/build-phase.mjs | 24 +++++++++++++++------ skill/scripts/comp-spec.mjs | 12 +++++++++++ skill/scripts/font-match.mjs | 15 +++++++++++++ tests/build-phase.test.mjs | 40 +++++++++++++++++++++++++++++++++++ 6 files changed, 94 insertions(+), 7 deletions(-) diff --git a/docs/COMP-FIDELITY.md b/docs/COMP-FIDELITY.md index 58ecd7517..efe15523a 100644 --- a/docs/COMP-FIDELITY.md +++ b/docs/COMP-FIDELITY.md @@ -291,3 +291,11 @@ On the handoff's question, whether 07 clears 72 without a force, the answer is t So 07's gate closure is unproven, not regressed. Fidelity is equal or better than sweep 8 on both niches (05 rose from 84/84 to 90/86; 07's hero readings sit at 0.789/0.777 against 0.79/0.79), plate discipline is perfect, the staged skill under each sample is byte-identical to the repo, and one sample carried a build through every phase to a ship. Settling the exact sentence takes a 07-only re-run at a realistic budget (`--slice-cap-usd 35`, roughly $50 to $70): opus costs $22 to $25 per sample on these niches, not the $15 the original estimate assumed, and `--batch-budget-usd` only blocks launching new slices rather than aborting running ones, so a four-slice launch's worst case is four times the per-slice cap. One design question falls out of this round rather than out of any defect. A build measuring 0.9025 held open by three colour edits, and a hero loop that ran 18 attempts without converging, suggest the gate's all-or-nothing pass condition and its per-region reading list interact badly with real turn and cost budgets. The gate teaches well; inside a bounded run it does not converge. + +## Ninth and tenth sweeps, and the pass-condition decision (2026-08-28) + +Abdul ran both handoff checks on the rebased branch (full write-ups in PR #599's comments): sol with the artifact fix (plates now produced, the SVG ban fires by name, 07's floor 48 to 62, but sol still declares done over open gates: compliance, not capability, and no further sol rounds are worth buying), and an opus confirmation (05 at **90 and 86, both `match`**, with the first complete traversal of the machine ending in a recorded ship; 07 at **78 and 76**, above the bar with zero forces, held open only by numeric readings and then cut by the eval cost cap mid-climb). + +The decision that followed, made by Paul on return: **above `HERO_MIN`, the numeric readings advise instead of block.** Hard vetoes stay unconditional at any score (a missing region, a contradicted plate or text block, an inline-SVG illustration, a clipped plate, invented ink); ink colours, letter-spacing, line pitch, strip heights, and box positions print as advisories with the pass and belong to the polish pass before responsive. Under this condition every sweep-10 sample closes its hero, which settles 07 without another paid round. + +Two ninth-sweep defects are fixed with it: the spec's escape hatches (`codeDrawn`, `container`, `bleed`) now persist into `spec.json` and announce themselves as WARN lines (an overridden refusal used to vanish from the record), and `font-match` probes `os.tmpdir()` before launching a browser and points `TMPDIR` at `.impeccable/tmp` when the sandbox's `/tmp` is unwritable (every ninth-sweep rank had silently fallen back to the catalog and set headlines at twice the comp's cap). \ No newline at end of file diff --git a/skill/reference/new-work.md b/skill/reference/new-work.md index 560309ad7..619d17f97 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-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. +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 hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: 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 bf55f5a1e..2754d052b 100644 --- a/skill/scripts/build-phase.mjs +++ b/skill/scripts/build-phase.mjs @@ -481,6 +481,7 @@ export function gateHero(state, { buildPath = HERO_REPRO, specPath = SPEC_PATH, let report; try { report = JSON.parse(res.stdout); } catch { return { ok: false, reasons: ['comp-diff produced no report'] }; } const reasons = []; + const advisories = []; // The capture must be the comp's own frame: a 1440-wide capture of a // 1536x1024 comp is a different composition before anything is compared. const [cw, ch] = String(report.compSize || '').split('x').map(Number); @@ -489,7 +490,14 @@ export function gateHero(state, { buildPath = HERO_REPRO, specPath = SPEC_PATH, const compAspect = cw / ch, buildAspect = bw / bh; if (bw < cw * 0.9 || Math.abs(buildAspect - compAspect) / compAspect > 0.08) reasons.push(`hero capture is ${bw}x${bh}; the comp is ${cw}x${ch}. Capture the first viewport at the comp's own dimensions (viewport ${cw}x${ch}, not full page) into ${buildPath}.`); } - if (report.overall < min) reasons.push(`hero overall ${(report.overall * 100).toFixed(1)}% < ${(min * 100).toFixed(0)}% (structure ${(report.scores.structure * 100).toFixed(0)}%, color ${(report.scores.color * 100).toFixed(0)}%, detail ${(report.scores.detail * 100).toFixed(0)}%)`); + // Above the fidelity bar, the numeric readings advise instead of block + // (Paul's calibration: builds at 68-73+ with colour and spacing nits were + // passes; a 90% build sat open behind three ink colours and a cost cap + // ended two 76-79% runs mid-loop). Hard vetoes stay unconditional: a + // missing region, a contradicted plate or text block, an SVG illustration, + // a clipped plate, invented ink are the wrong page at any score. + const aboveBar = report.overall >= min; + if (!aboveBar) reasons.push(`hero overall ${(report.overall * 100).toFixed(1)}% < ${(min * 100).toFixed(0)}% (structure ${(report.scores.structure * 100).toFixed(0)}%, color ${(report.scores.color * 100).toFixed(0)}%, detail ${(report.scores.detail * 100).toFixed(0)}%)`); if (report.scores.colorIntersection != null && report.scores.colorIntersection < 0.2) reasons.push(`the palette is not the comp's (color intersection ${(report.scores.colorIntersection * 100).toFixed(0)}%): comp ${(report.palette.comp || []).slice(0, 3).map((c) => c.hex).join(' ')} vs build ${(report.palette.build || []).slice(0, 3).map((c) => c.hex).join(' ')}. Use the spec's sampled palette values, not a rendition of them.`); // A texture band that shares its box with a text/control region carries // that region's ink in the comp crop; when the overlapping ink regions are @@ -536,7 +544,7 @@ export function gateHero(state, { buildPath = HERO_REPRO, specPath = SPEC_PATH, } const missingAfter = missing.filter((r) => r.verdict === 'missing'); for (const r of missingAfter) reasons.push(`region ${r.id} is missing (detail ${(r.score.detail * 100).toFixed(0)}%, structure ${(r.score.structure * 100).toFixed(0)}%): the comp shows material the build does not`); - for (const n of placementNotes) reasons.push(n); + for (const n of placementNotes) (aboveBar ? advisories : reasons).push(aboveBar ? `(advisory, above the ${(min * 100).toFixed(0)}% bar) ${n}` : n); const contradicted = report.regions.filter((r) => r.verdict === 'contradicted'); // A contradicted plate, image, or text region is the wrong page whatever // the mean says; chrome and controls get the one-third allowance. @@ -574,7 +582,7 @@ export function gateHero(state, { buildPath = HERO_REPRO, specPath = SPEC_PATH, const dh = r.inkBox.build.h - r.inkBox.comp.h, dw = r.inkBox.build.w - r.inkBox.comp.w; // the build's box is only comparable when it is discrete too if (r.inkBox.build.w >= rw * 0.98 || r.inkBox.build.h >= rh * 0.98) continue; - if (Math.abs(dh) > Math.max(6, r.inkBox.comp.h * 0.15) || Math.abs(dw) > Math.max(12, r.inkBox.comp.w * 0.15)) reasons.push(`region ${r.id}: its ink sits in a ${r.inkBox.comp.w}x${r.inkBox.comp.h}px box in the comp and ${r.inkBox.build.w}x${r.inkBox.build.h}px in the build (padding, row height, or size); match the box, not only the position`); + if (Math.abs(dh) > Math.max(6, r.inkBox.comp.h * 0.15) || Math.abs(dw) > Math.max(12, r.inkBox.comp.w * 0.15)) (aboveBar ? advisories : reasons).push(`${aboveBar ? `(advisory, above the ${(min * 100).toFixed(0)}% bar) ` : ''}region ${r.id}: its ink sits in a ${r.inkBox.comp.w}x${r.inkBox.comp.h}px box in the comp and ${r.inkBox.build.w}x${r.inkBox.build.h}px in the build (padding, row height, or size); match the box, not only the position`); } } const otherContradicted = contradicted.filter((r) => !directionContradicted.includes(r)); @@ -605,7 +613,6 @@ export function gateHero(state, { buildPath = HERO_REPRO, specPath = SPEC_PATH, // size, weight, colour, or place; a nav bar too tall; ink where the comp // has none (a kicker, a divider, a second row). Each was a pin in the // first human review of builds the region scores had passed. - const advisories = []; let readings = null; try { readings = heroReadings(state, specForRefs, buildPath); } catch (e) { reasons.push(`hero readings errored (${e.message}); the region scores above stand`); } if (readings) { @@ -633,8 +640,13 @@ export function gateHero(state, { buildPath = HERO_REPRO, specPath = SPEC_PATH, const fresh = all.filter((f) => !stale(f)); const advisory = all.filter((f) => !fresh.includes(f)); const kept = fresh.slice(0, 8); - if (kept.length) reasons.push(`READINGS, each one CSS edit (${fresh.length > kept.length ? `${kept.length} of ${fresh.length}, the rest after these` : `${kept.length}`}):`); - for (const f of kept) reasons.push(f); + if (aboveBar) { + if (kept.length) advisories.push(`(advisory, above the ${(min * 100).toFixed(0)}% bar; fix in the polish pass before responsive) ${kept.length} reading${kept.length === 1 ? '' : 's'}:`); + for (const f of kept) advisories.push(` ${f}`); + } else { + if (kept.length) reasons.push(`READINGS, each one CSS edit (${fresh.length > kept.length ? `${kept.length} of ${fresh.length}, the rest after these` : `${kept.length}`}):`); + for (const f of kept) reasons.push(f); + } if (advisory.length) advisories.push(...advisory.map((f) => `(advisory, unchanged for 3+ attempts) ${f}`)); for (const f of readings.plates || []) reasons.push(f); // invented ink: enough cells, or a couple of strongly inked ones (a diff --git a/skill/scripts/comp-spec.mjs b/skill/scripts/comp-spec.mjs index a2f822ead..46d5118cd 100644 --- a/skill/scripts/comp-spec.mjs +++ b/skill/scripts/comp-spec.mjs @@ -259,6 +259,14 @@ export function measureRegions(comp, regionsInput, compPath) { // filed under a code kind is a plate about to be redrawn in SVG: the // exploded carburetor "chrome" that the hero gate then scores missing. // Refuse at the spec, where the fix is one word, not at the hero. + // Escape hatches persist into the spec and announce themselves: a + // refusal overridden in regions.json used to vanish from spec.json, so + // the shipped spec showed a clean classification with no trace (found in + // the ninth sweep, where both carburetor illustrations were filed as + // chrome behind codeDrawn: true). + for (const key of ['codeDrawn', 'container', 'bleed']) { + if (raw[key]) warnings.push(`region ${raw.id}: "${key}": true set in the regions file${key === 'codeDrawn' ? ' (the painted-material refusal is overridden: code draws this region)' : key === 'container' ? ' (the region-size refusal is overridden: one undivided element)' : ' (the clipped-artwork refusal is overridden: the page crops it there)'}`); + } if (raw.note && !RASTER_KINDS.has(kind) && kind !== 'band' && PAINTED_NOTE.test(raw.note) && !raw.codeDrawn) { throw new Error(`region ${raw.id} is kind "${kind}" but its note describes painted material ("${raw.note}"). Anything drawn, photographed, or textured ships as a raster plate: set kind to plate (illustration, diagram, figure), image (photograph), or texture (ground). If the note is wrong and code really draws it (a table, a rule, a chrome bar), reword the note or set "codeDrawn": true on the region.`); } @@ -304,6 +312,10 @@ export function measureRegions(comp, regionsInput, compPath) { kind, note: raw.note || null, grid: raw.grid || null, + codeDrawn: raw.codeDrawn ? true : undefined, + container: raw.container ? true : undefined, + bleed: raw.bleed ? true : undefined, + snap: raw.snap === false ? false : undefined, coverBox: coverBox ? { x: r4(coverBox.x), y: r4(coverBox.y), w: r4(coverBox.w), h: r4(coverBox.h) } : undefined, box: { x: r4(box.x), y: r4(box.y), w: r4(box.w), h: r4(box.h) }, px, diff --git a/skill/scripts/font-match.mjs b/skill/scripts/font-match.mjs index 245818152..ecb421bda 100644 --- a/skill/scripts/font-match.mjs +++ b/skill/scripts/font-match.mjs @@ -174,7 +174,22 @@ export function choiceStamped(regionId, chosen) { // ---- browser -------------------------------------------------------------- +/** + * Playwright and puppeteer write launch artifacts to os.tmpdir(). In a + * sandbox whose /tmp is not writable (the ninth sweep: EPERM on + * mkdtemp /tmp/playwright-artifacts-*), every rank silently fell back to the + * catalog and three of four builds set headlines at twice the comp's cap. + * Probe once and point TMPDIR at a workspace dir when the system one fails. + */ +function ensureWritableTmp() { + const os = require('node:os'); + try { const d = fs.mkdtempSync(path.join(os.tmpdir(), 'fm-')); fs.rmSync(d, { recursive: true, force: true }); return; } catch { /* not writable */ } + const local = path.resolve('.impeccable', 'tmp'); + try { fs.mkdirSync(local, { recursive: true }); process.env.TMPDIR = local; process.env.TMP = local; process.env.TEMP = local; } catch { /* leave as is; launch will say why */ } +} + async function loadBrowser() { + ensureWritableTmp(); // IMPECCABLE_NODE_MODULES: a node_modules dir holding playwright or // puppeteer, for harnesses that mount the skill somewhere its own resolution // roots cannot see (a sandbox root, a plugin cache). NODE_PATH works too. diff --git a/tests/build-phase.test.mjs b/tests/build-phase.test.mjs index 3e99c49ec..fe066a282 100644 --- a/tests/build-phase.test.mjs +++ b/tests/build-phase.test.mjs @@ -91,6 +91,17 @@ describe('comp-spec', () => { assert.equal(plain.regions[0].box.w, 0.4); }); + it('persists the escape hatches into the spec and announces their use', () => { + const comp = makeComp(); + const painted = { id: 'rack', kind: 'chrome', grid: 'F1:J4', note: 'an exploded diagram of the rack', codeDrawn: true }; + const spec = measureRegions(comp, { allowUncovered: true, regions: [painted] }, 'c.png'); + assert.equal(spec.regions[0].codeDrawn, true, 'the override survives into the spec'); + assert.ok(spec.warnings.some((w) => /region rack: "codeDrawn": true set in the regions file/.test(w)), JSON.stringify(spec.warnings)); + const col = measureRegions(comp, { allowUncovered: true, regions: [{ id: 'col', kind: 'chrome', grid: 'G0:J9', note: 'right column', container: true }] }, 'c.png'); + assert.equal(col.regions[0].container, true); + assert.ok(col.warnings.some((w) => /"container": true/.test(w))); + }); + it('warns when a plate box cuts through its own artwork', () => { // a black arch on paper, drawn wider than the region that names it const comp = createImage(1000, 1000, [235, 232, 220, 255]); @@ -270,6 +281,35 @@ describe('build-phase state machine (CLI)', () => { assert.match(res.stdout, /ADVANCED plates -> hero/); }); + it('above the bar, numeric readings advise instead of block', () => { + const d5 = fs.mkdtempSync(path.join(os.tmpdir(), 'build-phase-bar-')); + const comp = makeComp(); + fs.writeFileSync(path.join(d5, 'comp.png'), encodePng(comp)); + fs.writeFileSync(path.join(d5, 'regions.json'), JSON.stringify({ allowUncovered: true, regions: [ + { id: 'masthead', kind: 'chrome', grid: 'A0:J0', note: 'navy masthead bar' }, + { id: 'headline', kind: 'text', grid: 'A1:D2', note: 'two-line block headline' }, + ] })); + run(PHASE_SCRIPT, ['start', '--comp', 'comp.png', '--artifact', 'index.html'], d5); + run(SPEC_SCRIPT, ['--comp', 'comp.png', '--regions', 'regions.json'], d5); + run(FONT_SCRIPT, ['--measure', 'headline'], d5); + run(FONT_SCRIPT, ['--rank', 'headline', '--text', 'KEEP'], d5); + run(PHASE_SCRIPT, ['advance'], d5); // spec -> plates + run(PHASE_SCRIPT, ['advance'], d5); // plates -> hero (none owed) + // the build is the comp with the headline recoloured: overall stays high, + // the ink-colour reading fires + const build = { ...comp, data: new Uint8Array(comp.data) }; + for (let y = 40; y < 160; y++) for (let x = 10; x < 260; x++) { const q = (y * comp.width + x) * 4; if (build.data[q] < 100) { build.data[q] = 170; build.data[q + 1] = 40; build.data[q + 2] = 30; } } + fs.mkdirSync(path.join(d5, '.impeccable', 'review'), { recursive: true }); + fs.writeFileSync(path.join(d5, '.impeccable', 'review', 'hero-repro.png'), encodePng(build)); + fs.writeFileSync(path.join(d5, 'index.html'), '
x
'); + const res = run(PHASE_SCRIPT, ['advance'], d5); + assert.equal(res.status, 0, res.stdout + res.stderr); + assert.match(res.stdout, /ADVANCED hero -> sections/); + assert.match(res.stdout, /advisory, above the 72% bar/); + assert.match(res.stdout, /ink is #/); + fs.rmSync(d5, { recursive: true, force: true }); + }); + 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);