diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md index 387f955a9..676f6c4f6 100644 --- a/docs/CLI-CONTRACT.md +++ b/docs/CLI-CONTRACT.md @@ -413,6 +413,16 @@ Optional keys added later by engines (appended after the above): `ignoreValue` ( - **Real mode**: no `OPENAI_API_KEY` → stderr `generate-image: OPENAI_API_KEY is not set; use the harness-native image tool instead.` exit 1. Without refs: `POST https://api.openai.com/v1/images/generations`, headers `Authorization: Bearer `, `content-type: application/json`, body `{"model":"gpt-image-2","prompt":...,"size":...,"quality":...,"n":1}`. With refs: `POST https://api.openai.com/v1/images/edits` multipart FormData fields `model=gpt-image-2`, `prompt`, `size`, `quality`, `n=1`, `image[]` blobs (type png/webp else jpeg, filename basename). Non-ok → stderr `generate-image: API error ${status}: ${first 300 chars}` exit 1; no `data[0].b64_json` → `generate-image: no image in response` exit 1. Writes decoded bytes to `--out`; best-effort `node embed-prompt.mjs --prompt ` and sidecar `${out}.json` = `{prompt, createdAt: ISO, tool: 'generate-image.mjs', model: 'gpt-image-2', [refs]}` (2-space). stdout `IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); prompt embedded + sidecar at ${out}.json`; exit 0. - Tests: `tests/new-work-e2e.test.mjs` (fake mode chain, opt-in `test:new-work-e2e`). +#### comp-fidelity verbs: `comp-spec` / `comp-diff` / `font-match` / `build-phase` + +Ported from the former `skill/scripts/{comp-spec,comp-diff,font-match,build-phase}.mjs` (+ `lib/{png,raster,image-metrics,font-fingerprint,font-index,hero-checks}.mjs`) into the engine; invoked as `{{scripts_path}}/impeccable `. All four resolve paths against the process cwd. Printed commands spell the launcher via `IMPECCABLE_SELF` (default `impeccable`), so they name `{{scripts_path}}/impeccable `, never `node …mjs`. ISO `createdAt`/`startedAt` timestamps in stdout and written JSON are the only run-dependent output. + +- **`comp-spec`** — turns an approved comp into a measured build spec. `--comp --grid` writes `.impeccable/build/comp-grid.png` (10x10 labeled grid) and prints PALETTE/BANDS/NEXT; `--comp --regions ` measures regions into `.impeccable/build/spec.json` (region box, sampled palette, medium, aspect, detail energy, plate path for raster kinds) and prints the spec; `--comp --auto` derives band regions; `--print` prints the compact spec; `--crop [--out f] [--scale n] [--raw]` writes a reference crop; `--plate-prompt ` prints the regeneration prompt. `--spec ` overrides the spec path (default `.impeccable/build/spec.json`). Validation refusals (stderr, exit 1) are the JS strings verbatim: a region with no id / duplicate id / no note, a code-kind region whose note names painted material, a code region over 25% of the comp, a grid span that is not `:`, uncovered ink cells without `allowUncovered`. spec.json is byte-identical to the JS output. +- **`comp-diff`** — `--comp --build [--spec spec.json] [--out-dir dir] [--align top|stretch|cover] [--label name] [--threshold t] [--json] [--no-files]`. Scores structure / color / detail / bands and per-region verdicts (`match`/`drift`/`missing`/`contradicted`); writes `side-by-side.png`, `heatmap.png`, `regions/.png`, and `report.json` under `--out-dir` (unless `--no-files`); prints the text summary or, with `--json`, the report. Exit 0 measured, 1 usage/unreadable input, 3 below `--threshold`. The JSON report and text summary are byte-identical to the JS. +- **`font-match`** — `--measure ` fingerprints the comp crop of a text region (cap height, width/weight class, shape vector), records it on the region's `type` block in the spec, and prints the MEASURE line (pure; byte-identical to the JS). `--rank [--candidates "Family:700,…"] [--text "…"] [--transform …] [--category …]` additionally renders candidate faces in a headless browser and ranks them by fingerprint distance, writing a stamped `chosen` face onto the region and a proof sheet under `.impeccable/build/font-match/`. **Browser**: an installed Chrome discovered and driven over CDP (the same browser the URL engine uses; the JS used Playwright/Puppeteer). With no browser resolvable, the catalog's nearest face is recorded (source `catalog`, estimated size) or, with no catalog either, the MEASURE line stands — matching the JS fallbacks; the sha1 `chosen` stamp is byte-identical. Screenshots vary by Chrome version, so the rendered ranking is not byte-stable. +- **`font-index` catalog (paid moat)** — resolved at run time, never committed to the engine repo: `IMPECCABLE_CATALOG_DIR/font-index.json` first, then the skill's shipped `IMPECCABLE_SKILL_DIR/scripts/data/font-index.json`; absent → the built-in per-width shortlist stands in (the JS degraded path). +- **`build-phase`** — the comp-led build state machine at `.impeccable/build/state.json`. `start --comp | --direction ` (opens the phases; reads comp dimensions for the breakpoint), `status [--json]`, `advance [--force --reason "…"]` (runs the current phase's gate; exit 2 on gate failure, state unchanged), `record hero --build `, `scaffold`, `note ""`, `finish --disposition ship|fix|rebuild|recapture`. Phases and gates (`comps`, `spec`, `plates`, `hero`, `sections`, `motion`, `responsive`, `review`) are unchanged from the JS; the hero/responsive gates call comp-diff in-process (the JS spawned it). The organic-clip-path CSS scan is the engine's own rule (`organic-clip-path`), injected into the gate; `--force` is refused unless `--reason` quotes the user downgrading the comp (the JS `forceAllowed` logic verbatim). + --- ## 2. Context and utility verbs diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs index c70eb7f58..c8c0d7961 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -51,17 +51,13 @@ export const SUITES = { // A finite per-test cap so an async hang is cancelled and reported // rather than left running with `--test-timeout` unset (Infinity). // Note: this timer lives in the event loop, so it cannot interrupt a - // test blocked in a synchronous spawnSync; the child bounds in - // tests/build-phase.test.mjs and the runner's wall-clock group-kill - // cover that case. The slowest core test is ~11s, so 180s is safe. + // test blocked in a synchronous spawnSync; the runner's wall-clock + // group-kill covers that case. The slowest core test is ~11s, so 180s + // is safe. timeoutMs: 180000, files: [ - 'tests/build-phase.test.mjs', 'tests/ci-test-plan.test.mjs', - 'tests/comp-diff.test.mjs', - 'tests/font-match.test.mjs', 'tests/github-sheriff.test.mjs', - 'tests/hero-checks.test.mjs', 'tests/hook-build.test.mjs', 'tests/openai-plugin.test.mjs', 'tests/release.test.mjs', diff --git a/skill/agents/impeccable-asset-producer.md b/skill/agents/impeccable-asset-producer.md index 0284f4b72..2ccd89a35 100644 --- a/skill/agents/impeccable-asset-producer.md +++ b/skill/agents/impeccable-asset-producer.md @@ -26,9 +26,9 @@ When the parent hands you a decision card packet instead of an approved mock, th ## Input Contract -Expect the measured spec (`.impeccable/build/spec.json`, written by `comp-spec.mjs` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. +Expect the measured spec (`.impeccable/build/spec.json`, written by `impeccable comp-spec` from the approved comp), the approved comp path, and the skill scripts path. Optionally: a subset of region ids to produce, extra prompt notes per region, and format or transparency needs. Everything else you need is in the spec: each raster region's id, kind (plate, image, texture), pixel box, sampled palette, aspect, note, and the plate path it must land on. -If there is no spec, stop and return one line asking the parent to run `comp-spec.mjs` first. You do not inventory the comp yourself; the spec is the inventory, and a second inventory disagrees with the first. +If there is no spec, stop and return one line asking the parent to run `impeccable comp-spec` first. You do not inventory the comp yourself; the spec is the inventory, and a second inventory disagrees with the first. ## The job @@ -36,8 +36,8 @@ Every region with `medium: raster` in the spec ships as a plate at its `plate` p Per region, in the spec's order: -1. `node {{scripts_path}}/comp-spec.mjs --crop ` writes the reference crop under `.impeccable/build/crops/`. -2. Produce the plate. With the API fallback: `{{scripts_path}}/impeccable generate-image --plate --quality high` does the whole step (crop as reference, the spec's plate prompt, output size chosen from the region's aspect, the file written to its plate path, prompt embedded, and the plate scored against the crop). With a harness-native image tool: use the crop as the input image and `node {{scripts_path}}/comp-spec.mjs --plate-prompt ` as the prompt, write the result to the plate path, then run `{{scripts_path}}/impeccable embed-prompt --prompt ""`. +1. `{{scripts_path}}/impeccable comp-spec --crop ` writes the reference crop under `.impeccable/build/crops/`. +2. Produce the plate. With the API fallback: `{{scripts_path}}/impeccable generate-image --plate --quality high` does the whole step (crop as reference, the spec's plate prompt, output size chosen from the region's aspect, the file written to its plate path, prompt embedded, and the plate scored against the crop). With a harness-native image tool: use the crop as the input image and `{{scripts_path}}/impeccable comp-spec --plate-prompt ` as the prompt, write the result to the plate path, then run `{{scripts_path}}/impeccable embed-prompt --prompt ""`. 3. Read the score line. `PLATE-SCORE` under 50%, or a `PLATE-WARN`, means the plate does not read as the region: open the plate beside the crop, name what drifted (subject, framing, palette, style), tighten the prompt with that, and regenerate once. Two misses on one region: keep the better plate, mark it `needs_parent_review`, and say why in one line. 4. Transparent cutouts (a figure or object on the page ground): generate on a flat chroma color absent from the subject and key it to alpha before writing the PNG; never ship the keyed background. @@ -49,4 +49,4 @@ Do not redesign. Do not add objects, restyle, or reinterpret; the comp was appro ## Output Contract -Return one line per raster region: ` % `. Then `blockers` (missing spec, missing comp, no image capability, exhausted key) and `assumptions`, each global and minimal. Nothing else: no summary, no praise, no implementation advice. The parent runs `build-phase.mjs advance` to verify the plates against the same spec; your line and its line must agree. +Return one line per raster region: ` % `. Then `blockers` (missing spec, missing comp, no image capability, exhausted key) and `assumptions`, each global and minimal. Nothing else: no summary, no praise, no implementation advice. The parent runs `impeccable build-phase advance` to verify the plates against the same spec; your line and its line must agree. diff --git a/skill/agents/impeccable-finish-reviewer.md b/skill/agents/impeccable-finish-reviewer.md index 8f9855349..1be3b6dff 100644 --- a/skill/agents/impeccable-finish-reviewer.md +++ b/skill/agents/impeccable-finish-reviewer.md @@ -22,7 +22,7 @@ A hard turn ceiling ends the run without warning; a run that ends before its con ## Input Contract -Expect: the original request; the confirmed user answers; the artifact path(s); the screenshots the parent captured, in `.impeccable/review/` (web: `desktop.png` and `mobile.png`; native: device-class names such as `phone.png` and `tablet.png`, suffixed per OS on adaptive). A screenshot path the calling brief names is authoritative when the file exists; `.impeccable/review/` is where to look when the brief names none or a named path is missing, never a filename you invent. Also expect: the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); the PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths; on a comp-led build the approved comp path (a code-led build has none; it passes the chosen decision comp as a separate critique-reference input, labeled as such, and nothing here that binds "the approved comp" binds it); on a comp-led build the build state (`.impeccable/build/state.json`), the measured spec (`.impeccable/build/spec.json`), and the diff directories `.impeccable/review/diff/hero/` and `.impeccable/review/diff/final/` (each holds `side-by-side.png`, `heatmap.png`, `regions/.png` paired crops, and `report.json` with per-region scores and verdicts from `comp-diff.mjs`); and the skill's `reference/craft-floor.md` path. On a native (`ios` / `android` / `adaptive`) build the packet adds the platform reference path(s) (`reference/ios.md` / `reference/android.md`) and a line saying no detector ran: read the platform reference alongside the craft floor, judge every check in the platform's own conventions, treat the screenshots as device captures, and know your floor check is the build's only slop gate. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped. +Expect: the original request; the confirmed user answers; the artifact path(s); the screenshots the parent captured, in `.impeccable/review/` (web: `desktop.png` and `mobile.png`; native: device-class names such as `phone.png` and `tablet.png`, suffixed per OS on adaptive). A screenshot path the calling brief names is authoritative when the file exists; `.impeccable/review/` is where to look when the brief names none or a named path is missing, never a filename you invent. Also expect: the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); the PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths; on a comp-led build the approved comp path (a code-led build has none; it passes the chosen decision comp as a separate critique-reference input, labeled as such, and nothing here that binds "the approved comp" binds it); on a comp-led build the build state (`.impeccable/build/state.json`), the measured spec (`.impeccable/build/spec.json`), and the diff directories `.impeccable/review/diff/hero/` and `.impeccable/review/diff/final/` (each holds `side-by-side.png`, `heatmap.png`, `regions/.png` paired crops, and `report.json` with per-region scores and verdicts from `impeccable comp-diff`); and the skill's `reference/craft-floor.md` path. On a native (`ios` / `android` / `adaptive`) build the packet adds the platform reference path(s) (`reference/ios.md` / `reference/android.md`) and a line saying no detector ran: read the platform reference alongside the craft floor, judge every check in the platform's own conventions, treat the screenshots as device captures, and know your floor check is the build's only slop gate. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped. ## Checks, in order diff --git a/skill/reference/new-work.md b/skill/reference/new-work.md index 0d2f7f343..c58540b58 100644 --- a/skill/reference/new-work.md +++ b/skill/reference/new-work.md @@ -100,16 +100,16 @@ Build the assigned direction, not a safer interpretation of it. The form supplie When an approved comp exists, it is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words. Models systematically believe their HTML, CSS, and SVG recreation of an image succeeded when it did not, so the build runs as a state machine on disk whose gates measure the screen against the comp instead of asking you to remember it. Start it once, and let it tell you what is next: -`node {{scripts_path}}/build-phase.mjs start --direction --kind ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp ` when a surface round already locked one. +`{{scripts_path}}/impeccable build-phase start --direction --kind ` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp ` when a surface round already locked one. -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): +Then, in order, each closed by `{{scripts_path}}/impeccable build-phase advance` (every verb below runs as `{{scripts_path}}/impeccable `; 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. 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 --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. `impeccable generate-image --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 `impeccable embed-prompt`. 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 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. +1. **spec.** Measure the comp: `impeccable comp-spec --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 `impeccable comp-spec --comp --regions `. The spec carries each region's box, sampled palette, and medium; `impeccable comp-spec --print` is the build's reference from here on. Type is measured, not guessed: `impeccable font-match --measure ` reads the comp's cap height, width class, and weight off the pixels, and `impeccable font-match --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. `impeccable generate-image --plate ` does one region end to end and scores it against the crop; a harness-native image tool takes the crop (`impeccable comp-spec --crop `) as its input image and `impeccable comp-spec --plate-prompt ` as its prompt, then `impeccable embed-prompt`. 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.** `impeccable build-phase 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 `impeccable build-phase 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 `impeccable comp-diff`, 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. @@ -132,7 +132,7 @@ Preserve semantics, accessibility, performance, responsiveness, project conventi ## 7. Inspect and finish -Inspect the surface's target sizes in one batched screenshot round: desktop and mobile on the web; on a native platform (`ios` / `android` / `adaptive`), the shipped device classes per OS, captured from the simulator or emulator the way the platform reference's Verifying the build section describes. When the harness reports the user's actual viewport (an in-app browser's size, a named resolution), add that width to the set: the width that breaks is the one the user sees first. Critique the render against the user's request and the direction contract, fix material gaps, and confirm with one final round; two rounds is the ceiling, and fixes batch between them rather than earning per-tweak screenshots. On a comp-led build, run `node {{scripts_path}}/comp-diff.mjs --comp --build .impeccable/review/desktop.png --spec .impeccable/build/spec.json --out-dir .impeccable/review/diff/final` and read its region rows and paired crops as the critique: the side-by-side is the view the build thread never has on its own, and a region it scores missing or contradicted is a fix whatever the page looks like from memory. Never judge fidelity from one full-page thumbnail; it hides exactly the failures that matter. On a Persuade surface, verify the mode did its job: a first-time visitor should know what this is, why it matters, and what to do within seconds, in the form's own vocabulary. +Inspect the surface's target sizes in one batched screenshot round: desktop and mobile on the web; on a native platform (`ios` / `android` / `adaptive`), the shipped device classes per OS, captured from the simulator or emulator the way the platform reference's Verifying the build section describes. When the harness reports the user's actual viewport (an in-app browser's size, a named resolution), add that width to the set: the width that breaks is the one the user sees first. Critique the render against the user's request and the direction contract, fix material gaps, and confirm with one final round; two rounds is the ceiling, and fixes batch between them rather than earning per-tweak screenshots. On a comp-led build, run `{{scripts_path}}/impeccable comp-diff --comp --build .impeccable/review/desktop.png --spec .impeccable/build/spec.json --out-dir .impeccable/review/diff/final` and read its region rows and paired crops as the critique: the side-by-side is the view the build thread never has on its own, and a region it scores missing or contradicted is a fix whatever the page looks like from memory. Never judge fidelity from one full-page thumbnail; it hides exactly the failures that matter. On a Persuade surface, verify the mode did its job: a first-time visitor should know what this is, why it matters, and what to do within seconds, in the form's own vocabulary. A capture is evidence only when it is valid, and you validate before you send. Settle or disable entrance motion first: an element hidden by animation timing reads as a missing element and gets fixed into a regression. Capture full-page shots from the document top. Capture the comp comparison at the comp's own pixel dimensions. Then open every file once and confirm it shows what its name claims: no black or blank regions, no wrong section behind a right filename, no half-loaded state. A malformed capture sent onward costs the whole round; the reviewer answers it with `disposition: recapture` and nothing it reviewed binds. diff --git a/skill/reference/visualize.md b/skill/reference/visualize.md index a7b1c2b49..a062e0da0 100644 --- a/skill/reference/visualize.md +++ b/skill/reference/visualize.md @@ -6,7 +6,7 @@ A probe tests composition, narrative, hierarchy, density, focal moment, signatur ## Generate three compositional options -The comp round runs inside the build's phase state: `build-phase.mjs start --direction --kind <...>` has already run (the roll's output names the command) and its `comps` phase is open before the first comp is generated; a comp rendered before that sits outside the state, and a session resumed from that point has no phases to follow. `impeccable generate-image` refuses to write under `.impeccable/mocks/` until start has run; a harness-native image tool is bound by the same order. +The comp round runs inside the build's phase state: `impeccable build-phase start --direction --kind <...>` has already run (the roll's output names the command) and its `comps` phase is open before the first comp is generated; a comp rendered before that sits outside the state, and a session resumed from that point has no phases to follow. `impeccable generate-image` refuses to write under `.impeccable/mocks/` until start has run; a harness-native image tool is bound by the same order. Render three distinct high-fidelity north-star comps of the requested surface, saved under `.impeccable/mocks/` so they survive the session. Comp at the surface's own viewport: portrait at device size for a native app or mobile-first surface, desktop landscape otherwise; a phone screen comped landscape misstates the composition before anything is built against it. Comps are the build thread's own work, never delegated: the thread that writes the prompts holds the direction's full context and has seen every comp when the build starts. Open every image by its workspace-relative path; sandboxed viewers reject absolute paths, and everything under the project root has a relative one. Base the comps on real content and the surface concepts already developed with the user. On an established world, anchor every comp on the real identity: capture a screenshot of a representative existing page and pass it as a reference image (the harness image tool's input image, or `impeccable generate-image --ref`); the prompt leads with the new surface's structure while the reference carries palette, type, and component character, because DESIGN.md words alone drift where a pixel reference does not. Name what the reference contributes and what it must not: chrome, palette, type, and component character carry over; the reference page's own content does not, and a banner, hero, or card lifted verbatim is the reference leaking, not fidelity. Three is the number: one comp invites rubber-stamping; the spread between three surfaces the composition worth building. The chosen card's decision comp is the first of the three: it already renders this direction at full fidelity under this discipline, so generate two more that vary what the first held fixed, and send all three to the approval point together. Only a round arriving with no decision comp (a degraded roll, an identity-mode page, a direction pinned without the decision round) renders all three here. @@ -29,13 +29,13 @@ Do not begin code until the user approves a direction or explicitly delegates th This approval point has no substitute and no skip condition. When the structured question tool errors, fall back to the decision page; only after both fail may you treat the choice as delegated, and a delegated pick is recorded exactly as an approval is and disclosed in your first reply, not your last. The finish reviewer treats comp-round comps with no recorded approval as a material finding; decision comps under `.impeccable/mocks/decision/` are the direction round's hand, not comp-round output, and imply no approval on their own. -After approval, record the choice where tools can find it: the approved comp's path goes in the surface brief, and its `.json` prompt sidecar gains `"approved": true` (every comp generated through `impeccable generate-image` has one; create it if a native tool didn't). The sidecar travels with the mocks folder, so the approval survives sessions and machines that never see the brief, and it is what `build-phase.mjs advance` reads to close the comps phase. Summarize the composition and the parts of the comp that must not be literalized, return to new-work.md, record the direction contract from the approved concept, and build. +After approval, record the choice where tools can find it: the approved comp's path goes in the surface brief, and its `.json` prompt sidecar gains `"approved": true` (every comp generated through `impeccable generate-image` has one; create it if a native tool didn't). The sidecar travels with the mocks folder, so the approval survives sessions and machines that never see the brief, and it is what `impeccable build-phase advance` reads to close the comps phase. Summarize the composition and the parts of the comp that must not be literalized, return to new-work.md, record the direction contract from the approved concept, and build. ## After approval: the comp becomes a spec The approved comp is a north star for translation into semantic, responsive, accessible code, never a license to recompose: keeping the palette and mood while redrawing the topology is a second art direction. Do not rasterize core UI text or controls. Do not substitute a different visual driver after approval without asking. -What the comp shows is measured, not remembered. new-work.md section 6 runs the build as phases (`build-phase.mjs`): the spec phase turns the comp into region boxes with sampled palettes (`comp-spec.mjs`), and the medium of every region follows from what the pixels are, never from what feels buildable: a figure, a product object, machinery, any illustration with perspective, shading, or drawing skill in it, and any texture by name (woven cloth, paper grain, fabric, leather, brushed metal) is a `plate` / `image` / `texture` region and ships as a raster; text, controls, chrome, diagrams with countable elements, flat shape systems, and anything that must move, scale, or respond are semantic. Writing "CSS" for a sculpted panel's finish, or a many-vertex `clip-path` for a torn edge, is the quiet deletion of the approved design; the detector's organic-clip-path and buried-raster rules and the hero gate's region scores catch it. Dropping an image-native region is a scope decision the user makes at the approval point, never a silent flattening after it. Generated imagery is a material, not a claim: evidence rules bind assertions, specs, testimonials, and photographs presented as real, never render fidelity. +What the comp shows is measured, not remembered. new-work.md section 6 runs the build as phases (`impeccable build-phase`): the spec phase turns the comp into region boxes with sampled palettes (`impeccable comp-spec`), and the medium of every region follows from what the pixels are, never from what feels buildable: a figure, a product object, machinery, any illustration with perspective, shading, or drawing skill in it, and any texture by name (woven cloth, paper grain, fabric, leather, brushed metal) is a `plate` / `image` / `texture` region and ships as a raster; text, controls, chrome, diagrams with countable elements, flat shape systems, and anything that must move, scale, or respond are semantic. Writing "CSS" for a sculpted panel's finish, or a many-vertex `clip-path` for a torn edge, is the quiet deletion of the approved design; the detector's organic-clip-path and buried-raster rules and the hero gate's region scores catch it. Dropping an image-native region is a scope decision the user makes at the approval point, never a silent flattening after it. Generated imagery is a material, not a claim: evidence rules bind assertions, specs, testimonials, and photographs presented as real, never render fidelity. ## Plates and provenance diff --git a/skill/scripts/build-phase.mjs b/skill/scripts/build-phase.mjs deleted file mode 100644 index 6f3a9bd9d..000000000 --- a/skill/scripts/build-phase.mjs +++ /dev/null @@ -1,1022 +0,0 @@ -#!/usr/bin/env node -/** - * build-phase: the comp-led build as a state machine on disk, so the phases - * new-work.md names are gated by scripts instead of remembered by the model. - * - * State lives at .impeccable/build/state.json. Phases, in order: - * - * comps the comp round: three comps of the chosen direction under - * .impeccable/mocks/ with prompt sidecars, one approved by the - * user (sidecar "approved": true). Skipped when start names an - * approved --comp (a surface round already locked one). - * spec the approved comp is measured (comp-spec.mjs wrote spec.json) - * plates every raster region in the spec has its plate on disk - * hero the first viewport is reproduced: comp-diff of hero-repro.png - * against the comp clears the gate - * sections the rest of the surface is built inside the spec's system - * motion interaction, reveals, motion - * responsive the other viewports - * review the finish reviewer ran; disposition recorded - * - * node build-phase.mjs start --comp [--breakpoint 1440x900] [--artifact index.html] - * node build-phase.mjs start --direction # no comp yet: opens the comps phase first - * node build-phase.mjs status # human-readable, plus NEXT line - * node build-phase.mjs status --json - * node build-phase.mjs advance # try to close the current phase; runs its gate - * node build-phase.mjs advance --force --reason "" # skip a gate; recorded, never silent - * node build-phase.mjs record hero --build .impeccable/review/hero-repro.png # run the hero gate explicitly - * node build-phase.mjs note "" # append a note to the current phase - * node build-phase.mjs finish --disposition ship|fix|rebuild|recapture - * - * Gates: - * comps -> >= 3 comp rasters (png/webp/jpg) directly under - * .impeccable/mocks/ (decision/ excluded), each with a .json - * sidecar, and exactly one sidecar carrying "approved": true; - * closing records that file as the state's comp. - * spec -> spec.json exists and has >= 1 region - * plates -> every region with medium raster has its plate file, decodable, - * at least 1.5x the comp region's pixel width (textures - * exempt), and reads as the region against the masked comp - * crop: structure >= PLATE_STRUCTURE_MIN and comp-diff overall - * >= PLATE_MIN (textures: palette + grain only). Structure is - * the floor because it is what a wrong-but-busy plate cannot - * fake: noise, a mirror, a mosaic, another region all keep - * the palette and the energy and lose structure. A missing - * or thin plate names itself. - * hero -> every plate is referenced by a source file (the artifact - * named at start, else a bounded walk of the project), and - * .impeccable/review/hero-repro.png exists and comp-diff overall - * >= HERO_MIN (default 0.72) with no region `missing`. The - * score, the report path, and the attempt count are recorded. - * responsive -> .impeccable/review/desktop.png and mobile.png exist, and - * the desktop capture still scores >= RESPONSIVE_MIN against - * the comp with no region missing: a first viewport that only - * holds at the comp's exact width is not built. - * sections / motion -> no mechanical gate; advancing records the moment, - * and the finish reviewer reads the timeline. - * - * Exit codes: 0 ok / advanced, 2 gate failed (state unchanged, reasons - * printed), 1 usage. - * - * Nothing here needs a browser. Screenshots come from the harness; this - * script only measures them. - */ -import fs from 'node:fs'; -import path from 'node:path'; -import { spawnSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; -import { createRequire } from 'node:module'; -import { decodePng, loadRaster } from './lib/png.mjs'; -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, svgIllustrations } from './lib/hero-checks.mjs'; -import { SPEC_PATH, BUILD_DIR, loadSpec, plateReference } from './comp-spec.mjs'; -import { choiceStamped } from './font-match.mjs'; - -const HERE = path.dirname(fileURLToPath(import.meta.url)); -export const STATE_PATH = path.join(BUILD_DIR, 'state.json'); -export const PHASES = ['comps', 'spec', 'plates', 'hero', 'sections', 'motion', 'responsive', 'review']; -export const MOCKS_DIR = path.join('.impeccable', 'mocks'); -export const HERO_MIN = 0.72; -export const RESPONSIVE_MIN = 0.65; -export const PLATE_MIN = 0.4; -export const PLATE_STRUCTURE_MIN = 0.4; -export const HERO_REPRO = path.join('.impeccable', 'review', 'hero-repro.png'); - -function arg(name, fallback = null) { - const i = process.argv.indexOf(`--${name}`); - if (i === -1) return fallback; - const v = process.argv[i + 1]; - return v && !v.startsWith('--') ? v : fallback; -} -const flag = (name) => process.argv.includes(`--${name}`); -const now = () => new Date().toISOString(); - -/** The recorded build path (config.local.json over config.json), or null. */ -export function readBuildPath(cwd = process.cwd()) { - let value = null; - for (const name of ['config.json', 'config.local.json']) { - try { - const raw = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', name), 'utf8')); - if (raw?.buildPath === 'comp' || raw?.buildPath === 'code') value = raw.buildPath; - } catch { /* absent */ } - } - return value; -} - -/** - * Whether a direction was dealt and the build never started, or started and - * stopped before the hero gate: the condition context.mjs and detect.mjs - * report as COMP_ROUND_OPEN when page code exists. Returns null when the - * build path is code-led (no round owed) or nothing is pending. - */ -export function compRoundOpen(cwd = process.cwd()) { - const buildPath = readBuildPath(cwd); - if (buildPath === 'code') return null; - const pending = path.join(cwd, BUILD_DIR, 'pending.json'); - const statePath = path.join(cwd, STATE_PATH); - if (fs.existsSync(pending) && !fs.existsSync(statePath)) return { reason: 'a direction was chosen (concept-seed rolled) but build-phase.mjs start never ran', pending }; - if (fs.existsSync(statePath)) { - try { - const st = JSON.parse(fs.readFileSync(statePath, 'utf8')); - const idx = PHASES.indexOf(st.phase); - if (idx !== -1 && idx <= PHASES.indexOf('hero') && st.phases?.comps?.status !== 'skipped' && st.phases?.comps?.status !== 'closed') return { reason: `build-phase is at ${st.phase}; the comps phase never closed`, state: statePath }; - if (idx !== -1 && idx <= PHASES.indexOf('hero')) return { reason: `build-phase is at ${st.phase}; the hero gate has not passed`, state: statePath }; - } catch { /* unreadable: say nothing */ } - } - return null; -} - -export function loadState(statePath = STATE_PATH) { - if (!fs.existsSync(statePath)) return null; - return JSON.parse(fs.readFileSync(statePath, 'utf8')); -} - -export function saveState(state, statePath = STATE_PATH) { - fs.mkdirSync(path.dirname(statePath), { recursive: true }); - fs.writeFileSync(statePath, JSON.stringify(state, null, 2)); -} - -export function newState({ comp = null, breakpoint = null, artifact = null, direction = null }) { - const first = comp ? 'spec' : 'comps'; - const phases = Object.fromEntries(PHASES.map((p) => [p, { status: p === first ? 'open' : 'pending', openedAt: p === first ? now() : null, closedAt: null, attempts: 0, notes: [], gate: null, forced: null }])); - if (comp) { phases.comps.status = 'skipped'; phases.comps.notes.push({ at: now(), text: 'started with an approved comp; the comp round happened before this state (surface round or manual)' }); } - return { - tool: 'build-phase', - version: 2, - startedAt: now(), - comp, - direction, - breakpoint, - artifact, - phase: first, - phases, - finish: null, - }; -} - -// ---- gates ----------------------------------------------------------------- - -/** Comp rasters directly under the mocks dir, with their sidecars. */ -export function listComps(mocksDir = MOCKS_DIR) { - if (!fs.existsSync(mocksDir)) return []; - const out = []; - for (const name of fs.readdirSync(mocksDir)) { - if (!/\.(png|webp|jpe?g)$/i.test(name)) continue; - const file = path.join(mocksDir, name); - if (!fs.statSync(file).isFile()) continue; - const sidecarPath = `${file}.json`; - let sidecar = null; - if (fs.existsSync(sidecarPath)) { try { sidecar = JSON.parse(fs.readFileSync(sidecarPath, 'utf8')); } catch { sidecar = null; } } - out.push({ file, sidecarPath, sidecar, approved: !!(sidecar && sidecar.approved === true) }); - } - return out; -} - -export function gateComps(state, { mocksDir = MOCKS_DIR } = {}) { - const comps = listComps(mocksDir); - const reasons = []; - if (comps.length < 3) reasons.push(`${comps.length} comp${comps.length === 1 ? '' : 's'} under ${mocksDir}; the comp round puts three compositional options of the chosen direction in front of the user (reference/visualize.md). Generate the missing ones (harness image tool or generate-image.mjs), each with a .json sidecar holding its prompt.`); - const noSidecar = comps.filter((c) => !c.sidecar); - if (noSidecar.length) reasons.push(`no prompt sidecar for: ${noSidecar.map((c) => path.basename(c.file)).join(', ')} (write .json with { "prompt": "..." }; generate-image.mjs does this itself)`); - const approved = comps.filter((c) => c.approved); - if (approved.length === 0) reasons.push('no comp is approved: put the three comps in front of the user (decision page via serve-question.mjs, or the structured question tool), then set "approved": true in the chosen comp\'s sidecar. A delegated pick is recorded the same way and disclosed.'); - if (approved.length > 1) reasons.push(`${approved.length} comps carry "approved": true; exactly one is the approved comp: ${approved.map((c) => path.basename(c.file)).join(', ')}`); - return { ok: reasons.length === 0, reasons, summary: `${comps.length} comps, ${approved.length} approved`, approved: approved.length === 1 ? approved[0].file : null }; -} - -export function gateSpec(state, { specPath = SPEC_PATH } = {}) { - const spec = loadSpec(specPath); - if (!spec) return { ok: false, reasons: [`no spec at ${specPath}: run comp-spec.mjs --comp ${state.comp} --grid, name the regions, then --regions regions.json`] }; - if (!spec.regions || spec.regions.length < 1) return { ok: false, reasons: ['spec has no regions'] }; - if (spec.comp && state.comp && path.resolve(spec.comp) !== path.resolve(state.comp)) { - 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 - // headline face wider and lighter than the comp's, the parts list smaller, - // the footer heavier: ratios font-match reads off pixels. - // The lead text region is the display type: the region whose measured cap - // height is largest (a headline), not the biggest box (a table of rows). - // Unmeasured regions sort by box height as a proxy until measured. - const textRegions = spec.regions.filter((r) => r.kind === 'text').sort((a, b) => { - const ca = a.type && a.type.comp ? a.type.comp.capHeightPx : a.px.h * 0.4; - const cb = b.type && b.type.comp ? b.type.comp.capHeightPx : b.px.h * 0.4; - return cb - ca; - }); - const reasons = []; - if (textRegions.length) { - // when nothing is measured yet, ask for all measurements first; the lead - // is only knowable once cap heights exist - const anyMeasured = textRegions.some((r) => r.type); - if (!anyMeasured) { - reasons.push(`measure the type before closing the spec: node ${HERE}/font-match.mjs --measure for each text region (${textRegions.map((r) => r.id).join(', ')}); the region with the largest cap height is the lead and gets --rank.`); - return { ok: false, reasons }; - } - const measurable = textRegions.filter((r) => r.type && r.type.comp); - const lead = measurable[0] || textRegions[0]; - if (!lead.type) reasons.push(`the lead text region ${lead.id} has no type measurement: run node ${HERE}/font-match.mjs --measure ${lead.id} (and --rank ${lead.id} --text "" to choose the face by metrics). Set font-size from the printed cap height; do not pick a face by name.`); - else if (lead.type.comp && !lead.type.chosen) reasons.push(`the lead text region ${lead.id} is measured (${lead.type.widthClass} ${lead.type.weightClass}, cap ${lead.type.comp.capHeightPx}px) but no face is ranked: run node ${HERE}/font-match.mjs --rank ${lead.id} --text "" [--candidates "Family:weight,..."] and use the USE line.`); - else if (lead.type.comp && lead.type.chosen && !choiceStamped(lead.id, lead.type.chosen)) reasons.push(`the lead text region ${lead.id} carries a "chosen" face that font-match did not write (${lead.type.chosen.family || '?'}). A face typed into spec.json is the guess this gate exists to refuse; run node ${HERE}/font-match.mjs --rank ${lead.id} --text "" and let it record the choice (with no browser it records the catalog's nearest face).`); - const unmeasured = textRegions.slice(1).filter((r) => !r.type).map((r) => r.id); - if (unmeasured.length && !reasons.length) reasons.push(`measure the other text regions too, each sets its own font-size and weight class: node ${HERE}/font-match.mjs --measure for ${unmeasured.join(', ')}`); - } - if (reasons.length) return { ok: false, reasons }; - return { ok: true, reasons: [], summary: `${spec.regions.length} regions, ${plates} plates, ${textRegions.length} text regions measured` }; -} - -/** - * The one plate rule, shared by the plates gate and generate-image's - * PLATE-WARN so they never disagree. `score` is compare().whole against the - * masked comp crop under cover alignment. - */ -export function plateVerdict(region, score) { - const isTexture = region.kind === 'texture'; - const reasons = []; - if (isTexture) { - const effective = 0.5 * score.color + 0.5 * Math.min(1, score.detail / 0.6); - if (effective < PLATE_MIN) reasons.push(`scores ${(effective * 100).toFixed(0)}% as the material of region ${region.id} (color ${(score.color * 100).toFixed(0)}%, detail ${(score.detail * 100).toFixed(0)}%); crop a clean patch of the comp region (comp-spec.mjs --crop ${region.id} --raw) and mirror-tile it, generate only when no clean patch exists`); - return { ok: reasons.length === 0, reasons, effective }; - } - // Added detail is invented material only when the comp region is calm; - // a paper sleeve is grainy in the comp too, and its plate is allowed the - // same grain. Comp energy travels on the region (comp-spec's detail.energy). - const compCalm = !region.detail || region.detail.energy < 12; - if (compCalm && score.detailAdded > 0.45) reasons.push(`carries detail the comp region ${region.id} does not have (added-detail ${(score.detailAdded * 100).toFixed(0)}% of cells): noise, grain, or a busier subject where the comp is calm; regenerate from the crop reference without adding texture`); - if (score.structure < PLATE_STRUCTURE_MIN) reasons.push(`structure ${(score.structure * 100).toFixed(0)}% against the comp region ${region.id}: the composition of the plate is not the region's (different subject, orientation, or crop); regenerate with comp-spec.mjs --crop ${region.id} as the reference image`); - if (score.overall < PLATE_MIN) reasons.push(`scores ${(score.overall * 100).toFixed(0)}% against the comp region ${region.id} (structure ${(score.structure * 100).toFixed(0)}%, color ${(score.color * 100).toFixed(0)}%, detail ${(score.detail * 100).toFixed(0)}%); regenerate with the crop as --ref and the comp-spec plate prompt`); - return { ok: reasons.length === 0, reasons, effective: score.overall }; -} - -export function gatePlates(state, { specPath = SPEC_PATH } = {}) { - const spec = loadSpec(specPath); - if (!spec) return { ok: false, reasons: ['no spec'] }; - const rasterRegions = spec.regions.filter((r) => r.medium === 'raster'); - if (!rasterRegions.length) return { ok: true, reasons: [], summary: 'no plates owed', plates: [] }; - let comp = null; - try { comp = loadRaster(spec.comp).image; } catch { /* scored without the comp crop below */ } - const reasons = [], plates = []; - for (const r of rasterRegions) { - const file = r.plate; - if (!file || !fs.existsSync(file)) { reasons.push(`plate missing for ${r.id}: expected ${file || '(no path)'}; produce it from comp-spec.mjs --crop ${r.id} with generate-image.mjs --plate`); plates.push({ id: r.id, file, status: 'missing' }); continue; } - let img; - try { img = decodePng(fs.readFileSync(file)); } catch (e) { reasons.push(`plate ${file} is not a decodable PNG: ${e.message}`); plates.push({ id: r.id, file, status: 'unreadable' }); continue; } - // A texture tiles, so it owes no size floor and no structural match: - // it is judged on palette and grain only. Every other plate must be at - // least 1.5x the region (capped at 1536px, the largest size the - // generators emit; past that the region is a full-bleed field the page - // scales) and read as the region under object-fit: cover. - const isTexture = r.kind === 'texture'; - const minW = Math.min(1536, r.px.w * 1.5); - if (!isTexture && img.width < minW) reasons.push(`plate ${file} is ${img.width}px wide; the comp region is ${r.px.w}px and a shipping plate needs at least ${Math.round(minW)}px. Regenerate at asset size, do not crop the comp.`); - let score = null; - if (comp) { - const ref = plateReference(comp, spec, r); - // a keyed (alpha) plate ships over the page ground: score it composited - // over the region's sampled ground, the way it will show - let build = img; - let transparent = 0; for (let i = 3; i < img.data.length; i += 4) if (img.data[i] < 128) transparent++; - if (transparent > (img.data.length / 4) * 0.05) { - const g = (r.palette && r.palette[0] && r.palette[0].hex) || '#ffffff'; - const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(g); - const ground = m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16), 255] : [255, 255, 255, 255]; - const over = createImage(img.width, img.height, ground); - blit(over, img, 0, 0); - build = over; - } - const res = compare({ comp: ref, build, align: 'cover', spec: null, kind: r.kind }); - score = res.whole; - const v = plateVerdict(r, score); - for (const reason of v.reasons) reasons.push(`plate ${file}: ${reason}`); - // A plate that matches the comp crop almost exactly is the comp crop, - // upscaled past the size floor: the comp's grain, its neighbours' - // edges, and its resolution ship as the artwork. Crops are never - // plates; the crop is the reference the plate is generated from. - // A fake-mode plate (offline pipelines, eval fixtures) IS the crop by - // design and says so in its tEXt; the identity refusal is for models - // shipping the comp's pixels as artwork, not for the deterministic - // stand-in. - const isFake = img.text && img.text['impeccable:fake'] === '1'; - if (!isTexture && !isFake) { - const raw = crop(comp, r.px.x, r.px.y, r.px.w, r.px.h); - const same = structureScore(raw, resize(img, raw.width, raw.height)); - if (same >= 0.95) reasons.push(`plate ${file} is the comp crop of region ${r.id} (structure ${(same * 100).toFixed(0)}% against the raw region, a resample of the same pixels): a crop of the comp is never a plate; generate the plate from the crop as reference (generate-image.mjs --plate ${r.id})`); - } - } - plates.push({ id: r.id, file, status: 'ok', size: `${img.width}x${img.height}`, score: score ? score.overall : null }); - } - return { ok: reasons.length === 0, reasons, summary: `${plates.filter((p) => p.status === 'ok').length}/${rasterRegions.length} plates`, plates }; -} - -/** Source files that could reference a plate: bounded walk, skipping deps and build output. */ -function sourceFiles(root = '.', limit = 400) { - const out = []; - // `assets` is walked: the extension filter already keeps binaries out, and - // a stylesheet at assets/hero.css may be the one file that references a - // plate (Greptile P1 on #599: the gate refused a valid build for it). - const skip = new Set(['node_modules', '.git', 'dist', 'build', 'out', '.next', '.svelte-kit', '.impeccable', 'coverage']); - const exts = /\.(html?|css|scss|jsx?|tsx?|svelte|vue|astro|mdx?|php|erb|hbs)$/i; - const walk = (dir, depth) => { - if (out.length >= limit || depth > 6) return; - let entries = []; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } - for (const e of entries) { - if (out.length >= limit) return; - if (e.isDirectory()) { if (!skip.has(e.name) && !e.name.startsWith('.')) walk(path.join(dir, e.name), depth + 1); } - else if (exts.test(e.name)) out.push(path.join(dir, e.name)); - } - }; - walk(root, 0); - return out; -} - -/** Plates the artifact never references: a plate on disk that no source names ships nothing. */ -export function unreferencedPlates(spec, artifact = null) { - const plates = (spec?.regions || []).filter((r) => r.medium === 'raster' && r.plate); - if (!plates.length) return []; - // The artifact narrows nothing: a plate may be referenced only from a - // stylesheet the artifact links, so the source walk runs either way, the - // artifact keeps a seat in the corpus, and the stylesheets it links are - // added by name (resolved against the artifact's own directory), which - // covers a stylesheet outside the walk's root, depth, or file limit. - const linked = []; - if (artifact && fs.existsSync(artifact)) { - linked.push(artifact); - try { - const html = fs.readFileSync(artifact, 'utf8'); - for (const m of html.matchAll(/]*href=["']([^"']+)["'][^>]*>/gi)) { - const href = m[1]; - if (/^(https?:|data:|\/\/)/i.test(href)) continue; - if (!/rel=["']?stylesheet/i.test(m[0]) && !/\.css(\?|$)/i.test(href)) continue; - const clean = href.split('?')[0]; - // a root-relative href serves from the project root, not the drive - // root: try the working directory and the artifact's own directory - // (unreadable candidates are skipped by the corpus loop) - if (clean.startsWith('/')) { linked.push(path.join(process.cwd(), clean), path.join(path.dirname(artifact), clean)); } - else linked.push(path.resolve(path.dirname(artifact), clean)); - } - } catch { /* the artifact still counts */ } - } - const files = [...new Set([...linked, ...sourceFiles()])]; - let corpus = ''; - for (const f of files) { try { corpus += fs.readFileSync(f, 'utf8') + '\n'; } catch { /* skip */ } } - const missing = []; - for (const r of plates) { - const base = path.basename(r.plate); - const stem = base.replace(/\.[a-z0-9]+$/i, ''); - // a data URI inline copy counts when the region id or file stem is named beside it - if (corpus.includes(base) || (corpus.includes('data:image/') && (corpus.includes(stem) || corpus.includes(r.id)))) continue; - missing.push(r); - } - return missing; -} - -/** Organic clip-path findings whose selector's element the artifact places (by class/id name) on a raster region. Cheap heuristic: the finding's selector or the surrounding rule mentions the region id or its plate stem. */ -export function organicClipRegions(artifactFile, spec) { - let scan; - try { - const mod = require(path.join(HERE, '..', '..', 'cli', 'engine', 'rules', 'checks.mjs')); - scan = mod.scanCssTextForOrganicClipPath; - } catch { scan = null; } - if (!scan) return []; - let html = ''; - try { html = fs.readFileSync(artifactFile, 'utf8'); } catch { return []; } - const findings = scan(html); - if (!findings.length) return []; - const rasterRegions = (spec.regions || []).filter((r) => r.medium === 'raster'); - const out = []; - for (const f of findings) { - const sel = String(f.selector || '').toLowerCase(); - for (const r of rasterRegions) { - const stem = path.basename(r.plate || '', path.extname(r.plate || '')).toLowerCase(); - if ((sel && (sel.includes(r.id.toLowerCase()) || (stem && sel.includes(stem)))) || rasterRegions.length === 1) { out.push({ id: r.id, snippet: f.snippet }); break; } - } - } - return out; -} - -/** - * The scaffold: the measured layout as CSS custom properties and one - * reference page. Positions in % of the comp so the frame scales; plates at - * their boxes with object-fit: contain (never cover: cover is how the arch - * lost its left side); text slots sized from the measured cap height (cap / - * 0.70 as the em estimate when font-match gave no size) in the ranked face. - * A reference for a builder that cannot lay out to a box, and a check for - * one that can; the gate reads pixels either way. - */ -export function writeScaffold(spec, state, { dir = path.join(BUILD_DIR, 'scaffold') } = {}) { - fs.mkdirSync(dir, { recursive: true }); - const W = spec.compSize.width, H = spec.compSize.height; - const pct = (v) => `${(v * 100).toFixed(3)}%`; - const vars = [':root {']; - const rules = []; - const bodyParts = []; - const fontLinks = new Set(); - for (const r of spec.regions) { - if (r.kind === 'band') continue; - const b = r.box, id = r.id; - vars.push(` --r-${id}-x: ${pct(b.x)}; --r-${id}-y: ${pct(b.y)}; --r-${id}-w: ${pct(b.w)}; --r-${id}-h: ${pct(b.h)};`); - const type = r.type || {}; - const cap = type.comp && type.comp.capHeightPx; - const chosen = type.chosen || null; - const fontPx = chosen && chosen.fontSizePx ? chosen.fontSizePx : (cap ? Math.round(cap / 0.7) : null); - if (cap) vars.push(` --r-${id}-cap: ${cap}px;${fontPx ? ` --r-${id}-font: ${fontPx}px;` : ''}${chosen ? ` --r-${id}-family: '${chosen.family}'; --r-${id}-weight: ${chosen.weight};` : ''}`); - if (chosen && chosen.family) fontLinks.add(`${chosen.family}:${chosen.weight}`); - rules.push(`.r-${id} { position: absolute; left: var(--r-${id}-x); top: var(--r-${id}-y); width: var(--r-${id}-w); height: var(--r-${id}-h); }`); - const label = (r.note || id).replace(/`); - } else if (r.kind === 'texture') { - const src = r.plate ? path.relative(dir, r.plate) : ''; - bodyParts.push(`
`); - } else if (r.kind === 'text') { - const style = [fontPx ? `font-size:var(--r-${id}-font)` : '', chosen ? `font-family:var(--r-${id}-family),sans-serif;font-weight:var(--r-${id}-weight)` : '', 'line-height:1.05', 'margin:0'].filter(Boolean).join(';'); - // the slot shows the region's own words when the spec has them, else its id - // at the measured size (a slot, not a caption: the note goes in a comment) - bodyParts.push(`

${(r.text || '').replace(/

`); - } else if (r.kind === 'control') { - bodyParts.push(`
`); - } else { - bodyParts.push(`
`); - } - } - vars.push('}'); - const css = [ - '/* Impeccable scaffold: the measured layout of the approved comp as custom properties. Generated by build-phase.mjs scaffold; regenerate after comp-spec.mjs --regions changes. Bind these to your own markup; positions are % of the comp frame so they scale with it. */', - ...vars, - '', - `.comp-frame { position: relative; width: 100%; aspect-ratio: ${W} / ${H}; overflow: hidden; }`, - ...rules, - '', - ].join('\n'); - const cssPath = path.join(dir, 'layout.css'); - fs.writeFileSync(cssPath, css); - const link = fontLinks.size ? ` \n` : ''; - const html = `\n\n\n \n Scaffold reference: ${path.basename(spec.comp)}\n${link} \n \n\n\n\n
\n${bodyParts.join('\n')}\n
\n\n\n`; - const htmlPath = path.join(dir, 'hero-reference.html'); - fs.writeFileSync(htmlPath, html); - return { dir, css: cssPath, html: htmlPath }; -} - -/** 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 = [], 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, plates, 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); - // Resolve the page first: with an artifact in hand, unreferencedPlates - // follows its linked stylesheets exactly (wherever they live), and the - // bounded source walk is only the fallback for a build with no page yet. - let pageFile = artifact || state.artifact || null; - if (!pageFile || !fs.existsSync(pageFile)) { - if (fs.existsSync('index.html')) pageFile = 'index.html'; - else { try { const htmls = fs.readdirSync('.').filter((f) => /\.html?$/i.test(f)); if (htmls.length === 1) pageFile = htmls[0]; } catch { /* fall back to the walk */ } } - } - const unreferenced = unreferencedPlates(specForRefs, pageFile && fs.existsSync(pageFile) ? pageFile : null); - if (unreferenced.length) { - return { ok: false, reasons: unreferenced.map((r) => `plate ${r.plate} (region ${r.id}) is not referenced by any source file this scan can see: the page draws that region in code while the produced plate sits unused. Place the plate (an , a background-image, or an inlined data URI named for it) and recapture. If the plate IS referenced from a file the scan missed (your page is not index.html, or the reference lives in a stylesheet outside the project walk), re-run with --artifact : its linked stylesheets are followed exactly.`) }; - } - const script = path.join(HERE, 'comp-diff.mjs'); - const args = [script, '--comp', state.comp, '--build', buildPath, '--out-dir', outDir, '--label', 'hero', '--json']; - const spec = loadSpec(specPath); - if (spec) args.push('--spec', specPath); - const res = spawnSync(process.execPath, args, { encoding: 'utf8' }); - if (res.status !== 0 && res.status !== 3) return { ok: false, reasons: [`comp-diff failed: ${res.stderr || res.stdout}`] }; - 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); - const [bw, bh] = String(report.buildSize || '').split('x').map(Number); - if (cw && ch && bw && bh) { - 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}.`); - } - // 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 - // present in the build, a low detail score on the texture is the ink - // metric measuring the wrong thing, not missing material. - const specRegions = specForRefs ? specForRefs.regions : []; - const overlaps = (a, b) => a.box.x < b.box.x + b.box.w && b.box.x < a.box.x + a.box.w && a.box.y < b.box.y + b.box.h && b.box.y < a.box.y + a.box.h; - const verdictOf = Object.fromEntries(report.regions.map((r) => [r.id, r.verdict])); - const missing = report.regions.filter((r) => { - if (r.verdict !== 'missing') return false; - if (r.kind !== 'texture') return true; - const me = specRegions.find((x) => x.id === r.id); - if (!me) return true; - const inkOver = specRegions.filter((x) => x.id !== r.id && (x.kind === 'text' || x.kind === 'control' || x.kind === 'chrome') && overlaps(me, x)); - const inkPresent = inkOver.length > 0 && inkOver.every((x) => verdictOf[x.id] && verdictOf[x.id] !== 'missing'); - if (inkPresent) { r.verdict = 'drift'; return false; } - return true; - }); - // A plate that passed the plates gate and is referenced by the page is - // placed material, not missing material: comp-diff at the region box - // re-litigates the plate's content (an exploded diagram of a different - // carburetor scores 'missing' on detail against the comp's), and no CSS - // edit can move that score. What the hero owes for a passed plate is its - // placement: material present in the box, at the box. Say that as a box. - const passedPlate = (id) => state.plates && state.plates[id] && state.plates[id].status === 'ok' && (state.plates[id].score == null || state.plates[id].score >= PLATE_MIN); - const placementNotes = []; - for (const r of report.regions) { - if (!(r.kind === 'plate' || r.kind === 'image' || r.kind === 'texture') || !passedPlate(r.id)) continue; - if (r.verdict !== 'missing' && r.verdict !== 'contradicted') continue; - // a texture's presence is its ground: palette and structure held means - // the material is there (grain reads flatter at capture scale); a plate - // or image needs its own energy in the box - const present = r.kind === 'texture' - ? (r.score.structure >= 0.85 && r.score.color >= 0.6) - : (r.score.detailRaw != null ? r.score.detailRaw >= 0.3 : r.score.detail >= 0.3); - if (!present) continue; // nothing drawn there: still missing - r.verdict = 'drift'; - r.placed = true; - if (r.inkBox && r.inkBox.comp && r.inkBox.build) { - const c = r.inkBox.comp, b = r.inkBox.build; - const off = Math.abs(b.w - c.w) > c.w * 0.2 || Math.abs(b.h - c.h) > c.h * 0.2 || Math.abs(b.x - c.x) > c.w * 0.15 || Math.abs(b.y - c.y) > c.h * 0.15; - if (off) placementNotes.push(`plate ${r.id} is placed but not at the comp's box: its ink spans ${c.w}x${c.h}px at (${c.x},${c.y}) in the comp region and ${b.w}x${b.h}px at (${b.x},${b.y}) in the build; size and position the to the spec box (object-fit: cover), not to the surrounding layout`); - } - } - 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) (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. - // Controls are held like text: their chrome (border, fill, chevron, arrow, - // the dropdown's shape) is the comp's, and a control that reads as a - // different control is a contradiction of its own. Only icon glyphs carry - // the closest-obtainable concession, and those live inside the region. - const directionContradicted = contradicted.filter((r) => r.kind === 'plate' || r.kind === 'image' || r.kind === 'text' || r.kind === 'control'); - for (const r of directionContradicted) reasons.push(`region ${r.id} (${r.kind}) is contradicted (structure ${(r.score.structure * 100).toFixed(0)}%, detail added ${(r.score.detailAdded * 100).toFixed(0)}%): ${r.kind === 'text' ? 'the composition of this text region differs from the comp; re-derive it from the spec box' : r.kind === 'control' ? 'this control does not read as the comp\'s: rebuild its chrome from the crop (border, fill, radius, chevron or arrow, label size) rather than from a component default' : 'the plate here does not read as the comp region; regenerate it with the crop as reference (generate-image.mjs --plate ' + r.id + ') and place it at its box'}`); - // a control that drifts far is a different control too: name it - for (const r of report.regions) { - if (r.kind !== 'control' || r.verdict !== 'drift' || r.score.overall >= 0.65) continue; - reasons.push(`control ${r.id} drifts to ${(r.score.overall * 100).toFixed(0)}% (structure ${(r.score.structure * 100).toFixed(0)}%, color ${(r.score.color * 100).toFixed(0)}%): its chrome differs from the comp's; open ${path.join(outDir, 'regions', `${r.id}.png`)} and match the border, fill, radius, chevron or arrow, and label size`); - } - // Controls: report the ink box in comp vs build so a button in a 63px row - // built into a 41px row is named as numbers, not as a drift score. - for (const r of report.regions) { - // whatever the region's verdict: a small button in a large region scores - // match on the region mean while being half its comp height - if (r.kind !== 'control') continue; - if (r.inkBox && r.inkBox.comp && r.inkBox.build) { - // Only when the comp's ink is a discrete element inside its region (a - // button, a tab), not when it fills the region edge to edge (a control - // drawn over a plate's edge, a full-width bar): then the box says nothing. - // report regions carry normalized x/y/w/h at the top level - const rwN = r.w ?? (r.box && r.box.w) ?? 1, rhN = r.h ?? (r.box && r.box.h) ?? 1; - const rw = rwN * (report.compSize ? parseInt(String(report.compSize).split('x')[0], 10) : 1536); - const rh = rhN * (report.compSize ? parseInt(String(report.compSize).split('x')[1], 10) : 1024); - // A bar that spans the region in either axis is not a discrete - // control: its ink box is the region box clipped, and the build's - // box is whatever the region clips there. Comparing the two told one - // session six times that a 1376x87 strip was 1382x102, and no edit it - // made could move that number. - if (r.inkBox.comp.w >= rw * 0.85 || r.inkBox.comp.h >= rh * 0.85) continue; - 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)) (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)); - if (otherContradicted.length > Math.max(1, Math.floor(report.regions.length / 3))) reasons.push(`${otherContradicted.length} of ${report.regions.length} regions contradicted: ${otherContradicted.map((r) => r.id).join(', ')}`); - // A CSS-drawn organic contour sitting on a raster region's box is the plate - // replaced by code, whatever the pixels score. - // The page resolved above serves the code scans too. - const artifactFile = pageFile; - 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 - // 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) { - // the numbers are the edit list: keep it short enough to act on in one - // pass (the worst first: size, then lines, then colour and place) - const order = (f) => (/cap height/.test(f) ? 0 : /lines? in the build/.test(f) ? 1 : /heavier|lighter/.test(f) ? 2 : /ink is/.test(f) ? 3 : 4); - // sibling regions (row-1 ... row-8, item-a / item-b) with the same kind - // of finding are one edit: fold them into one line naming the siblings - const folded = new Map(); - for (const f of readings.text) { - const m = /^text ([a-z0-9]+(?:-[a-z0-9]+)*?)(?:-(?:\d+|[a-z]))?: (cap height|\d+ lines? in the build|the face renders|ink is|its first line|it starts|line pitch)/i.exec(f); - const key = m ? `${m[1]}|${m[2].replace(/\d+/g, 'N')}` : f; - if (!folded.has(key)) folded.set(key, { first: f, ids: [] }); - const idm = /^text ([^:]+):/.exec(f); if (idm) folded.get(key).ids.push(idm[1]); - } - const text = [...folded.values()].map((v) => (v.ids.length > 1 ? `${v.first} (also ${v.ids.slice(1).join(', ')})` : v.first)).sort((a, b) => order(a) - order(b)); - // A reading that has come back unchanged three attempts running is one - // the session is not acting on (or cannot: a measured "rule" that is - // really an underline the layout does not have). It stays in the message - // as advisory and stops blocking; the fresh readings still do. One - // session spent 27 attempts on the same three lines. - const seen = state.phases.hero.readingsSeen || (state.phases.hero.readingsSeen = {}); - const stale = (f) => { const k = f.replace(/\s+/g, ' ').trim(); seen[k] = (seen[k] || 0) + 1; return seen[k] > 3; }; - const all = [...text, ...readings.chrome]; - const fresh = all.filter((f) => !stale(f)); - const advisory = all.filter((f) => !fresh.includes(f)); - const kept = fresh.slice(0, 8); - 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 - // 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`); - } - } - const worstRegions = [...report.regions].sort((a, b) => a.score.overall - b.score.overall).slice(0, 3); - const regionDir = path.join(outDir, 'regions'); - return { - ok: reasons.length === 0, - reasons, - summary: `hero ${(report.overall * 100).toFixed(0)}% (${report.verdict})`, - score: report.overall, - verdict: report.verdict, - report: path.join(outDir, 'report.json'), - sideBySide: report.files ? report.files.sideBySide : null, - worst: worstRegions.map((r) => `${r.id} ${r.verdict} ${(r.score.overall * 100).toFixed(0)}%`), - worstIds: worstRegions.map((r) => r.id), - worstCrops: worstRegions.map((r) => ({ id: r.id, verdict: r.verdict, score: r.score, file: path.join(regionDir, `${r.id}.png`) })), - advisories, - regionVerdicts: Object.fromEntries(report.regions.map((r) => [r.id, r.verdict])), - }; -} - -/** - * The hero attempt loop: after two failed advances where the same region is - * still missing/contradicted and the artifact changed only in CSS values, - * refuse a third of the same kind. Missing material is not a layout - * tolerance problem; the fix is a plate, a placed plate, or a rebuilt region. - */ -export function heroLoopVerdict(state, gate, artifactPath) { - const p = state.phases.hero; - const history = p.history || []; - const entry = { at: now(), score: gate.score ?? null, worstIds: gate.worstIds || [], regionVerdicts: gate.regionVerdicts || {}, artifactHash: hashFile(artifactPath) }; - history.push(entry); - p.history = history.slice(-6); - if (history.length < 3) return null; - const last3 = history.slice(-3); - const stuck = last3[0].worstIds[0] && last3.every((h) => h.worstIds[0] === last3[0].worstIds[0]); - const scores = last3.map((h) => h.score ?? 0); - const noProgress = Math.max(...scores) - Math.min(...scores) < 0.03; - if (stuck && noProgress) { - return `region ${last3[0].worstIds[0]} has been the worst region for three attempts and the score moved less than 3 points: value edits are not reaching it. Open ${path.join('.impeccable', 'review', 'diff', 'hero', 'regions', `${last3[0].worstIds[0]}.png`)} and rebuild that region from the comp crop (place its plate, or produce one with generate-image.mjs --plate, or re-derive its structure from the spec box), then recapture.`; - } - return null; -} - -function hashFile(file) { - try { - const crypto = require('node:crypto'); - return crypto.createHash('sha1').update(fs.readFileSync(file)).digest('hex').slice(0, 12); - } catch { return null; } -} - -/** - * Responsive gate: the desktop capture (whatever common width the build - * used, 1440 typically) must still read as the comp. A first viewport that - * only holds at the comp's exact width and collapses to one column 96px - * narrower passed every earlier gate in the first simulated round. - */ -export function gateResponsive(state, { specPath = SPEC_PATH, min = RESPONSIVE_MIN, outDir = path.join('.impeccable', 'review', 'diff', 'desktop') } = {}) { - const desktop = path.join('.impeccable', 'review', 'desktop.png'); - const mobile = path.join('.impeccable', 'review', 'mobile.png'); - const reasons = []; - if (!fs.existsSync(desktop)) reasons.push(`no ${desktop}: capture the page at a common desktop width (1440 wide, full page) into that path`); - if (!fs.existsSync(mobile)) reasons.push(`no ${mobile}: capture the page at 390 wide, full page, into that path`); - if (reasons.length) return { ok: false, reasons }; - const script = path.join(HERE, 'comp-diff.mjs'); - const args = [script, '--comp', state.comp, '--build', desktop, '--out-dir', outDir, '--label', 'desktop', '--json']; - if (loadSpec(specPath)) args.push('--spec', specPath); - const res = spawnSync(process.execPath, args, { encoding: 'utf8' }); - let report; - try { report = JSON.parse(res.stdout); } catch { return { ok: false, reasons: [`comp-diff failed on ${desktop}: ${res.stderr || res.stdout}`] }; } - // A texture read at 1440 differs from itself at 1536 by resampling alone; - // it passed at the hero and cannot block responsive on its own. - // Likewise a plate or image that passed the plates gate: it read 'missing' - // on detail at 1440 in a run where the hero had just accepted it at 1536, - // structure 94%. Only a region with no energy in its box is missing here. - const missing = report.regions.filter((r) => { - if (r.verdict !== 'missing' || r.kind === 'texture') return false; - const passed = state.plates && state.plates[r.id] && state.plates[r.id].status === 'ok'; - if ((r.kind === 'plate' || r.kind === 'image') && passed) { - const present = r.score.detailRaw != null ? r.score.detailRaw >= 0.3 : r.score.detail >= 0.3; - if (present && r.score.structure >= 0.5) return false; - } - return true; - }); - // A plate placed and passed at the hero is not re-litigated at 1440: the - // rescale alone drops SSIM on a busy region. Text can still contradict - // (a wrapped headline is a different composition). - const contradictedDirection = report.regions.filter((r) => r.verdict === 'contradicted' && r.kind === 'text'); - if (report.overall < min) reasons.push(`the desktop capture (${report.buildSize}; the top ${report.compSize} rows scaled to the comp's width are compared, a full-page capture is fine) scores ${(report.overall * 100).toFixed(0)}% against the comp, under ${(min * 100).toFixed(0)}%: the first viewport does not survive a common desktop width. The hero passed at ${state.breakpoint || 'the comp size'}; the layout must hold from ~1280 up, not only at the comp's exact width (grid columns in fr / minmax, not fixed px that overflow and wrap).`); - for (const r of missing) reasons.push(`at desktop width, region ${r.id} is missing`); - for (const r of contradictedDirection) reasons.push(`at desktop width, region ${r.id} (${r.kind}) is contradicted (structure ${(r.score.structure * 100).toFixed(0)}%)`); - return { ok: reasons.length === 0, reasons, summary: `desktop ${(report.overall * 100).toFixed(0)}% (${report.verdict})`, score: report.overall, sideBySide: report.files ? report.files.sideBySide : null }; -} - -const GATES = { comps: gateComps, spec: gateSpec, plates: gatePlates, hero: gateHero, responsive: gateResponsive }; - -// ---- transitions ----------------------------------------------------------- - -export function runGate(state, phase, opts = {}) { - const gate = GATES[phase]; - if (!gate) return { ok: true, reasons: [], summary: 'no mechanical gate' }; - // A gate that throws is a bug in the gate, never a verdict on the build: - // return it as a refusal that names itself, so the model sees one line - // and the state stays consistent instead of a stack trace and a half-run. - try { return gate(state, opts); } - catch (e) { return { ok: false, reasons: [`gate ${phase} errored (${e.message}); this is a tool bug, not a finding about the page. Re-run with the same inputs; if it repeats, note it and continue with build-phase.mjs advance --force --reason "user: gate ${phase} errored, proceeding" so the run is not lost.`], error: String(e && e.stack || e) }; } -} - -/** Reasons a gate may be forced past. The user downgrading the comp's authority - * in words is the only one; the parent quotes it. A reason that does not name - * the user is a model talking itself past its own gate, and it is refused. */ -export function forceAllowed(reason) { - if (typeof reason !== 'string' || reason.trim().length < 20) return false; - if (/gate \w+ errored/i.test(reason)) return true; - const namesUser = /\buser\b|\bthey (said|asked|told|chose|picked)\b|\bpaul\b/i.test(reason); - // The user must be downgrading the comp itself, not "approving" a - // translation the model proposed. A reason that keeps the comp's - // topology/palette while dropping "pixel-level" is a translation, and - // translation is what the gate measures; it is not a downgrade. - const aboutComp = /\b(comp|mock|mockup|composition|fidelity|plate|region)\b/i.test(reason); - const isTranslationDodge = /truthful|semantic|pixel-level|prioriti[sz]e (facts|semantics|accessibility)/i.test(reason) && !/(drop|skip|remove|without|not needed|don't need|do not need|ignore) (the )?(comp|plate|region|fidelity)/i.test(reason); - // The user's words have to be a downgrade of the comp, said as such: a - // brief line quoted back ("should feel like an extension of her artwork") - // is the direction the comp already serves, not permission to leave it. - // One session forced two gates on that sentence. Ask for a downgrade verb - // in the same reason as the comp noun. - const downgrades = /\b(don't|do not|doesn't|does not|no longer|not) (need|have to|want|care|require|match|follow|hold)|\b(drop|skip|remove|ignore|waive|relax|override|approve|approved|accept|accepted|fine|okay|ok|good enough|ship it|move on|proceed|go ahead|instead of|rather than)\b/i.test(reason); - // and the user's words are quoted or reported, not paraphrased into a - // principle ("the user made the artwork binding" is the model's reading) - const reported = /["'\u201c\u2018].{6,}["'\u201d\u2019]|\b(user|they|paul) (said|says|asked|asks|told|wrote|replied|answered|chose|picked|approved|confirmed)\b/i.test(reason); - const briefQuoteOnly = /\b(should feel|feel like|not a .* page|extension of)\b/i.test(reason) && !/\b(comp|mock|fidelity|gate|plate)\b.*\b(approved|accept|fine|ok|okay|skip|drop|waive|relax|override|move on|proceed)\b/i.test(reason); - return namesUser && aboutComp && downgrades && reported && !isTranslationDodge && !briefQuoteOnly; -} - -export function advance(state, { force = false, reason = null, gateOpts = {} } = {}) { - const phase = state.phase; - const idx = PHASES.indexOf(phase); - if (idx === -1 || phase === 'review') return { ok: false, reasons: [`phase ${phase} cannot advance; use finish`] }; - const p = state.phases[phase]; - p.attempts += 1; - const gate = runGate(state, phase, gateOpts); - const { plates: _p, ...gateRecord } = gate; - p.gate = { ...gateRecord, at: now() }; - // the plates gate's per-plate scores are what the hero gate reads to tell - // a placed plate from a missing one, so they travel on the state - if (phase === 'plates' && Array.isArray(gate.plates)) state.plates = Object.fromEntries(gate.plates.map((pl) => [pl.id, { status: pl.status, score: pl.score, size: pl.size }])); - if (!gate.ok && force && !forceAllowed(reason)) { - p.status = 'open'; - return { ok: false, phase, reasons: [...gate.reasons, `--force refused: "${reason || ''}" does not quote the user downgrading the comp. A single-file deliverable, a missing tool, or difficulty is not a reason, and a refused force is not the end of the phase: the readings above are the edits, each one a CSS value; make them, recapture, advance. Ask the user only when a reading contradicts something they said about this comp.`], gate }; - } - if (phase === 'hero' && (gate.score != null)) { - const stuck = heroLoopVerdict(state, gate, gateOpts.artifact || state.artifact || 'index.html'); - if (stuck && !gate.ok) gate.reasons = [stuck, ...gate.reasons]; - } - if (!gate.ok && !force) { p.status = 'open'; return { ok: false, phase, reasons: gate.reasons, gate }; } - if (!gate.ok && force) p.forced = { at: now(), reason, reasons: gate.reasons }; - p.status = 'closed'; p.closedAt = now(); - if (phase === 'comps' && gate.approved) { - state.comp = gate.approved; - if (!state.breakpoint) { try { const i = loadRaster(gate.approved).image; state.breakpoint = `${i.width}x${i.height}`; } catch { /* non-png comp: breakpoint stays unset */ } } - } - const next = PHASES[idx + 1]; - state.phase = next; - state.phases[next].status = 'open'; state.phases[next].openedAt = now(); - return { ok: true, phase, next, gate, forced: !!p.forced }; -} - -export function nextInstruction(state) { - switch (state.phase) { - case 'comps': return `Comp round for the chosen direction${state.direction ? ` (seed ${state.direction})` : ''}: read reference/visualize.md, generate three compositional comps of the requested surface at its own viewport into ${MOCKS_DIR}/ (each with a prompt sidecar), put them in front of the user, and set "approved": true in the chosen comp's sidecar. Then build-phase.mjs advance. No page code before this closes.`; - case 'spec': return `Measure the comp: node comp-spec.mjs --comp ${state.comp} --grid, open ${path.join(BUILD_DIR, 'comp-grid.png')}, write regions.json (every illustration, photo, texture as its own plate region; every text block its own text region), run comp-spec.mjs --comp ${state.comp} --regions regions.json. Then measure the type: node font-match.mjs --measure for each text region (cap height, width class, weight class) and font-match.mjs --rank --text "" to choose the headline face by metrics (the USE line is the CSS; with no browser it records the catalog's nearest face, which is the choice; do not install one, and do not write a chosen face into the spec by hand). Then build-phase.mjs advance.`; - case 'plates': return 'Produce every plate in the spec (comp-spec.mjs --print lists them). Illustrations, photos, figures: node generate-image.mjs --plate , one call per plate. It crops the comp region itself, sends the crop as the edit reference, sizes the plate, keys ink-on-ground to alpha, scores the result against the crop (PLATE-SCORE) and embeds the prompt; nothing else does all of that. Only when it errors (no key, no network) fall back to the harness image tool with comp-spec.mjs --crop as its reference image and comp-spec.mjs --plate-prompt as its prompt, then embed-prompt.mjs; do not post-process a plate with magick or write your own keying. A generation takes 30 to 90 seconds: run it with a long wait (a 90 s yield, or all plates in one command joined with &&) rather than polling an open session turn after turn. A line drawing or figure on flat ground is keyed to alpha automatically (PLATE-CHROMA): place it with a plain over the page\'s own ground, never on a second paper. An opaque plate whose ground differs from the page goes in with mix-blend-mode: multiply. Textures (paper, cloth, grain): do not generate first; crop a clean patch of the comp region (comp-spec.mjs --crop --raw, then cut a patch free of ink), mirror-tile it to the plate size, and save it as the plate; generate only when no clean patch exists. The gate scores a texture against its whole region box, so a texture region should be drawn around clean ground (a sample cell), not around the ink it sits under; the page tiles it wherever the material goes. Then build-phase.mjs advance. Write no page code before this passes.'; - case 'hero': return `Run 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 with every region at its box. Bind those numbers to your own markup (an element per region, its box from the properties); the reference is a check, not the page, and overlapping boxes are overlapping boxes. Build only the first viewport at ${state.breakpoint || 'the comp size'}. Copy the comp's words verbatim in this phase (headline, labels, table cells, footer): the user approved that comp with those words, and rewriting is a later, stated decision, never a silent one here. Set every text region's font-size from its measured cap height and its face from the ranking. Plates first: place every plate at its spec box (comp-spec.mjs --print lists boxes as percentages of the viewport) with object-fit: cover before writing a line of text or a control, capture into ${HERO_REPRO}, and run build-phase.mjs record hero (not advance) once so you see the plate regions read as match before text exists; then lay the semantic layer (text, controls, rules) over the plates from the spec's palette and boxes, capture, advance. When it fails, open the region crops it lists first, in order, then fix; do not build past the hero until it passes.`; - case 'sections': return 'Build the remaining sections inside the spec system (same corner language, rules, and palette; nothing the comp does not show). The hero passed with the comp\'s words verbatim; from here, content beyond the comp is yours to author at full fidelity, and any change to words the comp showed is a stated decision in your report, never silent. Then build-phase.mjs advance.'; - case 'motion': return 'Add the signature interaction, reveals, and motion. Then build-phase.mjs advance.'; - case 'responsive': return 'Build the other viewports (mobile first if the surface is mobile). The first viewport must hold at common desktop widths (1280 to 1600), not only at the comp\'s exact size: fluid columns, no fixed-px grid that wraps 96px narrower. Settle or disable entrance motion before capturing (an element mid-animation reads as missing). Capture desktop.png (1440 wide, full page) and mobile.png (390 wide, full page) into .impeccable/review/; the gate diffs the top of desktop.png (scaled to the comp\'s width) against the comp. Then build-phase.mjs advance.'; - case 'review': return 'Spawn the finish reviewer with the state file, the hero diff report, and the captures; record its disposition with build-phase.mjs finish --disposition .'; - default: return ''; - } -} - -export function renderStatus(state) { - const lines = [`BUILD-PHASE ${state.phase.toUpperCase()} comp ${state.comp || '(pending comp round)'}${state.direction ? ` direction ${state.direction}` : ''}${state.breakpoint ? ` breakpoint ${state.breakpoint}` : ''}`]; - for (const p of PHASES) { - const s = state.phases[p]; - let line = ` ${p.padEnd(11)} ${s.status.padEnd(8)}`; - if (s.gate && s.gate.summary) line += ` ${s.gate.summary}`; - if (s.attempts > 1) line += ` (${s.attempts} attempts)`; - if (s.forced) line += ` FORCED: ${s.forced.reason}`; - lines.push(line); - } - if (state.finish) lines.push(` finish ${state.finish.disposition} at ${state.finish.at}`); - lines.push(`NEXT ${nextInstruction(state)}`); - return lines.join('\n'); -} - -async function main() { - const cmd = process.argv[2]; - if (!cmd || flag('help')) { - console.error('usage: build-phase.mjs start --comp [--breakpoint WxH] | status [--json] | advance [--force --reason "..."] | record hero --build | scaffold | note "" | finish --disposition '); - process.exit(1); - } - if (cmd === 'start') { - const comp = arg('comp'); - const direction = arg('direction'); - if (!comp && !direction) { console.error('build-phase: start needs --comp (comp already approved) or --direction (comp round still to run)'); process.exit(1); } - if (comp && !fs.existsSync(comp)) { console.error(`build-phase: comp ${comp} does not exist`); process.exit(1); } - // The direction choice ping rides on start (see concept-seed.mjs): one - // command records the choice and opens the phases. Never fatal. - if (direction && arg('kind')) { - try { - const { pingChosen } = await import('./concept-seed.mjs'); - const sent = await pingChosen({ chosenId: arg('chosen') || undefined, key: direction, scope: 'direction', mode: arg('mode') || undefined, kind: arg('kind'), register: arg('register') || undefined }); - console.log(sent ? 'choice recorded' : 'choice ping skipped'); - } catch { console.log('choice ping skipped'); } - } - // Clear the roll's pending marker: the build has started. - try { fs.rmSync(path.join(BUILD_DIR, 'pending.json'), { force: true }); } catch { /* absent */ } - // Code-led: no phase machine to run; say what comes next and stop. - const buildPath = readBuildPath(); - if (direction && !comp && buildPath === 'code') { - console.log('CODE-LED (from .impeccable config): no comp round and no phase gates. Write the direction contract (reference/new-work.md section 5), build, and finish per section 7. The chosen decision comp, if any, rides to the finish review as the critique reference.'); - return; - } - let breakpoint = arg('breakpoint'); - if (!breakpoint && comp) { try { const i = loadRaster(comp).image; breakpoint = `${i.width}x${i.height}`; } catch { /* leave null */ } } - const existing = loadState(); - if (existing && !flag('reset')) { - console.log(`build-phase: state exists (phase ${existing.phase}); pass --reset to start over`); - console.log(renderStatus(existing)); - return; - } - const state = newState({ comp, breakpoint, artifact: arg('artifact'), direction }); - saveState(state); - console.log(renderStatus(state)); - return; - } - const state = loadState(); - if (!state) { console.error(`build-phase: no state at ${STATE_PATH}; run build-phase.mjs start --comp `); process.exit(1); } - if (cmd === 'status') { - if (flag('json')) console.log(JSON.stringify(state, null, 2)); else console.log(renderStatus(state)); - return; - } - if (cmd === 'scaffold') { - const spec = loadSpec(); - if (!spec) { console.error(`build-phase: no spec at ${SPEC_PATH}; run comp-spec.mjs first`); process.exit(1); } - const out = writeScaffold(spec, state); - console.log(`SCAFFOLD ${out.dir}`); - console.log(` ${out.css} one custom property set per region (--r--x/y/w/h in % of the comp; --r--cap, --r--font, --r--weight where measured); bind these to your own markup`); - console.log(` ${out.html} a reference page: every region positioned at its box inside a ${state.breakpoint || spec.compSize.width + 'x' + spec.compSize.height} frame, plates placed with object-fit: contain, text slots at the measured cap height in the ranked face`); - console.log(' The reference is a check, not the page: keep your own semantic structure and bind the numbers to it (an element per region, its box from the properties). Overlapping boxes are overlapping boxes. What the gate reads is pixels; a page that lands each region at its box passes whatever markup it uses.'); - return; - } - if (cmd === 'note') { - const text = process.argv.slice(3).filter((a) => !a.startsWith('--')).join(' '); - state.phases[state.phase].notes.push({ at: now(), text }); - saveState(state); - console.log(`noted on ${state.phase}`); - return; - } - if (cmd === 'record') { - const which = process.argv[3]; - if (which !== 'hero') { console.error('build-phase: record hero --build '); process.exit(1); } - const gate = gateHero(state, { buildPath: arg('build', HERO_REPRO), min: arg('min') ? parseFloat(arg('min')) : HERO_MIN }); - // record is a look, not an attempt: the plates-only capture is expected - // to fail on every text region, and counting it muddied the tally. - state.phases.hero.records = (state.phases.hero.records || 0) + 1; - state.phases.hero.gate = { ...gate, at: now() }; - saveState(state); - // record is the look, advance is the gate: on the plates-only capture, - // every text region reads missing by design, so say what the plates did. - const plateRows = Object.entries(gate.regionVerdicts || {}).filter(([id]) => { const spec = loadSpec(); const r = spec && spec.regions.find((x) => x.id === id); return r && r.medium === 'raster'; }); - if (plateRows.length) console.log(`PLATES ${plateRows.map(([id, v]) => `${id}:${v}`).join(' ')}`); - console.log(`${gate.ok ? 'PASS' : 'FAIL'} ${gate.summary || ''} (record: nothing advanced)`); - for (const r of gate.reasons) console.log(` - ${r}`); - if (gate.advisories) for (const a of gate.advisories) console.log(` ${a}`); - if (gate.worst) console.log(` worst: ${gate.worst.join('; ')}`); - if (gate.sideBySide) console.log(` open ${gate.sideBySide}`); - process.exit(gate.ok ? 0 : 2); - } - if (cmd === 'advance') { - const gateOpts = {}; - if (arg('build')) gateOpts.buildPath = arg('build'); - if (arg('min')) gateOpts.min = parseFloat(arg('min')); - if (arg('artifact')) gateOpts.artifact = arg('artifact'); - const res = advance(state, { force: flag('force'), reason: arg('reason'), gateOpts }); - saveState(state); - if (!res.ok) { - console.log(`GATE ${res.phase ? res.phase.toUpperCase() : ''} FAILED (state unchanged)`); - if (res.gate && res.gate.worstCrops && res.gate.worstCrops.length) { - console.log(' LOOK FIRST, in this order, before editing anything (comp on the left, your build on the right):'); - for (const c of res.gate.worstCrops) console.log(` ${c.file} ${c.id}: ${c.verdict} ${(c.score.overall * 100).toFixed(0)}% (structure ${(c.score.structure * 100).toFixed(0)}%, color ${(c.score.color * 100).toFixed(0)}%, detail ${(c.score.detail * 100).toFixed(0)}%)`); - console.log(' A region scored missing needs its material (a plate placed, or produced), not a value change; contradicted needs its structure re-derived from the spec box; drift is where padding and size edits belong. When a thin chrome strip (masthead, breadcrumb, table header) is the worst region, check its box height in the spec against the comp first: a strip one grid row tall in the spec but 53px in the comp compares your build against ground it never had.'); - } - for (const r of res.reasons) console.log(` - ${r}`); - if (res.gate && res.gate.advisories && res.gate.advisories.length) for (const a of res.gate.advisories) console.log(` ${a}`); - if (res.gate && res.gate.sideBySide) console.log(` then ${res.gate.sideBySide} for the whole viewport`); - process.exit(2); - } - console.log(`ADVANCED ${res.phase} -> ${res.next}${res.forced ? ' (FORCED; recorded)' : ''}${res.gate.summary ? ` ${res.gate.summary}` : ''}`); - if (res.gate && res.gate.advisories && res.gate.advisories.length) for (const a of res.gate.advisories) console.log(` ${a}`); - console.log(`NEXT ${nextInstruction(state)}`); - return; - } - 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); - console.log(renderStatus(state)); - return; - } - console.error(`build-phase: unknown command ${cmd}`); - process.exit(1); -} - -// realpath on both sides: a skill mounted through a symlink (Cursor, a -// worktree, an eval stage) must still run as a CLI. -const isMain = (() => { - try { return !!process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } - catch { return !!process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname); } -})(); -if (isMain) main(); diff --git a/skill/scripts/comp-diff.mjs b/skill/scripts/comp-diff.mjs deleted file mode 100644 index 082dfe172..000000000 --- a/skill/scripts/comp-diff.mjs +++ /dev/null @@ -1,391 +0,0 @@ -#!/usr/bin/env node -/** - * comp-diff: measure a build screenshot against its approved comp and produce - * the evidence a reviewer (human or model) needs to judge fidelity without - * trusting anyone's memory of the image. - * - * node comp-diff.mjs --comp .impeccable/mocks/approved.png --build .impeccable/review/hero-repro.png - * node comp-diff.mjs --comp comp.png --build desktop.png --spec .impeccable/build/spec.json --out-dir .impeccable/review/diff - * node comp-diff.mjs ... --json # machine-readable report on stdout - * node comp-diff.mjs ... --threshold 0.75 # exit 3 when the overall score is below - * - * Inputs: two PNGs. The build capture may be taller than the comp (a full-page - * screenshot); it is scaled to the comp's width and the top comp-height rows - * are compared, because the comp is the first viewport. `--align stretch` - * squashes the whole build onto the comp instead, for a comp that covers a - * whole page. - * - * Outputs (in --out-dir, default .impeccable/review/diff): - * side-by-side.png comp | build, same size, labeled, with the score - * heatmap.png build with the difference painted over it (red = wrong) - * regions/.png paired crops per region at legible scale, scored - * report.json every number below, plus per-region rows - * - * Scores (0..1): structure (blurred SSIM: is the composition the same?), - * color (histogram + dominant palette: is it the same palette at the same - * coverage?), detail (high-frequency energy ratio: did the material survive, - * or did an illustration become a gradient?), bands (do the horizontal - * sections line up?). `overall` weights them 0.35 / 0.25 / 0.25 / 0.15. - * - * Regions come from --spec (comp-spec.mjs output: normalized boxes) or, with - * none, from the comp's own horizontal bands, so the per-region crops exist - * either way. Every region row carries the same four scores plus `verdict`: - * match (>= 0.8), drift (>= 0.6), missing (detail ratio < 0.35 with structure - * < 0.6), or contradicted (everything else). The words are the finish - * reviewer's fidelity vocabulary on purpose. - * - * Exit codes: 0 measured (and above threshold when one is given), 1 usage or - * unreadable input, 3 below threshold. - */ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { decodePng, encodePng, loadRaster } from './lib/png.mjs'; -import { crop, resize, fit, blit, createImage, fillRect, strokeRect, drawLabel } from './lib/raster.mjs'; -import { structureScore, colorScore, detailScore, diffMap, horizontalBands, bandScore, dominantColors, toGray, blurGray, ssimShifted } from './lib/image-metrics.mjs'; - -function arg(name, fallback = null) { - const i = process.argv.indexOf(`--${name}`); - if (i === -1) return fallback; - const v = process.argv[i + 1]; - return v && !v.startsWith('--') ? v : fallback; -} -const flag = (name) => process.argv.includes(`--${name}`); - -export function readPng(file) { - return loadRaster(file).image; -} - -/** - * Scale the build to the comp's width; take the top comp-height rows - * (align=top), squash the whole build onto the comp (align=stretch), or scale - * to cover and center-crop (align=cover, the way `object-fit: cover` will show - * a plate whose aspect differs from its region). - */ -export function alignBuild(comp, build, align = 'top') { - if (align === 'stretch') return resize(build, comp.width, comp.height); - if (align === 'cover') { - const s = Math.max(comp.width / build.width, comp.height / build.height); - const scaled = resize(build, build.width * s, build.height * s); - return crop(scaled, (scaled.width - comp.width) / 2, (scaled.height - comp.height) / 2, comp.width, comp.height); - } - const scaled = build.width === comp.width ? build : resize(build, comp.width, Math.round((build.height / build.width) * comp.width)); - if (scaled.height === comp.height) return scaled; - if (scaled.height > comp.height) return crop(scaled, 0, 0, comp.width, comp.height); - // shorter than the comp: pad with white so a short page reads as missing content, not as a resize - const out = createImage(comp.width, comp.height, [255, 255, 255, 255]); - blit(out, scaled, 0, 0); - return out; -} - -/** Weights per region kind: what a region is made of decides what losing it looks like. */ -const WEIGHTS = { - default: { structure: 0.35, color: 0.25, detail: 0.25, bands: 0.15 }, - plate: { structure: 0.25, color: 0.2, detail: 0.5, bands: 0.05 }, - image: { structure: 0.25, color: 0.2, detail: 0.5, bands: 0.05 }, - texture: { structure: 0.15, color: 0.35, detail: 0.5, bands: 0 }, - text: { structure: 0.5, color: 0.25, detail: 0.15, bands: 0.1 }, - control: { structure: 0.45, color: 0.35, detail: 0.2, bands: 0 }, -}; - -export function scorePair(a, b, kind = null) { - const structure = structureScore(a, b); - const color = colorScore(a, b); - const detail = detailScore(a, b); - const bandsA = horizontalBands(a), bandsB = horizontalBands(b); - const bands = bandScore(bandsA, bandsB); - const w = WEIGHTS[kind] || WEIGHTS.default; - const overall = w.structure * structure + w.color * color.score + w.detail * detail.score + w.bands * bands; - return { - overall: r4(overall), - structure: r4(structure), - color: r4(color.score), - colorIntersection: r4(color.intersection), - paletteMatch: r4(color.paletteMatch), - detail: r4(detail.score), - detailRaw: r4(detail.rawScore ?? detail.score), - detailAdded: r4(detail.addedFraction), - bands: r4(bands), - _detail: detail, - _bands: { comp: bandsA, build: bandsB }, - }; -} - -/** Kinds that carry the direction: a wrong one is the wrong page, whatever the mean says. */ -export const DIRECTION_KINDS = new Set(['plate', 'image', 'text']); - -/** Best small global translation (build relative to comp), in pixels, by blurred-gray SSIM. */ -export function bestShift(comp, build, workWidth = 256) { - const h = Math.max(8, Math.round((comp.height / comp.width) * workWidth)); - const a = blurGray(toGray(resize(comp, workWidth, h)), 2); - const b = blurGray(toGray(resize(build, workWidth, h)), 2); - const maxShift = Math.max(2, Math.round(workWidth * 0.04)); - let best = { dx: 0, dy: 0, score: ssimShifted(a, b, 0, 0) }; - for (const dy of [-maxShift, -maxShift / 2, 0, maxShift / 2, maxShift]) { - for (const dx of [-maxShift, -maxShift / 2, 0, maxShift / 2, maxShift]) { - const sc = ssimShifted(a, b, Math.round(dx), Math.round(dy)); - if (sc > best.score + 0.01) best = { dx: Math.round(dx), dy: Math.round(dy), score: sc }; - } - } - const scale = comp.width / workWidth; - return { dx: Math.round(best.dx * scale), dy: Math.round(best.dy * scale), score: best.score }; -} - -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 - // own energy to be near zero relative to the comp (rawScore, before the - // added-detail penalty), and drift for a mere misalignment. - if (!painted && s.detail < 0.35 && s.structure < 0.6) { - if (s.detailRaw != null && s.detailRaw < 0.2) return 'missing'; - // low detail with structure and palette both holding is grain the build - // renders flatter (a spine of rotated type on textured red), not a - // different composition - if (s.structure >= 0.5 && s.color >= 0.5) return 'drift'; - return 'contradicted'; - } - if (s.detail < 0.35 && s.structure < 0.6) return 'missing'; - // Structure is the one thing a wrong-but-busy region cannot fake: noise, - // a mirrored crop, a swapped column, a tile shuffle all keep color and - // energy and lose structure. Below the floor it is contradicted whatever - // the weighted mean says; painted regions with invented detail likewise. - if (s.structure < 0.3) return 'contradicted'; - if (painted && (s.structure < 0.45 || s.detailAdded > 0.4)) return 'contradicted'; - // Text is set in a substitute face at a slightly different metric almost - // always, and blurred SSIM reads glyph shape; a text region with its - // structure above the swap floor and its palette intact is drift at worst. - // Chasing it past that point is what burned eight to thirteen hero attempts - // per build in the first simulated round. - if (kind === 'text' && s.color >= 0.5) return s.overall >= 0.8 ? 'match' : 'drift'; - // Chrome and controls are thin strips whose "detail" is mostly ground grain - // (a paper texture the build renders flatter, a scanline). When their - // structure and palette hold, low detail is drift, not contradiction. - if ((kind === 'chrome' || kind === 'control') && s.structure >= 0.5 && s.color >= 0.5) return s.overall >= 0.8 ? 'match' : 'drift'; - if (s.overall >= 0.8) return 'match'; - if (s.overall >= 0.6) return 'drift'; - return 'contradicted'; -} - -const r4 = (v) => Math.round(v * 10000) / 10000; - -/** Regions from a spec (normalized boxes) or derived from the comp's bands. */ -export function resolveRegions(comp, spec) { - const regions = []; - if (spec && Array.isArray(spec.regions) && spec.regions.length) { - for (const r of spec.regions) { - const box = r.box || r; - if ([box.x, box.y, box.w, box.h].some((v) => typeof v !== 'number')) continue; - regions.push({ id: r.id || `region-${regions.length + 1}`, x: box.x, y: box.y, w: box.w, h: box.h, kind: r.kind || null }); - } - if (regions.length) return regions; - } - const bands = horizontalBands(comp).filter((b) => b.strength > 0.2); - const cuts = [0, ...bands.map((b) => b.y), 1].filter((v, i, arr) => i === 0 || v - arr[i - 1] > 0.06); - if (cuts[cuts.length - 1] !== 1) cuts.push(1); - for (let i = 0; i + 1 < cuts.length; i++) { - regions.push({ id: `band-${i + 1}`, x: 0, y: cuts[i], w: 1, h: cuts[i + 1] - cuts[i], kind: 'band' }); - } - if (regions.length < 2) { - return [ - { id: 'top', x: 0, y: 0, w: 1, h: 0.5, kind: 'band' }, - { id: 'bottom', x: 0, y: 0.5, w: 1, h: 0.5, kind: 'band' }, - ]; - } - return regions; -} - -/** Crop a normalized region; regions thinner than 48px in either axis are grown to that so tiny strips do not swing on subpixel noise. */ -/** Bounding box of ink (pixels darker/lighter than the region's ground by a margin) within a crop, in px. */ -export function inkBox(img) { - const g = toGray(img); - // ground = median gray; ink = |v - ground| > 48 - const sample = []; for (let i = 0; i < g.data.length; i += Math.max(1, Math.floor(g.data.length / 4000))) sample.push(g.data[i]); - sample.sort((p, q) => p - q); const ground = sample[Math.floor(sample.length / 2)] || 255; - let x0 = img.width, y0 = img.height, x1 = -1, y1 = -1; - for (let y = 0; y < img.height; y++) for (let x = 0; x < img.width; x++) { - if (Math.abs(g.data[y * img.width + x] - ground) > 48) { if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; } - } - if (x1 < 0) return null; - return { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 }; -} - -function regionCrop(img, r) { - const minPx = 48; - let x = r.x * img.width, y = r.y * img.height, w = r.w * img.width, h = r.h * img.height; - if (h < minPx) { y -= (minPx - h) / 2; h = minPx; } - if (w < minPx) { x -= (minPx - w) / 2; w = minPx; } - return crop(img, x, y, w, h); -} - -const HEAT_LABEL = { match: [40, 160, 80, 255], drift: [220, 160, 30, 255], missing: [200, 40, 40, 255], contradicted: [200, 40, 40, 255] }; - -export function renderSideBySide(comp, build, label, score) { - const gap = 24, pad = 48; - const targetW = Math.min(comp.width, 1400); - const a = fit(comp, targetW, 100000), b = resize(build, a.width, a.height); - const out = createImage(a.width * 2 + gap + pad * 2, a.height + pad * 2 + 24, [24, 24, 28, 255]); - blit(out, a, pad, pad + 24); - blit(out, b, pad + a.width + gap, pad + 24); - drawLabel(out, 'COMP', pad, pad - 4, { scale: 2 }); - drawLabel(out, `BUILD ${label ? label.toUpperCase() : ''}`.trim(), pad + a.width + gap, pad - 4, { scale: 2 }); - const s = `OVERALL ${(score.overall * 100).toFixed(0)}% STRUCT ${(score.structure * 100).toFixed(0)}% COLOR ${(score.color * 100).toFixed(0)}% DETAIL ${(score.detail * 100).toFixed(0)}% BANDS ${(score.bands * 100).toFixed(0)}%`; - drawLabel(out, s, pad, out.height - pad + 8, { scale: 2, bg: HEAT_LABEL[verdictFor(score)] }); - return out; -} - -export function renderHeatmap(comp, build) { - const map = diffMap(comp, build); - const base = resize(build, map.width, map.height); - const out = { width: base.width, height: base.height, data: new Uint8Array(base.data) }; - for (let i = 0, p = 0; i < map.data.length; i++, p += 4) { - const d = map.data[i]; - if (d < 0.12) { // dim what matches so wrong stands out - out.data[p] = out.data[p] * 0.55 + 255 * 0.45 * 0.2; out.data[p + 1] = out.data[p + 1] * 0.55; out.data[p + 2] = out.data[p + 2] * 0.55; continue; - } - const a = Math.min(1, (d - 0.12) / 0.5); - out.data[p] = out.data[p] * (1 - a) + 235 * a; out.data[p + 1] = out.data[p + 1] * (1 - a) + 40 * a; out.data[p + 2] = out.data[p + 2] * (1 - a) + 40 * a; - } - const scaled = resize(out, comp.width, comp.height); - drawLabel(scaled, 'DIFF: RED = DIFFERS FROM COMP', 12, 12, { scale: 2 }); - return scaled; -} - -export function renderRegionPair(compCrop, buildCrop, id, score) { - const gap = 16, pad = 12; - const maxW = 700; - const a = fit(compCrop, maxW, 700, true), b = resize(buildCrop, a.width, a.height); - const out = createImage(a.width * 2 + gap + pad * 2, a.height + pad * 2 + 30, [24, 24, 28, 255]); - blit(out, a, pad, pad + 30); - blit(out, b, pad + a.width + gap, pad + 30); - const v = verdictFor(score); - drawLabel(out, `${id.toUpperCase()} COMP`, pad, pad, { scale: 2 }); - drawLabel(out, `BUILD ${v.toUpperCase()} ${(score.overall * 100).toFixed(0)}%`, pad + a.width + gap, pad, { scale: 2, bg: HEAT_LABEL[v] }); - return out; -} - -export function compare({ comp, build, spec = null, align = 'top', label = '', kind = null }) { - let aligned = alignBuild(comp, build, align); - const whole = scorePair(comp, aligned, kind); - // Region crops are taken at fixed boxes, so a small global offset (a - // taller masthead, a scrollbar) would read every thin region as - // contradicted while the whole-image search forgives it. Find the best - // global translation once and shift the aligned build by it before - // cropping regions; the whole score above stays as measured. - // The side-by-side and heatmap show the build as captured; only the - // region crops read the shifted copy. (The shifted copy used to be what the - // side-by-side drew, and its padding read as a white "letterbox" on the - // build in every human review.) - const asCaptured = aligned; - 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 regions = resolveRegions(comp, spec).map((r) => { - const a = regionCrop(comp, r), b = regionCrop(aligned, r); - const s = scorePair(a, b, r.kind); - return { ...r, score: strip(s), verdict: verdictFor(s, r.kind), inkBox: { comp: inkBox(a), build: inkBox(b) }, _a: a, _b: b }; - }); - const compPalette = dominantColors(comp), buildPalette = dominantColors(aligned); - return { label, align, whole: strip(whole), regions, aligned: asCaptured, alignedShifted: aligned, shift, compPalette, buildPalette, _whole: whole }; -} - -function strip(s) { - const { _detail, _bands, ...rest } = s; - return rest; -} - -export function writeArtifacts(result, comp, outDir) { - fs.mkdirSync(path.join(outDir, 'regions'), { recursive: true }); - const side = renderSideBySide(comp, result.aligned, result.label, result.whole); - fs.writeFileSync(path.join(outDir, 'side-by-side.png'), encodePng(side)); - fs.writeFileSync(path.join(outDir, 'heatmap.png'), encodePng(renderHeatmap(comp, result.aligned))); - const regionFiles = []; - for (const r of result.regions) { - const file = path.join(outDir, 'regions', `${r.id}.png`); - fs.writeFileSync(file, encodePng(renderRegionPair(r._a, r._b, r.id, r.score))); - regionFiles.push(file); - } - return { sideBySide: path.join(outDir, 'side-by-side.png'), heatmap: path.join(outDir, 'heatmap.png'), regionFiles }; -} - -export function buildReport(result, files, meta) { - return { - tool: 'comp-diff', - version: 1, - createdAt: new Date().toISOString(), - ...meta, - align: result.align, - overall: result.whole.overall, - verdict: verdictFor(result.whole), - scores: result.whole, - palette: { comp: result.compPalette.map(({ hex, coverage }) => ({ hex, coverage })), build: result.buildPalette.map(({ hex, coverage }) => ({ hex, coverage })) }, - regions: result.regions.map(({ _a, _b, ...r }) => r), - files, - }; -} - -function summarize(report) { - const lines = []; - lines.push(`COMP-DIFF ${report.label ? `[${report.label}] ` : ''}overall ${(report.overall * 100).toFixed(0)}% (${report.verdict}) structure ${(report.scores.structure * 100).toFixed(0)}% color ${(report.scores.color * 100).toFixed(0)}% detail ${(report.scores.detail * 100).toFixed(0)}% bands ${(report.scores.bands * 100).toFixed(0)}%`); - lines.push(`PALETTE comp ${report.palette.comp.slice(0, 5).map((c) => `${c.hex}(${Math.round(c.coverage * 100)}%)`).join(' ')}`); - lines.push(`PALETTE build ${report.palette.build.slice(0, 5).map((c) => `${c.hex}(${Math.round(c.coverage * 100)}%)`).join(' ')}`); - for (const r of report.regions) { - lines.push(`REGION ${r.id.padEnd(18)} ${r.verdict.padEnd(12)} ${(r.score.overall * 100).toFixed(0).padStart(3)}% structure ${(r.score.structure * 100).toFixed(0).padStart(3)}% color ${(r.score.color * 100).toFixed(0).padStart(3)}% detail ${(r.score.detail * 100).toFixed(0).padStart(3)}%${r.score.detailAdded > 0.25 ? ' +invented detail' : ''}`); - } - if (report.files) { - lines.push(`FILES side-by-side ${report.files.sideBySide}`); - lines.push(`FILES heatmap ${report.files.heatmap}`); - lines.push(`FILES regions ${report.files.regionFiles.length} under ${path.dirname(report.files.regionFiles[0] || report.files.heatmap)}`); - } - const worst = [...report.regions].sort((a, b) => a.score.overall - b.score.overall).slice(0, 3); - if (worst.length) lines.push(`WORST ${worst.map((r) => `${r.id} (${r.verdict}, ${(r.score.overall * 100).toFixed(0)}%)`).join('; ')}`); - lines.push('OPEN the side-by-side and the worst region pairs before deciding anything; the numbers rank, the crops decide.'); - return lines.join('\n'); -} - -async function main() { - const compPath = arg('comp'), buildPath = arg('build'); - if (!compPath || !buildPath) { - console.error('usage: comp-diff.mjs --comp --build [--spec spec.json] [--out-dir dir] [--align top|stretch] [--label name] [--threshold 0.75] [--json]'); - process.exit(1); - } - let comp, build; - try { comp = readPng(compPath); } catch (e) { console.error(`comp-diff: cannot read comp ${compPath}: ${e.message}`); process.exit(1); } - try { build = readPng(buildPath); } catch (e) { console.error(`comp-diff: cannot read build ${buildPath}: ${e.message}`); process.exit(1); } - let spec = null; - const specPath = arg('spec'); - if (specPath) { - try { spec = JSON.parse(fs.readFileSync(specPath, 'utf8')); } catch (e) { console.error(`comp-diff: cannot read spec ${specPath}: ${e.message}`); process.exit(1); } - } - const outDir = arg('out-dir', path.join(path.dirname(buildPath), 'diff')); - const label = arg('label', path.basename(buildPath, '.png')); - const result = compare({ comp, build, spec, align: arg('align', 'top'), label }); - const files = flag('no-files') ? null : writeArtifacts(result, comp, outDir); - const report = buildReport(result, files, { label, comp: compPath, build: buildPath, spec: specPath || null, compSize: `${comp.width}x${comp.height}`, buildSize: `${build.width}x${build.height}` }); - if (files) fs.writeFileSync(path.join(outDir, 'report.json'), JSON.stringify(report, null, 2)); - if (flag('json')) console.log(JSON.stringify(report, null, 2)); - else console.log(summarize(report)); - const threshold = arg('threshold') ? parseFloat(arg('threshold')) : null; - if (threshold != null && report.overall < threshold) { - if (!flag('json')) console.log(`BELOW THRESHOLD ${(threshold * 100).toFixed(0)}%: the reproduction is not done. Fix the worst regions and re-run; do not build past the hero.`); - process.exit(3); - } -} - -// realpath on both sides: a skill mounted through a symlink (Cursor, a -// worktree, an eval stage) must still run as a CLI. -const isMain = (() => { - try { return !!process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } - catch { return !!process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname); } -})(); -if (isMain) main(); diff --git a/skill/scripts/comp-spec.mjs b/skill/scripts/comp-spec.mjs deleted file mode 100644 index 46d5118cd..000000000 --- a/skill/scripts/comp-spec.mjs +++ /dev/null @@ -1,513 +0,0 @@ -#!/usr/bin/env node -/** - * comp-spec: turn an approved comp into a measured build spec, so the build - * codes against numbers and crops instead of a memory of the image. - * - * Step 1, look at the comp with a coordinate grid on it: - * node comp-spec.mjs --comp .impeccable/mocks/approved.png --grid - * writes .impeccable/build/comp-grid.png (10x10 labeled grid, A-J / 0-9) - * and prints the measured palette and horizontal bands. Open the grid - * image and name every salient region by its grid span. - * - * Step 2, write the regions file (JSON) and measure it: - * node comp-spec.mjs --comp --regions regions.json - * regions.json: { "regions": [ { "id": "exploded-plate", "kind": "plate", - * "grid": "E0:J4", "note": "exploded carburetor line drawing" }, ... ] } - * `grid` is ":" inclusive (A0 top-left cell to J9 bottom - * right); `box` { x, y, w, h } normalized 0..1 is accepted instead. `kind` - * is one of plate | image | texture | text | control | chrome | band. - * Writes .impeccable/build/spec.json: every region with its normalized - * box, pixel box, sampled palette, detail energy, and its medium: raster - * for plate / image / texture (produced as a plate, never CSS), semantic - * for text / control / chrome. `--auto` proposes band regions from the - * comp itself when you have no regions file yet. - * - * Step 3, use it: - * node comp-spec.mjs --print # compact spec for the build thread - * node comp-spec.mjs --crop exploded-plate --out tmp/plate-src.png [--scale 2] [--raw] - * crops the region from the comp (reference for a plate regeneration; a - * crop is never a shipping asset, its resolution is comp grade). For a - * raster region the crop has overlapping text/control/chrome regions - * painted out, matching what the plate prompt asks the generator to - * remove; --raw keeps them. - * node comp-spec.mjs --plate-prompt exploded-plate # the regeneration prompt for that region - * - * comp-diff.mjs reads the same spec (`--spec`) so its region rows and this - * file's rows are the same rows. - */ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { decodePng, encodePng, loadRaster } from './lib/png.mjs'; -import { crop, resize, fillRect, strokeRect, drawLabel, drawText } from './lib/raster.mjs'; -import { dominantColors, horizontalBands, detailGrid } from './lib/image-metrics.mjs'; - -function arg(name, fallback = null) { - const i = process.argv.indexOf(`--${name}`); - if (i === -1) return fallback; - const v = process.argv[i + 1]; - return v && !v.startsWith('--') ? v : fallback; -} -const flag = (name) => process.argv.includes(`--${name}`); - -export const BUILD_DIR = path.join('.impeccable', 'build'); -export const SPEC_PATH = path.join(BUILD_DIR, 'spec.json'); -export const GRID_PATH = path.join(BUILD_DIR, 'comp-grid.png'); -export const PLATES_DIR = path.join('assets', 'plates'); - -export const RASTER_KINDS = new Set(['plate', 'image', 'texture']); -export const KINDS = new Set(['plate', 'image', 'texture', 'text', 'control', 'chrome', 'band']); -const COLS = 'ABCDEFGHIJ'; - -/** "E0:J4" -> normalized box (inclusive cell span on a 10x10 grid). */ -export function gridToBox(span) { - const m = /^([A-J])(\d):([A-J])(\d)$/i.exec(String(span).trim()); - if (!m) throw new Error(`grid span "${span}" is not :, e.g. E0:J4`); - const c0 = COLS.indexOf(m[1].toUpperCase()), r0 = +m[2], c1 = COLS.indexOf(m[3].toUpperCase()), r1 = +m[4]; - const x0 = Math.min(c0, c1), x1 = Math.max(c0, c1), y0 = Math.min(r0, r1), y1 = Math.max(r0, r1); - return { x: x0 / 10, y: y0 / 10, w: (x1 - x0 + 1) / 10, h: (y1 - y0 + 1) / 10 }; -} - -export function renderGrid(comp) { - const targetW = Math.min(1536, comp.width); - const img = resize(comp, targetW, Math.round((comp.height / comp.width) * targetW)); - const cw = img.width / 10, ch = img.height / 10; - const line = [255, 40, 40, 200]; - for (let i = 1; i < 10; i++) { - fillRect(img, Math.round(i * cw), 0, 1, img.height, line); - fillRect(img, 0, Math.round(i * ch), img.width, 1, line); - } - for (let r = 0; r < 10; r++) for (let c = 0; c < 10; c++) { - drawLabel(img, `${COLS[c]}${r}`, Math.round(c * cw) + 3, Math.round(r * ch) + 3, { scale: 2, bg: [0, 0, 0, 170], fg: [255, 230, 120, 255] }); - } - return img; -} - -function paletteOf(img) { - return dominantColors(img, 5).map(({ hex, coverage }) => ({ hex, coverage })); -} - -/** Words in a region note that name painted material rather than code-drawn UI. */ -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|geometry|leader lines?|callout lines?|thumbnail|silhouette|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; -} - -/** - * Shrink a normalized box to the ink inside it (pixels far from the page - * ground), padded by `pad` px, never grown. Returns null when the crop has no - * ink or the ink fills the box already. - */ -export function snapBoxToInk(comp, box, ground, { pad = 6, minShrink = 0.06 } = {}) { - const px = { x: Math.round(box.x * comp.width), y: Math.round(box.y * comp.height), w: Math.round(box.w * comp.width), h: Math.round(box.h * comp.height) }; - if (px.w < 8 || px.h < 8) return null; - const c = crop(comp, px.x, px.y, px.w, px.h); - const W = c.width, H = c.height; - let x0 = W, y0 = H, x1 = -1, y1 = -1; - for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { - const i = (y * W + x) * 4; - const g = 0.299 * c.data[i] + 0.587 * c.data[i + 1] + 0.114 * c.data[i + 2]; - if (Math.abs(g - ground) > 60) { if (x < x0) x0 = x; if (x > x1) x1 = x; if (y < y0) y0 = y; if (y > y1) y1 = y; } - } - if (x1 < 0) return null; - // The bounding box of all ink cannot shed a neighbour that shares the - // span (a spine at the left edge, the next column's text at the right). - // Take the largest connected ink mass instead: cells of `cell` px are - // inked when 4% of their pixels are; 8-connected components; the one - // with the most inked cells is the element the region names. - const cell = Math.max(6, Math.round(Math.min(W, H) / 40)); - const cw = Math.ceil(W / cell), ch = Math.ceil(H / cell); - const on = new Uint8Array(cw * ch), cnt = new Uint16Array(cw * ch); - for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) { - const i = (y * W + x) * 4; - const g = 0.299 * c.data[i] + 0.587 * c.data[i + 1] + 0.114 * c.data[i + 2]; - if (Math.abs(g - ground) > 60) cnt[Math.floor(y / cell) * cw + Math.floor(x / cell)]++; - } - for (let i = 0; i < on.length; i++) on[i] = cnt[i] >= cell * cell * 0.04 ? 1 : 0; - // dilate by one cell so the letters of a word and the lines of a block - // join into one mass; a neighbouring column a few cells away stays apart - const grown = new Uint8Array(on.length); - for (let y = 0; y < ch; y++) for (let x = 0; x < cw; x++) { - if (!on[y * cw + x]) continue; - for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) { const nx = x + dx, ny = y + dy; if (nx >= 0 && ny >= 0 && nx < cw && ny < ch) grown[ny * cw + nx] = 1; } - } - const mask = grown; - const label = new Int32Array(cw * ch).fill(-1); - let best = null; - for (let s0 = 0; s0 < on.length; s0++) { - if (!mask[s0] || label[s0] >= 0) continue; - const stack = [s0]; label[s0] = s0; let n = 0, bx0 = cw, by0 = ch, bx1 = -1, by1 = -1; - while (stack.length) { - const k = stack.pop(); - const kx = k % cw, ky = (k / cw) | 0; - if (on[k]) { n += cnt[k]; if (kx < bx0) bx0 = kx; if (kx > bx1) bx1 = kx; if (ky < by0) by0 = ky; if (ky > by1) by1 = ky; } - for (let dy = -1; dy <= 1; dy++) for (let dx = -1; dx <= 1; dx++) { - const nx = kx + dx, ny = ky + dy; if (nx < 0 || ny < 0 || nx >= cw || ny >= ch) continue; - const nk = ny * cw + nx; if (mask[nk] && label[nk] < 0) { label[nk] = s0; stack.push(nk); } - } - } - // a mass touching the span's left or right edge continues past it (the - // spine, the next column); the element the region names sits inside. - // Prefer an inside mass unless the edge mass is far heavier. - const touchesSide = bx0 === 0 || bx1 === cw - 1; - const cand = { n, bx0, by0, bx1, by1, touchesSide }; - if (!best) best = cand; - else if (best.touchesSide && !cand.touchesSide && cand.n * 3 >= best.n) best = cand; - else if (!best.touchesSide && cand.touchesSide && cand.n < best.n * 3) { /* keep inside */ } - else if (cand.n > best.n) best = cand; - } - if (best) { x0 = best.bx0 * cell; y0 = best.by0 * cell; x1 = Math.min(W - 1, (best.bx1 + 1) * cell - 1); y1 = Math.min(H - 1, (best.by1 + 1) * cell - 1); } - const nx0 = Math.max(0, x0 - pad), ny0 = Math.max(0, y0 - pad), nx1 = Math.min(W, x1 + 1 + pad), ny1 = Math.min(H, y1 + 1 + pad); - const shrink = 1 - ((nx1 - nx0) * (ny1 - ny0)) / (W * H); - if (shrink < minShrink) return null; - return { x: (px.x + nx0) / comp.width, y: (px.y + ny0) / comp.height, w: (nx1 - nx0) / comp.width, h: (ny1 - ny0) / comp.height }; -} - -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; - return s / g.cells.length; -} - - -/** - * Grid cells (10x10) that carry ink the regions do not name. A regions file - * that omits the comp's callouts, notes block, or parts table makes those - * elements invisible to every later gate (they are never 'missing' if they - * were never named), so the spec refuses to close over them. Texture and - * band regions do not cover: a full-bleed paper texture names the ground, - * not the drawing on it. - */ -export function uncoveredInkCells(comp, regions) { - const grid = detailGrid(comp, 10, 10, 512); - const cells = []; - // The ground's own energy (paper grain, gradient) is the quietest tenth of - // cells; ink is anything clearly above that. Median-relative thresholds - // fail on textured comps where every cell carries grain. - const energies = [...grid.cells].sort((a, b) => a - b); - const ground = energies[Math.floor(energies.length * 0.1)] || 0; - const threshold = Math.max(4, ground * 2.2, ground + 12); - for (let r = 0; r < 10; r++) for (let c = 0; c < 10; c++) { - const e = grid.cells[r * 10 + c]; - if (e < threshold) continue; - const cx = (c + 0.5) / 10, cy = (r + 0.5) / 10; - const covered = regions.some((reg) => { const b = reg.coverBox || reg.box; return reg.kind !== 'texture' && reg.kind !== 'band' && cx >= b.x && cx <= b.x + b.w && cy >= b.y && cy <= b.y + b.h; }); - if (!covered) cells.push(`${COLS[c]}${r}`); - } - return cells; -} - -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}`); - 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 - // 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.`); - } - let box = raw.box && typeof raw.box.x === 'number' ? raw.box : gridToBox(raw.grid); - // A grid span over-covers: a headline named B1:E4 carries the deck below - // it and a slice of the next column, and every measurement downstream - // (cap height, line count, structure) inherits that slop; a session - // wrote a note saying its hero sat at 67 because the boxes straddled - // elements, and it was right. Text and control regions snap to the ink - // inside their span (page ground as the reference, a small pad); plates, - // textures, chrome, and any region given an explicit box are left as - // drawn. The grid stays on the record. - let coverBox = null; - if (!raw.box && raw.grid && (kind === 'text' || kind === 'control') && raw.snap !== false) { - const snapped = snapBoxToInk(comp, box, pageGround); - if (snapped) { coverBox = box; box = snapped; } - } - // A code region is one element the page draws: a headline, a table, a - // button, a bar. A "chrome" region covering a third of the comp is a - // column, and a column scored as one region hides everything inside it - // (a session named seven regions for a page with three plates, a table, - // a note, callouts and a spine, and the hero gate could name nothing). - // Raster regions may be as large as the material; a texture is a sample. - const area = box.w * box.h; - if (!RASTER_KINDS.has(kind) && kind !== 'band' && area > MAX_CODE_REGION_AREA && !raw.container) { - throw new Error(`region ${raw.id} (${kind}) covers ${Math.round(area * 100)}% of the comp; a code region is one element (a headline, a table, a control, a rule, a bar), and one this large is a column holding several. Name each element inside it as its own region (every illustration or photo as a plate), or set "container": true on the region if it truly is one undivided element.`); - } - const px = { x: Math.round(box.x * comp.width), y: Math.round(box.y * comp.height), w: Math.round(box.w * comp.width), h: Math.round(box.h * comp.height) }; - 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, - 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, - aspect: r4(px.w / px.h), - 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, - }); - } - const uncovered = uncoveredInkCells(comp, regions); - if (uncovered.length > 3 && !regionsInput.allowUncovered) { - throw new Error(`grid cells ${uncovered.join(', ')} carry ink no region names. Every element the comp shows must be in a region (text, control, chrome, or a plate) so its absence in the build can be measured; add regions for them, or set "allowUncovered": true in the regions file after confirming those cells are empty ground.`); - } - return { - tool: 'comp-spec', - version: 1, - createdAt: new Date().toISOString(), - comp: compPath, - warnings, - uncoveredInkCells: uncovered, - compSize: { width: comp.width, height: comp.height }, - aspect: r4(comp.width / comp.height), - orientation: comp.width >= comp.height ? 'landscape' : 'portrait', - palette: paletteOf(comp), - bands: horizontalBands(comp).filter((b) => b.strength > 0.2).map((b) => ({ y: r4(b.y), strength: r4(b.strength) })), - regions, - }; -} - -/** Propose regions from the comp's bands when no regions file exists yet. */ -export function autoRegions(comp) { - const bands = horizontalBands(comp).filter((b) => b.strength > 0.2); - const cuts = [0, ...bands.map((b) => b.y), 1].filter((v, i, arr) => i === 0 || v - arr[i - 1] > 0.06); - if (cuts[cuts.length - 1] !== 1) cuts.push(1); - const regions = []; - for (let i = 0; i + 1 < cuts.length; i++) regions.push({ id: `band-${i + 1}`, kind: 'band', box: { x: 0, y: cuts[i], w: 1, h: cuts[i + 1] - cuts[i] } }); - return { regions }; -} - -const r4 = (v) => Math.round(v * 10000) / 10000; - -/** - * The comp crop of a raster region, with every overlapping semantic region - * (text, control, chrome) painted out in the crop's own ground color. The - * plate prompt tells the generator to remove UI text and chrome, so a good - * plate must be scored against a crop that has them removed too; otherwise - * the plate loses structure points for obeying the spec. - */ -export function plateReference(comp, spec, region) { - const c = crop(comp, region.px.x, region.px.y, region.px.w, region.px.h); - const ground = (region.palette && region.palette[0] && hexToRgb(region.palette[0].hex)) || [255, 255, 255]; - for (const other of spec.regions || []) { - if (other.id === region.id || RASTER_KINDS.has(other.kind) || other.kind === 'band') continue; - const ox = Math.max(0, other.px.x - region.px.x), oy = Math.max(0, other.px.y - region.px.y); - const ox2 = Math.min(region.px.w, other.px.x + other.px.w - region.px.x), oy2 = Math.min(region.px.h, other.px.y + other.px.h - region.px.y); - if (ox2 <= ox || oy2 <= oy) continue; - fillRect(c, ox, oy, ox2 - ox, oy2 - oy, [...ground, 255]); - } - return c; -} - -function hexToRgb(hex) { - const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex || ''); - return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : null; -} - -export function platePrompt(spec, region) { - const world = spec.palette.slice(0, 3).map((c) => c.hex).join(', '); - const kindLine = region.kind === 'texture' - ? 'This is a seamless surface texture. Output a tileable texture plate with no objects, no text, no vignette.' - : region.kind === 'image' - ? 'This is a photographic or illustrated image region. Output the same subject, same framing, same lighting.' - : 'This is a designed illustration plate. Output the same drawing, same style, same line weight and shading.'; - return [ - 'Use the provided crop as the approved visual reference and recreate it as a clean production asset at the target aspect ratio.', - kindLine, - `Preserve silhouette, composition, perspective, palette (${world}), lighting, material, and texture exactly.`, - 'Remove every piece of UI text, label, caption, button, and interface chrome that is not part of the artwork itself.', - 'Remove letterboxing, borders, card corners, drop shadows, and any layout background that the page will draw in code.', - 'Do not add objects. Do not change the concept. Do not restyle. The artwork fills the whole frame edge to edge at the same scale as the reference; no margins, no border, no background band.', - region.note ? `Region: ${region.note}.` : '', - ].filter(Boolean).join(' '); -} - -export function printSpec(spec) { - const lines = []; - lines.push(`SPEC comp ${spec.comp} ${spec.compSize.width}x${spec.compSize.height} ${spec.orientation}`); - lines.push(`PALETTE ${spec.palette.map((c) => `${c.hex}(${Math.round(c.coverage * 100)}%)`).join(' ')}`); - lines.push(`BANDS ${spec.bands.map((b) => `${Math.round(b.y * 100)}%`).join(' ') || 'none'}`); - for (const r of spec.regions) { - const b = r.box; - lines.push(`REGION ${r.id.padEnd(18)} ${r.kind.padEnd(8)} ${r.medium.padEnd(8)} box x${Math.round(b.x * 100)}% y${Math.round(b.y * 100)}% w${Math.round(b.w * 100)}% h${Math.round(b.h * 100)}% (${r.px.w}x${r.px.h}px, ${r.aspect}:1) palette ${r.palette.slice(0, 3).map((c) => c.hex).join(' ')}${r.plate ? ` plate ${r.plate}` : ''}${r.note ? ` # ${r.note}` : ''}`); - } - 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'); -} - -export function loadSpec(specPath = SPEC_PATH) { - if (!fs.existsSync(specPath)) return null; - return JSON.parse(fs.readFileSync(specPath, 'utf8')); -} - -async function main() { - const specPath = arg('spec', SPEC_PATH); - if (flag('help') || process.argv.length <= 2) { - console.log(`usage: comp-spec.mjs --comp --grid write .impeccable/build/comp-grid.png (10x10 labeled grid) + palette + bands - comp-spec.mjs --comp --regions measure regions -> .impeccable/build/spec.json - regions json: { "regions": [ { "id": "art", "kind": "plate|image|texture|text|control|chrome", "grid": "E0:J4", "note": "..." } ] } - comp-spec.mjs --comp --auto band regions when you have no regions file - comp-spec.mjs --print the compact spec - comp-spec.mjs --crop [--out f] [--scale n] reference crop of a region (never a shipping asset) - comp-spec.mjs --plate-prompt the regeneration prompt for a raster region`); - return; - } - if (flag('print')) { - const spec = loadSpec(specPath); - if (!spec) { console.error(`comp-spec: no spec at ${specPath}; run with --comp --regions first`); process.exit(1); } - console.log(printSpec(spec)); - return; - } - if (arg('plate-prompt')) { - const spec = loadSpec(specPath); - if (!spec) { console.error(`comp-spec: no spec at ${specPath}`); process.exit(1); } - const region = spec.regions.find((r) => r.id === arg('plate-prompt')); - if (!region) { console.error(`comp-spec: no region ${arg('plate-prompt')}`); process.exit(1); } - console.log(platePrompt(spec, region)); - return; - } - if (arg('crop')) { - const spec = loadSpec(specPath); - if (!spec) { console.error(`comp-spec: no spec at ${specPath}`); process.exit(1); } - const region = spec.regions.find((r) => r.id === arg('crop')); - if (!region) { console.error(`comp-spec: no region ${arg('crop')}; ids: ${spec.regions.map((r) => r.id).join(', ')}`); process.exit(1); } - const comp = loadRaster(spec.comp).image; - let c = region.medium === 'raster' && !flag('raw') ? plateReference(comp, spec, region) : crop(comp, region.px.x, region.px.y, region.px.w, region.px.h); - const scale = parseFloat(arg('scale', '1')); - if (scale > 1) c = resize(c, c.width * scale, c.height * scale); - const out = arg('out', path.join(BUILD_DIR, 'crops', `${region.id}.png`)); - fs.mkdirSync(path.dirname(out), { recursive: true }); - fs.writeFileSync(out, encodePng(c, { text: { 'impeccable:crop-of': `${spec.comp}#${region.id}` } })); - console.log(`CROP ${out} (${c.width}x${c.height}) region ${region.id} of ${spec.comp}. Reference only: regenerate the plate from it, never ship it.`); - return; - } - - const compPath = arg('comp'); - if (!compPath) { - console.error('usage: comp-spec.mjs --comp (--grid | --regions | --auto) [--spec out.json]\n comp-spec.mjs --print | --crop [--out file] [--scale n] | --plate-prompt '); - process.exit(1); - } - let comp; - try { comp = loadRaster(compPath).image; } catch (e) { console.error(`comp-spec: cannot read ${compPath}: ${e.message}`); process.exit(1); } - - if (flag('grid')) { - fs.mkdirSync(path.dirname(GRID_PATH), { recursive: true }); - fs.writeFileSync(GRID_PATH, encodePng(renderGrid(comp))); - console.log(`GRID ${GRID_PATH} (${comp.width}x${comp.height} comp; cells A0 top-left to J9 bottom-right)`); - console.log(`PALETTE ${paletteOf(comp).map((c) => `${c.hex}(${Math.round(c.coverage * 100)}%)`).join(' ')}`); - console.log(`BANDS ${horizontalBands(comp).filter((b) => b.strength > 0.2).map((b) => `${Math.round(b.y * 100)}%`).join(' ') || 'none'}`); - console.log('NEXT open the grid image, then write regions.json in exactly this shape and run --regions regions.json:'); - console.log(' { "regions": [ { "id": "exploded-plate", "kind": "plate", "grid": "E0:H4", "note": "exploded carburetor drawing" }, { "id": "masthead", "kind": "chrome", "grid": "A0:J0", "note": "navy bar" } ] }'); - console.log(' kind: plate | image | texture (painted material: every illustration, photograph, figure, product object, texture; each ships as a raster plate) or text | control | chrome (code draws it). grid: :, A0 top-left to J9 bottom-right, inclusive.'); - console.log(' A texture region is a clean sample cell of the material (ground with no ink on it), not the whole band it covers; the page tiles it. Ink that sits on the material gets its own text/control region.'); - return; - } - - let regionsInput; - if (arg('regions')) { - try { regionsInput = JSON.parse(fs.readFileSync(arg('regions'), 'utf8')); } catch (e) { console.error(`comp-spec: cannot read regions ${arg('regions')}: ${e.message}`); process.exit(1); } - } else if (flag('auto')) { - regionsInput = autoRegions(comp); - } else { - console.error('comp-spec: pass --grid to get the coordinate grid, then --regions (or --auto for band regions)'); - process.exit(1); - } - let spec; - try { spec = measureRegions(comp, regionsInput, compPath); } catch (e) { console.error(`comp-spec: ${e.message}`); process.exit(1); } - fs.mkdirSync(path.dirname(specPath), { recursive: true }); - fs.writeFileSync(specPath, JSON.stringify(spec, null, 2)); - console.log(`WROTE ${specPath}`); - console.log(printSpec(spec)); -} - -// realpath on both sides: a skill mounted through a symlink (Cursor, a -// worktree, an eval stage) must still run as a CLI. -const isMain = (() => { - try { return !!process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } - catch { return !!process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname); } -})(); -if (isMain) main(); diff --git a/skill/scripts/font-match.mjs b/skill/scripts/font-match.mjs deleted file mode 100644 index f69a000ac..000000000 --- a/skill/scripts/font-match.mjs +++ /dev/null @@ -1,457 +0,0 @@ -#!/usr/bin/env node -/** - * font-match: measure the lettering in a comp's text region and rank candidate - * faces against it, so the face is chosen by metrics instead of by name. - * - * node font-match.mjs --measure [--spec .impeccable/build/spec.json] - * Fingerprints the comp crop of a text region (lib/font-fingerprint.mjs): - * cap height (px), glyph width per cap height (width class), stroke - * density and stem width (weight class), tracking, plus the size-invariant - * shape vector the ranking uses. Prints the summary and stores it on the - * region in the spec (`type` block), so build code can set font-size from - * capHeightPx and the hero gate can name a width/weight miss. - * - * node font-match.mjs --rank [--candidates "Barlow Condensed:700,Oswald:600"] [--text "The manuals stop."] [--category sans,display] - * Candidates come from a fingerprint index of the Google Fonts catalog - * (data/font-index.json, ~3,000 faces at two cap heights; the crop is - * routed to the 14px or 48px index by its cap height): the 25 nearest - * faces by fingerprint distance, plus the names you pass. Each candidate - * is then rendered with the region's text at the comp's cap height in a - * headless browser (Google Fonts CSS), fingerprinted the same way, and - * ranked by the same distance on the rendered text. Prints CATALOG (the - * index's top five), the ranking with per-face width and weight deltas, - * a proof sheet, and the CSS to use (family, weight, and the font-size - * that reproduces the comp's cap height). Needs a browser: playwright or - * puppeteer resolvable from the project or the impeccable CLI; without - * one, the CATALOG line is the ranking. Without the index the built-in - * per-width-class shortlist stands in. - * - * Why: models pick faces from memory and never measure. Three of the six - * misses a human called on a first-round build were the same miss: the - * headline face wider and lighter than the comp's, the parts list smaller, - * the footer heavier. All three are ratios a script can read off pixels. - */ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { createRequire } from 'node:module'; -import { createHash } from 'node:crypto'; -import { decodePng, encodePng, loadRaster } from './lib/png.mjs'; -import { crop } from './lib/raster.mjs'; -import { fingerprint, distance } from './lib/font-fingerprint.mjs'; -import { loadFontIndex, candidatesFromIndex, MIN_RANK_CAP_PX } from './lib/font-index.mjs'; -import { loadSpec, SPEC_PATH } from './comp-spec.mjs'; - -const require = createRequire(import.meta.url); - -function arg(name, fallback = null) { - const i = process.argv.indexOf(`--${name}`); - if (i === -1) return fallback; - const v = process.argv[i + 1]; - return v && !v.startsWith('--') ? v : fallback; -} - -// ---- fingerprint ---------------------------------------------------------- -// fingerprint(img) and distance(a, b) live in lib/font-fingerprint.mjs: size- -// invariant shape features per text line (advance, x-ratio, stem width, -// contrast, serif, density, ink profiles) and a noise-normalized weighted L1 -// fitted on held-out Google Fonts probes. The class helpers below turn two of -// those features into the words the MEASURE line prints. - -/** - * The feature that reads as width: advX (x-height glyph width / R) on a - * mixed-case crop, advTall (cap glyph width / R) when the crop is all caps. - * Thresholds sit on the catalog index (advX 0.20 quantile 0.58, median 0.64, - * 0.80 quantile 0.71) anchored by named faces: League Gothic 0.36, Oswald 0.42, - * Anton 0.48, Barlow Condensed 0.54, Roboto Condensed 0.58, Roboto 0.62, - * Inter 0.65, Space Grotesk 0.71, Montserrat Bold 0.76, Archivo Black 0.87. - */ -export function widthMeasure(fp) { - if (!fp) return null; - if (fp.advX != null) return { key: 'advX', value: fp.advX }; - if (fp.advTall != null) return { key: 'advTall', value: fp.advTall }; - if (fp.advance != null) return { key: 'advance', value: fp.advance }; - return null; -} -export function widthClass(fp) { - const m = typeof fp === 'number' ? { key: 'advX', value: fp } : widthMeasure(fp); - if (!m) return 'normal'; - // cap widths run ~10% wider than x-height widths against the same R - const t = m.key === 'advTall' ? [0.45, 0.61, 0.78] : [0.42, 0.585, 0.72]; - if (m.value < t[0]) return 'compressed'; - if (m.value < t[1]) return 'condensed'; - if (m.value < t[2]) return 'normal'; - return 'wide'; -} -/** - * The feature that reads as weight: densTall (ink / bbox area of cap-height - * glyphs); stemW (stem width / R) when no cap glyph was separable. Catalog - * anchors for densTall: Lato 300 0.27, Roboto 300 0.32, Playfair 400 0.37, - * Inter 400 0.44, Roboto 700 0.59, Work Sans 700 0.64, Bebas Neue 0.68, - * Oswald 700 0.72, League Gothic 0.76, Anton 0.79. For stemW: Roboto 300 0.10, - * Roboto 400 0.14, Inter 700 0.22, Archivo Black 0.30. - */ -export function weightMeasure(fp) { - if (!fp) return null; - if (fp.densTall != null) return { key: 'densTall', value: fp.densTall }; - if (fp.densX != null) return { key: 'densX', value: fp.densX }; - if (fp.stemW != null) return { key: 'stemW', value: fp.stemW }; - if (fp.weight != null) return { key: 'weight', value: fp.weight }; - return null; -} -export function weightClass(fp) { - const m = typeof fp === 'number' ? { key: 'densTall', value: fp } : weightMeasure(fp); - if (!m) return 'regular'; - const t = m.key === 'stemW' ? [0.105, 0.165, 0.195, 0.24] : [0.34, 0.48, 0.56, 0.66]; - if (m.value < t[0]) return 'light'; - if (m.value < t[1]) return 'regular'; - if (m.value < t[2]) return 'medium'; - if (m.value < t[3]) return 'bold'; - return 'black'; -} - -/** - * A starter shortlist per width class, Google Fonts only, chosen to span - * weight and character inside the class. Used only when the catalog index - * (data/font-index.json) is missing; with the index, candidates come from - * the comp's fingerprint and the model's own names. - */ -export const SHORTLIST = { - compressed: ['League Gothic:400', 'Bebas Neue:400', 'Anton:400', 'Six Caps:400', 'Big Shoulders Display:900', 'Antonio:700', 'Saira Extra Condensed:800', 'Oswald:700'], - condensed: ['League Gothic:400', 'Fjalla One:400', 'Anton:400', 'Bebas Neue:400', 'Oswald:600', 'Barlow Condensed:700', 'Roboto Condensed:800', 'Archivo Narrow:700', 'Pathway Gothic One:400', 'Big Shoulders Display:800', 'Teko:600', 'Sofia Sans Condensed:800'], - normal: ['Inter:700', 'Work Sans:700', 'IBM Plex Sans:700', 'Archivo:800', 'Public Sans:700', 'Source Sans 3:700', 'Roboto:900', 'Barlow:800', 'Manrope:800', 'Rubik:800'], - wide: ['Archivo Black:400', 'Syne:800', 'Space Grotesk:700', 'Unbounded:700', 'Bricolage Grotesque:800', 'Sora:800', 'Outfit:800', 'Lexend:800'], -}; - -/** Weight-shifted variants of a candidate list, one step lighter and heavier; the ranking decides. */ -export function withWeightVariants(list) { - const out = []; - for (const c of list) { - out.push(c); - const m = /^(.*?):(\d{3})$/.exec(c); - if (!m) continue; - const w = parseInt(m[2], 10); - for (const d of [-200, 200]) { const nw = w + d; if (nw >= 100 && nw <= 900) out.push(`${m[1]}:${nw}`); } - } - return [...new Set(out)]; -} - -/** - * Candidate faces for a comp fingerprint: the nearest index faces (top n by - * fingerprint distance, routed to the 14px or 48px index by the crop's cap - * height, optionally filtered by category), the caller's own names first, - * and the built-in shortlist only when there is no index. Returns - * { candidates: [{ family, weight }], catalog: [index hits], source }. - */ -export function selectCandidates(fp, { own = [], index = null, n = 25, category = null } = {}) { - const catalog = index ? candidatesFromIndex(fp, index, { n, category }) : []; - const list = [...own, ...catalog.map((c) => ({ family: c.family, weight: c.weight }))]; - let source = 'index'; - if (!index) { - source = 'shortlist'; - for (const s of withWeightVariants(SHORTLIST[widthClass(fp)] || SHORTLIST.normal)) list.push(parseCandidates(s)[0]); - } - const seen = new Set(); - const candidates = list.filter((c) => { const k = `${c.family}:${c.weight}`; if (seen.has(k)) return false; seen.add(k); return true; }); - return { candidates, catalog, source }; -} - -/** - * A choice font-match wrote carries a stamp over its own fields, so the spec - * gate can tell a measured choice from a hand-typed one. Sessions with no - * browser wrote `"chosen": { "family": "Arial Narrow", "source": "system-fallback" }` - * straight into spec.json to get past the gate; that is the guess the gate - * exists to refuse. Not secret, just not something a model reaches for. - */ -export function stampChoice(regionId, chosen) { - const h = createHash('sha1').update(`font-match:${regionId}:${chosen.family}:${chosen.weight}:${chosen.fontSizePx}:${chosen.source}`).digest('hex').slice(0, 12); - return { ...chosen, stamp: h }; -} -export function choiceStamped(regionId, chosen) { - if (!chosen || !chosen.stamp) return false; - return stampChoice(regionId, { ...chosen, stamp: undefined }).stamp === chosen.stamp; -} - -// ---- 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. - const extra = (process.env.IMPECCABLE_NODE_MODULES || '').split(path.delimiter).filter(Boolean); - const tries = [ - ...extra.map((dir) => () => require(require.resolve('playwright', { paths: [dir, path.dirname(dir)] }))), - () => require('playwright'), - () => require(require.resolve('playwright', { paths: [process.cwd()] })), - () => require(require.resolve('playwright', { paths: [path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..')] })), - ]; - for (const t of tries) { try { const pw = t(); if (pw?.chromium) return { kind: 'playwright', mod: pw }; } catch { /* next */ } } - const tries2 = [ - ...extra.map((dir) => () => require(require.resolve('puppeteer', { paths: [dir, path.dirname(dir)] }))), - () => require('puppeteer'), - () => require(require.resolve('puppeteer', { paths: [process.cwd()] })), - () => require(require.resolve('puppeteer', { paths: [path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..')] })), - ]; - for (const t of tries2) { try { const pp = t(); if (pp?.launch) return { kind: 'puppeteer', mod: pp }; } catch { /* next */ } } - return null; -} - -function parseCandidates(s) { - return String(s || '').split(',').map((x) => x.trim()).filter(Boolean).map((x) => { - const m = /^(.*?)(?::(\d{3}))?$/.exec(x); - return { family: m[1].trim(), weight: m[2] ? parseInt(m[2], 10) : 400 }; - }); -} - -/** Render `text` in each candidate at a font-size whose measured cap height ~= targetCapPx; return fingerprints. */ -export async function renderCandidates(candidates, text, targetCapPx, { transform = 'none' } = {}) { - const b = await loadBrowser(); - if (!b) return null; - // A resolvable module whose browser binary is absent (CI, a fresh install - // without npx playwright install) throws at launch; that is the same - // situation as no module, and the catalog fallback owns it. - let browser; - try { browser = b.kind === 'playwright' ? await b.mod.chromium.launch() : await b.mod.launch({ headless: true }); } catch { return null; } - // One stylesheet per family+weight: a combined request 400s when any one - // family lacks the requested axis (Anton has no wght range), and a static - // family answers only for the weights it ships. - const links = candidates.map((c) => ``).join(''); - const html = `${links}`; - const results = []; - const size0 = Math.max(12, Math.round(targetCapPx * 1.4)); - if (b.kind === 'playwright') { - const page = await browser.newPage({ viewport: { width: 1600, height: 400 }, deviceScaleFactor: 1 }); - await page.setContent(html, { waitUntil: 'load' }); - await page.waitForTimeout(800); - for (const c of candidates) { - // two passes: measure at size0, then rescale so the fingerprint's cap height matches the comp - let size = size0, fp = null, ok = true; - for (let pass = 0; pass < 2; pass++) { - await page.evaluate(({ family, weight, size, text }) => { - document.body.innerHTML = `
${text}
`; - }, { family: c.family, weight: c.weight, size, text }); - let loaded = false; - // Loaded means a real face of this family covers the requested weight; - // fonts.check() answers true for a synthetic bold of a lighter file. - try { - loaded = await page.evaluate(async (f) => { - const faces = await document.fonts.load(`${f.weight} 32px '${f.family}'`); - await document.fonts.ready; - const covers = (face) => { const w = String(face.weight || '400').split(/\s+/).map(Number); const lo = w[0], hi = w[1] ?? w[0]; return f.weight >= lo - 50 && f.weight <= hi + 50; }; - return faces.some((face) => face.family.replace(/["']/g, '') === f.family && face.status === 'loaded' && covers(face)); - }, c); - } catch { loaded = false; } - await page.waitForTimeout(100); - if (!loaded) ok = false; - const box = await page.evaluate(() => { const r = document.querySelector('div.s').getBoundingClientRect(); return { w: Math.ceil(r.width) + 8, h: Math.ceil(r.height) + 8 }; }); - const buf = await page.screenshot({ clip: { x: 0, y: 0, width: Math.min(1600, box.w), height: Math.min(400, box.h) } }); - fp = fingerprint(decodePng(buf)); - if (!fp || pass === 1) break; - size = Math.max(8, Math.round(size * (targetCapPx / fp.capHeightPx))); - } - results.push({ ...c, loaded: ok, fontSizePx: size, fp }); - } - await browser.close(); - } else { - const page = await browser.newPage(); - await page.setViewport({ width: 1600, height: 400 }); - await page.setContent(html, { waitUntil: 'load' }); - await new Promise((r) => setTimeout(r, 800)); - for (const c of candidates) { - let size = size0, fp = null, ok = true; - for (let pass = 0; pass < 2; pass++) { - await page.evaluate(({ family, weight, size, text }) => { - document.body.innerHTML = `
${text}
`; - }, { family: c.family, weight: c.weight, size, text }); - let loaded = false; - // Loaded means a real face of this family covers the requested weight; - // fonts.check() answers true for a synthetic bold of a lighter file. - try { - loaded = await page.evaluate(async (f) => { - const faces = await document.fonts.load(`${f.weight} 32px '${f.family}'`); - await document.fonts.ready; - const covers = (face) => { const w = String(face.weight || '400').split(/\s+/).map(Number); const lo = w[0], hi = w[1] ?? w[0]; return f.weight >= lo - 50 && f.weight <= hi + 50; }; - return faces.some((face) => face.family.replace(/["']/g, '') === f.family && face.status === 'loaded' && covers(face)); - }, c); - } catch { loaded = false; } - await new Promise((r) => setTimeout(r, 100)); - if (!loaded) ok = false; - const box = await page.evaluate(() => { const r = document.querySelector('div.s').getBoundingClientRect(); return { w: Math.ceil(r.width) + 8, h: Math.ceil(r.height) + 8 }; }); - const buf = await page.screenshot({ clip: { x: 0, y: 0, width: Math.min(1600, box.w), height: Math.min(400, box.h) } }); - fp = fingerprint(decodePng(buf)); - if (!fp || pass === 1) break; - size = Math.max(8, Math.round(size * (targetCapPx / fp.capHeightPx))); - } - results.push({ ...c, loaded: ok, fontSizePx: size, fp }); - } - await browser.close(); - } - return results; -} - -/** Comp crop over the top candidates, rendered at the comp's cap height, as one PNG. */ -export async function renderProofSheet(compCrop, top, text, capPx, transform = 'none') { - const b = await loadBrowser(); - if (!b || b.kind !== 'playwright') return null; - const compB64 = Buffer.from(encodePng(compCrop)).toString('base64'); - const links = top.map((c) => ``).join(''); - const rowsHtml = top.map((c) => `
${c.family} ${c.weight} · ${c.fontSizePx}px
${text}
`).join(''); - const html = `${links}
COMP
${rowsHtml}`; - let browser; - try { browser = await b.mod.chromium.launch(); } catch { return null; } - const page = await browser.newPage({ viewport: { width: Math.min(1600, Math.max(600, compCrop.width + 24)), height: 200 } }); - await page.setContent(html, { waitUntil: 'load' }); - try { await page.evaluate(async () => { await document.fonts.ready; }); } catch { /* ignore */ } - await page.waitForTimeout(600); - const buf = await page.screenshot({ fullPage: true }); - await browser.close(); - return buf; -} - -// ---- CLI ------------------------------------------------------------------ - -function describe(fp) { - const wm = widthMeasure(fp), wt = weightMeasure(fp); - const wmS = wm ? ` (${wm.key} ${wm.value})` : ''; - const wtS = wt ? ` (${wt.key} ${wt.value})` : ''; - return `capHeight ${fp.capHeightPx}px, width ${widthClass(fp)}${wmS}, weight ${weightClass(fp)}${wtS}, tracking ${fp.gap}${fp.allCaps ? ', all caps' : ''}`; -} - -/** Fingerprint fields the spec keeps for a region: the class-bearing features plus the shape summary, not the whole vector. */ -function compactFp(fp) { - if (!fp) return fp; - const keep = ['lines', 'glyphs', 'capHeightPx', 'inkIsDark', 'allCaps', 'advance', 'advTall', 'advX', 'gap', 'xRatio', 'stemW', 'contrast', 'serif', 'densTall', 'densX', 'weight']; - const out = {}; - for (const k of keep) if (fp[k] !== undefined) out[k] = fp[k]; - return out; -} - -async function main() { - const specPath = arg('spec', SPEC_PATH); - const spec = loadSpec(specPath); - const measureId = arg('measure'), rankId = arg('rank'); - const id = measureId || rankId; - if (!id) { - console.error('usage: font-match.mjs --measure | --rank [--candidates "Family:700,Family2:400,..."] [--text "..."] [--transform uppercase] [--category sans,serif,display,handwriting,mono]'); - process.exit(1); - } - if (!spec) { console.error(`font-match: no spec at ${specPath}; run comp-spec.mjs first`); process.exit(1); } - const region = spec.regions.find((r) => r.id === id); - if (!region) { console.error(`font-match: no region ${id}; ids: ${spec.regions.map((r) => r.id).join(', ')}`); process.exit(1); } - const comp = loadRaster(spec.comp).image; - const c = crop(comp, region.px.x, region.px.y, region.px.w, region.px.h); - const fp = fingerprint(c); - if (!fp) { - // Record the attempt so the spec gate does not ask again; a region with - // no separable glyphs (a rule, a bar of solid ink, a very small label at - // comp resolution) is measured as "no lettering" and the model sizes it - // by its box. - region.type = { ...(region.type || {}), comp: null, measuredAt: new Date().toISOString(), note: 'no separable lettering in the crop; size by the region box' }; - fs.writeFileSync(specPath, JSON.stringify(spec, null, 2)); - console.log(`MEASURE ${id}: no separable lettering in the region crop at comp resolution; size this text by its box (${region.px.w}x${region.px.h}px) and inherit face and weight from the nearest measured region.`); - process.exit(0); - } - region.type = { ...(region.type || {}), comp: compactFp(fp), widthClass: widthClass(fp), weightClass: weightClass(fp) }; - fs.writeFileSync(specPath, JSON.stringify(spec, null, 2)); - console.log(`MEASURE ${id}: ${describe(fp)} over ${fp.lines} line${fp.lines === 1 ? '' : 's'}, ${fp.glyphs} glyphs. Set this region's font-size so its cap height renders at ${fp.capHeightPx}px; choose a ${widthClass(fp)} ${weightClass(fp)} face.`); - if (!rankId) return; - if (fp.capHeightPx < MIN_RANK_CAP_PX) { - console.log(`RANK skipped: cap height ${fp.capHeightPx}px is under ${MIN_RANK_CAP_PX}px, too small at comp resolution for a face fingerprint to mean anything. Size this text by its box (${region.px.w}x${region.px.h}px) and inherit face and weight from the nearest measured region.`); - return; - } - const own = parseCandidates(arg('candidates')); - const index = loadFontIndex(); - const { candidates, catalog, source } = selectCandidates(fp, { own, index, n: 25, category: arg('category') }); - if (index) { - const top5 = []; for (const h of catalog) { if (!top5.some((t) => t.family === h.family)) top5.push(h); if (top5.length >= 5) break; } - console.log(`CATALOG top-5 by fingerprint: ${top5.map((t) => `${t.family}:${t.weight}`).join(', ')} (from ${index.entries.length} indexed faces${catalog[0] ? `, ${catalog[0].size}px index` : ''}${arg('category') ? `, category ${arg('category')}` : ''})`); - console.log(`CANDIDATES ${candidates.length}: ${own.length} yours + ${candidates.length - own.length} nearest in the catalog index`); - } else { - console.log(`CANDIDATES ${candidates.length}: ${own.length} yours + ${candidates.length - own.length} from the ${widthClass(fp)} shortlist (no catalog index at data/font-index.json)`); - } - const text = arg('text') || region.text || 'The manuals stop. The forum keeps going.'; - const transform = arg('transform', fp.allCaps ? 'uppercase' : 'none'); - const results = await renderCandidates(candidates, text, fp.capHeightPx, { transform }); - if (!results) { - // No browser: the catalog fingerprint index is the ranking. Its top hit is - // recorded as the chosen face (source `catalog`) so the spec gate has a - // measured choice to close on; without this the gate refused forever and - // sessions forced past it or spent ten turns installing Playwright. - // font-size is estimated from the cap height at a 0.70 cap/em ratio, the - // sans display median; the NOTE says to check one rendered word. - if (index && catalog[0]) { - const best = catalog[0]; - const fontSizePx = Math.round(fp.capHeightPx / 0.70); - console.log(`RANK unavailable: no browser (playwright or puppeteer) resolvable from this project or the impeccable CLI; the CATALOG order stands as the ranking.`); - console.log(`USE font-family: '${best.family}'; font-weight: ${best.weight}; font-size: ${fontSizePx}px;${transform !== 'none' ? ` text-transform: ${transform};` : ''} NOTE font-size is estimated (cap ${fp.capHeightPx}px / 0.70); render one headline word at that size, compare its cap height to the comp crop, and correct the size before building on it.`); - region.type.chosen = stampChoice(id, { family: best.family, weight: best.weight, fontSizePx, source: 'catalog', estimatedSize: true }); - fs.writeFileSync(specPath, JSON.stringify(spec, null, 2)); - return; - } - console.log(`RANK unavailable: no browser (playwright or puppeteer) resolvable from this project or the impeccable CLI, and no catalog index. Choose by the MEASURE line: match the width class first, then the weight class; render one headline word against the comp before building on it.`); - return; - } - // Drop faces that never loaded (a weight the family does not ship falls - // back to a system face and would rank as that face), then collapse - // duplicate renders (two requested weights that resolved to one file). - const seenFp = new Set(); - const rows = results - .filter((r) => r.fp && r.loaded) - .map((r) => ({ ...r, d: distance(fp, r.fp) })) - .filter((r) => Number.isFinite(r.d)) - .sort((a, b) => a.d - b.d) - .filter((r) => { const k = `${r.family}|${r.fp.advX}|${r.fp.advTall}|${r.fp.densTall}|${r.fp.stemW}`; if (seenFp.has(k)) return false; seenFp.add(k); return true; }); - const dropped = results.filter((r) => !r.loaded).map((r) => `${r.family}:${r.weight}`); - if (dropped.length) console.log(`SKIPPED (not available at that weight on Google Fonts): ${dropped.join(', ')}`); - const wm = widthMeasure(fp), wt = weightMeasure(fp); - const pctDelta = (m, other) => { if (!m || other?.[m.key] == null) return null; return (other[m.key] - m.value) / m.value; }; - const fmtPct = (v) => (v == null ? 'n/a' : `${v >= 0 ? '+' : ''}${(v * 100).toFixed(0)}%`); - for (const r of rows) { - console.log(`RANK ${r.family}:${r.weight} distance ${r.d.toFixed(3)} width ${widthClass(r.fp)} (${fmtPct(pctDelta(wm, r.fp))} ${wm?.key || 'advance'}) weight ${weightClass(r.fp)} (${fmtPct(pctDelta(wt, r.fp))} ${wt?.key || 'ink'}) font-size ${r.fontSizePx}px for cap ${fp.capHeightPx}px`); - } - // proof sheet: comp crop over the top three renders, so the choice is seen, not only scored - try { - const top = rows.slice(0, 3); - const sheet = await renderProofSheet(c, top, text, fp.capHeightPx, transform); - if (sheet) { - const out = path.join(path.dirname(specPath), 'font-match', `${id}.png`); - fs.mkdirSync(path.dirname(out), { recursive: true }); - fs.writeFileSync(out, sheet); - console.log(`PROOF ${out} (comp crop, then the top ${top.length} candidates at the comp's cap height; open it before choosing)`); - } - } catch { /* proof sheet is best-effort */ } - const best = rows[0]; - if (best) { - const advice = []; - const dw = pctDelta(wm, best.fp), dwt = pctDelta(wt, best.fp); - if (dw != null && Math.abs(dw) > 0.1) advice.push(dw > 0 ? 'still too wide: try a more condensed face or a variable font with a wdth axis' : 'still too narrow: try a wider face'); - // a weight step only helps on a family that ships one; a single-cut display face is what it is - const bestEntry = index?.entries.find((e) => e.family === best.family); - const variable = bestEntry ? bestEntry.variable : true; - if (dwt != null && Math.abs(dwt) > 0.15 && variable) advice.push(dwt > 0 ? `too heavy: drop to weight ${Math.max(100, best.weight - 200)}` : `too light: raise to weight ${Math.min(900, best.weight + 200)}`); - console.log(`USE font-family: '${best.family}'; font-weight: ${best.weight}; font-size: ${best.fontSizePx}px;${transform !== 'none' ? ` text-transform: ${transform};` : ''}${advice.length ? ' NOTE ' + advice.join('; ') : ''}`); - region.type.chosen = stampChoice(id, { family: best.family, weight: best.weight, fontSizePx: best.fontSizePx, source, fp: compactFp(best.fp) }); - fs.writeFileSync(specPath, JSON.stringify(spec, null, 2)); - } -} - -const isMain = (() => { - try { return !!process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } - catch { return false; } -})(); -if (isMain) main().catch((e) => { console.error(`font-match: ${e.message}`); process.exit(1); }); diff --git a/skill/scripts/lib/font-fingerprint.mjs b/skill/scripts/lib/font-fingerprint.mjs deleted file mode 100644 index 6d25a26bd..000000000 --- a/skill/scripts/lib/font-fingerprint.mjs +++ /dev/null @@ -1,564 +0,0 @@ -/** - * font-fingerprint: size-invariant, text-robust shape features for lettering - * in a raster (a comp crop or a rendered sample). fingerprint(img) returns the - * feature vector; distance(a, b) compares two vectors over noise-normalized, - * weighted features. Used by font-match.mjs (comp measurement and ranking) - * and by the catalog index build (scripts/build-font-index.mjs at the repo root). Depends only on - * lib/image-metrics.mjs and lib/raster.mjs. - * - * Every measure is taken per text line and normalized by R, the line's - * reference height (median of the tallest column heights above the baseline: - * the cap line on an all-caps line, the ascender line on a mixed line), so - * the same face gives the same numbers at any point size; per-glyph measures - * are medians so the numbers survive a change of text. Small crops are - * upsampled (bilinear) so R is at least 24px, and stroke runs are measured - * with antialiased edge pixels counted by coverage, so stem widths do not - * fatten at small sizes. - * - * Features (all in R units unless noted; null when not measurable): - * advance/advTall/advX median glyph width over baseline glyphs / tall glyphs / x-height glyphs - * advCV spread of glyph widths (std/median): mono ~0.15, sans ~0.3, script > 0.5 - * gap median inter-glyph gap - * xRatio x-line / R (null on all-caps lines) - * descRatio descender depth (90th pct) - * stemW median horizontal ink run in the x band (stem width) - * contrast stem width / median thin (vertical) run: didone high, grotesque ~1 - * serif foot width / mid-stem width on stems that reach the baseline - * roundFrac fraction of glyphs with bbox aspect > 0.9 - * densTall / densX ink / bbox area for tall / x-height glyphs (weight) - * runDensity horizontal ink runs per row per R of line width (stroke busyness) - * vprof0..9 normalized vertical ink profile from 0.35R below baseline to 1.05R above - * hrun25/50/75/90 quantiles of horizontal run lengths over the letter body - * vrun25/50/75/90 quantiles of vertical run lengths over the whole line - * colq25/75 quantiles of column heights above the baseline - * wq25/75 quantiles of glyph widths - * Also returned: lines, glyphs, capHeightPx (R in source pixels), allCaps, inkIsDark, - * upsampled, weight (densTall, so v1 callers keep a weight field). - */ -import { toGray } from './image-metrics.mjs'; -import { resize } from './raster.mjs'; - -function otsu(gray) { - const hist = new Float64Array(256); - for (let i = 0; i < gray.data.length; i++) hist[Math.max(0, Math.min(255, Math.round(gray.data[i])))]++; - const total = gray.data.length; - let sum = 0; for (let i = 0; i < 256; i++) sum += i * hist[i]; - let sumB = 0, wB = 0, best = 0, thr = 128; - for (let t = 0; t < 256; t++) { - wB += hist[t]; if (!wB) continue; - const wF = total - wB; if (!wF) break; - sumB += t * hist[t]; - const mB = sumB / wB, mF = (sum - sumB) / wF; - const between = wB * wF * (mB - mF) ** 2; - if (between > best) { best = between; thr = t; } - } - return thr; -} - -const med = (a) => { if (!a.length) return null; const s = [...a].sort((p, q) => p - q); const m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; }; -const pct = (a, p) => { if (!a.length) return null; const s = [...a].sort((p, q) => p - q); return s[Math.min(s.length - 1, Math.floor(p * s.length))]; }; -const mean = (a) => (a.length ? a.reduce((s, x) => s + x, 0) / a.length : null); - -/** Binarize; returns { W, H, ink: Uint8Array, inkIsDark }. */ -function binarize(img) { - const g = toGray(img); - let thr = otsu(g); - let dark = 0; for (let i = 0; i < g.data.length; i++) if (g.data[i] < thr) dark++; - // a two-level raster (no antialiasing) puts the Otsu threshold on the dark - // level itself; step it up so that level counts as ink - if (!dark) { thr += 1; for (let i = 0; i < g.data.length; i++) if (g.data[i] < thr) dark++; } - const inkIsDark = dark <= g.data.length / 2; - const ink = new Uint8Array(g.data.length); - let sI = 0, nI = 0, sG = 0, nG = 0; - for (let i = 0; i < g.data.length; i++) { - const on = (inkIsDark ? g.data[i] < thr : g.data[i] >= thr) ? 1 : 0; - ink[i] = on; - if (on) { sI += g.data[i]; nI++; } else { sG += g.data[i]; nG++; } - } - const inkLevel = nI ? sI / nI : (inkIsDark ? 0 : 255), groundLevel = nG ? sG / nG : (inkIsDark ? 255 : 0); - // coverage per pixel: 0 = ground, 1 = ink, linear between the two class means, so - // antialiased edge pixels count fractionally and stroke widths do not fatten at small sizes - const covA = new Float32Array(g.data.length); - const den = groundLevel - inkLevel || 1; - for (let i = 0; i < g.data.length; i++) covA[i] = Math.max(0, Math.min(1, (groundLevel - g.data[i]) / den)); - const cov = (i) => covA[i]; - return { W: g.width, H: g.height, ink, inkIsDark, cov, covA }; -} - -/** Text lines from the row-ink profile (same rules as font-match v1). */ -function findLines(bin) { - const { W, H, ink } = bin; - // Columns inked top to bottom (a rule, a black margin, a page edge) span - // every line and would fuse them into one run: leave them out of the row - // profile. Lettering never fills a column for more than ~85% of the crop. - const colInk = new Uint32Array(W); - for (let y = 0; y < H; y++) { const o = y * W; for (let x = 0; x < W; x++) colInk[x] += ink[o + x]; } - const colOk = new Uint8Array(W); - let okCount = 0; - for (let x = 0; x < W; x++) { if (colInk[x] < H * 0.85) { colOk[x] = 1; okCount++; } } - if (!okCount) return { lines: [], rowInk: new Uint32Array(H) }; - const rowInk = new Uint32Array(H); - for (let y = 0; y < H; y++) { let c = 0; const o = y * W; for (let x = 0; x < W; x++) if (colOk[x]) c += ink[o + x]; rowInk[y] = c; } - const floor = Math.max(1, W * 0.004); - const runs = []; - let y = 0; - while (y < H) { - if (rowInk[y] > floor) { - const y0 = y; while (y < H && (rowInk[y] > floor || (y + 1 < H && rowInk[y + 1] > floor))) y++; - if (y - y0 >= 4) runs.push({ y0, y1: y }); - } else y++; - } - const lines = []; - for (const run of runs) { - let peak = 0; for (let yy = run.y0; yy < run.y1; yy++) peak = Math.max(peak, rowInk[yy]); - const valley = peak * 0.15; - let start = run.y0, inValley = false, valleyStart = 0; - for (let yy = run.y0; yy < run.y1; yy++) { - const low = rowInk[yy] < valley; - if (low && !inValley) { inValley = true; valleyStart = yy; } - if (!low && inValley) { - inValley = false; - if (yy - valleyStart >= 3 && valleyStart - start >= 4) { lines.push({ y0: start, y1: valleyStart, run }); start = yy; } - } - } - if (run.y1 - start >= 4) lines.push({ y0: start, y1: run.y1, run }); - } - // A piece split off inside one run with a fraction of the ink of the text - // lines is not a line: a thin band of ascenders or tittles above the x band - // (few letters reach it, so the valley rule fires) or a stray rule. Ascender - // bands merge back into the line below them; anything else is dropped. - for (const ln of lines) { let m = 0; for (let yy = ln.y0; yy < ln.y1; yy++) m += rowInk[yy]; ln.mass = m; } - // A drawing or photo sharing the crop with body copy is one tall, massive - // 'line' that would carry maxMass and drop every real line under the 30% - // rule (a 461x307 thread crop measured as one 160px 'cap' off a - // 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. - // 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; - } - const maxMass = Math.max(0, ...lines.filter((l) => !l.tall).map((l) => l.mass)); - const merged = []; - for (let i = 0; i < lines.length; i++) { - const ln = lines[i]; - if (ln.tall) continue; - if (ln.mass >= maxMass * 0.3) { merged.push({ y0: ln.y0, y1: ln.y1, mass: ln.mass }); continue; } - const next = lines[i + 1]; - if (next && next.run === ln.run && next.mass >= maxMass * 0.3 && (ln.y1 - ln.y0) <= (next.y1 - next.y0) * 0.5) { next.y0 = ln.y0; } - } - return { lines: merged, rowInk }; -} - -/** Feature names in fingerprint order (used by distance). */ -const VBINS = 10, HQ = [0.25, 0.5, 0.75, 0.9]; -export const FEATURES = ['advance', 'advTall', 'advX', 'advCV', 'gap', 'xRatio', 'descRatio', 'stemW', 'contrast', 'serif', 'roundFrac', 'densTall', 'densX', 'runDensity', - ...Array.from({ length: VBINS }, (_, i) => `vprof${i}`), ...HQ.map((q) => `hrun${Math.round(q * 100)}`), ...HQ.map((q) => `vrun${Math.round(q * 100)}`), 'colq25', 'colq75', 'wq25', 'wq75']; - -/** Center of the densest window of width tol in a list of values, and its count. */ -function modeOf(vals, tol) { - let best = null, bestC = -1; - const s = [...vals].sort((a, b) => a - b); - let j = 0; - for (let i = 0; i < s.length; i++) { - while (s[i] - s[j] > tol) j++; - const c = i - j + 1; - if (c > bestC) { bestC = c; best = (s[i] + s[j]) / 2; } - } - return { v: best, n: bestC }; -} - -/** - * Per-line vertical metrics from column extrema, which do not need glyphs to - * be separable. baseline = mode of column bottoms. R (the reference height) - * is the top line of the tallest cluster: the cap line on an all-caps line, - * the ascender line (or the cap line when caps are taller) on a mixed line. - * The x-line is a second mode of column heights well below R; when there is - * none the line is read as all-caps. - */ -function lineMetrics(bin, ln) { - const { W, ink, cov } = bin; - const cols = []; - for (let x = 0; x < W; x++) { - let top = -1, bot = -1; - for (let yy = ln.y0; yy < ln.y1; yy++) if (ink[yy * W + x]) { if (top < 0) top = yy; bot = yy + 1; } - if (top < 0) continue; - // sub-pixel edges from the antialiased boundary pixel's coverage - const t = top > 0 ? top - cov((top - 1) * W + x) : top; - const b = bot < bin.H ? bot + cov(bot * W + x) : bot; - cols.push({ x, top: t, bot: b }); - } - if (cols.length < 8) return null; - const roughH = pct(cols.map((c) => c.bot - c.top), 0.9); - const tol = Math.max(1, Math.round(roughH * 0.04)); - const baseF = modeOf(cols.map((c) => c.bot), tol).v; - const base = Math.round(baseF); - const hs = cols.filter((c) => c.bot <= baseF + tol * 1.5).map((c) => baseF - c.top).filter((h) => h > 0); - if (hs.length < 8) return null; - const hMaxAbs = pct(hs, 0.995); - const topCluster = hs.filter((h) => h >= hMaxAbs * 0.94); - const R = med(topCluster); - if (!R || R < 4) return null; - const lowHs = hs.filter((h) => h >= R * 0.3 && h <= R * 0.86); - let xh = null; - if (lowHs.length >= Math.max(6, hs.length * 0.12)) { - const m = modeOf(lowHs, tol); - if (m.n >= Math.max(4, lowHs.length * 0.25)) xh = m.v; - } - const dsc = cols.filter((c) => c.bot > baseF + tol * 1.5 && c.top < baseF - R * 0.3).map((c) => (c.bot - baseF) / R); - const descRatio = dsc.length >= 4 ? pct(dsc, 0.9) : null; - return { base, R, cap: R, xh, descRatio, tol, xL: cols[0].x, xR: cols[cols.length - 1].x + 1, hs, ln }; -} - -/** Glyph boxes: column runs of ink inside the x band, so ascender/descender bridges do not merge letters. */ -function segment(bin, ln, m) { - const { W, ink } = bin; - const bandTop = Math.max(ln.y0, Math.round(m.base - (m.xh || m.cap * 0.6))); - const bandH = m.base - bandTop; - const thr = 1; - const colBand = new Uint32Array(W); - for (let yy = bandTop; yy < m.base; yy++) { const o = yy * W; for (let x = m.xL; x < m.xR; x++) colBand[x] += ink[o + x]; } - const runs = []; - let x = m.xL; - while (x < m.xR) { - if (colBand[x] >= thr) { const x0 = x; while (x < m.xR && colBand[x] >= thr) x++; runs.push({ x0, x1: x }); } else x++; - } - const out = []; - for (const r of runs) { - let top = -1, bot = -1, area = 0; - for (let yy = ln.y0; yy < ln.y1; yy++) { - let c = 0, cv = 0; const o = yy * W; for (let xx = r.x0; xx < r.x1; xx++) { c += ink[o + xx]; cv += bin.covA[o + xx]; } - if (c) { if (top < 0) top = yy; bot = yy + 1; } - area += cv; - } - if (top >= 0) out.push({ x0: r.x0, x1: r.x1, w: r.x1 - r.x0, top, bot, h: bot - top, area }); - } - return out; -} - -function measure(bin, lines) { - const { W, H, ink, covA } = bin; - // run lengths with the antialiased edge pixels counted by coverage - const hLen = (o, x0, x1) => { let s = 0; for (let x = Math.max(0, x0 - 1); x < Math.min(W, x1 + 1); x++) s += covA[o + x]; return s; }; - const vLen = (x, y0, y1) => { let s = 0; for (let y = Math.max(0, y0 - 1); y < Math.min(H, y1 + 1); y++) s += covA[y * W + x]; return s; }; - let glyphN = 0; - const per = { xh: [], desc: [], runDensity: [] }; - let allCapsLines = 0; - const vprof = new Float64Array(VBINS); const hruns = [], vruns = [], colHs = [], widths = []; - const advTall = [], advAll = [], advX = [], gaps = [], stems = [], thins = [], serifR = [], round = [], densTall = [], densX = []; - let capSum = 0, capN = 0; - // One crop, one case. In a multi-line all-caps headline one line can grow a - // spurious x-height from crossbars (the A and E arms of "JAPANESE" at 0.32R) - // while its neighbours report none; that line then measures its stems and - // its x band on the crossbar zone. Lines vote: when most lines see no - // x-height, none does. - const metrics = lines.map((ln) => lineMetrics(bin, ln)).filter(Boolean); - if (metrics.length >= 2) { - const withX = metrics.filter((m) => m.xh).length; - if (withX * 2 <= metrics.length) for (const m of metrics) m.xh = null; - } - for (const m of metrics) { - const ln = m.ln; - const { base, cap, xh, tol, xL, xR } = m; - capSum += cap; capN++; - if (xh) per.xh.push(xh / cap); - if (!xh) allCapsLines++; - if (m.descRatio != null) per.desc.push(m.descRatio); - for (const h of m.hs) colHs.push(h / cap); - // vertical ink profile from 0.35R below the baseline to 1.05R above, VBINS bins - for (let yy = ln.y0; yy < ln.y1; yy++) { - const u = (base - yy - 0.5) / cap; // height above baseline in R units - const bi = Math.floor((u + 0.35) / 1.4 * VBINS); - if (bi < 0 || bi >= VBINS) continue; - let c = 0; const o = yy * W; for (let x = xL; x < xR; x++) c += ink[o + x]; - vprof[bi] += c; - } - // horizontal run lengths over the whole line body (x band to cap line), vertical run lengths over all columns - for (let yy = Math.max(ln.y0, Math.round(base - cap)); yy < base; yy++) { - const o = yy * W; let x = xL; - while (x < xR) { if (ink[o + x]) { const x0 = x; while (x < xR && ink[o + x]) x++; hruns.push(hLen(o, x0, x) / cap); } else x++; } - } - for (let x = xL; x < xR; x++) { - let yy = ln.y0; - while (yy < ln.y1) { if (ink[yy * W + x]) { const y0 = yy; while (yy < ln.y1 && ink[yy * W + x]) yy++; vruns.push(vLen(x, y0, yy) / cap); } else yy++; } - } - const gl = segment(bin, ln, m); - const G = gl.filter((g) => g.w >= cap * 0.12 && (base - g.top) >= cap * 0.3); - glyphN += G.length; - const onBase = G.filter((g) => Math.abs(g.bot - base) <= tol * 1.5); - const capG = onBase.filter((g) => base - g.top >= cap * 0.88); - const xs = xh ? onBase.filter((g) => Math.abs(base - g.top - xh) <= Math.max(tol * 1.5, cap * 0.05)) : []; - for (const g of capG) { advTall.push(g.w / cap); densTall.push(g.area / (g.w * g.h)); } - for (const g of xs) { densX.push(g.area / (g.w * g.h)); advX.push(g.w / cap); } - for (const g of onBase) { advAll.push(g.w / cap); widths.push(g.w / cap); round.push(g.w / (base - g.top) > 0.9 ? 1 : 0); } - for (let i = 0; i + 1 < G.length; i++) { const gap = G[i + 1].x0 - G[i].x1; if (gap >= 0 && gap < cap * 0.6) gaps.push(gap / cap); } - const xTop = base - (xh || cap * 0.55); - const bandTop = Math.round(xTop + (base - xTop) * 0.2), bandBot = Math.round(base - (base - xTop) * 0.2); - let runCount = 0, runRows = 0; - for (let yy = bandTop; yy < bandBot; yy++) { - const o = yy * W; let x = xL; runRows++; - while (x < xR) { if (ink[o + x]) { const x0 = x; while (x < xR && ink[o + x]) x++; const L = hLen(o, x0, x); runCount++; if (L < cap * 0.5) stems.push(L / cap); } else x++; } - } - if (runRows) per.runDensity.push((runCount / runRows) / ((xR - xL) / cap)); - for (let x = xL; x < xR; x++) { - let yy = ln.y0; - while (yy < ln.y1) { if (ink[yy * W + x]) { const y0 = yy; while (yy < ln.y1 && ink[yy * W + x]) yy++; const L = vLen(x, y0, yy); if (L < cap * 0.35) thins.push(L / cap); } else yy++; } - } - // serif: stems that run straight to the baseline; foot width vs mid-stem width - const runAt = (yy, x) => { const o = yy * W; if (!ink[o + x]) return 0; let a = x, b = x; while (a > xL && ink[o + a - 1]) a--; while (b + 1 < xR && ink[o + b + 1]) b++; return hLen(o, a, b + 1); }; - const yMid = Math.round(base - cap * 0.4), yHi = Math.round(base - cap * 0.18), yFoot = base - Math.max(1, Math.round(cap * 0.04)); - let x = xL; - while (x < xR) { - let yy = base - 1; if (!ink[yy * W + x]) { x++; continue; } - while (yy > ln.y0 && ink[(yy - 1) * W + x]) yy--; - if (yy > yMid) { x++; continue; } - const x0 = x; x++; while (x < xR && ink[(base - 1) * W + x] && ink[yMid * W + x]) x++; - const xc = Math.round((x0 + x - 1) / 2); - const wMid = runAt(yMid, xc), wHi = runAt(yHi, xc), wFoot = runAt(yFoot, xc); - if (wMid > 0 && wMid < cap * 0.5 && wHi <= wMid * 1.3 && wHi >= wMid * 0.7) serifR.push(wFoot / wMid); - } - } - if (!capN) return null; - const stemW = med(stems), thinW = med(thins); - const advM = med(advAll); - const advSd = advAll.length > 3 ? Math.sqrt(advAll.reduce((s, v) => s + (v - advM) ** 2, 0) / advAll.length) : null; - const vsum = vprof.reduce((s, x) => s + x, 0) || 1; - const extra = {}; - for (let i = 0; i < VBINS; i++) extra[`vprof${i}`] = vprof[i] / vsum; - for (const q of HQ) { extra[`hrun${Math.round(q * 100)}`] = pct(hruns, q); extra[`vrun${Math.round(q * 100)}`] = pct(vruns, q); } - extra.colq25 = pct(colHs, 0.25); extra.colq75 = pct(colHs, 0.75); - extra.wq25 = pct(widths, 0.25); extra.wq75 = pct(widths, 0.75); - return { - ...extra, - capHeightPx: capSum / capN, - glyphs: glyphN, - advance: advM, - advTall: advTall.length ? med(advTall) : null, - advX: advX.length ? med(advX) : null, - advCV: advSd != null && advM ? advSd / advM : null, - gap: gaps.length ? med(gaps) : 0, - xRatio: per.xh.length ? med(per.xh) : null, - descRatio: per.desc.length ? med(per.desc) : null, - allCaps: allCapsLines * 2 > capN, - runDensity: med(per.runDensity), - stemW, - contrast: stemW && thinW ? stemW / thinW : null, - serif: serifR.length >= 3 ? med(serifR) : null, - roundFrac: round.length ? mean(round) : null, - densTall: densTall.length ? med(densTall) : null, - densX: densX.length ? med(densX) : null, - }; -} - -/** - * fingerprint(img) -> features, or null when no lettering is found. Upsamples (bilinear) when the - * cap height is under 24px so runs and edges are measured on finer pixels. - */ -/** - * Keep the dominant lettering in a region crop: the lines whose cap height is - * within `tol` of the tallest, clipped horizontally to their own ink. A comp - * region drawn on a 10x10 grid over-covers: the headline crop carries the - * first line of body copy below it and a slice of the neighbouring column, - * and every one of those small letters pulls stem width, run lengths and the - * x-height vote toward a lighter, wider face. Returns { lines, x0, x1 } in - * the binarized image, or null when nothing survives. - */ -export function isolateDominant(bin, lines, { tol = 0.28 } = {}) { - const ms = lines.map((ln) => ({ ln, m: lineMetrics(bin, ln) })).filter((x) => x.m); - if (!ms.length) return null; - // The dominant class is the one holding most of the ink, not the tallest - // line: a body-copy crop that clips the last line of the headline above it - // is body copy. Cluster caps within tol of each other and pick the cluster - // with the most ink mass; the tallest wins only a tie. - const clusters = []; - for (const x of [...ms].sort((a, b) => b.m.cap - a.m.cap)) { - const c = clusters.find((cl) => Math.abs(cl.cap - x.m.cap) <= cl.cap * tol); - if (c) { c.items.push(x); c.mass += x.ln.mass || 0; } else clusters.push({ cap: x.m.cap, items: [x], mass: x.ln.mass || 0 }); - } - // Mass per line-height, so one heavy display line does not outvote five - // lines of body copy; and a cluster of a single clipped line never wins - // over a cluster of three or more. - for (const c of clusters) { c.rows = c.items.reduce((n, x) => n + (x.ln.y1 - x.ln.y0), 0); c.density = c.mass / Math.max(1, c.rows); c.n = c.items.length; } - clusters.sort((a, b) => { - const aMulti = a.n >= 3, bMulti = b.n >= 3; - if (aMulti !== bMulti) return aMulti ? -1 : 1; - return (b.mass - a.mass) || (b.cap - a.cap); - }); - const keep = clusters[0].items; - const capMax = Math.max(...keep.map((x) => x.m.cap)); - // horizontal extent of the kept lines' tallest ink columns only: a small - // column of body text beside the headline shares its rows but not its height - const { W, ink } = bin; - let x0 = W, x1 = 0; - for (const { ln, m } of keep) { - const top = Math.round(m.base - m.cap * 0.75); - for (let x = m.xL; x < m.xR; x++) { - let tall = false; - for (let y = top; y < m.base && !tall; y++) if (ink[y * W + x]) tall = true; - if (!tall) continue; - // a column is headline ink when a run of at least 0.5 cap of ink stands in it - let run = 0, best = 0; - for (let y = ln.y0; y < ln.y1; y++) { if (ink[y * W + x]) { run++; if (run > best) best = run; } else run = 0; } - if (best >= m.cap * 0.5) { if (x < x0) x0 = x; if (x + 1 > x1) x1 = x + 1; } - } - } - if (x1 <= x0) return null; - // grow the box by half a cap so glyph sides and the tracking gap survive - const pad = Math.round(capMax * 0.5); - return { lines: keep.map((x) => x.ln), x0: Math.max(0, x0 - pad), x1: Math.min(W, x1 + pad), dropped: ms.length - keep.length }; -} - -function maskOutside(bin, x0, x1, lines) { - const { W, H, ink, covA } = bin; - const keepRow = new Uint8Array(H); - for (const ln of lines) for (let y = ln.y0; y < ln.y1; y++) keepRow[y] = 1; - const ink2 = new Uint8Array(ink.length), cov2 = new Float32Array(covA.length); - for (let y = 0; y < H; y++) { - if (!keepRow[y]) continue; - for (let x = x0; x < x1; x++) { const i = y * W + x; ink2[i] = ink[i]; cov2[i] = covA[i]; } - } - return { ...bin, ink: ink2, covA: cov2, cov: (i) => cov2[i] }; -} - -export function fingerprint(img, { minCap = 24, minGlyphs = 3, isolate = true } = {}) { - let bin = binarize(img); - let { lines } = findLines(bin); - if (!lines.length) return null; - let isolated = 0; - if (isolate && lines.length > 1) { - const iso = isolateDominant(bin, lines); - if (iso && (iso.dropped > 0 || iso.x1 - iso.x0 < bin.W * 0.9)) { - bin = maskOutside(bin, iso.x0, iso.x1, iso.lines); - lines = iso.lines; - isolated = iso.dropped; - } - } - let f = measure(bin, lines); - // fewer than minGlyphs separable glyphs is not lettering (a rule, a solid - // bar, one letterform): callers read null as "no separable lettering" - if (!f || f.glyphs < minGlyphs) return null; - let scale = 1; - if (f.capHeightPx < minCap && f.capHeightPx >= 4) { - scale = Math.min(4, Math.ceil(minCap / f.capHeightPx)); - const up = resize(img, img.width * scale, img.height * scale); - bin = binarize(up); - lines = findLines(bin).lines; - // the upsample re-reads the whole crop: isolate again so the clipped - // headline or the drawing does not come back at scale - if (isolate && lines.length > 1) { - const iso2 = isolateDominant(bin, lines); - if (iso2 && (iso2.dropped > 0 || iso2.x1 - iso2.x0 < bin.W * 0.9)) { bin = maskOutside(bin, iso2.x0, iso2.x1, iso2.lines); lines = iso2.lines; isolated = Math.max(isolated, iso2.dropped); } - } - const f2 = lines.length ? measure(bin, lines) : null; - if (f2) f = f2; - else scale = 1; - } - const r = { lines: lines.length, glyphs: f.glyphs, capHeightPx: +(f.capHeightPx / scale).toFixed(1), inkIsDark: bin.inkIsDark, upsampled: scale > 1, allCaps: f.allCaps, isolatedFrom: isolated, weight: f.densTall == null && f.densX == null ? null : +(f.densTall ?? f.densX).toFixed(4) }; - for (const k of FEATURES) r[k] = f[k] == null ? null : +f[k].toFixed(4); - return r; -} - -/** - * Distance normalization fitted on 299 held-out probes (150 at ~30px cap, 149 - * at ~14px, text different from the index text) against a 3,092-entry Google - * Fonts index: std = within-family noise (1.4826 x median |probe - own index - * entry|, floored at 5% of the catalog IQR spread), w = group weight from - * coordinate descent on top-5 family recall. mean is unused by the distance. - */ -export const STATS = { - advance: { std: 0.07648, w: 0 }, - advTall: { std: 0.25331, w: 0 }, - advX: { std: 0.05144, w: 1.5 }, - advCV: { std: 0.0857, w: 1 }, - gap: { std: 0.02668, w: 1 }, - xRatio: { std: 0.02315, w: 1 }, - descRatio: { std: 0.17831, w: 1 }, - stemW: { std: 0.01922, w: 1 }, - contrast: { std: 0.05969, w: 3 }, - serif: { std: 0.31477, w: 0.5 }, - roundFrac: { std: 0.09341, w: 1 }, - densTall: { std: 0.05708, w: 2 }, - densX: { std: 0.07666, w: 0 }, - runDensity: { std: 0.18199, w: 1 }, - vprof0: { std: 0.01178, w: 1 }, - vprof1: { std: 0.01331, w: 1 }, - vprof2: { std: 0.02745, w: 1 }, - vprof3: { std: 0.03046, w: 1 }, - vprof4: { std: 0.01933, w: 1 }, - vprof5: { std: 0.01737, w: 1 }, - vprof6: { std: 0.0336, w: 1 }, - vprof7: { std: 0.03195, w: 1 }, - vprof8: { std: 0.03271, w: 1 }, - vprof9: { std: 0.02951, w: 1 }, - hrun25: { std: 0.01751, w: 1 }, - hrun50: { std: 0.02124, w: 1 }, - hrun75: { std: 0.04503, w: 1 }, - hrun90: { std: 0.06844, w: 1 }, - vrun25: { std: 0.01895, w: 1 }, - vrun50: { std: 0.02405, w: 1 }, - vrun75: { std: 0.06199, w: 1 }, - vrun90: { std: 0.09486, w: 1 }, - colq25: { std: 0.02906, w: 1 }, - colq75: { std: 0.18204, w: 1 }, - wq25: { std: 0.19862, w: 1 }, - wq75: { std: 0.09687, w: 1 }, -}; -export const Z_CLIP = 3; - -/** Weighted L1 over z-scored features; a feature missing on either side is skipped and the weight mass renormalized. */ -/** - * The two readings a designer makes before any detail: how wide, how heavy. - * Width from the advance of tall glyphs (all-caps crops) or x-height glyphs; - * weight from ink density of tall glyphs. Both are on the same scale in the - * comp crop and in a catalog render, so their gap is a plain ratio. Distance - * adds a penalty that grows with the ratio's log: a face 50% wider or 35% - * lighter than the comp cannot rank above one that is right on both, whatever - * its run-length profile says. Weighted like three fine features (the width - * gap and the weight gap each score up to zClip x 1.5). - */ -export function grossGap(a, b) { - const pick = (f, keys) => { for (const k of keys) if (f[k] != null) return { k, v: f[k] }; return null; }; - const wa = pick(a, ['advX', 'advTall', 'advance']), wb = wa ? (b[wa.k] != null ? { k: wa.k, v: b[wa.k] } : null) : null; - const ha = pick(a, ['densTall', 'densX', 'stemW']), hb = ha ? (b[ha.k] != null ? { k: ha.k, v: b[ha.k] } : null) : null; - const gap = (x, y) => (x && y && x.v > 0 && y.v > 0 ? Math.abs(Math.log(y.v / x.v)) : null); - return { width: gap(wa, wb), weight: gap(ha, hb) }; -} - -export const GROSS_STD = { width: 0.12, weight: 0.12 }; // one "step" of width class or weight class, in log ratio -export const GROSS_W = 1.5; - -export function distance(a, b, stats = STATS, { p = 1, zClip = Z_CLIP, gross = true } = {}) { - let d = 0, wsum = 0; - if (gross) { - const g = grossGap(a, b); - for (const k of ['width', 'weight']) { - if (g[k] == null) continue; - const z = Math.min(zClip, g[k] / GROSS_STD[k]); - d += GROSS_W * (p === 1 ? z : z * z); wsum += GROSS_W; - } - } - for (const k of FEATURES) { - const s = stats[k]; if (!s || !s.w) continue; - const av = a[k], bv = b[k]; - if (av == null || bv == null) continue; - const z = Math.min(zClip, Math.abs(av - bv) / s.std); - d += s.w * (p === 1 ? z : z * z); wsum += s.w; - } - if (!wsum) return Infinity; - const v = d / wsum; - return p === 1 ? v : Math.sqrt(v); -} - -/** Debug: per-line metrics (base, R, xh, mode counts) for a raster. */ -export function _debugLines(img) { - const bin = binarize(img); - const { lines } = findLines(bin); - return lines.map((ln) => { const m = lineMetrics(bin, ln); if (!m) return { ln, m: null }; const hs = m.hs.map((h) => +(h / m.R).toFixed(2)).sort((a, b) => a - b); const hist = {}; for (const h of hs) { const b = Math.round(h * 20) / 20; hist[b] = (hist[b] || 0) + 1; } return { y0: ln.y0, y1: ln.y1, base: m.base, R: +m.R.toFixed(1), xh: m.xh && +m.xh.toFixed(1), hist }; }); -} diff --git a/skill/scripts/lib/font-index.mjs b/skill/scripts/lib/font-index.mjs deleted file mode 100644 index 609d0e286..000000000 --- a/skill/scripts/lib/font-index.mjs +++ /dev/null @@ -1,130 +0,0 @@ -/** - * font-index: the fingerprint index of the Google Fonts catalog that - * font-match.mjs --rank uses as its candidate generator, and the pack/unpack - * helpers the release-time build (repo scripts/build-font-index.mjs) shares with it. - * - * File: skill/scripts/data/font-index.json - * { - * schema: 1, - * text: "", - * sizes: [48, 14], // cap heights (px) the catalog was rendered at - * features: [...], // feature names, in vector order (the fitted-nonzero subset of FEATURES) - * categories: ["sans", ...], // category index -> name - * entries: [[family, weight, categoryIdx, variable(0|1), vec48, vec14], ...] - * } - * A vector is a string of 3-char base-36 numbers, one per feature, each the - * feature value x 1000 (three decimals, clipped at 46.655); "___" is null. - * That packing keeps ~3,000 faces x 2 sizes x 33 features under 750 KB on - * disk, which is what makes it shippable inside the skill without gzip. - * - * Two sizes because the fingerprint's features are stable within a factor of - * ~2 in size but not from 48px down to 14px: a crop is routed to the index - * rendered nearer its own cap height (ROUTE_CAP_PX is the boundary). - */ -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { FEATURES, STATS, distance } from './font-fingerprint.mjs'; - -export const INDEX_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'data', 'font-index.json'); -/** Cap heights the catalog is rendered at. `48c` is the same 48px cap in ALL - * CAPS text (schema 2), queried for caps crops; a schema-1 index without it - * routes caps crops to the mixed-case 48 as before. */ -export const INDEX_SIZES = [48, 14, '48c']; -/** Crops with a cap height under this many px query the 14px index. */ -export const ROUTE_CAP_PX = 22; -/** Below this cap height the fingerprint is not trustworthy; callers size by the box instead. */ -export const MIN_RANK_CAP_PX = 10; -export const CATEGORIES = ['sans', 'serif', 'display', 'handwriting', 'mono']; -/** The features the index stores: the ones the fitted distance gives nonzero weight. */ -export const GROSS_FEATURES = ['advance', 'advTall', 'advX', 'densTall', 'densX', 'stemW']; -export const INDEX_FEATURES = FEATURES.filter((k) => (STATS[k] && STATS[k].w > 0) || GROSS_FEATURES.includes(k)); - -const NULL_TOKEN = '___'; -const MAX_Q = 36 ** 3 - 1; - -export function packVector(fp, features = INDEX_FEATURES) { - return features.map((k) => { - const v = fp?.[k]; - if (v == null || !Number.isFinite(v)) return NULL_TOKEN; - return Math.min(MAX_Q, Math.max(0, Math.round(v * 1000))).toString(36).padStart(3, '0'); - }).join(''); -} - -export function unpackVector(s, features = INDEX_FEATURES) { - const out = {}; - for (let i = 0; i < features.length; i++) { - const t = s.slice(i * 3, i * 3 + 3); - out[features[i]] = t === NULL_TOKEN || t.length < 3 ? null : parseInt(t, 36) / 1000; - } - return out; -} - -let cached = null; -/** - * Load and decode the index. Returns null when the file is missing (callers - * fall back to their built-in shortlist). Result: { schema, text, sizes, - * features, entries: [{ family, weight, category, variable, fp: { 48: {...}, 14: {...}|null } }] }. - */ -export function loadFontIndex(file = INDEX_PATH) { - if (cached && cached.file === file) return cached.index; - if (!fs.existsSync(file)) return null; - const raw = JSON.parse(fs.readFileSync(file, 'utf8')); - const features = raw.features || INDEX_FEATURES; - const cats = raw.categories || CATEGORIES; - const sizes = raw.sizes || INDEX_SIZES; - const entries = raw.entries.map((e) => { - const fp = {}; - sizes.forEach((sz, i) => { const v = e[4 + i]; fp[sz] = v ? unpackVector(v, features) : null; }); - return { family: e[0], weight: e[1], category: cats[e[2]] ?? String(e[2]), variable: !!e[3], fp }; - }); - const index = { schema: raw.schema, text: raw.text, sizes, features, entries }; - cached = { file, index }; - return index; -} - -/** Which of the index's cap sizes a crop with this cap height should query. */ -export function routeSize(capHeightPx, sizes = INDEX_SIZES, { allCaps = false } = {}) { - const numeric = sizes.filter((s) => typeof s === 'number').sort((a, b) => a - b); - if (allCaps && capHeightPx >= ROUTE_CAP_PX && sizes.includes('48c')) return '48c'; - return capHeightPx < ROUTE_CAP_PX ? numeric[0] : numeric[numeric.length - 1]; -} - -/** - * The n nearest catalog faces to a comp fingerprint, routed by cap height. - * Returns [{ family, weight, category, variable, d, size }] sorted by distance; - * at most `perFamily` entries of one family so the shortlist spans faces, not weights. - */ -/** - * Faces that are not lettering: barcodes, redaction bars, placeholder "flow" - * text, dingbats, symbol fonts, and effect faces (outlines, shades, glitch, - * pixel, 3D) whose fingerprint lands near heavy condensed text without being - * usable as it. A comp headline never wants them; a caller who does can pass - * them by name in `--candidates`. - */ -export const NON_TEXT_FAMILY = /barcode|^redacted|^flow (block|circular|rounded)|dings|symbols|^bungee (hairline|outline|shade|spice)|^rubik (80s|beastly|broken|bubbles|burned|dirt|distressed|doodle|gemstones|glitch|iso|lines|marker|maze|microbe|moonrocks|pixels|puddles|scribble|spray|storm|vinyl|wet)|^(nabla|honk|kablammo|sixtyfour|workbench|codystar|rock 3d|zen dots|ballet|butcherman|creepster|eater|faster one|frijole|nosifer|metal mania|miltonian)/i; - -export function candidatesFromIndex(fp, index, { n = 25, category = null, perFamily = 2, includeNonText = false } = {}) { - if (!fp || !index) return []; - const size = routeSize(fp.capHeightPx, index.sizes, { allCaps: !!fp.allCaps }); - const wantCat = category ? String(category).split(',').map((s) => s.trim().toLowerCase()).filter(Boolean) : null; - const scored = []; - for (const e of index.entries) { - if (wantCat && !wantCat.includes(e.category)) continue; - if (!includeNonText && NON_TEXT_FAMILY.test(e.family)) continue; - const v = e.fp[size]; - if (!v) continue; - const d = distance(fp, v); - if (!Number.isFinite(d)) continue; - scored.push({ family: e.family, weight: e.weight, category: e.category, variable: e.variable, d, size }); - } - scored.sort((a, b) => a.d - b.d); - const perFam = new Map(); const out = []; - for (const s of scored) { - const c = perFam.get(s.family) || 0; - if (c >= perFamily) continue; - perFam.set(s.family, c + 1); out.push(s); - if (out.length >= n) break; - } - return out; -} diff --git a/skill/scripts/lib/hero-checks.mjs b/skill/scripts/lib/hero-checks.mjs deleted file mode 100644 index 0f7f3fd28..000000000 --- a/skill/scripts/lib/hero-checks.mjs +++ /dev/null @@ -1,246 +0,0 @@ -/** - * 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); - // colour reads on any text region, measured or not: a spine set vertical - // (unmeasurable) came back white on red where the comp had black on red - // in five builds - const colourOnly = () => { - const ca = inkColor(compCrop), cb = inkColor(buildCrop); - if (ca && cb && ca.ink && cb.ink && deltaE(ca.ink.lab, cb.ink.lab) > 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`); - return { findings, metrics: null }; - }; - if (!comp || !comp.capHeightPx || comp.capHeightPx < minCap || comp.glyphs < 6) return colourOnly(); - // 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 colourOnly(); - // a cap taller than half the box is a drawing read as a glyph, not type - if (comp.capHeightPx > compCrop.height * 0.6) return colourOnly(); - 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`); - } else if (comp.lines >= 3 && bfp.lines === comp.lines && Math.abs(capDelta) <= capTol) { - // same lines at the same size: the leading is the remaining shape - const ba0 = inkBox(compCrop), bb0 = inkBox(buildCrop); - if (ba0 && bb0) { - const pa = ba0.h / comp.lines, pb = bb0.h / bfp.lines; - const dp = (pb - pa) / pa; - if (Math.abs(dp) > 0.2) findings.push(`text ${region.id}: line pitch ${Math.round(pb)}px in the build, ${Math.round(pa)}px in the comp (${dp > 0 ? '+' : ''}${Math.round(dp * 100)}%); set line-height so ${comp.lines} lines stand ${Math.round(ba0.h)}px tall`); - } - } - // tracking: the gap between glyphs in cap units, when both sides read it - if (comp.gap != null && bfp.gap != null && Math.abs(capDelta) <= capTol && comp.glyphs >= 8 && bfp.glyphs >= 8) { - const dg = bfp.gap - comp.gap; - if (Math.abs(dg) > Math.max(0.03, comp.gap * 0.5)) findings.push(`text ${region.id}: letter-spacing is ${dg > 0 ? 'wider' : 'tighter'} than the comp's (gap ${bfp.gap.toFixed(3)} vs ${comp.gap.toFixed(3)} of the cap height); set letter-spacing to ${dg > 0 ? 'close' : 'open'} it by about ${Math.abs(Math.round(dg * comp.capHeightPx))}px`); - } - // 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. Small type on a ruled or grainy - // ground (a track row across staff lines at cap 14) has no reliable ink - // cluster; the reading fired both ways on neighbouring rows of one list. - if (comp.capHeightPx >= 16) { - 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 }; - // a control that is one link or one button, not a bar across its box, has - // no strip height to compare (its underline read as a 'rule' for 27 - // attempts in one session) - if (region.kind === 'control') { - const ib = inkBox(compCrop); - if (!ib || ib.w < compCrop.width * 0.6) 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) }; -} - -/** - * 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 }; -} - -/** - * 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 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 = /]*)>([\s\S]*?)<\/svg>/gi; - let m; - while ((m = re.exec(html))) { - const attrs = m[1], body = m[2]; - const paths = (body.match(/ 0 && long <= iconPx && paths <= maxPaths; - const uses = / pathBudget || paths > maxPaths || (long > iconPx && paths > 0)) { - const id = /\b(id|class|aria-label|data-region)="([^"]+)"/i.exec(attrs); - out.push({ snippet: ` (${paths} shapes, ${budget} chars of path data${long ? `, ${long}px` : ''})`, label: id ? id[2] : null, paths, budget, long }); - } - } - return out; -} diff --git a/skill/scripts/lib/image-metrics.mjs b/skill/scripts/lib/image-metrics.mjs deleted file mode 100644 index baa5c95a1..000000000 --- a/skill/scripts/lib/image-metrics.mjs +++ /dev/null @@ -1,306 +0,0 @@ -/** - * Perceptual measures for comparing a comp with a build screenshot. Pure - * functions over `{ width, height, data }` RGBA images; no I/O. - * - * Three families, because a build fails a comp in three separable ways: - * - * - structure: is the composition the same? Measured as SSIM over a blurred - * grayscale downsample, which forgives font hinting and a few pixels of - * drift and punishes a moved, missing, or invented region. - * - color: is the palette and its distribution the same? Histogram - * intersection in a coarse quantized space plus a dominant-color extraction, - * so a navy page built from a bone comp fails even if the shapes match. - * - detail: is the material there? Local high-frequency energy per cell. A - * comp with an illustration, texture, or photograph carries energy a flat - * CSS stand-in does not; the ratio build/comp per region is the most direct - * measure of "the plate got replaced by a gradient". - */ -import { resize } from './raster.mjs'; - -export function toGray(img) { - const g = new Float32Array(img.width * img.height); - for (let i = 0, p = 0; i < g.length; i++, p += 4) { - const a = img.data[p + 3] / 255; - // composite over white so transparent regions read as the page ground - const r = img.data[p] * a + 255 * (1 - a), gg = img.data[p + 1] * a + 255 * (1 - a), b = img.data[p + 2] * a + 255 * (1 - a); - g[i] = 0.2126 * r + 0.7152 * gg + 0.0722 * b; - } - return { width: img.width, height: img.height, data: g }; -} - -/** Separable box blur on a float gray image, radius r. */ -export function blurGray(gray, r) { - if (r <= 0) return gray; - const { width, height, data } = gray; - const tmp = new Float32Array(data.length), out = new Float32Array(data.length); - const win = 2 * r + 1; - for (let y = 0; y < height; y++) { - let acc = 0; - for (let x = -r; x <= r; x++) acc += data[y * width + Math.min(width - 1, Math.max(0, x))]; - for (let x = 0; x < width; x++) { - tmp[y * width + x] = acc / win; - const outX = x - r, inX = x + r + 1; - acc += data[y * width + Math.min(width - 1, inX)] - data[y * width + Math.max(0, outX)]; - } - } - for (let x = 0; x < width; x++) { - let acc = 0; - for (let y = -r; y <= r; y++) acc += tmp[Math.min(height - 1, Math.max(0, y)) * width + x]; - for (let y = 0; y < height; y++) { - out[y * width + x] = acc / win; - const outY = y - r, inY = y + r + 1; - acc += tmp[Math.min(height - 1, inY) * width + x] - tmp[Math.max(0, outY) * width + x]; - } - } - return { width, height, data: out }; -} - -/** Global SSIM between two same-size gray images using an 8x8 window grid. */ -export function ssim(a, b, win = 8) { - if (a.width !== b.width || a.height !== b.height) throw new Error('ssim: size mismatch'); - const C1 = (0.01 * 255) ** 2, C2 = (0.03 * 255) ** 2; - let total = 0, n = 0; - for (let y = 0; y + win <= a.height; y += win) { - for (let x = 0; x + win <= a.width; x += win) { - let ma = 0, mb = 0; - for (let yy = 0; yy < win; yy++) for (let xx = 0; xx < win; xx++) { const i = (y + yy) * a.width + x + xx; ma += a.data[i]; mb += b.data[i]; } - ma /= win * win; mb /= win * win; - let va = 0, vb = 0, cov = 0; - for (let yy = 0; yy < win; yy++) for (let xx = 0; xx < win; xx++) { const i = (y + yy) * a.width + x + xx; const da = a.data[i] - ma, db = b.data[i] - mb; va += da * da; vb += db * db; cov += da * db; } - va /= win * win - 1; vb /= win * win - 1; cov /= win * win - 1; - total += ((2 * ma * mb + C1) * (2 * cov + C2)) / ((ma * ma + mb * mb + C1) * (va + vb + C2)); - n++; - } - } - return n ? total / n : 1; -} - -/** SSIM of `a` against `b` shifted by (dx, dy); the overlap is compared, edges dropped. */ -export function ssimShifted(a, b, dx, dy, win = 8) { - const w = a.width - Math.abs(dx), h = a.height - Math.abs(dy); - if (w < win || h < win) return 0; - const sa = { width: w, height: h, data: new Float32Array(w * h) }; - const sb = { width: w, height: h, data: new Float32Array(w * h) }; - const ax = Math.max(0, -dx), ay = Math.max(0, -dy), bx = Math.max(0, dx), by = Math.max(0, dy); - for (let y = 0; y < h; y++) { - sa.data.set(a.data.subarray((y + ay) * a.width + ax, (y + ay) * a.width + ax + w), y * w); - sb.data.set(b.data.subarray((y + by) * b.width + bx, (y + by) * b.width + bx + w), y * w); - } - return ssim(sa, sb, win); -} - -/** - * Structure score 0..1: SSIM over blurred grayscale at a fixed working width, - * taking the best of a small translation search so a composition that sits a - * few pixels off (a different masthead height, a scrollbar) is not read as a - * different composition. Shifts up to ~4% of the width are forgiven; a moved - * region is not. - */ -export function structureScore(imgA, imgB, workWidth = 256) { - const h = Math.max(8, Math.round((imgA.height / imgA.width) * workWidth)); - const a = blurGray(toGray(resize(imgA, workWidth, h)), 2); - const b = blurGray(toGray(resize(imgB, workWidth, h)), 2); - const win = Math.min(8, Math.max(2, Math.floor(Math.min(workWidth, h) / 8))); - let best = ssim(a, b, win); - const maxShift = Math.max(2, Math.round(workWidth * 0.04)); - for (const dy of [-maxShift, -maxShift / 2, 0, maxShift / 2, maxShift]) { - for (const dx of [-maxShift, -maxShift / 2, 0, maxShift / 2, maxShift]) { - if (!dx && !dy) continue; - best = Math.max(best, ssimShifted(a, b, Math.round(dx), Math.round(dy), win)); - } - } - return Math.max(0, Math.min(1, best)); -} - -// ---- color ----------------------------------------------------------------- - -function rgbToLab(r, g, b) { - const lin = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }; - const R = lin(r), G = lin(g), B = lin(b); - const X = (R * 0.4124 + G * 0.3576 + B * 0.1805) / 0.95047; - const Y = (R * 0.2126 + G * 0.7152 + B * 0.0722) / 1.0; - const Z = (R * 0.0193 + G * 0.1192 + B * 0.9505) / 1.08883; - const f = (t) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116); - const fx = f(X), fy = f(Y), fz = f(Z); - return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)]; -} - -export function deltaE(lab1, lab2) { - return Math.hypot(lab1[0] - lab2[0], lab1[1] - lab2[1], lab1[2] - lab2[2]); -} - -/** Quantized color histogram (4 bits per channel = 4096 bins), normalized. */ -export function colorHistogram(img, sampleStep = 2) { - const bins = new Float32Array(4096); - let n = 0; - for (let y = 0; y < img.height; y += sampleStep) { - for (let x = 0; x < img.width; x += sampleStep) { - const p = (y * img.width + x) * 4; - if (img.data[p + 3] < 16) continue; - const key = ((img.data[p] >> 4) << 8) | ((img.data[p + 1] >> 4) << 4) | (img.data[p + 2] >> 4); - bins[key]++; n++; - } - } - if (n) for (let i = 0; i < bins.length; i++) bins[i] /= n; - return bins; -} - -export function histogramIntersection(h1, h2) { - let s = 0; - for (let i = 0; i < h1.length; i++) s += Math.min(h1[i], h2[i]); - return s; -} - -/** - * Dominant colors: merge histogram bins greedily by Lab distance into up to - * `k` clusters and return them sorted by coverage. - */ -export function dominantColors(img, k = 6, sampleStep = 3) { - const hist = colorHistogram(img, sampleStep); - const entries = []; - for (let i = 0; i < hist.length; i++) if (hist[i] > 0.0005) entries.push({ key: i, w: hist[i] }); - entries.sort((a, b) => b.w - a.w); - const clusters = []; - for (const e of entries) { - const r = ((e.key >> 8) & 15) * 16 + 8, g = ((e.key >> 4) & 15) * 16 + 8, b = (e.key & 15) * 16 + 8; - const lab = rgbToLab(r, g, b); - let best = null, bestD = Infinity; - for (const c of clusters) { const d = deltaE(c.lab, lab); if (d < bestD) { bestD = d; best = c; } } - if (best && bestD < 14) { - const tw = best.w + e.w; - best.rgb = [(best.rgb[0] * best.w + r * e.w) / tw, (best.rgb[1] * best.w + g * e.w) / tw, (best.rgb[2] * best.w + b * e.w) / tw]; - best.lab = rgbToLab(...best.rgb); best.w = tw; - } else clusters.push({ rgb: [r, g, b], lab, w: e.w }); - } - clusters.sort((a, b) => b.w - a.w); - const top = clusters.slice(0, k); - const covered = top.reduce((s, c) => s + c.w, 0) || 1; - return top.map((c) => ({ hex: toHex(c.rgb), coverage: +(c.w / covered).toFixed(4), lab: c.lab })); -} - -export function toHex(rgb) { - return '#' + rgb.map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join(''); -} - -/** - * Palette match 0..1: for each dominant comp color, coverage-weighted best - * Lab match in the build's dominant set (dE 0 -> 1, dE >= 25 -> 0). - */ -export function paletteMatch(compColors, buildColors) { - if (!compColors.length) return 1; - let s = 0, wsum = 0; - for (const c of compColors) { - let best = Infinity; - for (const b of buildColors) best = Math.min(best, deltaE(c.lab, b.lab)); - s += c.coverage * Math.max(0, 1 - best / 25); wsum += c.coverage; - } - return wsum ? s / wsum : 1; -} - -/** Color score 0..1: blend of histogram intersection and dominant-palette match. */ -export function colorScore(imgA, imgB) { - const inter = histogramIntersection(colorHistogram(imgA), colorHistogram(imgB)); - const pm = paletteMatch(dominantColors(imgA), dominantColors(imgB)); - return { score: 0.35 * inter + 0.65 * pm, intersection: inter, paletteMatch: pm }; -} - -// ---- detail ---------------------------------------------------------------- - -/** Mean absolute gradient (Sobel-lite) per cell over a cols x rows grid. */ -export function detailGrid(img, cols = 12, rows = 8, workWidth = 512) { - const h = Math.max(rows, Math.round((img.height / img.width) * workWidth)); - const g = toGray(resize(img, workWidth, h)); - const grid = new Float32Array(cols * rows); - const counts = new Float32Array(cols * rows); - for (let y = 1; y < g.height - 1; y++) { - const cy = Math.min(rows - 1, Math.floor((y / g.height) * rows)); - for (let x = 1; x < g.width - 1; x++) { - const cx = Math.min(cols - 1, Math.floor((x / g.width) * cols)); - const i = y * g.width + x; - const gx = Math.abs(g.data[i + 1] - g.data[i - 1]); - const gy = Math.abs(g.data[i + g.width] - g.data[i - g.width]); - grid[cy * cols + cx] += gx + gy; counts[cy * cols + cx]++; - } - } - for (let i = 0; i < grid.length; i++) grid[i] = counts[i] ? grid[i] / counts[i] : 0; - return { cols, rows, cells: grid }; -} - -/** - * Detail score 0..1 and per-cell ratio. Cells where the comp is nearly flat - * are ignored (nothing to lose); the score is the coverage-weighted mean of - * min(1, build/comp) over cells with comp energy, so extra detail in the - * build (invented chrome) is reported separately as `added`. - */ -export function detailScore(imgA, imgB, cols = 12, rows = 8) { - const a = detailGrid(imgA, cols, rows), b = detailGrid(imgB, cols, rows); - const floor = 1.5; // energy below this is a flat field at the 512px working width - let s = 0, w = 0, added = 0, addedW = 0; - const ratios = new Float32Array(cols * rows); - for (let i = 0; i < a.cells.length; i++) { - const ca = a.cells[i], cb = b.cells[i]; - ratios[i] = ca > floor ? cb / ca : (cb > floor ? Infinity : 1); - // Signed: too much energy is as wrong as too little. Noise, a tile - // shuffle, or a mosaic saturate a one-sided ratio; a real plate does not. - if (ca > floor) { s += Math.min(cb / ca, ca / cb) * ca; w += ca; } - if (cb > ca * 1.8 && cb > floor * 2) { added += 1; } - addedW += 1; - } - const addedFraction = addedW ? added / addedW : 0; - const raw = w ? s / w : 1; - return { score: Math.max(0, raw - 0.5 * addedFraction), rawScore: raw, addedFraction, comp: a, build: b, ratios }; -} - -// ---- pixel diff ----------------------------------------------------------- - -/** Per-pixel Lab-ish difference map (0..1) at a working width; blurred a little. */ -export function diffMap(imgA, imgB, workWidth = 384) { - const h = Math.max(8, Math.round((imgA.height / imgA.width) * workWidth)); - const a = resize(imgA, workWidth, h), b = resize(imgB, workWidth, h); - const out = new Float32Array(workWidth * h); - for (let i = 0, p = 0; i < out.length; i++, p += 4) { - const dr = a.data[p] - b.data[p], dg = a.data[p + 1] - b.data[p + 1], db = a.data[p + 2] - b.data[p + 2]; - out[i] = Math.min(1, Math.sqrt(dr * dr + dg * dg + db * db) / 200); - } - return blurGray({ width: workWidth, height: h, data: out }, 1); -} - -// ---- bands (horizontal layout structure) --------------------------------- - -/** - * Detect horizontal band boundaries: rows where the mean color changes - * sharply. Returns normalized y positions (0..1) with strengths. This is the - * "layout grid" read of a page: header / hero / index / footer as bands. - */ -export function horizontalBands(img, workWidth = 128, minGap = 0.02) { - const h = Math.max(16, Math.round((img.height / img.width) * workWidth)); - const s = resize(img, workWidth, h); - const rowMean = new Float32Array(h * 3); - for (let y = 0; y < h; y++) { - let r = 0, g = 0, b = 0; - for (let x = 0; x < workWidth; x++) { const p = (y * workWidth + x) * 4; r += s.data[p]; g += s.data[p + 1]; b += s.data[p + 2]; } - rowMean[y * 3] = r / workWidth; rowMean[y * 3 + 1] = g / workWidth; rowMean[y * 3 + 2] = b / workWidth; - } - const edges = []; - for (let y = 1; y < h; y++) { - const d = Math.hypot(rowMean[y * 3] - rowMean[(y - 1) * 3], rowMean[y * 3 + 1] - rowMean[(y - 1) * 3 + 1], rowMean[y * 3 + 2] - rowMean[(y - 1) * 3 + 2]); - if (d > 18) edges.push({ y: y / h, strength: Math.min(1, d / 120) }); - } - // merge close edges - const merged = []; - for (const e of edges) { - const last = merged[merged.length - 1]; - if (last && e.y - last.y < minGap) { if (e.strength > last.strength) { last.y = e.y; last.strength = e.strength; } } - else merged.push({ ...e }); - } - return merged; -} - -/** Band agreement 0..1: fraction of comp bands with a build band within tolerance, and vice versa. */ -export function bandScore(bandsA, bandsB, tol = 0.04) { - if (!bandsA.length && !bandsB.length) return 1; - const match = (from, to) => from.filter((a) => to.some((b) => Math.abs(a.y - b.y) <= tol)).length; - const recall = bandsA.length ? match(bandsA, bandsB) / bandsA.length : 1; - const precision = bandsB.length ? match(bandsB, bandsA) / bandsB.length : 1; - return 0.6 * recall + 0.4 * precision; -} diff --git a/skill/scripts/lib/png.mjs b/skill/scripts/lib/png.mjs deleted file mode 100644 index 1d5130cc2..000000000 --- a/skill/scripts/lib/png.mjs +++ /dev/null @@ -1,281 +0,0 @@ -/** - * Dependency-free PNG decode/encode for the skill scripts. - * - * decodePng(buffer) -> { width, height, data } where data is RGBA8 (Uint8Array, - * width*height*4). Handles every color type (0, 2, 3, 4, 6), bit depths 1-16 - * (16-bit is reduced to 8), all five filters, and Adam7 interlacing. - * - * encodePng({ width, height, data }) -> Buffer, RGBA8 in, 8-bit RGBA PNG out. - * - * Kept small on purpose: the skill scripts ship without npm dependencies, and - * comps (gpt-image PNGs) and screenshots (Playwright / harness PNGs) are the - * only formats the comp-fidelity tooling has to read. - */ -import zlib from 'node:zlib'; -import fs from 'node:fs'; -import { execFileSync } from 'node:child_process'; - -const SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); - -const crcTable = (() => { - const t = new Uint32Array(256); - for (let n = 0; n < 256; n++) { - let c = n; - for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; - t[n] = c >>> 0; - } - return t; -})(); - -function crc32(data) { - let c = 0xffffffff; - for (let i = 0; i < data.length; i++) c = crcTable[(c ^ data[i]) & 0xff] ^ (c >>> 8); - return (c ^ 0xffffffff) >>> 0; -} - -export function isPng(buf) { - return buf && buf.length > 8 && buf.subarray(0, 8).equals(SIGNATURE); -} - -function readChunks(buf) { - const chunks = []; - let pos = 8; - while (pos + 8 <= buf.length) { - const length = buf.readUInt32BE(pos); - const type = buf.toString('latin1', pos + 4, pos + 8); - const data = buf.subarray(pos + 8, pos + 8 + length); - chunks.push({ type, data }); - pos += 12 + length; - if (type === 'IEND') break; - } - return chunks; -} - -const CHANNELS = { 0: 1, 2: 3, 3: 1, 4: 2, 6: 4 }; - -function paeth(a, b, c) { - const p = a + b - c; - const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c); - if (pa <= pb && pa <= pc) return a; - if (pb <= pc) return b; - return c; -} - -/** Unfilter one pass of scanlines in place; returns the raw (unfiltered) bytes. */ -function unfilter(raw, width, height, bpp, bitDepth, channels) { - const stride = Math.ceil((width * channels * bitDepth) / 8); - const out = new Uint8Array(stride * height); - let inPos = 0; - let prev = null; - for (let y = 0; y < height; y++) { - const filter = raw[inPos++]; - const line = out.subarray(y * stride, (y + 1) * stride); - line.set(raw.subarray(inPos, inPos + stride)); - inPos += stride; - switch (filter) { - case 0: break; - case 1: for (let i = bpp; i < stride; i++) line[i] = (line[i] + line[i - bpp]) & 0xff; break; - case 2: if (prev) for (let i = 0; i < stride; i++) line[i] = (line[i] + prev[i]) & 0xff; break; - case 3: - for (let i = 0; i < stride; i++) { - const left = i >= bpp ? line[i - bpp] : 0; - const up = prev ? prev[i] : 0; - line[i] = (line[i] + ((left + up) >> 1)) & 0xff; - } - break; - case 4: - for (let i = 0; i < stride; i++) { - const left = i >= bpp ? line[i - bpp] : 0; - const up = prev ? prev[i] : 0; - const ul = prev && i >= bpp ? prev[i - bpp] : 0; - line[i] = (line[i] + paeth(left, up, ul)) & 0xff; - } - break; - default: throw new Error(`png: unknown filter ${filter} on row ${y}`); - } - prev = line; - } - return { bytes: out, stride, consumed: inPos }; -} - -/** Read sample `index` (0-based across the row) from a packed scanline. */ -function sampleReader(bitDepth) { - if (bitDepth === 8) return (line, i) => line[i]; - if (bitDepth === 16) return (line, i) => line[i * 2]; // high byte - const perByte = 8 / bitDepth; - const mask = (1 << bitDepth) - 1; - const scale = 255 / mask; - return (line, i) => { - const byte = line[(i / perByte) | 0]; - const shift = 8 - bitDepth * ((i % perByte) + 1); - return Math.round(((byte >> shift) & mask) * scale); - }; -} - -function writePixels(dst, dstWidth, bytes, stride, passWidth, passHeight, colorType, bitDepth, palette, trns, mapX, mapY) { - const channels = CHANNELS[colorType]; - const read = sampleReader(bitDepth); - const rawIndex = bitDepth < 8 ? (line, i) => { - const perByte = 8 / bitDepth; - const mask = (1 << bitDepth) - 1; - const byte = line[(i / perByte) | 0]; - const shift = 8 - bitDepth * ((i % perByte) + 1); - return (byte >> shift) & mask; - } : read; - for (let y = 0; y < passHeight; y++) { - const line = bytes.subarray(y * stride, (y + 1) * stride); - const dy = mapY(y); - for (let x = 0; x < passWidth; x++) { - const dx = mapX(x); - const o = (dy * dstWidth + dx) * 4; - let r, g, b, a = 255; - switch (colorType) { - case 0: { - r = g = b = read(line, x); - if (trns && trns.gray === rawIndex(line, x)) a = 0; - break; - } - case 2: { - r = read(line, x * 3); g = read(line, x * 3 + 1); b = read(line, x * 3 + 2); - break; - } - case 3: { - const idx = rawIndex(line, x); - r = palette[idx * 3]; g = palette[idx * 3 + 1]; b = palette[idx * 3 + 2]; - if (trns && trns.alpha && idx < trns.alpha.length) a = trns.alpha[idx]; - break; - } - case 4: { - r = g = b = read(line, x * 2); a = read(line, x * 2 + 1); - break; - } - case 6: { - r = read(line, x * 4); g = read(line, x * 4 + 1); b = read(line, x * 4 + 2); a = read(line, x * 4 + 3); - break; - } - default: throw new Error(`png: unsupported color type ${colorType}`); - } - dst[o] = r; dst[o + 1] = g; dst[o + 2] = b; dst[o + 3] = a; - } - } - return channels; -} - -export function decodePng(buf) { - if (!isPng(buf)) throw new Error('png: not a PNG (bad signature)'); - const chunks = readChunks(buf); - const ihdr = chunks.find((c) => c.type === 'IHDR'); - if (!ihdr) throw new Error('png: missing IHDR'); - const width = ihdr.data.readUInt32BE(0); - const height = ihdr.data.readUInt32BE(4); - const bitDepth = ihdr.data[8]; - const colorType = ihdr.data[9]; - const interlace = ihdr.data[12]; - const channels = CHANNELS[colorType]; - if (!channels) throw new Error(`png: unsupported color type ${colorType}`); - const palChunk = chunks.find((c) => c.type === 'PLTE'); - const palette = palChunk ? palChunk.data : null; - const trnsChunk = chunks.find((c) => c.type === 'tRNS'); - let trns = null; - if (trnsChunk) { - if (colorType === 3) trns = { alpha: trnsChunk.data }; - else if (colorType === 0) trns = { gray: trnsChunk.data.readUInt16BE(0) >> (bitDepth === 16 ? 8 : 0) }; - } - const idat = Buffer.concat(chunks.filter((c) => c.type === 'IDAT').map((c) => c.data)); - const raw = zlib.inflateSync(idat); - const bpp = Math.max(1, Math.ceil((channels * bitDepth) / 8)); - const data = new Uint8Array(width * height * 4); - const text = {}; - for (const c of chunks) { - if (c.type === 'tEXt') { - const z = c.data.indexOf(0); - if (z > 0) text[c.data.toString('latin1', 0, z)] = c.data.toString('utf8', z + 1); - } - } - - if (interlace === 0) { - const { bytes, stride } = unfilter(raw, width, height, bpp, bitDepth, channels); - writePixels(data, width, bytes, stride, width, height, colorType, bitDepth, palette, trns, (x) => x, (y) => y); - } else { - // Adam7 - const passes = [ - [0, 0, 8, 8], [4, 0, 8, 8], [0, 4, 4, 8], [2, 0, 4, 4], [0, 2, 2, 4], [1, 0, 2, 2], [0, 1, 1, 2], - ]; - let offset = 0; - for (const [sx, sy, dx, dy] of passes) { - const pw = Math.ceil((width - sx) / dx); - const ph = Math.ceil((height - sy) / dy); - if (pw <= 0 || ph <= 0) continue; - const { bytes, stride, consumed } = unfilter(raw.subarray(offset), pw, ph, bpp, bitDepth, channels); - offset += consumed; - writePixels(data, width, bytes, stride, pw, ph, colorType, bitDepth, palette, trns, (x) => sx + x * dx, (y) => sy + y * dy); - } - } - return { width, height, data, text }; -} - -function chunk(type, data) { - const len = Buffer.alloc(4); - len.writeUInt32BE(data.length, 0); - const typeBuf = Buffer.from(type, 'latin1'); - const crc = Buffer.alloc(4); - crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0); - return Buffer.concat([len, typeBuf, data, crc]); -} - -/** - * Encode RGBA8 to PNG. `text` (optional) is a map of tEXt keyword -> value. - * Uses filter type 0 on every row: comps and screenshots compress fine and the - * encoder stays trivial. - */ -export function encodePng({ width, height, data }, { text = null, level = 6 } = {}) { - if (data.length !== width * height * 4) throw new Error(`png: data length ${data.length} != ${width}x${height}x4`); - const stride = width * 4; - const raw = Buffer.alloc((stride + 1) * height); - for (let y = 0; y < height; y++) { - raw[y * (stride + 1)] = 0; - raw.set(data.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1); - } - const ihdr = Buffer.alloc(13); - ihdr.writeUInt32BE(width, 0); - ihdr.writeUInt32BE(height, 4); - ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0; - const parts = [SIGNATURE, chunk('IHDR', ihdr)]; - if (text) { - for (const [k, v] of Object.entries(text)) { - parts.push(chunk('tEXt', Buffer.concat([Buffer.from(k, 'latin1'), Buffer.from([0]), Buffer.from(String(v), 'utf8')]))); - } - } - parts.push(chunk('IDAT', zlib.deflateSync(raw, { level }))); - parts.push(chunk('IEND', Buffer.alloc(0))); - return Buffer.concat(parts); -} - -/** - * Read any raster the comp pipeline meets (PNG natively; WebP / JPEG / GIF / - * AVIF through a converter on PATH) as RGBA. Non-PNG input is converted to a - * sibling cache file `..png` next to the source, never in place: - * a session that overwrites `comp.webp` with PNG bytes leaves a file the - * next tool cannot trust and a transcript replay cannot reconstruct. - * Returns { image, path } where path is the PNG actually decoded. - */ -export function loadRaster(file) { - const buf = fs.readFileSync(file); - if (isPng(buf)) return { image: decodePng(buf), path: file }; - const cache = `${file}.png`; - if (fs.existsSync(cache)) { - try { const b = fs.readFileSync(cache); if (isPng(b)) return { image: decodePng(b), path: cache }; } catch { /* reconvert */ } - } - const attempts = [ - ['dwebp', [file, '-o', cache]], - ['sips', ['-s', 'format', 'png', file, '--out', cache]], - ['magick', [file, cache]], - ['convert', [file, cache]], - ]; - let lastErr = null; - for (const [cmd, args] of attempts) { - try { execFileSync(cmd, args, { stdio: 'ignore' }); const b = fs.readFileSync(cache); if (isPng(b)) return { image: decodePng(b), path: cache }; } - catch (e) { lastErr = e; } - } - throw new Error(`png: ${file} is not a PNG and no converter (dwebp, sips, magick, convert) could produce ${cache}${lastErr ? `: ${lastErr.message}` : ''}`); -} diff --git a/skill/scripts/lib/raster.mjs b/skill/scripts/lib/raster.mjs deleted file mode 100644 index 2d3e0ae3a..000000000 --- a/skill/scripts/lib/raster.mjs +++ /dev/null @@ -1,194 +0,0 @@ -/** - * Small RGBA raster toolkit shared by the comp-fidelity scripts: crop, resize - * (area-averaging down, bilinear up), composite, fills, rectangles, and a - * bitmap-font label so composites can be captioned without a font stack. - * - * An image is `{ width, height, data }` with RGBA8 data (Uint8Array). - */ - -export function createImage(width, height, fill = [0, 0, 0, 0]) { - const data = new Uint8Array(width * height * 4); - if (fill[0] || fill[1] || fill[2] || fill[3]) { - for (let i = 0; i < data.length; i += 4) { data[i] = fill[0]; data[i + 1] = fill[1]; data[i + 2] = fill[2]; data[i + 3] = fill[3]; } - } - return { width, height, data }; -} - -export function clampRect(img, x, y, w, h) { - const x0 = Math.max(0, Math.min(img.width, Math.round(x))); - const y0 = Math.max(0, Math.min(img.height, Math.round(y))); - const x1 = Math.max(x0, Math.min(img.width, Math.round(x + w))); - const y1 = Math.max(y0, Math.min(img.height, Math.round(y + h))); - return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 }; -} - -export function crop(img, x, y, w, h) { - const r = clampRect(img, x, y, w, h); - const out = createImage(Math.max(1, r.w), Math.max(1, r.h)); - for (let yy = 0; yy < r.h; yy++) { - const src = ((r.y + yy) * img.width + r.x) * 4; - out.data.set(img.data.subarray(src, src + r.w * 4), yy * out.width * 4); - } - return out; -} - -/** Resize with area averaging when shrinking and bilinear when growing. */ -export function resize(img, width, height) { - width = Math.max(1, Math.round(width)); - height = Math.max(1, Math.round(height)); - if (width === img.width && height === img.height) return { width, height, data: new Uint8Array(img.data) }; - const out = createImage(width, height); - const sx = img.width / width, sy = img.height / height; - if (sx >= 1 && sy >= 1) { - for (let y = 0; y < height; y++) { - const y0 = Math.floor(y * sy), y1 = Math.min(img.height, Math.max(y0 + 1, Math.floor((y + 1) * sy))); - for (let x = 0; x < width; x++) { - const x0 = Math.floor(x * sx), x1 = Math.min(img.width, Math.max(x0 + 1, Math.floor((x + 1) * sx))); - let r = 0, g = 0, b = 0, a = 0, n = 0; - for (let yy = y0; yy < y1; yy++) { - let p = (yy * img.width + x0) * 4; - for (let xx = x0; xx < x1; xx++, p += 4) { r += img.data[p]; g += img.data[p + 1]; b += img.data[p + 2]; a += img.data[p + 3]; n++; } - } - const o = (y * width + x) * 4; - out.data[o] = r / n; out.data[o + 1] = g / n; out.data[o + 2] = b / n; out.data[o + 3] = a / n; - } - } - return out; - } - for (let y = 0; y < height; y++) { - const fy = Math.min(img.height - 1, (y + 0.5) * sy - 0.5); - const y0 = Math.max(0, Math.floor(fy)), y1 = Math.min(img.height - 1, y0 + 1), wy = fy - y0; - for (let x = 0; x < width; x++) { - const fx = Math.min(img.width - 1, (x + 0.5) * sx - 0.5); - const x0 = Math.max(0, Math.floor(fx)), x1 = Math.min(img.width - 1, x0 + 1), wx = fx - x0; - const o = (y * width + x) * 4; - for (let c = 0; c < 4; c++) { - const p00 = img.data[(y0 * img.width + x0) * 4 + c], p10 = img.data[(y0 * img.width + x1) * 4 + c]; - const p01 = img.data[(y1 * img.width + x0) * 4 + c], p11 = img.data[(y1 * img.width + x1) * 4 + c]; - out.data[o + c] = (p00 * (1 - wx) + p10 * wx) * (1 - wy) + (p01 * (1 - wx) + p11 * wx) * wy; - } - } - } - return out; -} - -/** Scale to fit inside (maxW x maxH) preserving aspect; never upscale unless `allowUpscale`. */ -export function fit(img, maxW, maxH, allowUpscale = false) { - const s = Math.min(maxW / img.width, maxH / img.height); - if (s >= 1 && !allowUpscale) return img; - return resize(img, img.width * s, img.height * s); -} - -/** Alpha-composite `src` onto `dst` at (x, y). */ -export function blit(dst, src, x, y) { - x = Math.round(x); y = Math.round(y); - for (let yy = 0; yy < src.height; yy++) { - const dy = y + yy; if (dy < 0 || dy >= dst.height) continue; - for (let xx = 0; xx < src.width; xx++) { - const dx = x + xx; if (dx < 0 || dx >= dst.width) continue; - const s = (yy * src.width + xx) * 4, d = (dy * dst.width + dx) * 4; - const a = src.data[s + 3] / 255; - if (a >= 1) { dst.data[d] = src.data[s]; dst.data[d + 1] = src.data[s + 1]; dst.data[d + 2] = src.data[s + 2]; dst.data[d + 3] = 255; continue; } - if (a <= 0) continue; - const da = dst.data[d + 3] / 255, oa = a + da * (1 - a); - for (let c = 0; c < 3; c++) dst.data[d + c] = (src.data[s + c] * a + dst.data[d + c] * da * (1 - a)) / (oa || 1); - dst.data[d + 3] = oa * 255; - } - } -} - -export function fillRect(img, x, y, w, h, rgba) { - const r = clampRect(img, x, y, w, h); - const a = (rgba[3] ?? 255) / 255; - for (let yy = r.y; yy < r.y + r.h; yy++) { - for (let xx = r.x; xx < r.x + r.w; xx++) { - const o = (yy * img.width + xx) * 4; - if (a >= 1) { img.data[o] = rgba[0]; img.data[o + 1] = rgba[1]; img.data[o + 2] = rgba[2]; img.data[o + 3] = 255; } - else { for (let c = 0; c < 3; c++) img.data[o + c] = rgba[c] * a + img.data[o + c] * (1 - a); img.data[o + 3] = Math.max(img.data[o + 3], a * 255); } - } - } -} - -export function strokeRect(img, x, y, w, h, rgba, thickness = 2) { - fillRect(img, x, y, w, thickness, rgba); - fillRect(img, x, y + h - thickness, w, thickness, rgba); - fillRect(img, x, y, thickness, h, rgba); - fillRect(img, x + w - thickness, y, thickness, h, rgba); -} - -// 5x7 bitmap font, uppercase + digits + a little punctuation. Enough for labels. -const GLYPHS = { - A: ['01110', '10001', '10001', '11111', '10001', '10001', '10001'], - B: ['11110', '10001', '10001', '11110', '10001', '10001', '11110'], - C: ['01110', '10001', '10000', '10000', '10000', '10001', '01110'], - D: ['11110', '10001', '10001', '10001', '10001', '10001', '11110'], - E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'], - F: ['11111', '10000', '10000', '11110', '10000', '10000', '10000'], - G: ['01110', '10001', '10000', '10111', '10001', '10001', '01111'], - H: ['10001', '10001', '10001', '11111', '10001', '10001', '10001'], - I: ['11111', '00100', '00100', '00100', '00100', '00100', '11111'], - J: ['00111', '00010', '00010', '00010', '00010', '10010', '01100'], - K: ['10001', '10010', '10100', '11000', '10100', '10010', '10001'], - L: ['10000', '10000', '10000', '10000', '10000', '10000', '11111'], - M: ['10001', '11011', '10101', '10101', '10001', '10001', '10001'], - N: ['10001', '10001', '11001', '10101', '10011', '10001', '10001'], - O: ['01110', '10001', '10001', '10001', '10001', '10001', '01110'], - P: ['11110', '10001', '10001', '11110', '10000', '10000', '10000'], - Q: ['01110', '10001', '10001', '10001', '10101', '10010', '01101'], - R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001'], - S: ['01111', '10000', '10000', '01110', '00001', '00001', '11110'], - T: ['11111', '00100', '00100', '00100', '00100', '00100', '00100'], - U: ['10001', '10001', '10001', '10001', '10001', '10001', '01110'], - V: ['10001', '10001', '10001', '10001', '10001', '01010', '00100'], - W: ['10001', '10001', '10001', '10101', '10101', '10101', '01010'], - X: ['10001', '10001', '01010', '00100', '01010', '10001', '10001'], - Y: ['10001', '10001', '01010', '00100', '00100', '00100', '00100'], - Z: ['11111', '00001', '00010', '00100', '01000', '10000', '11111'], - 0: ['01110', '10001', '10011', '10101', '11001', '10001', '01110'], - 1: ['00100', '01100', '00100', '00100', '00100', '00100', '01110'], - 2: ['01110', '10001', '00001', '00010', '00100', '01000', '11111'], - 3: ['11110', '00001', '00001', '01110', '00001', '00001', '11110'], - 4: ['00010', '00110', '01010', '10010', '11111', '00010', '00010'], - 5: ['11111', '10000', '11110', '00001', '00001', '10001', '01110'], - 6: ['00110', '01000', '10000', '11110', '10001', '10001', '01110'], - 7: ['11111', '00001', '00010', '00100', '01000', '01000', '01000'], - 8: ['01110', '10001', '10001', '01110', '10001', '10001', '01110'], - 9: ['01110', '10001', '10001', '01111', '00001', '00010', '01100'], - ' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'], - '.': ['00000', '00000', '00000', '00000', '00000', '01100', '01100'], - ':': ['00000', '01100', '01100', '00000', '01100', '01100', '00000'], - '-': ['00000', '00000', '00000', '11111', '00000', '00000', '00000'], - '/': ['00001', '00010', '00010', '00100', '01000', '01000', '10000'], - '%': ['11001', '11010', '00010', '00100', '01000', '01011', '10011'], - '(': ['00010', '00100', '01000', '01000', '01000', '00100', '00010'], - ')': ['01000', '00100', '00010', '00010', '00010', '00100', '01000'], - '#': ['01010', '01010', '11111', '01010', '11111', '01010', '01010'], - '_': ['00000', '00000', '00000', '00000', '00000', '00000', '11111'], - '?': ['01110', '10001', '00001', '00010', '00100', '00000', '00100'], - '=': ['00000', '00000', '11111', '00000', '11111', '00000', '00000'], - '+': ['00000', '00100', '00100', '11111', '00100', '00100', '00000'], - ',': ['00000', '00000', '00000', '00000', '01100', '00100', '01000'], -}; - -export function textWidth(text, scale = 2) { - return text.length * 6 * scale; -} - -/** Draw uppercase bitmap text. Returns width drawn. */ -export function drawText(img, text, x, y, rgba, scale = 2) { - let cx = Math.round(x); - for (const chRaw of String(text).toUpperCase()) { - const g = GLYPHS[chRaw] || GLYPHS['?']; - for (let r = 0; r < 7; r++) for (let c = 0; c < 5; c++) if (g[r][c] === '1') fillRect(img, cx + c * scale, y + r * scale, scale, scale, rgba); - cx += 6 * scale; - } - return cx - x; -} - -/** Draw a label with a background pill. */ -export function drawLabel(img, text, x, y, { fg = [255, 255, 255, 255], bg = [0, 0, 0, 220], scale = 2, pad = 4 } = {}) { - const w = textWidth(text, scale) + pad * 2, h = 7 * scale + pad * 2; - fillRect(img, x, y, w, h, bg); - drawText(img, text, x + pad, y + pad, fg, scale); - return { w, h }; -} diff --git a/tests/build-phase.test.mjs b/tests/build-phase.test.mjs deleted file mode 100644 index 141258fd5..000000000 --- a/tests/build-phase.test.mjs +++ /dev/null @@ -1,516 +0,0 @@ -import { describe, it, before, after } from 'node:test'; -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { encodePng } from '../skill/scripts/lib/png.mjs'; -import { createImage, fillRect, blit, resize, drawText } from '../skill/scripts/lib/raster.mjs'; -import { gridToBox, measureRegions, platePrompt } from '../skill/scripts/comp-spec.mjs'; - -const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const SPEC_SCRIPT = path.join(ROOT, 'skill', 'scripts', 'comp-spec.mjs'); -const PHASE_SCRIPT = path.join(ROOT, 'skill', 'scripts', 'build-phase.mjs'); -const FONT_SCRIPT = path.join(ROOT, 'skill', 'scripts', 'font-match.mjs'); - -function lcg(seed) { let s = seed >>> 0; return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 0xffffffff); } - -function makeComp(w = 640, h = 400) { - const img = createImage(w, h, [240, 237, 226, 255]); - fillRect(img, 0, 0, w, 40, [19, 33, 48, 255]); - // two lines of block lettering (raster.mjs bitmap font) so the headline - // region measures as type: cap ~40px at scale 5 - drawText(img, 'KEEP OLD', 20, 52, [19, 33, 48, 255], 4); - drawText(img, 'IRON', 20, 88, [19, 33, 48, 255], 4); - const rnd = lcg(3); - for (let y = 40; y < 200; y++) for (let x = 320; x < 640; x++) { - const v = 100 + Math.floor(rnd() * 140); - const p = (y * w + x) * 4; img.data[p] = v; img.data[p + 1] = v; img.data[p + 2] = v; - } - for (let i = 0; i < 3; i++) { fillRect(img, 0, 220 + i * 50, w, 1, [19, 33, 48, 255]); fillRect(img, 20, 232 + i * 50, 300, 12, [19, 33, 48, 255]); } - return img; -} - -// Every child here is spawned synchronously. spawnSync blocks the test -// worker's thread, so node's own `--test-timeout` (an event-loop timer) can -// never interrupt a child that wedges — under load a fork/exec can block on -// OS resources, and a gate's grandchild (comp-diff.mjs) or an accidental -// browser launch can stall. spawnSync's own `timeout`/`killSignal` is the one -// mechanism that can kill such a child, so bound every run: a wedge becomes a -// fast, named failure that the next test survives, never a suite-wide hang. -const RUN_TIMEOUT_MS = Number(process.env.IMPECCABLE_BUILD_PHASE_RUN_TIMEOUT_MS) || 120_000; -function run(script, args, cwd) { - const res = spawnSync(process.execPath, [script, ...args], { - cwd, - encoding: 'utf8', - timeout: RUN_TIMEOUT_MS, - killSignal: 'SIGKILL', - }); - if (res.error && (res.error.code === 'ETIMEDOUT' || res.signal === 'SIGKILL')) { - throw new Error( - `build-phase child timed out after ${RUN_TIMEOUT_MS}ms and was killed (SIGKILL): ` + - `node ${script} ${args.join(' ')}\nstdout so far:\n${res.stdout || ''}\nstderr so far:\n${res.stderr || ''}`, - ); - } - return res; -} - -describe('comp-spec', () => { - it('parses grid spans into normalized boxes', () => { - assert.deepEqual(gridToBox('A0:A0'), { x: 0, y: 0, w: 0.1, h: 0.1 }); - assert.deepEqual(gridToBox('E0:J4'), { x: 0.4, y: 0, w: 0.6, h: 0.5 }); - assert.deepEqual(gridToBox('j4:e0'), { x: 0.4, y: 0, w: 0.6, h: 0.5 }); - assert.throws(() => gridToBox('K0:A1')); - }); - - it('measures regions with palette, pixel box, medium, and plate path', () => { - const comp = makeComp(); - const spec = measureRegions(comp, { allowUncovered: true, regions: [ - { id: 'masthead', kind: 'chrome', grid: 'A0:J0', note: 'navy masthead bar' }, - { id: 'art', kind: 'plate', grid: 'F1:J4', note: 'noise plate' }, - ] }, 'comp.png'); - assert.equal(spec.regions.length, 2); - const art = spec.regions.find((r) => r.id === 'art'); - assert.equal(art.medium, 'raster'); - assert.equal(art.plate, path.join('assets', 'plates', 'art.png')); - assert.equal(art.px.x, 320); - assert.ok(art.palette.length > 0); - assert.equal(spec.regions[0].medium, 'semantic'); - assert.equal(spec.orientation, 'landscape'); - assert.match(platePrompt(spec, art), /noise plate/); - }); - - it('refuses a code kind whose note describes painted material, unless codeDrawn', () => { - const comp = makeComp(); - const painted = { id: 'rack', kind: 'chrome', grid: 'F1:J4', note: 'countable four-carburetor technical geometry with blue leaders, an exploded diagram' }; - assert.throws(() => measureRegions(comp, { allowUncovered: true, regions: [painted] }, 'c.png'), /describes painted material/); - const ok = measureRegions(comp, { allowUncovered: true, regions: [{ ...painted, codeDrawn: true }] }, 'c.png'); - assert.equal(ok.regions[0].kind, 'chrome'); - const table = measureRegions(comp, { allowUncovered: true, regions: [{ id: 'index', kind: 'chrome', grid: 'F1:J4', note: 'ruled discussion table with thread, author, replies columns' }] }, 'c.png'); - assert.equal(table.regions[0].kind, 'chrome'); - const plate = measureRegions(comp, { allowUncovered: true, regions: [{ ...painted, kind: 'plate' }] }, 'c.png'); - assert.equal(plate.regions[0].medium, 'raster'); - }); - - it('snaps a text region to the largest ink mass in its grid span, keeping the span for coverage', () => { - // headline block at left, a thin dark spine on the span's left edge, a - // column of small text at its right: the span B1:E4 covers all three - const comp = createImage(1000, 1000, [235, 232, 220, 255]); - fillRect(comp, 100, 0, 12, 1000, [160, 40, 30, 255]); - drawText(comp, 'KEEP OLD', 160, 130, [20, 20, 20, 255], 8); - drawText(comp, 'IRON', 160, 220, [20, 20, 20, 255], 8); - for (let i = 0; i < 6; i++) drawText(comp, 'small column text', 430, 120 + i * 30, [20, 20, 20, 255], 2); - const spec = measureRegions(comp, { allowUncovered: true, regions: [{ id: 'headline', kind: 'text', grid: 'B1:E4', note: 'two-line block headline' }] }, 'c.png'); - const r = spec.regions[0]; - assert.equal(r.grid, 'B1:E4'); - assert.ok(r.coverBox && r.coverBox.w === 0.4, 'the span stays on the record'); - assert.ok(r.box.w < 0.3 && r.box.x >= 0.14 && r.box.x + r.box.w <= 0.42, `snapped to the headline: ${JSON.stringify(r.box)}`); - const plain = measureRegions(comp, { allowUncovered: true, regions: [{ id: 'headline', kind: 'text', grid: 'B1:E4', note: 'two-line block headline', snap: false }] }, 'c.png'); - 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]); - 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/); - const ok = measureRegions(comp, { allowUncovered: true, regions: [{ id: 'parts-column', kind: 'chrome', grid: 'G0:J9', container: true, note: 'right column of parts' }] }, 'c.png'); - assert.equal(ok.regions[0].kind, 'chrome'); - const plate = measureRegions(comp, { allowUncovered: true, regions: [{ id: 'art', kind: 'plate', grid: 'G0:J9', note: 'noise plate' }] }, 'c.png'); - assert.equal(plate.regions[0].medium, 'raster'); - }); - - it('refuses a regions file that leaves comp ink unnamed, unless allowUncovered', () => { - const comp = makeComp(); - // only the masthead named: the headline, plate, and list are ink no region covers - assert.throws(() => measureRegions(comp, { regions: [{ id: 'masthead', kind: 'chrome', grid: 'A0:J0', note: 'navy masthead bar' }] }, 'c.png'), /carry ink no region names/); - const spec = measureRegions(comp, { allowUncovered: true, regions: [{ id: 'masthead', kind: 'chrome', grid: 'A0:J0', note: 'navy masthead bar' }] }, 'c.png'); - assert.ok(spec.uncoveredInkCells.length > 3); - }); - - it('rejects duplicate ids and missing ids', () => { - const comp = makeComp(); - assert.throws(() => measureRegions(comp, { regions: [{ id: 'a', grid: 'A0:A0' }, { id: 'a', grid: 'B0:B0' }] }, 'c.png'), /duplicate/); - assert.throws(() => measureRegions(comp, { regions: [{ grid: 'A0:A0' }] }, 'c.png'), /id/); - }); -}); - -describe('build-phase comps phase (start --direction)', () => { - let dir; - before(() => { - dir = fs.mkdtempSync(path.join(os.tmpdir(), 'build-phase-comps-')); - fs.mkdirSync(path.join(dir, '.impeccable', 'mocks', 'decision'), { recursive: true }); - }); - after(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} }); - - it('opens at comps, refuses with fewer than three sidecar\'d comps or no approval, then records the approved comp', () => { - let res = run(PHASE_SCRIPT, ['start', '--direction', 'abcd1234'], dir); - assert.equal(res.status, 0, res.stderr); - assert.match(res.stdout, /BUILD-PHASE COMPS/); - assert.match(res.stdout, /direction abcd1234/); - assert.match(res.stdout, /NEXT Comp round/); - res = run(PHASE_SCRIPT, ['advance'], dir); - assert.equal(res.status, 2); - assert.match(res.stdout, /0 comps under/); - // three comps, one without sidecar, none approved - const comp = makeComp(); - for (const n of ['a', 'b', 'c']) fs.writeFileSync(path.join(dir, '.impeccable', 'mocks', `comp-${n}.png`), encodePng(comp)); - // a decision-round comp must not count - fs.writeFileSync(path.join(dir, '.impeccable', 'mocks', 'decision', 'card.png'), encodePng(comp)); - fs.writeFileSync(path.join(dir, '.impeccable', 'mocks', 'comp-a.png.json'), JSON.stringify({ prompt: 'a' })); - fs.writeFileSync(path.join(dir, '.impeccable', 'mocks', 'comp-b.png.json'), JSON.stringify({ prompt: 'b' })); - res = run(PHASE_SCRIPT, ['advance'], dir); - assert.equal(res.status, 2); - assert.match(res.stdout, /no prompt sidecar for: comp-c.png/); - assert.match(res.stdout, /no comp is approved/); - fs.writeFileSync(path.join(dir, '.impeccable', 'mocks', 'comp-c.png.json'), JSON.stringify({ prompt: 'c' })); - fs.writeFileSync(path.join(dir, '.impeccable', 'mocks', 'comp-b.png.json'), JSON.stringify({ prompt: 'b', approved: true })); - res = run(PHASE_SCRIPT, ['advance'], dir); - assert.equal(res.status, 0, res.stdout); - assert.match(res.stdout, /ADVANCED comps -> spec/); - const state = JSON.parse(fs.readFileSync(path.join(dir, '.impeccable', 'build', 'state.json'), 'utf8')); - assert.equal(state.comp, path.join('.impeccable', 'mocks', 'comp-b.png')); - assert.equal(state.breakpoint, '640x400'); - assert.equal(state.phases.comps.status, 'closed'); - }); - - it('start --comp skips the comps phase and records why', () => { - const d2 = fs.mkdtempSync(path.join(os.tmpdir(), 'build-phase-comps2-')); - fs.writeFileSync(path.join(d2, 'comp.png'), encodePng(makeComp())); - const res = run(PHASE_SCRIPT, ['start', '--comp', 'comp.png'], d2); - assert.equal(res.status, 0, res.stderr); - const state = JSON.parse(fs.readFileSync(path.join(d2, '.impeccable', 'build', 'state.json'), 'utf8')); - assert.equal(state.phase, 'spec'); - assert.equal(state.phases.comps.status, 'skipped'); - fs.rmSync(d2, { recursive: true, force: true }); - }); -}); - -describe('build-phase state machine (CLI)', () => { - let dir; - before(() => { - dir = fs.mkdtempSync(path.join(os.tmpdir(), 'build-phase-')); - fs.writeFileSync(path.join(dir, 'comp.png'), encodePng(makeComp())); - fs.writeFileSync(path.join(dir, 'regions.json'), JSON.stringify({ regions: [ - { id: 'masthead', kind: 'chrome', grid: 'A0:J0', note: 'navy masthead bar' }, - { id: 'headline', kind: 'text', grid: 'A1:D2', note: 'two-line block headline' }, - { id: 'art', kind: 'plate', grid: 'F1:J4', note: 'noise plate' }, - { id: 'list', kind: 'control', grid: 'A5:J9', container: true, note: 'ruled list rows' }, - ] })); - }); - after(() => { try { fs.rmSync(dir, { recursive: true, force: true }); } catch {} }); - - it('start writes state at spec and prints NEXT', () => { - const res = run(PHASE_SCRIPT, ['start', '--comp', 'comp.png'], dir); - assert.equal(res.status, 0, res.stderr); - assert.match(res.stdout, /BUILD-PHASE SPEC/); - assert.match(res.stdout, /NEXT Measure the comp/); - assert.ok(fs.existsSync(path.join(dir, '.impeccable', 'build', 'state.json'))); - }); - - it('spec gate fails without a spec, passes once comp-spec wrote one', () => { - let res = run(PHASE_SCRIPT, ['advance'], dir); - assert.equal(res.status, 2); - assert.match(res.stdout, /GATE SPEC FAILED/); - res = run(SPEC_SCRIPT, ['--comp', 'comp.png', '--grid'], dir); - assert.equal(res.status, 0, res.stderr); - assert.ok(fs.existsSync(path.join(dir, '.impeccable', 'build', 'comp-grid.png'))); - res = run(SPEC_SCRIPT, ['--comp', 'comp.png', '--regions', 'regions.json'], dir); - assert.equal(res.status, 0, res.stderr); - assert.match(res.stdout, /PLATES 1 to produce: art/); - // type must be measured before spec closes - res = run(PHASE_SCRIPT, ['advance'], dir); - assert.equal(res.status, 2, res.stdout); - assert.match(res.stdout, /measure the type before closing the spec/); - res = run(FONT_SCRIPT, ['--measure', 'headline'], dir); - assert.equal(res.status, 0, res.stderr); - assert.match(res.stdout, /MEASURE headline/); - // a face typed straight into spec.json is refused: only font-match's own - // stamped choice closes the spec - const specFile = path.join(dir, '.impeccable', 'build', 'spec.json'); - const spec = JSON.parse(fs.readFileSync(specFile, 'utf8')); - const head = spec.regions.find((r) => r.id === 'headline'); - assert.ok(head.type && head.type.comp, 'the test comp headline measures as type'); - { - head.type.chosen = { family: 'Arial Narrow', weight: 700, fontSizePx: 40, source: 'system-fallback' }; - fs.writeFileSync(specFile, JSON.stringify(spec, null, 2)); - res = run(PHASE_SCRIPT, ['advance'], dir); - assert.equal(res.status, 2, res.stdout); - assert.match(res.stdout, /font-match did not write/); - // no browser in the test env either way: --rank records the catalog's nearest face, stamped - res = run(FONT_SCRIPT, ['--rank', 'headline', '--text', 'HEADLINE'], dir); - assert.equal(res.status, 0, res.stderr); - assert.match(res.stdout, /USE font-family/); - const after = JSON.parse(fs.readFileSync(specFile, 'utf8')).regions.find((r) => r.id === 'headline'); - assert.ok(after.type.chosen && after.type.chosen.stamp, 'font-match stamps its choice'); - } - res = run(PHASE_SCRIPT, ['advance'], dir); - assert.equal(res.status, 0, res.stdout + res.stderr); - assert.match(res.stdout, /ADVANCED spec -> plates/); - }); - - it('plates gate names the missing plate, rejects a comp-size crop, accepts a 2x plate', () => { - let res = run(PHASE_SCRIPT, ['advance'], dir); - assert.equal(res.status, 2); - assert.match(res.stdout, /plate missing for art/); - // force without the user's words is refused; with them it is recorded - res = run(PHASE_SCRIPT, ['advance', '--force', '--reason', 'single-file HTML delivery requires embedded CSS'], dir); - assert.equal(res.status, 2); - assert.match(res.stdout, /--force refused/); - // comp-size crop: too small - res = run(SPEC_SCRIPT, ['--crop', 'art', '--out', 'assets/plates/art.png'], dir); - assert.equal(res.status, 0, res.stderr); - res = run(PHASE_SCRIPT, ['advance'], dir); - assert.equal(res.status, 2); - assert.match(res.stdout, /needs at least 480px/); - // 2x crop of the comp: sized right, but a crop is never a plate - res = run(SPEC_SCRIPT, ['--crop', 'art', '--scale', '2', '--out', 'assets/plates/art.png'], dir); - assert.equal(res.status, 0, res.stderr); - res = run(PHASE_SCRIPT, ['advance'], dir); - assert.equal(res.status, 2, res.stdout); - assert.match(res.stdout, /is the comp crop of region art/); - // a produced plate: the same material rendered fresh (another noise field - // of the same statistics), at 2x - const produced = createImage(640, 320, [0, 0, 0, 255]); - const rndP = lcg(99); - for (let y = 0; y < 320; y++) for (let x = 0; x < 640; x++) { const v = 100 + Math.floor(rndP() * 140); const q = (y * 640 + x) * 4; produced.data[q] = v; produced.data[q + 1] = v; produced.data[q + 2] = v; } - fs.writeFileSync(path.join(dir, 'assets', 'plates', 'art.png'), encodePng(produced)); - res = run(PHASE_SCRIPT, ['advance'], dir); - assert.equal(res.status, 0, res.stdout); - 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); - assert.match(res.stdout, /SCAFFOLD/); - const css = fs.readFileSync(path.join(dir, '.impeccable', 'build', 'scaffold', 'layout.css'), 'utf8'); - assert.match(css, /--r-headline-x: [\d.]+%; --r-headline-y: [\d.]+%; --r-headline-w: [\d.]+%; --r-headline-h: [\d.]+%;/); - assert.match(css, /--r-headline-cap: [\d.]+px/); - assert.match(css, /\.r-art \{ position: absolute; left: var\(--r-art-x\)/); - const html = fs.readFileSync(path.join(dir, '.impeccable', 'build', 'scaffold', 'hero-reference.html'), 'utf8'); - assert.match(html, /class="r-art region plate"[^>]*>
{ - const comp = makeComp(); - const flat = createImage(comp.width, comp.height, [240, 237, 226, 255]); - fillRect(flat, 0, 0, comp.width, 40, [19, 33, 48, 255]); - fs.mkdirSync(path.join(dir, '.impeccable', 'review'), { recursive: true }); - fs.writeFileSync(path.join(dir, '.impeccable', 'review', 'hero-repro.png'), encodePng(flat)); - // no source references the plate yet: refused before any diff runs - let res = run(PHASE_SCRIPT, ['advance'], dir); - assert.equal(res.status, 2, res.stdout + res.stderr); - assert.match(res.stdout, /not referenced by any source file/); - fs.writeFileSync(path.join(dir, 'index.html'), ''); - res = run(PHASE_SCRIPT, ['advance'], dir); - assert.equal(res.status, 2, res.stdout); - assert.match(res.stdout, /GATE HERO FAILED/); - assert.match(res.stdout, /region art is missing/); - assert.ok(fs.existsSync(path.join(dir, '.impeccable', 'review', 'diff', 'hero', 'side-by-side.png'))); - // the plates gate recorded the plate on the state - let st = JSON.parse(fs.readFileSync(path.join(dir, '.impeccable', 'build', 'state.json'), 'utf8')); - assert.ok(st.plates && st.plates.art && st.plates.art.status === 'ok', 'plate row travels on the state'); - // a passed plate that IS drawn in the box (a different noise field, so its - // content re-scores low) is placed material: the hero says placement, - // never 'missing', and only when the box is off - const placed = createImage(comp.width, comp.height, [240, 237, 226, 255]); - fillRect(placed, 0, 0, comp.width, 40, [19, 33, 48, 255]); - const rnd2 = lcg(11); - for (let y = 40; y < 200; y++) for (let x = 320; x < 640; x++) { const v = 100 + Math.floor(rnd2() * 140); const q = (y * comp.width + x) * 4; placed.data[q] = v; placed.data[q + 1] = v; placed.data[q + 2] = v; } - fs.writeFileSync(path.join(dir, '.impeccable', 'review', 'hero-repro.png'), encodePng(placed)); - res = run(PHASE_SCRIPT, ['advance'], dir); - assert.doesNotMatch(res.stdout, /region art is missing/); - // faithful: the comp shifted by a few px, captured at 1.5x width - const shifted = createImage(comp.width, comp.height, [240, 237, 226, 255]); - blit(shifted, comp, 3, 2); - fs.writeFileSync(path.join(dir, '.impeccable', 'review', 'hero-repro.png'), encodePng(resize(shifted, comp.width * 1.5, comp.height * 1.5))); - res = run(PHASE_SCRIPT, ['advance'], dir); - assert.equal(res.status, 0, res.stdout); - assert.match(res.stdout, /ADVANCED hero -> sections/); - const state = JSON.parse(fs.readFileSync(path.join(dir, '.impeccable', 'build', 'state.json'), 'utf8')); - assert.equal(state.phases.hero.attempts, 4); - assert.ok(state.phases.hero.gate.score >= 0.72); - }); - - it('hero gate reports a control whose ink box differs from the comp, and never throws on the report shape', () => { - const d4 = fs.mkdtempSync(path.join(os.tmpdir(), 'build-phase-ctrl-')); - // a discrete CTA (a 160x40 button at 40,300) inside a larger control region; - // the build renders it half-height. Rules that span the region are erased so - // the comp's ink box is the button, not the region clipped. - const comp = makeComp(); - fillRect(comp, 0, 200, 320, 200, [240, 237, 226, 255]); - fillRect(comp, 40, 300, 160, 40, [19, 33, 48, 255]); - fs.writeFileSync(path.join(d4, 'comp.png'), encodePng(comp)); - fs.writeFileSync(path.join(d4, 'regions.json'), JSON.stringify({ allowUncovered: true, regions: [ - { id: 'masthead', kind: 'chrome', grid: 'A0:J0', note: 'navy masthead bar' }, - { id: 'cta', kind: 'control', box: { x: 0, y: 0.5, w: 0.5, h: 0.5 }, note: 'black CTA button' }, - ] })); - run(PHASE_SCRIPT, ['start', '--comp', 'comp.png', '--artifact', 'index.html'], d4); - run(SPEC_SCRIPT, ['--comp', 'comp.png', '--regions', 'regions.json'], d4); - run(PHASE_SCRIPT, ['advance'], d4); - run(PHASE_SCRIPT, ['advance'], d4); // no plates - // makeComp is 640x400; the region is its bottom-left quarter (0..320, 200..400) holding - // three table rows and the CTA. Shrink the ink there: erase and redraw the rows half-height. - const build = { ...comp, data: new Uint8Array(comp.data) }; - fillRect(build, 0, 200, 320, 200, [240, 237, 226, 255]); - fillRect(build, 40, 300, 160, 18, [19, 33, 48, 255]); - fs.mkdirSync(path.join(d4, '.impeccable', 'review'), { recursive: true }); - fs.writeFileSync(path.join(d4, '.impeccable', 'review', 'hero-repro.png'), encodePng(build)); - fs.writeFileSync(path.join(d4, 'index.html'), ''); - const res = run(PHASE_SCRIPT, ['advance'], d4); - assert.doesNotMatch(res.stdout + res.stderr, /TypeError|errored/); - assert.match(res.stdout, /region cta: its ink sits in a/); - fs.rmSync(d4, { recursive: true, force: true }); - }); - - it('hero gate lists region crops first on failure and refuses a third value-only attempt on the same region', () => { - // fresh project at hero with a stuck build - const d3 = fs.mkdtempSync(path.join(os.tmpdir(), 'build-phase-hero-')); - const comp = makeComp(); - fs.writeFileSync(path.join(d3, 'comp.png'), encodePng(comp)); - fs.writeFileSync(path.join(d3, 'regions.json'), JSON.stringify({ allowUncovered: true, regions: [ - { id: 'masthead', kind: 'chrome', grid: 'A0:J0', note: 'navy masthead bar' }, - { id: 'art', kind: 'plate', grid: 'F1:J4', note: 'noise plate' }, - { id: 'list', kind: 'control', grid: 'A5:J9', container: true, note: 'ruled list rows' }, - ] })); - run(PHASE_SCRIPT, ['start', '--comp', 'comp.png', '--artifact', 'index.html'], d3); - run(SPEC_SCRIPT, ['--comp', 'comp.png', '--regions', 'regions.json'], d3); - run(PHASE_SCRIPT, ['advance'], d3); - // a produced plate (fresh noise of the same statistics), not the crop - fs.mkdirSync(path.join(d3, 'assets', 'plates'), { recursive: true }); - { const produced = createImage(640, 320, [0, 0, 0, 255]); const rndP = lcg(77); for (let y = 0; y < 320; y++) for (let x = 0; x < 640; x++) { const v = 100 + Math.floor(rndP() * 140); const q = (y * 640 + x) * 4; produced.data[q] = v; produced.data[q + 1] = v; produced.data[q + 2] = v; } fs.writeFileSync(path.join(d3, 'assets', 'plates', 'art.png'), encodePng(produced)); } - run(PHASE_SCRIPT, ['advance'], d3); - fs.writeFileSync(path.join(d3, 'index.html'), ''); - const flat = createImage(comp.width, comp.height, [240, 237, 226, 255]); - fillRect(flat, 0, 0, comp.width, 40, [19, 33, 48, 255]); - fs.mkdirSync(path.join(d3, '.impeccable', 'review'), { recursive: true }); - fs.writeFileSync(path.join(d3, '.impeccable', 'review', 'hero-repro.png'), encodePng(flat)); - let res; - for (let i = 0; i < 3; i++) { - fs.writeFileSync(path.join(d3, 'index.html'), ``); - res = run(PHASE_SCRIPT, ['advance'], d3); - assert.equal(res.status, 2); - } - assert.match(res.stdout, /LOOK FIRST/); - assert.match(res.stdout, /regions\/art\.png/); - assert.match(res.stdout, /three attempts/); - fs.rmSync(d3, { recursive: true, force: true }); - }); - - it('later phases advance without a gate; force is recorded; finish records the disposition', () => { - for (const from of ['sections', 'motion']) { - const res = run(PHASE_SCRIPT, ['advance'], dir); - assert.equal(res.status, 0, res.stdout); - assert.match(res.stdout, new RegExp(`ADVANCED ${from}`)); - } - // responsive gate: needs desktop.png + mobile.png, and desktop must read as the comp - let res = run(PHASE_SCRIPT, ['advance'], dir); - assert.equal(res.status, 2); - assert.match(res.stdout, /no \.impeccable\/review\/desktop\.png/); - const comp = makeComp(); - const wide = createImage(1440, 900, [240, 237, 226, 255]); - blit(wide, resize(comp, 1440, 900), 0, 0); - fs.writeFileSync(path.join(dir, '.impeccable', 'review', 'desktop.png'), encodePng(wide)); - fs.writeFileSync(path.join(dir, '.impeccable', 'review', 'mobile.png'), encodePng(resize(comp, 390, 600))); - res = run(PHASE_SCRIPT, ['advance'], dir); - assert.equal(res.status, 0, res.stdout); - assert.match(res.stdout, /ADVANCED responsive -> review/); - res = run(PHASE_SCRIPT, ['finish', '--disposition', 'fix'], dir); - assert.equal(res.status, 0); - assert.match(res.stdout, /finish fix/); - res = run(PHASE_SCRIPT, ['status', '--json'], dir); - const state = JSON.parse(res.stdout); - assert.equal(state.phase, 'review'); - assert.equal(state.finish.disposition, 'fix'); - }); - - it('refuses a bad disposition and an unknown command', () => { - assert.equal(run(PHASE_SCRIPT, ['finish', '--disposition', 'great'], dir).status, 1); - assert.equal(run(PHASE_SCRIPT, ['dance'], dir).status, 1); - }); -}); - -describe('unreferencedPlates', () => { - it('finds a plate referenced only from a stylesheet under assets/', async () => { - const { unreferencedPlates } = await import('../skill/scripts/build-phase.mjs'); - const d = fs.mkdtempSync(path.join(os.tmpdir(), 'unref-')); - const prev = process.cwd(); - try { - process.chdir(d); - fs.mkdirSync(path.join(d, 'assets', 'plates'), { recursive: true }); - fs.writeFileSync(path.join(d, 'assets', 'plates', 'hero-plate.png'), 'x'); - fs.writeFileSync(path.join(d, 'assets', 'hero.css'), ".hero { background-image: url('./plates/hero-plate.png'); }"); - const spec = { regions: [{ id: 'hero-art', medium: 'raster', plate: 'assets/plates/hero-plate.png' }] }; - assert.deepEqual(unreferencedPlates(spec, null), [], 'the stylesheet under assets counts as a reference'); - // an explicit artifact does not bypass the stylesheet walk - fs.writeFileSync(path.join(d, 'index.html'), '
'); - assert.deepEqual(unreferencedPlates(spec, path.join(d, 'index.html')), [], 'the linked stylesheet still counts with --artifact set'); - // a linked stylesheet beyond the walk's reach (deep path) still counts - const deep = path.join(d, 'a', 'b', 'c', 'e', 'f', 'g', 'h', 'styles'); - fs.mkdirSync(deep, { recursive: true }); - fs.writeFileSync(path.join(deep, 'deep.css'), ".hero { background-image: url('hero-plate.png'); }"); - fs.writeFileSync(path.join(d, 'index.html'), `
`); - fs.writeFileSync(path.join(d, 'assets', 'hero.css'), '.hero { background: red; }'); - assert.deepEqual(unreferencedPlates(spec, path.join(d, 'index.html')), [], 'a stylesheet linked by the artifact counts wherever it lives'); - // a root-relative href resolves against the project, not the drive root - fs.writeFileSync(path.join(d, 'index.html'), `
`); - assert.deepEqual(unreferencedPlates(spec, path.join(d, 'index.html')), [], 'a root-relative stylesheet href counts'); - fs.writeFileSync(path.join(deep, 'deep.css'), '.hero { background: red; }'); - assert.equal(unreferencedPlates(spec, null).length, 1, 'an unreferenced plate is still named'); - assert.equal(unreferencedPlates(spec, path.join(d, 'index.html')).length, 1, 'and with the artifact set too'); - } finally { process.chdir(prev); fs.rmSync(d, { recursive: true, force: true }); } - }); -}); diff --git a/tests/comp-diff.test.mjs b/tests/comp-diff.test.mjs deleted file mode 100644 index 4b2d3d83e..000000000 --- a/tests/comp-diff.test.mjs +++ /dev/null @@ -1,242 +0,0 @@ -import { describe, it, before } from 'node:test'; -import assert from 'node:assert/strict'; -import { spawnSync, execFileSync } from 'node:child_process'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { decodePng, encodePng, loadRaster } from '../skill/scripts/lib/png.mjs'; -import { createImage, fillRect, blit, crop, resize, drawText } from '../skill/scripts/lib/raster.mjs'; -import { compare, verdictFor, alignBuild } from '../skill/scripts/comp-diff.mjs'; -import { dominantColors, structureScore, detailScore } from '../skill/scripts/lib/image-metrics.mjs'; - -const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const SCRIPT = path.join(ROOT, 'skill', 'scripts', 'comp-diff.mjs'); - -// A synthetic "comp": bone ground, navy masthead, a red-gutter table, and a -// noisy "illustration" region on the right of the fold. Deterministic noise. -function lcg(seed) { let s = seed >>> 0; return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 0xffffffff); } - -function makeComp(w = 768, h = 512) { - const img = createImage(w, h, [240, 237, 226, 255]); - fillRect(img, 0, 0, w, 32, [19, 33, 48, 255]); // masthead - drawText(img, 'CARBURETOR CLUB', 12, 8, [240, 237, 226, 255], 2); - // headline block - fillRect(img, 24, 70, 280, 22, [19, 33, 48, 255]); - fillRect(img, 24, 100, 240, 22, [19, 33, 48, 255]); - fillRect(img, 24, 136, 90, 4, [176, 40, 32, 255]); - // illustration: high-frequency noise plate - const rnd = lcg(7); - for (let y = 48; y < 240; y++) for (let x = 340; x < 740; x++) { - const v = 120 + Math.floor(rnd() * 120); - const p = (y * w + x) * 4; img.data[p] = v; img.data[p + 1] = v; img.data[p + 2] = v + 10; - } - // table with red gutter - fillRect(img, 0, 260, w, 2, [19, 33, 48, 255]); - for (let i = 0; i < 3; i++) { - const y = 270 + i * 60; - fillRect(img, 0, y, 10, 50, i === 0 ? [176, 40, 32, 255] : [19, 33, 48, 255]); - fillRect(img, 30, y + 10, 300, 14, [19, 33, 48, 255]); - fillRect(img, 30, y + 30, 200, 8, [120, 120, 120, 255]); - fillRect(img, 0, y + 56, w, 1, [19, 33, 48, 255]); - } - // CTA - fillRect(img, 24, 460, 160, 36, [19, 33, 48, 255]); - return img; -} - -function flattenIllustration(comp) { - const img = { ...comp, data: new Uint8Array(comp.data) }; - fillRect(img, 340, 48, 400, 192, [200, 200, 205, 255]); // a flat gray box where the plate was - return img; -} - -function recolor(comp) { - const img = { ...comp, data: new Uint8Array(comp.data) }; - for (let i = 0; i < img.data.length; i += 4) { - if (img.data[i] > 220 && img.data[i + 1] > 210) { img.data[i] = 20; img.data[i + 1] = 40; img.data[i + 2] = 60; } - } - return img; -} - -function shifted(comp, dx, dy) { - const img = createImage(comp.width, comp.height, [240, 237, 226, 255]); - blit(img, comp, dx, dy); - return img; -} - -function tallPage(comp) { - // a full-page screenshot: comp on top, then more page below - const img = createImage(comp.width, comp.height * 3, [240, 237, 226, 255]); - blit(img, comp, 0, 0); - fillRect(img, 0, comp.height + 40, comp.width, 200, [19, 33, 48, 255]); - return img; -} - -const SPEC = { - regions: [ - { id: 'masthead', kind: 'control', box: { x: 0, y: 0, w: 1, h: 32 / 512 } }, - { id: 'headline', kind: 'text', box: { x: 0.02, y: 60 / 512, w: 0.4, h: 100 / 512 } }, - { id: 'plate', kind: 'plate', box: { x: 340 / 768, y: 48 / 512, w: 400 / 768, h: 192 / 512 } }, - { id: 'table', kind: 'control', box: { x: 0, y: 260 / 512, w: 1, h: 190 / 512 } }, - ], -}; - -describe('png codec', () => { - it('round-trips RGBA through encode/decode', () => { - const img = createImage(20, 10, [10, 20, 30, 255]); - img.data.set([200, 100, 50, 128], (2 * 20 + 2) * 4); - const back = decodePng(encodePng(img, { text: { 'impeccable:prompt': 'hello' } })); - assert.equal(back.width, 20); assert.equal(back.height, 10); - assert.deepEqual([...back.data.subarray(0, 4)], [10, 20, 30, 255]); - assert.deepEqual([...back.data.subarray((2 * 20 + 2) * 4, (2 * 20 + 2) * 4 + 4)], [200, 100, 50, 128]); - assert.equal(back.text['impeccable:prompt'], 'hello'); - }); - - it('decodes a real gpt-image / Playwright style PNG when one is on disk (skips otherwise)', () => { - const sample = path.join(ROOT, 'tests', 'fixtures', 'comp-fidelity', 'sample.png'); - if (!fs.existsSync(sample)) return; - const img = decodePng(fs.readFileSync(sample)); - assert.ok(img.width > 0 && img.height > 0); - }); -}); - -describe('image metrics', () => { - const comp = makeComp(); - it('identity scores 1', () => { - assert.ok(structureScore(comp, comp) > 0.999); - assert.ok(detailScore(comp, comp).score > 0.999); - }); - it('dominant colors find the ground and the ink', () => { - const cols = dominantColors(comp).map((c) => c.hex); - assert.ok(cols.some((h) => h.startsWith('#f') || h.startsWith('#e')), `ground missing in ${cols}`); - assert.ok(cols.some((h) => h.startsWith('#1') || h.startsWith('#0') || h.startsWith('#2')), `ink missing in ${cols}`); - }); - it('a flattened plate loses detail', () => { - const d = detailScore(comp, flattenIllustration(comp)); - assert.ok(d.score < 0.85, `expected detail loss, got ${d.score}`); - }); -}); - -describe('comp-diff compare', () => { - const comp = makeComp(); - - it('scores the comp against itself as a match everywhere', () => { - const r = compare({ comp, build: comp, spec: SPEC }); - assert.equal(r.whole.overall, 1); - for (const region of r.regions) assert.equal(region.verdict, 'match', region.id); - }); - - it('forgives a small translation', () => { - const r = compare({ comp, build: shifted(comp, 6, 4), spec: SPEC }); - assert.ok(r.whole.overall >= 0.8, `overall ${r.whole.overall}`); - assert.equal(verdictFor(r.whole), 'match'); - }); - - it('reads the top of a full-page screenshot as the first viewport', () => { - const r = compare({ comp, build: tallPage(comp), spec: SPEC }); - assert.equal(r.aligned.height, comp.height); - assert.ok(r.whole.overall > 0.95, `overall ${r.whole.overall}`); - }); - - it('flags a flattened plate region as missing while the rest matches', () => { - const r = compare({ comp, build: flattenIllustration(comp), spec: SPEC }); - const plate = r.regions.find((x) => x.id === 'plate'); - assert.equal(plate.verdict, 'missing'); - assert.equal(r.regions.find((x) => x.id === 'table').verdict, 'match'); - assert.equal(r.regions.find((x) => x.id === 'masthead').verdict, 'match'); - }); - - it('fails a recolored page on color and structure', () => { - const r = compare({ comp, build: recolor(comp), spec: SPEC }); - assert.ok(r.whole.color < 0.7, `color ${r.whole.color}`); - assert.ok(r.whole.overall < 0.6, `overall ${r.whole.overall}`); - assert.notEqual(verdictFor(r.whole), 'match'); - }); - - it('derives band regions when no spec is given', () => { - const r = compare({ comp, build: comp }); - assert.ok(r.regions.length >= 2); - assert.ok(r.regions.every((x) => x.kind === 'band')); - }); - - it('pads a shorter build with white so a truncated page reads as missing content', () => { - const half = crop(comp, 0, 0, comp.width, comp.height / 2); - const aligned = alignBuild(comp, half); - assert.equal(aligned.height, comp.height); - const r = compare({ comp, build: half, spec: SPEC }); - assert.notEqual(r.regions.find((x) => x.id === 'table').verdict, 'match'); - }); - - it('scales a build captured at a different width onto the comp', () => { - const wide = resize(comp, comp.width * 1.5, comp.height * 1.5); - const r = compare({ comp, build: wide, spec: SPEC }); - assert.ok(r.whole.overall > 0.9, `overall ${r.whole.overall}`); - }); -}); - -describe('comp-diff CLI', () => { - let dir; - before(() => { - dir = fs.mkdtempSync(path.join(os.tmpdir(), 'comp-diff-')); - const comp = makeComp(); - fs.writeFileSync(path.join(dir, 'comp.png'), encodePng(comp)); - fs.writeFileSync(path.join(dir, 'flat.png'), encodePng(flattenIllustration(comp))); - fs.writeFileSync(path.join(dir, 'spec.json'), JSON.stringify(SPEC)); - }); - - it('writes side-by-side, heatmap, region pairs, and report.json', () => { - const out = path.join(dir, 'diff'); - const res = spawnSync(process.execPath, [SCRIPT, '--comp', path.join(dir, 'comp.png'), '--build', path.join(dir, 'flat.png'), '--spec', path.join(dir, 'spec.json'), '--out-dir', out], { encoding: 'utf8' }); - assert.equal(res.status, 0, res.stderr + res.stdout); - assert.match(res.stdout, /^COMP-DIFF/m); - assert.match(res.stdout, /REGION plate\s+missing/); - for (const f of ['side-by-side.png', 'heatmap.png', 'report.json', path.join('regions', 'plate.png')]) { - assert.ok(fs.existsSync(path.join(out, f)), `${f} missing`); - } - const report = JSON.parse(fs.readFileSync(path.join(out, 'report.json'), 'utf8')); - assert.equal(report.tool, 'comp-diff'); - assert.equal(report.regions.length, 4); - assert.ok(report.palette.comp.length > 0); - // artifacts decode - const side = decodePng(fs.readFileSync(path.join(out, 'side-by-side.png'))); - assert.ok(side.width > 768); - }); - - it('exits 3 below --threshold and prints the instruction', () => { - const res = spawnSync(process.execPath, [SCRIPT, '--comp', path.join(dir, 'comp.png'), '--build', path.join(dir, 'flat.png'), '--no-files', '--threshold', '0.99'], { encoding: 'utf8' }); - assert.equal(res.status, 3); - assert.match(res.stdout, /BELOW THRESHOLD/); - }); - - it('--json prints the report', () => { - const res = spawnSync(process.execPath, [SCRIPT, '--comp', path.join(dir, 'comp.png'), '--build', path.join(dir, 'comp.png'), '--no-files', '--json'], { encoding: 'utf8' }); - assert.equal(res.status, 0); - const report = JSON.parse(res.stdout); - assert.equal(report.verdict, 'match'); - }); - - it('exits 1 with usage on missing args', () => { - const res = spawnSync(process.execPath, [SCRIPT], { encoding: 'utf8' }); - assert.equal(res.status, 1); - assert.match(res.stderr, /usage/); - }); -}); - -it('loadRaster reads a WebP comp through a sibling .png cache instead of rewriting the source', () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'raster-')); - const src = path.join(dir, 'comp.webp'); - const png = encodePng((() => { const i = createImage(24, 16); fillRect(i, 0, 0, 24, 16, [200, 40, 40, 255]); return i; })()); - fs.writeFileSync(path.join(dir, 'seed.png'), png); - let ok = true; - try { execFileSync('cwebp', ['-lossless', path.join(dir, 'seed.png'), '-o', src], { stdio: 'ignore' }); } catch { ok = false; } - if (!ok) return; // no cwebp on this machine: nothing to assert - const before = fs.readFileSync(src); - const { image, path: decoded } = loadRaster(src); - assert.equal(image.width, 24); - assert.equal(image.height, 16); - assert.equal(decoded, `${src}.png`); - assert.ok(fs.readFileSync(src).equals(before), 'source webp bytes untouched'); - assert.ok(fs.existsSync(`${src}.png`), 'sibling cache written'); -}); diff --git a/tests/font-match.test.mjs b/tests/font-match.test.mjs deleted file mode 100644 index 833a10401..000000000 --- a/tests/font-match.test.mjs +++ /dev/null @@ -1,192 +0,0 @@ -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import path from 'node:path'; -import { createRequire } from 'node:module'; -import { fileURLToPath } from 'node:url'; - -import { createImage, drawText } from '../skill/scripts/lib/raster.mjs'; -import { fingerprint, distance, FEATURES, STATS } from '../skill/scripts/lib/font-fingerprint.mjs'; -import { loadFontIndex, candidatesFromIndex, routeSize, packVector, unpackVector, INDEX_PATH, INDEX_FEATURES, ROUTE_CAP_PX, GROSS_FEATURES, NON_TEXT_FAMILY } from '../skill/scripts/lib/font-index.mjs'; -import { widthClass, weightClass, selectCandidates, SHORTLIST, renderCandidates } from '../skill/scripts/font-match.mjs'; - -const require = createRequire(import.meta.url); -const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const hasPlaywright = (() => { try { return !!require('playwright').chromium; } catch { return false; } })(); - -/** A white raster with a line of the bitmap font drawn on it, cap height 7 * scale px. */ -function textSample(text, scale = 6, { x = 20, y = 20 } = {}) { - const w = text.length * 6 * scale + 40, h = 7 * scale + 40; - const img = createImage(w, h, [255, 255, 255, 255]); - drawText(img, text, x, y, [0, 0, 0, 255], scale); - return img; -} - -describe('font-fingerprint', () => { - it('fingerprints a synthetic rendered-text PNG with the expected fields', () => { - const fp = fingerprint(textSample('HAMBURGEVONS THE QUICK BROWN FOX', 6)); - assert.ok(fp, 'fingerprint returned null'); - for (const k of ['lines', 'glyphs', 'capHeightPx', 'inkIsDark', 'allCaps', 'weight', ...FEATURES]) assert.ok(k in fp, `missing ${k}`); - assert.equal(fp.lines, 1); - assert.ok(fp.glyphs >= 20, `glyphs ${fp.glyphs}`); - assert.ok(Math.abs(fp.capHeightPx - 42) <= 2, `capHeightPx ${fp.capHeightPx}`); - assert.equal(fp.inkIsDark, true); - assert.equal(typeof fp.allCaps, 'boolean'); - assert.ok(fp.advTall > 0.5 && fp.advTall < 0.9, `advTall ${fp.advTall}`); - assert.ok(fp.densTall > 0.2 && fp.densTall < 0.9, `densTall ${fp.densTall}`); - assert.ok(fp.stemW > 0, `stemW ${fp.stemW}`); - }); - - it('is size-stable: the same text at 4x and 6x scale lands close, and a bolder sample farther', () => { - const a = fingerprint(textSample('HAMBURGEVONS THE QUICK BROWN FOX', 6)); - const b = fingerprint(textSample('HAMBURGEVONS THE QUICK BROWN FOX', 4)); - assert.equal(distance(a, a), 0); - const dSame = distance(a, b); - assert.ok(dSame > 0 && Number.isFinite(dSame), `dSame ${dSame}`); - // a different sample: same glyph set but drawn twice offset by one scale step, so every stem doubles (a heavier face) - const heavy = textSample('HAMBURGEVONS THE QUICK BROWN FOX', 6); - drawText(heavy, 'HAMBURGEVONS THE QUICK BROWN FOX', 20 + 4, 20, [0, 0, 0, 255], 6); - const c = fingerprint(heavy); - const dOther = distance(a, c); - assert.ok(dOther > dSame, `heavier sample (${dOther}) should be farther than a rescale (${dSame})`); - }); - - it('every weighted feature has a positive std, and distance skips features missing on either side', () => { - for (const k of FEATURES) { assert.ok(STATS[k], `no STATS for ${k}`); assert.ok(STATS[k].std > 0); } - const a = { advX: 0.5, densTall: 0.5, contrast: 1 }, b = { advX: 0.5, densTall: 0.5, contrast: null }; - assert.equal(distance(a, b), 0); - assert.equal(distance({}, {}), Infinity); - }); -}); - -describe('font-index', () => { - it('packs and unpacks a vector to three decimals, nulls preserved', () => { - const fp = { advX: 0.3649, densTall: 0.6719, contrast: null, serif: 12.3456 }; - const back = unpackVector(packVector(fp)); - assert.equal(back.advX, 0.365); - assert.equal(back.densTall, 0.672); - assert.equal(back.contrast, null); - assert.equal(back.serif, 12.346); - assert.equal(back.gap, null, 'absent feature reads as null'); - }); - - it('stores the features the fitted distance weights plus the gross width and weight readings', () => { - assert.ok(INDEX_FEATURES.length >= 30); - for (const k of INDEX_FEATURES) assert.ok(STATS[k].w > 0 || GROSS_FEATURES.includes(k), `${k} is indexed without weight or gross role`); - for (const k of FEATURES) if (STATS[k].w === 0 && !GROSS_FEATURES.includes(k)) assert.ok(!INDEX_FEATURES.includes(k), `${k} has zero weight and should not be indexed`); - for (const k of GROSS_FEATURES) assert.ok(INDEX_FEATURES.includes(k), `${k} is a gross reading and must be indexed`); - }); - - it('ships a three-render catalog index under 1.5 MB with > 2500 entries and the expected keys', () => { - assert.ok(fs.existsSync(INDEX_PATH), `missing ${INDEX_PATH}`); - assert.ok(fs.statSync(INDEX_PATH).size < 1.5 * 1024 * 1024, 'index over 1.5 MB'); - const index = loadFontIndex(); - assert.ok(index.entries.length > 2500, `entries ${index.entries.length}`); - assert.deepEqual([...index.sizes].map(String).sort(), ['14', '48', '48c']); - assert.deepEqual(index.features, INDEX_FEATURES); - for (const e of index.entries.slice(0, 50)) { - for (const k of ['family', 'weight', 'category', 'variable', 'fp']) assert.ok(k in e, `entry missing ${k}`); - assert.ok(['sans', 'serif', 'display', 'handwriting', 'mono'].includes(e.category), e.category); - assert.ok(e.fp[48], 'no 48px vector'); - for (const k of INDEX_FEATURES) assert.ok(k in e.fp[48]); - } - const lg = index.entries.find((e) => e.family === 'League Gothic'); - assert.ok(lg && lg.fp[48] && lg.fp[14] && lg.fp['48c'], 'League Gothic at all three renders'); - assert.ok(lg.fp['48c'].advTall != null && lg.fp['48c'].densTall != null, 'gross readings stored on the caps render'); - assert.ok(lg.fp[48].advX < 0.45, `League Gothic reads condensed: advX ${lg.fp[48].advX}`); - const with14 = index.entries.filter((e) => e.fp[14]).length; - assert.ok(with14 > 2500, `14px vectors ${with14}`); - }); - - it('routes candidate selection by cap height and returns 25 entries', () => { - const index = loadFontIndex(); - const lg = index.entries.find((e) => e.family === 'League Gothic'); - assert.equal(routeSize(72), 48); - assert.equal(routeSize(ROUTE_CAP_PX - 1), 14); - assert.equal(routeSize(72, index.sizes, { allCaps: true }), '48c', 'caps crops route to the caps render'); - assert.equal(routeSize(ROUTE_CAP_PX - 1, index.sizes, { allCaps: true }), 14, 'small caps crops still route to the small size'); - assert.equal(routeSize(72, [48, 14], { allCaps: true }), 48, 'a schema-1 index without 48c falls back'); - // barcode / effect faces never come back as candidates - const capsFp = { ...lg.fp['48c'], capHeightPx: 72, allCaps: true }; - const caps = candidatesFromIndex(capsFp, index, { n: 25 }); - assert.equal(caps[0].family, 'League Gothic'); - assert.equal(caps[0].size, '48c'); - assert.ok(caps.every((c) => !NON_TEXT_FAMILY.test(c.family))); - assert.ok(NON_TEXT_FAMILY.test('Libre Barcode 128 Text') && NON_TEXT_FAMILY.test('Redacted') && !NON_TEXT_FAMILY.test('Rubik') && !NON_TEXT_FAMILY.test('Bungee')); - const big = candidatesFromIndex({ ...lg.fp[48], capHeightPx: 72 }, index, { n: 25 }); - assert.equal(big.length, 25); - assert.equal(big[0].family, 'League Gothic'); - assert.equal(big[0].size, 48); - const small = candidatesFromIndex({ ...lg.fp[14], capHeightPx: 14 }, index, { n: 25 }); - assert.equal(small.length, 25); - assert.equal(small[0].family, 'League Gothic'); - assert.equal(small[0].size, 14); - for (const c of big) for (const k of ['family', 'weight', 'category', 'variable', 'd', 'size']) assert.ok(k in c); - const sans = candidatesFromIndex({ ...lg.fp[48], capHeightPx: 72 }, index, { n: 10, category: 'serif' }); - assert.equal(sans.length, 10); - assert.ok(sans.every((c) => c.category === 'serif')); - }); -}); - -describe('font-match', () => { - it('reads width and weight classes off the v2 features with catalog-anchored thresholds', () => { - const index = loadFontIndex(); - const at = (family, weight) => index.entries.find((e) => e.family === family && e.weight === weight).fp[48]; - assert.equal(widthClass(at('League Gothic', 400)), 'compressed'); - assert.equal(widthClass(at('Oswald', 700)), 'condensed'); - assert.equal(widthClass(at('Inter', 400)), 'normal'); - assert.equal(widthClass(at('Archivo Black', 400)), 'wide'); - assert.equal(weightClass(at('Lato', 300)), 'light'); - assert.equal(weightClass(at('Inter', 400)), 'regular'); - assert.equal(weightClass(at('Roboto', 700)), 'bold'); - assert.equal(weightClass(at('Anton', 400)), 'black'); - // all-caps crop: falls through to advTall - assert.equal(widthClass({ advX: null, advTall: 0.48 }), 'condensed'); - assert.equal(weightClass({ densTall: null, densX: null, stemW: 0.09 }), 'light'); - }); - - it('selects candidates from the index, keeps the caller names first, and uses the shortlist only without an index', () => { - const index = loadFontIndex(); - const lg = { ...index.entries.find((e) => e.family === 'League Gothic').fp[48], capHeightPx: 72 }; - const own = [{ family: 'Bebas Neue', weight: 400 }]; - const r = selectCandidates(lg, { own, index, n: 25 }); - assert.equal(r.source, 'index'); - assert.equal(r.candidates[0].family, 'Bebas Neue'); - assert.equal(r.catalog.length, 25); - assert.ok(r.candidates.length >= 25 && r.candidates.length <= 26); - assert.ok(!r.candidates.some((c) => SHORTLIST.wide.includes(`${c.family}:${c.weight}`)), 'shortlist must not leak in when the index is present'); - const noIdx = selectCandidates(lg, { own, index: null }); - assert.equal(noIdx.source, 'shortlist'); - assert.ok(noIdx.candidates.some((c) => c.family === 'Six Caps'), 'compressed shortlist used'); - }); - - it('renders and ranks candidates against a League Gothic sample (browser)', { skip: !hasPlaywright && 'playwright not resolvable' }, async () => { - const results = await renderCandidates([{ family: 'League Gothic', weight: 400 }, { family: 'Inter', weight: 400 }], 'The manuals stop.', 48); - if (!results) return; // module resolves but no browser binary (CI): the catalog fallback owns it - const ok = results.filter((r) => r.loaded && r.fp); - if (ok.length < 2) return; // offline: Google Fonts unreachable - const index = loadFontIndex(); - const lg = index.entries.find((e) => e.family === 'League Gothic').fp[48]; - const inter = index.entries.find((e) => e.family === 'Inter' && e.weight === 400).fp[48]; - const rLg = ok.find((r) => r.family === 'League Gothic'), rIn = ok.find((r) => r.family === 'Inter'); - assert.ok(distance(rLg.fp, lg) < distance(rLg.fp, inter), 'rendered League Gothic is nearer its own index entry'); - assert.ok(distance(rIn.fp, inter) < distance(rIn.fp, lg), 'rendered Inter is nearer its own index entry'); - }); -}); - -describe('font-fingerprint on mixed crops', () => { - it('measures the body copy, not the drawing beside it or the headline clipped above it', () => { - // 460x300 crop: one clipped headline line at the top (cap ~34), five lines - // of body copy (cap ~10), and a dense drawing (a noise block) at the bottom - const img = createImage(460, 300, [235, 232, 220, 255]); - drawText(img, 'THE MANIFOLDS', 4, 2, [20, 20, 20, 255], 5); - for (let i = 0; i < 5; i++) drawText(img, 'fresh cables slides return cleanly and the', 4, 60 + i * 24, [20, 20, 20, 255], 2); - let seed = 7; const rnd = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 0xffffffff); - for (let y = 190; y < 300; y++) for (let x = 0; x < 300; x++) { const v = 40 + Math.floor(rnd() * 180); const p = (y * 460 + x) * 4; img.data[p] = v; img.data[p + 1] = v; img.data[p + 2] = v; } - const fp = fingerprint(img); - assert.ok(fp, 'lettering found'); - assert.ok(fp.capHeightPx >= 8 && fp.capHeightPx <= 14, `body cap measured, got ${fp.capHeightPx}`); - assert.ok(fp.lines >= 4, `body lines, got ${fp.lines}`); - assert.ok(fp.isolatedFrom >= 1, 'the headline line was set aside'); - }); -}); diff --git a/tests/hero-checks.test.mjs b/tests/hero-checks.test.mjs deleted file mode 100644 index 83828a350..000000000 --- a/tests/hero-checks.test.mjs +++ /dev/null @@ -1,76 +0,0 @@ -import { describe, it } from 'node:test'; -import assert from 'node:assert/strict'; -import { createImage, fillRect, drawText } from '../skill/scripts/lib/raster.mjs'; -import { textRegionCheck, chromeStripCheck, inventedInk, ruleRows, inkColor } from '../skill/scripts/lib/hero-checks.mjs'; - -const paper = [235, 232, 220, 255], ink = [20, 20, 20, 255]; -function textCrop({ scale = 4, lines = 2, color = ink, y0 = 20, x0 = 12, w = 460, h = 200 } = {}) { - const img = createImage(w, h, paper); - for (let i = 0; i < lines; i++) drawText(img, 'KEEP OLD IRON', x0, y0 + i * (scale * 9), color, scale); - return img; -} - -describe('hero-checks: text regions', () => { - const region = { id: 'headline', kind: 'text', type: { chosen: { family: 'Six Caps', weight: 400, fontSizePx: 40 } } }; - it('says nothing when the build sets the type as the comp does', () => { - const { findings } = textRegionCheck(region, textCrop(), textCrop()); - assert.deepEqual(findings, []); - }); - it('names a cap-height miss with the ranked face and size', () => { - const { findings } = textRegionCheck(region, textCrop({ scale: 4 }), textCrop({ scale: 6, lines: 2 })); - assert.ok(findings.some((f) => /cap height .*px in the build, .*px in the comp/.test(f) && /Six Caps 400 at 40px/.test(f)), findings.join('\n')); - }); - it('names a different line count', () => { - const { findings } = textRegionCheck(region, textCrop({ lines: 3 }), textCrop({ lines: 2 })); - assert.ok(findings.some((f) => /2 lines in the build, 3 in the comp/.test(f)), findings.join('\n')); - }); - it('names a colour change and a vertical shift', () => { - const { findings } = textRegionCheck(region, textCrop(), textCrop({ color: [180, 40, 30, 255], y0: 80 })); - assert.ok(findings.some((f) => /ink is #/.test(f)), findings.join('\n')); - assert.ok(findings.some((f) => /starts 60px lower/.test(f)), findings.join('\n')); - }); - it('stays quiet on rotated or unmeasurable comp crops', () => { - const blank = createImage(300, 300, paper); - assert.deepEqual(textRegionCheck(region, blank, textCrop()).findings, []); - }); -}); - -describe('hero-checks: chrome strips and invented ink', () => { - it('reads a strip height off its rule', () => { - const mk = (ruleY) => { const img = createImage(800, 100, paper); fillRect(img, 0, ruleY, 800, 2, ink); drawText(img, 'THREADS GARAGE', 20, 12, ink, 2); return img; }; - assert.deepEqual(ruleRows(mk(60)), [59]); // the edge row above the rule - const { findings } = chromeStripCheck({ id: 'masthead', kind: 'chrome' }, mk(44), mk(70)); - assert.ok(findings.some((f) => /43px into the box in the comp and 69px in the build/.test(f)), findings.join('\n')); - assert.deepEqual(chromeStripCheck({ id: 'masthead', kind: 'chrome' }, mk(44), mk(47)).findings, []); - }); - it('lists cells where the build carries ink over a calm comp', () => { - const comp = createImage(1000, 1000, paper); - const build = createImage(1000, 1000, paper); - drawText(build, 'SECTION KICKER', 20, 20, ink, 3); - fillRect(build, 100, 500, 800, 2, ink); - const r = inventedInk(comp, build); - assert.ok(r.cells.length >= 3, `cells ${r.cells.length}`); - assert.ok(r.cells.some((c) => c.row === 0), 'the kicker row'); - assert.deepEqual(inventedInk(comp, comp).cells, []); - }); - it('inkColor separates ink from ground', () => { - const c = inkColor(textCrop({ color: [180, 40, 30, 255] })); - 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 = ''; - const chevron = ''; - const sprite = ''; - const diagram = '' + Array.from({ length: 30 }, (_, i) => ``).join('') + ''; - const staff = '' + Array.from({ length: 12 }, (_, i) => ``).join('') + ''; - 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); - }); -}); diff --git a/tests/oracle/cases/comp.mjs b/tests/oracle/cases/comp.mjs new file mode 100644 index 000000000..9a1f24671 --- /dev/null +++ b/tests/oracle/cases/comp.mjs @@ -0,0 +1,83 @@ +/** + * Corpus for the comp-fidelity verbs: `comp-spec`, `comp-diff`, `font-match`, + * `build-phase`. Only the deterministic, browser-free paths are exercised here + * (the font-match browser ranking and the comp-diff artifact PNGs vary by + * Chrome/encoder and are covered by Rust tests instead). + * + * Workspace tests/oracle/workspaces/comp-basic: + * comp.png a 768x512 comp fixture (from crates/comp/tests/fixtures) + * build.png a recolored build capture of the same composition + * regions.json three regions (chrome / plate / text) with allowUncovered + * spec.json a pre-measured spec of comp.png+regions.json (the fixture the + * print / diff / measure cases read; the regions case rewrites + * its own under .impeccable/build/) + * + * ISO timestamps in stdout and in the written spec/state are masked by the + * harness, so the createdAt/startedAt fields never make a golden run-dependent. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +const WS = 'comp-basic'; + +const write = (ws, rel, body) => { + const abs = path.join(ws, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, body); +}; + +// The comp verbs emit no staleness/update directives, but pin the catalog and +// skill-dir overrides so a recording machine's env never leaks a font-index +// path or a native reference into the output. +const BASE_ENV = { + IMPECCABLE_CATALOG_DIR: null, + IMPECCABLE_SKILL_DIR: null, + IMPECCABLE_SELF: null, + IMPECCABLE_BROWSER: null, + PUPPETEER_EXECUTABLE_PATH: null, + CHROME_PATH: null, + CI: null, +}; +const env = (extra = {}) => ({ ...BASE_ENV, ...extra }); + +const cases = [ + // comp-spec: grid readout (stdout only), full measure (writes spec.json), print. + { id: 'comp-spec-grid', verb: 'comp-spec', workspace: WS, args: ['--comp', 'comp.png', '--grid'], env: env() }, + { + id: 'comp-spec-regions', verb: 'comp-spec', workspace: WS, + args: ['--comp', 'comp.png', '--regions', 'regions.json'], + files: ['.impeccable/build/spec.json'], env: env(), + }, + { id: 'comp-spec-print', verb: 'comp-spec', workspace: WS, args: ['--print', '--spec', 'spec.json'], env: env() }, + { id: 'comp-spec-plate-prompt', verb: 'comp-spec', workspace: WS, args: ['--plate-prompt', 'art', '--spec', 'spec.json'], env: env() }, + { id: 'comp-spec-usage', verb: 'comp-spec', workspace: WS, args: [], env: env() }, + { + id: 'comp-spec-refuses-painted-chrome', verb: 'comp-spec', workspace: WS, + setup: (ws) => write(ws, 'bad.json', JSON.stringify({ allowUncovered: true, regions: [{ id: 'x', kind: 'chrome', grid: 'A0:B1', note: 'an exploded diagram illustration' }] })), + args: ['--comp', 'comp.png', '--regions', 'bad.json'], env: env(), + }, + + // comp-diff: with a spec (region rows) and without (derived bands). + { id: 'comp-diff-json', verb: 'comp-diff', workspace: WS, args: ['--comp', 'comp.png', '--build', 'build.png', '--spec', 'spec.json', '--no-files', '--json'], env: env() }, + { id: 'comp-diff-text', verb: 'comp-diff', workspace: WS, args: ['--comp', 'comp.png', '--build', 'build.png', '--spec', 'spec.json', '--no-files'], env: env() }, + { id: 'comp-diff-no-spec', verb: 'comp-diff', workspace: WS, args: ['--comp', 'comp.png', '--build', 'build.png', '--no-files', '--json'], env: env() }, + { id: 'comp-diff-threshold-below', verb: 'comp-diff', workspace: WS, args: ['--comp', 'comp.png', '--build', 'build.png', '--spec', 'spec.json', '--no-files', '--threshold', '0.95'], env: env() }, + { id: 'comp-diff-usage', verb: 'comp-diff', workspace: WS, args: ['--comp', 'comp.png'], env: env() }, + + // font-match: MEASURE (pure; writes type onto the region). RANK is skipped + // here because it needs a browser; the no-browser catalog fallback needs the + // moat's font-index, which is not present in this repo's oracle env. + { id: 'font-match-measure', verb: 'font-match', workspace: WS, args: ['--measure', 'body', '--spec', 'spec.json'], files: ['spec.json'], env: env() }, + { id: 'font-match-usage', verb: 'font-match', workspace: WS, args: [], env: env() }, + + // build-phase: start (reads comp dims) then status, sharing one workspace. + { + id: 'build-phase-start-status', verb: 'build-phase', workspace: WS, + files: ['.impeccable/build/state.json'], env: env(), + steps: [{ args: ['start', '--comp', 'comp.png'] }, { args: ['status'] }], + }, + { id: 'build-phase-usage', verb: 'build-phase', workspace: WS, args: [], env: env() }, +]; + +export default cases; diff --git a/tests/oracle/golden/build-phase-start-status.json b/tests/oracle/golden/build-phase-start-status.json new file mode 100644 index 000000000..141062c3b --- /dev/null +++ b/tests/oracle/golden/build-phase-start-status.json @@ -0,0 +1,19 @@ +{ + "steps": [ + { + "stdout": "BUILD-PHASE SPEC comp comp.png breakpoint 768x512\n comps skipped \n spec open \n plates pending \n hero pending \n sections pending \n motion pending \n responsive pending \n review pending \nNEXT Measure the comp: impeccable comp-spec --comp comp.png --grid, open .impeccable/build/comp-grid.png, write regions.json (every illustration, photo, texture as its own plate region; every text block its own text region), run impeccable comp-spec --comp comp.png --regions regions.json. Then measure the type: impeccable font-match --measure for each text region (cap height, width class, weight class) and impeccable font-match --rank --text \"\" to choose the headline face by metrics (the USE line is the CSS; with no browser it records the catalog's nearest face, which is the choice; do not install one, and do not write a chosen face into the spec by hand). Then impeccable build-phase advance.\n", + "stderr": "", + "exit": 0, + "signal": null + }, + { + "stdout": "BUILD-PHASE SPEC comp comp.png breakpoint 768x512\n comps skipped \n spec open \n plates pending \n hero pending \n sections pending \n motion pending \n responsive pending \n review pending \nNEXT Measure the comp: impeccable comp-spec --comp comp.png --grid, open .impeccable/build/comp-grid.png, write regions.json (every illustration, photo, texture as its own plate region; every text block its own text region), run impeccable comp-spec --comp comp.png --regions regions.json. Then measure the type: impeccable font-match --measure for each text region (cap height, width class, weight class) and impeccable font-match --rank --text \"\" to choose the headline face by metrics (the USE line is the CSS; with no browser it records the catalog's nearest face, which is the choice; do not install one, and do not write a chosen face into the spec by hand). Then impeccable build-phase advance.\n", + "stderr": "", + "exit": 0, + "signal": null + } + ], + "files": { + ".impeccable/build/state.json": "{\n \"tool\": \"build-phase\",\n \"version\": 2,\n \"startedAt\": \"\",\n \"comp\": \"comp.png\",\n \"direction\": null,\n \"breakpoint\": \"768x512\",\n \"artifact\": null,\n \"phase\": \"spec\",\n \"phases\": {\n \"comps\": {\n \"status\": \"skipped\",\n \"openedAt\": null,\n \"closedAt\": null,\n \"attempts\": 0,\n \"notes\": [\n {\n \"at\": \"\",\n \"text\": \"started with an approved comp; the comp round happened before this state (surface round or manual)\"\n }\n ],\n \"gate\": null,\n \"forced\": null\n },\n \"spec\": {\n \"status\": \"open\",\n \"openedAt\": \"\",\n \"closedAt\": null,\n \"attempts\": 0,\n \"notes\": [],\n \"gate\": null,\n \"forced\": null\n },\n \"plates\": {\n \"status\": \"pending\",\n \"openedAt\": null,\n \"closedAt\": null,\n \"attempts\": 0,\n \"notes\": [],\n \"gate\": null,\n \"forced\": null\n },\n \"hero\": {\n \"status\": \"pending\",\n \"openedAt\": null,\n \"closedAt\": null,\n \"attempts\": 0,\n \"notes\": [],\n \"gate\": null,\n \"forced\": null\n },\n \"sections\": {\n \"status\": \"pending\",\n \"openedAt\": null,\n \"closedAt\": null,\n \"attempts\": 0,\n \"notes\": [],\n \"gate\": null,\n \"forced\": null\n },\n \"motion\": {\n \"status\": \"pending\",\n \"openedAt\": null,\n \"closedAt\": null,\n \"attempts\": 0,\n \"notes\": [],\n \"gate\": null,\n \"forced\": null\n },\n \"responsive\": {\n \"status\": \"pending\",\n \"openedAt\": null,\n \"closedAt\": null,\n \"attempts\": 0,\n \"notes\": [],\n \"gate\": null,\n \"forced\": null\n },\n \"review\": {\n \"status\": \"pending\",\n \"openedAt\": null,\n \"closedAt\": null,\n \"attempts\": 0,\n \"notes\": [],\n \"gate\": null,\n \"forced\": null\n }\n },\n \"finish\": null\n}" + } +} diff --git a/tests/oracle/golden/build-phase-usage.json b/tests/oracle/golden/build-phase-usage.json new file mode 100644 index 000000000..d923b6031 --- /dev/null +++ b/tests/oracle/golden/build-phase-usage.json @@ -0,0 +1,7 @@ +{ + "stdout": "", + "stderr": "usage: build-phase.mjs start --comp [--breakpoint WxH] | status [--json] | advance [--force --reason \"...\"] | record hero --build | scaffold | note \"\" | finish --disposition \n", + "exit": 1, + "signal": null, + "files": {} +} diff --git a/tests/oracle/golden/comp-diff-json.json b/tests/oracle/golden/comp-diff-json.json new file mode 100644 index 000000000..245cbf3b1 --- /dev/null +++ b/tests/oracle/golden/comp-diff-json.json @@ -0,0 +1,7 @@ +{ + "stdout": "{\n \"tool\": \"comp-diff\",\n \"version\": 1,\n \"createdAt\": \"\",\n \"label\": \"build\",\n \"comp\": \"comp.png\",\n \"build\": \"build.png\",\n \"spec\": \"spec.json\",\n \"compSize\": \"768x512\",\n \"buildSize\": \"768x512\",\n \"align\": \"top\",\n \"overall\": 0.8374,\n \"verdict\": \"match\",\n \"scores\": {\n \"overall\": 0.8374,\n \"structure\": 0.9846,\n \"color\": 0.8306,\n \"colorIntersection\": 0.8148,\n \"paletteMatch\": 0.8391,\n \"detail\": 0.5407,\n \"detailRaw\": 0.5407,\n \"detailAdded\": 0,\n \"bands\": 1\n },\n \"palette\": {\n \"comp\": [\n {\n \"hex\": \"#f7e8e8\",\n \"coverage\": 0.6852\n },\n {\n \"hex\": \"#182838\",\n \"coverage\": 0.14\n },\n {\n \"hex\": \"#9a9aa5\",\n \"coverage\": 0.0682\n },\n {\n \"hex\": \"#c5c5d1\",\n \"coverage\": 0.067\n },\n {\n \"hex\": \"#7c7c82\",\n \"coverage\": 0.0373\n },\n {\n \"hex\": \"#b82828\",\n \"coverage\": 0.0022\n }\n ],\n \"build\": [\n {\n \"hex\": \"#ede1e1\",\n \"coverage\": 0.844\n },\n {\n \"hex\": \"#182838\",\n \"coverage\": 0.14\n },\n {\n \"hex\": \"#787878\",\n \"coverage\": 0.0138\n },\n {\n \"hex\": \"#b82828\",\n \"coverage\": 0.0022\n }\n ]\n },\n \"regions\": [\n {\n \"id\": \"top\",\n \"x\": 0,\n \"y\": 0,\n \"w\": 1,\n \"h\": 0.2,\n \"kind\": \"chrome\",\n \"score\": {\n \"overall\": 0.8167,\n \"structure\": 0.9826,\n \"color\": 0.7489,\n \"colorIntersection\": 0.7389,\n \"paletteMatch\": 0.7544,\n \"detail\": 0.5422,\n \"detailRaw\": 0.5422,\n \"detailAdded\": 0,\n \"bands\": 1\n },\n \"verdict\": \"match\",\n \"inkBox\": {\n \"comp\": {\n \"x\": 0,\n \"y\": 0,\n \"w\": 768,\n \"h\": 102\n },\n \"build\": {\n \"x\": 0,\n \"y\": 0,\n \"w\": 768,\n \"h\": 102\n }\n }\n },\n {\n \"id\": \"art\",\n \"x\": 0.4,\n \"y\": 0.2,\n \"w\": 0.6,\n \"h\": 0.3,\n \"kind\": \"plate\",\n \"score\": {\n \"overall\": 0.3551,\n \"structure\": 0.8561,\n \"color\": 0.4159,\n \"colorIntersection\": 0.2608,\n \"paletteMatch\": 0.4994,\n \"detail\": 0.0158,\n \"detailRaw\": 0.0158,\n \"detailAdded\": 0,\n \"bands\": 1\n },\n \"verdict\": \"missing\",\n \"inkBox\": {\n \"comp\": {\n \"x\": 33,\n \"y\": 0,\n \"w\": 400,\n \"h\": 138\n },\n \"build\": null\n }\n },\n {\n \"id\": \"body\",\n \"x\": 0,\n \"y\": 0.5,\n \"w\": 0.4,\n \"h\": 0.4,\n \"kind\": \"text\",\n \"score\": {\n \"overall\": 1,\n \"structure\": 1,\n \"color\": 1,\n \"colorIntersection\": 1,\n \"paletteMatch\": 1,\n \"detail\": 1,\n \"detailRaw\": 1,\n \"detailAdded\": 0,\n \"bands\": 1\n },\n \"verdict\": \"match\",\n \"inkBox\": {\n \"comp\": {\n \"x\": 0,\n \"y\": 4,\n \"w\": 307,\n \"h\": 201\n },\n \"build\": {\n \"x\": 0,\n \"y\": 4,\n \"w\": 307,\n \"h\": 201\n }\n }\n }\n ],\n \"files\": null\n}\n", + "stderr": "", + "exit": 0, + "signal": null, + "files": {} +} diff --git a/tests/oracle/golden/comp-diff-no-spec.json b/tests/oracle/golden/comp-diff-no-spec.json new file mode 100644 index 000000000..3199d9521 --- /dev/null +++ b/tests/oracle/golden/comp-diff-no-spec.json @@ -0,0 +1,7 @@ +{ + "stdout": "{\n \"tool\": \"comp-diff\",\n \"version\": 1,\n \"createdAt\": \"\",\n \"label\": \"build\",\n \"comp\": \"comp.png\",\n \"build\": \"build.png\",\n \"spec\": null,\n \"compSize\": \"768x512\",\n \"buildSize\": \"768x512\",\n \"align\": \"top\",\n \"overall\": 0.8374,\n \"verdict\": \"match\",\n \"scores\": {\n \"overall\": 0.8374,\n \"structure\": 0.9846,\n \"color\": 0.8306,\n \"colorIntersection\": 0.8148,\n \"paletteMatch\": 0.8391,\n \"detail\": 0.5407,\n \"detailRaw\": 0.5407,\n \"detailAdded\": 0,\n \"bands\": 1\n },\n \"palette\": {\n \"comp\": [\n {\n \"hex\": \"#f7e8e8\",\n \"coverage\": 0.6852\n },\n {\n \"hex\": \"#182838\",\n \"coverage\": 0.14\n },\n {\n \"hex\": \"#9a9aa5\",\n \"coverage\": 0.0682\n },\n {\n \"hex\": \"#c5c5d1\",\n \"coverage\": 0.067\n },\n {\n \"hex\": \"#7c7c82\",\n \"coverage\": 0.0373\n },\n {\n \"hex\": \"#b82828\",\n \"coverage\": 0.0022\n }\n ],\n \"build\": [\n {\n \"hex\": \"#ede1e1\",\n \"coverage\": 0.844\n },\n {\n \"hex\": \"#182838\",\n \"coverage\": 0.14\n },\n {\n \"hex\": \"#787878\",\n \"coverage\": 0.0138\n },\n {\n \"hex\": \"#b82828\",\n \"coverage\": 0.0022\n }\n ]\n },\n \"regions\": [\n {\n \"id\": \"band-1\",\n \"x\": 0,\n \"y\": 0,\n \"w\": 1,\n \"h\": 0.09411764705882353,\n \"kind\": \"band\",\n \"score\": {\n \"overall\": 1,\n \"structure\": 1,\n \"color\": 1,\n \"colorIntersection\": 1,\n \"paletteMatch\": 1,\n \"detail\": 1,\n \"detailRaw\": 1,\n \"detailAdded\": 0,\n \"bands\": 1\n },\n \"verdict\": \"match\",\n \"inkBox\": {\n \"comp\": {\n \"x\": 0,\n \"y\": 8,\n \"w\": 768,\n \"h\": 40\n },\n \"build\": {\n \"x\": 0,\n \"y\": 8,\n \"w\": 768,\n \"h\": 40\n }\n }\n },\n {\n \"id\": \"band-2\",\n \"x\": 0,\n \"y\": 0.09411764705882353,\n \"w\": 1,\n \"h\": 0.0823529411764706,\n \"kind\": \"band\",\n \"score\": {\n \"overall\": 0.6951,\n \"structure\": 0.9814,\n \"color\": 0.5732,\n \"colorIntersection\": 0.5482,\n \"paletteMatch\": 0.5866,\n \"detail\": 0.2333,\n \"detailRaw\": 0.2333,\n \"detailAdded\": 0,\n \"bands\": 1\n },\n \"verdict\": \"drift\",\n \"inkBox\": {\n \"comp\": {\n \"x\": 24,\n \"y\": 3,\n \"w\": 716,\n \"h\": 45\n },\n \"build\": {\n \"x\": 24,\n \"y\": 25,\n \"w\": 280,\n \"h\": 22\n }\n }\n },\n {\n \"id\": \"band-3\",\n \"x\": 0,\n \"y\": 0.17647058823529413,\n \"w\": 1,\n \"h\": 0.2941176470588235,\n \"kind\": \"band\",\n \"score\": {\n \"overall\": 0.662,\n \"structure\": 0.9645,\n \"color\": 0.539,\n \"colorIntersection\": 0.5123,\n \"paletteMatch\": 0.5534,\n \"detail\": 0.1585,\n \"detailRaw\": 0.1585,\n \"detailAdded\": 0,\n \"bands\": 1\n },\n \"verdict\": \"drift\",\n \"inkBox\": {\n \"comp\": {\n \"x\": 24,\n \"y\": 0,\n \"w\": 716,\n \"h\": 150\n },\n \"build\": {\n \"x\": 24,\n \"y\": 0,\n \"w\": 280,\n \"h\": 50\n }\n }\n },\n {\n \"id\": \"band-4\",\n \"x\": 0,\n \"y\": 0.47058823529411764,\n \"w\": 1,\n \"h\": 0.07058823529411762,\n \"kind\": \"band\",\n \"score\": {\n \"overall\": 0.9468,\n \"structure\": 0.9934,\n \"color\": 0.9286,\n \"colorIntersection\": 0.9385,\n \"paletteMatch\": 0.9232,\n \"detail\": 0.868,\n \"detailRaw\": 0.868,\n \"detailAdded\": 0,\n \"bands\": 1\n },\n \"verdict\": \"match\",\n \"inkBox\": {\n \"comp\": {\n \"x\": 0,\n \"y\": 0,\n \"w\": 768,\n \"h\": 48\n },\n \"build\": {\n \"x\": 0,\n \"y\": 25,\n \"w\": 768,\n \"h\": 23\n }\n }\n },\n {\n \"id\": \"band-5\",\n \"x\": 0,\n \"y\": 0.5411764705882353,\n \"w\": 1,\n \"h\": 0.09411764705882353,\n \"kind\": \"band\",\n \"score\": {\n \"overall\": 1,\n \"structure\": 1,\n \"color\": 1,\n \"colorIntersection\": 1,\n \"paletteMatch\": 1,\n \"detail\": 1,\n \"detailRaw\": 1,\n \"detailAdded\": 0,\n \"bands\": 1\n },\n \"verdict\": \"match\",\n \"inkBox\": {\n \"comp\": {\n \"x\": 0,\n \"y\": 0,\n \"w\": 330,\n \"h\": 43\n },\n \"build\": {\n \"x\": 0,\n \"y\": 0,\n \"w\": 330,\n \"h\": 43\n }\n }\n },\n {\n \"id\": \"band-6\",\n \"x\": 0,\n \"y\": 0.6352941176470588,\n \"w\": 1,\n \"h\": 0.08235294117647063,\n \"kind\": \"band\",\n \"score\": {\n \"overall\": 1,\n \"structure\": 1,\n \"color\": 1,\n \"colorIntersection\": 1,\n \"paletteMatch\": 1,\n \"detail\": 1,\n \"detailRaw\": 1,\n \"detailAdded\": 0,\n \"bands\": 1\n },\n \"verdict\": \"match\",\n \"inkBox\": {\n \"comp\": {\n \"x\": 0,\n \"y\": 4,\n \"w\": 768,\n \"h\": 44\n },\n \"build\": {\n \"x\": 0,\n \"y\": 4,\n \"w\": 768,\n \"h\": 44\n }\n }\n },\n {\n \"id\": \"band-7\",\n \"x\": 0,\n \"y\": 0.7176470588235294,\n \"w\": 1,\n \"h\": 0.09411764705882353,\n \"kind\": \"band\",\n \"score\": {\n \"overall\": 1,\n \"structure\": 1,\n \"color\": 1,\n \"colorIntersection\": 1,\n \"paletteMatch\": 1,\n \"detail\": 1,\n \"detailRaw\": 1,\n \"detailAdded\": 0,\n \"bands\": 1\n },\n \"verdict\": \"match\",\n \"inkBox\": {\n \"comp\": {\n \"x\": 0,\n \"y\": 0,\n \"w\": 768,\n \"h\": 49\n },\n \"build\": {\n \"x\": 0,\n \"y\": 0,\n \"w\": 768,\n \"h\": 49\n }\n }\n },\n {\n \"id\": \"band-8\",\n \"x\": 0,\n \"y\": 0.8117647058823529,\n \"w\": 1,\n \"h\": 0.07058823529411762,\n \"kind\": \"band\",\n \"score\": {\n \"overall\": 1,\n \"structure\": 1,\n \"color\": 1,\n \"colorIntersection\": 1,\n \"paletteMatch\": 1,\n \"detail\": 1,\n \"detailRaw\": 1,\n \"detailAdded\": 0,\n \"bands\": 1\n },\n \"verdict\": \"match\",\n \"inkBox\": {\n \"comp\": {\n \"x\": 0,\n \"y\": 0,\n \"w\": 768,\n \"h\": 37\n },\n \"build\": {\n \"x\": 0,\n \"y\": 0,\n \"w\": 768,\n \"h\": 37\n }\n }\n },\n {\n \"id\": \"band-9\",\n \"x\": 0,\n \"y\": 0.8823529411764706,\n \"w\": 1,\n \"h\": 0.09411764705882353,\n \"kind\": \"band\",\n \"score\": {\n \"overall\": 1,\n \"structure\": 1,\n \"color\": 1,\n \"colorIntersection\": 1,\n \"paletteMatch\": 1,\n \"detail\": 1,\n \"detailRaw\": 1,\n \"detailAdded\": 0,\n \"bands\": 1\n },\n \"verdict\": \"match\",\n \"inkBox\": {\n \"comp\": {\n \"x\": 24,\n \"y\": 8,\n \"w\": 160,\n \"h\": 36\n },\n \"build\": {\n \"x\": 24,\n \"y\": 8,\n \"w\": 160,\n \"h\": 36\n }\n }\n },\n {\n \"id\": \"band-10\",\n \"x\": 0,\n \"y\": 0.9764705882352941,\n \"w\": 1,\n \"h\": 0.02352941176470591,\n \"kind\": \"band\",\n \"score\": {\n \"overall\": 1,\n \"structure\": 1,\n \"color\": 1,\n \"colorIntersection\": 1,\n \"paletteMatch\": 1,\n \"detail\": 1,\n \"detailRaw\": 1,\n \"detailAdded\": 0,\n \"bands\": 1\n },\n \"verdict\": \"match\",\n \"inkBox\": {\n \"comp\": {\n \"x\": 24,\n \"y\": 0,\n \"w\": 160,\n \"h\": 14\n },\n \"build\": {\n \"x\": 24,\n \"y\": 0,\n \"w\": 160,\n \"h\": 14\n }\n }\n }\n ],\n \"files\": null\n}\n", + "stderr": "", + "exit": 0, + "signal": null, + "files": {} +} diff --git a/tests/oracle/golden/comp-diff-text.json b/tests/oracle/golden/comp-diff-text.json new file mode 100644 index 000000000..bef8c5b6a --- /dev/null +++ b/tests/oracle/golden/comp-diff-text.json @@ -0,0 +1,7 @@ +{ + "stdout": "COMP-DIFF [build] overall 84% (match) structure 98% color 83% detail 54% bands 100%\nPALETTE comp #f7e8e8(69%) #182838(14%) #9a9aa5(7%) #c5c5d1(7%) #7c7c82(4%)\nPALETTE build #ede1e1(84%) #182838(14%) #787878(1%) #b82828(0%)\nREGION top match 82% structure 98% color 75% detail 54%\nREGION art missing 36% structure 86% color 42% detail 2%\nREGION body match 100% structure 100% color 100% detail 100%\nWORST art (missing, 36%); top (match, 82%); body (match, 100%)\nOPEN the side-by-side and the worst region pairs before deciding anything; the numbers rank, the crops decide.\n", + "stderr": "", + "exit": 0, + "signal": null, + "files": {} +} diff --git a/tests/oracle/golden/comp-diff-threshold-below.json b/tests/oracle/golden/comp-diff-threshold-below.json new file mode 100644 index 000000000..9d0151607 --- /dev/null +++ b/tests/oracle/golden/comp-diff-threshold-below.json @@ -0,0 +1,7 @@ +{ + "stdout": "COMP-DIFF [build] overall 84% (match) structure 98% color 83% detail 54% bands 100%\nPALETTE comp #f7e8e8(69%) #182838(14%) #9a9aa5(7%) #c5c5d1(7%) #7c7c82(4%)\nPALETTE build #ede1e1(84%) #182838(14%) #787878(1%) #b82828(0%)\nREGION top match 82% structure 98% color 75% detail 54%\nREGION art missing 36% structure 86% color 42% detail 2%\nREGION body match 100% structure 100% color 100% detail 100%\nWORST art (missing, 36%); top (match, 82%); body (match, 100%)\nOPEN the side-by-side and the worst region pairs before deciding anything; the numbers rank, the crops decide.\nBELOW THRESHOLD 95%: the reproduction is not done. Fix the worst regions and re-run; do not build past the hero.\n", + "stderr": "", + "exit": 3, + "signal": null, + "files": {} +} diff --git a/tests/oracle/golden/comp-diff-usage.json b/tests/oracle/golden/comp-diff-usage.json new file mode 100644 index 000000000..238e3ab65 --- /dev/null +++ b/tests/oracle/golden/comp-diff-usage.json @@ -0,0 +1,7 @@ +{ + "stdout": "", + "stderr": "usage: comp-diff.mjs --comp --build [--spec spec.json] [--out-dir dir] [--align top|stretch] [--label name] [--threshold 0.75] [--json]\n", + "exit": 1, + "signal": null, + "files": {} +} diff --git a/tests/oracle/golden/comp-spec-grid.json b/tests/oracle/golden/comp-spec-grid.json new file mode 100644 index 000000000..8a2d98a40 --- /dev/null +++ b/tests/oracle/golden/comp-spec-grid.json @@ -0,0 +1,7 @@ +{ + "stdout": "GRID .impeccable/build/comp-grid.png (768x512 comp; cells A0 top-left to J9 bottom-right)\nPALETTE #f7e8e8(69%) #182838(14%) #9a9aa5(7%) #c5c5d1(7%) #7c7c82(4%)\nBANDS 6% 9% 14% 18% 20% 24% 47% 51% 54% 58% 60% 64% 66% 69% 72% 75% 78% 81% 84% 88% 91% 98%\nNEXT open the grid image, then write regions.json in exactly this shape and run --regions regions.json:\n { \"regions\": [ { \"id\": \"exploded-plate\", \"kind\": \"plate\", \"grid\": \"E0:H4\", \"note\": \"exploded carburetor drawing\" }, { \"id\": \"masthead\", \"kind\": \"chrome\", \"grid\": \"A0:J0\", \"note\": \"navy bar\" } ] }\n kind: plate | image | texture (painted material: every illustration, photograph, figure, product object, texture; each ships as a raster plate) or text | control | chrome (code draws it). grid: :, A0 top-left to J9 bottom-right, inclusive.\n A texture region is a clean sample cell of the material (ground with no ink on it), not the whole band it covers; the page tiles it. Ink that sits on the material gets its own text/control region.\n", + "stderr": "", + "exit": 0, + "signal": null, + "files": {} +} diff --git a/tests/oracle/golden/comp-spec-plate-prompt.json b/tests/oracle/golden/comp-spec-plate-prompt.json new file mode 100644 index 000000000..4b658dc38 --- /dev/null +++ b/tests/oracle/golden/comp-spec-plate-prompt.json @@ -0,0 +1,7 @@ +{ + "stdout": "Use the provided crop as the approved visual reference and recreate it as a clean production asset at the target aspect ratio. This is a designed illustration plate. Output the same drawing, same style, same line weight and shading. Preserve silhouette, composition, perspective, palette (#f7e8e8, #182838, #9a9aa5), lighting, material, and texture exactly. Remove every piece of UI text, label, caption, button, and interface chrome that is not part of the artwork itself. Remove letterboxing, borders, card corners, drop shadows, and any layout background that the page will draw in code. Do not add objects. Do not change the concept. Do not restyle. The artwork fills the whole frame edge to edge at the same scale as the reference; no margins, no border, no background band. Region: an exploded illustration drawing.\n", + "stderr": "", + "exit": 0, + "signal": null, + "files": {} +} diff --git a/tests/oracle/golden/comp-spec-print.json b/tests/oracle/golden/comp-spec-print.json new file mode 100644 index 000000000..2d4473c74 --- /dev/null +++ b/tests/oracle/golden/comp-spec-print.json @@ -0,0 +1,7 @@ +{ + "stdout": "SPEC comp comp.png 768x512 landscape\nPALETTE #f7e8e8(69%) #182838(14%) #9a9aa5(7%) #c5c5d1(7%) #7c7c82(4%)\nBANDS 6% 9% 14% 18% 20% 24% 47% 51% 54% 58% 60% 64% 66% 69% 72% 75% 78% 81% 84% 88% 91% 98%\nREGION top chrome semantic box x0% y0% w100% h20% (768x102px, 7.5294:1) palette #f5e7e8 #182838 #bdbdc8 # top masthead band area\nREGION art plate raster box x40% y20% w60% h30% (461x154px, 2.9935:1) palette #e6dfe5 #a2a2ad #7f7f88 plate assets/plates/art.png # an exploded illustration drawing\nREGION body text semantic box x0% y50% w40% h40% (307x205px, 1.4976:1) palette #f8e8e8 #182838 #787878 # a paragraph of body text content\nPLATES 1 to produce: art\nRULE 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.\n", + "stderr": "", + "exit": 0, + "signal": null, + "files": {} +} diff --git a/tests/oracle/golden/comp-spec-refuses-painted-chrome.json b/tests/oracle/golden/comp-spec-refuses-painted-chrome.json new file mode 100644 index 000000000..31384e373 --- /dev/null +++ b/tests/oracle/golden/comp-spec-refuses-painted-chrome.json @@ -0,0 +1,7 @@ +{ + "stdout": "", + "stderr": "comp-spec: region x is kind \"chrome\" but its note describes painted material (\"an exploded diagram illustration\"). 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.\n", + "exit": 1, + "signal": null, + "files": {} +} diff --git a/tests/oracle/golden/comp-spec-regions.json b/tests/oracle/golden/comp-spec-regions.json new file mode 100644 index 000000000..4d98c44d7 --- /dev/null +++ b/tests/oracle/golden/comp-spec-regions.json @@ -0,0 +1,9 @@ +{ + "stdout": "WROTE .impeccable/build/spec.json\nSPEC comp comp.png 768x512 landscape\nPALETTE #f7e8e8(69%) #182838(14%) #9a9aa5(7%) #c5c5d1(7%) #7c7c82(4%)\nBANDS 6% 9% 14% 18% 20% 24% 47% 51% 54% 58% 60% 64% 66% 69% 72% 75% 78% 81% 84% 88% 91% 98%\nREGION top chrome semantic box x0% y0% w100% h20% (768x102px, 7.5294:1) palette #f5e7e8 #182838 #bdbdc8 # top masthead band area\nREGION art plate raster box x40% y20% w60% h30% (461x154px, 2.9935:1) palette #e6dfe5 #a2a2ad #7f7f88 plate assets/plates/art.png # an exploded illustration drawing\nREGION body text semantic box x0% y50% w40% h40% (307x205px, 1.4976:1) palette #f8e8e8 #182838 #787878 # a paragraph of body text content\nPLATES 1 to produce: art\nRULE 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.\n", + "stderr": "", + "exit": 0, + "signal": null, + "files": { + ".impeccable/build/spec.json": "{\n \"tool\": \"comp-spec\",\n \"version\": 1,\n \"createdAt\": \"\",\n \"comp\": \"comp.png\",\n \"warnings\": [],\n \"uncoveredInkCells\": [\n \"A2\",\n \"B2\",\n \"C2\",\n \"E5\",\n \"F5\",\n \"G5\",\n \"H5\",\n \"I5\",\n \"J5\",\n \"E6\",\n \"A9\",\n \"B9\",\n \"C9\"\n ],\n \"compSize\": {\n \"width\": 768,\n \"height\": 512\n },\n \"aspect\": 1.5,\n \"orientation\": \"landscape\",\n \"palette\": [\n {\n \"hex\": \"#f7e8e8\",\n \"coverage\": 0.6868\n },\n {\n \"hex\": \"#182838\",\n \"coverage\": 0.1403\n },\n {\n \"hex\": \"#9a9aa5\",\n \"coverage\": 0.0683\n },\n {\n \"hex\": \"#c5c5d1\",\n \"coverage\": 0.0672\n },\n {\n \"hex\": \"#7c7c82\",\n \"coverage\": 0.0374\n }\n ],\n \"bands\": [\n {\n \"y\": 0.0588,\n \"strength\": 1\n },\n {\n \"y\": 0.0941,\n \"strength\": 0.4087\n },\n {\n \"y\": 0.1412,\n \"strength\": 0.7036\n },\n {\n \"y\": 0.1765,\n \"strength\": 0.6963\n },\n {\n \"y\": 0.2,\n \"strength\": 0.6026\n },\n {\n \"y\": 0.2353,\n \"strength\": 0.6125\n },\n {\n \"y\": 0.4706,\n \"strength\": 0.3982\n },\n {\n \"y\": 0.5059,\n \"strength\": 0.9754\n },\n {\n \"y\": 0.5412,\n \"strength\": 0.5699\n },\n {\n \"y\": 0.5765,\n \"strength\": 0.8782\n },\n {\n \"y\": 0.6,\n \"strength\": 0.3576\n },\n {\n \"y\": 0.6353,\n \"strength\": 0.4894\n },\n {\n \"y\": 0.6588,\n \"strength\": 0.5699\n },\n {\n \"y\": 0.6941,\n \"strength\": 0.8782\n },\n {\n \"y\": 0.7176,\n \"strength\": 0.3576\n },\n {\n \"y\": 0.7529,\n \"strength\": 0.4877\n },\n {\n \"y\": 0.7765,\n \"strength\": 0.5699\n },\n {\n \"y\": 0.8118,\n \"strength\": 0.8782\n },\n {\n \"y\": 0.8353,\n \"strength\": 0.3576\n },\n {\n \"y\": 0.8824,\n \"strength\": 0.4877\n },\n {\n \"y\": 0.9059,\n \"strength\": 0.3028\n },\n {\n \"y\": 0.9765,\n \"strength\": 0.3039\n }\n ],\n \"regions\": [\n {\n \"id\": \"top\",\n \"kind\": \"chrome\",\n \"note\": \"top masthead band area\",\n \"grid\": \"A0:J1\",\n \"box\": {\n \"x\": 0,\n \"y\": 0,\n \"w\": 1,\n \"h\": 0.2\n },\n \"px\": {\n \"x\": 0,\n \"y\": 0,\n \"w\": 768,\n \"h\": 102\n },\n \"aspect\": 7.5294,\n \"palette\": [\n {\n \"hex\": \"#f5e7e8\",\n \"coverage\": 0.392\n },\n {\n \"hex\": \"#182838\",\n \"coverage\": 0.3825\n },\n {\n \"hex\": \"#bdbdc8\",\n \"coverage\": 0.1348\n },\n {\n \"hex\": \"#8b8b96\",\n \"coverage\": 0.0908\n }\n ],\n \"detail\": {\n \"energy\": 39.1367\n },\n \"medium\": \"semantic\",\n \"plate\": null,\n \"text\": null\n },\n {\n \"id\": \"art\",\n \"kind\": \"plate\",\n \"note\": \"an exploded illustration drawing\",\n \"grid\": \"E2:J4\",\n \"box\": {\n \"x\": 0.4,\n \"y\": 0.2,\n \"w\": 0.6,\n \"h\": 0.3\n },\n \"px\": {\n \"x\": 307,\n \"y\": 102,\n \"w\": 461,\n \"h\": 154\n },\n \"aspect\": 2.9935,\n \"palette\": [\n {\n \"hex\": \"#e6dfe5\",\n \"coverage\": 0.5326\n },\n {\n \"hex\": \"#a2a2ad\",\n \"coverage\": 0.383\n },\n {\n \"hex\": \"#7f7f88\",\n \"coverage\": 0.0844\n }\n ],\n \"detail\": {\n \"energy\": 38.5615\n },\n \"medium\": \"raster\",\n \"plate\": \"assets/plates/art.png\",\n \"text\": null\n },\n {\n \"id\": \"body\",\n \"kind\": \"text\",\n \"note\": \"a paragraph of body text content\",\n \"grid\": \"A5:D8\",\n \"box\": {\n \"x\": 0,\n \"y\": 0.5,\n \"w\": 0.4,\n \"h\": 0.4\n },\n \"px\": {\n \"x\": 0,\n \"y\": 256,\n \"w\": 307,\n \"h\": 205\n },\n \"aspect\": 1.4976,\n \"palette\": [\n {\n \"hex\": \"#f8e8e8\",\n \"coverage\": 0.6826\n },\n {\n \"hex\": \"#182838\",\n \"coverage\": 0.223\n },\n {\n \"hex\": \"#787878\",\n \"coverage\": 0.0848\n },\n {\n \"hex\": \"#b82828\",\n \"coverage\": 0.0096\n }\n ],\n \"detail\": {\n \"energy\": 32.0304\n },\n \"medium\": \"semantic\",\n \"plate\": null,\n \"text\": null\n }\n ]\n}" + } +} diff --git a/tests/oracle/golden/comp-spec-usage.json b/tests/oracle/golden/comp-spec-usage.json new file mode 100644 index 000000000..21658557e --- /dev/null +++ b/tests/oracle/golden/comp-spec-usage.json @@ -0,0 +1,7 @@ +{ + "stdout": "usage: comp-spec.mjs --comp --grid write .impeccable/build/comp-grid.png (10x10 labeled grid) + palette + bands\n comp-spec.mjs --comp --regions measure regions -> .impeccable/build/spec.json\n regions json: { \"regions\": [ { \"id\": \"art\", \"kind\": \"plate|image|texture|text|control|chrome\", \"grid\": \"E0:J4\", \"note\": \"...\" } ] }\n comp-spec.mjs --comp --auto band regions when you have no regions file\n comp-spec.mjs --print the compact spec\n comp-spec.mjs --crop [--out f] [--scale n] reference crop of a region (never a shipping asset)\n comp-spec.mjs --plate-prompt the regeneration prompt for a raster region\n", + "stderr": "", + "exit": 0, + "signal": null, + "files": {} +} diff --git a/tests/oracle/golden/font-match-measure.json b/tests/oracle/golden/font-match-measure.json new file mode 100644 index 000000000..43799df22 --- /dev/null +++ b/tests/oracle/golden/font-match-measure.json @@ -0,0 +1,9 @@ +{ + "stdout": "MEASURE body: capHeight 24px, width wide (advX 11.5417), weight black (densTall 0.9899), tracking 0 over 3 lines, 6 glyphs. Set this region's font-size so its cap height renders at 24px; choose a wide black face.\n", + "stderr": "", + "exit": 0, + "signal": null, + "files": { + "spec.json": "{\n \"tool\": \"comp-spec\",\n \"version\": 1,\n \"createdAt\": \"\",\n \"comp\": \"comp.png\",\n \"warnings\": [],\n \"uncoveredInkCells\": [\n \"A2\",\n \"B2\",\n \"C2\",\n \"E5\",\n \"F5\",\n \"G5\",\n \"H5\",\n \"I5\",\n \"J5\",\n \"E6\",\n \"A9\",\n \"B9\",\n \"C9\"\n ],\n \"compSize\": {\n \"width\": 768,\n \"height\": 512\n },\n \"aspect\": 1.5,\n \"orientation\": \"landscape\",\n \"palette\": [\n {\n \"hex\": \"#f7e8e8\",\n \"coverage\": 0.6868\n },\n {\n \"hex\": \"#182838\",\n \"coverage\": 0.1403\n },\n {\n \"hex\": \"#9a9aa5\",\n \"coverage\": 0.0683\n },\n {\n \"hex\": \"#c5c5d1\",\n \"coverage\": 0.0672\n },\n {\n \"hex\": \"#7c7c82\",\n \"coverage\": 0.0374\n }\n ],\n \"bands\": [\n {\n \"y\": 0.0588,\n \"strength\": 1\n },\n {\n \"y\": 0.0941,\n \"strength\": 0.4087\n },\n {\n \"y\": 0.1412,\n \"strength\": 0.7036\n },\n {\n \"y\": 0.1765,\n \"strength\": 0.6963\n },\n {\n \"y\": 0.2,\n \"strength\": 0.6026\n },\n {\n \"y\": 0.2353,\n \"strength\": 0.6125\n },\n {\n \"y\": 0.4706,\n \"strength\": 0.3982\n },\n {\n \"y\": 0.5059,\n \"strength\": 0.9754\n },\n {\n \"y\": 0.5412,\n \"strength\": 0.5699\n },\n {\n \"y\": 0.5765,\n \"strength\": 0.8782\n },\n {\n \"y\": 0.6,\n \"strength\": 0.3576\n },\n {\n \"y\": 0.6353,\n \"strength\": 0.4894\n },\n {\n \"y\": 0.6588,\n \"strength\": 0.5699\n },\n {\n \"y\": 0.6941,\n \"strength\": 0.8782\n },\n {\n \"y\": 0.7176,\n \"strength\": 0.3576\n },\n {\n \"y\": 0.7529,\n \"strength\": 0.4877\n },\n {\n \"y\": 0.7765,\n \"strength\": 0.5699\n },\n {\n \"y\": 0.8118,\n \"strength\": 0.8782\n },\n {\n \"y\": 0.8353,\n \"strength\": 0.3576\n },\n {\n \"y\": 0.8824,\n \"strength\": 0.4877\n },\n {\n \"y\": 0.9059,\n \"strength\": 0.3028\n },\n {\n \"y\": 0.9765,\n \"strength\": 0.3039\n }\n ],\n \"regions\": [\n {\n \"id\": \"top\",\n \"kind\": \"chrome\",\n \"note\": \"top masthead band area\",\n \"grid\": \"A0:J1\",\n \"box\": {\n \"x\": 0,\n \"y\": 0,\n \"w\": 1,\n \"h\": 0.2\n },\n \"px\": {\n \"x\": 0,\n \"y\": 0,\n \"w\": 768,\n \"h\": 102\n },\n \"aspect\": 7.5294,\n \"palette\": [\n {\n \"hex\": \"#f5e7e8\",\n \"coverage\": 0.392\n },\n {\n \"hex\": \"#182838\",\n \"coverage\": 0.3825\n },\n {\n \"hex\": \"#bdbdc8\",\n \"coverage\": 0.1348\n },\n {\n \"hex\": \"#8b8b96\",\n \"coverage\": 0.0908\n }\n ],\n \"detail\": {\n \"energy\": 39.1367\n },\n \"medium\": \"semantic\",\n \"plate\": null,\n \"text\": null\n },\n {\n \"id\": \"art\",\n \"kind\": \"plate\",\n \"note\": \"an exploded illustration drawing\",\n \"grid\": \"E2:J4\",\n \"box\": {\n \"x\": 0.4,\n \"y\": 0.2,\n \"w\": 0.6,\n \"h\": 0.3\n },\n \"px\": {\n \"x\": 307,\n \"y\": 102,\n \"w\": 461,\n \"h\": 154\n },\n \"aspect\": 2.9935,\n \"palette\": [\n {\n \"hex\": \"#e6dfe5\",\n \"coverage\": 0.5326\n },\n {\n \"hex\": \"#a2a2ad\",\n \"coverage\": 0.383\n },\n {\n \"hex\": \"#7f7f88\",\n \"coverage\": 0.0844\n }\n ],\n \"detail\": {\n \"energy\": 38.5615\n },\n \"medium\": \"raster\",\n \"plate\": \"assets/plates/art.png\",\n \"text\": null\n },\n {\n \"id\": \"body\",\n \"kind\": \"text\",\n \"note\": \"a paragraph of body text content\",\n \"grid\": \"A5:D8\",\n \"box\": {\n \"x\": 0,\n \"y\": 0.5,\n \"w\": 0.4,\n \"h\": 0.4\n },\n \"px\": {\n \"x\": 0,\n \"y\": 256,\n \"w\": 307,\n \"h\": 205\n },\n \"aspect\": 1.4976,\n \"palette\": [\n {\n \"hex\": \"#f8e8e8\",\n \"coverage\": 0.6826\n },\n {\n \"hex\": \"#182838\",\n \"coverage\": 0.223\n },\n {\n \"hex\": \"#787878\",\n \"coverage\": 0.0848\n },\n {\n \"hex\": \"#b82828\",\n \"coverage\": 0.0096\n }\n ],\n \"detail\": {\n \"energy\": 32.0304\n },\n \"medium\": \"semantic\",\n \"plate\": null,\n \"text\": null,\n \"type\": {\n \"comp\": {\n \"lines\": 3,\n \"glyphs\": 6,\n \"capHeightPx\": 24,\n \"inkIsDark\": true,\n \"allCaps\": false,\n \"advance\": 5.9792,\n \"advTall\": 0.4167,\n \"advX\": 11.5417,\n \"gap\": 0,\n \"xRatio\": 0.5882,\n \"stemW\": 0.4194,\n \"contrast\": null,\n \"serif\": 1,\n \"densTall\": 0.9899,\n \"densX\": 0.9993,\n \"weight\": 0.9899\n },\n \"widthClass\": \"wide\",\n \"weightClass\": \"black\"\n }\n }\n ]\n}" + } +} diff --git a/tests/oracle/golden/font-match-usage.json b/tests/oracle/golden/font-match-usage.json new file mode 100644 index 000000000..db11231aa --- /dev/null +++ b/tests/oracle/golden/font-match-usage.json @@ -0,0 +1,7 @@ +{ + "stdout": "", + "stderr": "usage: font-match.mjs --measure | --rank [--candidates \"Family:700,Family2:400,...\"] [--text \"...\"] [--transform uppercase] [--category sans,serif,display,handwriting,mono]\n", + "exit": 1, + "signal": null, + "files": {} +} diff --git a/tests/oracle/workspaces/comp-basic/build.png b/tests/oracle/workspaces/comp-basic/build.png new file mode 100644 index 000000000..5e4ebd20c Binary files /dev/null and b/tests/oracle/workspaces/comp-basic/build.png differ diff --git a/tests/oracle/workspaces/comp-basic/comp.png b/tests/oracle/workspaces/comp-basic/comp.png new file mode 100644 index 000000000..601d42a49 Binary files /dev/null and b/tests/oracle/workspaces/comp-basic/comp.png differ diff --git a/tests/oracle/workspaces/comp-basic/regions.json b/tests/oracle/workspaces/comp-basic/regions.json new file mode 100644 index 000000000..1c23bf699 --- /dev/null +++ b/tests/oracle/workspaces/comp-basic/regions.json @@ -0,0 +1,5 @@ +{ "allowUncovered": true, "regions": [ + { "id": "top", "kind": "chrome", "grid": "A0:J1", "note": "top masthead band area" }, + { "id": "art", "kind": "plate", "grid": "E2:J4", "note": "an exploded illustration drawing" }, + { "id": "body", "kind": "text", "grid": "A5:D8", "note": "a paragraph of body text content" } +] } diff --git a/tests/oracle/workspaces/comp-basic/spec.json b/tests/oracle/workspaces/comp-basic/spec.json new file mode 100644 index 000000000..b7f547be1 --- /dev/null +++ b/tests/oracle/workspaces/comp-basic/spec.json @@ -0,0 +1,267 @@ +{ + "tool": "comp-spec", + "version": 1, + "createdAt": "2026-09-01T19:17:09.643Z", + "comp": "comp.png", + "warnings": [], + "uncoveredInkCells": [ + "A2", + "B2", + "C2", + "E5", + "F5", + "G5", + "H5", + "I5", + "J5", + "E6", + "A9", + "B9", + "C9" + ], + "compSize": { + "width": 768, + "height": 512 + }, + "aspect": 1.5, + "orientation": "landscape", + "palette": [ + { + "hex": "#f7e8e8", + "coverage": 0.6868 + }, + { + "hex": "#182838", + "coverage": 0.1403 + }, + { + "hex": "#9a9aa5", + "coverage": 0.0683 + }, + { + "hex": "#c5c5d1", + "coverage": 0.0672 + }, + { + "hex": "#7c7c82", + "coverage": 0.0374 + } + ], + "bands": [ + { + "y": 0.0588, + "strength": 1 + }, + { + "y": 0.0941, + "strength": 0.4087 + }, + { + "y": 0.1412, + "strength": 0.7036 + }, + { + "y": 0.1765, + "strength": 0.6963 + }, + { + "y": 0.2, + "strength": 0.6026 + }, + { + "y": 0.2353, + "strength": 0.6125 + }, + { + "y": 0.4706, + "strength": 0.3982 + }, + { + "y": 0.5059, + "strength": 0.9754 + }, + { + "y": 0.5412, + "strength": 0.5699 + }, + { + "y": 0.5765, + "strength": 0.8782 + }, + { + "y": 0.6, + "strength": 0.3576 + }, + { + "y": 0.6353, + "strength": 0.4894 + }, + { + "y": 0.6588, + "strength": 0.5699 + }, + { + "y": 0.6941, + "strength": 0.8782 + }, + { + "y": 0.7176, + "strength": 0.3576 + }, + { + "y": 0.7529, + "strength": 0.4877 + }, + { + "y": 0.7765, + "strength": 0.5699 + }, + { + "y": 0.8118, + "strength": 0.8782 + }, + { + "y": 0.8353, + "strength": 0.3576 + }, + { + "y": 0.8824, + "strength": 0.4877 + }, + { + "y": 0.9059, + "strength": 0.3028 + }, + { + "y": 0.9765, + "strength": 0.3039 + } + ], + "regions": [ + { + "id": "top", + "kind": "chrome", + "note": "top masthead band area", + "grid": "A0:J1", + "box": { + "x": 0, + "y": 0, + "w": 1, + "h": 0.2 + }, + "px": { + "x": 0, + "y": 0, + "w": 768, + "h": 102 + }, + "aspect": 7.5294, + "palette": [ + { + "hex": "#f5e7e8", + "coverage": 0.392 + }, + { + "hex": "#182838", + "coverage": 0.3825 + }, + { + "hex": "#bdbdc8", + "coverage": 0.1348 + }, + { + "hex": "#8b8b96", + "coverage": 0.0908 + } + ], + "detail": { + "energy": 39.1367 + }, + "medium": "semantic", + "plate": null, + "text": null + }, + { + "id": "art", + "kind": "plate", + "note": "an exploded illustration drawing", + "grid": "E2:J4", + "box": { + "x": 0.4, + "y": 0.2, + "w": 0.6, + "h": 0.3 + }, + "px": { + "x": 307, + "y": 102, + "w": 461, + "h": 154 + }, + "aspect": 2.9935, + "palette": [ + { + "hex": "#e6dfe5", + "coverage": 0.5326 + }, + { + "hex": "#a2a2ad", + "coverage": 0.383 + }, + { + "hex": "#7f7f88", + "coverage": 0.0844 + } + ], + "detail": { + "energy": 38.5615 + }, + "medium": "raster", + "plate": "assets/plates/art.png", + "text": null + }, + { + "id": "body", + "kind": "text", + "note": "a paragraph of body text content", + "grid": "A5:D8", + "box": { + "x": 0, + "y": 0.5, + "w": 0.4, + "h": 0.4 + }, + "px": { + "x": 0, + "y": 256, + "w": 307, + "h": 205 + }, + "aspect": 1.4976, + "palette": [ + { + "hex": "#f8e8e8", + "coverage": 0.6826 + }, + { + "hex": "#182838", + "coverage": 0.223 + }, + { + "hex": "#787878", + "coverage": 0.0848 + }, + { + "hex": "#b82828", + "coverage": 0.0096 + } + ], + "detail": { + "energy": 32.0304 + }, + "medium": "semantic", + "plate": null, + "text": null + } + ] +} \ No newline at end of file