mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
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:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
Reference in New Issue
Block a user