diff --git a/skill/reference/new-work.md b/skill/reference/new-work.md index 745f487ef..79ccce2e0 100644 --- a/skill/reference/new-work.md +++ b/skill/reference/new-work.md @@ -101,7 +101,7 @@ When an approved comp exists, it is a spatial contract, not a mood board: only t 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): 0. **comps.** The comp round from [visualize.md](visualize.md): three compositional comps of the requested surface at its own viewport under `.impeccable/mocks/`, each with a prompt sidecar, put in front of the user; the chosen one's sidecar gets `"approved": true`. The gate counts them and reads the approval; a `start --comp` skips this phase because it already happened. -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 (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 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. +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 (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). 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), icons (exact match unless the user chose an icon library), 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. 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. diff --git a/skill/scripts/build-phase.mjs b/skill/scripts/build-phase.mjs index c42b20a89..684bcae84 100644 --- a/skill/scripts/build-phase.mjs +++ b/skill/scripts/build-phase.mjs @@ -70,7 +70,7 @@ import { decodePng, loadRaster } from './lib/png.mjs'; const require = createRequire(import.meta.url); import { crop, createImage, blit } from './lib/raster.mjs'; import { compare, verdictFor, alignBuild, bestShift } from './comp-diff.mjs'; -import { textRegionCheck, chromeStripCheck, inventedInk } from './lib/hero-checks.mjs'; +import { textRegionCheck, chromeStripCheck, inventedInk, plateClipCheck } from './lib/hero-checks.mjs'; import { SPEC_PATH, BUILD_DIR, loadSpec, plateReference } from './comp-spec.mjs'; import { choiceStamped } from './font-match.mjs'; @@ -194,6 +194,9 @@ export function gateSpec(state, { specPath = SPEC_PATH } = {}) { return { ok: false, reasons: [`spec measures ${spec.comp}, but this build started on ${state.comp}; re-run comp-spec on the approved comp`] }; } const plates = spec.regions.filter((r) => r.medium === 'raster').length; + // A plate box that cuts its artwork is a plate the page will crop. + const cut = spec.regions.filter((r) => r.medium === 'raster' && r.clipped && r.clipped.length); + if (cut.length) return { ok: false, reasons: cut.map((r) => `region ${r.id}: the comp's artwork runs off its box on the ${r.clipped.join(' and ')}; widen the region's span so the box holds the whole shape with a margin (or set "bleed": true if the page really crops it there), then re-run comp-spec.mjs --regions`) }; // Type is measured, not guessed: the largest text region must carry a // font-match measurement and a ranked choice before page code exists. // Three of six misses a human called on a first-round build were the @@ -373,15 +376,19 @@ export function heroReadings(state, spec, buildPath) { let aligned = alignBuild(comp, build, 'top'); const shift = bestShift(comp, aligned); if (shift.dx || shift.dy) { const shifted = createImage(aligned.width, aligned.height, [255, 255, 255, 255]); blit(shifted, aligned, -shift.dx, -shift.dy); aligned = shifted; } - const text = [], chrome = []; + const text = [], chrome = [], plates = []; for (const r of spec.regions) { if (!r.px) continue; const a = crop(comp, r.px.x, r.px.y, r.px.w, r.px.h), b = crop(aligned, r.px.x, r.px.y, r.px.w, r.px.h); if (r.kind === 'text') { const t = textRegionCheck(r, a, b); text.push(...t.findings); } else if (r.kind === 'chrome' || r.kind === 'control') { const c = chromeStripCheck(r, a, b); chrome.push(...c.findings); } + else if (r.kind === 'plate' || r.kind === 'image') { + const c = plateClipCheck(r, a, b); + if (c.sides.length) plates.push(`plate ${r.id} is clipped at the ${c.sides.join(' and ')}: the comp's artwork keeps a margin there (ink box ${c.comp.w}x${c.comp.h} at ${c.comp.x},${c.comp.y} in the region) and the build's runs to the edge (${c.build.w}x${c.build.h} at ${c.build.x},${c.build.y}); size the box to the artwork's aspect and use object-fit: contain, or place the at the artwork's own size, never cover on a narrower box`); + } } const invented = inventedInk(comp, aligned); - return { text, chrome, invented }; + return { text, chrome, plates, invented }; } export function gateHero(state, { buildPath = HERO_REPRO, specPath = SPEC_PATH, min = HERO_MIN, outDir = path.join('.impeccable', 'review', 'diff', 'hero'), artifact = null } = {}) { @@ -522,7 +529,11 @@ export function gateHero(state, { buildPath = HERO_REPRO, specPath = SPEC_PATH, 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}`)); - if (readings.invented && readings.invented.fraction >= INVENTED_MIN) { + for (const f of readings.plates || []) reasons.push(f); + // invented ink: enough cells, or a couple of strongly inked ones (a + // legend, a badge, a second control in a calm corner) + const strongCells = (readings.invented ? readings.invented.cells : []).filter((c) => c.build >= 22); + if (readings.invented && (readings.invented.fraction >= INVENTED_MIN || strongCells.length >= 2)) { const cells = readings.invented.cells.map((c) => c.label); reasons.push(`the build carries ink in ${cells.length} grid cells where the comp is calm (${cells.slice(0, 12).join(', ')}${cells.length > 12 ? ', ...' : ''}); nothing exists on the page that the comp does not show (a kicker, an extra nav item, a divider, a second row of controls); remove it or name it in a stated decision after the hero passes`); } diff --git a/skill/scripts/comp-spec.mjs b/skill/scripts/comp-spec.mjs index e0d655ab6..cd52398e0 100644 --- a/skill/scripts/comp-spec.mjs +++ b/skill/scripts/comp-spec.mjs @@ -88,11 +88,51 @@ function paletteOf(img) { } /** Words in a region note that name painted material rather than code-drawn UI. */ -export const PAINTED_NOTE = /\b(diagram|drawing|drawn|illustrat\w*|figure|schematic|exploded|photo\w*|picture|painting|painted|render\w*|artwork|engraving|etching|linework|line art|texture\w*|grain|paper|fabric|halftone|watercolou?r|sketch\w*|blueprint|technical geometry|carburetor geometry|product shot|hero image|3d)\b/i; +export const PAINTED_NOTE = /\b(diagram|drawing|drawn|illustration|illustrations|illustrated|figure|schematic|exploded|photo|photos|photograph\w*|picture|painting|painted|render|rendered|rendering|artwork|engraving|etching|linework|line art|texture|textured|textures|grain|fabric|halftone|watercolou?r|sketch|sketched|blueprint|technical geometry|carburetor geometry|product shot|hero image|3d)\b/i; /** A text/control/chrome region larger than this fraction of the comp is a column, not an element. */ export const MAX_CODE_REGION_AREA = 0.25; +/** Fraction of an edge's length the artwork's dark mass has to touch to count as running off the box. */ +export const EDGE_CONTACT_MIN = 0.35; + +/** + * Which edges of a plate region crop the artwork touches. 'Artwork' is the + * region's non-ground mass: pixels far from the crop's median gray. A margin + * of paper along an edge means the shape ends inside the box; a long run of + * ink along it means the shape continues past it. + */ +export function artworkTouchesEdges(img, { contact = EDGE_CONTACT_MIN, band = 2, ground = null } = {}) { + const W = img.width, H = img.height; + const gray = new Float32Array(W * H); + for (let i = 0, j = 0; i < img.data.length; i += 4, j++) gray[j] = 0.299 * img.data[i] + 0.587 * img.data[i + 1] + 0.114 * img.data[i + 2]; + // ground is the page's, not the crop's: a region that is mostly a black + // arch on paper has a mid-gray median and every edge reads as ink + if (ground == null) { + const sample = []; for (let i = 0; i < gray.length; i += Math.max(1, Math.floor(gray.length / 5000))) sample.push(gray[i]); + sample.sort((a, b) => a - b); ground = sample[Math.floor(sample.length / 2)]; + } + const ink = (x, y) => Math.abs(gray[y * W + x] - ground) > 60; + const sides = []; + // the longest contiguous run of ink along the edge, as a fraction of it: + // an arch cut by the box leaves a long unbroken contact; grain, a rule + // crossing, or a line of small type leave short ones + const run = (n, at) => { let best = 0, cur = 0; for (let i = 0; i < n; i++) { if (at(i)) { cur++; if (cur > best) best = cur; } else cur = 0; } return best / n; }; + if (run(H, (y) => { for (let x = 0; x < band; x++) if (ink(x, y)) return true; return false; }) >= contact) sides.push('left'); + if (run(H, (y) => { for (let x = W - band; x < W; x++) if (ink(x, y)) return true; return false; }) >= contact) sides.push('right'); + if (run(W, (x) => { for (let y = 0; y < band; y++) if (ink(x, y)) return true; return false; }) >= contact) sides.push('top'); + if (run(W, (x) => { for (let y = H - band; y < H; y++) if (ink(x, y)) return true; return false; }) >= contact) sides.push('bottom'); + return sides; +} + +function medianGray(img) { + const sample = []; + const step = Math.max(1, Math.floor((img.width * img.height) / 6000)); + for (let j = 0; j < img.width * img.height; j += step) { const i = j * 4; sample.push(0.299 * img.data[i] + 0.587 * img.data[i + 1] + 0.114 * img.data[i + 2]); } + sample.sort((a, b) => a - b); + return sample[Math.floor(sample.length / 2)]; +} + function energyOf(img) { const g = detailGrid(img, 4, 4, 256); let s = 0; for (const v of g.cells) s += v; @@ -129,7 +169,9 @@ export function uncoveredInkCells(comp, regions) { export function measureRegions(comp, regionsInput, compPath) { const regions = []; + const warnings = []; const seen = new Set(); + const pageGround = medianGray(comp); for (const raw of regionsInput.regions || []) { if (!raw.id) throw new Error('every region needs an id'); if (seen.has(raw.id)) throw new Error(`duplicate region id ${raw.id}`); @@ -165,6 +207,15 @@ export function measureRegions(comp, regionsInput, compPath) { const c = crop(comp, px.x, px.y, px.w, px.h); const energy = energyOf(c); const raster = RASTER_KINDS.has(kind); + // A plate box that cuts through its own artwork is a plate the page will + // crop: object-fit: cover on that box shows the artwork with the side the + // box lost, and the hero passed a cover arch cut flat on the left and + // bleeding into the footer at 87%. Measure the artwork's edge contact + // and say it here, where the fix is a wider grid span. + // sides on the comp's own edge do not count: the comp crops there too + const atCompEdge = { left: px.x <= 1, top: px.y <= 1, right: px.x + px.w >= comp.width - 1, bottom: px.y + px.h >= comp.height - 1 }; + const clipped = raster && kind !== 'texture' && !raw.bleed ? artworkTouchesEdges(c, { ground: pageGround }).filter((side) => !atCompEdge[side]) : []; + if (clipped.length) warnings.push(`region ${raw.id}: the artwork runs off the box on the ${clipped.join(' and ')} (its ink reaches the edge over ${EDGE_CONTACT_MIN * 100}% of that side). Widen the region so the box holds the whole shape with a margin; a plate placed with object-fit: cover on this box would be cut there.`); regions.push({ id: raw.id, kind, @@ -175,6 +226,7 @@ export function measureRegions(comp, regionsInput, compPath) { palette: paletteOf(c), detail: { energy: r4(energy) }, medium: raw.medium || (raster ? 'raster' : 'semantic'), + clipped: clipped.length ? clipped : undefined, plate: raster ? (raw.plate || path.join(PLATES_DIR, `${raw.id}.png`)) : null, text: raw.text || null, }); @@ -188,6 +240,7 @@ export function measureRegions(comp, regionsInput, compPath) { version: 1, createdAt: new Date().toISOString(), comp: compPath, + warnings, uncoveredInkCells: uncovered, compSize: { width: comp.width, height: comp.height }, aspect: r4(comp.width / comp.height), @@ -264,6 +317,7 @@ export function printSpec(spec) { } const plates = spec.regions.filter((r) => r.medium === 'raster'); lines.push(`PLATES ${plates.length} to produce: ${plates.map((r) => r.id).join(', ') || 'none'}`); + for (const w of spec.warnings || []) lines.push(`WARN ${w}`); lines.push('RULE anything not in this list does not exist on the page: no borders, rules, chrome, or containers the comp does not show. Every raster region ships as its plate, never as CSS.'); return lines.join('\n'); } diff --git a/skill/scripts/data/font-index-failures.json b/skill/scripts/data/font-index-failures.json new file mode 100644 index 000000000..1aca67220 --- /dev/null +++ b/skill/scripts/data/font-index-failures.json @@ -0,0 +1,121 @@ +[ + { + "family": "Betania Patmos GDL", + "weight": 400, + "category": "handwriting", + "variable": false, + "reason": "not loaded or no lettering" + }, + { + "family": "Betania Patmos In GDL", + "weight": 400, + "category": "handwriting", + "variable": false, + "reason": "not loaded or no lettering" + }, + { + "family": "Doto", + "weight": 300, + "category": "sans", + "variable": true, + "reason": "not loaded or no lettering" + }, + { + "family": "Doto", + "weight": 700, + "category": "sans", + "variable": true, + "reason": "not loaded or no lettering" + }, + { + "family": "Jacquard 12 Charted", + "weight": 400, + "category": "display", + "variable": false, + "reason": "not loaded or no lettering" + }, + { + "family": "Jacquard 24 Charted", + "weight": 400, + "category": "display", + "variable": false, + "reason": "not loaded or no lettering" + }, + { + "family": "Jacquarda Bastarda 9 Charted", + "weight": 400, + "category": "display", + "variable": false, + "reason": "not loaded or no lettering" + }, + { + "family": "Jersey 10 Charted", + "weight": 400, + "category": "display", + "variable": false, + "reason": "not loaded or no lettering" + }, + { + "family": "Jersey 15 Charted", + "weight": 400, + "category": "display", + "variable": false, + "reason": "not loaded or no lettering" + }, + { + "family": "Jersey 20 Charted", + "weight": 400, + "category": "display", + "variable": false, + "reason": "not loaded or no lettering" + }, + { + "family": "Jersey 25 Charted", + "weight": 400, + "category": "display", + "variable": false, + "reason": "not loaded or no lettering" + }, + { + "family": "Micro 5 Charted", + "weight": 400, + "category": "display", + "variable": false, + "reason": "not loaded or no lettering" + }, + { + "family": "Montserrat Underline", + "weight": 400, + "category": "sans", + "variable": true, + "reason": "not loaded or no lettering" + }, + { + "family": "Montserrat Underline", + "weight": 700, + "category": "sans", + "variable": true, + "reason": "not loaded or no lettering" + }, + { + "family": "Redacted", + "weight": 400, + "category": "display", + "variable": false, + "reason": "not loaded or no lettering" + }, + { + "family": "Yarndings 12 Charted", + "weight": 400, + "category": "display", + "variable": false, + "reason": "not loaded or no lettering" + }, + { + "family": "Yarndings 20 Charted", + "weight": 400, + "category": "display", + "variable": false, + "reason": "not loaded or no lettering" + } +] \ No newline at end of file diff --git a/skill/scripts/lib/hero-checks.mjs b/skill/scripts/lib/hero-checks.mjs index 9fde90cba..ec0c9f132 100644 --- a/skill/scripts/lib/hero-checks.mjs +++ b/skill/scripts/lib/hero-checks.mjs @@ -161,3 +161,24 @@ export function inventedInk(comp, build, { cols = 10, rows = 10, floor = 10, add } return { cells, fraction: cells.length / (cols * rows) }; } + +/** + * A plate cropped by its box: the comp's artwork keeps a margin inside the + * region on some side and the build's ink runs flush to that edge (object-fit: + * cover on a box smaller than the artwork's aspect, or an sized to the + * column). The best build of the fifth sweep passed the hero at 87% with the + * cover arch cut off at the left and bottom; the human review called it a + * bug in one word. Returns the sides clipped, or []. + */ +export function plateClipCheck(region, compCrop, buildCrop, { margin = 6 } = {}) { + const a = inkBox(compCrop), b = inkBox(buildCrop); + if (!a || !b) return { sides: [] }; + const W = compCrop.width, H = compCrop.height; + const sides = []; + const flush = (v) => v <= 1; + if (a.x >= margin && flush(b.x)) sides.push('left'); + if (a.y >= margin && flush(b.y)) sides.push('top'); + if (W - (a.x + a.w) >= margin && flush(W - (b.x + b.w))) sides.push('right'); + if (H - (a.y + a.h) >= margin && flush(H - (b.y + b.h))) sides.push('bottom'); + return { sides, comp: a, build: b }; +} diff --git a/tests/build-phase.test.mjs b/tests/build-phase.test.mjs index b8a06508b..4aee7887b 100644 --- a/tests/build-phase.test.mjs +++ b/tests/build-phase.test.mjs @@ -74,6 +74,16 @@ describe('comp-spec', () => { assert.equal(plate.regions[0].medium, 'raster'); }); + 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]); + fillRect(comp, 400, 100, 500, 800, [15, 15, 15, 255]); + const cut = measureRegions(comp, { allowUncovered: true, regions: [{ id: 'arch', kind: 'plate', box: { x: 0.5, y: 0, w: 0.5, h: 1 }, note: 'hand-cut black arch' }] }, 'c.png'); + assert.ok(cut.warnings.some((w) => /region arch: the artwork runs off the box on the left/.test(w)), JSON.stringify(cut.warnings)); + const whole = measureRegions(comp, { allowUncovered: true, regions: [{ id: 'arch', kind: 'plate', box: { x: 0.35, y: 0.05, w: 0.6, h: 0.9 }, note: 'hand-cut black arch' }] }, 'c.png'); + assert.deepEqual(whole.warnings, []); + }); + it('refuses a code region that covers a column of the comp, unless container', () => { const comp = makeComp(); assert.throws(() => measureRegions(comp, { allowUncovered: true, regions: [{ id: 'parts-column', kind: 'chrome', grid: 'G0:J9', note: 'right column of parts' }] }, 'c.png'), /covers 40% of the comp/);