mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
Hero refuses inline SVG illustrations; finish cannot record ship over an open phase; the comp-led path names its model tier
From the human review's most repeated pin ('terrible svg instead of asset', on every model) and from sessions that wrote 'ship' with the hero open. Icons, arrows, chevrons, and runtime data charts stay code; diagrams, notation, and leader lines are plates.
AI-assisted (Claude Code).
This commit is contained in:
@@ -101,7 +101,9 @@ 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 <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 <comp> --regions <file>`. 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 <text region>` reads the comp's cap height, width class, and weight off the pixels, and `font-match.mjs --rank <region> --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 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. <!-- rule:skill-comp-spec -->
|
||||
The comp-led path is a frontier-tier job: it asks the builder to hold a measured layout, place plates at their boxes, and act on numeric readings across a dozen attempts. Smaller or faster models produce a recognisable page and stall under the hero gate; if the model in hand is one of those, say so before the direction round and take the code-led path, or expect the run to end at the hero with its readings unmet.
|
||||
|
||||
1. **spec.** Measure the comp: `comp-spec.mjs --comp <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 <comp> --regions <file>`. 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 <text region>` reads the comp's cap height, width class, and weight off the pixels, and `font-match.mjs --rank <region> --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. <!-- rule:skill-comp-spec -->
|
||||
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 <id>` does one region end to end and scores it against the crop; a harness-native image tool takes the crop (`comp-spec.mjs --crop <id>`) as its input image and `comp-spec.mjs --plate-prompt <id>` 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. <!-- rule:skill-plates-before-page -->
|
||||
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 `<img>`, 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. <!-- rule:skill-hero-gate -->
|
||||
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.
|
||||
|
||||
@@ -71,7 +71,7 @@ const require = createRequire(import.meta.url);
|
||||
import { crop, createImage, blit, resize } from './lib/raster.mjs';
|
||||
import { structureScore } from './lib/image-metrics.mjs';
|
||||
import { compare, verdictFor, alignBuild, bestShift } from './comp-diff.mjs';
|
||||
import { textRegionCheck, chromeStripCheck, inventedInk, plateClipCheck } from './lib/hero-checks.mjs';
|
||||
import { textRegionCheck, chromeStripCheck, inventedInk, plateClipCheck, svgIllustrations } from './lib/hero-checks.mjs';
|
||||
import { SPEC_PATH, BUILD_DIR, loadSpec, plateReference } from './comp-spec.mjs';
|
||||
import { choiceStamped } from './font-match.mjs';
|
||||
|
||||
@@ -521,6 +521,15 @@ export function gateHero(state, { buildPath = HERO_REPRO, specPath = SPEC_PATH,
|
||||
if (artifactFile && fs.existsSync(artifactFile) && specForRefs) {
|
||||
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`);
|
||||
// Inline SVG past an icon's budget is a drawing in code: the single most
|
||||
// repeated pin of the human review ("terrible svg instead of asset",
|
||||
// "lines that point nowhere"), on every model. Icons, arrows and
|
||||
// chevrons pass; diagrams, notation, and leader lines do not; those are
|
||||
// plates, or part of the plate they annotate.
|
||||
let svgs = [];
|
||||
try { svgs = svgIllustrations(fs.readFileSync(artifactFile, 'utf8')); } catch { svgs = []; }
|
||||
for (const v of svgs.slice(0, 6)) reasons.push(`artifact draws an illustration in inline SVG${v.label ? ` (${v.label})` : ''}: ${v.snippet}. Drawings, diagrams, notation, and leader lines are plates or belong to the plate they annotate; only icon-sized SVG (under 64px, a few paths) is code`);
|
||||
if (svgs.length > 6) reasons.push(`...and ${svgs.length - 6} more inline SVG illustrations`);
|
||||
}
|
||||
// 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
|
||||
@@ -860,6 +869,14 @@ async function main() {
|
||||
if (cmd === 'finish') {
|
||||
const disposition = arg('disposition');
|
||||
if (!['ship', 'fix', 'rebuild', 'recapture'].includes(disposition)) { console.error('build-phase: finish --disposition ship|fix|rebuild|recapture'); process.exit(1); }
|
||||
// A ship cannot be recorded over an open phase. The model can still stop
|
||||
// talking, but it cannot write "ship" into the state with the hero open;
|
||||
// sessions did exactly that and summarised the build as complete.
|
||||
const openBefore = PHASES.filter((ph) => ph !== 'review' && state.phases[ph] && state.phases[ph].status !== 'closed' && state.phases[ph].status !== 'skipped');
|
||||
if (disposition === 'ship' && openBefore.length) {
|
||||
console.error(`build-phase: finish --disposition ship refused: ${openBefore.join(', ')} ${openBefore.length === 1 ? 'is' : 'are'} not closed (phase ${state.phase}). Record fix or rebuild, or close the phases first; a page shipped over an open hero is a page shipped against its own gate.`);
|
||||
process.exit(2);
|
||||
}
|
||||
state.finish = { disposition, at: now(), phaseAtFinish: state.phase };
|
||||
if (state.phase === 'review') { state.phases.review.status = 'closed'; state.phases.review.closedAt = now(); }
|
||||
saveState(state);
|
||||
|
||||
@@ -207,3 +207,40 @@ export function plateClipCheck(region, compCrop, buildCrop, { margin = 6 } = {})
|
||||
if (H - (a.y + a.h) >= margin && flush(H - (b.y + b.h))) sides.push('bottom');
|
||||
return { sides, comp: a, build: b };
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline SVG that is an illustration, not an icon. An icon is small (a
|
||||
* viewBox or box under `iconPx` on its long side) with a few paths; anything
|
||||
* with a real path budget is a drawing in code: a diagram, a rack of
|
||||
* carburetors, staff notation, leader lines with arrows, a "terrible svg
|
||||
* approximation of the asset". Those ship as plates or as part of the plate
|
||||
* they annotate. Returns one entry per offending <svg> with a snippet.
|
||||
*
|
||||
* `html` is the artifact source. `pathBudget` counts characters of path
|
||||
* data (d="..."), points, and polyline/polygon points across the element.
|
||||
*/
|
||||
export function svgIllustrations(html, { iconPx = 64, pathBudget = 480, maxPaths = 8 } = {}) {
|
||||
const out = [];
|
||||
const re = /<svg\b([^>]*)>([\s\S]*?)<\/svg>/gi;
|
||||
let m;
|
||||
while ((m = re.exec(html))) {
|
||||
const attrs = m[1], body = m[2];
|
||||
const paths = (body.match(/<path\b/gi) || []).length + (body.match(/<(polyline|polygon|line|circle|ellipse|rect)\b/gi) || []).length;
|
||||
let budget = 0;
|
||||
for (const d of body.matchAll(/\sd="([^"]*)"/g)) budget += d[1].length;
|
||||
for (const pts of body.matchAll(/\spoints="([^"]*)"/g)) budget += pts[1].length;
|
||||
const vb = /viewBox="\s*[-\d.]+\s+[-\d.]+\s+([\d.]+)\s+([\d.]+)/.exec(attrs);
|
||||
const w = /\swidth="([\d.]+)(px)?"/.exec(attrs), h = /\sheight="([\d.]+)(px)?"/.exec(attrs);
|
||||
const long = Math.max(vb ? Math.max(+vb[1], +vb[2]) : 0, w ? +w[1] : 0, h ? +h[1] : 0);
|
||||
const iconSized = long > 0 && long <= iconPx && paths <= maxPaths;
|
||||
const uses = /<use\b/i.test(body) && paths === 0; // a sprite reference
|
||||
if (uses) continue;
|
||||
if (iconSized && budget <= pathBudget) continue;
|
||||
if (budget <= pathBudget && paths <= maxPaths && long === 0 && !/<(text|image)\b/i.test(body)) continue; // a tiny inline glyph with no size hint
|
||||
if (budget > pathBudget || paths > maxPaths || (long > iconPx && paths > 0)) {
|
||||
const id = /\b(id|class|aria-label|data-region)="([^"]+)"/i.exec(attrs);
|
||||
out.push({ snippet: `<svg${attrs.slice(0, 80).replace(/\s+/g, ' ')}...> (${paths} shapes, ${budget} chars of path data${long ? `, ${long}px` : ''})`, label: id ? id[2] : null, paths, budget, long });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -58,3 +58,19 @@ describe('hero-checks: chrome strips and invented ink', () => {
|
||||
assert.ok(c.ink && /^#[0-9a-f]{6}$/.test(c.ink.hex));
|
||||
});
|
||||
});
|
||||
|
||||
describe('hero-checks: inline SVG illustrations', () => {
|
||||
it('lets icons, arrows and sprite references through and refuses drawings', async () => {
|
||||
const { svgIllustrations } = await import('../skill/scripts/lib/hero-checks.mjs');
|
||||
const icon = '<svg width="16" height="16" viewBox="0 0 16 16"><path d="M2 8h12M9 3l5 5-5 5"/></svg>';
|
||||
const chevron = '<svg viewBox="0 0 24 24" class="chev"><path d="M6 9l6 6 6-6"/></svg>';
|
||||
const sprite = '<svg class="i"><use href="#wrench"/></svg>';
|
||||
const diagram = '<svg viewBox="0 0 600 400" class="carb-rack">' + Array.from({ length: 30 }, (_, i) => `<path d="M${i * 10} 10 C ${i * 10 + 5} 40, ${i * 10 + 20} 60, ${i * 10 + 30} 90 L ${i * 10 + 40} 120 Z"/>`).join('') + '</svg>';
|
||||
const staff = '<svg width="800" height="200">' + Array.from({ length: 12 }, (_, i) => `<line x1="0" y1="${i * 12}" x2="800" y2="${i * 12}"/>`).join('') + '</svg>';
|
||||
assert.deepEqual(svgIllustrations(icon + chevron + sprite), []);
|
||||
const found = svgIllustrations(icon + diagram + staff);
|
||||
assert.equal(found.length, 2);
|
||||
assert.equal(found[0].label, 'carb-rack');
|
||||
assert.ok(found[0].paths >= 30);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user