Hero gate reads type, strips, and invented ink as numbers; every region needs a note; missing beats the text relaxation

From the first human review of sweep-3 builds (pins on 12 samples): fonts at the wrong size, weight, colour, or place; nav bars too tall; kickers and dividers the comp does not have; a footer strip pushed off the frame that read as drift on ground colour alone; a drawing filed as chrome with no note to catch it.

- lib/hero-checks.mjs: textRegionCheck (cap height, line count, ink density, ink colour, first-line offset vs the comp crop, measured fresh), chromeStripCheck (first rule row), inventedInk (build energy over a calm comp cell and neighbourhood). Wired into gateHero as reasons; invented ink vetoes at 4% of cells.
- comp-diff verdictFor: detailRaw < 0.15 is missing whatever the palette.
- comp-spec: every region carries a note.
- font-fingerprint: the tall-line filter takes its median over lines with real mass, so two display lines above a small line are not 'tall'.

AI-assisted (Claude Code).
This commit is contained in:
Paul Bakaus
2026-08-28 06:13:59 +05:00
committed by Abdul Wahab
parent ba26730411
commit 00a180380e
8 changed files with 296 additions and 20 deletions
+42 -1
View File
@@ -69,7 +69,8 @@ import { createRequire } from 'node:module';
import { decodePng, loadRaster } from './lib/png.mjs';
const require = createRequire(import.meta.url);
import { crop, createImage, blit } from './lib/raster.mjs';
import { compare, verdictFor } from './comp-diff.mjs';
import { compare, verdictFor, alignBuild, bestShift } from './comp-diff.mjs';
import { textRegionCheck, chromeStripCheck, inventedInk } from './lib/hero-checks.mjs';
import { SPEC_PATH, BUILD_DIR, loadSpec, plateReference } from './comp-spec.mjs';
import { choiceStamped } from './font-match.mjs';
@@ -357,6 +358,32 @@ export function organicClipRegions(artifactFile, spec) {
return out;
}
/** Fraction of grid cells with invented ink that fails the hero. */
export const INVENTED_MIN = 0.04;
/**
* Text, chrome and invented-ink readings for the hero: the comp and the
* build aligned the way comp-diff aligns them, then per-region checks from
* lib/hero-checks.mjs. Returns { text: [], chrome: [], invented }.
*/
export function heroReadings(state, spec, buildPath) {
if (!spec || !state.comp) return null;
const comp = loadRaster(state.comp).image;
const build = loadRaster(buildPath).image;
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 = [];
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); }
}
const invented = inventedInk(comp, aligned);
return { text, chrome, invented };
}
export function gateHero(state, { buildPath = HERO_REPRO, specPath = SPEC_PATH, min = HERO_MIN, outDir = path.join('.impeccable', 'review', 'diff', 'hero'), artifact = null } = {}) {
if (!fs.existsSync(buildPath)) return { ok: false, reasons: [`no hero capture at ${buildPath}: screenshot the first viewport at the comp's own dimensions (${state.breakpoint || 'comp size'}) into that path`] };
const specForRefs = loadSpec(specPath);
@@ -469,6 +496,20 @@ export function gateHero(state, { buildPath = HERO_REPRO, specPath = SPEC_PATH,
const organic = organicClipRegions(artifactFile, specForRefs);
for (const r of organic) reasons.push(`artifact draws an organic clip-path (${r.snippet}) inside raster region ${r.id}'s box; that region ships as its plate, never as a polygon`);
}
// Numbers a designer reads off the side-by-side: type set at the wrong
// 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.
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) {
for (const f of readings.text) reasons.push(f);
for (const f of readings.chrome) reasons.push(f);
if (readings.invented && readings.invented.fraction >= INVENTED_MIN) {
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`);
}
}
const worstRegions = [...report.regions].sort((a, b) => a.score.overall - b.score.overall).slice(0, 3);
const regionDir = path.join(outDir, 'regions');
return {
+6
View File
@@ -133,6 +133,12 @@ export function bestShift(comp, build, workWidth = 256) {
export function verdictFor(s, kind = null) {
const painted = kind === 'plate' || kind === 'image' || kind === 'texture';
// Nothing drawn where the comp drew something is missing whatever the
// palette says: a footer strip the build pushed below the fold read as
// 'drift' on ground colour alone (detail 4%, structure 92%). detailRaw is
// 1 when the comp region itself is calm, so a low value already means the
// comp had material there.
if (s.detailRaw != null && s.detailRaw < 0.15) return 'missing';
if (painted && s.detail < 0.5) return 'missing';
// For text, chrome, and controls "missing" means the build has nothing
// there, not that a thin strip sits a few pixels off: require the build's
+7
View File
@@ -135,6 +135,13 @@ export function measureRegions(comp, regionsInput, compPath) {
if (seen.has(raw.id)) throw new Error(`duplicate region id ${raw.id}`);
seen.add(raw.id);
const kind = raw.kind && KINDS.has(raw.kind) ? raw.kind : 'band';
// Every region says what it is. The note is what the plate prompt, the
// gate messages, and the painted-material check read; a regions file of
// bare ids and kinds is a list of boxes, and a session that named a
// carburetor drawing "chrome" with no note was caught by nothing.
if (kind !== 'band' && !(raw.note && String(raw.note).trim().length >= 8)) {
throw new Error(`region ${raw.id} has no note. Say in a few words what the comp shows there (the element, its material, its role): the note drives the plate prompt and the gate's messages, and a drawing named as chrome is only caught by what its note says.`);
}
// The note is the model's own reading of the region. A note that names
// painted material (a drawing, diagram, photo, illustration, texture)
// filed under a code kind is a plate about to be redrawn in SVG: the
+8 -2
View File
@@ -134,8 +134,14 @@ function findLines(bin) {
// carburetor drawing). When several lines exist, ones far taller than the
// median are not lettering: leave them out of the mass reference and out
// of the result.
if (lines.length >= 3) {
const hs = lines.map((l) => l.y1 - l.y0).sort((a, b) => a - b);
// The median is taken over lines carrying real mass (rule slivers and
// tittles do not vote), and needs three of them: two 145px headline lines
// above a 26px artist line were dropped as 'tall' against a median pulled
// to 28 by three slivers.
const massMax = Math.max(1, ...lines.map((l) => l.mass));
const real = lines.filter((l) => l.mass >= massMax * 0.05);
if (real.length >= 3) {
const hs = real.map((l) => l.y1 - l.y0).sort((a, b) => a - b);
const medH = hs[Math.floor(hs.length / 2)];
for (const ln of lines) if (ln.y1 - ln.y0 > medH * 3) ln.tall = true;
}
+156
View File
@@ -0,0 +1,156 @@
/**
* Hero-gate checks that name a miss as a number the model can act on.
*
* comp-diff scores regions; these read what a designer reads when the two
* frames sit side by side and says it as numbers: the headline is set at
* cap 78px where the comp's is 103; it wraps to four lines where the comp
* has three; its ink is #2a2a2a where the comp's is #a72f1b; it starts
* 60px lower in its box; the masthead is 92px tall where the comp's is 58;
* these grid cells carry ink the comp does not have (a kicker, a divider, a
* second nav row). Every one of those was a pin in the first human review
* of the third sweep, on builds the region scores had already let through.
*
* All functions are pure over decoded rasters and the spec; the gate wires
* them and decides what vetoes.
*/
import { fingerprint } from './font-fingerprint.mjs';
import { crop } from './raster.mjs';
import { dominantColors, deltaE, detailGrid } from './image-metrics.mjs';
import { inkBox } from '../comp-diff.mjs';
/** Dominant ink colour of a crop: the heaviest cluster that is not the ground. */
export function inkColor(img) {
const cols = dominantColors(img, 4);
if (!cols.length) return null;
const ground = cols[0];
const ink = cols.find((c) => c !== ground && deltaE(c.lab, ground.lab) > 20) || null;
return { ground, ink };
}
/**
* Compare one text region's build crop against the comp's measurement.
* `region` is a spec region with `type.comp` (font-match --measure) and
* `px`; `compCrop` / `buildCrop` are the region crops at comp scale.
* Returns { findings: string[], metrics }.
*/
export function textRegionCheck(region, compCrop, buildCrop, { capTol = 0.22, minCap = 10 } = {}) {
const findings = [];
// Measure the comp crop now rather than trusting spec.type.comp: the spec
// may carry an older fingerprint's reading, and this check has to agree
// with itself on both sides.
const comp = fingerprint(compCrop);
if (!comp || !comp.capHeightPx || comp.capHeightPx < minCap || comp.glyphs < 6) return { findings, metrics: null };
// rotated type (a spine set vertical) reads as many short 'lines' of one
// or two glyphs; the fingerprint has nothing to say about it
if (comp.lines >= 5 && comp.glyphs / comp.lines < 3) return { findings, metrics: null };
// a cap taller than half the box is a drawing read as a glyph, not type
if (comp.capHeightPx > compCrop.height * 0.6) return { findings, metrics: null };
const bfp = fingerprint(buildCrop);
const metrics = { comp: { cap: comp.capHeightPx, lines: comp.lines, glyphs: comp.glyphs }, build: bfp ? { cap: bfp.capHeightPx, lines: bfp.lines, glyphs: bfp.glyphs } : null };
if (!bfp || bfp.glyphs < 4) {
// nothing legible in the box: comp-diff's missing/contradicted covers it
return { findings, metrics };
}
const capDelta = (bfp.capHeightPx - comp.capHeightPx) / comp.capHeightPx;
if (Math.abs(capDelta) > capTol) {
findings.push(`text ${region.id}: cap height ${bfp.capHeightPx}px in the build, ${comp.capHeightPx}px in the comp (${capDelta > 0 ? '+' : ''}${Math.round(capDelta * 100)}%); set font-size so the cap height renders at ${comp.capHeightPx}px${region.type.chosen ? ` (font-match ranked ${region.type.chosen.family} ${region.type.chosen.weight} at ${region.type.chosen.fontSizePx}px)` : ''}`);
}
if (comp.lines >= 2 && bfp.lines !== comp.lines && Math.abs(bfp.lines - comp.lines) >= 1) {
findings.push(`text ${region.id}: ${bfp.lines} line${bfp.lines === 1 ? '' : 's'} in the build, ${comp.lines} in the comp; the measure (max-width, font-size, letter-spacing) wraps it differently, so the block is a different shape`);
}
// weight: compare ink density of tall glyphs when both sides have it and
// the sizes agree (density at a different cap is a different reading)
if (comp.densTall != null && bfp.densTall != null && Math.abs(capDelta) <= capTol) {
const r = bfp.densTall / comp.densTall;
if (r > 1.25) findings.push(`text ${region.id}: the face renders ${Math.round((r - 1) * 100)}% heavier than the comp's (ink density ${bfp.densTall.toFixed(2)} vs ${comp.densTall.toFixed(2)}); drop a weight step or use the ranked face`);
else if (r < 0.75) findings.push(`text ${region.id}: the face renders ${Math.round((1 - r) * 100)}% lighter than the comp's (ink density ${bfp.densTall.toFixed(2)} vs ${comp.densTall.toFixed(2)}); raise a weight step or use the ranked face`);
}
// colour: dominant ink of each crop
const ca = inkColor(compCrop), cb = inkColor(buildCrop);
if (ca && cb && ca.ink && cb.ink) {
const d = deltaE(ca.ink.lab, cb.ink.lab);
if (d > 22) findings.push(`text ${region.id}: ink is ${cb.ink.hex} in the build, ${ca.ink.hex} in the comp; use the comp's colour`);
}
// vertical placement inside the box: top of ink
const ba = inkBox(compCrop), bb = inkBox(buildCrop);
if (ba && bb) {
const dy = bb.y - ba.y;
if (Math.abs(dy) > Math.max(12, compCrop.height * 0.15)) findings.push(`text ${region.id}: its first line starts ${Math.abs(Math.round(dy))}px ${dy > 0 ? 'lower' : 'higher'} than in the comp (${bb.y}px vs ${ba.y}px into the region box); the spacing above it is ${dy > 0 ? 'too large' : 'too small'}`);
const dx = bb.x - ba.x;
if (Math.abs(dx) > Math.max(12, compCrop.width * 0.15)) findings.push(`text ${region.id}: it starts ${Math.abs(Math.round(dx))}px ${dx > 0 ? 'further right' : 'further left'} than in the comp`);
}
metrics.capDelta = +capDelta.toFixed(3);
return { findings, metrics };
}
/**
* Rows of a crop that carry a horizontal rule: a row whose gray step from
* the row above (or below) is strong across at least `span` of the width.
* Returns row indices sorted top to bottom.
*/
export function ruleRows(img, { span = 0.5, step = 28 } = {}) {
const W = img.width, H = img.height;
const gray = (x, y) => { const i = (y * W + x) * 4; return 0.299 * img.data[i] + 0.587 * img.data[i + 1] + 0.114 * img.data[i + 2]; };
const rows = [];
for (let y = 1; y < H - 1; y++) {
let strong = 0;
for (let x = 0; x < W; x++) { const d = Math.max(Math.abs(gray(x, y) - gray(x, y - 1)), Math.abs(gray(x, y) - gray(x, y + 1))); if (d > step) strong++; }
if (strong >= W * span) rows.push(y);
}
// collapse adjacent rows into one edge
const out = [];
for (const y of rows) if (!out.length || y - out[out.length - 1] > 3) out.push(y);
return out;
}
/**
* A thin chrome region (masthead, nav bar, footer strip) has a height, and
* its height is where its rule sits. Compare the first horizontal rule's row
* in comp vs build; fall back to the ink extents when neither has a rule.
*/
export function chromeStripCheck(region, compCrop, buildCrop) {
const findings = [];
const strip = compCrop.height <= compCrop.width * 0.35;
if (!strip) return { findings };
const ra = ruleRows(compCrop), rb = ruleRows(buildCrop);
if (ra.length && rb.length) {
// the rule that closes the strip is the first one from the top (a grid
// row often carries the next element's top edge lower down)
const ya = ra[0], yb = rb[0];
const dy = yb - ya;
if (Math.abs(dy) > Math.max(5, compCrop.height * 0.06)) findings.push(`${region.kind} ${region.id}: its rule sits ${ya}px into the box in the comp and ${yb}px in the build (${dy > 0 ? '+' : ''}${dy}px), so the strip is ${dy > 0 ? 'taller' : 'shorter'} than the comp's; match the height, not only the position`);
return { findings, comp: ya, build: yb };
}
const ba = inkBox(compCrop), bb = inkBox(buildCrop);
if (!ba || !bb) return { findings };
if (ba.w >= compCrop.width * 0.6 && ba.h <= compCrop.height * 0.6) {
const dh = bb.h - ba.h;
if (Math.abs(dh) > Math.max(10, ba.h * 0.25)) findings.push(`${region.kind} ${region.id}: its ink is ${bb.h}px tall in the build and ${ba.h}px in the comp (${dh > 0 ? '+' : ''}${dh}px); match the height, not only the position`);
}
return { findings, comp: ba, build: bb };
}
/**
* Cells of the frame where the build carries ink and the comp is calm.
* Returns { cells: [{col,row,label}], fraction } on a cols x rows grid.
* `floor` is the comp energy under which a cell counts as calm; `added` is
* the build energy over which the build counts as inked.
*/
export function inventedInk(comp, build, { cols = 10, rows = 10, floor = 10, added = 12, ratio = 2.5 } = {}) {
const a = detailGrid(comp, cols, rows, 512), b = detailGrid(build, cols, rows, 512);
const cells = [];
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
const i = r * cols + c;
// calm in the comp (grain, flat ground) and inked in the build well past
// what grain would give: a kicker over paper, a divider, a nav row
if (!(a.cells[i] < floor && b.cells[i] > Math.max(added, a.cells[i] * ratio))) continue;
// the comp must be calm around the cell too: a hard edge one pixel over
// the cell boundary in the build (a bar shifted by a subpixel of the
// alignment) reads as invented otherwise
let neighbourhood = 0, n = 0;
for (let dr = -1; dr <= 1; dr++) for (let dc = -1; dc <= 1; dc++) { const rr = r + dr, cc = c + dc; if (rr < 0 || cc < 0 || rr >= rows || cc >= cols) continue; neighbourhood += a.cells[rr * cols + cc]; n++; }
if (neighbourhood / n >= floor * 2) continue;
cells.push({ col: c, row: r, label: `${String.fromCharCode(65 + c)}${r}`, comp: +a.cells[i].toFixed(1), build: +b.cells[i].toFixed(1) });
}
return { cells, fraction: cells.length / (cols * rows) };
}