Compare commits

...
Author SHA1 Message Date
Abdul WahabandClaude Opus 5 b291f4217e Fit the questionnaire seed into the measured comp pipeline, fix CI
The live-reference test still asserted SKILL.src.md's pre-rewrite setup
phrasing, which failed every pipeline on main and here and blocked the
generated-output sync; the assertions now match the current text. The
sync list gains .veto, added in #675 but never listed. Two seed-world
seams in the comp-led phases close: DESIGN.md's named faces ride into
font-match as candidates instead of losing silently to a metric
lookalike, and a sampled spec color within render noise of a recorded
hex builds at the recorded value. The cue also stops claiming type
character it cannot carry; comp prompts name the recorded faces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 15:55:54 +05:00
Abdul WahabandClaude Opus 5 6efe4a43c8 Restore generated provider output to main's state
This PR stays source-first per 187e589e: the mid-rebase regeneration
swept in provider dirs the sync workflow owns, .veto included, which
main's CI produces after merge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 10:21:53 +05:00
Abdul WahabandClaude Opus 5 66632b050a Map main's two new detector rules into the hook discipline groups
organic-clip-path and buried-raster landed on main after this branch's
hook-rule-presentation map; both are produced-imagery rules, grouped
under Imagery beside shape-assembled-illustration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 10:18:14 +05:00
Abdul WahabandClaude Opus 5 93218c543a Regenerate provider output and hook rules after rebase onto main
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 10:13:02 +05:00
Abdul Wahab 5b9974a50a update 2026-09-01 10:09:45 +05:00
Abdul Wahab 07ebdfd5b1 update 2026-09-01 10:09:45 +05:00
Abdul Wahab 35de8595b6 update 2026-09-01 10:09:45 +05:00
Abdul Wahab 82b03772e4 update 2026-09-01 10:09:03 +05:00
Abdul Wahab f775f9e585 Fix the three design-context findings from the command test pass
- edit_request replies now apply attached --answers/--context through the
  session, as visual-cues.md's edit-loop contract already promised
- the bundle's per-file cap rises to 8 MiB so the chosen cue image survives
  export/import (the 20 MiB bundle ceiling stays)
- the questionnaire refuses to start without visual-cues/cues.json instead
  of serving a broken palette screen

AI-assisted: implemented by Cursor Composer executing plan-27.
2026-09-01 10:09:03 +05:00
Abdul Wahab 618dcae7bf udpate 2026-09-01 10:09:03 +05:00
Abdul WahabandClaude Fable 5 065bd4f3ca Trim dead code and tighten the branch to surgical scope
- palette-picker.js: drop the superseded APP/DOCS/INDEX copy tables, the
  disabled font-rail hoist machinery, LOREM.caption, and an orphan marker
- design-context.css + dcx-hierarchy.css: drop the demo-era specimen,
  radius, and spacing-bar rule groups nothing renders
- index.astro: drop an unreferenced boards const
- de-export module-internal names (store, bindings, portability,
  visual-cues); remove picker-server's unused doc-session kill switch and
  document --doc in its help
- palette.mjs: realpath the entry guard so symlinked installs still run
  the CLI (same guard as visual-cues.mjs)
- revert serve-question.mjs copy ride-along; drop the plan/ gitignore
  entry; dedupe a build-picker comment; file design-context with the
  system category entries; alphabetize IMPECCABLE_SUB_COMMANDS
- new-work.md: drop a same-screen restatement of the comp/world ranking
- CLAUDE.md: correct the picker gate baseline to the measured 606 and
  explain the per-element multiplication; the trims removed the two
  dead-block font findings
- DESIGN-CONTEXT-ARCHITECTURE.md: list portability.mjs in the code layout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 10:08:22 +05:00
Abdul WahabandClaude Fable 5 6c2a79891c Remove stale design-context-categorization docs
PROVENANCE.md specs a design.json questionnaire/producedPaths schema that
shipped nowhere and contradicts the store DESIGN-CONTEXT-ARCHITECTURE.md
documents; design-context.html is an 89KB frozen prototype snapshot with
no inbound references (the port to picker/ is complete, and git history
keeps it); the README only indexed the two.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 10:08:03 +05:00
Abdul WahabandClaude Fable 5 187e589ef7 Drop generated provider output from the feature branch
The 17 harness dirs and plugin/ revert to main's state: feature PRs are
source-first per CLAUDE.md, the post-merge sync workflow regenerates them,
and the committed snapshot was stale by one commit (its context.mjs
predated the DESIGN_CONTEXT directive). Also add .agent and .github/agents
to the sync workflow's GENERATED_PATHS so those two outputs stop relying
on manual release syncs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 10:08:03 +05:00
Abdul Wahab 0aec856f45 update 2026-09-01 10:08:03 +05:00
Abdul Wahab 89c842e5f4 update 2026-09-01 10:07:07 +05:00
Abdul Wahab a5cf9266e6 Add new design context document 2026-09-01 10:06:21 +05:00
Abdul Wahab bba73a2283 update 2026-09-01 10:05:59 +05:00
Abdul Wahab 33ea98ae3e update 2026-09-01 10:05:00 +05:00
Abdul Wahab 7e9a873499 udpate 2026-09-01 10:05:00 +05:00
Abdul Wahab 4199ad4bc4 update 2026-09-01 10:05:00 +05:00
Abdul WahabandCursor 5400675585 Fix dead links in PROVENANCE.md after docs trim
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:04:59 +05:00
Abdul WahabandCursor 3202a3aab5 Trim design-context-categorization docs to prototype, provenance, and index
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:04:59 +05:00
Abdul WahabandCursor 4f069a39ef Untrack the picker gallery toolchain; tmp is scratch again
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:04:59 +05:00
Abdul WahabandCursor ba999bd799 Remove unreferenced mode illustration binaries
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:04:59 +05:00
Abdul Wahab 6343ec689b update 2026-09-01 10:04:59 +05:00
Abdul Wahab 8b6bbaef3d update 2026-09-01 10:04:59 +05:00
Abdul Wahab 6cfeb7d374 update 2026-09-01 10:04:59 +05:00
Abdul Wahab bccbc98c9b update 2026-09-01 10:04:59 +05:00
Abdul Wahab 07c8d43b5d update 2026-09-01 10:04:59 +05:00
Abdul Wahab 2fd4c86794 update 2026-09-01 10:04:59 +05:00
Abdul Wahab f68c27a13f update 2026-09-01 10:04:59 +05:00
Abdul Wahab 80e26259e1 update 2026-09-01 10:04:59 +05:00
Abdul Wahab 40ff2b6eda updated 2026-09-01 10:04:59 +05:00
Abdul Wahab be7d4d9242 update 2026-09-01 10:04:59 +05:00
Abdul Wahab cc8d992d8b update 2026-09-01 10:04:59 +05:00
Abdul Wahab fa5d7474b3 update 2026-09-01 10:04:59 +05:00
Abdul Wahab 6d0dc0287d update 2026-09-01 10:04:59 +05:00
Abdul Wahab 6fbf5b952f update 2026-09-01 10:04:59 +05:00
Abdul WahabandCursor 1537368b8c Make the questionnaire ask_instruction sites sentence-initial
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:04:59 +05:00
Abdul WahabandCursor 8b4507db53 Regenerate bun.lock after rebase onto main
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:04:59 +05:00
Abdul WahabandCursor a256b6e42b Scope the comp's authority and bind builds to the recorded world.
The comp rules composition; the world rules material. Adds the authority
ranking to new-work.md, rewrite and token discipline to the craft floor,
token and motion evidence checks to the finish reviewer, derived text
inks to the questionnaire seed, and stale-seed disclosure at attended
stops. From the ekihana run's confirmed drift.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:03:52 +05:00
Abdul WahabandCursor 9fbda768e6 Launch the picker in the harness browser first.
Name Cursor browser_navigate as the in-IDE viewport, then other in-app tools, then the system opener. Same ladder in serve-question stdout so agents stop using macOS open while the IDE browser works.

AI-assisted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor 72fec2849c Dedupe the seed interview against the browser questionnaire.
On the questionnaire path, chat now asks only assets, named references, and one anti-reference; color, type, and motion stay in the picker. Interview-only still asks all five. visual-cues.md drops the retired Q-keys and re-anchors font pairs from surfaces, references, and assets.

AI-assisted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor 44aa29fb63 Constrain dealt font pairs to Latin-script faces and English copy.
CJK and other non-Latin families render the picker's previews and scale sheets wrong until language-aware pairs exist; the fallback deck comment matches the new visual-cues Step 6 rule.

AI-assisted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor c582fd4699 Require verbatim skip quote for seed pause and fix hidden doc shell.
Direction rolls on projects without DESIGN.md now refuse until the user accepts document --seed or re-runs with --seed-declined carrying their actual skip answer; IMPECCABLE_SEED_DECLINED=1 remains the unattended harness escape. Hide the design context shell when [hidden] so it does not reserve viewport space. Extend concept-seed tests for the seed gate.

AI-assisted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor 5758e4c6bd Recommend the seed questionnaire before rolling a direction on world-less projects
When new-work reaches world creation and DESIGN.md is missing, pause and
offer /impeccable document --seed (skippable) so the visual world can come
from the user's own choices instead of an assigned roll.

AI-assisted change (Cursor agent), directed by the maintainer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor b56ca30fcd Restore dry-run guard around the release build step
The picker staleness check added on this branch had replaced main's
dry-run branch, so `--dry-run` executed the real build. Keep the picker
hash check but only on a real run, matching main's dry-run contract.

AI-assisted (agent-implemented, maintainer-directed).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor 601a7a8755 Enrich design context Product, Brand, and Color sections.
Add product conversion/principles/surface copy and leading-surface badge; Brand voice and principles blocks; Color ink chips plus as-dealt/edited provenance tags. Extend visual-cues schema. Polish font-pair preview cells 24–25 and sync gallery replica.

AI-assisted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor b4cfe626ff Polish Portfolio type-board captions for font-pair preview cells 19–20.
Add gallery avatars, tighten the experience index layout, and sync the improved previews gallery replica.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor a975a8bcc1 Fix font-pair preview polish for cells 22–24 and sync gallery experiments.
Tighten Landing, App UI, and Docs type-board copy, color, spacing, and gallery experiments so the improved preview gallery matches the live picker.

AI-assisted commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor da94340e9b Scale Docs board carry bars and fix Portfolio preview cells 19–21.
Board-stage read artboards get thicker bar weight in picker.css; the gallery replica adds matching experiments plus desktop caption circles for Portfolio restrained/committed/drenched and drenched color roles on cell 21.

AI-assisted commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor 457c2d877c Improve strategy previews for Docs wireframe weight and App UI color roles.
Boost docs strategy-stage line and dot weight to match App UI legibility, keep phone artboard lines proportional via pv-text scaling, and extend the gallery replica experiments for cells 14–18.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor 470b655096 Number questionnaire preview gallery cells 1 through 75.
Add sequential labels to figcaptions and iframe titles in the gallery builder, then regenerate baseline and improved HTML.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor d886003879 Track questionnaire previews gallery and color strategy experiments.
Un-ignore tmp/questionaire for the gallery toolchain, baseline HTML, and the improved replica with drenched, committed, and full-palette color fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul Wahab 55617ac5d8 update 2026-09-01 10:02:40 +05:00
Abdul WahabandCursor 60130bcea9 Add strategy-band contrast alert and expand design context fields.
Mirror the screen 02 neutral contrast badge on the strategy strip, share alert
painting between both strips, and render richer PRODUCT.md context in the
design document with matching visual-cues schema guidance.

AI-assisted commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor ae1c632594 Improve picker finish screen, neutral contrast alert, and DCX tile rest states.
Replace the neutral palette hint warning with an on-swatch badge, simplify the finish loader screen, and keep typography/material vignettes ink-only until hover.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor 6d684d2d6a Mute design-context tile accents at rest like Color.
Product, Brand, Typography, and Material vignettes now use anim-join so their gold accent reads at 25% opacity before hover and brightens on hover, matching the Color tile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor adabd62011 Move type scale and layout ahead of the five-card hub.
Type scale and layout return to the linear path before configure; the hub shrinks to five cards with updated navigation. Also fixes palette tooltip clipping, drops the design-context masthead and icon hub notes, and locks the mosaic shell to the viewport without overflow.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor 0b867f0e25 Tighten configure hub pill sizing.
Pills shrink to content width and use slightly more padding so values read as compact chips rather than full-width rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor b629daef01 Refine configure hub cards with pills, notes, and layout polish.
Add the intro lede, per-row patina answer marks with DOM-sourced notes, mode-tile hover, and tighter card typography including inline pills and title arrows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor 80211f3364 Add configure hub after the font pair screen.
Let questionnaire runs finish early or jump into the seven remaining questions from a summary card grid, with hub navigation and per-answer summaries.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor 5dffc569ff Warn on screen 02 when the palette neutral fails contrast
Add neutralContrastIssue() with a 7:1 ink floor and a 3:1 primary
separation floor, re-run it from renderPreview(), and paint the palette
hint in vermilion while the working neutral is bad. The warning clears
as soon as the neutral is fixed.

Prepared with AI assistance (Cursor).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor 38b7029184 Build out the layout screen's three compositions
Each option now draws its own page rather than a shared field with a
different column count. Simple grid holds strict paired rows at equal
image measures, balanced alternates wide images against captions, and
freeform crops a lead, adds an upper portrait, and spills the card row
into a set of prints with their own captions and marks. All three carry
handset sequences at the reference measures.

Behind them: one shared alignment-grid component mounted on every body
that asks for it, code-native nav primitives placed at the reference
measures rather than left to their grids, a project image-tone contract
the boards key their fills on, and footer link groups.

Prepared with AI assistance (Cursor).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor a5083e23e1 Default the color question to full palette and color the type boards
The default lives in each surface's fallback, not in the markup: show()
checks whichever row the fallback names every time a tab opens, so the
checked attribute on the option never survived. Persuade, Operate, and
Read now open on Full palette. Experience keeps Restrained because four
working colors are not on its allow list, and a default pointing at a
blocked row would leave that tab with nothing selected.

The font screen's boards also take the chosen palette under Restrained,
which is the one answer no strategy block rewrites. They were drawing a
page the palette had never reached while screen 03 kept its color under
the same answer.

Prepared with AI assistance (Cursor).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor a34c9828aa Add the layout screen's portfolio board and default color to full palette
The index board grows the parts only a portfolio has: a project rail, a
support row, and a pagination rail restated at the foot of the handset
stack. They mount behind a prop rather than for every board, because the
motion screen reads the same index body through :nth-of-type and a stray
sibling would shift its scenes. Their sheet loads after the boundary,
corner, and depth sheets so it wins ties on source order.

The color strategy question now opens on Full palette.

Prepared with AI assistance (Cursor).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor 05f228728e Fix uneven gallery squares and size the phone section copy
The card row's squares came out three different widths: centering the
cards in the gallery shrank each one to its own label, and the square is
a share of its card. The cards take their column again and their contents
do the centering.

On the handset the section title and its paragraph were the smallest type
on a board that has room for more, so both take a step up.

Prepared with AI assistance (Cursor).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul WahabandCursor e2048614b5 Improve font pair and color strategy questionnaire screens
Font pair screen: drop the phone footer from the type boards, widen the
hero picture, split the editorial band evenly with the card row, center
the desktop cards, and fix the label line heights. The handset takes a
26px headline, stepped section spacing instead of one uniform gap, and a
single centered proof item between chevrons. Every word slot on these
boards now draws lorem rather than product copy, so a pair is judged on
its setting.

Color strategy screen: one-line question, an options panel that hugs its
rows, a preview whose height is free again, and the CTA stack in the same
place the font screen keeps it.

Also nudges the type scale specimen's top and left padding.

Prepared with AI assistance (Cursor).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul Wahab 7d4e1bff0c update 2026-09-01 10:02:40 +05:00
Abdul Wahab 3214ead9c2 update 2026-09-01 10:02:40 +05:00
Abdul Wahab b12b67d344 update 2026-09-01 10:02:40 +05:00
Abdul Wahab cadb70a09e update 2026-09-01 10:02:40 +05:00
Abdul WahabandCursor bf8e3ee0c2 Add standalone design context prototype and WHAT spec.
Ship the browsable mosaic document as an isolated HTML file and document the build intent in WHAT.md so the categorization work has a concrete UI target.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:40 +05:00
Abdul Wahab c06bf3f558 Add design doc 2026-09-01 10:02:40 +05:00
Abdul Wahab ecadbc9556 update 2026-09-01 10:02:40 +05:00
Abdul Wahab 4c1fbbc7c0 update 2026-09-01 10:02:40 +05:00
Abdul Wahab 4b0c4a5e80 update 2026-09-01 10:02:40 +05:00
Abdul Wahab b4110667c9 update 2026-09-01 10:02:39 +05:00
Abdul Wahab 4d919ec519 update 2026-09-01 10:02:39 +05:00
Abdul Wahab 3e67293747 update 2026-09-01 10:02:39 +05:00
Abdul Wahab ca4f39e01d update 2026-09-01 10:02:39 +05:00
Abdul Wahab e0ddad3198 update 2026-09-01 10:02:39 +05:00
Abdul Wahab 24cea3bd60 update 2026-09-01 10:02:39 +05:00
Abdul Wahab 6b30ce5d02 update 2026-09-01 10:02:39 +05:00
Abdul Wahab cf80c3df1a update 2026-09-01 10:02:39 +05:00
Abdul Wahab 29598150b4 update 2026-09-01 10:02:39 +05:00
Abdul Wahab 2feb2bdb9d update 2026-09-01 10:02:39 +05:00
Abdul Wahab 035e191ce5 udpate 2026-09-01 10:02:39 +05:00
Abdul Wahab 2f491f54c3 update 2026-09-01 10:02:39 +05:00
Abdul Wahab 1be3f1145b Update 2026-09-01 10:02:39 +05:00
Abdul Wahab 1d192d1f39 Update 2026-09-01 10:02:39 +05:00
Abdul Wahab ba9b1cd8c3 update 2026-09-01 10:02:39 +05:00
Abdul Wahab 8489467b99 Update 2026-09-01 10:02:39 +05:00
Abdul Wahab 0db6186617 Update 2026-09-01 10:02:39 +05:00
Abdul Wahab 154a84df56 update 2026-09-01 10:02:39 +05:00
Abdul Wahab 336ee34b73 update 2026-09-01 10:02:39 +05:00
Abdul Wahab f3c26f1f42 update 2026-09-01 10:02:39 +05:00
Abdul Wahab 5602cdfc7e update 2026-09-01 10:02:39 +05:00
Abdul Wahab 7af6e831fb update 2026-09-01 10:02:39 +05:00
Abdul Wahab 83b4fbcb5d update 2026-09-01 10:02:39 +05:00
Abdul Wahab e9f36ad9d8 update 2026-09-01 10:02:39 +05:00
Abdul WahabandCursor 28abe9a071 Improve strategy preview wireframes on desktop and phone.
Restrained mode now reads the palette like screen 02: primary-tinted
headline, tertiary eyebrow, secondary gradient hero, outlined ghost
CTA, and four-role gallery accents. Artboards lift with a soft shadow;
the phone gains surface bars and a second copy line. Each strategy
block sets matching image-gradient stops so committed, full palette,
and drenched transitions stay depthful instead of flat fills.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:39 +05:00
Abdul Wahab 6444deb361 update 2026-09-01 10:02:39 +05:00
Abdul WahabandCursor 9af6fc1cb7 Restyle font pair cards after fonttrio's pairing anatomy.
The heading family name sits big in its own face; one sentence in the
body face names its partner in bold and carries the why line. The
sentence demonstrates the body font, so the Heading/Body labels go.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:39 +05:00
Abdul WahabandCursor 51ce043130 Rework typography screen to match the questionnaire pattern.
Screen 04 drops the kicker and the text eyebrow, renders six large
scrollable specimen cards (heading and body each labeled and set in
their own face), and reuses screen 03's dual desktop-plus-phone
artboards with both hero copy blocks typeset live. The type hero
widens its copy column so wide display faces clear the image block.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:39 +05:00
Abdul WahabandCursor 626bfbb0a8 Add color strategy screen and font-pair pipeline step.
Screen 03 lets operators compare restrained through drenched strategies on the dual-artboard preview; visual-cues now composes fonts.json before launch, and the picker server exposes it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:39 +05:00
Abdul WahabandCursor f7751fad0c Improve palette preview: dual artboard, drop register wiring.
Show landing and app wireframes side by side with palette-driven accents instead of PRODUCT.md register and /context.json, matching v4's per-surface modes. Assisted-by: Cursor AI agent.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:39 +05:00
Abdul WahabandCursor d64909decf Reconcile questionnaire branch with main's site split and redesign
Post-rebase repairs after main moved 226 commits (site/ split to the
private impeccable-site repo, DESIGN.md spec now eight sections, register
extraction replaced by extractSectionValue, seed mode rewritten):

- document.md: transplant this branch's seed pipeline (assets, interview,
  visual cues, picker) onto main's rewritten file; adopt the canonical
  section vocabulary. The interplay with new-work's workshop is left for
  PR review.
- picker: vendor kinpaku tokens/kit CSS, hero art, and favicon under
  picker/ (site/ no longer exists here); drop the sharp resize step and
  ship the compressed JPEG directly; declare astro/sharp-free deps.
- picker-server: read the register via extractSectionValue('Register')
  since extractRegister is gone from context.mjs.
- palette.mjs / utils.js / test-suites.mjs / package.json / release.mjs:
  rebuild as main's version plus this branch's additions, undoing hunks
  the -X theirs replay had clobbered.
- craft.md / shape.md: reset to main (this branch's net change was zero).
- progress bar: animate transform instead of width (design hook finding).

Validation: bun run build green (prose validators clean), bun run test
198/198, picker-server suite 7/7.

AI-assisted (agent-implemented, maintainer-directed).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:39 +05:00
Abdul WahabandCursor c19c349fc6 Add screen 02 palette picker: cue deck, sampling rings, band tools
Deck of cue cards + seed palettes with scroll-snap browsing, draggable
color-sampling rings with loupe, four-band panel (copy hex+OKLCH, tint
strip, native custom color, card-local reset), /palettes.json route.

AI-assisted (agent-implemented, maintainer-directed).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:02:05 +05:00
Abdul Wahab ba7f3c8891 Add browser questionnaire foundation
Prepared with AI assistance.
2026-09-01 10:02:05 +05:00
Abdul Wahab 1f20997a6d Revert "Default the gemini provider to Nano Banana Pro."
This reverts commit 666d21dab4755efcf27f0aa34954ca91d8f67e39.
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor 72ba69e3a4 Default the gemini provider to Nano Banana Pro.
gemini-3-pro-image is Google's strongest image model; IMAGE_GEN_MODEL still overrides for the cheaper flash tier.

AI-assisted commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor 98ab04b94b Improve visual cue heroes for color real estate and Gemini safety.
Ban hex codes from image prompts to stop Nano Banana swatch strips, require crisp deep focus and set-dressed surfaces inside close product-world scenes, and add wordless and real-estate inspection retries. The Gemini wrapper now converts JPEG bytes to PNG and infers AQ.-prefixed keys.

AI-assisted commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor c23c587ed5 Add provider-flexible image API path and dedupe visual cue compiles.
Ship image-gen.mjs with bfl and gemini backends plus custom delegation, document the keyless flow in image-api.md, and tighten seed Step 4 so missing keys ask once and opt-out falls back to text-only seeding. Compile now removes in-folder hero intermediates so each cue keeps one PNG.

AI-assisted commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor a0f7fae4e2 Speed up visual cues wave: drop smoke test, slim spawns, brief-only reads.
Replace preflight generation with harness tool presence, move territories into brief.md, forbid specialist repo reads, and document native tool output paths.

AI-assisted commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor af01134825 Drop artifact sheets from visual cues for faster hero-only pipeline.
Each specialist now generates one hero image instead of a second chroma-key sheet, and the compile step replaces crop with a simpler cues.json shape.

AI-assisted commit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul Wahab 93e0571f20 update 2026-09-01 10:00:42 +05:00
Abdul WahabandCursor f8981c61dd Switch artifact sheets to per-cue chroma keys kept in crops
Drop the two-pass matte pipeline in favor of one chroma-key sheet per
cue: each specialist picks the farthest-hue key, stages larger artifacts
with shadowless crisp edges, reports CHROMA in its reply, and crop records
the hex in cues.json for browser keying downstream.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor 7593ec9d93 Matte transparent artifacts from two sheet passes; require square, well-lit generations
Artifact crops now ship with real alpha: each sheet is generated twice
(cream, then black via image edit) and fused by a new difference-matting
command, sidestepping models' inability to emit alpha and the chroma
key's spill. Crop and matte reject non-square inputs so squareness must
come from the generation call's 1:1 parameter, and the hero prompt now
demands bright, generous light so palettes stay sampleable.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor a84002d69b Fix cross-subagent image collisions on shared output filenames
A live run shipped one hero under three slugs: parallel subagents all
wrote the generation tool's output as a generic hero.png in the shared
project assets folder, then copied and referenced each other's files.
Spawn tasks now require slug-based output filenames, using only the
tool-reported path, a verify-your-own-files check before reporting, and
the parent hashes all heroes before cropping and re-spawns duplicates.

AI-assisted (Cursor agent), directed by abdulwahabone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor 2ae67485fb Stage hero palettes as large color fields, framed close
Live cues rendered the good colors as slivers: the hero prompt asked for
an atmospheric scene with a 60% neutral ground and a small accent. The
skeleton now frames tight on the subject and stages each color as a
large unbroken field (neutral about half, primary a continuous third,
tertiary small but legible), with the rationale in the fill rules.

AI-assisted (Cursor agent), directed by abdulwahabone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor 6e4dcef80c Fix: em dashes in the territory example set
AI-assisted (Cursor agent), directed by abdulwahabone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor 0473c9f7e0 Give every territory a hue ground and gate image gen on self-critique
Investigation of a live run showed five of six palettes converging on the
brief's deep-teal anchor: territories were scenic but hue-silent, and the
anchor reached every spawn unscoped. Territories now must close on a hue
ground (no two in one family), the anchor is owned by exactly one
territory with everyone else told to leave it, spawn tasks may not be
compressed, and each subagent critiques its palette and concept against
the rules before generating.

AI-assisted (Cursor agent), directed by abdulwahabone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor 71f683ddb6 Harden the parallel mandate and keep six cues on the serial path
Name the common spawn tools, require all six spawns in a single message,
and call out spawn-await-spawn as the serial-loop failure it is. The
no-subagent fallback now runs all six personas instead of four, so the
user gets six cues on every path.

AI-assisted (Cursor agent), directed by abdulwahabone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor a6d818b31c Assign territories upfront and run the studio as one wave
Drop the sequential palette chain: the parent carves six visual,
emotional, or strategic territories from the brief, then all six
personas spawn in one parallel batch, each composing inside its own
territory and generating both images in the same task.

AI-assisted (Cursor agent), directed by abdulwahabone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor 4bf39fe09d Split the studio into a palette chain and an image wave
Personas now compose sequentially, each shown the color territories the
earlier personas claimed, replacing the similarity script and peer
critique as the uniqueness mechanism. Generation moves to a parallel wave
of dedicated image-prompt specialists that stage the finished palettes.

AI-assisted (Cursor agent), directed by abdulwahabone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul Wahab e237df2b11 Revert "Replace peer critique with a similarity convergence loop"
This reverts commit 57e45628f39149cf3700da4a431f66a595754d1d.
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor 24aaabdd26 Replace peer critique with a similarity convergence loop
Drop the six-way critique wave from the visual-cues studio: after the
compose wave, the OKLab similarity script loops with revision waves for
flagged personas only (capped at three) until every verdict is clear,
then the generation wave stages the settled palettes.

AI-assisted (Cursor agent), directed by abdulwahabone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor 54a5cbd379 Unify studio flow on wave numbering
Drop the parallel "round" vocabulary from the visual-cues studio so one
counting system covers the three spawn waves; the subagent template calls
the palette a first draft instead.

AI-assisted (Cursor agent), directed by abdulwahabone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor a0fd0475f5 Compose cue palettes with six competing persona subagents
Replace the single-mind palette composition in the visual-cues pipeline
with a three-wave studio: six persona specialists compose in parallel, a
new OKLab similarity command plus peer critiques build each persona's
scorecard, then each revises and generates its hero and artifact sheet.

AI-assisted (Cursor agent), directed by abdulwahabone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor ec83174eb2 Generate visual cues for document seed color picks
Document seed mode's palette pick used to ask the user to imagine colors
from words. Add a visual-cues pipeline: compose four-hex palettes from the
brief (mood, references, PRODUCT.md), generate a hero scene plus a cream
artifact sheet per concept, crop the sheet into isolated artifacts, and
write cues.json so the user can pick a direction by eye.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul Wahab f92adeab17 Revert "Recognize the seed-collected image key in shape and craft."
This reverts commit 70eddbe2742948e1b8e95a3ef515de4e85b55a80.
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor 65ac2a5f13 Recognize the seed-collected image key in shape and craft.
A stored IMAGE_GEN_API_KEY in .impeccable/.env now counts as image generation being available for shape probes and the craft codex flow. The key ask itself stays exclusive to document seed mode; shape and craft still never prompt for APIs or tooling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor 840165463c Add editor voice and capability-gated visual cues to document seed.
Seed questions now avoid skill vocabulary (mirroring init.md's magazine-editor line), and a new step offers 2-4 generated palette cue images: native image_gen when the harness has it, otherwise an opt-in IMAGE_GEN_API_KEY in gitignored .impeccable/.env.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:42 +05:00
Abdul WahabandCursor 94ed8d7d45 Extend document seed mode to gather brand assets first.
Ask for logos, reference images, and moodboards before the five-question interview so the first DESIGN.md seed can ground taste selections in real visual context.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 10:00:41 +05:00
github-actions[bot] 94b7f34f6e Sync generated provider output 2026-09-01 04:23:23 +00:00
Paul BakausandGitHub 6fe900dbb4 Improve incumbent evidence and direction fusion (#689)
Prefer committed visual goldens when the app cannot run and make assigned-system translation explicit when a pinned register conflicts with literal materials.\n\nAI-assisted: prepared with Codex under @pbakaus direction.
2026-09-01 00:22:49 -04:00
github-actions[bot] 2c8816a3ce Sync generated provider output 2026-09-01 04:01:22 +00:00
Paul BakausandGitHub 632912b5ae Fix live script response encoding (#690)
Declare UTF-8 on the generated live and detector JavaScript responses and cover both endpoints with integration assertions.\n\nAI-assisted: prepared with Codex under @pbakaus direction.
2026-09-01 00:00:51 -04:00
Paul BakausandGitHub 85d82c0afc Fix PRODUCT schema drift (#688)
Update the public init description and migrate the repository product record to the current stamped schema without changing its established product truths.\n\nAI-assisted: prepared with Codex under @pbakaus direction.
2026-09-01 00:00:43 -04:00
github-actions[bot] 5b585c0885 Sync generated provider output 2026-09-01 03:49:18 +00:00
Paul BakausandGitHub 187790826d Fix concept seed under symlinked installs (#686)
Resolve the CLI entry path through realpath and cover linked skill directories on Unix and Windows junctions.

AI-assisted change: implemented with Codex under @pbakaus direction.
2026-08-31 23:48:43 -04:00
github-actions[bot] 40b5151237 Sync generated provider output 2026-09-01 03:28:21 +00:00
Paul BakausandGitHub 1bcdf80f91 Fix radius var fallback detection (#687)
Strip closing var() parentheses before resolving fallback radius tokens, preserving on-scale values and actionable ignore values.

AI-assisted change: implemented with Codex under @pbakaus direction.
2026-08-31 23:27:49 -04:00
github-actions[bot] d3f4cc8f4b Sync generated provider output 2026-09-01 02:43:18 +00:00
Abdul WahabandGitHub 5211bdf4b1 Fix: critique snapshot close path (#660)
Preserve critique trend history while closing the exact processed snapshot. Track target identity and content freshness for local files and URLs, isolate colliding streams, and prevent legacy snapshots from resurfacing after a modern close.

Validated with 50 focused tests, a clean 17-provider build, 834 core tests, passing GitHub CI and Cursor Bugbot, Greptile 5/5, and zero unresolved review threads.

AI-assisted maintainer repair: implemented and reviewed with Codex under @pbakaus direction.
2026-08-31 22:42:44 -04:00
Abdul WahabandGitHub bfafc7dbcb Fix plugin script path resolution (#535)
Makes plugin instructions execute the installed plugin copy, safely quotes paths containing spaces, removes the broad Node pre-approval, and ships guarded generated plugin output.\n\nAI-assisted maintainer conflict resolution, review, and validation by Codex under maintainer direction.
2026-08-31 20:16:04 -04:00
github-actions[bot] 0bc8643b51 Sync generated provider output 2026-09-01 00:11:15 +00:00
Abdul WahabandGitHub 4fb66a15e7 Fix prompt embedding for install paths with spaces (#676)
Resolves the embed helper with a filesystem path, reports fallback accurately, and adds regression coverage while leaving generated harness output to the post-merge sync.\n\nAI-assisted maintainer repair, review, and validation by Codex under maintainer direction.
2026-08-31 20:10:41 -04:00
oleg kovalandGitHub f3df3ffe40 Add Veto harness support (#675)
Adds Veto detection, provider transforms, installation paths, documentation, and regression coverage.\n\nAI-assisted maintainer repair, review, and validation by Codex under maintainer direction.
2026-08-31 19:52:46 -04:00
Paul Bakaus 405998ad51 Normalize sheriff exemption labels
AI-assisted: implemented and validated by Codex under maintainer direction.
2026-08-31 16:43:21 -07:00
github-actions[bot] 4cc9578a3d Sync generated provider output 2026-08-31 23:34:40 +00:00
Paul BakausandGitHub 8c59bc7fb0 Centralize image prompt parsing (#641)
Centralize raster prompt lookup and PNG chunk parsing while preserving read, scan, replacement, and sidecar behavior.

AI-assisted merge: reviewed and executed by Codex under explicit interactive maintainer direction.
2026-08-31 19:34:07 -04:00
github-actions[bot] 52139bc8d3 Sync generated provider output 2026-08-31 23:22:14 +00:00
jesusdizvalls-cellandGitHub ac64a2fe12 Fix grammar in skill introduction (#679)
Correct the article before award-winning in the source skill introduction. Generated provider copies will refresh through the post-merge sync.

AI-assisted merge: reviewed and executed by Codex under maintainer direction.
2026-08-31 19:21:34 -04:00
github-actions[bot] 61a2d230fd Sync generated provider output 2026-08-31 23:21:27 +00:00
Abdul WahabandGitHub 2cfd60765a Fix: do not flag Roboto in system font stacks (#678)
Treat the leading system face as primary so later Roboto fallbacks do not trigger overused-font, while named web-font primaries still flag.

AI-assisted merge: reviewed and executed by Codex under maintainer direction.
2026-08-31 19:20:51 -04:00
github-actions[bot] 205643cf7a Sync generated provider output 2026-08-31 23:08:20 +00:00
MorganandGitHub 1130d00ebc Fix: keep direction contracts out of production output (#673)
Store direction contracts in development-only surface briefs and guard against browser-delivered leakage with regression coverage.

AI-assisted merge: reviewed and executed by Codex under maintainer direction.
2026-08-31 19:07:44 -04:00
Paul Bakaus ee1442d7af Improve PR sheriff queue flow
Make policy and merge-conflict blockers age as contributor work, keep maintainer-policy decisions out of ready state, auto-close regular contributors unless explicitly exempted, and mark new or reopened issues for triage.

AI-assisted change: implemented and validated by Codex under maintainer direction.
2026-08-31 15:47:32 -07:00
TekniumandGitHub 33367e2297 Add Hermes Agent to supported harness docs (#672)
Documents the existing Hermes Agent provider, including profile-aware global installation and project trust behavior. Contributed by the Hermes Agent maintainer.\n\nAI-assisted change: repaired, validated, and merged by Codex under maintainer direction.
2026-08-31 18:39:24 -04:00
github-actions[bot] 5b5fdc12f3 Sync generated provider output 2026-08-31 22:22:06 +00:00
Abdul WahabandGitHub fc442be223 Fix: make hooks reset remove installed manifest entries (#668)
Fixes #512 and resolves the verified reproduction in #667.\n\nAI-assisted change: reviewed and merged by Codex under maintainer direction.
2026-08-31 18:21:34 -04:00
Abdul WahabandGitHub 2b1804deaa Fix: ignore review and questions dirs in README gitignore (#677)
Fixes #669.\n\nAI-assisted change: reviewed and merged by Codex under maintainer direction.
2026-08-31 18:20:58 -04:00
github-actions[bot] e92bf2b774 Sync generated provider output 2026-08-31 20:32:06 +00:00
Paul BakausandGitHub a74bb22e3a Merge pull request #684 from pbakaus/codex/simplify-design-rule-extraction-20260831
Simplify design rule extraction
2026-08-31 16:31:28 -04:00
dependabot[bot]andGitHub 3a37bcb7d3 Bump the Bun dependency group with 7 updates (#683)
Update marked, the coordinated provider packages, Anthropic SDKs, and Puppeteer. Keep ai at the known-good 7.0.69 until provider-backed behavior validation can confirm the post-7.0.70 tool-loop path.

AI-assisted dependency maintenance performed by Codex.
2026-08-31 16:00:51 -04:00
Paul Bakaus f1e8d4e70e Simplify named rule extraction
AI-assisted by OpenAI Codex under maintainer pbakaus's scheduled-refactor authorization.
2026-08-31 12:59:53 -07:00
github-actions[bot] b0594c72d1 Sync generated provider output 2026-08-29 00:58:57 +00:00
Paul BakausandGitHub 79a08fde1a Merge pull request #599 from pbakaus/feat/comp-fidelity
Comp fidelity: measured spec, plates, and gated build phases for comp-led work
2026-08-28 20:58:23 -04:00
Paul Bakaus 9434dde9af Merge main: skipScan visual-contrast coverage, live overlay waivers, generated output sync
The generated browser bundle is rebuilt from the merged engine sources in the next commit's build step (both branches had regenerated it).

AI-assisted (Claude Code).
2026-08-28 16:01:35 -07:00
Paul Bakaus 10f7c7b6f8 The unreferenced-plate refusal names its own escape: --artifact <page> when the scan cannot see the reference
Closes the residual page-inference edge (no recorded artifact, no index.html, several root HTML files) by making the conservative refusal self-correcting instead of adding more inference; the gate never falsely passes in that configuration, only asks for the page.

AI-assisted (Claude Code).
2026-08-28 15:53:52 -07:00
Paul Bakaus af109a85ae gateHero resolves the page before the unreferenced-plates check
Greptile's fourth finding on the seam: the no-artifact path was depth-limited. The page default (index.html, or the one .html at the root) now applies before unreferencedPlates, so the link-following path, which is exact and unbounded, handles every build that has a page; the bounded walk is only the no-page fallback.

AI-assisted (Claude Code).
2026-08-28 15:43:53 -07:00
Paul Bakaus 0d2df39339 Root-relative stylesheet hrefs resolve against the project, not the drive root
Bugbot on #599: path.resolve treated /assets/hero.css as filesystem-absolute. Both the working directory and the artifact's directory are tried; unreadable candidates skip.

AI-assisted (Claude Code).
2026-08-28 15:32:22 -07:00
Paul Bakaus 64001fe213 unreferencedPlates follows the artifact's linked stylesheets by name
Greptile's third P1 on the same seam: a stylesheet linked from the artifact but outside the bounded walk's root, depth, or file limit was still invisible. The hrefs the artifact itself declares are resolved against its directory and joined to the corpus, which closes every variant.

AI-assisted (Claude Code).
2026-08-28 15:26:28 -07:00
Paul Bakaus 18e8c287b5 unreferencedPlates: an explicit artifact joins the source corpus instead of replacing it
Greptile's follow-up P1 on #599: with --artifact set, only that HTML file was read, so a plate referenced exclusively from a linked stylesheet still read as unused. The bounded source walk now runs either way.

AI-assisted (Claude Code).
2026-08-28 15:17:09 -07:00
Paul Bakaus 09ddc1758e sourceFiles walks assets/: a stylesheet there may be the one reference to a plate
Greptile P1 on #599: unreferencedPlates read a plate referenced only from assets/hero.css as unused and the hero gate refused a valid build. The extension filter already keeps binaries out of the walk.

AI-assisted (Claude Code).
2026-08-28 15:04:57 -07:00
Paul Bakaus 3818a5655b Address the Bugbot and Copilot findings on #599
- keyChroma re-encodes with the PNG's tEXt chunks intact (the embedded prompt survived generation but not keying)
- organic-clip-path counts relative curve commands too (path data letters are only commands, so the match is case-insensitive)
- buried-raster normalizes percentage alphas (parseFloat('80%') read as 80) and reads 4- and 8-digit hex alpha instead of treating #rrggbbaa as opaque
- the extension-injected-node skip in checkQuality runs before any finding is pushed (a low-opacity injected raster was recorded, then returned by the skip)
- fake-mode plates carry impeccable:fake tEXt and the plates gate's crop-identity refusal skips them (fake mode IS the crop by design; the refusal is for models shipping the comp's pixels as artwork)

Findings by cursor[bot] and Copilot on PR #599; detector engines rebuilt (build:browser, build:extension).

AI-assisted (Claude Code).
2026-08-28 14:54:04 -07:00
Paul Bakaus 7edc5a43da font-match: a browser module without its binary is the same as no browser
CI resolves playwright but has no downloaded chromium; launch threw instead of falling back to the catalog ranking, and every spec gate downstream failed. Launch failures now return the no-browser path (and the browser test skips instead of asserting).

AI-assisted (Claude Code).
2026-08-28 14:47:21 -07:00
Paul Bakaus c75f9f1086 Above the bar, hero readings advise instead of block; spec escape hatches persist and announce; font-match tolerates an unwritable /tmp
Paul's decision on the tenth sweep's design question: hard vetoes (missing region, contradicted plate or text, SVG illustration, clipped plate, invented ink) stay unconditional; at overall >= HERO_MIN the numeric readings (ink colour, letter-spacing, line pitch, strip heights, box positions) print as advisories with the pass and belong to the polish pass. Every sweep-10 sample closes its hero under this condition, which settles 07 without another paid round.

Ninth-sweep defects: codeDrawn / container / bleed now persist into spec.json with WARN lines (an overridden refusal used to vanish from the record); font-match probes os.tmpdir() and points TMPDIR at .impeccable/tmp when the sandbox /tmp is unwritable (every ninth-sweep rank silently fell back to the catalog).

AI-assisted (Claude Code).
2026-08-28 14:41:47 -07:00
github-actions[bot] ea360025b5 Sync generated provider output 2026-08-28 13:39:10 +00:00
00095adb26 Fix: skipScan must cover the visual contrast stage too
Bugbot on PR #665: the skipScan guard emptied only the analytic
collectBrowserFindings pass, and scan()'s detached visual-contrast
stage then repopulated an ignoreFiles-waived page with contrast
markers and a second non-zero results post. Hoist the guard into
skipScanActive() and honor it in scan() and the async collector;
regenerate the browser bundle.

Adds a browser-backed regression test that reproduces the leak
(second results post carrying low-contrast findings) and pins the
zero contract; drops a tautological assert flagged in review.

AI-assisted change: implemented with Claude Code under maintainer
direction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 18:38:36 +05:00
46f13989eb Fix: write the build-path flip before answering the POST
serve-question answered POST /build-path with 200 and only then wrote the
flip file. The caller is a separate process, so the response could reach it
while the server was still preempted before the write landed: a poller that
trusted the 200 could look for the flip file and miss it.

Measured on a loaded machine, the old order lost that race 29 times out of
40; writing first and answering after loses it 0 times out of 40. This is
what made tests/serve-question.test.mjs fail intermittently in CI on the
Node 22 job while passing on Node 24.

AI-assisted change: diagnosed and implemented with Claude Code under
maintainer direction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 18:38:36 +05:00
152d6940b0 Fix: harden live overlay detector waivers (#639 follow-up)
Read waiver config from every live root (appRoot, contextRoot, repoRoot),
so monorepo projects whose config lives at the repo root reach the
overlay; serialize served roots and page identities repo-relative there.

Resolve each page URL to its actual serving file via the inject config's
resolved page list before applying file-scoped waivers; ambiguous URLs
keep the conservative common-ancestor fallback (PR #645 review
discussion r3840011436).

Honour detector.ignoreFiles: a wholly waived page now scans to zero
findings in the overlay, matching the CLI and the edit hook.

Guard the resolver call so a throwing resolver degrades to an unfiltered
scan instead of breaking the detect toggle.

Match design-system-color waivers by color value across hex and rgb()
spellings, and stop extracting font values for bounce-easing findings,
mirroring extractFindingIgnoreValue. Regenerate the browser bundle.

AI-assisted change: reviewed, planned, and implemented with Claude Code
under maintainer direction.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 18:38:36 +05:00
github-actions[bot] 09506a9bb5 Sync generated provider output 2026-08-28 10:15:23 +00:00
cbd7870159 Address review: collision-resistant slugs, os.homedir() tilde expansion
- The per-project state dir key is now the readable separator-mapped
  slug plus an 8-hex sha256 of the resolved project path. The readable
  part alone is lossy (/x/my.app and /x/my-app both mapped to -x-my-app
  and shared hook state); the digest keeps distinct projects' cache and
  pending state apart while the dir name stays human-scannable.
- Tilde roots now expand via os.homedir() instead of HOME/USERPROFILE
  with a '.' fallback. When no home dir can be determined, expansion is
  rejected and state falls back to the project-local default rather
  than anchoring under the hook process's working directory.
- Tests updated to the digest-suffixed slug via a mirrored slugFor()
  helper, plus two new cases: colliding readable slugs get distinct
  state dirs, and the tilde form resolves identically to the explicit
  homedir-joined form.

Prepared with AI assistance (Claude Code) under direction of
0xDarkMatter, per the maintainer-approved issue #422.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 15:14:49 +05:00
30b3628f5b Expand a leading ~ in IMPECCABLE_CACHE_ROOT against the home dir
Env files and settings JSON hand '~/caches' to Node unexpanded; without
this it would resolve to a literal '~' directory under the process cwd.
Mirrors the exact treatment IMPECCABLE_HOOK_LOG already gets in
writeAuditLog (HOME || USERPROFILE fallback), plus the Windows '~\'
spelling.

Prepared with AI assistance (Claude Code) under direction of
0xDarkMatter, per the maintainer-approved issue #422.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 15:14:49 +05:00
5c82d58b7e Harden IMPECCABLE_CACHE_ROOT edges: normalization, opt-in gate, failure path
- hookStateDir now trims the env value (stray whitespace in env files)
  and path.resolve()s both the root and the cwd, so trailing separators
  and relative segments slug to the same per-project dir.
- The #344/#305 persist gate also treats an existing (possibly
  redirected) cache file as the opted-in marker. Without this, once
  state relocated, clean-edit editCount bumps stopped persisting because
  the project-local .impeccable/ dir never appears. No-op under stock
  paths, where the cache file lives inside .impeccable/.
- New tests: slug normalization equivalences, whitespace trim, graceful
  persistCache failure on an unusable root, and three runHook
  end-to-end cases (findings persist + dedup through the redirect,
  clean-edit editCount persistence, and the no-footprint no-op gate
  holding under redirect).

Prepared with AI assistance (Claude Code) under direction of
0xDarkMatter, per the maintainer-approved issue #422.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 15:14:49 +05:00
77a2eae861 Add IMPECCABLE_CACHE_ROOT to relocate hook state out of project roots (#422)
Honor an optional IMPECCABLE_CACHE_ROOT env var in getCachePath() /
getPendingPath(): when set, hook.cache.json and hook.pending.json land
under $IMPECCABLE_CACHE_ROOT/<project-slug>/ (slug = project path with
[:\/.] mapped to hyphens, mirroring Claude Code's ~/.claude/projects/
convention). Unset or blank env keeps stock project-local behavior.
User-authored config (config.json, config.local.json, design.json)
deliberately stays project-local - only disposable state relocates.

Also clears ambient IMPECCABLE_CACHE_ROOT at the top of hook.test.mjs so
a developer running the suite with the redirect active still gets
deterministic stock-path assertions; the new suite sets and restores the
var explicitly.

Prepared with AI assistance (Claude Code) under direction of
0xDarkMatter, per the maintainer-approved issue #422.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 15:14:49 +05:00
github-actions[bot] 0c2517884d Sync generated provider output 2026-08-28 10:02:05 +00:00
Paul BakausandAbdul Wahab 08b03e8763 Centralize live path glob matching
AI-assisted change prepared by Codex under scheduled architecture-simplification authorization from maintainer pbakaus.
2026-08-28 15:01:21 +05:00
Paul BakausandAbdul Wahab 3df4c4b10d Simplify CI test plan routing
Collapse the nightly alternate plan into the shared event routing while preserving every GitHub output and schedule behavior. Strengthen the nightly characterization for all deterministic suites.\n\nAI assistance: prepared by OpenAI Codex under maintainer pbakaus's standing scheduled-refactor authorization.
2026-08-28 15:00:56 +05:00
Abdul WahabandClaude Opus 5 f379c4c76f COMP-FIDELITY: ninth sweep (sol, artifact fix confirmed) and tenth sweep (opus confirmation on the rebased branch)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 14:59:06 +05:00
github-actions[bot] 31dcc687c6 Sync generated provider output 2026-08-28 02:14:29 +00:00
45943c3b1f Fix: assert only the common ancestor of the glob roots as a URL prefix
Cursor's review caught the previous commit over-correcting. One tree listed
at two depths (prototype/*.html plus prototype/library/**/*.html) derived
two roots, and requiring a waiver to match under both stopped a normal
project-relative waiver like prototype/index.html from applying anywhere.

The rule both reviews were circling is simpler: one live session is served
by one server, so a single document root must sit at or above every
configured page. The only prefix the resolver can safely assert is the
deepest common ancestor of the glob roots. Nested roots collapse to their
shared tree, so normal waivers keep applying. Disjoint roots (src/ and
public/) share nothing, so no prefix is asserted and only the URL path
itself matches, which keeps the earlier fix intact: a src/foo.html waiver
still cannot hide a finding on a page served from public/foo.html.

This also deletes the match-under-every-root machinery from the previous
commit; with a single asserted prefix, plain matching is enough.

Also switches the new test file to derive the repo root from
import.meta.url rather than process.cwd(), per review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 07:13:43 +05:00
ce1c9f8dad Fix: a waiver scoped to one served root must not hide findings on another
Greptile's review found a real bug in the new resolver. When the live config
lists pages under more than one folder (src/**/*.html and public/**/*.html),
the overlay treated a URL like /foo.html as src/foo.html and public/foo.html
at the same time. A waiver written only for src/foo.html could then hide a
finding on the page actually served from public/foo.html. That fails in the
worst direction: a real finding disappears and nothing says so.

The overlay can never look up the right file. The live server does not serve
the pages; the project's own dev or static server does, and its URL-to-file
mapping is invisible from here. So the fix stops guessing: a file-scoped
waiver now applies only when it matches the URL path itself, which is true
whichever folder serves the page, or when it matches under every configured
folder, so no possible reading disagrees. Anything ambiguous shows the
finding, which is also what the CLI reports for the file really being served.

With a single configured root, the common case, nothing changes: the new
rule reduces to the old behaviour exactly. Multi-root projects keep three
ways to write a waiver that still applies: name the file under each folder,
use the bare path, or use **/.

Two new unit tests pin the ambiguous case and the safe spellings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 07:13:43 +05:00
5330fa358e Fix: honour .impeccable detector ignores in the live overlay (#639)
The live overlay's detect scan ran unfiltered: requestDetectScan() posted
only { scanId }, so detector.ignoreRules and detector.ignoreValues in
.impeccable/config.json reached impeccable detect and the edit hook but
never the surface a designer actually watches.

The server now serializes the project's detector waivers into the /live.js
prelude (window.__IMPECCABLE_PROJECT_IGNORES__), read per request through
hook-lib's readConfig so config.local.json wins and edits land on the next
tab reload. A new script part, live-browser-ignores.js, resolves that
config against the page URL when a scan starts: ignoreRules suppress
outright, wildcard ignoreValues suppress their rule in the files their
globs name, and the remaining entries ride along as disabledValues for the
detector to match on each finding's own value. The detector bundle applies
those where the findings are assembled, since the overlay draws its own
markers from the collected findings.

Scope resolution mirrors cli/lib/impeccable-config.mjs deliberately: the
same glob dialect (globToRegex, including {a,b} alternation), the same
path-suffix matching as findingMatchesScopedIgnoreFile, and the same
refusal to apply an unscoped wildcard entry. The served-root prefixes that
bridge project-relative globs and site-relative URLs come from the inject
config's own files globs, never from the ignore globs; deriving them from
the ignore globs lets one entry scoped to prototype/library/** lend its
prefix to every page and suppress site-wide, which looks like success
because the numbers go down.

Known gaps, recorded in the detector comment: the motion value extractor
is not mirrored, so a value-scoped bounce-easing waiver only matches when
the finding carries ignoreValue directly, and design-system-color matches
on the normalized string without the CLI's color-equality fallback.

Tests: unit tests for the resolver part (stale globals, string ignoreRules,
malformed entries, directory URLs, percent-escapes, glob metacharacters,
the roots trap), an extension-mode puppeteer test that disabledValues
suppress exactly the waived findings, and the live-browser regression pin
now asserts the new scan config shape instead of { scanId }.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 07:13:43 +05:00
Paul BakausandAbdul Wahab ae42c0c3ce COMP-FIDELITY: eighth sweep (opus 84/84 on 05, 79/78 on 07 with the scaffold and the SVG ban)
AI-assisted (Claude Code).
2026-08-28 06:13:59 +05:00
Paul BakausandAbdul Wahab 2896e28613 Hero code scans read index.html when start recorded no artifact; painted-note regex learns 'geometry', 'leader lines', 'thumbnail'
A sol build named its two carburetor drawings 'countable ... geometry' chrome regions, drew them in inline SVG, and the SVG ban never ran because state.artifact was null.

AI-assisted (Claude Code).
2026-08-28 06:13:59 +05:00
Paul BakausandAbdul Wahab 1117672934 docs: the 2026-08-17 human review of comp-fidelity builds (verdicts and pin notes)
AI-assisted (Claude Code).
2026-08-28 06:13:59 +05:00
Paul BakausandAbdul Wahab 9ca77151de Register tests/hero-checks.test.mjs and lib/hero-checks in the suite map
AI-assisted (Claude Code).
2026-08-28 06:13:59 +05:00
Paul BakausandAbdul Wahab bef185360f build-phase.mjs scaffold: the measured layout as CSS custom properties and a reference page
A reference, not the page: --r-<id>-x/y/w/h in % of the comp (plus cap height, font-size, family, weight where measured) to bind to any markup, and hero-reference.html with every region at its box and every plate placed with object-fit: contain, as a check on positions. Attacks the most common execution failure of weaker builders (badly positioned, overflowing, pushed below the fold) without dictating structure to strong ones; overlapping boxes are overlapping boxes and the gate reads pixels regardless.

AI-assisted (Claude Code).
2026-08-28 06:13:59 +05:00
Paul BakausandAbdul Wahab 2debb7078f Hero refuses inline SVG illustrations; finish cannot record ship over an open phase; the comp-led path names its model tier
From the human review's most repeated pin ('terrible svg instead of asset', on every model) and from sessions that wrote 'ship' with the hero open. Icons, arrows, chevrons, and runtime data charts stay code; diagrams, notation, and leader lines are plates.

AI-assisted (Claude Code).
2026-08-28 06:13:59 +05:00
Paul BakausandAbdul Wahab 866bd94b7e COMP-FIDELITY: final human verdicts on sweeps 6-7
AI-assisted (Claude Code).
2026-08-28 06:13:59 +05:00
Paul BakausandAbdul Wahab 38170564c6 Plates gate refuses a comp crop shipped as a plate; text readings include letter-spacing
From the final review batch: 'bad asset crop (crops are never allowed)' twice, 'letter spacing way too wide'. A crop resampled to the region scores 99.8% structure against the raw region; a produced plate scores 30-60.

AI-assisted (Claude Code).
2026-08-28 06:13:59 +05:00
Paul BakausandAbdul Wahab 9746567ea9 COMP-FIDELITY: seventh sweep and the review's two verdict boundaries
AI-assisted (Claude Code).
2026-08-28 06:13:59 +05:00
Paul BakausandAbdul Wahab 77acd73cd3 Hero readings: sibling regions fold into one line; ink colour only on type at cap 16+
A build reached hero 81% and stalled on eight staff rows read one at a time (and both ways on ink colour). The session asked the user and was told to build as written; the force was legitimate and recorded.

AI-assisted (Claude Code).
2026-08-28 06:13:59 +05:00
Paul BakausandAbdul Wahab 7cea970c6c comp-spec snaps text and control regions to the largest ink mass in their grid span
A session's own note said its hero sat at 67 because the 10x10 grid boxes straddled two elements each, and it was right: every downstream measurement (cap height, line count, structure) inherited the slop. Text and control regions now snap to the dominant connected ink inside the span (page-ground threshold, dilated cells, masses touching the span's sides lose to inside masses), keep the span on the record for coverage, and can opt out with snap: false.

AI-assisted (Claude Code).
2026-08-28 06:13:59 +05:00
Paul BakausandAbdul Wahab 36e6864d56 Controls are held like text at the hero: a contradicted control vetoes, a far drift is named; the icon concession covers glyphs only
Per pbakaus: close-enough icons are fine, arrows and dropdown chrome are not.

AI-assisted (Claude Code).
2026-08-28 06:13:59 +05:00
Paul BakausandAbdul Wahab 693953806c COMP-FIDELITY: the human review, its calibration, and what each pin became
AI-assisted (Claude Code).
2026-08-28 06:13:59 +05:00
Paul BakausandAbdul Wahab f80846ad85 Side-by-side shows the capture, not the shift-padded copy; colour reading on unmeasurable text; line pitch on 3+ lines
The shifted copy's padding read as a white 'letterbox' on the build in every human review. A vertical spine came back white on red where the comp had black in five builds; its ink colour is now compared even though its type cannot be measured.

AI-assisted (Claude Code).
2026-08-28 06:13:59 +05:00
Paul BakausandAbdul Wahab 07e3fad3b6 Spec refuses a plate box that cuts its artwork; hero counts strong small invented inserts; painted-note regex no longer matches a label
From the second review batch: the best build of the fifth sweep passed the hero at 87% with the cover arch cut flat on the left (object-fit: cover on a box narrower than the shape), and legends, badges, and extra controls one or two cells wide slipped under the invented-ink floor. comp-spec measures the artwork's contiguous contact with each box edge against the page ground; the spec gate refuses such a box unless bleed is set. Human pass line landed at comp-diff 72-73; HERO_MIN stays 0.72.

AI-assisted (Claude Code).
2026-08-28 06:13:59 +05:00
Paul BakausandAbdul Wahab 1931066107 COMP-FIDELITY: sixth sweep
AI-assisted (Claude Code).
2026-08-28 06:13:59 +05:00
Paul BakausandAbdul Wahab bae0c24f45 Hero readings go advisory after three unchanged attempts; overall shows a decimal near the floor; a single link is not a strip
One cf6 session spent 27 attempts on the same three readings and read '72% < 72%'.

AI-assisted (Claude Code).
2026-08-28 06:13:59 +05:00
Paul BakausandAbdul Wahab 9a1daacd76 Hero readings as an ordered edit list; a refused force points back at them
AI-assisted (Claude Code).
2026-08-28 06:13:59 +05:00
Paul BakausandAbdul Wahab 00a180380e Hero gate reads type, strips, and invented ink as numbers; every region needs a note; missing beats the text relaxation
From the first human review of sweep-3 builds (pins on 12 samples): fonts at the wrong size, weight, colour, or place; nav bars too tall; kickers and dividers the comp does not have; a footer strip pushed off the frame that read as drift on ground colour alone; a drawing filed as chrome with no note to catch it.

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

AI-assisted (Claude Code).
2026-08-28 06:13:59 +05:00
Paul BakausandAbdul Wahab ba26730411 COMP-FIDELITY: opus arm and 07 re-pass
AI-assisted (Claude Code).
2026-08-28 06:13:59 +05:00
Paul BakausandAbdul Wahab 356362a410 comp-spec refuses a code region larger than a quarter of the comp
A session named seven regions for a page with three plates, a table, a note, callouts and a spine, so the hero gate could name nothing and the score sat at 70. A code region is one element; a column is a container of several.

AI-assisted (Claude Code).
2026-08-28 06:13:59 +05:00
Paul BakausandAbdul Wahab f5819fbd6a COMP-FIDELITY: fourth sweep
AI-assisted (Claude Code).
2026-08-28 06:13:59 +05:00
Paul BakausandAbdul Wahab 3e7e85daf7 font-fingerprint: full-height inked columns leave the row profile; forceAllowed needs the user's reported words and a downgrade
Staff rules and a black page edge fused eight track rows into one 389px 'line'. A session forced two gates by quoting a brief line ('should feel like an extension of her artwork') as permission; a force now needs the user's words reported or quoted, a downgrade verb, and the comp noun in one reason.

AI-assisted (Claude Code).
2026-08-28 06:13:59 +05:00
Paul BakausandAbdul Wahab 2cc60a5348 font-fingerprint: measure the dominant lettering class in a mixed crop; NEXT prefers generate-image --plate
A comp region drawn on the 10x10 grid over-covers: a body-copy crop carries the last headline line above it and a drawing beside it, and one session measured 'thread-body' at cap 160px off a carburetor drawing and ranked Londrina Shadow for it. Tall non-text 'lines' leave the mass reference; lines cluster by cap height and the cluster holding the most ink (multi-line first) is measured, re-applied after upsampling.

The plates NEXT line now names generate-image.mjs --plate as the tool (harness image tool only as fallback) after a session spent 25 turns keying plates with magick.

AI-assisted (Claude Code).
2026-08-28 06:13:59 +05:00
Paul BakausandAbdul Wahab 08ce88f565 Texture presence at the hero: structure over palette
AI-assisted (Claude Code).
2026-08-28 06:13:59 +05:00
Paul BakausandAbdul Wahab 4f2de6a0b6 Gates: a passed texture with held ground is placed; responsive does not re-score a passed plate as missing
AI-assisted (Claude Code).
2026-08-28 06:13:59 +05:00
Paul BakausandAbdul Wahab caf9744763 Hero gate: a passed plate is placed material; plate rows travel on state; low-detail text with held structure is drift; wait long on plate generation
Also records the third sweep in COMP-FIDELITY.md (branch +7 to +18 points over main on three niches, sol).

AI-assisted (Claude Code).
2026-08-28 06:13:59 +05:00
Paul BakausandAbdul Wahab 99f73390db Comp fidelity: font ranking that holds without a browser, spec refuses painted chrome, control-box veto only for discrete controls
font-match / catalog index
- Index schema 2 adds a third render, 48c (48px cap, ALL CAPS text). Caps headline crops have no x-height band and ranked against mixed-case renders as barcode faces; they now route to the caps render.
- Non-text families (barcodes, redacted, flow, dingbats, effect faces) are excluded from candidates.
- The distance adds a gross width and weight gap (log ratio of advance and ink density) so a face 50% wider or 35% lighter cannot rank first on run-length detail alone; the index stores those readings.
- Multi-line all-caps crops vote on x-height across lines: one line's crossbars no longer give the crop a spurious x band.
- With no browser, --rank records the catalog's nearest face (source catalog, size estimated) so the spec gate can close; the NEXT line and new-work.md say not to install a browser or hand-write a choice.
- font-match stamps the choice it writes; the spec gate refuses a chosen face it did not write (sessions typed Arial Narrow into spec.json to pass).
- IMPECCABLE_NODE_MODULES lets a harness lend a playwright.

comp-spec
- A region note that describes painted material (diagram, drawing, photo, texture...) under a code kind is refused at the spec unless codeDrawn is set: the exploded carburetor filed as chrome is a plate about to be redrawn in SVG.

build-phase
- The control ink-box veto applies only when the comp's ink is a discrete element and the build's box is too; a full-width bar told one session six times that 1376x87 was 1382x102 with no edit able to move it.

AI-assisted (Claude Code).
2026-08-28 06:13:59 +05:00
Paul BakausandAbdul Wahab 919c68d2b9 COMP-FIDELITY: record the second sweep (packets without state, prefix inertia, WebP comps)
AI-assisted (Claude Code).
2026-08-28 06:13:59 +05:00
Paul BakausandAbdul Wahab 48350ffb14 Read WebP/JPEG comps through a sibling PNG cache instead of forcing PNG
comp-spec, comp-diff, build-phase, font-match, and generate-image now decode any comp raster via loadRaster(), converting non-PNG input to <file>.png next to the source. Sessions used to hit 'not a PNG' and rewrite the .webp in place with PNG bytes, which broke transcript replay (a later step rewrites the comp beyond the cut) and left a mislabeled file.

AI-assisted (Claude Code).
2026-08-28 06:13:59 +05:00
95294e464a font-match v2: fingerprint the comp lettering and pick candidates from a Google Fonts catalog index
lib/font-fingerprint.mjs replaces the three-number fingerprint with
size-invariant shape features (x-height ratio, stroke contrast, stem width,
run-length quantiles, roundness, serif signal, width spread) and a
noise-normalized distance; family recall on a held-out self-test rose from
13% to 72% top-5. data/font-index.json carries the whole Google Fonts
catalog (3,092 faces at two cap sizes, 707 KB); font-match --rank fingerprints
the comp crop, takes the 25 nearest faces from the index (plus the model's
own names), renders them at the comp's cap height, ranks by the same
distance, and prints a proof sheet and the CSS to use. scripts/build-font-
index.mjs rebuilds the index at release time.

AI-assisted (Claude).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 06:13:59 +05:00
cacb2868ac No comps outside the state: generate-image refuses .impeccable/mocks/ output while a roll is pending and build-phase has not started
The first paid confirmation sweep showed the failure: models rendered the
three comps first and ran build-phase.mjs start after, so a session cut at
the composition pick carried no state.json and the resumed model followed
the conversation ('translate the comp into HTML now') instead of the
phases. Decision comps (.impeccable/mocks/decision/) are unaffected;
--force-mock overrides.

AI-assisted (Claude).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 06:13:59 +05:00
62f59a3934 build-phase: gate errors are refusals that name themselves, never stack traces; fix ink-box crash on report shape
The first paid confirmation run hit a TypeError in the ink-box check
(report regions carry normalized w/h at the top level, not under box);
runGate now catches a throwing gate and returns a one-line refusal with
an explicit force path so the run is not lost to a tool bug.

AI-assisted (Claude).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 06:13:59 +05:00
cbe89a1969 Round-4 sim fixes: lead text region by cap height, ink-box only for discrete controls, grain allowed where the comp is grainy, textures cannot block responsive alone
AI-assisted (Claude).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 06:13:59 +05:00
f5751c4d75 font-match: choose the face by metrics; chroma-keyed plates; ink-box report; verbatim words in the hero
font-match.mjs fingerprints a text region's lettering in the comp (cap
height, glyph advance per cap = width class, ink fraction = weight class,
tracking), renders candidate faces at that cap height in a headless
browser (yours plus a shortlist for the width class), and ranks them by
distance with a proof sheet; the spec gate refuses to close until the lead
text region is measured and ranked. generate-image --plate keys ink-on-
ground plates to alpha (chroma) so the drawing sits on the page's own
ground; the plates gate scores keyed plates composited over the region's
ground. comp-diff reports each region's ink box; the hero gate names a
control whose box height or width differs from the comp. The hero
instruction copies the comp's words verbatim; rewording is a stated
decision after the hero passes.

Driven by a human review of the r3 side-by-sides: face width and weight,
plate ground, control row height, and content substitution.

AI-assisted (Claude).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 06:13:59 +05:00
b015e26ddf Round-3 sim fixes: record hero is not an attempt; texture bands under present ink are drift; capture guidance
AI-assisted (Claude).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 06:13:59 +05:00
f704fca29c forceAllowed: a 'truthful translation' the model proposed is not the user downgrading the comp
AI-assisted (Claude).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 06:13:59 +05:00
bfa6014796 Round-2 sim fixes: one plate rule, plate size from the gate's floor, missing means empty
plateVerdict() is shared by the plates gate and generate-image's PLATE-WARN
so they cannot disagree; --plate picks a frame that clears the 1.5x width
floor (a square region wider than 682px takes the 1536 landscape frame);
'missing' on text/chrome/control regions requires the build region to be
near-empty, so a 12px rule a few pixels off reads as contradicted or drift,
not missing; the responsive gate does not re-litigate a plate that passed
the hero; record hero after close does not inflate the attempt count.

AI-assisted (Claude).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 06:13:59 +05:00
af4ac68805 Plates: textures tile a clean comp patch first; record hero reports plate rows
AI-assisted (Claude).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 06:13:59 +05:00
e618357a69 Responsive gate: the desktop capture must still read as the comp
Round-1 simulated builds passed the hero at 1536 and shipped a page whose
first viewport collapsed to one column at 1440 (comp-diff 50% on the
final capture, 82% on the hero). The responsive phase now requires
desktop.png and mobile.png and diffs desktop.png against the comp at 65%
with no region missing.

AI-assisted (Claude).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 06:13:59 +05:00
24773eeedf Hero gate: text and chrome regions read as drift once structure and palette hold; scripts run through symlinks
Three simulated builds under the previous gate reached 78-83% overall with
the exploded plate placed and the table right, then spent 12-20 attempts
chasing 'contradicted' verdicts on a headline set in a substitute face and
on 50px chrome strips whose detail was paper grain. Text with structure
above the floor and its palette intact is drift; chrome and controls with
structure and palette held are drift. The adversarial set (swapped
columns, mirror, sepia, noise plate, tile shuffle) still fails.

isMain uses realpath on both sides so a skill mounted through a symlink
(Cursor, worktrees, staged evals) still runs its CLIs.

AI-assisted (Claude).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 06:13:59 +05:00
d9a155be8c Gates that cannot be talked past: metric hardening, one start command, uncovered-ink refusal
From a forensics pass over twelve runs and two adversarial passes over
the metrics:

- comp-diff: detail is signed and penalizes invented energy; regions with
  structure under 0.3 (or painted regions under 0.45 / added detail over
  0.4) are contradicted whatever the mean says; palette ramp tightened;
  region crops inherit the whole-image best translation so a shifted page
  is not eight contradicted regions.
- hero gate: fails on any contradicted plate/image/text region (chrome and
  controls keep the one-third allowance), on a capture that is not the
  comp's frame, on a palette that is not the comp's, and on an organic
  clip-path drawn inside a raster region's box.
- plates gate: scored against the comp crop with overlapping text/chrome
  painted out (comp-spec plateReference; generate-image uses it too), with
  a structure floor and an added-detail veto; the real plate passes, noise,
  mirrors, mosaics, and other regions do not.
- comp-spec refuses a regions file that leaves comp ink unnamed.
- The direction-choice ping is folded into build-phase.mjs start
  --direction --kind; the roll writes .impeccable/build/pending.json and
  context.mjs / detect.mjs report COMP_ROUND_OPEN until the hero gate
  passes. A code-led config makes start print the contract step and stop.

AI-assisted (Claude).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 06:13:59 +05:00
e0ec77b4bb Lock down the scroll-clip case for text-occlusion (#602)
The paintedRect clamp landed in 9d2b0556 without a fixture, so nothing
stopped the false positive coming back. This adds the shape that produced
it: a scroll region with an opaque bar directly beneath.

Text scrolled past the panel's bottom edge still reports its full
unclipped rect, and that rect lands on the bar. The probe then samples
coordinates the text is not painted at, finds the bar, and reports the
text as buried under it. Any sticky footer or toolbar under a scroller
has this shape.

Verified red then green: with the clamp reverted to main's version the
fixture reports a fourth finding and the assertion fails; with it in
place the count holds at three.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 06:13:59 +05:00
6dd15238c4 Close the comp-round gap and make the hero gate teach
concept-seed's choice ping now prints the next mandatory step from the
recorded build path (comp-led: build-phase.mjs start --direction <key>),
because every run that skipped the comp round did so right after that
ping. build-phase gains a comps phase ahead of spec (three sidecar'd comps
under .impeccable/mocks/, one approved) and records the approved comp on
close. The hero gate lists the worst region crops first with the fix class
per verdict, and refuses a third value-only attempt on the same stuck
region. Hero instruction is plates first, then the semantic layer. The
finish reviewer treats a comp-led build with no closed comps phase as a
material finding.

AI-assisted (Claude).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 06:13:59 +05:00
2fbfef0b43 docs: final numbers from the overnight sweep
AI-assisted (Claude).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 06:13:27 +05:00
2dcaacbcd1 docs: first eval sweep results for comp fidelity
AI-assisted (Claude).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 06:13:27 +05:00
b450da2082 comp-spec: --help and the exact regions.json shape after --grid
AI-assisted (Claude).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 06:13:27 +05:00
f4987eebba Hero gate refuses while a produced plate is unreferenced by the source
The first live run produced a faithful carburetor plate, then drew the
region in SVG and left the plate on disk. Before diffing, the hero gate
now walks the artifact (or a bounded source tree) for every plate's file
name or a data URI named for it.

AI-assisted (Claude).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 06:13:27 +05:00
63dd7faa0e build-phase: textures skip the size floor, --force needs the user's words
The first live run forced past the plates gate with 'single-file HTML
delivery requires embedded CSS/SVG'. That is not a reason the comp's
authority moves for; the script now refuses a --force whose reason does
not quote the user, and new-work.md says a single-file deliverable inlines
the plate as a data URI. Texture plates are judged on palette and grain,
not size or structure, since they tile.

AI-assisted (Claude).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 06:13:27 +05:00
34ef9ac2c4 Rewrite the comp-led build around the phase gates; reviewer reads the diff first
new-work.md section 6 becomes the spec / plates / hero / sections / motion /
responsive phase list, each closed by build-phase.mjs advance; the
reproduction and hero-checkpoint prose that asked the model to compare
from memory is gone. visualize.md's inventory, medium gate, and produce
sections collapse into the spec and plate mechanism. The finish reviewer
takes the state file and comp-diff reports as inputs and starts its
fidelity matrix from the measured verdicts. docs/COMP-FIDELITY.md records
the design.

AI-assisted (Claude).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 06:13:27 +05:00
5856161014 Plate pipeline, asset producer rewrite, and two detector rules for CSS standing in for material
generate-image.mjs --plate produces one raster region of the measured spec
from the comp crop, scores it against the crop, and refuses under --min.
The asset producer's job becomes producing the spec's plates. Detector
gains organic-clip-path (many-vertex polygon / curved path() clips) and
buried-raster (raster under a near-opaque wash or at near-zero opacity),
wired into both engines with fixtures.

AI-assisted (Claude).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 06:09:46 +05:00
b0fc2e8801 Add comp-diff, comp-spec, and build-phase: measured comp fidelity for the build phase
Dependency-free PNG codec, perceptual metrics (structure / color / detail /
bands), side-by-side + heatmap + per-region crops, a measured spec from the
approved comp (grid overlay, sampled palette, plate list), and a phase state
machine whose spec / plates / hero gates run the diff instead of asking the
model to remember the image.

AI-assisted (Claude).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-28 06:09:46 +05:00
github-actions[bot] f86473ba7d Sync generated provider output 2026-08-28 00:53:54 +00:00
377fb112b0 Pass the session key from detached idle-grace tests
Main's #469 tests POSTed /heartbeat and /answer without ?key=, which the
gate now rejects, so those daemons looked dead. The e2e heartbeat counter
also has to match pathname rather than a suffix, now that the URL carries
the key.

Written with AI assistance under maintainer direction.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 05:53:25 +05:00
7982002dac Allow bare loopback Host/Origin on port 80, where browsers omit the suffix
Bugbot caught that the exact-match allowlists 403 every request on --port 80
because browsers drop the default-port suffix; other ports stay strict.

Written with AI assistance under maintainer direction.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 05:53:25 +05:00
2e075dc58c Gate the build-path flip behind the same session key and origin checks
An unauthenticated POST /build-path wrote the flip event that makes --wait
instruct the agent to generate comps: same class as the /answer hole in #555.

Written with AI assistance under maintainer direction.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 05:53:25 +05:00
eaaecbd1fe Fix: require session key and origin/host checks on serve-question POSTs (#555)
Unauthenticated POST /answer copied steer into the agent ANSWER line. The handler now requires the detached session key and rejects foreign Origin and Host.

Written with AI assistance under maintainer direction.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 05:53:25 +05:00
d690349db1 Fix: keep URL basic-auth credentials on the scan origin (#657)
page.authenticate is page-wide, so a cross-origin redirect that then 401s would receive the original credentials. Attach Authorization only to requests for the scan origin.

Written with AI assistance (Cursor); reviewed by maintainer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 05:53:01 +05:00
d5873ff8eb Fix: redact URL userinfo from detect findings (#657)
Strip basic-auth credentials from scan-target URLs before goto and finding output, and pass them to page.authenticate instead.

Written with AI assistance (Cursor); reviewed by maintainer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 05:53:01 +05:00
github-actions[bot] 1df992ade0 Sync generated provider output 2026-08-28 00:44:04 +00:00
be87f5eb86 Fix: refuse inert exact ignore-value entries (#662)
ignore-value stored exact values for rules that cannot extract one, so the entries never matched. Refuse them and point at "*" --file.

AI assistance: implemented with Cursor Grok 4.6.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 05:43:36 +05:00
af2e8b3ac3 Fix: stream bundle downloads to disk instead of buffering
AI assistance: implemented with Cursor Grok 4.6.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 05:43:04 +05:00
5d932f9fbe Fix: safe temp staging and downloadFile error handling (#479)
AI assistance: implemented with Cursor Grok 4.6.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 05:43:04 +05:00
63b04e2530 Release: CLI v3.6.1, extension v1.3.3
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 11:02:54 +05:00
380cfcb08e Release: skill v4.1.2
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 11:02:54 +05:00
github-actions[bot] ba68dce41e Sync generated provider output 2026-08-26 05:31:15 +00:00
Abdul WahabandGitHub 8f416aa760 Merge pull request #653 from pbakaus/fix/652-root-relative-stylesheets
Fix: resolve root-relative linked stylesheets in static detect (#652)
2026-08-26 10:30:46 +05:00
Abdul WahabandCursor daae1d4117 Fix: reject root-relative .. segments and warn per scan
Dot-segment hrefs like /../outside.css could leave the project, and a process-wide warning set hid missing-sheet notices on later detectHtml calls.

AI assistance: implemented with Cursor Grok 4.6.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 19:47:54 +05:00
Abdul WahabandCursor 2b88aa5231 Fix: resolve root-relative linked stylesheets in static detect (#652)
Root-relative hrefs like /static/app.css were treated as OS-absolute and silently dropped, hiding contrast findings.

AI assistance: implemented with Cursor Grok 4.6.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 19:34:55 +05:00
github-actions[bot] fcd7622cd2 Sync generated provider output 2026-08-25 12:17:17 +00:00
Abdul WahabandGitHub 356b761391 Merge pull request #594 from pbakaus/fix/570-monorepo-design-root
Fix: inherit the monorepo root's DESIGN.md in detect design-system rules (#570)
2026-08-25 17:16:47 +05:00
github-actions[bot] 1159100c96 Sync generated provider output 2026-08-25 11:17:54 +00:00
Abdul WahabandGitHub 0e9b6f9884 Merge pull request #651 from pbakaus/fix/573-context-windows-teardown
Fix: close fetch sockets before context helper exit (#573)
2026-08-25 16:17:11 +05:00
Abdul WahabandCursor 47e411952b Fix: own nested workspace packages and honor projectRoots first (#570)
packages/* now includes nested package.json dirs under a matched
workspace package, and Impeccable projectRoots govern a path even when
package-manager workspaces exclude it.

Written with AI assistance (Cursor); reviewed by maintainer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 08:03:16 +05:00
Abdul WahabandCursor 6bea544a0a Fix: drain context stdout before process.exit (#573)
process.exit after a queued write truncated boot output on a backpressured pipe. Await the write callback, then close the fetch dispatcher.

AI assistance: implemented with Cursor Grok 4.6.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 07:51:47 +05:00
Abdul WahabandCursor 5d7c1cce34 Fix: inherit DESIGN.md only from a monorepo root that owns the path (#570)
findDesignRoot continued past every workspace package.json to any
workspace-declaring ancestor. It now matches the boundary against that
ancestor's globs (including negations and globstars), so excluded and
stray packages do not inherit, while included workspaces still do.

Written with AI assistance (Cursor); reviewed by maintainer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 07:47:16 +05:00
Abdul WahabandCursor 2ef8e43d1e Fix: close fetch sockets before context helper exit (#573)
On Windows/Node 24, a live undici keep-alive from the update-check fetch aborted libuv during teardown after valid stdout. Destroy the dispatcher first, matching concept-seed.

AI assistance: implemented with Cursor Grok 4.6.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 07:31:18 +05:00
Abdul Wahab 043e8a5bfd Merge origin/main into fix/570-monorepo-design-root 2026-08-25 06:58:14 +05:00
dependabot[bot]andGitHub 78b50aa416 Build(deps): update the Bun dependency group (#650)
Update the grouped Bun dependencies while holding ai at 7.0.69 to avoid the automatic tool-execution behavior regression introduced in 7.0.70.

Prepared and validated with AI assistance.
2026-08-24 15:15:01 -04:00
Abdul WahabandGitHub c3a30086bc Merge pull request #649 from pbakaus/codex/link-hook-trust-guide
Docs: Link harness trust guidance
2026-08-24 08:26:26 +05:00
Abdul Wahab 21510c3632 Docs: Link harness trust guidance
Point installer readers to the harness-specific approval and verification steps on impeccable.style.\n\nAI-assisted: Codex prepared and verified this documentation update under direct maintainer instruction.
2026-08-24 08:00:38 +05:00
github-actions[bot] 5d00e30405 Sync generated provider output 2026-08-24 02:52:15 +00:00
Abdul WahabandGitHub f01a808890 Merge pull request #647 from pbakaus/fix/603-codex-stop-payload
Fix: emit Codex Stop hook as decision/block (#603)
2026-08-24 07:51:42 +05:00
Abdul WahabandCursor 2064b0696f Merge origin/main into fix/603-codex-stop-payload
Keep Codex as its own Stop harness (decision/block) while taking main's Grok envelope detection and Stop cache sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 07:25:47 +05:00
github-actions[bot] c3ea1c79f5 Sync generated provider output 2026-08-24 02:18:59 +00:00
Abdul WahabandGitHub f849d610f3 Merge pull request #648 from pbakaus/fix/646-grok-hook-stdin
Fix: parse Grok Build camelCase hook stdin (#646)
2026-08-24 07:18:24 +05:00
Abdul WahabandClaude Fable 5 bfe634e254 Trim the Grok normalizer to the fields the hook reads
The hook_event_name mapping (and its pre_tool_use entry) had no reader:
hook.mjs routes on the raw stdin via isStopEvent, and nothing downstream
keys on the normalized event name. The trailing-slash strip duplicated
the path.resolve every consumer already does. Pin the one commit-2
behavior that had no test: a Stop detector failure must leave the
remembered set alone.

Prepared with AI assistance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 06:52:52 +05:00
Abdul WahabandCursor 3c442af7ad Fix: sync Stop finding cache after a clean Grok scan
A clean Stop never replaced remembered keys, so a finding that was fixed and then reintroduced stayed silent. Remember the live scan, including empty, and persist that write.

Prepared with AI assistance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 06:23:45 +05:00
Abdul WahabandGitHub c0b1ec6fef Merge pull request #644 from pbakaus/fix/642-grok-global-hook
Fix: rewrite Grok project hooks to the global skill path (#642)
2026-08-24 06:14:38 +05:00
Abdul WahabandCursor 35ae07339b Fix: parse Grok Build camelCase hook stdin (#646)
Grok was classified as GitHub Copilot, so the design hook skipped every
edit with no-file-path and never ran Stop. Normalize toolInput/sessionId
and treat Stop additionalContext as the Grok product.

Prepared with AI assistance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 05:28:21 +05:00
Abdul WahabandCursor c9e7cd8a64 Fix: emit Codex Stop hook as decision/block (#603)
Codex Stop rejects Claude's hookSpecificOutput shape. Detect Codex from
turn_id at runtime and emit { decision: "block", reason } so existing
installs keep working without rewriting hook commands.

AI-assisted change, prepared with Cursor Grok under maintainer direction.

Fixes #603
Fixes #643

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-24 05:23:51 +05:00
github-actions[bot] c39b6425fa Sync generated provider output 2026-08-23 23:33:14 +00:00
Abdul WahabandGitHub 2c39c39f16 Merge pull request #631 from pbakaus/fix/488-strip-reserved-poll-fields
Fix: strip page-controlled poller fields before they reach the agent (#488)
2026-08-24 04:32:49 +05:00
Abdul WahabandGitHub c87e460f5e Merge pull request #623 from pbakaus/codex/centralize-framework-detection-20260820
Centralize live adapter detection probes
2026-08-24 04:32:32 +05:00
github-actions[bot] b40982a967 Sync generated provider output 2026-08-23 23:30:15 +00:00
Abdul WahabandGitHub 82cb738f46 Merge pull request #629 from pbakaus/fix/618-live-source-symlink
Fix: stop live-server /source from following symlinks out of the workspace (#618)
2026-08-24 04:29:43 +05:00
Abdul WahabandCursor 869c887372 Test: cover directory, chained, and relative /source symlink escapes (#618)
AI assistance: Cursor Grok 4.6 implemented this change.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 05:51:12 +05:00
Abdul WahabandCursor bda7411acd Fix: strip page-controlled poller fields before they reach the agent (#488)
A page-supplied _instructions suppressed the locally generated next step and was presented as authoritative over live.md. Drop reserved poller-owned fields at ingest and always overwrite them locally.

AI assistance: implemented with Cursor Grok 4.6.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 05:31:05 +05:00
Abdul WahabandCursor d008dd98c3 Fix: stop live-server /source from following symlinks out of the workspace (#618)
AI assistance: Cursor Grok 4.6 implemented this change.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 05:19:36 +05:00
Paul Bakaus 93dce3d62e Centralize framework detection probes
Reuse the shared dependency and ordered file-probe helpers in the SvelteKit and TanStack live adapters, removing duplicate package parsing and path search.

AI assistance: prepared by OpenAI Codex under maintainer pbakaus standing scheduled architecture-refactor authorization.
2026-08-20 11:38:20 -07:00
Abdul WahabandCursor e975bec412 Harden monorepo design-root recognition and the home-directory stop (#570)
Read all four workspace-glob sources context.mjs reads (.impeccable
projectRoots, package.json workspaces, lerna packages, pnpm packages),
so lerna-glob roots and impeccable projectRoots no longer hit the same
abstention. Compare the walk against both the logical and realpath
forms of the home directory: on distros that symlink home paths
(/home to /var/home) the string comparison never matched, and the
post-boundary walk could inherit a workspace-declaring home's
DESIGN.md.

Written with AI assistance (Cursor); reviewed by maintainer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 01:07:52 +05:00
Abdul WahabandCursor 91f2c7b47e Fix: strip inline YAML comments when reading pnpm workspace globs (#570)
An inline comment on a pnpm-workspace.yaml packages line defeated the
end-anchored flow-list regex and the block-list state switch, so
workspaces outside apps/ or packages/ went unrecognized. Reuses the
engine's existing stripInlineYamlComment, matching context.mjs.

Written with AI assistance (Cursor); reviewed by maintainer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 23:47:59 +05:00
Abdul WahabandCursor dca8f1ca6f Fix: inherit the monorepo root's DESIGN.md in detect design-system rules (#570)
findDesignRoot stopped at the first package.json boundary, so every
design-system rule silently abstained for files inside monorepo
workspaces. The walk now continues past a workspace boundary to the
monorepo root that owns it, recognized the same way context.mjs does
(declared workspace globs, or a marker file beside apps/ or packages/
children). A nested repo with its own .git, a workspace-owned
DESIGN.md, and non-monorepo projects keep their existing behavior.

Written with AI assistance (Cursor); reviewed by maintainer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 23:41:29 +05:00
1101 changed files with 165822 additions and 9524 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 4.1.1
version: 4.1.2
license: Apache 2.0
allowed-tools:
- Bash(npx impeccable *)
@@ -17,7 +17,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s);
0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-<width>.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round.
1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/review/hero-repro.png` exists: the hero reproduction checkpoint's capture at the comp's own dimensions; its absence means the reproduction phase ran unproven, a material finding. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all.
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition.
4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport.
5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every image-native region of the approved comp shipped as a real asset, not a gradient standing in for one, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind other paint is a compliance token, not a shipped material.
+4 -4
View File
@@ -2,9 +2,9 @@
Manage the **design detector hook** for the current project.
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write. Grok Build fires the same PostToolUse scan to mark touched files, then surfaces findings on Stop `additionalContext`. Do not expect a Grok per-edit reminder: Grok discards that stdout.
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit.
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code, Codex, and Grok Build, which dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit. Grok also fires an observe-only Stop with `reason: "shutdown"` after `end_turn`; skip that one, scan only `end_turn`.
Every hook is a mechanical pass. The reflexes no scanner catches live in [craft-floor.md](craft-floor.md), which the skill loads before it edits UI, so they apply whether or not a hook is wired. A session with no automatic hook gets one `MANUAL_DETECTOR_REQUIRED` directive from `context.mjs` asking for a single detector run at the end.
@@ -14,7 +14,7 @@ Declare server-side template extensions under **`detector.extensions`** when the
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), Grok Build (`.grok/hooks/impeccable.json` in the project; requires `/hooks-trust` or `--trust`), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands.
@@ -44,7 +44,7 @@ The first argument is the action. Defaults to `status`.
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
@@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u
4. Run `node .agent/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen.
5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLES PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .agent/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .agent/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
@@ -68,7 +68,7 @@ Calibration: AI-generated interfaces cluster around a few looks regardless of su
## 5. Record the decision
Before code, state the chosen direction as a contract in the artifact's opening comment, five short blocks, 150 words at most, in a form that survives the production build: an HTML comment in the emitted markup, never only a templating-frontmatter comment, placed as the first child of the document's body in the root layout, never inside a slotted or child component (some compilers, Astro among them, strip a slot's leading comment while keeping deeper ones). After the first production build, grep the built output for the seed key; a contract the build erased is a contract nobody can audit. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, and the seed key the script printed. Close with one more line, FINISH: the run's exit condition, verbatim "unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md". The comment tops the artifact you re-open on every edit, the one reminder that survives a long build: a page that looks complete with the FINISH line undischarged is not done, it is abandoned at the finish line. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
Before code, state the chosen direction as a contract in the artifact's opening comment, five short blocks, 150 words at most, in a form that survives the production build: an HTML comment in the emitted markup, never only a templating-frontmatter comment, placed as the first child of the document's body in the root layout, never inside a slotted or child component (some compilers, Astro among them, strip a slot's leading comment while keeping deeper ones). After the first production build, grep the built output for the seed key; a contract the build erased is a contract nobody can audit. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, and the seed key the script printed. Close with one more line, FINISH: the run's exit condition, verbatim "unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance". The comment tops the artifact you re-open on every edit, the one reminder that survives a long build: a page that looks complete with the FINISH line undischarged is not done, it is abandoned at the finish line. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
On a new or replacement world, DESIGN.md is written at finish, from the built world, by the shipped documenter (section 7); a rulebook written before the build gets defended against reality instead of describing it, and hands the design-system detector an unstable target. A new world shipped with no DESIGN.md is still an incomplete run. An ordinary extension does not rewrite DESIGN.md.
@@ -86,7 +86,7 @@ For `shape`, return the selected direction to [shape.md](shape.md) and stop befo
## 6. Build with full commitment
When an approved comp exists, the comp is king, and the build happens in phases. The comp is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words, and difficulty never infers a downgrade. Phase one is reproduction: rebuild the comp at its own breakpoint until a screenshot at the comp's width and height overlaps it near pixel-perfectly, materials, components, elevation, assets, and implied design language included. Exactly three concessions exist: fonts (the closest obtainable face), icons (exact match unless the user already chose an icon library), and genuine defects in the generated comp such as spelling errors. Everything else must match, and models systematically believe their HTML, CSS, and SVG recreation succeeded when it did not, so the overlap comparison is the authority, never your conviction: set the screenshot beside the freshly reopened comp image at identical dimensions after every region, never beside your memory of it, and when a region keeps losing that comparison, stop recreating it in code and produce it as a rendered asset composited into the page. The comp also outranks every written record of it: when the recorded brief or inventory commits to less than the comp shows, a softer texture, a sparser field, a sculpted plate reduced to flat CSS, correct the record upward to the comp; qualifiers like subtle, restrained, and low-contrast, and counts rounded down to a comfortable fraction, are how approved materials die between approval and build. A produced material must then survive to the screen: a texture buried under a nearly opaque color wash ships the wash, not the material, so judge every material by the screenshot beside the comp, never by the stylesheet. Only when reproduction holds does phase two begin: static regions that should live become animated or interactive, reveals and motion are added, then responsiveness across the surface's devices. Where the comp does not cover the whole surface, continue building the remainder inside the comp's recorded world and design language; a component the comp never shows inherits the recorded system's corner language, line weights, and materials, and may not introduce container styles, border weights, or chrome the comp never uses.
When an approved comp exists, the comp is king, and the build happens in phases. The comp is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words, and difficulty never infers a downgrade. Phase one is reproduction: rebuild the comp at its own breakpoint until a screenshot at the comp's width and height overlaps it near pixel-perfectly, materials, components, elevation, assets, and implied design language included. Exactly three concessions exist: fonts (the closest obtainable face), icons (exact match unless the user already chose an icon library), and genuine defects in the generated comp such as spelling errors. Everything else must match, and models systematically believe their HTML, CSS, and SVG recreation succeeded when it did not, so the overlap comparison is the authority, never your conviction: set the screenshot beside the freshly reopened comp image at identical dimensions after every region, never beside your memory of it, and when a region keeps losing that comparison, stop recreating it in code and produce it as a rendered asset composited into the page. The comp also outranks every written record of it: when the recorded brief or inventory commits to less than the comp shows, a softer texture, a sparser field, a sculpted plate reduced to flat CSS, correct the record upward to the comp; qualifiers like subtle, restrained, and low-contrast, and counts rounded down to a comfortable fraction, are how approved materials die between approval and build. A produced material must then survive to the screen: a texture buried under a nearly opaque color wash ships the wash, not the material, so judge every material by the screenshot beside the comp, never by the stylesheet. Every color the brief records gets that comparison by number, not by eye: sample the build screenshot's ground, dominant fields, and accents the same way each record was taken (an interior patch average where the record is an average, both end colors where the record is a gradient) and set each value against its recorded counterpart (sampled from the comp itself when the brief lacks one), and when a texture or tile paints over a base token, measure the net on-screen value, because the eye files a drifted color under the same color word and the number is what catches it. Judge the gap like a colorist, not a diff tool: a difference with a color name (warmer, grayer, darker than the record) is drift to fix, while a few digits of render and compression noise are the same color. Only when reproduction holds does phase two begin: static regions that should live become animated or interactive, reveals and motion are added, then responsiveness across the surface's devices. Where the comp does not cover the whole surface, continue building the remainder inside the comp's recorded world and design language; a component the comp never shows inherits the recorded system's corner language, line weights, and materials, and may not introduce container styles, border weights, or chrome the comp never uses.
Build the assigned direction, not a safer interpretation of it. The form supplies structure, reading order, component conventions, and native motion; the product supplies every fact. Commit every atom: nav, buttons, inputs, and links are rebuilt in the form's vocabulary, and a stock component inside a committed form is a lapse. Land the first build fully committed; committing is the hard part, and the passes that follow exist to make the committed thing clear and effective, never to dilute it. In unattended work, the safe rendition is the known risk.
@@ -113,6 +113,8 @@ Then spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccabl
Act on the disposition word; there are exactly four. **recapture**: the evidence failed, not the build. Recapture what the return names under the capture-validity rules, then run a full review over the new evidence. A review conducted on invalid evidence binds nothing, and a verdict pass may never follow it. **rebuild**: fidelity failed wholesale, not in patches. Skip the fix batch and execute the rebuild immediately: re-derive the named regions, produce the named assets, and send the result back for a fresh full review, never a verdict pass; a rebuild replaces regions wholesale, so the whole matrix runs again over the recaptures. Tell the user what is happening rather than asking permission to fix a failure. Consult the user only on a second rebuild directive, both verdicts on the table, or when rebuilding would discard content the user approved. **ship**: nothing is owed; report the verdict at its scope and continue to the documenter. **fix**: apply the material fixes in one batch, rebuild once, and recapture the same viewports over the same files. A recapture measures positions, loading, and overflow; it cannot measure whether a fix reached the quality the finding named, so send the recaptured screenshots back to the same reviewer for a verdict scoring every material fix resolved, partial, or unresolved (through the harness's agent continuation; without one, run the scoring fresh from [degraded/finish-reviewer.md](degraded/finish-reviewer.md)'s Verdict Pass). Fixes scored partial or unresolved get another batch, recapture, and verdict. Two rounds is the budget an unattended run ends at; an attended session's ceiling belongs to the user, so when the second verdict still lists open items, put the table in front of them and let them choose between shipping as it stands and funding another round. Whoever decides, stop the moment a round resolves nothing, and the reviewer's findings are the only list you work from, never your own re-opened hunt. Do not run a second detector.
A rebuild and a fix round share one asset rule: a raster either round creates or replaces is still asset work under [visualize.md](visualize.md)'s Produce section and keeps its **provenance** like every build raster, and a raster the round abandons is deleted in the same batch. Before either round's result goes back for review or verdict, run `node .agent/skills/impeccable/scripts/embed-prompt.mjs --scan <asset-dir...>` over the directories the artifact's rasters ship from and clear every file it reports by embedding what it is missing: the exact generation prompt for a produced raster, the origin for a sourced, stock, or pre-existing one. The scan only reads; deletion is reserved for rasters the round abandoned, never for a file the scan flagged.
Report the final verdict under the reviewer's own disposition word and at its actual scope. A verdict pass scores the listed fixes and nothing else: "the reviewer scored all three fixes resolved" is a claim it supports, "no material issues remain" is not. A table with open material findings is never announced as a pass, never softened, and never dressed as whole-surface approval when only a fix list was scored. When the user answers a ship with evidence against it, their own screenshot, a named mismatch with the comp, that evidence outranks every capture you made: put their material in the packet and spawn a fresh reviewer for a new full review. Patching inline and self-certifying is how a rejected page ships twice.
Then spawn the shipped documenter, `impeccable-documenter` (`impeccable_documenter` in codex), with the project root, the artifact path, the direction contract, PRODUCT.md, the [document.md](document.md) reference path, and the boundary to write at; it records DESIGN.md and the sidecar from the built world, ground truth over intention; without subagents the pass runs from [degraded/documenter.md](degraded/documenter.md). The documenter runs after the last correction lands: when any fix round follows the documentation, re-run the documenter over the changed surface, because a DESIGN.md describing a layout that no longer exists turns defects into system guidance. A clean detector pass is not finished; finished is the contract kept, the comp honored, the review closed, and the system recorded.
@@ -33,6 +33,8 @@ After approval, record the choice where tools can find it: the approved comp's p
Before building, read the approved comp as a design system and record it in the brief: component grammar, corner language, line weights, elevation treatment, and the type ramp. Everything the comp does not show gets built from this record; without it the fallback is the model's stock kit of square boxes, 1px grids, bento cells, and hard shadows. Then inventory the comp's major visible ingredients in writing (a short table in the surface brief or working notes; the finish reviewer audits shipped assets against it) and choose an implementation medium for each: semantic HTML/CSS/SVG, existing project asset, generated raster, sourced raster, icon library, canvas/WebGL, or accepted omission. The same inventory names the comp's compositional commitments: navigation items and icons, headline levels and their scale relationship, signature geometry such as seams, masks, and overlaps, and each section's arrangement and density. The primary action gets its own row with its own medium: when the comp dissolves, stamps, erodes, or otherwise physically works the main CTA, that treatment is signature material on the page's most important element, and shrinking it to a border trick is the compliance-token version of commitment. An element never written down is the element the build silently drops; the direction contract's 150 words cannot carry this list, so it lives here.
The record is sampled, never estimated: read the comp's page **ground**, each dominant field, and each accent's actual hex from its pixels (ImageMagick, Python with PIL, any pixel-reading tool on the machine) and write the values into the same record. Take a flat field from any interior pixel, a textured or grainy one as the average of an interior patch (crop a swatch, scale it to one pixel), and a gradient as its two end colors; never sample an edge, where antialiasing blends neighbors into colors the design never chose. An adjective is a direction, not a record: cream covers everything from near-white to beige, charcoal a third of the value scale, and wherever no number pins a color, the rendition prior picks the spot. Sampled values supersede the palette chips on the decision and composition cards: those were authored before this comp existed, and a chip that disagrees with the comp's pixels is a draft the approval retired.
The medium column is where an approved design most often dies, so it obeys a gate: the medium is decided by what the comp region shows, never by what feels buildable in the current stack. A human figure, a product object, machinery, or any material with lighting and depth is raster whatever the stack; so is any texture by name alone: woven cloth, paper grain, fabric, leather, brushed metal need no depth argument, because a CSS gradient is not a texture medium and "layered CSS textures" is not a medium at all. Writing "silhouette" for a photographic figure, or "CSS" for a sculpted panel's finish, is not a medium choice; it is the quiet deletion of the approved design, and it is how a comp full of physical material becomes a flat page with the same section order. Style does not move this boundary: a comp region with perspective, shading, figure drawing, or dense mechanical detail is illustration however line-drawn it looks, and no build session can author illustration as vectors, so it regenerates as raster like any photograph. Authored SVG covers what a session can specify exactly (diagrams with countable elements, controls, flat shape systems) and ends where drawing skill begins; an instruction-manual world keeps its illustrations as line-art illustrations, not diagrams. Produce such regions by regenerating them cleanly, with the approved comp and its embedded prompt as the reference for a fresh render at asset resolution; never crop pixels out of the comp itself, whose effective resolution sits far below asset grade. 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; "no photography on hand" forbids fake proof, not an illustrated hero.
The gate runs both ways: precise geometry, hard-edged shape systems, diagrams, expressive motion, shaders, and anything interactive are vector and GPU territory (SVG, canvas, WebGL), where a raster flattens what should move, scale, and respond. A field or texture built from many small elements carries a quantity commitment either way: write down its approximate density and coverage ("thousands of glyphs over two-thirds of the fold, dense at the top fading into the path"), because a field rebuilt at a tenth of its density passes every checklist and still is not the design. TYPE rows carry the same discipline: name the face's compression class, and render one headline word against the comp before building on it; a visibly wider or lighter silhouette means the face is wrong, and every section built on it inherits the miss. Raster is for what the world paints; code is for what the world draws, animates, or reacts with, and choosing code there is ambition, not economy. Every `produce` entry is produced before the build ships, through the asset producer or in the current thread; an inventory with unproduced entries is an unfinished build, and this gate is where imagery-free pages come from when it is skipped.
@@ -43,7 +45,9 @@ The comp is a north star, not something to trace, and know what that allows: tra
## Produce only the assets the build needs
Generation context is part of the asset: a build composed by a thread that never saw the prompts places assets it does not understand. Prefer generating build-critical imagery in the build thread when the budget allows; when a subagent produces assets instead, every asset carries its prompt, and the builder reads those prompts before composing. The carrier is uniform across harnesses: after generating any image with any tool, native or `generate-image.mjs` (which does it automatically), run `node .agent/skills/impeccable/scripts/embed-prompt.mjs <image> --prompt "<the prompt used>"` so the intent lives inside the file and survives copies between machines and harnesses; `--read` recovers it from any impeccable-generated image.
Generation context is part of the asset: a build composed by a thread that never saw the prompts places assets it does not understand. Prefer generating build-critical imagery in the build thread when the budget allows; when a subagent produces assets instead, every asset carries its prompt, and the builder reads those prompts before composing. The carrier is uniform across harnesses: after generating any image with any tool, native or `generate-image.mjs` (which does it automatically), run `node .agent/skills/impeccable/scripts/embed-prompt.mjs <image> --prompt "<prompt>"` with the exact string the generation tool received, pasted whole, so the intent lives inside the file and survives copies between machines and harnesses; a summary reconstructed from memory records an asset that was never made. `--read` recovers the prompt from any impeccable-generated image, and `--scan <dir>` lists every raster in a directory still missing one. The embedded prompt plus the asset's row in the written inventory is the raster's **provenance**, and every raster the artifact references carries it; a sourced, stock, or pre-existing raster with no generation prompt embeds its origin instead.
Provenance is owed for the run, not the build phase: a raster created or replaced later, in a fix batch or a reviewer's rebuild, is produced under this same section, prompt embedded and inventory row added, because the inventory is how the next thread knows what ships. A raster a fix abandons or supersedes is deleted from the assets directory in the same batch; an unreferenced raster with no record is a provenance leak, not a spare.
When the harness runs subagents, spawn the shipped asset producer every time, even when the inventory's produce bucket looks empty: its manifest is the independent second opinion on your media, and the runs that skipped the spawn are the runs whose cotton became CSS. An honestly empty manifest costs one cheap spawn; a wrongly empty produce bucket costs the build its materials. Use `impeccable-asset-producer` (`impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent"): give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Without subagents, produce the minimum required assets in the current thread by the book: load [degraded/asset-producer.md](degraded/asset-producer.md) and follow it inline, with whatever generation exists.
+23 -3
View File
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
}
}
// Destroy fetch's global undici dispatcher before process.exit(): a live
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
// successful boot (nodejs/node#56645, issue #573).
async function destroyFetchDispatcher() {
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
if (dispatcher && typeof dispatcher.destroy === 'function') {
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
}
}
// Drain the boot payload before process.exit(): a live pipe that has not
// flushed yet is truncated when Node tears down (issue #573 review). Then
// close fetch so Windows teardown does not abort on the keep-alive socket.
async function finishCli(output) {
await new Promise((resolve) => {
process.stdout.write(output, () => resolve());
});
await destroyFetchDispatcher();
process.exit(0);
}
// Two instructions used to sit in one directive: ask, and "if they agree, run
// it". Nothing gated the second on an answer, and the same sentence said to
// continue without waiting, so a run that could never establish agreement was
@@ -1159,8 +1180,7 @@ async function cli() {
appendImageToolsDirective(parts);
appendStalenessDirective(parts, ctx, cliOptions);
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
process.exit(0);
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
if (ctx.hasDesign) {
@@ -1206,7 +1226,7 @@ async function cli() {
}
}
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
function parseCliOptions(args) {
@@ -626,7 +626,7 @@ if (IS_BROWSER) {
if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');
if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');
const solidBg = parseRgb(currentStyle.backgroundColor);
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
current = current.parentElement;
}
@@ -688,7 +688,7 @@ if (IS_BROWSER) {
// starve the url()-backed texts this mode exists to sample.
if (options.imageOnly && !reasons.includes('image background')) continue;
const textColor = parseRgb(style.color);
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const fontSize = parseFloat(style.fontSize) || 16;
const fontWeight = parseInt(style.fontWeight) || 400;
const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
@@ -985,7 +985,7 @@ if (IS_BROWSER) {
return sample;
}
}
const bg = parseRgb(style.backgroundColor);
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
return { status: 'unresolved', reason: 'no readable background' };
}
@@ -1115,7 +1115,7 @@ if (IS_BROWSER) {
}
const style = getComputedStyle(el);
const textColor = parseRgb(style.color) || candidate.textColor;
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
@@ -13,6 +13,11 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
// CLI can't import (separate tree). `.git` and `package.json` are the common
// boundaries; `.impeccable` is our own project marker.
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
// Monorepo-root recognition, mirroring context.mjs's isMonorepoRoot: declared
// workspace globs (package.json `workspaces`, pnpm-workspace.yaml `packages:`)
// or a marker file beside apps/ or packages/ children.
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
const COLOR_CHANNEL_TOLERANCE = 6;
// Shadow blacks at different alphas are different tokens (0.28 vs 0.55 is the
// difference between a documented shadow and drift), so shadow matching cannot
@@ -575,14 +580,179 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
}
}
// Same two groups as context.mjs's readProjectPatternGroups: Impeccable
// projectRoots govern any path they match (positive or negated); package-manager
// globs only apply to paths the Impeccable group does not match.
function readWorkspacePatternGroups(dir) {
const impeccable = [];
for (const name of ['config.json', 'config.local.json']) {
const roots = safeReadJson(path.join(dir, '.impeccable', name))?.projectRoots;
if (Array.isArray(roots)) {
impeccable.push(...roots.filter(entry => typeof entry === 'string' && entry.trim()).map(entry => entry.trim()));
}
}
const pkg = [];
const workspaces = safeReadJson(path.join(dir, 'package.json'))?.workspaces;
if (Array.isArray(workspaces)) pkg.push(...workspaces);
else if (Array.isArray(workspaces?.packages)) pkg.push(...workspaces.packages);
const lernaPackages = safeReadJson(path.join(dir, 'lerna.json'))?.packages;
if (Array.isArray(lernaPackages)) pkg.push(...lernaPackages);
try {
let inPackages = false;
for (const line of fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf-8').split(/\r?\n/)) {
const trimmed = stripInlineYamlComment(line).trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const flow = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
if (flow) {
pkg.push(...flow[1].split(',').map(entry => entry.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean));
break;
}
if (/^packages:\s*$/.test(trimmed)) { inPackages = true; continue; }
if (!inPackages) continue;
const item = trimmed.match(/^-\s*(.+)$/);
if (item) pkg.push(item[1].trim().replace(/^['"]|['"]$/g, ''));
else if (/^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
}
} catch { /* no pnpm-workspace.yaml */ }
return [impeccable, pkg];
}
function readWorkspacePatterns(dir) {
return readWorkspacePatternGroups(dir).flat();
}
function isMonorepoRoot(dir) {
if (readWorkspacePatterns(dir).some(pattern => !String(pattern).trim().startsWith('!'))) return true;
if (!MONOREPO_MARKER_FILES.some(file => fs.existsSync(path.join(dir, file)))) return false;
return MONOREPO_FALLBACK_PROJECT_DIRS.some(name => {
try {
return fs.readdirSync(path.join(dir, name), { withFileTypes: true }).some(entry => entry.isDirectory());
} catch {
return false;
}
});
}
function monorepoOwnsPath(root, boundaryDir) {
const rel = path.relative(root, boundaryDir);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
const relSegments = rel.split(path.sep).filter(Boolean);
function normalizeWorkspacePattern(pattern) {
return String(pattern || '')
.trim()
.replace(/^['"]|['"]$/g, '')
.replace(/^\.\//, '')
.replace(/\/+$/, '');
}
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function segmentMatches(patternSegment, relSegment) {
if (patternSegment === '*') return true;
if (!patternSegment.includes('*')) return patternSegment === relSegment;
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
return re.test(relSegment);
}
function matchGlobSegments(patternSegments, relSegments) {
function rec(pi, ri) {
if (pi === patternSegments.length) return ri === relSegments.length;
if (patternSegments[pi] === '**') {
if (pi === patternSegments.length - 1) return true;
for (let k = ri; k <= relSegments.length; k++) {
if (rec(pi + 1, k)) return true;
}
return false;
}
if (ri >= relSegments.length) return false;
if (!segmentMatches(patternSegments[pi], relSegments[ri])) return false;
return rec(pi + 1, ri + 1);
}
return rec(0, 0);
}
// Negations like !packages/excluded must also cover nested dirs under that path.
function matchesNegation(pattern) {
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
if (!patternSegments.length) return false;
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
if (relSegments.length < patternSegments.length) return false;
for (let i = 0; i < patternSegments.length; i++) {
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
}
return true;
}
// Positive globs identify workspace packages at exact depth (`*` is a direct
// child). A nested package.json under that package is still owned: the
// ancestor directory of glob length must itself be a package.
function positiveOwns(pattern) {
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
if (!patternSegments.length) return false;
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
if (relSegments.length < patternSegments.length) return false;
for (let i = 0; i < patternSegments.length; i++) {
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
}
if (relSegments.length === patternSegments.length) return true;
const ancestorDir = path.join(root, ...relSegments.slice(0, patternSegments.length));
return fs.existsSync(path.join(ancestorDir, 'package.json'));
}
function groupOwns(rawPatterns) {
const patterns = rawPatterns.map(normalizeWorkspacePattern).filter(Boolean);
if (!patterns.length) return null;
const excluded = patterns.some((pattern) => (
pattern.startsWith('!') && matchesNegation(pattern.slice(1))
));
const included = patterns.filter((pattern) => !pattern.startsWith('!')).some(positiveOwns);
if (!excluded && !included) return null;
if (excluded) return false;
return true;
}
const [impeccable, pkg] = readWorkspacePatternGroups(root);
const fromImpeccable = groupOwns(impeccable);
if (fromImpeccable !== null) return fromImpeccable;
const fromPkg = groupOwns(pkg);
if (fromPkg !== null) return fromPkg;
if ([...impeccable, ...pkg].some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) {
return false;
}
return relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]);
}
// Both forms of the home directory. The walk compares path strings, and a
// symlinked home (e.g. /home -> /var/home) never string-matches the physical
// paths a cwd-resolved target produces, which would let the post-boundary walk
// sail through $HOME and inherit from it.
function homeDirForms() {
const homeDir = path.resolve(os.homedir());
const forms = new Set([homeDir]);
try {
forms.add(fs.realpathSync(homeDir));
} catch { /* keep the logical form only */ }
return forms;
}
// Walk up from `startDir` to the directory that governs the target's design
// system, mirroring skill/scripts/context.mjs's project-boundary semantics:
//
// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the
// design root — that's where the rules live.
// - A directory carrying a project marker (.git / package.json / .impeccable)
// but no DESIGN.md is a project BOUNDARY: the walk stops with no design
// system, so a sibling project never inherits a parent's or cwd's rules.
// but no DESIGN.md is a project BOUNDARY. A nested package.json inherits
// the ancestor DESIGN.md only when that ancestor's workspace declarations
// include the path (negations win; a nested package under a matched
// workspace still inherits). Marker-only roots (turbo/nx/lerna/pnpm
// with no globs) still own apps/<name> and packages/<name>. A stray nested
// package that matches no glob does not inherit. This is detect's
// contamination contract, not skill-context's repoRoot fallback for
// excluded paths. A nested separate repository (.git with no workspace
// declaration) still inherits nothing (issue #570).
// - Reaching the home directory / filesystem root with neither means no
// design system at all — never process.cwd()'s.
//
@@ -590,15 +760,33 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
// runs out. This is the fix for cross-project contamination.
export function findDesignRoot(startDir) {
let dir = path.resolve(startDir);
const homeDir = path.resolve(os.homedir());
const homeDirs = homeDirForms();
let boundary = null;
while (true) {
if (resolveDesignMdPath(dir)) return { dir, hasDesign: true };
if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
return { dir, hasDesign: false };
if (!boundary && resolveDesignMdPath(dir)) return { dir, hasDesign: true };
if (boundary) {
// Past the boundary the walk only looks for the monorepo root that owns
// the workspace path (workspace globs including negations, or marker-only
// apps/packages fallback). Monorepo-root before .git, same order as
// context.mjs: a workspace root carrying its own .git is still recognized,
// while a .git that declares no workspaces is a separate repository and
// stops the walk with nothing inherited. The home directory is never an
// owning root, same as context.mjs's findMonorepoRoot, which stops at
// homeDir before its monorepo check.
if (!homeDirs.has(dir) && isMonorepoRoot(dir)) {
if (monorepoOwnsPath(dir, boundary.dir)) return { dir, hasDesign: !!resolveDesignMdPath(dir) };
return boundary;
}
if (fs.existsSync(path.join(dir, '.git'))) return boundary;
} else if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
boundary = { dir, hasDesign: false };
// A boundary that is itself a monorepo root, or a separate repository
// with its own .git, inherits nothing from above.
if (isMonorepoRoot(dir) || fs.existsSync(path.join(dir, '.git'))) return boundary;
}
if (dir === homeDir) return null;
if (homeDirs.has(dir)) return boundary;
const parent = path.dirname(dir);
if (parent === dir) return null;
if (parent === dir) return boundary;
dir = parent;
}
}
@@ -773,14 +773,22 @@ function extractColorFunctionTokens(value) {
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
const tokenSpans = [];
let from = 0;
// Stops arrive in whatever syntax the author wrote and the browser kept.
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
// to read as a gradient with no stops at all.
for (const token of extractColorFunctionTokens(bgImage)) {
const start = bgImage.indexOf(token, from);
if (start < 0) break;
tokenSpans.push({ start, end: start + token.length });
from = start + token.length;
const c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
// Nested hex inside color-mix is an ingredient, not a stop (issue #578).
if (tokenSpans.some(s => m.index >= s.start && m.index < s.end)) continue;
const h = m[1];
if (h.length === 6) {
colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 });
@@ -1955,20 +1963,19 @@ function scanCssTextForGlow(content) {
return results;
}
// Decorative grid or line-field backgrounds drawn with hairline
// Decorative two-axis grid backgrounds drawn with hairline
// linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML
// pattern pass and the regex source engine so standalone CSS, component
// styles, and inline styles receive the same coverage. Both signals must
// co-occur in one declaration block; unrelated rules must not add up across
// the file. Returns [{ index, snippet }], capped at one finding per source to
// match the page-level HTML check's existing behavior.
// the file. A single hairline is a line, divider, or rail, not a grid, even
// when tiled by a 2D px cell. Returns [{ index, snippet }], capped at one
// finding per source to match the page-level HTML check's existing behavior.
function scanCssTextForGridBackground(content) {
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
let blk;
@@ -1985,13 +1992,10 @@ function scanCssTextForGridBackground(content) {
}
if (hairlineCount === 0) continue;
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
if (hairlineCount >= 2 && hasPxCell) {
return [{
index: blk.index,
snippet: hairlineCount >= 2
? 'two-axis grid-line gradient background'
: 'px-tiled hairline line-field background',
snippet: 'two-axis grid-line gradient background',
}];
}
}
@@ -3986,7 +3990,7 @@ function checkElementAIPaletteDOM(el) {
}
// Check for neon text (vivid cyan/purple color on dark background)
const textColor = parseRgb(style.color);
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
if (textColor && hasChroma(textColor, 80)) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
@@ -7281,7 +7285,7 @@ if (IS_BROWSER) {
if (currentStyle.filter && currentStyle.filter !== 'none') reasons.add('filter');
if (currentStyle.backdropFilter && currentStyle.backdropFilter !== 'none') reasons.add('backdrop filter');
const solidBg = parseRgb(currentStyle.backgroundColor);
const solidBg = parseRgb(currentStyle.backgroundColor) || parseAnyColor(currentStyle.backgroundColor);
if (solidBg && solidBg.a >= 0.95 && (!bgImage || bgImage === 'none')) break;
current = current.parentElement;
}
@@ -7343,7 +7347,7 @@ if (IS_BROWSER) {
// starve the url()-backed texts this mode exists to sample.
if (options.imageOnly && !reasons.includes('image background')) continue;
const textColor = parseRgb(style.color);
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
const fontSize = parseFloat(style.fontSize) || 16;
const fontWeight = parseInt(style.fontWeight) || 400;
const isLargeText = fontSize >= WCAG_LARGE_TEXT_PX || (fontSize >= WCAG_LARGE_BOLD_TEXT_PX && fontWeight >= 700);
@@ -7640,7 +7644,7 @@ if (IS_BROWSER) {
return sample;
}
}
const bg = parseRgb(style.backgroundColor);
const bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
if (bg && bg.a > 0.05) return { status: 'sampled', color: bg, method: 'solid-background' };
return { status: 'unresolved', reason: 'no readable background' };
}
@@ -7770,7 +7774,7 @@ if (IS_BROWSER) {
}
const style = getComputedStyle(el);
const textColor = parseRgb(style.color) || candidate.textColor;
const textColor = parseRgb(style.color) || parseAnyColor(style.color) || candidate.textColor;
if (!textColor) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'unreadable text color' };
const rect = getDirectTextRect(el) || el.getBoundingClientRect();
@@ -42,6 +42,7 @@ function shouldRunPageAnalyzers(content, filePath) {
}
const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
const STYLESHEET_EXTS = new Set(['.css', '.scss', '.sass', '.less']);
const REGEX_PREFIX_KEYWORDS = new Set(['await', 'case', 'default', 'delete', 'do', 'else', 'in', 'instanceof', 'new', 'of', 'return', 'throw', 'typeof', 'void', 'yield']);
const BLOCK_BRACE_PREFIX_KEYWORDS = new Set(['do', 'else', 'finally', 'try']);
@@ -256,6 +257,153 @@ function stripCssComments(content) {
return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' '));
}
function blankHtmlComments(text) {
return text.replace(/<!--[\s\S]*?-->/g, comment => comment.replace(/[^\n]/g, ' '));
}
function blankCssLineCommentsInStyleBlocks(text) {
const re = /<style\b[^>]*>([\s\S]*?)<\/style>/gi;
let output = '';
let lastIndex = 0;
let match;
while ((match = re.exec(text)) !== null) {
const inner = match[1];
const openLength = match[0].length - inner.length - '</style>'.length;
output += text.slice(lastIndex, match.index);
output += match[0].slice(0, openLength);
output += blankCssLineComments(inner);
output += match[0].slice(openLength + inner.length);
lastIndex = re.lastIndex;
}
return output + text.slice(lastIndex);
}
function blankHtmlAndCssCommentsOutsideScripts(text) {
const re = /<script\b[^>]*>[\s\S]*?<\/script>/gi;
let output = '';
let lastIndex = 0;
let match;
while ((match = re.exec(text)) !== null) {
output += blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex, match.index))));
output += match[0];
lastIndex = re.lastIndex;
}
return output + blankCssLineCommentsInStyleBlocks(stripCssComments(blankHtmlComments(text.slice(lastIndex))));
}
function blankCssLineComments(text) {
let output = '';
let state = 'code';
let urlDepth = 0;
for (let i = 0; i < text.length; i++) {
const char = text[i];
const next = text[i + 1];
if (state === 'line') {
if (char === '\n') {
output += '\n';
state = 'code';
} else {
output += ' ';
}
continue;
}
if (state === 'single' || state === 'double') {
output += char;
if (char === '\\' && next) {
output += next;
i++;
} else if ((state === 'single' && char === "'") || (state === 'double' && char === '"')) {
state = 'code';
}
continue;
}
const prev = output.length ? output[output.length - 1] : '';
if (char === '/' && next === '/' && urlDepth === 0 && prev !== ':' && prev !== '(' && prev !== '\\') {
output += ' ';
i++;
state = 'line';
continue;
}
if (char === "'") state = 'single';
else if (char === '"') state = 'double';
if (char === '(') {
const behind = output.replace(/\s+$/, '');
if (urlDepth > 0 || /url$/i.test(behind)) urlDepth++;
} else if (char === ')' && urlDepth) {
urlDepth--;
}
output += char;
}
return output;
}
function findAstroFrontmatterClose(text) {
if (!text.startsWith('---')) return -1;
let cursor = text.indexOf('\n');
if (cursor === -1) return -1;
cursor += 1;
while (cursor < text.length) {
if (text[cursor - 1] === '\n' && text.startsWith('---', cursor)) {
let end = cursor + 3;
while (text[end] === ' ' || text[end] === '\t') end++;
if (end >= text.length || text[end] === '\n' || text[end] === '\r') return cursor - 1;
}
const char = text[cursor];
const next = text[cursor + 1];
if (char === "'" || char === '"') {
const close = findQuotedStringEnd(text, cursor, char);
if (close === -1) return -1;
cursor = close + 1;
continue;
}
if (char === '`') {
const close = findTemplateLiteralEnd(text, cursor);
if (close === -1) return -1;
cursor = close + 1;
continue;
}
if (char === '/' && next === '/') {
const lineEnd = text.indexOf('\n', cursor);
if (lineEnd === -1) return -1;
cursor = lineEnd;
continue;
}
if (char === '/' && next === '*') {
const commentEnd = text.indexOf('*/', cursor + 2);
if (commentEnd === -1) return -1;
cursor = commentEnd + 2;
continue;
}
if (char === '/' && next !== '/' && next !== '*') {
const close = findRegexLiteralEnd(text, cursor);
if (close !== -1) {
cursor = close + 1;
continue;
}
}
cursor++;
}
return -1;
}
function blankAstroFrontmatterComments(text) {
const close = findAstroFrontmatterClose(text);
if (close === -1) return text;
return stripJsComments(text.slice(0, close)) + text.slice(close);
}
function blankCommentsForMatchers(text, ext) {
if (PAGE_ANALYZER_EXTS.has(ext)) {
const withFrontmatter = ext === '.astro' ? blankAstroFrontmatterComments(text) : text;
return blankHtmlAndCssCommentsOutsideScripts(withFrontmatter);
}
if (STYLESHEET_EXTS.has(ext)) {
const withoutBlocks = stripCssComments(text);
return ext === '.css' ? withoutBlocks : blankCssLineComments(withoutBlocks);
}
return text;
}
function firstOverusedGoogleFont(text) {
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
}
@@ -1028,14 +1176,13 @@ function detectText(content, filePath, options = {}) {
const ext = extFromFilePath(filePath);
const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, {
jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx',
}) : content;
}) : blankCommentsForMatchers(content, ext);
const source = stripCssInJsComments(commentStrippedSource, ext);
const lines = source.split('\n');
// Run regex matchers on the full file content (catches Tailwind classes, inline styles)
// Enable block context for CSS files where related properties span multiple lines
const cssLike = new Set(['.css', '.scss', '.sass', '.less']);
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
findings.push(...runRegexMatchers(lines, filePath, 0, STYLESHEET_EXTS.has(ext) || null, {
profile,
phase: 'source',
}));
@@ -1050,7 +1197,7 @@ function detectText(content, filePath, options = {}) {
scanCssTextForPseudoStripe(text).map(hit =>
finding(hit.id, filePath, hit.snippet, lineOffset + text.slice(0, hit.index).split('\n').length));
if (cssLike.has(ext)) {
if (STYLESHEET_EXTS.has(ext)) {
findings.push(...scanInsetStripeCss(content, filePath));
findings.push(...pseudoStripeFindings(content, 0));
}
@@ -1078,7 +1225,8 @@ function detectText(content, filePath, options = {}) {
}, () => extractStyleBlocks(content, ext))
: extractStyleBlocks(content, ext);
for (const block of styleBlocks) {
const blockLines = block.content.split('\n');
const blockContent = blankCssLineComments(stripCssComments(block.content));
const blockLines = blockContent.split('\n');
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
profile,
phase: 'style-block',
@@ -1089,8 +1237,8 @@ function detectText(content, filePath, options = {}) {
// 1-based, so the offset is startLine - 2; startLine - 1 double-counted and
// reported every selector one line low. runRegexMatchers keeps startLine - 1
// because it indexes its split lines from zero.
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 2));
findings.push(...pseudoStripeFindings(block.content, block.startLine - 2));
findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 2));
findings.push(...pseudoStripeFindings(blockContent, block.startLine - 2));
}
// Extract and scan CSS-in-JS template literals
@@ -835,10 +835,11 @@ class StaticElement {
}
}
closest(selector) {
const matcher = this._doc.matcherFor(selector);
let cur = this.node;
while (cur && cur.type === 'tag') {
try {
if (this._doc.is(cur, selector)) return this._doc.wrap(cur);
if (matcher(cur)) return this._doc.wrap(cur);
} catch {
return null;
}
@@ -862,9 +863,10 @@ class StaticDocument {
this.root = root;
this.selectAll = modules.selectAll;
this.selectOne = modules.selectOne;
this.is = modules.is;
this.compile = modules.compile;
this.domutils = modules.domutils;
this._wrappers = new WeakMap();
this._compiledSelectors = new Map();
this._styleMap = new WeakMap();
this._hoverStyleMap = new WeakMap();
this._accentDashPseudo = new WeakSet();
@@ -882,6 +884,20 @@ class StaticDocument {
}
return wrapped;
}
matcherFor(selector) {
let matcher = this._compiledSelectors.get(selector);
if (!matcher) {
try {
matcher = this.compile(selector);
} catch (err) {
// Cache the failure as a rethrower so a bad selector still reaches
// closest()'s catch on every call, first and repeat alike.
matcher = () => { throw err; };
}
this._compiledSelectors.set(selector, matcher);
}
return matcher;
}
querySelectorAll(selector) {
try {
return this.selectAll(selector, this.root.children || []).map(node => this.wrap(node));
@@ -948,8 +964,34 @@ function buildStaticWindow(staticDoc) {
};
}
function resolveLinkedCssPath(fileDir, href) {
const stripped = href.split(/[?#]/)[0];
const rootRelative = stripped.startsWith('/') && !stripped.startsWith('//');
if (!rootRelative) return path.resolve(fileDir, stripped);
// Drop "." and reject ".." so /../outside.css cannot walk out of dir.
const segments = stripped.replace(/^\/+/, '').split(/[/\\]/).filter(p => p && p !== '.');
if (segments.some(p => p === '..')) return path.join(fileDir, segments.filter(p => p !== '..').join(path.sep));
const rel = segments.join(path.sep);
let dir = fileDir;
for (;;) {
const parent = path.dirname(dir);
if (parent === dir) break; // never use the filesystem root as document root
try {
const candidate = path.join(dir, rel);
if (fs.statSync(candidate).isFile()) return candidate;
} catch { /* missing or unreadable candidate */ }
// Stop at the project root so a coincidental ~/static/app.css cannot win.
try {
if (fs.existsSync(path.join(dir, 'package.json')) || fs.existsSync(path.join(dir, '.git'))) break;
} catch { /* unreadable marker */ }
dir = parent;
}
return path.join(fileDir, rel);
}
function collectStaticCssText(root, fileDir, profile, filePath, modules) {
const styleTexts = [];
const warnedMissingStylesheets = new Set();
for (const styleEl of modules.selectAll('style', root.children || [])) {
styleTexts.push(modules.domutils.textContent(styleEl));
}
@@ -958,10 +1000,10 @@ function collectStaticCssText(root, fileDir, profile, filePath, modules) {
const rel = link.attribs?.rel || '';
const href = link.attribs?.href || '';
if (!/\bstylesheet\b/i.test(rel) || !href || /^(https?:)?\/\//i.test(href)) continue;
// Cache-busting hrefs (styles.css?v=3) resolve to the file, not to a
// literal path with the query in it; a versioned link otherwise made the
// whole stylesheet invisible to every element-level check.
const cssPath = path.resolve(fileDir, href.split(/[?#]/)[0]);
// Cache-busting (styles.css?v=3) and root-relative (/static/app.css) hrefs
// must not resolve as OS-absolute paths; otherwise the whole stylesheet is
// invisible to every element-level check.
const cssPath = resolveLinkedCssPath(fileDir, href);
try {
const css = profileStep(profile, {
engine: 'static-html',
@@ -971,7 +1013,14 @@ function collectStaticCssText(root, fileDir, profile, filePath, modules) {
detail: href,
}, () => fs.readFileSync(cssPath, 'utf-8'));
styleTexts.push(css);
} catch { /* skip unreadable */ }
} catch {
if (!warnedMissingStylesheets.has(cssPath)) {
warnedMissingStylesheets.add(cssPath);
process.stderr.write(
`impeccable detect: could not read linked stylesheet ${href} (resolved to ${cssPath}); color and custom-property rules will be incomplete\n`
);
}
}
}
return styleTexts.join('\n');
}
@@ -134,7 +134,7 @@ async function detectHtml(filePath, options = {}) {
parseDocument: htmlparser2.parseDocument,
selectAll: cssSelect.selectAll,
selectOne: cssSelect.selectOne,
is: cssSelect.is,
compile: cssSelect.compile,
csstree,
domutils,
};
@@ -721,20 +721,19 @@ function scanCssTextForGlow(content) {
return results;
}
// Decorative grid or line-field backgrounds drawn with hairline
// Decorative two-axis grid backgrounds drawn with hairline
// linear-gradient layers tiled by a fixed pixel cell. Shared by the HTML
// pattern pass and the regex source engine so standalone CSS, component
// styles, and inline styles receive the same coverage. Both signals must
// co-occur in one declaration block; unrelated rules must not add up across
// the file. Returns [{ index, snippet }], capped at one finding per source to
// match the page-level HTML check's existing behavior.
// the file. A single hairline is a line, divider, or rail, not a grid, even
// when tiled by a 2D px cell. Returns [{ index, snippet }], capped at one
// finding per source to match the page-level HTML check's existing behavior.
function scanCssTextForGridBackground(content) {
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
const invertedHairlineRe = /transparent\s+calc\(100%\s*-\s*\d{1,3}px\)/gi;
const sizeDeclPxRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
const sizeDeclPxPairRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\s+\d{1,3}px/i;
const shorthandPxAnyRe = /\/\s*\d{1,3}px\b/;
const shorthandPxPairRe = /\/\s*\d{1,3}px\s+\d{1,3}px/;
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
let blk;
@@ -751,13 +750,10 @@ function scanCssTextForGridBackground(content) {
}
if (hairlineCount === 0) continue;
const hasPxCell = sizeDeclPxRe.test(block) || shorthandPxAnyRe.test(bgJoined);
const hasPxPairCell = sizeDeclPxPairRe.test(block) || shorthandPxPairRe.test(bgJoined);
if ((hairlineCount >= 2 && hasPxCell) || hasPxPairCell) {
if (hairlineCount >= 2 && hasPxCell) {
return [{
index: blk.index,
snippet: hairlineCount >= 2
? 'two-axis grid-line gradient background'
: 'px-tiled hairline line-field background',
snippet: 'two-axis grid-line gradient background',
}];
}
}
@@ -2752,7 +2748,7 @@ function checkElementAIPaletteDOM(el) {
}
// Check for neon text (vivid cyan/purple color on dark background)
const textColor = parseRgb(style.color);
const textColor = parseRgb(style.color) || parseAnyColor(style.color);
if (textColor && hasChroma(textColor, 80)) {
const hue = getHue(textColor);
const isAIPalette = (hue >= 160 && hue <= 200) || (hue >= 260 && hue <= 310);
@@ -103,14 +103,22 @@ function extractColorFunctionTokens(value) {
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
const tokenSpans = [];
let from = 0;
// Stops arrive in whatever syntax the author wrote and the browser kept.
// A dark ground painted as `linear-gradient(oklch(...), oklch(...))` used
// to read as a gradient with no stops at all.
for (const token of extractColorFunctionTokens(bgImage)) {
const start = bgImage.indexOf(token, from);
if (start < 0) break;
tokenSpans.push({ start, end: start + token.length });
from = start + token.length;
const c = parseAnyColor(token);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
// Nested hex inside color-mix is an ingredient, not a stop (issue #578).
if (tokenSpans.some(s => m.index >= s.start && m.index < s.end)) continue;
const h = m[1];
if (h.length === 6) {
colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 });
+14 -23
View File
@@ -33,13 +33,8 @@ import {
stampProductSchema,
} from './lib/artifact-schema.mjs';
import {
checkBuildPathUnset,
checkConfig,
checkDesignSidecar,
collectBootFindingGroups,
checkNativePlatformEvidence,
checkProduct,
checkProjectRoots,
checkSurfaceBriefs,
designSidecarCandidatesFor,
} from './lib/staleness.mjs';
import {
@@ -106,34 +101,30 @@ async function collect(cwd, targetOptions) {
extractPlatform,
readFile: safeRead,
});
const bootFindings = collectBootFindingGroups(ctx, {
absDesignPath,
sidecarCandidates,
projectRootPatterns: readProjectRootPatterns(ctx.repoRoot),
targetCandidates: workspaceCandidates,
});
const findings = [
...checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
...(ctx.product
? checkNativePlatformEvidence({
projectRoot,
platform: ctx.platform,
product: ctx.product,
productPath: ctx.productPath,
})
: []),
...checkDesignSidecar({ designPath: absDesignPath, sidecarCandidates, projectRoot }),
...bootFindings.product,
...bootFindings.nativePlatform,
...bootFindings.designSidecar,
...checkDesignDrift({ designPath: absDesignPath, projectRoot }),
...checkDesignCoverage({ design: ctx.design, designPath: ctx.designPath, parseDesignMd }),
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
...bootFindings.config,
...bootFindings.buildPath,
...checkDetectorIgnores({ projectRoot, knownRuleIds }),
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
...bootFindings.surfaceBriefs,
...checkHookInstallation({
projectRoot,
repoRoot: ctx.repoRoot,
providerId: IMPECCABLE_PROVIDER_ID,
}),
...checkLegacyLiveState({ projectRoot }),
...checkProjectRoots({
patterns: readProjectRootPatterns(ctx.repoRoot),
candidates: workspaceCandidates,
}),
...bootFindings.projectRoots,
...workspaceResult.findings,
];
@@ -5,6 +5,7 @@
// node embed-prompt.mjs <image> --prompt "the prompt text"
// node embed-prompt.mjs <image> --prompt-file prompt.txt
// node embed-prompt.mjs <image> --read
// node embed-prompt.mjs --scan <dir...> # list rasters missing a prompt; exit 3 when any
//
// Formats: PNG (tEXt chunk, keyword "impeccable:prompt"), JPEG (COM segment).
// WebP and anything else fall back to a `<image>.json` sidecar; --read checks
@@ -21,8 +22,49 @@ const KEYWORD = 'impeccable:prompt';
const args = process.argv.slice(2);
const file = args.find(a => !a.startsWith('--'));
const readMode = args.includes('--read');
const scanMode = args.includes('--scan');
const argOf = (name) => { const i = args.indexOf(name); return i !== -1 ? args[i + 1] : null; };
function promptOf(imagePath) {
const b = fs.readFileSync(imagePath);
let prompt = null;
if (b.length > 8 && b.readUInt32BE(0) === 0x89504e47) prompt = readPngText(b);
else if (b.length > 3 && b[0] === 0xff && b[1] === 0xd8) prompt = readJpegCom(b);
if (prompt == null && fs.existsSync(`${imagePath}.json`)) {
try { prompt = JSON.parse(fs.readFileSync(`${imagePath}.json`, 'utf8')).prompt ?? null; } catch { /* stays null */ }
}
return prompt;
}
if (scanMode) {
const targets = args.filter(a => !a.startsWith('--'));
if (targets.length === 0) { console.error('embed-prompt: --scan needs at least one directory'); process.exit(1); }
const RASTER = /\.(png|jpe?g|webp)$/i;
const rasters = [];
const walk = (p, isRoot) => {
const stat = fs.statSync(p);
if (stat.isDirectory()) {
const base = p.replace(/\/+$/, '').split('/').pop();
// Skip installed deps and hidden dirs found during the walk, but honor a
// hidden dir the caller passed explicitly (e.g. .impeccable/mocks).
if (!isRoot && (base === 'node_modules' || base.startsWith('.'))) return;
for (const entry of fs.readdirSync(p)) walk(`${p.replace(/\/+$/, '')}/${entry}`, false);
} else if (RASTER.test(p)) {
rasters.push(p);
}
};
for (const target of targets) {
if (!fs.existsSync(target)) { console.error(`embed-prompt: no such path ${target}`); process.exit(1); }
walk(target, true);
}
let missing = 0;
for (const raster of rasters) {
if (promptOf(raster) == null) { console.log(`MISSING: ${raster}`); missing++; }
}
console.log(`SCAN: ${rasters.length} raster${rasters.length === 1 ? '' : 's'}, ${missing} missing`);
process.exit(missing > 0 ? 3 : 0);
}
if (!file || !fs.existsSync(file)) { console.error('embed-prompt: image file required'); process.exit(1); }
const buf = fs.readFileSync(file);
@@ -75,11 +75,11 @@ const HOOK_MANIFEST_TARGETS = [
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: immediate-tier checks after Edit/Write/MultiEdit on UI files, full-rule deep pass on Stop.',
description: 'Impeccable design detector: immediate-tier checks after Edit/Write on UI files, full-rule deep pass on Stop.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
matcher: 'Edit|Write',
hooks: [
{
type: 'command',
+130 -26
View File
@@ -816,9 +816,9 @@ export function splitFindingsByTier(findings) {
}
// Whether the per-edit pass for this harness should defer non-immediate
// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop
// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for
// them would silently drop the non-immediate rules entirely.
// findings to a Stop deep pass. Claude Code, Codex, and Grok Build dispatch
// our Stop hook; Cursor and GitHub Copilot have no deep pass wired, so
// deferring for them would silently drop the non-immediate rules entirely.
export function perEditTieringActive(config, harness) {
if (harness === 'cursor' || harness === 'github') return false;
return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all';
@@ -1251,18 +1251,50 @@ export function resolveHarness(env = {}, event = null) {
const explicit = env?.IMPECCABLE_HOOK_HARNESS;
if (explicit === 'cursor') return 'cursor';
if (explicit === 'github') return 'github';
if (explicit === 'claude' || explicit === 'codex') return 'claude';
// GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
// has no `tool_name`/`tool_input`. That shape is the discriminator.
if (explicit === 'grok') return 'grok';
if (explicit === 'claude') return 'claude';
if (explicit === 'codex') return 'codex';
// Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no
// snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`.
// Check Grok first: the old GitHub heuristic (`toolName` and no
// `tool_input`) also matches Grok, which is how live PostToolUse was
// classified as Copilot and then skipped with no-file-path (#646).
if (looksLikeGrokEnvelope(event)) return 'grok';
if (event && typeof event === 'object'
&& (typeof event.toolName === 'string' || event.toolArgs !== undefined)
&& event.tool_name === undefined && event.tool_input === undefined) {
return 'github';
}
if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
// Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting
// it here means an already-installed Codex hook emits the Codex Stop
// contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS.
// https://developers.openai.com/codex/hooks#stop
if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex';
return 'claude';
}
function looksLikeGrokEnvelope(event) {
if (!event || typeof event !== 'object') return false;
if (event.hook_event_name !== undefined
|| event.tool_name !== undefined
|| event.tool_input !== undefined) {
return false;
}
if (event.toolArgs !== undefined) return false;
if (typeof event.hookEventName === 'string') return true;
return typeof event.toolName === 'string' && event.toolInput !== undefined;
}
// Stop arrives as Claude's `hook_event_name: "Stop"` or Grok Build's
// `hookEventName: "stop"`. hook.mjs routes on the raw stdin, before any
// normalize, so both casings must match here.
export function isStopEvent(event) {
if (!event || typeof event !== 'object') return false;
const name = event.hook_event_name || event.hookEventName;
return typeof name === 'string' && name.toLowerCase() === 'stop';
}
// GitHub Copilot's postToolUse payload is
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
@@ -1354,9 +1386,36 @@ function normalizeGitHubEvent(event, projectCwd) {
};
}
// Grok Build 1.0.5 (captured 2026-08-24) sends camelCase `toolName` /
// `toolInput` / `sessionId` / `stopHookActive`, plus `cwd` alongside a
// trailing-slashed `workspaceRoot` (every consumer path.resolve()s, so no
// stripping here). Only the fields the hook reads are copied; the event
// name stays camelCase because routing already happened on the raw stdin
// (isStopEvent) and nothing downstream reads `hook_event_name`.
function normalizeGrokEvent(event, projectCwd) {
const cwd = event.cwd || event.workspaceRoot || envProjectDir(projectCwd) || projectCwd;
const sessionId = event.sessionId || event.session_id || 'unknown';
const rawInput = event.toolInput ?? event.tool_input;
const toolInput = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
? { ...rawInput }
: {};
const out = {
...event,
cwd,
session_id: sessionId,
tool_name: event.toolName || event.tool_name || null,
tool_input: toolInput,
};
if (event.stopHookActive !== undefined && event.stop_hook_active === undefined) {
out.stop_hook_active = event.stopHookActive;
}
return out;
}
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
if (!event || typeof event !== 'object') return event;
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
if (harness === 'grok') return normalizeGrokEvent(event, projectCwd);
if (harness !== 'cursor') return event;
const cwd = event.cwd
@@ -1959,7 +2018,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
// findings stop being remembered and a reintroduced one reads as fresh.
// Only the immediate tier is remembered: a deferred finding the per-edit
// pass never reported must still read as fresh to the Stop deep pass.
rememberFindings(cache, sessionId, filePath, immediate);
//
// Grok ignores PostToolUse stdout, so Stop is the user-visible pass.
// Remembering here would dedupe those findings out of Stop. Touch the
// file so Stop has it, and leave the finding list empty.
if (harness === 'grok') {
touchFile(cache, sessionId, filePath);
} else {
rememberFindings(cache, sessionId, filePath, immediate);
}
cacheDirty = true;
if (fresh.length > 0) {
@@ -2163,8 +2230,11 @@ export const STOP_MAX_FILES = 20;
* { exitCode, stdout, audit, emission? }
*
* Never throws; exits silent (and fast) when the session touched no UI
* files. Output uses the Stop hookSpecificOutput channel: additionalContext
* is delivered to the model and the conversation continues so it can act.
* files. Output goes out on the harness's Stop continuation channel: Claude
* Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes
* a decision: "block" whose reason becomes the continuation prompt. Either
* way the findings reach the model and the conversation continues so it
* can act.
*/
export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) {
const audit = { ts: new Date(now()).toISOString(), event: 'Stop' };
@@ -2191,22 +2261,36 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });
}
// Claude Code's Stop-hook contract: `stop_hook_active` is true when this
// hook is being re-invoked only because a prior invocation kept the turn
// alive (here, via hookSpecificOutput.additionalContext). Re-scanning and
// re-blocking now would loop until Claude Code's consecutive-block cap
// force-ends the turn (issue #400). The prior fire already surfaced the
// findings; whether to act on them is the agent's call. Exit fast with no
// output before any scan. Only Claude Code sends this field; other
// harnesses omit it, so the strict `=== true` is a no-op for them. This
// guard makes the loop impossible regardless of the finding cache key's
// line-number sensitivity (out of scope here; see findingCacheKey).
const harness = resolveHarness(env, event);
audit.harness = harness;
event = normalizeHookEvent(event, cwd, harness);
// Stop-hook re-entry guard: `stop_hook_active` is true when this hook is
// being re-invoked only because a prior invocation kept the turn alive
// (Claude Code via hookSpecificOutput.additionalContext, Codex via a
// decision: "block" continuation). Re-scanning and re-blocking now could
// loop (issue #400). The prior fire already surfaced the findings;
// whether to act on them is the agent's call. Exit fast with no output
// before any scan. Claude Code and Codex both send this field: Codex
// mirrors the Claude contract (StopCommandInput in
// codex-rs/hooks/src/schema.rs) and latches it true for the rest of the
// turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok
// sends `stopHookActive`, copied onto the snake_case field above. Cursor
// and GitHub Copilot omit the field, so the strict `=== true` is a no-op
// for them. The guard makes the loop impossible regardless of the finding
// cache key's line-number sensitivity (out of scope here; see
// findingCacheKey).
if (event.stop_hook_active === true) {
return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started });
}
const harness = resolveHarness(env, event);
audit.harness = harness;
// Grok fires Stop twice: `end_turn` (the gate that can inject
// additionalContext) then an observe-only `shutdown`. A second deep
// pass would re-emit the same findings. Claude omits `reason`; only
// skip when Grok named a reason that is not end_turn.
if (harness === 'grok' && typeof event.reason === 'string' && event.reason !== 'end_turn') {
return result({ skipped: 'stop-reason', reason: event.reason, durationMs: Date.now() - started });
}
// A Stop event carries no file, so the session cwd is the project.
// Umbrella-dir launches keyed their per-edit cache to the edited file's
@@ -2241,6 +2325,7 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
const freshGroups = [];
let scanned = 0;
let cacheDirty = false;
for (const filePath of touched) {
if (scanned >= STOP_MAX_FILES) break;
if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue;
@@ -2261,29 +2346,39 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
let findings;
let detectorThrew = false;
const useHtmlEngine = configuredExt
? configuredExt.engine === 'html'
: (ext === '.html' || ext === '.htm');
if (useHtmlEngine && typeof det.detectHtml === 'function') {
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; }
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
} else {
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; }
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
}
// A detector failure tells us nothing about the file. Leave whatever
// was remembered alone rather than recording an empty scan as truth.
if (detectorThrew) continue;
// Full rule set: no tier split here. Config/inline ignores still apply,
// and the session dedupe drops everything the per-edit pass (or an
// earlier Stop pass) already surfaced.
const filtered = filterFindings(findings || [], content, ext, config);
const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath);
// Sync to the live scan, including empty. Remembering only `fresh`
// (or skipping the write on a clean Stop) left stale keys in place, so
// a finding that was fixed and later reintroduced never fired again.
rememberFindings(cache, sessionId, filePath, filtered);
cacheDirty = true;
if (fresh.length > 0) {
rememberFindings(cache, sessionId, filePath, fresh);
freshGroups.push({ filePath, findings: fresh });
}
}
audit.scannedFiles = scanned;
if (freshGroups.length === 0) {
if (cacheDirty) persistCache(projectCwd, cache);
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
}
@@ -2300,8 +2395,8 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
);
commitFooterShown(cache, sessionId, text);
// Fresh findings earn the cache write so the next Stop fire is silent
// unless new issues appear; the notice flags ride along.
// Persist the live finding set so the next Stop fire is silent unless
// new issues appear; the notice flags ride along.
persistCache(projectCwd, cache);
return {
exitCode: 0,
@@ -2337,6 +2432,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
if (harness === 'github') {
return JSON.stringify({ additionalContext: text });
}
// Codex shares Claude Code's PostToolUse additional-context shape, but its
// Stop schema rejects unknown fields. Findings that should continue the
// turn must be a top-level blocking decision.
// https://developers.openai.com/codex/hooks#stop (schema of record:
// codex-rs/hooks/src/schema.rs, StopCommandOutputWire)
if (harness === 'codex' && eventName === 'Stop') {
if (!String(text ?? '').trim()) return '';
return JSON.stringify({ decision: 'block', reason: text });
}
return JSON.stringify({
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
});
+10 -9
View File
@@ -2,15 +2,17 @@
/**
* Impeccable design hook PostToolUse + Stop entry point.
*
* Reads the Claude Code / Codex / Cursor hook event from stdin and routes by
* `hook_event_name`:
* Reads the Claude Code / Codex / Cursor / Grok Build hook event from stdin
* and routes by Stop vs everything else. Claude uses `hook_event_name:
* "Stop"`; Grok uses `hookEventName: "stop"`.
*
* - PostToolUse: runs the immediate-tier detector rules against the touched
* file and emits a system reminder via
* `hookSpecificOutput.additionalContext` when findings exist.
* `hookSpecificOutput.additionalContext` when findings exist. Grok
* discards that stdout; the scan still warms the session cache for Stop.
* - Stop: runs the FULL detector rule set over every UI file touched this
* session (the deep pass), deduped against what the per-edit pass already
* surfaced, and emits once via the Stop additionalContext channel.
* surfaced, and emits once via the harness-specific continuation channel.
*
* Contract: never break a turn. Always exit 0. Clean files emit a small ack
* unless quiet mode is enabled; a clean Stop pass is silent.
@@ -19,7 +21,7 @@
* subprocess. This file is the thin stdin/stdout adapter.
*/
import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs';
import { runHook, runStopHook, writeAuditLog, isStopEvent } from './hook-lib.mjs';
async function readStdin() {
if (process.stdin.isTTY) return '';
@@ -28,10 +30,9 @@ async function readStdin() {
return Buffer.concat(chunks).toString('utf-8');
}
function isStopEvent(stdinJson) {
function stdinIsStop(stdinJson) {
try {
const event = JSON.parse(stdinJson);
return event && typeof event === 'object' && event.hook_event_name === 'Stop';
return isStopEvent(JSON.parse(stdinJson));
} catch {
// Malformed stdin falls through to runHook, which audits the skip.
return false;
@@ -48,7 +49,7 @@ async function main() {
let stdinJson = '';
try { stdinJson = await readStdin(); } catch { /* fall through */ }
const run = isStopEvent(stdinJson) ? runStopHook : runHook;
const run = stdinIsStop(stdinJson) ? runStopHook : runHook;
const result = await run({
stdinJson,
env: inheritedEnv,
@@ -196,9 +196,6 @@ function parseScalar(raw) {
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
@@ -550,36 +547,6 @@ function detectFormat(v) {
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
@@ -488,41 +488,46 @@ export function describeWorkspaceContext(candidates = []) {
// ─── Tier 1 orchestration ──────────────────────────────────────────────────
/**
* Everything a boot can afford. `ctx` is the loadContext result; `extras`
* carries values the caller already computed so nothing is recomputed here.
* Everything a boot can afford, grouped by artifact so deeper reports can
* interleave their own checks without rebuilding this policy. `ctx` is the
* loadContext result; `extras` carries values the caller already computed so
* nothing is recomputed here.
*/
export function collectBootFindings(ctx, extras = {}) {
if (!ctx) return [];
export function collectBootFindingGroups(ctx, extras = {}) {
if (!ctx) return {};
const projectRoot = ctx.projectRoot || process.cwd();
const absProductPath = extras.absProductPath || null;
const absDesignPath = extras.absDesignPath || null;
return [
...checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
return {
product: checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
// Only checked once a PRODUCT.md exists. Without one the boot already
// emits NO_PRODUCT_MD and routes into init, which asks for the platform
// directly; a second signal saying the same thing is noise.
...(ctx.product
nativePlatform: ctx.product
? checkNativePlatformEvidence({
projectRoot,
platform: ctx.platform,
product: ctx.product,
productPath: ctx.productPath,
})
: []),
...checkDesignSidecar({
: [],
designSidecar: checkDesignSidecar({
designPath: absDesignPath,
sidecarCandidates: extras.sidecarCandidates || [],
projectRoot,
}),
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
...checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
...(extras.projectRootPatterns
config: checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
buildPath: checkBuildPathUnset({ projectRoot, repoRoot: ctx.repoRoot, product: ctx.product }),
surfaceBriefs: checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
projectRoots: extras.projectRootPatterns
? checkProjectRoots({
patterns: extras.projectRootPatterns,
candidates: extras.targetCandidates || [],
})
: []),
];
: [],
};
}
export function collectBootFindings(ctx, extras = {}) {
return Object.values(collectBootFindingGroups(ctx, extras)).flat();
}
@@ -8,6 +8,12 @@ export function getSurfaceBriefDir(projectRoot) {
return path.join(projectRoot, '.impeccable', 'surfaces');
}
function normalizeRouteTarget(route) {
if (!route.startsWith('/') || route.includes('..')) return null;
const normalized = route.split(/[?#]/, 1)[0].replace(/\/{2,}/g, '/').replace(/\/$/, '') || '/';
return `route:${normalized}`;
}
export function normalizeSurfaceTarget(target, { projectRoot = process.cwd() } = {}) {
if (!target || typeof target !== 'string' || !target.trim()) return null;
const trimmed = target.trim();
@@ -21,21 +27,13 @@ export function normalizeSurfaceTarget(target, { projectRoot = process.cwd() } =
return null;
}
}
if (/^route:/i.test(trimmed)) {
const route = trimmed.slice(trimmed.indexOf(':') + 1).trim();
if (!route.startsWith('/') || route.includes('..')) return null;
const normalizedRoute = route.split(/[?#]/, 1)[0].replace(/\/{2,}/g, '/').replace(/\/$/, '') || '/';
return `route:${normalizedRoute}`;
}
if (trimmed === '/') return 'route:/';
if (/^route:/i.test(trimmed)) return normalizeRouteTarget(trimmed.slice(trimmed.indexOf(':') + 1).trim());
if (trimmed === '/') return normalizeRouteTarget(trimmed);
if (trimmed.startsWith('/')) {
const absolute = path.resolve(trimmed);
const relativeToProject = path.relative(projectRoot, absolute);
const isProjectFile = relativeToProject && !relativeToProject.startsWith('..') && !path.isAbsolute(relativeToProject);
if (!isProjectFile && !fs.existsSync(absolute) && !trimmed.includes('..')) {
const normalizedRoute = trimmed.split(/[?#]/, 1)[0].replace(/\/{2,}/g, '/').replace(/\/$/, '') || '/';
return `route:${normalizedRoute}`;
}
if (!isProjectFile && !fs.existsSync(absolute)) return normalizeRouteTarget(trimmed);
}
const abs = path.isAbsolute(trimmed) ? trimmed : path.resolve(projectRoot, trimmed);
const rel = path.relative(projectRoot, abs);
@@ -4902,6 +4902,13 @@
saveSession();
}
function completeParameterGenerationIfReady() {
if (expectedVariants <= 0 || arrivedVariants < expectedVariants) return;
if (parameterGenerationState === 'pending' || parameterGenerationState === 'loading') {
completeParameterPublication();
}
}
function toggleTunePopover() {
if (pendingApplyInFlight) { showManualApplyBusyToast(); return; }
if (tuneOpen) { closeTunePopover(); return; }
@@ -5796,7 +5803,7 @@
setLiveState('CYCLING');
showOrUpdateCyclingBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
return;
}
@@ -5884,7 +5891,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Mounted ' + arrivedVariants + ' ' + manifest.framework + ' component variants.');
} catch (err) {
console.error('[impeccable] Failed to mount component-preview variants:', err);
@@ -6329,7 +6336,7 @@
refreshParamsPanel();
positionBar();
saveSession();
if (parameterGenerationState === 'loading') completeParameterPublication();
completeParameterGenerationIfReady();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
})
.catch(err => {
@@ -6836,6 +6843,7 @@
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
if (expected > 0) expectedVariants = expected;
completeParameterGenerationIfReady();
if (arrivedVariants > 0) {
setLiveState('CYCLING');
@@ -944,8 +944,42 @@ export async function commitManualEdits({
};
}
const repairContext = {
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
};
const baseRollbackScope = collectApplyOwnedFiles(batch, cwd);
const rollbackSnapshot = snapshotRollbackFiles(cwd, baseRollbackScope);
const failWithRollback = ({
scope = baseRollbackScope,
extraFiles = [],
failed,
files = [],
details = {},
}) => {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, extraFiles, scope);
return {
applied: [],
failed,
files,
cleared: 0,
count,
pageUrl,
...details,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
};
let result;
try {
result = repairOnly
@@ -965,42 +999,27 @@ export async function commitManualEdits({
chatAvailable,
});
} catch (err) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, [], baseRollbackScope);
return {
applied: [],
return failWithRollback({
failed: batch.entries.map((entry) => ({
id: entry.id,
reason: err.message || String(err),
candidates: candidatesForEntry(batch, entry.id),
})),
files: [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
});
}
if (result.status === 'error') {
const rollbackScope = collectApplyOwnedFiles(batch, cwd, result.files || []);
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const failed = normalizeFailedEntries(batch, result, result.message || 'AI copy edit failed');
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: failed.length > 0
? failed
: verificationFailuresForEntries(batch, batch.entries, result.message || 'AI copy edit failed'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedIds = uniqueStrings(result.appliedEntryIds || []);
@@ -1013,72 +1032,44 @@ export async function commitManualEdits({
const conflictingAppliedIds = reportedAppliedIds.filter((id) => failedIds.has(id));
if (conflictingAppliedIds.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
const conflictingEntries = batch.entries.filter((entry) => conflictingAppliedIds.includes(entry.id));
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...verificationFailuresForEntries(batch, conflictingEntries, 'conflicting_apply_result'),
...aiFailed.filter((item) => !conflictingAppliedIds.includes(item.id)),
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const unreportedFiles = unreportedChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
if (unreportedFiles.length > 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], [...rollbackScope, ...unreportedFiles]);
return {
applied: [],
return failWithRollback({
scope: [...rollbackScope, ...unreportedFiles],
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'unreported_source_changes', { files: unreportedFiles }),
files: result.files || [],
unreportedFiles,
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { unreportedFiles, notes: result.notes || [] },
});
}
if (result.status === 'done' && reportedAppliedIds.length === 0) {
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: verificationFailuresForEntries(batch, batch.entries, 'missing_applied_entry_ids'),
files: result.files || [],
cleared: 0,
count,
pageUrl,
notes: result.notes || [],
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
const reportedAppliedEntries = batch.entries.filter((entry) => reportedAppliedIds.includes(entry.id));
if (reportedAppliedIds.length > 0 && reportedFiles.length === 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: aiFailed,
@@ -1089,21 +1080,10 @@ export async function commitManualEdits({
});
}
const verifiedAppliedIds = [];
const verificationFailed = [];
for (const entry of reportedAppliedEntries) {
const failures = verifyAppliedEntry({ batch, entry, reportedFiles, cwd });
if (failures.length === 0) {
verifiedAppliedIds.push(entry.id);
} else {
verificationFailed.push({
id: entry.id,
reason: 'source_verification_failed',
failures,
candidates: candidatesForEntry(batch, entry.id),
});
}
}
const {
verifiedIds: verifiedAppliedIds,
failed: verificationFailed,
} = verifyEntriesAfterRepair({ batch, appliedEntryIds: reportedAppliedIds, files: reportedFiles, cwd });
const unreportedEntries = result.status === 'done' || result.status === 'partial'
? batch.entries.filter((entry) => !reportedAppliedIds.includes(entry.id) && !aiFailed.some((item) => item.id === entry.id))
: [];
@@ -1133,37 +1113,22 @@ export async function commitManualEdits({
reason: 'rolled_back_due_to_failed_entry_source_changed',
candidates: candidatesForEntry(batch, entry.id),
}));
const rollback = rollbackChangedFiles(cwd, rollbackSnapshot, result.files || [], rollbackScope);
return {
applied: [],
return failWithRollback({
scope: rollbackScope,
extraFiles: result.files || [],
failed: [
...leakedUnapplied,
...failed.filter((item) => !leakedIds.has(item.id)),
...rolledBackVerified,
],
files: result.files || [],
cleared: 0,
count,
pageUrl,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
notes: result.notes || [],
...countByPage(cwd),
};
details: { notes: result.notes || [] },
});
}
if (verificationFailed.length > 0) {
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: reportedAppliedIds,
files: result.files || [],
failed: nonRepairFailed,
@@ -1180,16 +1145,7 @@ export async function commitManualEdits({
? reportedAppliedEntries.filter((entry) => verifiedAppliedIds.includes(entry.id))
: batch.entries;
return repairPostApplyValidation({
batch,
cwd,
pageUrl,
count,
provider,
env,
timeoutMs,
applyBatchToSource,
chatAvailable,
transactionId,
...repairContext,
appliedEntryIds: verifiedAppliedIds.length > 0
? verifiedAppliedIds
: postCheckEntries.map((entry) => entry.id).filter(Boolean),
@@ -238,10 +238,9 @@ export async function completeAcceptHandling(event, base, token) {
});
} catch (err) {
event._completionAck = { ok: false, error: err.message };
return event;
}
if (!event._completionAck) {
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
}
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
return event;
}
@@ -269,9 +268,11 @@ export function printPollEvent(event) {
// Situational plumbing rides with the event itself: `_instructions` is the
// authoritative next step, with real ids and paths substituted, so the
// reference doc can stay lean and can never drift from script behavior.
if (event && typeof event === 'object' && !event._instructions) {
// A wire-supplied value must never win over the locally generated one.
if (event && typeof event === 'object') {
const instructions = instructionsForEvent(event, { scriptsPath: SELF_DIR });
if (instructions) event._instructions = instructions;
else delete event._instructions;
}
console.log(JSON.stringify(event));
}
@@ -181,8 +181,16 @@ function chatAgentLikelyActive() {
// cap at 10 MB to guard against runaway writes from a misbehaving client.
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
const POLLER_OWNED_EVENT_FIELDS = ['_instructions', '_completionAck', '_acceptResult'];
function stripPollerOwnedEventFields(event) {
if (!event || typeof event !== 'object') return;
for (const key of POLLER_OWNED_EVENT_FIELDS) delete event[key];
}
function enqueueEvent(event) {
if (!event) return;
stripPollerOwnedEventFields(event);
// Dedupe by (session, type), except mount failures, which are per-variant:
// variant 2 failing must not be swallowed because variant 1's failure is
// still queued.
@@ -936,15 +944,23 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
const filePath = url.searchParams.get('path');
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
const absPath = path.resolve(process.cwd(), filePath);
// Confine to the project root. A bare `startsWith(cwd)` string check lets a
// sibling dir whose name extends the root name (projeto -> projeto-backup)
// slip through; compare on the relative path instead (same pattern as
// sessionFileMetadataFromPollReply below). An empty rel means the request
// resolved to the root directory itself, which this file route never serves.
const rel = path.relative(process.cwd(), absPath);
let realRoot, realTarget;
try {
realRoot = fs.realpathSync(process.cwd());
realTarget = fs.realpathSync(absPath);
} catch {
res.writeHead(404); res.end('File not found'); return;
}
// Confine to the project root after symlink resolution. A bare
// `startsWith(cwd)` string check lets a sibling dir whose name extends the
// root name (projeto -> projeto-backup) slip through; compare on the
// relative path instead (same pattern as sessionFileMetadataFromPollReply
// below). An empty rel means the request resolved to the root directory
// itself, which this file route never serves.
const rel = path.relative(realRoot, realTarget);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { res.writeHead(403); res.end('Forbidden'); return; }
let content;
try { content = fs.readFileSync(absPath, 'utf-8'); }
try { content = fs.readFileSync(realTarget, 'utf-8'); }
catch { res.writeHead(404); res.end('File not found'); return; }
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(content);
@@ -1026,6 +1042,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
res.end(JSON.stringify({ error }));
return;
}
stripPollerOwnedEventFields(msg);
if (msg.type === 'agent_phase') {
recordAgentPhase(msg.id, msg.phase, {
...(Number.isFinite(msg.durationMs) ? { durationMs: msg.durationMs } : {}),
@@ -11,6 +11,8 @@ import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
@@ -45,11 +47,17 @@ export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
if (!hasTemplateMarkers) return null;
const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js'))
|| fs.existsSync(path.join(cwd, 'svelte.config.mjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.cjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.ts'));
const hasKitPackage = packageHasSvelteKit(cwd);
const hasSvelteConfig = Boolean(firstExistingFile(cwd, [
'svelte.config.js',
'svelte.config.mjs',
'svelte.config.cjs',
'svelte.config.ts',
]));
const hasKitPackage = hasAnyDependency(cwd, [
'@sveltejs/kit',
'@sveltejs/vite-plugin-svelte',
'svelte',
]);
if (!hasSvelteConfig && !hasKitPackage) return null;
return {
@@ -260,36 +268,16 @@ function findSvelteKitAppHtml(cwd, config) {
}
function findSvelteKitLayout(cwd) {
const candidates = [
return firstExistingFile(cwd, [
'src/routes/+layout.svelte',
'src/routes/(app)/+layout.svelte',
];
for (const rel of candidates) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return 'src/routes/+layout.svelte';
]) || 'src/routes/+layout.svelte';
}
function defaultSvelteLayout() {
return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`;
}
function packageHasSvelteKit(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
} catch {
return false;
}
}
function fileIncludes(file, text) {
try {
return fs.readFileSync(file, 'utf-8').includes(text);
@@ -19,6 +19,8 @@
import fs from 'node:fs';
import path from 'node:path';
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
import { buildLiveScriptSrc } from './frameworks/script-src.mjs';
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
@@ -42,8 +44,8 @@ const START_PACKAGES = [
];
export function detectTanStackStartProject(cwd = process.cwd()) {
if (!packageHasTanStackStart(cwd)) return null;
const rootRoute = findRootRouteFile(cwd);
if (!hasAnyDependency(cwd, START_PACKAGES)) return null;
const rootRoute = firstExistingFile(cwd, ROOT_ROUTE_CANDIDATES);
if (!rootRoute) return null;
const ext = path.extname(rootRoute);
@@ -218,29 +220,6 @@ function isManagedComponent(content) {
return String(content || '').includes('impeccable-live-tanstack');
}
function findRootRouteFile(cwd) {
for (const rel of ROOT_ROUTE_CANDIDATES) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return null;
}
function packageHasTanStackStart(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return START_PACKAGES.some((name) => Boolean(deps[name]));
} catch {
return false;
}
}
function relativeImportSpecifier(fromFile, toFile) {
const rel = path.posix.relative(
path.posix.dirname(fromFile.split(path.sep).join('/')),
@@ -95,9 +95,16 @@
* --stop --key K kill a daemonized question.
* --update --key K --payload F deliver the next hand after a re-roll: the
* live page swaps to loading cards when the user re-rolls, and
* reloads into this new payload the moment it lands.
* reloads into this new payload the moment it lands. Always the
* same key the round started with; a second --start serves a new
* URL and strands the open tab on a hand that never arrives.
*
* node serve-question.mjs --payload question.json [--timeout 900] [--no-open] [--port 0]
* --timeout bounds the wait for a page to arrive, never the user's decision:
* once the page heartbeats, the server lives while the page does, and exits
* only after --idle-grace seconds (default 600) pass with no beat, wide
* enough to survive a closed laptop lid mid-decision.
*
* node serve-question.mjs --payload question.json [--timeout 900] [--idle-grace 600] [--no-open] [--port 0]
*/
import http from 'node:http';
import fs from 'node:fs';
@@ -120,11 +127,13 @@ if (process.env.IMPECCABLE_QUESTION_DISABLED) {
}
// Headless self-detection, applied only where a browser is actually wanted.
// --no-open means the caller opens the URL itself, and --wait / --stop /
// --schema never open anything: --wait polls a daemon whose browser question
// was already settled at --start, --stop kills one, --schema prints text. A
// spurious exit 2 from those breaks the documented loop, which polls --wait
// while it exits 3 and reads --schema before building a payload.
const wantsBrowser = !hasFlag('no-open') && !hasFlag('wait') && !hasFlag('stop') && !hasFlag('schema');
// --schema / --update never open anything: --wait polls a daemon whose
// browser question was already settled at --start, --stop kills one,
// --schema prints text, and --update hands the next round to a page that is
// already open. A spurious exit 2 from those breaks the documented loop,
// which polls --wait while it exits 3, reads --schema before building a
// payload, and delivers re-rolled hands with --update.
const wantsBrowser = !hasFlag('no-open') && !hasFlag('wait') && !hasFlag('stop') && !hasFlag('schema') && !hasFlag('update');
if (wantsBrowser && !process.env.IMPECCABLE_QUESTION_FORCE) {
const headless =
process.env.CI ||
@@ -176,7 +185,20 @@ function printAnswer(raw) {
}
const payloadPath = arg('payload');
const timeoutSec = Number(arg('timeout', '900'));
// --timeout bounds only the wait for a page to open; 0 is the explicit
// wait-forever. A negative or unparseable value takes the default, so a
// typo cannot disarm the no-page exit and leak the daemon.
const timeoutArg = Number(arg('timeout', '900'));
const timeoutSec = Number.isFinite(timeoutArg) && timeoutArg >= 0 ? timeoutArg : 900;
// How long the server (and the page's own delivery deadline) outlive the
// last heartbeat; a zero, negative, or unparseable value takes the default.
const idleGraceArg = Number(arg('idle-grace', '600'));
const idleGraceMs = (Number.isFinite(idleGraceArg) && idleGraceArg > 0 ? idleGraceArg : 600) * 1000;
// How long a delivered next hand may sit unclaimed before it means no page
// is coming back: --wait reads it to keep a stalled page from counting as
// closed mid-delivery, and the daemon reads it to survive until the page's
// watch claims a hand delivered moments before the idle deadline.
const NEXT_CLAIM_GRACE_MS = 10000;
const portArg = Number(arg('port', '0'));
const QUESTION_DIR = path.join(process.cwd(), '.impeccable', 'questions');
const stateFile = (key) => path.join(QUESTION_DIR, `${key}.state.json`);
@@ -243,7 +265,19 @@ if (hasFlag('wait')) {
}
try {
const state = JSON.parse(fs.readFileSync(stateFile(key), 'utf8'));
if (state.lastBeat && Date.now() - state.lastBeat > 15000) { sawClose = true; break; }
// A silent page is not a closed one while a freshly delivered next
// hand sits unclaimed: a stalled page stops beating by design and its
// watch reloads, beating again, within seconds of the file landing.
// The suppression is age-bound because a closed tab never claims the
// hand: a file still there after the grace means no page is coming.
const midDelivery = (() => {
try { if (Date.now() - fs.statSync(path.join(QUESTION_DIR, `${key}.next.json`)).mtimeMs < NEXT_CLAIM_GRACE_MS) return true; }
catch { /* nothing delivered */ }
// The claim deletes that file before the reloaded page can beat: the
// claim stamp the server persisted covers the same bounded gap.
return Boolean(state.claimedAt) && Date.now() - state.claimedAt < NEXT_CLAIM_GRACE_MS;
})();
if (!midDelivery && state.lastBeat && Date.now() - state.lastBeat > 15000) { sawClose = true; break; }
} catch { /* state mid-write */ }
await new Promise((r) => setTimeout(r, 1000));
}
@@ -280,10 +314,33 @@ if (hasFlag('stop')) {
if (hasFlag('update')) {
const key = arg('key');
if (!key || !payloadPath) { console.error('serve-question: --update needs --key and --payload'); process.exit(1); }
JSON.parse(fs.readFileSync(payloadPath, 'utf8'));
try { process.kill(JSON.parse(fs.readFileSync(stateFile(key), 'utf8')).pid, 0); }
catch { console.error('serve-question: no live question server for that key'); process.exit(2); }
fs.copyFileSync(payloadPath, path.join(QUESTION_DIR, `${key}.next.json`));
// A hand the server cannot load must fail here, at the sender: delivered
// anyway, the page would see ready:true for a round that never renders.
const nextRound = JSON.parse(fs.readFileSync(payloadPath, 'utf8'));
if (!nextRound || !Array.isArray(nextRound.options) || nextRound.options.length === 0) {
console.error('serve-question: --update payload needs an options array; nothing was delivered. Fix the payload and rerun --update on the same key.');
process.exit(1);
}
// Liveness mirrors --wait: a fresh page heartbeat is the primary proof, the
// kill probe is secondary, and EPERM means a sandbox blocked the signal,
// never a dead server. This is the documented re-roll delivery step, so a
// false "no live server" here strands the page mid-shuffle.
const live = (() => {
try {
const state = JSON.parse(fs.readFileSync(stateFile(key), 'utf8'));
if (state.lastBeat && Date.now() - state.lastBeat < 12000) return true;
try { process.kill(state.pid, 0); return true; }
catch (err) { return err.code === 'EPERM'; }
} catch { return false; }
})();
if (!live) { console.error('serve-question: no live question server for that key; the page it served is gone too. Re-present the round with --start and a fresh key, or fall back to the structured question tool.'); process.exit(2); }
const deliveredFile = path.join(QUESTION_DIR, `${key}.next.json`);
fs.copyFileSync(payloadPath, deliveredFile);
// The file's mtime is the delivery clock --wait's grace reads: stamp it
// here, because a copy that preserves the source payload's older mtime
// would start the grace already spent.
const deliveredAt = new Date();
fs.utimesSync(deliveredFile, deliveredAt, deliveredAt);
console.log('next round delivered; the page reloads itself');
process.exit(0);
}
@@ -301,7 +358,8 @@ if (hasFlag('start')) {
const logFd = fs.openSync(logFile, 'a');
const child = spawn(process.execPath, [
fileURLToPath(import.meta.url), '--payload', payloadPath, '--detached-serve', '--key', key,
'--timeout', String(timeoutSec), ...(hasFlag('open') ? [] : ['--no-open']),
'--timeout', String(timeoutSec), ...(arg('idle-grace') ? ['--idle-grace', arg('idle-grace')] : []),
...(hasFlag('open') ? [] : ['--no-open']),
], { detached: true, stdio: ['ignore', logFd, logFd] });
child.unref();
fs.closeSync(logFd);
@@ -338,6 +396,13 @@ let localImages = [];
// even when the round never rendered a toggle.
let buildPathDefault = null;
let liveBuildPath = null;
// True between a collected re-roll or followup answer and the --update that
// replaces the round: the window where GET / must serve the wait, not the
// answered cards. The timestamp anchors the delivery deadline server-side,
// so a native refresh re-enters the wait with the time already spent, never
// with a fresh allowance.
let awaitingNext = false;
let awaitingNextSince = 0;
function loadRound(json) {
const parsed = JSON.parse(json);
@@ -387,6 +452,9 @@ function loadRound(json) {
? { value: parsed.buildPath.value, toggle: parsed.buildPath.toggle === true }
: null;
liveBuildPath = buildPathDefault?.value ?? null;
// Last: a round that failed to load anywhere above must leave the waiting
// window open, never resurrect the answered cards.
awaitingNext = false;
}
try { loadRound(raw); } catch (error) { console.error(`serve-question: ${error.message}`); process.exit(1); }
const detachedKey = hasFlag('detached-serve') ? arg('key') : null;
@@ -394,7 +462,11 @@ const nextFile = () => detachedKey ? path.join(QUESTION_DIR, `${detachedKey}.nex
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
function page() {
function page(waiting = false) {
// The delivery deadline survives refreshes: a waiting page gets whatever
// remains of the original allowance, so reloading cannot renew it. Spent
// means the page renders already stalled and never starts a heartbeat.
const waitBudgetMs = waiting ? Math.max(0, awaitingNextSince + idleGraceMs - Date.now()) : idleGraceMs;
const flipChip = (label) => `<button type="button" class="chip flip" aria-label="Flip the card"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4a8 8 0 1 1-8 8" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><path d="M4 5.5V12h6.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg><span>${label}</span></button>`;
const expandChip = `<button type="button" class="chip expand" aria-label="Expand the image"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9V4h5M20 15v5h-5M20 9V4h-5M4 15v5h5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg></button>`;
// Structured anatomy: chips and one-line facts render when the payload
@@ -866,6 +938,7 @@ function page() {
not a recommendation. */
#canon { align-self: center; padding: 0 4px; font-family: var(--ks-mono); font-size: .66rem; letter-spacing: .08em; text-transform: uppercase; color: inherit; opacity: .45; background: transparent; border: none; border-bottom: 1px dotted currentColor; cursor: pointer; transition: opacity .2s ease; }
#canon:hover { opacity: .85; }
#canon[disabled] { opacity: .18; cursor: default; }
.card.skeleton .media { background: var(--ks-graphite); }
.shimmer { width: 100%; height: 100%; background: linear-gradient(100deg, var(--ks-graphite) 35%, var(--ks-graphite-2) 50%, var(--ks-graphite) 65%); background-size: 220% 100%; animation: shimmer 1.4s linear infinite; }
.card.skeleton .line { height: 11px; border-radius: 4px; background: linear-gradient(100deg, var(--ks-graphite) 35%, var(--ks-graphite-2) 50%, var(--ks-graphite) 65%); background-size: 220% 100%; animation: shimmer 1.4s linear infinite; }
@@ -877,6 +950,8 @@ function page() {
@keyframes shimmer { from { background-position: 120% 0; } to { background-position: -80% 0; } }
@media (prefers-reduced-motion: reduce) { .shimmer, .card.skeleton .line { animation: none; } }
.done { display: flex; flex-direction: column; align-items: center; gap: 1rem; padding: 7rem 1rem; font-family: var(--ks-font-display); font-size: 1.4rem; color: var(--ks-champagne); text-align: center; }
.stall { width: 100%; display: flex; flex-direction: column; align-items: center; gap: 1.2rem; padding: 4.5rem 1rem; font-family: var(--ks-font-display); font-size: 1.4rem; color: var(--ks-champagne); text-align: center; }
.stall .choose { align-self: center; margin-top: 0; }
</style>
<div id="ambient" aria-hidden="true"></div>
<div id="scrim" aria-hidden="true"></div>
@@ -945,11 +1020,22 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// still gets the goodbye screen, never a loading hand nothing will resolve.
const FOLLOWUP = ${payload.followup === true && Boolean(detachedKey) ? 'true' : 'false'};
const beat = () => { try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
beat();
setInterval(beat, 5000);
${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
const beatTimer = setInterval(beat, 5000);
// A dead server must fail loudly: awaiting a rejected fetch here used to
// swallow the click and never print the confirmation, so the user believed
// a choice had landed that no one would ever collect.
async function answer(optionId) {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
if (FOLLOWUP) { await awaitNextRound(); return; }
// Quiet at the click: a re-roll or canon posted while this pick's POST
// is in flight would overwrite the answer being collected.
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
try {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
} catch {
document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
return;
}
if (FOLLOWUP) { await awaitNextRound(true); return; }
document.body.innerHTML = '<div class="done"><svg viewBox="0 0 24 24" width="38" height="38" fill="oklch(84% 0.19 80.46)" aria-hidden="true"><path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/><path d="M16.5 2.5 L19 2.5 Q21.5 2.5 21.5 5 L21.5 19 Q21.5 21.5 19 21.5 L8.5 21.5 Z"/></svg>Choice recorded. The agent is resuming; you can close this tab.</div>';
}
document.querySelectorAll('button.choose').forEach(b => b.addEventListener('click', () => answer(b.dataset.id)));
@@ -1381,15 +1467,62 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !lightbox.hidden) closeLightbox(); });
document.getElementById('canon')?.addEventListener('click', () => answer('canon'));
const dealAgain = async (register) => {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
await awaitNextRound();
// Quiet at the click, not after the fly-out: the POST round-trip plus
// the 700ms animation was a window where a second click posted another
// re-roll and renewed the delivery deadline.
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
try {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
} catch {
document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
return;
}
await awaitNextRound(true);
};
async function awaitNextRound() {
async function awaitNextRound(animate, budgetMs = ${idleGraceMs}) {
const grid = document.querySelector('.grid');
let poll;
let misses = 0;
const shuffleStart = Date.now();
const stall = (message) => {
clearInterval(poll);
// A stalled page is an abandoned flow: keep heartbeating and the
// daemon never reaches its idle grace, so --wait spins on WAITING
// forever. Go silent and let the server reclaim itself. Reload must
// not undo that silence: an unconditional reload re-serves the same
// unresolved round and its fresh page beats again, so check for a
// delivered hand first and only reload when one exists. The re-roll
// buttons and the canon exit go too: a stalled page served already
// expired never disabled them, a re-roll would renew the deadline the
// stall just enforced, and a canon pick would overwrite a re-roll
// --wait already collected, closing the table under the agent.
clearInterval(beatTimer);
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
// Silence is for heartbeats only: a hand delivered after the deadline
// must still land without a click, so a beat-free watch keeps checking
// and reloads into it. /next-status never beats, so the daemon's idle
// grace still reclaims a flow nobody resumes.
const watch = setInterval(async () => {
try { if ((await (await fetch('/next-status')).json()).ready) { clearInterval(watch); location.reload(); } } catch { /* server gone; the screen already says so */ }
}, 1500);
grid.innerHTML = '<div class="stall"><p>' + message + '</p><button type="button" class="choose">Reload</button></div>';
grid.querySelector('.stall .choose').addEventListener('click', async () => {
try {
if ((await (await fetch('/next-status')).json()).ready) { location.reload(); return; }
grid.querySelector('.stall p').textContent = 'Still nothing to deal. Check the agent session, or answer in the chat instead.';
} catch {
grid.querySelector('.stall p').textContent = 'The question server went away. Ask the agent to restart it, or answer in the chat instead.';
}
});
};
// A refresh that lands after the delivery deadline has nothing left to
// wait for: stall before the heartbeat timer's first tick can fire, so
// the served page stays silent.
if (budgetMs <= 0) { stall('The next hand never arrived. Check the agent session, then reload.'); return; }
const cardsNow = [...grid.querySelectorAll('.card')];
const g = grid.getBoundingClientRect();
const cx = g.left + g.width / 2, cy = g.top + g.height / 2;
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) {
if (animate && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
const g = grid.getBoundingClientRect();
const cx = g.left + g.width / 2, cy = g.top + g.height / 2;
cardsNow.forEach((card, i) => {
const r = card.getBoundingClientRect();
card.style.transition = 'transform .5s cubic-bezier(.5,0,.75,0) ' + (i * 60) + 'ms, opacity .4s ease ' + (i * 60 + 120) + 'ms, filter .45s ease ' + (i * 60) + 'ms';
@@ -1401,17 +1534,35 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
}
const cardHeight = cardsNow[0] ? cardsNow[0].getBoundingClientRect().height : 0;
grid.innerHTML = cardsNow.map(() => '<article class="card skeleton"' + (cardHeight ? ' style="height:' + cardHeight + 'px"' : '') + '><div class="card-inner"><div class="face front"><div class="media"><div class="shimmer"></div></div><div class="body"><div class="line tier w40"></div><div class="line title w70"></div><div class="line w90"></div><div class="line w80"></div><div class="line w60"></div><div class="line button"></div></div></div></div></article>').join('');
document.querySelectorAll('.reroll-btn').forEach(b => b.setAttribute('disabled', ''));
const poll = setInterval(async () => {
// Canon goes quiet with the re-roll buttons: a pick posted mid-wait can
// never be collected once --wait has the re-roll, only close the table.
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
// The wait must be able to end: a dead server rejects every tick and a
// round nobody delivers stays ready:false forever, and both used to spin
// the skeletons indefinitely. Distinguish them, say so, and offer a way
// out. The delivery deadline is the server's own idle grace, so the page
// never gives up on a server that would still accept the hand.
poll = setInterval(async () => {
try {
const status = await (await fetch('/next-status')).json();
misses = 0;
if (status.ready) { clearInterval(poll); location.reload(); }
} catch { /* server briefly busy */ }
else if (Date.now() - shuffleStart > budgetMs) stall('The next hand never arrived. Check the agent session, then reload.');
} catch {
misses += 1;
if (misses >= 8) stall('The question server went away. Ask the agent to restart it, or answer in the chat instead.');
}
}, 1200);
}
document.getElementById('reroll')?.addEventListener('click', () => dealAgain());
document.getElementById('reroll-safer')?.addEventListener('click', () => dealAgain('safer'));
document.getElementById('reroll-bolder')?.addEventListener('click', () => dealAgain('bolder'));
// A native refresh must not resurrect an answered round: while the server
// holds a collected re-roll or followup pick with no replacement delivered,
// it serves the page in waiting mode and the refresh re-enters the same
// bounded wait, with only the time the original deadline has left, instead
// of showing dead cards whose heartbeat props the daemon forever.
${waiting ? `awaitNextRound(false, ${waitBudgetMs});` : ''}
</script>`;
}
@@ -1419,14 +1570,32 @@ const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
try { loadRound(fs.readFileSync(pending, 'utf8')); fs.rmSync(pending); } catch { /* keep current round */ }
// A next file the round cannot load has to leave the disk either way:
// kept, /next-status stays ready:true and the waiting page reloads
// into the same failure without bound.
try { loadRound(fs.readFileSync(pending, 'utf8')); } catch { /* keep current round */ }
try { fs.rmSync(pending); } catch { /* already gone */ }
// The claim consumes the file the idle-exit hold reads, and the
// reloading page cannot beat until it has parsed: stamp the claim so
// the same bounded grace covers the gap between them. Persisted too,
// because --wait watches the same gap from outside this process and
// would otherwise read the stale beat as a closed page.
server.lastClaimAt = Date.now();
if (detachedKey) {
try {
const state = JSON.parse(fs.readFileSync(stateFile(detachedKey), 'utf8'));
state.claimedAt = server.lastClaimAt;
fs.writeFileSync(stateFile(detachedKey), JSON.stringify(state));
} catch { /* state file recreated on next beat */ }
}
}
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(page());
res.end(page(awaitingNext));
return;
}
if (req.method === 'POST' && req.url === '/heartbeat') {
res.writeHead(204); res.end();
server.lastBeatSeen = Date.now();
if (detachedKey) {
const now = Date.now();
if (!server.lastBeatWrite || now - server.lastBeatWrite > 4000) {
@@ -1502,6 +1671,11 @@ const server = http.createServer((req, res) => {
...((chosen?.comp ?? chosen?.sketch) ? { comp: chosen.comp ?? chosen.sketch } : {}),
...(liveBuildPath && !isReroll ? { buildPath: liveBuildPath, buildPathFlipped: liveBuildPath !== (buildPathDefault?.value ?? null) } : {}),
});
// The delivery deadline is single-issue: a duplicate answer racing the
// page's disable must not restamp the allowance already inherited.
const wasAwaiting = awaitingNext;
awaitingNext = (isReroll || followupOpen) && Boolean(detachedKey);
if (awaitingNext && !wasAwaiting) awaitingNextSince = Date.now();
if (detachedKey) {
fs.mkdirSync(QUESTION_DIR, { recursive: true });
fs.writeFileSync(answerFile(detachedKey), answer + '\n');
@@ -1531,10 +1705,38 @@ server.listen(portArg, '127.0.0.1', () => {
if (!hasFlag('no-open')) {
openSystemBrowser(url);
}
if (timeoutSec > 0) {
setTimeout(() => {
console.log('serve-question: timed out with no answer');
process.exit(2);
}, timeoutSec * 1000).unref?.();
}
// The timeout bounds the wait for a page, never the user's decision: an
// absolute guillotine counted from start used to kill the server under a
// still-open tab (a slow re-rolled round easily outlived it), leaving the
// page polling skeletons that could never resolve. Once the page beats,
// the server's lifetime tracks the beats, and it exits only after the idle
// grace passes with none, long enough to survive a closed laptop lid.
// --timeout 0 waits for a page forever, but the idle grace still applies
// once one has beat: a page that arrived and went silent is a closed tab,
// and no timeout setting should let that daemon leak.
const startedAt = Date.now();
const lifetime = setInterval(() => {
if (!server.lastBeatSeen) {
if (timeoutSec > 0 && Date.now() - startedAt > timeoutSec * 1000) {
console.log('serve-question: timed out with no answer');
process.exit(2);
}
} else if (Date.now() - server.lastBeatSeen > idleGraceMs) {
// A hand delivered moments before this deadline still gets its claim
// window: the stalled page's watch reloads into it and beats again
// within seconds, while a file unclaimed past the grace means no page
// is coming back (the same verdict --wait reads from its age). The
// claim itself holds the daemon too: GET / deletes the file before the
// reloaded page can beat, so a tick in that gap must not exit under
// the hand just claimed.
const pending = nextFile();
let deliveredAt = 0;
if (pending) { try { deliveredAt = fs.statSync(pending).mtimeMs; } catch { /* nothing delivered */ } }
if (Date.now() - Math.max(deliveredAt, server.lastClaimAt || 0) > NEXT_CLAIM_GRACE_MS) {
console.log('serve-question: the page stopped beating and never came back; exiting');
process.exit(2);
}
}
}, 2000);
lifetime.unref?.();
});
+3 -3
View File
@@ -1,10 +1,10 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 4.1.1
version: 4.1.2
---
This skill gives you the tools and permission to create design that earns to be called out-of-distribution craft: Whereas before, your design work would have been safe, timid and measured, you now approach every design task as a award-winning design director with impeccable understanding for what makes exceptional design work: production-grade code, peak creativity, a clear POV, deep understanding of the needs of the client and users, and exceptional craft.
This skill gives you the tools and permission to create design that earns to be called out-of-distribution craft: Whereas before, your design work would have been safe, timid and measured, you now approach every design task as an award-winning design director with impeccable understanding for what makes exceptional design work: production-grade code, peak creativity, a clear POV, deep understanding of the needs of the client and users, and exceptional craft.
Core principles:
- Go all out. No hedging, no shortcuts. The deliverable must be complete (except assets the user must provide).
@@ -14,7 +14,7 @@ Core principles:
## Setup
1. Run `node <skill-base-dir>/scripts/context.mjs` once per session, where `<skill-base-dir>` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .agents/skills/impeccable/scripts/...` command in this skill and its references, and `.agents/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target <path>`. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it.
2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing.
2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures.
3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work.
## How to design
@@ -17,76 +17,26 @@ When the parent hands you a decision card packet instead of an approved mock, th
## Input Contract
Expect:
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.
- Approved mock path or screenshot reference.
- Crop paths or a contact sheet with crop ids.
- Output directory.
- Required dimensions, format, transparency needs, and avoid list.
- Notes on what should remain semantic HTML/CSS/SVG instead of raster.
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 the source mock is attached but has no filesystem path, use it for visual planning; ask for a path only before cropping or writing assets.
## The job
Defaults unless contradicted:
Every region with `medium: raster` in the spec ships as a plate at its `plate` path. A plate is the region regenerated at asset resolution from the comp crop as reference: same subject, same composition, same palette, same lighting and material, with the UI text and page chrome removed, at 1.5x the comp region's pixel size or more. The page draws text, controls, radius, shadow, and layout in code; the plate carries what code cannot draw. Crops from the comp are references, never shipping pixels: a comp is reference grade and a shipped crop is how a beautiful comp becomes a blurry site.
- `.webp` for opaque photos, backgrounds, and textures.
- `.png` for transparent cutouts, seals, tickets, and illustrations.
- Target production size, or at least 2x display size when dimensions are known. Never default to the small size of a full-page mock crop.
- Remove UI text, navigation, buttons, labels, and body copy.
- Keep physical marks only when the parent says they are part of the asset.
- Remove letterboxing, empty padding, baked card corners, borders, shadows, caption bands, and layout background unless the parent says those pixels are intrinsic.
- Keep the final assets directory clean: only files the build will consume. Source crops, reference crops, masks, and contact sheets go in a sibling `_sources`, `sources`, or review folder.
Per region, in the spec's order:
Ask blockers once, globally. Missing source path/crops or output directory blocks production. Exact dimensions, compression targets, retina variants, and format preferences do not; choose defaults and report them.
1. `node .agents/skills/impeccable/scripts/comp-spec.mjs --crop <id>` writes the reference crop under `.impeccable/build/crops/`.
2. Produce the plate. With the API fallback: `node .agents/skills/impeccable/scripts/generate-image.mjs --plate <id> --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 .agents/skills/impeccable/scripts/comp-spec.mjs --plate-prompt <id>` as the prompt, write the result to the plate path, then run `node .agents/skills/impeccable/scripts/embed-prompt.mjs <plate> --prompt "<the exact 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.
## Workflow
Codex: the imagegen skill's built-in `image_gen` path is the native tool here; prefer it for generation and editing, with the crop as the input image.
1. Inventory the full approved mock or every assigned crop.
2. Put each visual role in exactly one bucket:
- `produce`: needs generation, image editing, cleanup, cutout work, or a clean plate before it can ship.
- `direct`: ships after format conversion, compression, or renaming because the parent supplied a real standalone source: a project file, stock, or prior production art. A crop from the approved mock is never `direct`, whatever its apparent size.
- `semantic`: build in HTML/CSS/SVG/canvas, no raster output.
3. Crops from the mock are binding visual references, never shipping pixels: a full-page mock's effective resolution is reference grade, and a shipped crop, however close it looks, is how a beautiful comp becomes a blurry site. Every mock-derived asset goes through `produce` as a clean regeneration.
4. Give the parent an execution order for the `produce` bucket.
5. For produced assets, choose the least inventive strategy: image-to-image clean plate, faithful regeneration from crop reference, transparent cutout, texture/pattern reconstruction, stock/project source, or a semantic HTML/CSS/SVG recommendation when raster is wrong.
6. Use the harness's native image tool by default when generation or editing is needed; otherwise use the skill's generate-image.mjs.
Codex: the imagegen skill's built-in `image_gen` path is the native tool here; prefer it for generation, editing, and the chroma-key workflow.
7. Remove baked-in UI text, navigation, buttons, body copy, and mock chrome unless the text is part of the asset.
8. Think through the final DOM/CSS representation before generating. If CSS will own radius, clipping, shadows, borders, perspective, responsive cropping, captions, or card frames, do not bake those into the bitmap.
9. Save outputs non-destructively in the requested project directory, and leave the intent with the file: after every generation, run `node .agents/skills/impeccable/scripts/embed-prompt.mjs <asset> --prompt "<the prompt used>"` so the prompt lives inside the image itself. The build thread composes what you made and needs to know what it is looking at, and the embedding survives copies where sidecars get lost.
10. Compare each output against its source crop, opening every image by its workspace-relative path; sandboxed viewers reject absolute paths. If a review/QA tool is available, run it before the final manifest, then retry each major/fatal finding once before finalizing.
Use `texture/pattern extraction` only when the source region is already clean enough to sample as texture. If UI, cards, labels, headings, body copy, or footer chrome must be removed first, classify it as crop-derived cleanup or clean-plate work.
Use `semantic` for dashboards, charts, controls, screenshots of whole UI sections, data widgets, card chrome, app frames, icon toolbars, logos, wordmarks, and anything the final implementation can render crisply in HTML/CSS/SVG/canvas. Ship a screenshot raster only when the parent explicitly says the screenshot itself is the final asset.
Semantic does not mean ignored. For every semantic role, write a concrete implementation handoff for the parent craft agent: the DOM/component layers, CSS-owned visual treatment, SVG/canvas/icon-library pieces, responsive behavior, and which nearby produced raster assets it composes with. For logos and icons, prefer inline SVG/vector or icon-library implementation unless the parent provides a production logo raster.
## Prompt Pattern
Use this shape for image-to-image work:
```text
Use the provided crop as the approved visual reference.
Recreate the same asset as a clean reusable production image at the target component aspect ratio and at least 2x display resolution.
Preserve silhouette, object/scene perspective, camera angle, palette, lighting, material, texture, and visual role.
Remove baked-in UI copy, navigation, buttons, labels, body text, watermarks, and mock chrome unless explicitly part of the asset.
Remove letterboxing, padding, card borders, rounded clipping, CSS shadows, perspective transforms, caption bands, and layout backgrounds that the implementation should create in code.
Do not add new objects. Do not change the concept. Do not redesign the composition.
```
For transparent cutouts: use true alpha when the tool supports it; otherwise generate on a flat chroma-key color that cannot appear in the subject and post-process that color to alpha before shipping the PNG/WebP. Never ship the keyed background as the final asset.
Do not redesign. Do not add objects, restyle, or reinterpret; the comp was approved as it is. Do not touch the page code, the spec, or the comp. Do not produce anything the spec does not list; a region the parent forgot goes back as a one-line note, not a plate.
## Output Contract
Return a complete manifest, grouped by `produce`, `direct`, and `semantic`. For each asset include: `id`, `source_crop`, `output_path` when applicable, `strategy`, `prompt_used` when applicable, `dimensions`, `format`, `transparency`, `deviations`, and `qa_status`.
For each semantic row include `id`, `implementation`, `notes`, and `qa_status`. The `implementation` is a concrete build handoff, not a note that no asset was produced: name the likely HTML/CSS/SVG/canvas/icon/component pieces and the visual responsibilities code owns.
`qa_status` is `accepted`, `needs_parent_review`, or `blocked`. `accepted` only after visual comparison passes. `needs_parent_review` for cut-off subjects, unwanted borders or rounded-card chrome, letterboxing, baked semantic text, low-resolution output, perspective that should have been CSS, missing transparency, or drift from the crop. `blocked` when inputs, permissions, image capability, or asset source quality prevent a credible result.
End with `execution_order`, `blockers`, and `assumptions` sections. Keep blockers global and minimal; per-asset rows carry only asset-specific risks or decisions.
Do not modify implementation code. Do not edit the approved mock. Do not produce final page copy. The parent craft agent owns implementation and final mock fidelity.
Return one line per raster region: `<id> <plate path> <WxH> <score>% <accepted|needs_parent_review|blocked> <one-line note or ->`. 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.
'''
@@ -13,16 +13,16 @@ 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); 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/<id>.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.
## Checks, in order
0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-<width>.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round.
1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/review/hero-repro.png` exists: the hero reproduction checkpoint's capture at the comp's own dimensions; its absence means the reproduction phase ran unproven, a material finding. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all.
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all.
2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition.
4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport.
5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every image-native region of the approved comp shipped as a real asset, not a gradient standing in for one, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind other paint is a compliance token, not a shipped material.
5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every raster region of the spec shipped as its plate (the spec names the file; the page references it; the region's diff row is not `missing`), not a gradient, an inline SVG, or a many-vertex `clip-path` standing in for it, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind a wash is a compliance token, not a shipped material, and the detector's `buried-raster` and `organic-clip-path` findings in the packet are material fixes.
6. **Floor.** Read the craft floor's Refuse list and hold the screenshots against it: kickers and eyebrows, hard offset shadows outside a neobrutalist world, glyph icons, system display faces, gradient text, side stripes, and the rest. A banned element is a material fix even when it matches nothing in the comp: the builder loaded the same ban before writing it, and fidelity to a comp cannot authorize what the floor refuses. The parent's hook findings cover this mechanically where hooks run; this check exists because hookless harnesses reach you with none, and the last two live sessions shipped five kickers past a reviewer that never looked.
Do not run a second detector pass; mechanical findings belong to the parent's hooks.
@@ -1,6 +1,6 @@
### Purpose
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive/backlog for future commands.
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive of that run.
### Hard Invariants
@@ -97,7 +97,7 @@ Codex failure accounting: final Run Notes must include target slug, ignore list,
Synthesize both assessments into a single report. Do NOT simply concatenate. Weave the findings together, noting where the LLM review and detector agree, where the detector caught issues the LLM missed, and where detector findings are false positives.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is only an archive/backlog for later commands.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is an archive of that run.
Codex final-answer note: `$impeccable critique` produces a report artifact, so the final chat response should intentionally exceed the usual concise close-out style. Do not title the final response "Critique Summary" unless the user explicitly asked for a summary.
@@ -219,7 +219,7 @@ Skip this step if the Setup slug was null (vague or root-level target).
IMPECCABLE_CRITIQUE_META='{"target":"<user phrasing>","total_score":<n>,"max_score":<n>,"na_heuristics":"<comma-separated numbers, or empty>","p0_count":<n>,"p1_count":<n>}' \
node .agents/skills/impeccable/scripts/critique-storage.mjs write "<resolved target>" <body-file>
```
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. The helper prints the absolute path it wrote.
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. For a local file target, the helper also records an exact content fingerprint so polish can distinguish the assessed bytes from later edits without relying on Git state or timestamps. The helper prints the absolute path it wrote. Leave that file on disk. Polish closes it; this run does not.
3. **Delete the temp body file** after the write attempt completes, whether the write succeeded or failed. If deletion fails, mention `temp-file cleanup failed: <reason>` briefly in the final output, but do not block the critique.
@@ -15,75 +15,25 @@ When the parent hands you a decision card packet instead of an approved mock, th
## Input Contract
Expect:
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.
- Approved mock path or screenshot reference.
- Crop paths or a contact sheet with crop ids.
- Output directory.
- Required dimensions, format, transparency needs, and avoid list.
- Notes on what should remain semantic HTML/CSS/SVG instead of raster.
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 the source mock is attached but has no filesystem path, use it for visual planning; ask for a path only before cropping or writing assets.
## The job
Defaults unless contradicted:
Every region with `medium: raster` in the spec ships as a plate at its `plate` path. A plate is the region regenerated at asset resolution from the comp crop as reference: same subject, same composition, same palette, same lighting and material, with the UI text and page chrome removed, at 1.5x the comp region's pixel size or more. The page draws text, controls, radius, shadow, and layout in code; the plate carries what code cannot draw. Crops from the comp are references, never shipping pixels: a comp is reference grade and a shipped crop is how a beautiful comp becomes a blurry site.
- `.webp` for opaque photos, backgrounds, and textures.
- `.png` for transparent cutouts, seals, tickets, and illustrations.
- Target production size, or at least 2x display size when dimensions are known. Never default to the small size of a full-page mock crop.
- Remove UI text, navigation, buttons, labels, and body copy.
- Keep physical marks only when the parent says they are part of the asset.
- Remove letterboxing, empty padding, baked card corners, borders, shadows, caption bands, and layout background unless the parent says those pixels are intrinsic.
- Keep the final assets directory clean: only files the build will consume. Source crops, reference crops, masks, and contact sheets go in a sibling `_sources`, `sources`, or review folder.
Per region, in the spec's order:
Ask blockers once, globally. Missing source path/crops or output directory blocks production. Exact dimensions, compression targets, retina variants, and format preferences do not; choose defaults and report them.
1. `node .agents/skills/impeccable/scripts/comp-spec.mjs --crop <id>` writes the reference crop under `.impeccable/build/crops/`.
2. Produce the plate. With the API fallback: `node .agents/skills/impeccable/scripts/generate-image.mjs --plate <id> --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 .agents/skills/impeccable/scripts/comp-spec.mjs --plate-prompt <id>` as the prompt, write the result to the plate path, then run `node .agents/skills/impeccable/scripts/embed-prompt.mjs <plate> --prompt "<the exact 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.
## Workflow
Codex: the imagegen skill's built-in `image_gen` path is the native tool here; prefer it for generation and editing, with the crop as the input image.
1. Inventory the full approved mock or every assigned crop.
2. Put each visual role in exactly one bucket:
- `produce`: needs generation, image editing, cleanup, cutout work, or a clean plate before it can ship.
- `direct`: ships after format conversion, compression, or renaming because the parent supplied a real standalone source: a project file, stock, or prior production art. A crop from the approved mock is never `direct`, whatever its apparent size.
- `semantic`: build in HTML/CSS/SVG/canvas, no raster output.
3. Crops from the mock are binding visual references, never shipping pixels: a full-page mock's effective resolution is reference grade, and a shipped crop, however close it looks, is how a beautiful comp becomes a blurry site. Every mock-derived asset goes through `produce` as a clean regeneration.
4. Give the parent an execution order for the `produce` bucket.
5. For produced assets, choose the least inventive strategy: image-to-image clean plate, faithful regeneration from crop reference, transparent cutout, texture/pattern reconstruction, stock/project source, or a semantic HTML/CSS/SVG recommendation when raster is wrong.
6. Use the harness's native image tool by default when generation or editing is needed; otherwise use the skill's generate-image.mjs.
Codex: the imagegen skill's built-in `image_gen` path is the native tool here; prefer it for generation, editing, and the chroma-key workflow.
7. Remove baked-in UI text, navigation, buttons, body copy, and mock chrome unless the text is part of the asset.
8. Think through the final DOM/CSS representation before generating. If CSS will own radius, clipping, shadows, borders, perspective, responsive cropping, captions, or card frames, do not bake those into the bitmap.
9. Save outputs non-destructively in the requested project directory, and leave the intent with the file: after every generation, run `node .agents/skills/impeccable/scripts/embed-prompt.mjs <asset> --prompt "<the prompt used>"` so the prompt lives inside the image itself. The build thread composes what you made and needs to know what it is looking at, and the embedding survives copies where sidecars get lost.
10. Compare each output against its source crop, opening every image by its workspace-relative path; sandboxed viewers reject absolute paths. If a review/QA tool is available, run it before the final manifest, then retry each major/fatal finding once before finalizing.
Use `texture/pattern extraction` only when the source region is already clean enough to sample as texture. If UI, cards, labels, headings, body copy, or footer chrome must be removed first, classify it as crop-derived cleanup or clean-plate work.
Use `semantic` for dashboards, charts, controls, screenshots of whole UI sections, data widgets, card chrome, app frames, icon toolbars, logos, wordmarks, and anything the final implementation can render crisply in HTML/CSS/SVG/canvas. Ship a screenshot raster only when the parent explicitly says the screenshot itself is the final asset.
Semantic does not mean ignored. For every semantic role, write a concrete implementation handoff for the parent craft agent: the DOM/component layers, CSS-owned visual treatment, SVG/canvas/icon-library pieces, responsive behavior, and which nearby produced raster assets it composes with. For logos and icons, prefer inline SVG/vector or icon-library implementation unless the parent provides a production logo raster.
## Prompt Pattern
Use this shape for image-to-image work:
```text
Use the provided crop as the approved visual reference.
Recreate the same asset as a clean reusable production image at the target component aspect ratio and at least 2x display resolution.
Preserve silhouette, object/scene perspective, camera angle, palette, lighting, material, texture, and visual role.
Remove baked-in UI copy, navigation, buttons, labels, body text, watermarks, and mock chrome unless explicitly part of the asset.
Remove letterboxing, padding, card borders, rounded clipping, CSS shadows, perspective transforms, caption bands, and layout backgrounds that the implementation should create in code.
Do not add new objects. Do not change the concept. Do not redesign the composition.
```
For transparent cutouts: use true alpha when the tool supports it; otherwise generate on a flat chroma-key color that cannot appear in the subject and post-process that color to alpha before shipping the PNG/WebP. Never ship the keyed background as the final asset.
Do not redesign. Do not add objects, restyle, or reinterpret; the comp was approved as it is. Do not touch the page code, the spec, or the comp. Do not produce anything the spec does not list; a region the parent forgot goes back as a one-line note, not a plate.
## Output Contract
Return a complete manifest, grouped by `produce`, `direct`, and `semantic`. For each asset include: `id`, `source_crop`, `output_path` when applicable, `strategy`, `prompt_used` when applicable, `dimensions`, `format`, `transparency`, `deviations`, and `qa_status`.
For each semantic row include `id`, `implementation`, `notes`, and `qa_status`. The `implementation` is a concrete build handoff, not a note that no asset was produced: name the likely HTML/CSS/SVG/canvas/icon/component pieces and the visual responsibilities code owns.
`qa_status` is `accepted`, `needs_parent_review`, or `blocked`. `accepted` only after visual comparison passes. `needs_parent_review` for cut-off subjects, unwanted borders or rounded-card chrome, letterboxing, baked semantic text, low-resolution output, perspective that should have been CSS, missing transparency, or drift from the crop. `blocked` when inputs, permissions, image capability, or asset source quality prevent a credible result.
End with `execution_order`, `blockers`, and `assumptions` sections. Keep blockers global and minimal; per-asset rows carry only asset-specific risks or decisions.
Do not modify implementation code. Do not edit the approved mock. Do not produce final page copy. The parent craft agent owns implementation and final mock fidelity.
Return one line per raster region: `<id> <plate path> <WxH> <score>% <accepted|needs_parent_review|blocked> <one-line note or ->`. 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.
@@ -11,16 +11,16 @@ 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); 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/<id>.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.
## Checks, in order
0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-<width>.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round.
1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/review/hero-repro.png` exists: the hero reproduction checkpoint's capture at the comp's own dimensions; its absence means the reproduction phase ran unproven, a material finding. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all.
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all.
2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition.
4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport.
5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every image-native region of the approved comp shipped as a real asset, not a gradient standing in for one, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind other paint is a compliance token, not a shipped material.
5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every raster region of the spec shipped as its plate (the spec names the file; the page references it; the region's diff row is not `missing`), not a gradient, an inline SVG, or a many-vertex `clip-path` standing in for it, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind a wash is a compliance token, not a shipped material, and the detector's `buried-raster` and `organic-clip-path` findings in the packet are material fixes.
6. **Floor.** Read the craft floor's Refuse list and hold the screenshots against it: kickers and eyebrows, hard offset shadows outside a neobrutalist world, glyph icons, system display faces, gradient text, side stripes, and the rest. A banned element is a material fix even when it matches nothing in the comp: the builder loaded the same ban before writing it, and fidelity to a comp cannot authorize what the floor refuses. The parent's hook findings cover this mechanically where hooks run; this check exists because hookless harnesses reach you with none, and the last two live sessions shipped five kickers past a reviewer that never looked.
Do not run a second detector pass; mechanical findings belong to the parent's hooks.
+4 -4
View File
@@ -2,9 +2,9 @@
Manage the **design detector hook** for the current project.
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write. Grok Build fires the same PostToolUse scan to mark touched files, then surfaces findings on Stop `additionalContext`. Do not expect a Grok per-edit reminder: Grok discards that stdout.
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit.
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code, Codex, and Grok Build, which dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit. Grok also fires an observe-only Stop with `reason: "shutdown"` after `end_turn`; skip that one, scan only `end_turn`.
Every hook is a mechanical pass. The reflexes no scanner catches live in [craft-floor.md](craft-floor.md), which the skill loads before it edits UI, so they apply whether or not a hook is wired. A session with no automatic hook gets one `MANUAL_DETECTOR_REQUIRED` directive from `context.mjs` asking for a single detector run at the end.
@@ -14,7 +14,7 @@ Declare server-side template extensions under **`detector.extensions`** when the
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), Grok Build (`.grok/hooks/impeccable.json` in the project; requires `/hooks-trust` or `--trust`), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands.
@@ -32,7 +32,7 @@ The first argument is the action. Defaults to `status`.
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. |
| `ignore-value <id> "*" --file <glob> [--file <glob>...]` | Turn one rule off in matching files only, leaving it active everywhere else. Repeat `--file`, or use `--file=<glob>` / `--files=<glob>`. A bare `"*"` with no `--file` is refused: use `ignore-rule <id>` if you really mean project-wide. |
| `reset` | Delete the project config, dedup cache, and Cursor pending queue. |
| `reset` | Delete the project config, dedup cache, and Cursor pending queue, and remove the hook's entries from every provider manifest `on` installs, the committed Copilot file included (a team-shared `settings.json` that `on` never writes is never touched). |
## Flow
+37 -12
View File
@@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u
4. Run `node .agents/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen.
5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLES PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .agents/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .agents/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
@@ -68,16 +68,20 @@ Calibration: AI-generated interfaces cluster around a few looks regardless of su
## 5. Record the decision
Before code, state the chosen direction as a contract in the artifact's opening comment, five short blocks, 150 words at most, in a form that survives the production build: an HTML comment in the emitted markup, never only a templating-frontmatter comment, placed as the first child of the document's body in the root layout, never inside a slotted or child component (some compilers, Astro among them, strip a slot's leading comment while keeping deeper ones). After the first production build, grep the built output for the seed key; a contract the build erased is a contract nobody can audit. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, and the seed key the script printed. Close with one more line, FINISH: the run's exit condition, verbatim "unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance". The comment tops the artifact you re-open on every edit, the one reminder that survives a long build: a page that looks complete with the FINISH line undischarged is not done, it is abandoned at the finish line. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
Before code, record the chosen direction as a development-only contract under `## Direction contract` in the relevant surface brief. A direction contract is durable route or artifact strategy, so create or update the brief even when no other surface strategy needs persistence. Keep the contract to six short blocks and 150 words at most. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, and the seed key the script printed. Close with one more line, FINISH: the run's exit condition, verbatim "unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance". The surface brief is the reminder later agents reload across edits and sessions: a page that looks complete with the FINISH line undischarged is not done, it is abandoned at the finish line. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
Never copy the direction contract into implementation source or any browser-delivered artifact. This includes HTML or framework comments, hidden DOM, `<template>` elements, `data-*` attributes, rendered JSX or TSX output, serialized props or state, React Server Component payloads, client bundles, metadata or JSON-LD, accessibility-only text, and files served beside the artifact. A compiler or optimizer removing development metadata is not a safety boundary. Reviewers and documenters receive the contract from the surface brief.
On a new or replacement world, DESIGN.md is written at finish, from the built world, by the shipped documenter (section 7); a rulebook written before the build gets defended against reality instead of describing it, and hands the design-system detector an unstable target. A new world shipped with no DESIGN.md is still an incomplete run. An ordinary extension does not rewrite DESIGN.md.
If the work establishes durable strategy for a route or artifact, read its existing surface brief, then update it:
Read the existing surface brief before updating it:
`node .agents/skills/impeccable/scripts/surface-brief.mjs read <primary-target>`
`node .agents/skills/impeccable/scripts/surface-brief.mjs write <primary-target> <body-file> [related-target ...]`
After writing, read the brief once more and verify that all six contract blocks and the seed key are present before building.
Keep the brief small: scope and visitor mode; audience, job, action/task, proof/content, and constraints; chosen direction and memorable moment; unresolved decisions. Do not copy global product truth or DESIGN.md tokens into it.
On a comp-led build, whenever any image generation is available (a harness-native tool or the API fallback context.mjs reports), the locked direction is visualized before it is built, never skipped: load [visualize.md](visualize.md) and follow it, three compositional options put before the user for approval, the chosen card's decision comp plus two variations. This step is proven to produce the most compositional and ambitious work. On a code-led build the comp round is skipped by contract, never by drift: the ambition it would have carried lives in the direction contract's FIRST VIEWPORT block and named signature interaction, and the finish reviewer audits those promises in behavior.
@@ -86,30 +90,51 @@ For `shape`, return the selected direction to [shape.md](shape.md) and stop befo
## 6. Build with full commitment
When an approved comp exists, the comp is king, and the build happens in phases. The comp is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words, and difficulty never infers a downgrade. Phase one is reproduction: rebuild the comp at its own breakpoint until a screenshot at the comp's width and height overlaps it near pixel-perfectly, materials, components, elevation, assets, and implied design language included. Exactly three concessions exist: fonts (the closest obtainable face), icons (exact match unless the user already chose an icon library), and genuine defects in the generated comp such as spelling errors. Everything else must match, and models systematically believe their HTML, CSS, and SVG recreation succeeded when it did not, so the overlap comparison is the authority, never your conviction: set the screenshot beside the freshly reopened comp image at identical dimensions after every region, never beside your memory of it, and when a region keeps losing that comparison, stop recreating it in code and produce it as a rendered asset composited into the page. The comp also outranks every written record of it: when the recorded brief or inventory commits to less than the comp shows, a softer texture, a sparser field, a sculpted plate reduced to flat CSS, correct the record upward to the comp; qualifiers like subtle, restrained, and low-contrast, and counts rounded down to a comfortable fraction, are how approved materials die between approval and build. A produced material must then survive to the screen: a texture buried under a nearly opaque color wash ships the wash, not the material, so judge every material by the screenshot beside the comp, never by the stylesheet. Every color the brief records gets that comparison by number, not by eye: sample the build screenshot's ground, dominant fields, and accents the same way each record was taken (an interior patch average where the record is an average, both end colors where the record is a gradient) and set each value against its recorded counterpart (sampled from the comp itself when the brief lacks one), and when a texture or tile paints over a base token, measure the net on-screen value, because the eye files a drifted color under the same color word and the number is what catches it. Judge the gap like a colorist, not a diff tool: a difference with a color name (warmer, grayer, darker than the record) is drift to fix, while a few digits of render and compression noise are the same color. Only when reproduction holds does phase two begin: static regions that should live become animated or interactive, reveals and motion are added, then responsiveness across the surface's devices. Where the comp does not cover the whole surface, continue building the remainder inside the comp's recorded world and design language; a component the comp never shows inherits the recorded system's corner language, line weights, and materials, and may not introduce container styles, border weights, or chrome the comp never uses.
Build the assigned direction, not a safer interpretation of it. The form supplies structure, reading order, component conventions, and native motion; the product supplies every fact. Commit every atom: nav, buttons, inputs, and links are rebuilt in the form's vocabulary, and a stock component inside a committed form is a lapse. Land the first build fully committed; the passes that follow exist to make the committed thing clear and effective, never to dilute it. In unattended work, the safe rendition is the known risk.
Build the assigned direction, not a safer interpretation of it. The form supplies structure, reading order, component conventions, and native motion; the product supplies every fact. Commit every atom: nav, buttons, inputs, and links are rebuilt in the form's vocabulary, and a stock component inside a committed form is a lapse. Land the first build fully committed; committing is the hard part, and the passes that follow exist to make the committed thing clear and effective, never to dilute it. In unattended work, the safe rendition is the known risk.
### Comp-led: the comp is a measured contract
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 .agents/skills/impeccable/scripts/build-phase.mjs start --direction <seed key> --kind <assigned|pick|challenger|canon>` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp <approved comp>` when a surface round already locked one.
Then, in order, each closed by `node .agents/skills/impeccable/scripts/build-phase.mjs advance` (every script below lives under `.agents/skills/impeccable/scripts/` and runs with `node`; exit 2 means the gate failed and printed why; fix that and advance again; write nothing for a later phase while an earlier gate is open):
0. **comps.** The comp round from [visualize.md](visualize.md): three compositional comps of the requested surface at its own viewport under `.impeccable/mocks/`, each with a prompt sidecar, put in front of the user; the chosen one's sidecar gets `"approved": true`. The gate counts them and reads the approval; a `start --comp` skips this phase because it already happened.
The comp-led path is a frontier-tier job: it asks the builder to hold a measured layout, place plates at their boxes, and act on numeric readings across a dozen attempts. Smaller or faster models produce a recognisable page and stall under the hero gate; if the model in hand is one of those, say so before the direction round and take the code-led path, or expect the run to end at the hero with its readings unmet.
1. **spec.** Measure the comp: `comp-spec.mjs --comp <comp> --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `comp-spec.mjs --comp <comp> --regions <file>`. The spec carries each region's box, sampled palette, and medium; `comp-spec.mjs --print` is the build's reference from here on. Type is measured, not guessed: `font-match.mjs --measure <text region>` reads the comp's cap height, width class, and weight off the pixels, and `font-match.mjs --rank <region> --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors.
2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (ink on flat ground is generated on a chroma key and keyed to alpha, so it sits on the page's own ground rather than a second paper); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `generate-image.mjs --plate <id>` does one region end to end and scores it against the crop; a harness-native image tool takes the crop (`comp-spec.mjs --crop <id>`) as its input image and `comp-spec.mjs --plate-prompt <id>` as its prompt, then `embed-prompt.mjs`. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason.
3. **hero.** `build-phase.mjs scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r-<id>-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 `<img>`, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `build-phase.mjs record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `comp-diff.mjs`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no 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.
### Code-led
No comp and no apology for it: the ambition lives in the direction contract's FIRST VIEWPORT block and the named signature interaction, and the finish reviewer audits those promises in behavior. The chosen decision comp rides to the finish review as the critique reference.
### Both paths
- **The first viewport is a thesis, not a header.** Demonstrate the mechanism immediately, at the scale the form has in life; do not trap the concept inside a standard hero or card shell. The memory test: if someone left after one viewport, what would they describe an hour later? If the honest answer is a mood, the concept has not committed yet.
- **Prove the hero before building past it.** When an approved comp exists, render the first viewport, capture it at the comp's own pixel dimensions, and set it beside the comp's first viewport before any later section: the hero carries the run's ambition, and every following section inherits its shortfall. Save that capture as `.impeccable/review/hero-repro.png` (create the directory); the finish reviewer verifies it exists, so a skipped checkpoint is a visible checkpoint. Judge scale and density as quantities, a field at a tenth of the comp's coverage or type at half its weight is a different design, and a five-minute retry here is what a rebuild verdict at the finish costs when this check is skipped.
- **Prove, don't claim.** Show the subject doing its job: the interface at work, the mechanism dramatized, specifics a competitor could not copy-paste. Sections that restate a claim in different words add length, not substance. Demonstration data is design material: author it at full fidelity and label it synthetic; claims stay uninventable.
- **Author the assets; never substitute chrome.** Great surfaces live on carefully made content: names, entries, copy, covers, thumbnails, textures. In greenfield work every blank the ask round left open is yours to author at production fidelity; content is authorable, claims are labelable, no section is omittable. An unanswered commercial claim ships as a clearly marked placeholder on the user's replacement list. When image generation exists, producing the design's imagery is part of building, at the scale the composition needs: a viewport that wants atmosphere gets a full-bleed layered scene, and a library of small centered subjects standardized for tidiness forecloses it. Gradients, glass, and generic icon tiles where an authored asset belongs are the gap wearing chrome; icons drawn in the world's own grammar are the remedy, not the target.
- **Build the form's web leverage.** When the chosen world names a technique (canvas, WebGL, view transitions, generative motion), build the technique itself, not a static imitation of it; the graceful fallback serves constrained clients, it is not the default experience.
- **Prove, don't claim.** Show the subject doing its job: the interface at work, the mechanism dramatized, specifics a competitor could not copy-paste. Demonstration data is design material: author it at full fidelity and label it synthetic; claims stay uninventable.
- **Author the assets; never substitute chrome.** Great surfaces live on carefully made content: names, entries, copy, covers, thumbnails, textures. In greenfield work every blank the ask round left open is yours to author at production fidelity; content is authorable, claims are labelable, no section is omittable. Gradients, glass, generic icon tiles, and many-vertex `clip-path` polygons where an authored asset belongs are the gap wearing chrome; the detector flags the last two.
- **Build the form's web leverage.** When the chosen world names a technique (canvas, WebGL, view transitions, generative motion), build the technique itself, not a static imitation of it.
- **Pace the scroll like a studio.** Vary density, scale, image, motion, and quiet inside one grammar; a dense passage earns a quiet one, and the page ends anchored by a real close. One spacing rhythm throughout, with more space above a heading than below it.
- **Use real, verified imagery when the brief implies it.** Search for the subject's physical object rather than the category; one decisive photo beats five mediocre ones. Verify stock URLs resolve.
- **Author motion as material.** The form has native motion, what it does in life between states; give the page that motion once, orchestrated, rather than scattered hover effects. Bound expensive effects and keep content visible by default.
- **Author motion as material.** Give the page the form's native motion once, orchestrated, rather than scattered hover effects. Bound expensive effects and keep content visible by default.
Preserve semantics, accessibility, performance, responsiveness, project conventions, and working behavior.
## 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. When an approved comp exists, the critique is a side-by-side: view the comp region and the build region together, the hero and each section as its own crop at legible scale, never one full-page thumbnail, which hides exactly the failures that matter, crude controls, wrong lettering character, flattened material, behind a superficially similar section order. 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 `node .agents/skills/impeccable/scripts/comp-diff.mjs --comp <approved 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.
After the second inspection round the build thread's polishing is over: no further defect hunts, micro-edit scripts, or rebuilds here; whatever remains ships through the handoffs, where a fresh context does the finding better and cheaper. On the web, where this harness runs no design hook, run `node .agents/skills/impeccable/scripts/detect.mjs --json` on the changed targets once here, fix what is mechanical, and pass the remaining findings to the reviewer; a hookless web build that skips this ships every tell the hook exists to catch. A native platform skips the detector entirely: it reads HTML and CSS and has no verdict on native code, so the reviewer's floor check is the only slop gate and the input packet says so. Capture the screenshots into `.impeccable/review/`, one file per captured viewport (on the web, `desktop.png` and `mobile.png`, plus `user-<width>.png` whenever the user's viewport joined the inspected set; on native, one per device class, such as `phone.png` and `tablet.png`, suffixed per OS on adaptive), creating that directory when the harness does not; the paths you pass the reviewer are its spec, every viewport you inspected is named required in the packet, and that directory is where it looks when a passed path is missing.
Then spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccable_finish_reviewer` in codex; `/impeccable-finish-reviewer` in Cursor; on GitHub Copilot say "Use the impeccable-finish-reviewer agent"), with the original request, confirmed answers, the artifact path, the screenshot paths, the direction contract, existing hook findings, the QUALITY BAR card and approved comp paths (a code-led build has no approved comp; the chosen decision comp rides in that slot as the critique reference, named as such), the craft-floor reference path, and on a native platform the platform reference path(s), [ios.md](ios.md) / [android.md](android.md), both on adaptive, plus one line saying no detector ran, so the reviewer judges in the platform's conventions rather than the web's. The reviewer has no browser; screenshots you fail to pass are checks it cannot run. Never read the shipped agents' definition files before spawning; the harness loads them at spawn, and you owe only the input packet. Wait on any agent with one long timeout rather than a loop of short polls, and spend the wait on the next independent step. Verify the return carries the five contract sections (a recapture return carries one, its recapture list); on an empty or thrashed return, respawn once with the same inputs. This review never runs inside the build thread and never inherits it: spawn the reviewer fresh, with no forked conversation history (`fork_turns: 0` in codex); a reviewer that inherits your transcript inherits your framing, your optimism, and your abstractions, and everything it needs travels in the inputs above. Only a harness with no subagent capability at all substitutes a fresh in-thread pass after stepping fully out of the build context, run from [degraded/finish-reviewer.md](degraded/finish-reviewer.md), and a substituted or failed-and-replaced review is disclosed in one line at finish, never silently.
Then spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccable_finish_reviewer` in codex; `/impeccable-finish-reviewer` in Cursor; on GitHub Copilot say "Use the impeccable-finish-reviewer agent"), with the original request, confirmed answers, the artifact path, the screenshot paths, the direction contract, existing hook findings, the QUALITY BAR card and approved comp paths (a code-led build has no approved comp; the chosen decision comp rides in that slot as the critique reference, named as such), on a comp-led build the build state (`.impeccable/build/state.json`), the spec, and the diff directories (`.impeccable/review/diff/hero/` and `.impeccable/review/diff/final/`, whose side-by-side, heatmap, region pairs, and `report.json` are the fidelity evidence), the craft-floor reference path, and on a native platform the platform reference path(s), [ios.md](ios.md) / [android.md](android.md), both on adaptive, plus one line saying no detector ran, so the reviewer judges in the platform's conventions rather than the web's. The reviewer has no browser; screenshots you fail to pass are checks it cannot run. Never read the shipped agents' definition files before spawning; the harness loads them at spawn, and you owe only the input packet. Wait on any agent with one long timeout rather than a loop of short polls, and spend the wait on the next independent step. Verify the return carries the five contract sections (a recapture return carries one, its recapture list); on an empty or thrashed return, respawn once with the same inputs. This review never runs inside the build thread and never inherits it: spawn the reviewer fresh, with no forked conversation history (`fork_turns: 0` in codex); a reviewer that inherits your transcript inherits your framing, your optimism, and your abstractions, and everything it needs travels in the inputs above. Only a harness with no subagent capability at all substitutes a fresh in-thread pass after stepping fully out of the build context, run from [degraded/finish-reviewer.md](degraded/finish-reviewer.md), and a substituted or failed-and-replaced review is disclosed in one line at finish, never silently.
Act on the disposition word; there are exactly four. **recapture**: the evidence failed, not the build. Recapture what the return names under the capture-validity rules, then run a full review over the new evidence. A review conducted on invalid evidence binds nothing, and a verdict pass may never follow it. **rebuild**: fidelity failed wholesale, not in patches. Skip the fix batch and execute the rebuild immediately: re-derive the named regions, produce the named assets, and send the result back for a fresh full review, never a verdict pass; a rebuild replaces regions wholesale, so the whole matrix runs again over the recaptures. Tell the user what is happening rather than asking permission to fix a failure. Consult the user only on a second rebuild directive, both verdicts on the table, or when rebuilding would discard content the user approved. **ship**: nothing is owed; report the verdict at its scope and continue to the documenter. **fix**: apply the material fixes in one batch, rebuild once, and recapture the same viewports over the same files. A recapture measures positions, loading, and overflow; it cannot measure whether a fix reached the quality the finding named, so send the recaptured screenshots back to the same reviewer for a verdict scoring every material fix resolved, partial, or unresolved (through the harness's agent continuation; without one, run the scoring fresh from [degraded/finish-reviewer.md](degraded/finish-reviewer.md)'s Verdict Pass). Fixes scored partial or unresolved get another batch, recapture, and verdict. Two rounds is the budget an unattended run ends at; an attended session's ceiling belongs to the user, so when the second verdict still lists open items, put the table in front of them and let them choose between shipping as it stands and funding another round. Whoever decides, stop the moment a round resolves nothing, and the reviewer's findings are the only list you work from, never your own re-opened hunt. Do not run a second detector.
+10 -2
View File
@@ -29,10 +29,10 @@ Use the feature yourself at the surface's representative sizes: desktop and mobi
If a prior critique exists, use it as one input:
```bash
node .agents/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>"
node .agents/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>" --json
```
Exit 0 returns the latest snapshot; incorporate relevant P0/P1 findings and name the snapshot read. Exit 2 means none exists. Perform an independent pass either way.
Exit 0 returns JSON with the latest snapshot's `body` and an exact `snapshot_file` identity. Retain `snapshot_file` until the end of the pass. For a local file target, the helper compares the file's exact current content fingerprint with the fingerprint captured by critique. Unchanged staged, unstaged, or untracked content remains current; any byte change, deletion, or replacement with a non-file closes the backlog it identified while preserving its trend history and exits 2. A URL target has no local fingerprint and remains current until explicitly closed. When current, incorporate relevant P0/P1 findings from `body` and name the snapshot read. Exit 2 means none exists or the target changed. Perform an independent pass either way.
## 3. Triage
@@ -95,3 +95,11 @@ Walk the complete path again with mouse, keyboard, and touch where applicable. C
Follow the quality guidance supplied by `context.mjs` and hooks, then run any other relevant QA commands. Context requests a manual scan only when no automatic detector is active; never add another detector pass. Fix real defects and document only narrow intentional exceptions. A clean scan does not replace visual judgment.
Finish with a source diff: remove accidental churn, orphaned code, redundant values, and temporary artifacts. Ship only when the feature is functionally complete and consistently finished across the path.
When this pass clears every Priority Issue it took from a snapshot, close that snapshot:
```bash
node .agents/skills/impeccable/scripts/critique-storage.mjs close "<resolved target>" "<snapshot_file returned by latest>"
```
This closes only the snapshot this pass actually processed; if a newer critique landed meanwhile, its backlog stays live. Do not close when no snapshot was read, when `snapshot_file` was not retained, or when Priority Issues remain.
@@ -8,7 +8,7 @@ Reason over the signals; there is no score to obey:
- `setup.hasDesign` false while `setup.hasCode` true → `document` (capture the visual system).
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `$impeccable critique <surface>` is a strong default.
- `critique.latest` with a low `score` or non-zero `p0` / `p1``polish` (it reads that snapshot as its backlog), or re-run `critique` if the snapshot looks stale.
- `critique.latest` with a low `score` or non-zero `p0` / `p1``polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `detect.mjs` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
@@ -6,6 +6,8 @@ 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 <seed key> --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. `generate-image.mjs` 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 `generate-image.mjs --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.
- A comp is a designed surface, not a picture of the subject. Lead the prompt with the surface's own structure: the regions this design has, named in order with their scale relationships; a page with no navigation says so instead of inventing one, and an unconventional surface states its unconventional skeleton. A prompt that leads with atmosphere gets a vignette back: the model paints the fish market instead of the fish market's website. Self-check every render: if it could hang as a poster, or reads as a photograph with some text on it, it is not a comp; regenerate with the layout scaffold stated more literally.
@@ -27,30 +29,18 @@ 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 `generate-image.mjs` 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. 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 `generate-image.mjs` 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.
## Inventory implementation fidelity
## After approval: the comp becomes a spec
Before building, read the approved comp as a design system and record it in the brief: component grammar, corner language, line weights, elevation treatment, and the type ramp. Everything the comp does not show gets built from this record; without it the fallback is the model's stock kit of square boxes, 1px grids, bento cells, and hard shadows. Then inventory the comp's major visible ingredients in writing (a short table in the surface brief or working notes; the finish reviewer audits shipped assets against it) and choose an implementation medium for each: semantic HTML/CSS/SVG, existing project asset, generated raster, sourced raster, icon library, canvas/WebGL, or accepted omission. The same inventory names the comp's compositional commitments: navigation items and icons, headline levels and their scale relationship, signature geometry such as seams, masks, and overlaps, and each section's arrangement and density. The primary action gets its own row with its own medium: when the comp dissolves, stamps, erodes, or otherwise physically works the main CTA, that treatment is signature material on the page's most important element, and shrinking it to a border trick is the compliance-token version of commitment. An element never written down is the element the build silently drops; the direction contract's 150 words cannot carry this list, so it lives here.
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.
The record is sampled, never estimated: read the comp's page **ground**, each dominant field, and each accent's actual hex from its pixels (ImageMagick, Python with PIL, any pixel-reading tool on the machine) and write the values into the same record. Take a flat field from any interior pixel, a textured or grainy one as the average of an interior patch (crop a swatch, scale it to one pixel), and a gradient as its two end colors; never sample an edge, where antialiasing blends neighbors into colors the design never chose. An adjective is a direction, not a record: cream covers everything from near-white to beige, charcoal a third of the value scale, and wherever no number pins a color, the rendition prior picks the spot. Sampled values supersede the palette chips on the decision and composition cards: those were authored before this comp existed, and a chip that disagrees with the comp's pixels is a draft the approval retired.
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.
The medium column is where an approved design most often dies, so it obeys a gate: the medium is decided by what the comp region shows, never by what feels buildable in the current stack. A human figure, a product object, machinery, or any material with lighting and depth is raster whatever the stack; so is any texture by name alone: woven cloth, paper grain, fabric, leather, brushed metal need no depth argument, because a CSS gradient is not a texture medium and "layered CSS textures" is not a medium at all. Writing "silhouette" for a photographic figure, or "CSS" for a sculpted panel's finish, is not a medium choice; it is the quiet deletion of the approved design, and it is how a comp full of physical material becomes a flat page with the same section order. Style does not move this boundary: a comp region with perspective, shading, figure drawing, or dense mechanical detail is illustration however line-drawn it looks, and no build session can author illustration as vectors, so it regenerates as raster like any photograph. Authored SVG covers what a session can specify exactly (diagrams with countable elements, controls, flat shape systems) and ends where drawing skill begins; an instruction-manual world keeps its illustrations as line-art illustrations, not diagrams. Produce such regions by regenerating them cleanly, with the approved comp and its embedded prompt as the reference for a fresh render at asset resolution; never crop pixels out of the comp itself, whose effective resolution sits far below asset grade. 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; "no photography on hand" forbids fake proof, not an illustrated hero.
## Plates and provenance
The gate runs both ways: precise geometry, hard-edged shape systems, diagrams, expressive motion, shaders, and anything interactive are vector and GPU territory (SVG, canvas, WebGL), where a raster flattens what should move, scale, and respond. A field or texture built from many small elements carries a quantity commitment either way: write down its approximate density and coverage ("thousands of glyphs over two-thirds of the fold, dense at the top fading into the path"), because a field rebuilt at a tenth of its density passes every checklist and still is not the design. TYPE rows carry the same discipline: name the face's compression class, and render one headline word against the comp before building on it; a visibly wider or lighter silhouette means the face is wrong, and every section built on it inherits the miss. Raster is for what the world paints; code is for what the world draws, animates, or reacts with, and choosing code there is ambition, not economy. Every `produce` entry is produced before the build ships, through the asset producer or in the current thread; an inventory with unproduced entries is an unfinished build, and this gate is where imagery-free pages come from when it is skipped.
Pay special attention to the dominant composition, signature use, image-native content, second-fold system, and any interaction the still image only implies.
The comp is a north star, not something to trace, and know what that allows: translation into semantic, responsive, accessible code, never recomposition. Keeping the palette and mood while redrawing the topology is a second art direction, not an adaptation. Do not rasterize core UI text or controls. Do not substitute a different visual driver after approval without asking.
## Produce only the assets the build needs
Generation context is part of the asset: a build composed by a thread that never saw the prompts places assets it does not understand. Prefer generating build-critical imagery in the build thread when the budget allows; when a subagent produces assets instead, every asset carries its prompt, and the builder reads those prompts before composing. The carrier is uniform across harnesses: after generating any image with any tool, native or `generate-image.mjs` (which does it automatically), run `node .agents/skills/impeccable/scripts/embed-prompt.mjs <image> --prompt "<prompt>"` with the exact string the generation tool received, pasted whole, so the intent lives inside the file and survives copies between machines and harnesses; a summary reconstructed from memory records an asset that was never made. `--read` recovers the prompt from any impeccable-generated image, and `--scan <dir>` lists every raster in a directory still missing one. The embedded prompt plus the asset's row in the written inventory is the raster's **provenance**, and every raster the artifact references carries it; a sourced, stock, or pre-existing raster with no generation prompt embeds its origin instead.
Provenance is owed for the run, not the build phase: a raster created or replaced later, in a fix batch or a reviewer's rebuild, is produced under this same section, prompt embedded and inventory row added, because the inventory is how the next thread knows what ships. A raster a fix abandons or supersedes is deleted from the assets directory in the same batch; an unreferenced raster with no record is a provenance leak, not a spare.
When the harness runs subagents, spawn the shipped asset producer every time, even when the inventory's produce bucket looks empty: its manifest is the independent second opinion on your media, and the runs that skipped the spawn are the runs whose cotton became CSS. An honestly empty manifest costs one cheap spawn; a wrongly empty produce bucket costs the build its materials. Use `impeccable-asset-producer` (`impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent"): give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Without subagents, produce the minimum required assets in the current thread by the book: load [degraded/asset-producer.md](degraded/asset-producer.md) and follow it inline, with whatever generation exists.
Every raster region's plate is produced in the plates phase, before any page code, by the shipped asset producer or in the current thread (`generate-image.mjs --plate <id>`, or the harness image tool with the crop as input and the spec's plate prompt). Generation context is part of the asset: after generating any image with any tool, run `node .agents/skills/impeccable/scripts/embed-prompt.mjs <image> --prompt "<prompt>"` with the exact string the tool received (`generate-image.mjs` does this itself), so the intent lives inside the file; `--read` recovers it, `--scan <dir>` lists rasters still missing one. The embedded prompt plus the region's row in the spec is the raster's **provenance**, and every raster the artifact references carries it; a sourced, stock, or pre-existing raster embeds its origin instead. A raster created or replaced later, in a fix batch or a reviewer's rebuild, is produced the same way; a raster a fix abandons is deleted in the same batch.
Convert images with a converter context.mjs reported at boot (the IMAGE_TOOLS line); probe only when it reported none, at most once per session, never per image.
Return to [new-work.md](new-work.md) for the direction contract, implementation, and the finishing pass.
Return to [new-work.md](new-work.md) for the direction contract, the phased build, and the finishing pass.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,391 @@
#!/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/<id>.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 <png> --build <png> [--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();
@@ -0,0 +1,513 @@
#!/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 <comp> --regions regions.json
* regions.json: { "regions": [ { "id": "exploded-plate", "kind": "plate",
* "grid": "E0:J4", "note": "exploded carburetor line drawing" }, ... ] }
* `grid` is "<colrow>:<colrow>" 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 <colrow>:<colrow>, 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 <png> --grid write .impeccable/build/comp-grid.png (10x10 labeled grid) + palette + bands
comp-spec.mjs --comp <png> --regions <json> 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 <png> --auto band regions when you have no regions file
comp-spec.mjs --print the compact spec
comp-spec.mjs --crop <id> [--out f] [--scale n] reference crop of a region (never a shipping asset)
comp-spec.mjs --plate-prompt <id> 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 <png> --regions <json> 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 <png> (--grid | --regions <json> | --auto) [--spec out.json]\n comp-spec.mjs --print | --crop <id> [--out file] [--scale n] | --plate-prompt <id>');
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: <colrow>:<colrow>, 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 <json> (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();
@@ -90,7 +90,8 @@
*/
import crypto from 'node:crypto';
import { dirname, join, resolve } from 'node:path';
import { dirname, join, relative, resolve } from 'node:path';
import { readFileSync, realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
approvedPoolRevision,
@@ -613,16 +614,22 @@ rivals to your habitual layout, and keep only what makes this product clearer.${
assignment by deal order, so the dice still choose. Verdicts and donations
apply between the challengers, weighed against the leader. The pick card
sits out; the canon stays, as always.`;
const telemetryBlock = data.source === 'api'
? `TELEMETRY: after the user's choice resolves, rerun this script once with
--kind <assigned|pick|challenger|canon> --from ${key} --scope ${scope}${mode ? ` --mode ${mode}` : ''},
adding --chosen <challenger-id> when a dealt challenger won and keeping
--register <safer|bolder> when the resolved round came from a steered hand.
One ping per resolved attended round. The ping is anonymous, the card kind
plus the catalog id when one won; your grounded candidates' names never
leave the machine, and the ping is skipped automatically when DO_NOT_TRACK
or IMPECCABLE_NO_TELEMETRY is set.\n`
: '';
// The one command that follows a resolved choice. It records the choice
// (anonymous telemetry on API-dealt rolls; skipped under DO_NOT_TRACK /
// IMPECCABLE_NO_TELEMETRY) and opens the build's phase machine, whose
// first gate is the comp round on a comp-led build. Every run that skipped
// the comp round did so by treating a separate "telemetry ping" as
// bookkeeping: suppressed with >/dev/null, run after the page was written,
// or never run. So there is no separate ping; the start command is the
// ping, and it is not optional.
const nextCommand = scope === 'direction'
? `AFTER THE CHOICE, run exactly one command and follow what it prints (do not suppress its output; do not write page code before it):
node ${relative(process.cwd(), here) || '.'}/build-phase.mjs start --direction ${key} --kind <assigned|pick|challenger|canon>${data.source === 'api' ? ' [--chosen <challenger-id>]' : ''}${register ? ` --register ${register}` : ''}
It records the choice${data.source === 'api' ? ' (anonymous: card kind plus catalog id; skipped under DO_NOT_TRACK / IMPECCABLE_NO_TELEMETRY)' : ''} and opens the build phases: on a comp-led build the comp round is the first gate (three comps, one approved) and no page code is written before it closes; on a code-led build it prints the contract step. A build without this state file is a build the finish reviewer treats as having skipped the round.\n`
: (data.source === 'api'
? `AFTER THE CHOICE, run once: node ${relative(process.cwd(), here) || '.'}/concept-seed.mjs --kind <assigned|pick|challenger|canon> --from ${key} --scope ${scope}${mode ? ` --mode ${mode}` : ''} (records the choice; the locked card's comp is the approved comp, so then: node ${relative(process.cwd(), here) || '.'}/build-phase.mjs start --comp <that comp>).\n`
: `AFTER THE CHOICE: the locked card's comp is the approved comp; run node ${relative(process.cwd(), here) || '.'}/build-phase.mjs start --comp <that comp> and follow what it prints.\n`);
const telemetryBlock = nextCommand;
const assignedBlock = register === null
? `${scope === 'direction' ? `ASSIGNED INDEX: ${buildIndex}` : `DEALT INDICES: ${dealtIndices.join(', ')} (index ${buildIndex} leads)`}
${promotedInstruction}
@@ -666,7 +673,58 @@ ${restated}
`;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
/**
* What the model must do next, once a direction (or surface structure) is
* chosen. Read from the same config the boot directive reads:
* `.impeccable/config.local.json` over `.impeccable/config.json`,
* `buildPath` comp|code; with neither, comp-led whenever image generation
* exists (an OpenAI key here; a harness-native image tool is invisible to
* this script, so the text names it too), code-led otherwise.
*/
export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = process.env } = {}) {
let buildPath = null;
for (const name of ['config.json', 'config.local.json']) {
try {
const raw = JSON.parse(readFileSync(resolve(cwd, '.impeccable', name), 'utf8'));
if (raw?.buildPath === 'comp' || raw?.buildPath === 'code') buildPath = raw.buildPath;
} catch { /* absent */ }
}
const scriptsDir = dirname(fileURLToPath(import.meta.url));
const scripts = relative(cwd, scriptsDir) || '.';
const imageGen = !!env.OPENAI_API_KEY;
const seed = key ? ` --direction ${key}` : '';
if (buildPath === 'code') {
return `NEXT (code-led, from .impeccable config): write the direction contract, then build; no comp round. Load reference/new-work.md section 5 and 6.\n`;
}
const why = buildPath === 'comp' ? 'from .impeccable config' : imageGen ? 'default: image generation is available' : 'default: comp-led unless no image tool exists; if your harness truly has none and there is no OpenAI key, this is code-led and you say so in one line';
if (scope === 'surface') {
return `NEXT (comp-led, ${why}): the locked card's comp is the approved comp. Run: node ${scripts}/build-phase.mjs start --comp <that comp> and follow its NEXT lines. Do not write page code before build-phase.mjs advance has closed the spec, plates, and hero gates.\n`;
}
return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`;
}
export function sameMainModulePath(left, right, platform = process.platform) {
if (platform !== 'win32') return left === right;
const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`);
return normalizeDriveLetter(left) === normalizeDriveLetter(right);
}
function isMainModule() {
if (!process.argv[1]) return false;
try {
// Node resolves import.meta.url through symlinks but leaves argv[1] as the
// invoked path. Compare real paths so a linked skill still runs its CLI,
// normalizing the drive-letter casing that Windows junctions can change.
return sameMainModulePath(
realpathSync(process.argv[1]),
realpathSync(fileURLToPath(import.meta.url))
);
} catch {
return false;
}
}
if (isMainModule()) {
const args = process.argv.slice(2);
const fromIdx = args.indexOf('--from');
const scopeIdx = args.indexOf('--scope');
@@ -692,7 +750,27 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
register: registerIdx !== -1 ? args[registerIdx + 1] : undefined,
});
process.stdout.write(sent ? 'choice recorded\n' : 'choice ping skipped\n');
// The choice is resolved; this is the last script output the model
// reads before it decides what to do next, and every run that skipped
// the comp round did so right here: prose 20 KB into new-work.md lost
// to "direction locked, building now". So the ping prints the next
// mandatory step from the recorded build path, and the phase machine
// takes it from there.
process.stdout.write(nextStepAfterChoice({
key: fromIdx !== -1 ? args[fromIdx + 1] : undefined,
scope: scopeIdx !== -1 ? args[scopeIdx + 1] : undefined,
}));
} else {
// A dealt roll leaves a marker the build phase clears: context.mjs and
// detect.mjs read it and refuse to treat page work as done while a
// direction is chosen but the build never started (COMP_ROUND_OPEN).
try {
const { mkdirSync, writeFileSync: wf } = await import('node:fs');
if (scopeIdx !== -1 && args[scopeIdx + 1] === 'direction') {
mkdirSync(resolve(process.cwd(), '.impeccable', 'build'), { recursive: true });
wf(resolve(process.cwd(), '.impeccable', 'build', 'pending.json'), JSON.stringify({ scope: 'direction', at: new Date().toISOString() }, null, 2));
}
} catch { /* marker is best-effort */ }
// Mechanical init gate: prose alone does not keep a model from dealing
// before init, and fresh repos produced exactly that skip (the model
// rolled directions with no PRODUCT.md, so nothing grounded the fusion).
+44 -3
View File
@@ -1013,6 +1013,27 @@ async function fetchLatestSkillVersion() {
}
}
// Destroy fetch's global undici dispatcher before process.exit(): a live
// keep-alive socket trips a libuv assertion on Windows/Node 24 after a
// successful boot (nodejs/node#56645, issue #573).
async function destroyFetchDispatcher() {
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
if (dispatcher && typeof dispatcher.destroy === 'function') {
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
}
}
// Drain the boot payload before process.exit(): a live pipe that has not
// flushed yet is truncated when Node tears down (issue #573 review). Then
// close fetch so Windows teardown does not abort on the keep-alive socket.
async function finishCli(output) {
await new Promise((resolve) => {
process.stdout.write(output, () => resolve());
});
await destroyFetchDispatcher();
process.exit(0);
}
// Two instructions used to sit in one directive: ask, and "if they agree, run
// it". Nothing gated the second on an answer, and the same sentence said to
// continue without waiting, so a run that could never establish agreement was
@@ -1151,6 +1172,7 @@ async function cli() {
appendDetectorFallback(parts, ctx);
appendImageGenDirective(parts);
appendBuildPathDirective(parts, ctx);
await appendCompRoundOpenDirective(parts, ctx);
appendAutonomyCounterDirective(parts);
appendSubagentAuthorizationDirective(parts);
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
@@ -1159,8 +1181,7 @@ async function cli() {
appendImageToolsDirective(parts);
appendStalenessDirective(parts, ctx, cliOptions);
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
process.exit(0);
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
if (ctx.hasDesign) {
@@ -1171,6 +1192,7 @@ async function cli() {
appendDetectorFallback(parts, ctx);
appendImageGenDirective(parts);
appendBuildPathDirective(parts, ctx);
await appendCompRoundOpenDirective(parts, ctx);
appendAutonomyCounterDirective(parts);
appendSubagentAuthorizationDirective(parts);
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
@@ -1206,7 +1228,7 @@ async function cli() {
}
}
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
await finishCli(parts.join('\n\n---\n\n') + '\n');
}
function parseCliOptions(args) {
@@ -1309,6 +1331,25 @@ function readBuildPathAt(root) {
// selecting another workspace, cwd is the caller's app, not the target's, and
// letting it rank above the repo root hands one workspace another's workflow.
// It stands in only when no project resolved at all.
// A direction was dealt for a comp-led build and the phase machine never
// started, or stopped short of the hero gate: the comp round is open. Said
// here because every model in the corpus ran context.mjs unprompted, and
// the run that skipped the round did so between the roll and the first
// write; a boot that names the open round is a boot the write cannot claim
// it never saw. Reads build-phase's own helper so the two agree.
async function appendCompRoundOpenDirective(parts, ctx) {
try {
const { compRoundOpen } = await import('./build-phase.mjs');
const roots = [...new Set([ctx?.projectRoot || process.cwd(), ctx?.repoRoot].filter(Boolean).map((r) => path.resolve(r)))];
for (const root of roots) {
const open = compRoundOpen(root);
if (!open) continue;
parts.push(`COMP_ROUND_OPEN: ${open.reason}. On a comp-led build no page code is written before build-phase.mjs closes the comps, spec, plates, and hero gates; run \`node ${path.dirname(fileURLToPath(import.meta.url))}/build-phase.mjs status\` and follow its NEXT line. A page written past an open round is what the finish reviewer sends back.`);
return;
}
} catch { /* build-phase absent: nothing to say */ }
}
function appendBuildPathDirective(parts, ctx) {
const roots = [...new Set(
[ctx?.projectRoot || process.cwd(), ctx?.repoRoot].filter(Boolean).map((root) => path.resolve(root)),
@@ -16,8 +16,9 @@
* CLI entry points (called from skill instructions):
* node critique-storage.mjs slug <resolved-target>
* node critique-storage.mjs write <slug> <snapshot-body-file>
* node critique-storage.mjs latest <slug>
* node critique-storage.mjs latest <slug> [--json]
* node critique-storage.mjs trend <slug> [limit]
* node critique-storage.mjs close <resolved-target> <snapshot-file>
*
* Note: there is intentionally no `ignore` subcommand. ignore.md is a plain
* markdown file; the model reads it directly with its file-read tool. This
@@ -27,6 +28,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
import { slugFromTarget } from './lib/target-slug.mjs';
@@ -50,6 +52,45 @@ export function nowFilenameStamp(date = new Date()) {
return iso.replace(/[:.]/g, '-').replace(/-\d+Z$/, 'Z');
}
/**
* Return an exact content fingerprint for a local file target. URLs and
* non-files return null because their content is not available here.
*
* The fingerprint deliberately describes bytes, not Git state or mtimes:
* critique often assesses an uncommitted file, and a later polish run should
* inherit that backlog when the bytes are unchanged regardless of staging.
*/
function resolveLocalTargetPath(target, { cwd = process.cwd() } = {}) {
if (!target || /^https?:\/\//i.test(target)) return null;
return path.isAbsolute(target) ? path.resolve(target) : path.resolve(cwd, target);
}
function resolveTargetIdentity(target, { cwd = process.cwd() } = {}) {
if (!target || typeof target !== 'string') return null;
if (/^https?:\/\//i.test(target)) {
try {
const url = new URL(target);
const pathname = url.pathname.replace(/\/+$/, '') || '/';
return `url:${url.origin}${pathname}`;
} catch {
return null;
}
}
const filePath = resolveLocalTargetPath(target, { cwd });
return filePath ? `file:${filePath}` : null;
}
export function fingerprintTarget(target, { cwd = process.cwd() } = {}) {
const filePath = resolveLocalTargetPath(target, { cwd });
if (!filePath) return null;
try {
if (!fs.statSync(filePath).isFile()) return null;
return `sha256:${createHash('sha256').update(fs.readFileSync(filePath)).digest('hex')}`;
} catch {
return null;
}
}
/**
* Write a snapshot for `slug`. `meta` carries the small structured frontmatter
* keys read back by readTrend(). `body` is the human-readable critique
@@ -62,14 +103,27 @@ export function writeSnapshot({ slug, meta, body, cwd = process.cwd(), now = new
const dir = getCritiqueDir(cwd);
fs.mkdirSync(dir, { recursive: true });
const timestamp = nowFilenameStamp(now);
const filePath = path.join(dir, `${timestamp}__${slug}.md`);
// Spread `meta` first so internally computed `timestamp` and `slug`
// always win. Otherwise a caller-supplied meta blob (parsed from the
// IMPECCABLE_CRITIQUE_META env var) could clobber them, leaving the
// filename in disagreement with its frontmatter and corrupting trends.
const front = serializeFrontmatter({ ...meta, timestamp, slug });
fs.writeFileSync(filePath, `${front}\n${body.trim()}\n`, 'utf-8');
return filePath;
const contents = `${front}\n${body.trim()}\n`;
// A second critique can finish in the same UTC second. Use exclusive
// creation and a fixed-width suffix so concurrent writers cannot replace
// history and lexical ordering still keeps collision entries newest.
for (let collision = 0; collision <= 9999; collision += 1) {
const suffix = collision === 0 ? '' : `~${String(collision).padStart(4, '0')}`;
const filePath = path.join(dir, `${timestamp}${suffix}__${slug}.md`);
try {
fs.writeFileSync(filePath, contents, { encoding: 'utf-8', flag: 'wx' });
return filePath;
} catch (error) {
if (error?.code !== 'EEXIST') throw error;
}
}
throw new Error(`Too many critique snapshots for ${slug} at ${timestamp}`);
}
function serializeFrontmatter(obj) {
@@ -98,6 +152,8 @@ function parseFrontmatter(text) {
try { value = JSON.parse(value); } catch { /* leave as-is */ }
} else if (/^-?\d+$/.test(value)) {
value = Number(value);
} else if (value === 'true' || value === 'false') {
value = value === 'true';
}
out[key] = value;
}
@@ -107,7 +163,7 @@ function parseFrontmatter(text) {
/**
* Return snapshot files matching `suffix`, sorted oldest newest.
*/
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/;
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z(?:~\d{4})?__.+\.md$/;
function listSnapshots(suffix, cwd) {
const dir = getCritiqueDir(cwd);
@@ -118,24 +174,105 @@ function listSnapshots(suffix, cwd) {
.map((f) => path.join(dir, f));
}
function readLatestSnapshotMatching(suffix, cwd) {
const filePath = listSnapshots(suffix, cwd).at(-1);
function readSnapshot(filePath) {
if (!filePath) return null;
const body = fs.readFileSync(filePath, 'utf-8');
return { path: filePath, body, meta: parseFrontmatter(body) };
}
function snapshotTargetIdentity(snapshot) {
const targetPath = snapshot?.meta.target_path;
return snapshot?.meta.target_identity
|| (targetPath ? `file:${targetPath}` : null);
}
function readNewestSnapshot(slug, { cwd = process.cwd() } = {}) {
return readSnapshot(listSnapshots(`__${slug}.md`, cwd).at(-1));
}
function readNewestSnapshotForIdentity(
slug,
targetIdentity,
{ cwd = process.cwd() } = {},
) {
const matches = listSnapshots(`__${slug}.md`, cwd)
.map(readSnapshot)
.filter((snapshot) => snapshotTargetIdentity(snapshot) === targetIdentity);
return matches.at(-1) || null;
}
/**
* Return the most recent snapshot for `slug`, or null. Polish reads this
* to find its fix backlog when the slug matches.
*/
export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
return readLatestSnapshotMatching(`__${slug}.md`, cwd);
const latest = readNewestSnapshot(slug, { cwd });
return latest?.meta.closed === true ? null : latest;
}
/**
* Mark one exact snapshot closed without deleting the score history consumed
* by `trend`. Exact identity matters: a newer critique may land after polish
* reads its backlog, and that newer snapshot must remain live. `snapshotFile`
* may be the absolute path returned by readLatestSnapshot() or the basename
* emitted by `latest --json`. Returns the path marked closed, or null.
*/
export function closeSnapshot(snapshotFile, { cwd = process.cwd() } = {}) {
if (!snapshotFile || typeof snapshotFile !== 'string') return null;
const dir = path.resolve(getCritiqueDir(cwd));
const snapshotPath = path.isAbsolute(snapshotFile)
? path.resolve(snapshotFile)
: path.resolve(dir, snapshotFile);
const filename = path.basename(snapshotPath);
if (
path.dirname(snapshotPath) !== dir
|| !SNAPSHOT_FILENAME.test(filename)
) return null;
let snapshot;
try {
if (!fs.lstatSync(snapshotPath).isFile()) return null;
snapshot = readSnapshot(snapshotPath);
} catch {
return null;
}
if (!snapshot || snapshot.meta.closed === true) return null;
const closedBody = snapshot.body.replace(
/^(---\r?\n[\s\S]*?)(\r?\n---)/,
'$1\nclosed: true$2',
);
if (closedBody === snapshot.body) {
throw new Error(`Cannot close snapshot without frontmatter: ${snapshot.path}`);
}
fs.writeFileSync(snapshot.path, closedBody, 'utf-8');
return snapshot.path;
}
/** Return the most recent snapshot across all targets, or null. */
export function readLatestSnapshotAcrossTargets({ cwd = process.cwd() } = {}) {
return readLatestSnapshotMatching('.md', cwd);
const snapshots = listSnapshots('.md', cwd).map(readSnapshot);
const identifiedSlugs = new Set(
snapshots
.filter((snapshot) => snapshotTargetIdentity(snapshot))
.map((snapshot) => snapshot.meta.slug),
);
const latestByTarget = new Map();
for (const snapshot of snapshots) {
if (!snapshot?.meta.slug) continue;
// Slugs are lossy: distinct targets such as foo/bar and foo-bar can share
// one. Keep each known identity's latest open/closed state independent so
// closing one target cannot hide another target's live backlog. Once a
// slug has any identity-aware snapshot, its older legacy records are no
// longer independently routable and must not resurface as zombie work.
const targetIdentity = snapshotTargetIdentity(snapshot);
if (!targetIdentity && identifiedSlugs.has(snapshot.meta.slug)) continue;
const streamKey = targetIdentity || `slug:${snapshot.meta.slug}`;
latestByTarget.set(streamKey, snapshot);
}
return [...latestByTarget.values()]
.filter((snapshot) => snapshot.meta.closed !== true)
.sort((a, b) => a.path.localeCompare(b.path))
.at(-1) || null;
}
/**
@@ -153,9 +290,13 @@ export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
// Accept either a ready slug or a concrete target (path/URL) everywhere, so
// callers never have to run the slug step separately. Anything containing a
// path or URL marker is resolved through slugFromTarget.
function isReadySlug(value) {
return /^[a-z0-9-]+$/.test(value || '') && !value.includes('/');
}
function coerceSlug(value) {
if (!value) return null;
if (/^[a-z0-9-]+$/.test(value) && !value.includes('/')) return value;
if (isReadySlug(value)) return value;
return slugFromTarget(value);
}
@@ -181,14 +322,124 @@ function main(argv) {
if (metaArg) {
try { meta = JSON.parse(metaArg); } catch { /* ignore */ }
}
// The helper, not caller-provided metadata, owns the target fingerprint.
// This makes the snapshot describe the exact file bytes critique saw.
delete meta.target_fingerprint;
delete meta.target_path;
delete meta.target_identity;
const targetIdentity = resolveTargetIdentity(slugArg);
if (targetIdentity) meta.target_identity = targetIdentity;
const targetFingerprint = fingerprintTarget(slugArg);
if (targetFingerprint) {
meta.target_fingerprint = targetFingerprint;
meta.target_path = resolveLocalTargetPath(slugArg);
}
const out = writeSnapshot({ slug, meta, body: raw });
process.stdout.write(`${out}\n`);
return;
}
case 'latest': {
const latest = readLatestSnapshot(coerceSlug(args[0]));
if (!latest) { process.exit(2); }
process.stdout.write(latest.body);
const target = args[0];
const format = args[1];
const slug = coerceSlug(target);
if (!slug || (format && format !== '--json')) {
process.stderr.write('usage: latest <slug-or-target> [--json]\n');
process.exit(1);
}
const targetFingerprint = fingerprintTarget(target);
const targetPath = resolveLocalTargetPath(target);
const targetIdentity = resolveTargetIdentity(target);
const readySlug = isReadySlug(target);
const newestForSlug = readNewestSnapshot(slug);
if (!newestForSlug) { process.exit(2); }
// Concrete targets select the newest snapshot for their exact identity,
// not merely the newest filename for a lossy slug. This keeps distinct
// targets such as foo/bar and foo-bar from hiding each other's backlog.
const exactSnapshot = readNewestSnapshotForIdentity(slug, targetIdentity);
let latest = exactSnapshot;
if (!latest && !readySlug) {
// Legacy snapshots have no identity. Preserve their old explicit
// path/URL behavior only when no known target identity was selected.
latest = readNewestSnapshotForIdentity(slug, null);
}
if (!latest) latest = newestForSlug;
if (latest.meta.closed === true) { process.exit(2); }
const recordedTargetPath = latest.meta.target_path;
const recordedTargetIdentity = snapshotTargetIdentity(latest);
const matchingIdentity = recordedTargetIdentity === targetIdentity;
// Bare slugs remain a supported lookup mode, including for URL
// snapshots. But when a same-named local file exists, the request is
// ambiguous unless that exact file owns the snapshot identity.
if (readySlug && !recordedTargetIdentity) {
process.stderr.write(
'ambiguous legacy snapshot target; use an explicit ./path or full URL\n',
);
process.exit(2);
}
if (readySlug && targetPath && fs.existsSync(targetPath) && !matchingIdentity) {
process.stderr.write(
'ambiguous snapshot slug; use an explicit ./path or remove the local name collision\n',
);
process.exit(2);
}
const concreteTarget = !readySlug || matchingIdentity;
if (concreteTarget && recordedTargetIdentity && !matchingIdentity) {
process.exit(2);
}
const concreteLocalTarget = concreteTarget && targetPath;
if (concreteLocalTarget && latest.meta.target_fingerprint !== targetFingerprint) {
closeSnapshot(latest.path);
process.exit(2);
}
if (format === '--json') {
process.stdout.write(JSON.stringify({
snapshot_file: path.basename(latest.path),
body: latest.body,
}, null, 2) + '\n');
} else {
process.stdout.write(latest.body);
}
return;
}
case 'close': {
const [slugArg, snapshotFile, ...extra] = args;
const slug = coerceSlug(slugArg);
if (!slug || !snapshotFile || extra.length > 0) {
process.stderr.write('usage: close <resolved-target> <snapshot-file>\n');
process.exit(1);
}
if (
path.basename(snapshotFile) !== snapshotFile
|| !SNAPSHOT_FILENAME.test(snapshotFile)
|| !snapshotFile.endsWith(`__${slug}.md`)
) process.exit(2);
// A slug and filename are not enough to prove ownership because two
// distinct targets can normalize to the same slug. Modern snapshots
// carry a canonical identity, so require the supplied resolved target
// to match it before allowing the exact snapshot to be closed. Legacy
// snapshots without identity retain their historical close behavior.
const snapshotPath = path.join(getCritiqueDir(process.cwd()), snapshotFile);
let snapshot;
try {
if (!fs.lstatSync(snapshotPath).isFile()) process.exit(2);
snapshot = readSnapshot(snapshotPath);
} catch {
process.exit(2);
}
const recordedTargetIdentity = snapshotTargetIdentity(snapshot);
if (
recordedTargetIdentity
&& recordedTargetIdentity !== resolveTargetIdentity(slugArg)
) process.exit(2);
const closed = closeSnapshot(snapshotFile);
if (!closed) { process.exit(2); }
process.stdout.write(`${closed}\n`);
return;
}
case 'trend': {
@@ -197,7 +448,7 @@ function main(argv) {
return;
}
default:
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend> [args]\n');
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend|close> [args]\n');
process.exit(1);
}
}
@@ -0,0 +1,121 @@
[
{
"family": "Betania Patmos GDL",
"weight": 400,
"category": "handwriting",
"variable": false,
"reason": "not loaded or no lettering"
},
{
"family": "Betania Patmos In GDL",
"weight": 400,
"category": "handwriting",
"variable": false,
"reason": "not loaded or no lettering"
},
{
"family": "Doto",
"weight": 300,
"category": "sans",
"variable": true,
"reason": "not loaded or no lettering"
},
{
"family": "Doto",
"weight": 700,
"category": "sans",
"variable": true,
"reason": "not loaded or no lettering"
},
{
"family": "Jacquard 12 Charted",
"weight": 400,
"category": "display",
"variable": false,
"reason": "not loaded or no lettering"
},
{
"family": "Jacquard 24 Charted",
"weight": 400,
"category": "display",
"variable": false,
"reason": "not loaded or no lettering"
},
{
"family": "Jacquarda Bastarda 9 Charted",
"weight": 400,
"category": "display",
"variable": false,
"reason": "not loaded or no lettering"
},
{
"family": "Jersey 10 Charted",
"weight": 400,
"category": "display",
"variable": false,
"reason": "not loaded or no lettering"
},
{
"family": "Jersey 15 Charted",
"weight": 400,
"category": "display",
"variable": false,
"reason": "not loaded or no lettering"
},
{
"family": "Jersey 20 Charted",
"weight": 400,
"category": "display",
"variable": false,
"reason": "not loaded or no lettering"
},
{
"family": "Jersey 25 Charted",
"weight": 400,
"category": "display",
"variable": false,
"reason": "not loaded or no lettering"
},
{
"family": "Micro 5 Charted",
"weight": 400,
"category": "display",
"variable": false,
"reason": "not loaded or no lettering"
},
{
"family": "Montserrat Underline",
"weight": 400,
"category": "sans",
"variable": true,
"reason": "not loaded or no lettering"
},
{
"family": "Montserrat Underline",
"weight": 700,
"category": "sans",
"variable": true,
"reason": "not loaded or no lettering"
},
{
"family": "Redacted",
"weight": 400,
"category": "display",
"variable": false,
"reason": "not loaded or no lettering"
},
{
"family": "Yarndings 12 Charted",
"weight": 400,
"category": "display",
"variable": false,
"reason": "not loaded or no lettering"
},
{
"family": "Yarndings 20 Charted",
"weight": 400,
"category": "display",
"variable": false,
"reason": "not loaded or no lettering"
}
]
File diff suppressed because one or more lines are too long
@@ -18,4 +18,13 @@ if (!detectorPath) {
const { detectCli } = await import(pathToFileURL(detectorPath));
// A comp-led build with its comp round or hero gate still open is not a page
// the detector can pass: say so after the scan (stderr, so --json stays
// parseable), on the same condition context.mjs reports at boot.
try {
const { compRoundOpen } = await import(pathToFileURL(path.join(__dirname, 'build-phase.mjs')));
const open = compRoundOpen(process.cwd());
if (open) process.stderr.write(`COMP_ROUND_OPEN: ${open.reason}. A detector pass is not a finish: run node ${__dirname}/build-phase.mjs status and follow its NEXT line before treating this page as built.\n`);
} catch { /* build-phase absent */ }
await detectCli();
@@ -1472,7 +1472,19 @@ if (IS_BROWSER) {
return findings;
}
// A page matched by detector.ignoreFiles is waived wholesale: every scan
// stage answers empty so the badge and toast read zero. Mirrors
// shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live
// overlay resolves the globs per page (live-browser-ignores.js) and
// forwards the verdict as config.skipScan.
function skipScanActive() {
return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true;
}
function collectBrowserFindings() {
if (skipScanActive()) {
return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] };
}
const groupMap = new Map();
const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : [];
const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id);
@@ -1675,6 +1687,119 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, mapped);
}
// Value-level suppression (issue #639). `disabledRules` above handles
// whole rules; this applies the config's remaining ignoreValues entries,
// which the CLI filters through isIgnoredFindingValue in
// cli/lib/impeccable-config.mjs, so a project waiver like
// overused-font = "geist mono" reaches the overlay and extension too.
const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
const _disabledValues = EXTENSION_MODE
? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
.filter(e => e && typeof e === 'object' && e.rule && e.value)
.map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
: [];
if (_disabledValues.length > 0) {
// The six rules whose findings carry a matchable value; keep in step
// with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
// Everything else is suppressed by rule or by file scope, both already
// resolved into disabledRules before the scan message was sent.
const _directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
'design-system-font-size',
]);
// The design-system checks set `ignoreValue` on their findings; the
// detail fallbacks catch overused-font, whose value lives in its
// sentence. One CLI matcher is not mirrored here: the motion extractor
// (a value-scoped bounce-easing waiver only matches when the finding
// carries ignoreValue directly). The CLI's [?&]family= URL fallback is
// also omitted on purpose: browser findings for these rules always
// carry ignoreValue or a "Primary font:" / "Google Fonts:" /
// font-family sentence, so it is unreachable here.
const _findingValue = (f) => {
if (!f || !_directValueRules.has(f.type || f.id)) return '';
const direct = f.ignoreValue || f.value;
if (direct) return _normValue(direct);
// The CLI routes bounce-easing through extractMotionIgnoreValue and
// never the font regexes; without a direct ignoreValue there is no
// value to match, so do not invent one from unrelated CSS text.
if ((f.type || f.id) === 'bounce-easing') return '';
for (const text of [f.detail, f.snippet]) {
if (typeof text !== 'string' || !text) continue;
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return _normValue(primary[1]);
const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
if (google) return _normValue(google[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return _normValue(family[1]);
}
return '';
};
// design-system-color compares by color value, not by spelling: the
// browser reports computed rgb(...) strings while waivers are usually
// written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in
// cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms;
// hsl stays CLI-only.
const _colorKey = (value) => {
const text = String(value || '').trim().toLowerCase();
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/);
if (hex) {
const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1];
const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16));
return `${r},${g},${b},${a}`;
}
const rgb = text.match(/^rgba?\((.*)\)$/);
if (!rgb) return '';
const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / ');
let parts;
if (body.includes(',')) {
parts = body.split(',').map(p => p.trim()).filter(Boolean);
const last = parts[parts.length - 1];
if (last && last.includes('/')) {
parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)];
}
} else {
parts = body.split(/\s+/).filter(p => p && p !== '/');
}
if (parts.length < 3 || parts.length > 4) return '';
const channel = (raw, isAlpha) => {
const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/);
if (!m) return null;
let v = parseFloat(m[1]);
if (m[2]) v = isAlpha ? v / 100 : v * 2.55;
const max = isAlpha ? 1 : 255;
if (!Number.isFinite(v) || v < 0 || v > max) return null;
return isAlpha ? v : Math.round(v);
};
const r = channel(parts[0], false);
const g = channel(parts[1], false);
const b = channel(parts[2], false);
const a = parts[3] === undefined ? 1 : channel(parts[3], true);
if ([r, g, b, a].some(v => v === null)) return '';
return `${r},${g},${b},${Math.round(a * 255)}`;
};
const _valueIgnored = (f) => {
const value = _findingValue(f);
if (!value) return false;
const rule = f.type || f.id;
return _disabledValues.some(e => e.rule === rule && (e.value === value
|| (rule === 'design-system-color'
&& _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(value))));
};
for (const [el, list] of [...groupMap.entries()]) {
const kept = list.filter(f => !_valueIgnored(f));
if (kept.length > 0) groupMap.set(el, kept);
else groupMap.delete(el);
}
for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
}
}
return {
groupMap,
allFindings: browserFindingsFromMap(groupMap),
@@ -1892,6 +2017,12 @@ if (IS_BROWSER) {
async function collectBrowserFindingsAsync(options = {}, runtime = {}) {
const collected = collectBrowserFindings();
// The visual pass walks the DOM on its own; on a skipScan page it would
// repopulate the emptied scan, so it is skipped with everything else.
if (skipScanActive()) {
lastVisualContrastAnalyses = [];
return { ...collected, allFindings: [], visualContrastAnalyses: [] };
}
await addVisualContrastFindings(collected.groupMap, options, runtime);
return {
...collected,
@@ -1945,7 +2076,7 @@ if (IS_BROWSER) {
const generation = scanGeneration;
const collected = collectBrowserFindings();
const allFindings = renderBrowserFindings(collected, options);
if (shouldRunVisualContrast(options)) {
if (!skipScanActive() && shouldRunVisualContrast(options)) {
addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation })
.then(() => {
if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options);
@@ -13,6 +13,11 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
// CLI can't import (separate tree). `.git` and `package.json` are the common
// boundaries; `.impeccable` is our own project marker.
const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable'];
// Monorepo-root recognition, mirroring context.mjs's isMonorepoRoot: declared
// workspace globs (package.json `workspaces`, pnpm-workspace.yaml `packages:`)
// or a marker file beside apps/ or packages/ children.
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
const COLOR_CHANNEL_TOLERANCE = 6;
// Shadow blacks at different alphas are different tokens (0.28 vs 0.55 is the
// difference between a documented shadow and drift), so shadow matching cannot
@@ -575,14 +580,179 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
}
}
// Same two groups as context.mjs's readProjectPatternGroups: Impeccable
// projectRoots govern any path they match (positive or negated); package-manager
// globs only apply to paths the Impeccable group does not match.
function readWorkspacePatternGroups(dir) {
const impeccable = [];
for (const name of ['config.json', 'config.local.json']) {
const roots = safeReadJson(path.join(dir, '.impeccable', name))?.projectRoots;
if (Array.isArray(roots)) {
impeccable.push(...roots.filter(entry => typeof entry === 'string' && entry.trim()).map(entry => entry.trim()));
}
}
const pkg = [];
const workspaces = safeReadJson(path.join(dir, 'package.json'))?.workspaces;
if (Array.isArray(workspaces)) pkg.push(...workspaces);
else if (Array.isArray(workspaces?.packages)) pkg.push(...workspaces.packages);
const lernaPackages = safeReadJson(path.join(dir, 'lerna.json'))?.packages;
if (Array.isArray(lernaPackages)) pkg.push(...lernaPackages);
try {
let inPackages = false;
for (const line of fs.readFileSync(path.join(dir, 'pnpm-workspace.yaml'), 'utf-8').split(/\r?\n/)) {
const trimmed = stripInlineYamlComment(line).trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const flow = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
if (flow) {
pkg.push(...flow[1].split(',').map(entry => entry.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean));
break;
}
if (/^packages:\s*$/.test(trimmed)) { inPackages = true; continue; }
if (!inPackages) continue;
const item = trimmed.match(/^-\s*(.+)$/);
if (item) pkg.push(item[1].trim().replace(/^['"]|['"]$/g, ''));
else if (/^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
}
} catch { /* no pnpm-workspace.yaml */ }
return [impeccable, pkg];
}
function readWorkspacePatterns(dir) {
return readWorkspacePatternGroups(dir).flat();
}
function isMonorepoRoot(dir) {
if (readWorkspacePatterns(dir).some(pattern => !String(pattern).trim().startsWith('!'))) return true;
if (!MONOREPO_MARKER_FILES.some(file => fs.existsSync(path.join(dir, file)))) return false;
return MONOREPO_FALLBACK_PROJECT_DIRS.some(name => {
try {
return fs.readdirSync(path.join(dir, name), { withFileTypes: true }).some(entry => entry.isDirectory());
} catch {
return false;
}
});
}
function monorepoOwnsPath(root, boundaryDir) {
const rel = path.relative(root, boundaryDir);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
const relSegments = rel.split(path.sep).filter(Boolean);
function normalizeWorkspacePattern(pattern) {
return String(pattern || '')
.trim()
.replace(/^['"]|['"]$/g, '')
.replace(/^\.\//, '')
.replace(/\/+$/, '');
}
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function segmentMatches(patternSegment, relSegment) {
if (patternSegment === '*') return true;
if (!patternSegment.includes('*')) return patternSegment === relSegment;
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
return re.test(relSegment);
}
function matchGlobSegments(patternSegments, relSegments) {
function rec(pi, ri) {
if (pi === patternSegments.length) return ri === relSegments.length;
if (patternSegments[pi] === '**') {
if (pi === patternSegments.length - 1) return true;
for (let k = ri; k <= relSegments.length; k++) {
if (rec(pi + 1, k)) return true;
}
return false;
}
if (ri >= relSegments.length) return false;
if (!segmentMatches(patternSegments[pi], relSegments[ri])) return false;
return rec(pi + 1, ri + 1);
}
return rec(0, 0);
}
// Negations like !packages/excluded must also cover nested dirs under that path.
function matchesNegation(pattern) {
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
if (!patternSegments.length) return false;
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
if (relSegments.length < patternSegments.length) return false;
for (let i = 0; i < patternSegments.length; i++) {
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
}
return true;
}
// Positive globs identify workspace packages at exact depth (`*` is a direct
// child). A nested package.json under that package is still owned: the
// ancestor directory of glob length must itself be a package.
function positiveOwns(pattern) {
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
if (!patternSegments.length) return false;
if (patternSegments.includes('**')) return matchGlobSegments(patternSegments, relSegments);
if (relSegments.length < patternSegments.length) return false;
for (let i = 0; i < patternSegments.length; i++) {
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
}
if (relSegments.length === patternSegments.length) return true;
const ancestorDir = path.join(root, ...relSegments.slice(0, patternSegments.length));
return fs.existsSync(path.join(ancestorDir, 'package.json'));
}
function groupOwns(rawPatterns) {
const patterns = rawPatterns.map(normalizeWorkspacePattern).filter(Boolean);
if (!patterns.length) return null;
const excluded = patterns.some((pattern) => (
pattern.startsWith('!') && matchesNegation(pattern.slice(1))
));
const included = patterns.filter((pattern) => !pattern.startsWith('!')).some(positiveOwns);
if (!excluded && !included) return null;
if (excluded) return false;
return true;
}
const [impeccable, pkg] = readWorkspacePatternGroups(root);
const fromImpeccable = groupOwns(impeccable);
if (fromImpeccable !== null) return fromImpeccable;
const fromPkg = groupOwns(pkg);
if (fromPkg !== null) return fromPkg;
if ([...impeccable, ...pkg].some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) {
return false;
}
return relSegments.length >= 2 && MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0]);
}
// Both forms of the home directory. The walk compares path strings, and a
// symlinked home (e.g. /home -> /var/home) never string-matches the physical
// paths a cwd-resolved target produces, which would let the post-boundary walk
// sail through $HOME and inherit from it.
function homeDirForms() {
const homeDir = path.resolve(os.homedir());
const forms = new Set([homeDir]);
try {
forms.add(fs.realpathSync(homeDir));
} catch { /* keep the logical form only */ }
return forms;
}
// Walk up from `startDir` to the directory that governs the target's design
// system, mirroring skill/scripts/context.mjs's project-boundary semantics:
//
// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the
// design root — that's where the rules live.
// - A directory carrying a project marker (.git / package.json / .impeccable)
// but no DESIGN.md is a project BOUNDARY: the walk stops with no design
// system, so a sibling project never inherits a parent's or cwd's rules.
// but no DESIGN.md is a project BOUNDARY. A nested package.json inherits
// the ancestor DESIGN.md only when that ancestor's workspace declarations
// include the path (negations win; a nested package under a matched
// workspace still inherits). Marker-only roots (turbo/nx/lerna/pnpm
// with no globs) still own apps/<name> and packages/<name>. A stray nested
// package that matches no glob does not inherit. This is detect's
// contamination contract, not skill-context's repoRoot fallback for
// excluded paths. A nested separate repository (.git with no workspace
// declaration) still inherits nothing (issue #570).
// - Reaching the home directory / filesystem root with neither means no
// design system at all — never process.cwd()'s.
//
@@ -590,15 +760,33 @@ function designSystemStartDir(targetPath, cwd = process.cwd()) {
// runs out. This is the fix for cross-project contamination.
export function findDesignRoot(startDir) {
let dir = path.resolve(startDir);
const homeDir = path.resolve(os.homedir());
const homeDirs = homeDirForms();
let boundary = null;
while (true) {
if (resolveDesignMdPath(dir)) return { dir, hasDesign: true };
if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
return { dir, hasDesign: false };
if (!boundary && resolveDesignMdPath(dir)) return { dir, hasDesign: true };
if (boundary) {
// Past the boundary the walk only looks for the monorepo root that owns
// the workspace path (workspace globs including negations, or marker-only
// apps/packages fallback). Monorepo-root before .git, same order as
// context.mjs: a workspace root carrying its own .git is still recognized,
// while a .git that declares no workspaces is a separate repository and
// stops the walk with nothing inherited. The home directory is never an
// owning root, same as context.mjs's findMonorepoRoot, which stops at
// homeDir before its monorepo check.
if (!homeDirs.has(dir) && isMonorepoRoot(dir)) {
if (monorepoOwnsPath(dir, boundary.dir)) return { dir, hasDesign: !!resolveDesignMdPath(dir) };
return boundary;
}
if (fs.existsSync(path.join(dir, '.git'))) return boundary;
} else if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) {
boundary = { dir, hasDesign: false };
// A boundary that is itself a monorepo root, or a separate repository
// with its own .git, inherits nothing from above.
if (isMonorepoRoot(dir) || fs.existsSync(path.join(dir, '.git'))) return boundary;
}
if (dir === homeDir) return null;
if (homeDirs.has(dir)) return boundary;
const parent = path.dirname(dir);
if (parent === dir) return null;
if (parent === dir) return boundary;
dir = parent;
}
}
@@ -807,7 +995,9 @@ function extractRadiusTokens(value) {
return String(value || '')
.replace(/\s*\/\s*/g, ' ')
.split(/\s+/)
.map(token => token.trim())
// var() fallbacks leave the closing parenthesis on the final token. Strip
// it before length resolution so `8px)` is not treated as unitless 8rem.
.map(token => token.trim().replace(/\)+$/, ''))
.filter(Boolean);
}
@@ -70,13 +70,27 @@ function isBrandFontOnOwnDomain(font) {
return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix));
}
const GENERIC_FONTS = new Set([
// Overused-font primary selection skips only CSS generics so a system stack
// keeps the system face as primary; GENERIC_FONTS still includes platform
// faces for design-system/serif resolution.
const CSS_GENERIC_FONTS = new Set([
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
'inherit', 'initial', 'unset', 'revert',
]);
const GENERIC_FONTS = new Set([
...CSS_GENERIC_FONTS,
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
]);
function primaryFontFace(fontFamily, skip = CSS_GENERIC_FONTS) {
return String(fontFamily || '')
.split(',')
.map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())
.find(f => f && !skip.has(f)) || null;
}
// WCAG large text thresholds are defined in points: 18pt normal text and
// 14pt bold text. Browsers expose font-size in CSS pixels at 96px per inch.
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
@@ -232,6 +246,24 @@ const ANTIPATTERNS = [
'A large inline SVG that builds a pictorial scene from a pile of primitive shapes reads as placeholder clip art, not illustration. Icons, logos, and data graphics are fine at their scale; a hero-sized visual deserves real artwork, a photograph, or a deliberately drawn graphic.',
skillSection: 'Imagery',
},
{
id: 'organic-clip-path',
category: 'quality',
name: 'Organic contour drawn as clip-path',
description:
'A clip-path polygon with many arbitrary vertices, or a curved clip-path path(), is CSS approximating a torn edge, blob, or silhouette. It reads as the cheap version of the effect and is usually a produced or photographic material replaced with code. Derive an alpha matte from the real image, or ship the shape as a cut-out raster; keep clip-path for geometry (cut corners, diagonals, hexagons).',
skillSection: 'Imagery',
skillGuideline: 'geometric masks standing in for organic contours',
},
{
id: 'buried-raster',
category: 'quality',
name: 'Raster buried under a wash or opacity',
description:
'A background image under a near-opaque gradient wash, or a raster on an element at near-zero opacity, never reaches the screen: the page shows the wash, and the produced texture or photo ships as a compliance token. Let the material show (a tint under 0.9 alpha, a blend mode, an opacity you can see) or remove the file.',
skillSection: 'Imagery',
skillGuideline: 'a produced material must survive to the screen',
},
{
id: 'dark-glow',
category: 'slop',
@@ -1573,7 +1605,7 @@ function checkIconTile(opts) {
function resolveSerif(fontFamily) {
if (!fontFamily) return { primary: null, isSerif: false };
const tokens = fontFamily.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
const primary = tokens.find(f => f && !GENERIC_FONTS.has(f)) || null;
const primary = primaryFontFace(fontFamily, GENERIC_FONTS);
if (!primary) return { primary: null, isSerif: false };
if (KNOWN_SERIF_FONTS.has(primary)) return { primary, isSerif: true };
if (tokens.includes('serif')) return { primary, isSerif: true };
@@ -1924,8 +1956,13 @@ function enclosingCssSelector(cssText, index) {
if (!cssText || !Number.isFinite(index)) return null;
const open = cssText.lastIndexOf('{', index);
if (open === -1) return null;
// A match inside an inline style fragment (`style="…"` appended to the
// corpus by buildHtmlPatternCorpora) has no enclosing rule; the previous
// `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null;
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
const raw = cssText.slice(prevClose + 1, open).trim().replace(/\s+/g, ' ');
const raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' ');
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from`
// and `to` would read as (never-matching) type selectors and get a valid
@@ -2691,6 +2728,95 @@ function scanHtmlForShapeAssembledIllustration(html) {
return findings;
}
// --- Organic clip-path polygons ----------------------------------------------
// A `clip-path: polygon(...)` with many vertices, or `clip-path: path(...)`
// with curves, is CSS approximating an organic contour: a torn edge, a blob,
// a silhouette. The approximation reads as the cheap version of the effect
// (the craft floor's geometric-occlusion-mask ban), and it is the signature
// of a comp's produced material being replaced with code. Geometric clips
// (cut corners, diagonals, hexagons, arrows: few vertices, or vertices on
// the 0/50/100 grid) pass; circle()/inset()/ellipse() pass; a mask-image
// from an alpha matte passes.
const ORGANIC_POLYGON_MIN_VERTICES = 10;
function scanCssTextForOrganicClipPath(styleText) {
const findings = [];
const re = /clip-path\s*:\s*(polygon|path)\s*\(([^)]*(?:\)[^;}]*)?)/gi;
let m;
while ((m = re.exec(styleText)) !== null) {
const kind = m[1].toLowerCase();
const body = m[2];
if (kind === 'path') {
// curves (C, S, Q, T, A, absolute or relative) drawing a contour, not a
// rectilinear M/L/Z outline; letters in path data are only commands
const curves = (body.match(/[CSQTA]/gi) || []).length;
if (curves < 3) continue;
findings.push({ id: 'organic-clip-path', snippet: `clip-path: path() with ${curves} curve segments`, selector: enclosingCssSelector(styleText, m.index) || undefined });
continue;
}
const points = body.split(',').map((p) => p.trim()).filter(Boolean);
if (points.length < ORGANIC_POLYGON_MIN_VERTICES) continue;
// Vertices sitting on a coarse grid (multiples of 25%) are geometric; a
// contour has arbitrary values.
let offGrid = 0;
for (const p of points) {
const nums = p.match(/-?[\d.]+/g) || [];
for (const n of nums) { const v = parseFloat(n); if (Math.abs(v - Math.round(v / 25) * 25) > 0.5) offGrid++; }
}
if (offGrid < points.length) continue;
findings.push({ id: 'organic-clip-path', snippet: `clip-path: polygon() with ${points.length} vertices approximating an organic contour`, selector: enclosingCssSelector(styleText, m.index) || undefined });
}
return findings;
}
// --- Buried raster ------------------------------------------------------------
// A raster (background-image url or <img>) that never reaches the screen:
// under a near-opaque gradient wash in the same background stack, or on an
// element at near-zero opacity. It is how a produced texture "ships" while
// the page shows flat color, and the finish reviewer cannot see it either.
// A tint under 0.9 alpha passes (hero darkening); a blend mode passes
// (multiply/overlay keep the material visible); opacity >= 0.15 passes.
function scanCssTextForBuriedRaster(styleText) {
const findings = [];
// background stacks: split declarations, look for url() + a gradient whose
// stops all carry alpha >= 0.9 (or opaque hex/named colors)
const declRe = /background(?:-image)?\s*:\s*([^;}]+)/gi;
let m;
while ((m = declRe.exec(styleText)) !== null) {
const value = m[1];
if (!/url\(/i.test(value) || !/gradient\(/i.test(value)) continue;
// a blend mode declared in the same rule keeps the raster visible
const ruleStart = styleText.lastIndexOf('{', m.index);
const ruleEnd = styleText.indexOf('}', m.index);
const rule = styleText.slice(ruleStart < 0 ? 0 : ruleStart, ruleEnd < 0 ? styleText.length : ruleEnd);
if (/background-blend-mode\s*:\s*(?!normal)/i.test(rule) || /mix-blend-mode\s*:\s*(?!normal)/i.test(rule)) continue;
// Layers are painted first-on-top: only a wash listed BEFORE the url()
// covers it. An image on top of a gradient is not buried.
const firstUrl = value.search(/url\(/i);
const gradients = [...value.matchAll(/(?:linear|radial|conic)-gradient\([^()]*(?:\([^()]*\)[^()]*)*\)/gi)].filter((gm) => gm.index < firstUrl).map((gm) => gm[0]);
let opaqueWash = false;
// an alpha token normalized to 0..1: '0.8' -> 0.8, '80%' -> 0.8
const alphaOf = (a) => { if (a == null) return 1; const v = parseFloat(a); return String(a).trim().endsWith('%') ? v / 100 : v; };
for (const g of gradients) {
const alphas = [...g.matchAll(/rgba?\(\s*[\d.]+%?\s*,?\s*[\d.]+%?\s*,?\s*[\d.]+%?\s*(?:[,/]\s*([\d.]+%?))?\s*\)|hsla?\([^)]*?(?:[,/]\s*([\d.]+%?))?\s*\)/gi)].map((a) => alphaOf(a[1] ?? a[2]));
const stripped = g.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)/gi, '');
// hex stops: 4- and 8-digit forms carry their own alpha
for (const h of stripped.matchAll(/#([0-9a-f]{3,8})\b/gi)) {
const hex = h[1];
if (hex.length === 4) alphas.push(parseInt(hex[3] + hex[3], 16) / 255);
else if (hex.length === 8) alphas.push(parseInt(hex.slice(6), 16) / 255);
else alphas.push(1);
}
const named = /\b(?:white|black|ivory|beige|linen|snow|cream)\b/i.test(stripped);
if (named) alphas.push(1);
if (alphas.length && alphas.every((a) => !Number.isFinite(a) || a >= 0.9)) { opaqueWash = true; break; }
}
if (!opaqueWash) continue;
findings.push({ id: 'buried-raster', snippet: `raster under a near-opaque gradient wash: ${value.trim().slice(0, 90)}`, selector: enclosingCssSelector(styleText, m.index) || undefined });
}
return findings;
}
// Scoped scan corpora for the page-level pattern checks. CSS-property
// regexes run over the whole source string fire on documentation ABOUT
// css — `<code>background-clip: text</code>` prose, <pre> samples, HTML
@@ -2863,6 +2989,10 @@ function checkHtmlPatterns(html, corpora) {
// Shape-assembled illustrations (large pictorial SVGs built from primitives)
findings.push(...scanHtmlForShapeAssembledIllustration(html));
// Organic clip-path contours and rasters buried under washes or opacity
findings.push(...scanCssTextForOrganicClipPath(styleText));
findings.push(...scanCssTextForBuriedRaster(styleText));
// Auto-scrolling marquees (<marquee> or infinite horizontal loop animations)
findings.push(...scanCssTextForMarquee(styleText, html));
@@ -4333,14 +4463,30 @@ function isNonRenderedText(el, tag, style) {
function checkQuality(opts) {
const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80, viewportWidth = 0, win = null } = opts;
const findings = [];
// Skip browser extension injected elements. Read the id via getAttribute
// whenever `el.id` is not a string: on a <form> (and other
// [LegacyOverrideBuiltIns] hosts) a named control like <input name="id">
// shadows the builtin `id` getter and returns the control element, whose
// `.startsWith` is undefined and throws (issue #407 — every Shopify product
// form ships an <input name="id">).
// A raster (<img>, or an element with a background url) at near-zero
// opacity never reaches the screen: the produced material ships as a
// compliance token. The CSS-text scan catches the stylesheet form; this
// catches computed opacity on the element itself (both engines).
// Skip browser extension injected elements BEFORE any finding is pushed
// (a low-opacity raster those hosts inject used to be recorded and then
// returned by this very skip). Read the id via getAttribute whenever
// `el.id` is not a string: on a <form> (and other [LegacyOverrideBuiltIns]
// hosts) a named control like <input name="id"> shadows the builtin `id`
// getter and returns the control element, whose `.startsWith` is undefined
// and throws (issue #407 — every Shopify product form ships an
// <input name="id">).
const elId = typeof el.id === 'string' ? el.id : (el.getAttribute?.('id') || '');
if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings;
{
const op = parseFloat(style.opacity);
if (Number.isFinite(op) && op < 0.15 && op >= 0) {
const bg = String(style.backgroundImage || '');
if (tag === 'img' || /url\(/i.test(bg)) {
const label = tag === 'img' ? (el.getAttribute && el.getAttribute('alt')) || '' : (el.textContent || '').trim().slice(0, 40);
findings.push({ id: 'buried-raster', snippet: `${tag === 'img' ? '<img>' : 'raster background'} at opacity ${op}${label ? ` "${label}"` : ''}` });
}
}
}
// --- Line length too long --- (browser-only: needs rect.width)
if (rect && hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) {
@@ -5058,8 +5204,7 @@ function checkTypography() {
const style = getComputedStyle(el);
const ff = style.fontFamily;
if (!ff) continue;
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
const primary = primaryFontFace(ff);
if (!primary) continue;
fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1);
totalTextElements++;
@@ -5304,8 +5449,7 @@ function checkPageTypography(doc, win) {
if (rule.type !== 1) continue;
const ff = rule.style?.fontFamily;
if (!ff) continue;
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
const primary = primaryFontFace(ff);
if (primary) {
fonts.add(primary);
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
@@ -5324,11 +5468,10 @@ function checkPageTypography(doc, win) {
const ffRe = /font-family\s*:\s*([^;}]+)/gi;
let fm;
while ((fm = ffRe.exec(html)) !== null) {
for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
if (f && !GENERIC_FONTS.has(f)) {
fonts.add(f);
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
}
const primary = primaryFontFace(fm[1]);
if (primary) {
fonts.add(primary);
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
}
}
@@ -6404,6 +6547,36 @@ function checkTextOcclusionDOM() {
// reads as an opaque box.
const effectiveOpacity = effectiveOpacityDOM;
// The part of an element that is actually painted, after every scrolling or
// clipping ancestor has had its say.
//
// getBoundingClientRect reports where a box would be if nothing cut it off,
// so a paragraph half scrolled out of a panel still reports its full height,
// and the half that is clipped away lands wherever the page continues below
// the panel. The elementFromPoint probe then samples coordinates the text is
// not painted at, finds whatever genuinely is painted there, and reports the
// text as buried under it. Any sticky footer or toolbar beneath a scroll
// region produces this, and it is the shape most likely to be waved off as
// noise, which costs the rule its credibility on the findings that are real.
//
// Border box rather than padding box on purpose: it errs toward probing, and
// giving up a scrollbar gutter's width would drop true findings at the right
// edge of a scroller.
const paintedRect = (el, rect) => {
let left = rect.left, top = rect.top, right = rect.right, bottom = rect.bottom;
for (let cur = el.parentElement; cur && cur !== document.documentElement; cur = cur.parentElement) {
let cs; try { cs = getComputedStyle(cur); } catch { continue; }
const clipsX = String(cs.overflowX || 'visible') !== 'visible';
const clipsY = String(cs.overflowY || 'visible') !== 'visible';
if (!clipsX && !clipsY) continue;
let b; try { b = cur.getBoundingClientRect(); } catch { continue; }
if (clipsX) { left = Math.max(left, b.left); right = Math.min(right, b.right); }
if (clipsY) { top = Math.max(top, b.top); bottom = Math.min(bottom, b.bottom); }
if (right - left < 1 || bottom - top < 1) return null;
}
return { left, top, right, bottom, width: right - left, height: bottom - top };
};
// Collect renderable text owners in / near the first viewport for the
// elementFromPoint probe. SVG <text> counts too.
const textEls = [];
@@ -6416,8 +6589,13 @@ function checkTextOcclusionDOM() {
if (text.length < 2) continue;
if (!isPaintedForOcclusion(el)) continue;
if (effectiveOpacity(el) <= 0.02) continue;
let rect; try { rect = el.getBoundingClientRect(); } catch { continue; }
if (rect.width < 6 || rect.height < 6) continue;
let full; try { full = el.getBoundingClientRect(); } catch { continue; }
if (full.width < 6 || full.height < 6) continue;
// Probe only where the text is on screen. A run clipped down to a sliver is
// dropped rather than sampled: a few pixels of visible text cannot support
// a coverage fraction worth reporting either way.
const rect = paintedRect(el, full);
if (!rect || rect.width < 6 || rect.height < 6) continue;
// Viewport-bound probe: keep text whose box overlaps the live viewport.
if (rect.bottom <= 0 || rect.top >= vh) continue;
textEls.push({ el, rect, text, inSvg });
@@ -8131,7 +8309,19 @@ if (IS_BROWSER) {
return findings;
}
// A page matched by detector.ignoreFiles is waived wholesale: every scan
// stage answers empty so the badge and toast read zero. Mirrors
// shouldIgnoreDetectionFile in cli/lib/impeccable-config.mjs; the live
// overlay resolves the globs per page (live-browser-ignores.js) and
// forwards the verdict as config.skipScan.
function skipScanActive() {
return EXTENSION_MODE && window.__IMPECCABLE_CONFIG__?.skipScan === true;
}
function collectBrowserFindings() {
if (skipScanActive()) {
return { groupMap: new Map(), allFindings: [], pageLevelFindings: [] };
}
const groupMap = new Map();
const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : [];
const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id);
@@ -8334,6 +8524,119 @@ if (IS_BROWSER) {
addBrowserFindings(groupMap, document.body, mapped);
}
// Value-level suppression (issue #639). `disabledRules` above handles
// whole rules; this applies the config's remaining ignoreValues entries,
// which the CLI filters through isIgnoredFindingValue in
// cli/lib/impeccable-config.mjs, so a project waiver like
// overused-font = "geist mono" reaches the overlay and extension too.
const _normValue = (v) => String(v || '').trim().replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ').replace(/\s+/g, ' ').toLowerCase();
const _disabledValues = EXTENSION_MODE
? (Array.isArray(window.__IMPECCABLE_CONFIG__?.disabledValues) ? window.__IMPECCABLE_CONFIG__.disabledValues : [])
.filter(e => e && typeof e === 'object' && e.rule && e.value)
.map(e => ({ rule: String(e.rule).trim().toLowerCase(), value: _normValue(e.value) }))
: [];
if (_disabledValues.length > 0) {
// The six rules whose findings carry a matchable value; keep in step
// with extractFindingIgnoreValue in cli/lib/impeccable-config.mjs.
// Everything else is suppressed by rule or by file scope, both already
// resolved into disabledRules before the scan message was sent.
const _directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
'design-system-font-size',
]);
// The design-system checks set `ignoreValue` on their findings; the
// detail fallbacks catch overused-font, whose value lives in its
// sentence. One CLI matcher is not mirrored here: the motion extractor
// (a value-scoped bounce-easing waiver only matches when the finding
// carries ignoreValue directly). The CLI's [?&]family= URL fallback is
// also omitted on purpose: browser findings for these rules always
// carry ignoreValue or a "Primary font:" / "Google Fonts:" /
// font-family sentence, so it is unreachable here.
const _findingValue = (f) => {
if (!f || !_directValueRules.has(f.type || f.id)) return '';
const direct = f.ignoreValue || f.value;
if (direct) return _normValue(direct);
// The CLI routes bounce-easing through extractMotionIgnoreValue and
// never the font regexes; without a direct ignoreValue there is no
// value to match, so do not invent one from unrelated CSS text.
if ((f.type || f.id) === 'bounce-easing') return '';
for (const text of [f.detail, f.snippet]) {
if (typeof text !== 'string' || !text) continue;
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return _normValue(primary[1]);
const google = text.match(/Google Fonts:\s*([^()\n;]+)/i);
if (google) return _normValue(google[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return _normValue(family[1]);
}
return '';
};
// design-system-color compares by color value, not by spelling: the
// browser reports computed rgb(...) strings while waivers are usually
// written as hex. Mirrors ignoreValueMatches -> colorIgnoreKey in
// cli/lib/impeccable-config.mjs for the hex and rgb()/rgba() forms;
// hsl stays CLI-only.
const _colorKey = (value) => {
const text = String(value || '').trim().toLowerCase();
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/);
if (hex) {
const expanded = hex[1].length <= 4 ? [...hex[1]].map(d => d + d).join('') : hex[1];
const [r, g, b, a = 255] = expanded.match(/../g).map(ch => parseInt(ch, 16));
return `${r},${g},${b},${a}`;
}
const rgb = text.match(/^rgba?\((.*)\)$/);
if (!rgb) return '';
const body = rgb[1].trim().replace(/\s*\/\s*/g, ' / ');
let parts;
if (body.includes(',')) {
parts = body.split(',').map(p => p.trim()).filter(Boolean);
const last = parts[parts.length - 1];
if (last && last.includes('/')) {
parts = [...parts.slice(0, -1), ...last.split('/').map(p => p.trim()).filter(Boolean)];
}
} else {
parts = body.split(/\s+/).filter(p => p && p !== '/');
}
if (parts.length < 3 || parts.length > 4) return '';
const channel = (raw, isAlpha) => {
const m = String(raw).trim().match(/^(-?\d*\.?\d+)(%)?$/);
if (!m) return null;
let v = parseFloat(m[1]);
if (m[2]) v = isAlpha ? v / 100 : v * 2.55;
const max = isAlpha ? 1 : 255;
if (!Number.isFinite(v) || v < 0 || v > max) return null;
return isAlpha ? v : Math.round(v);
};
const r = channel(parts[0], false);
const g = channel(parts[1], false);
const b = channel(parts[2], false);
const a = parts[3] === undefined ? 1 : channel(parts[3], true);
if ([r, g, b, a].some(v => v === null)) return '';
return `${r},${g},${b},${Math.round(a * 255)}`;
};
const _valueIgnored = (f) => {
const value = _findingValue(f);
if (!value) return false;
const rule = f.type || f.id;
return _disabledValues.some(e => e.rule === rule && (e.value === value
|| (rule === 'design-system-color'
&& _colorKey(e.value) !== '' && _colorKey(e.value) === _colorKey(value))));
};
for (const [el, list] of [...groupMap.entries()]) {
const kept = list.filter(f => !_valueIgnored(f));
if (kept.length > 0) groupMap.set(el, kept);
else groupMap.delete(el);
}
for (let i = pageLevelFindings.length - 1; i >= 0; i--) {
if (_valueIgnored(pageLevelFindings[i])) pageLevelFindings.splice(i, 1);
}
}
return {
groupMap,
allFindings: browserFindingsFromMap(groupMap),
@@ -8551,6 +8854,12 @@ if (IS_BROWSER) {
async function collectBrowserFindingsAsync(options = {}, runtime = {}) {
const collected = collectBrowserFindings();
// The visual pass walks the DOM on its own; on a skipScan page it would
// repopulate the emptied scan, so it is skipped with everything else.
if (skipScanActive()) {
lastVisualContrastAnalyses = [];
return { ...collected, allFindings: [], visualContrastAnalyses: [] };
}
await addVisualContrastFindings(collected.groupMap, options, runtime);
return {
...collected,
@@ -8604,7 +8913,7 @@ if (IS_BROWSER) {
const generation = scanGeneration;
const collected = collectBrowserFindings();
const allFindings = renderBrowserFindings(collected, options);
if (shouldRunVisualContrast(options)) {
if (!skipScanActive() && shouldRunVisualContrast(options)) {
addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation })
.then(() => {
if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options);
@@ -162,7 +162,68 @@ async function runVisualContrastFallback(page, serializedGroups, options, profil
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
async function detectUrl(url, options = {}) {
function decodeUrlComponent(value) {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function splitScanUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return { href: url, credentials: null };
}
if (!parsed.username && !parsed.password) {
return { href: url, credentials: null };
}
const credentials =
parsed.protocol === 'http:' || parsed.protocol === 'https:'
? {
username: decodeUrlComponent(parsed.username),
password: decodeUrlComponent(parsed.password),
}
: null;
parsed.username = '';
parsed.password = '';
return { href: parsed.href, credentials };
}
function basicAuthHeader(credentials) {
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
// page.authenticate is page-wide: a cross-origin redirect that then 401s
// would receive these credentials. Attach Authorization only to the scan origin.
async function applyOriginScopedAuth(page, href, credentials) {
if (!credentials) return;
let origin = '';
try {
origin = new URL(href).origin;
} catch {
return;
}
if (!origin) return;
const header = basicAuthHeader(credentials);
await page.setRequestInterception(true);
page.on('request', (request) => {
let headers;
try {
if (new URL(request.url()).origin === origin) {
headers = { ...request.headers(), authorization: header };
}
} catch {
// invalid request URL: continue without auth
}
void request.continue(headers ? { headers } : undefined).catch(() => {});
});
}
async function detectUrl(rawUrl, options = {}) {
const { href: url, credentials } = splitScanUrl(rawUrl);
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
@@ -238,6 +299,7 @@ async function detectUrl(url, options = {}) {
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
await applyOriginScopedAuth(page, url, credentials);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
@@ -369,4 +431,4 @@ async function createBrowserDetector(options = {}) {
};
}
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser };
export { runVisualContrastFallback, detectUrl, createBrowserDetector, launchBrowser, splitScanUrl };
@@ -964,8 +964,34 @@ function buildStaticWindow(staticDoc) {
};
}
function resolveLinkedCssPath(fileDir, href) {
const stripped = href.split(/[?#]/)[0];
const rootRelative = stripped.startsWith('/') && !stripped.startsWith('//');
if (!rootRelative) return path.resolve(fileDir, stripped);
// Drop "." and reject ".." so /../outside.css cannot walk out of dir.
const segments = stripped.replace(/^\/+/, '').split(/[/\\]/).filter(p => p && p !== '.');
if (segments.some(p => p === '..')) return path.join(fileDir, segments.filter(p => p !== '..').join(path.sep));
const rel = segments.join(path.sep);
let dir = fileDir;
for (;;) {
const parent = path.dirname(dir);
if (parent === dir) break; // never use the filesystem root as document root
try {
const candidate = path.join(dir, rel);
if (fs.statSync(candidate).isFile()) return candidate;
} catch { /* missing or unreadable candidate */ }
// Stop at the project root so a coincidental ~/static/app.css cannot win.
try {
if (fs.existsSync(path.join(dir, 'package.json')) || fs.existsSync(path.join(dir, '.git'))) break;
} catch { /* unreadable marker */ }
dir = parent;
}
return path.join(fileDir, rel);
}
function collectStaticCssText(root, fileDir, profile, filePath, modules) {
const styleTexts = [];
const warnedMissingStylesheets = new Set();
for (const styleEl of modules.selectAll('style', root.children || [])) {
styleTexts.push(modules.domutils.textContent(styleEl));
}
@@ -974,10 +1000,10 @@ function collectStaticCssText(root, fileDir, profile, filePath, modules) {
const rel = link.attribs?.rel || '';
const href = link.attribs?.href || '';
if (!/\bstylesheet\b/i.test(rel) || !href || /^(https?:)?\/\//i.test(href)) continue;
// Cache-busting hrefs (styles.css?v=3) resolve to the file, not to a
// literal path with the query in it; a versioned link otherwise made the
// whole stylesheet invisible to every element-level check.
const cssPath = path.resolve(fileDir, href.split(/[?#]/)[0]);
// Cache-busting (styles.css?v=3) and root-relative (/static/app.css) hrefs
// must not resolve as OS-absolute paths; otherwise the whole stylesheet is
// invisible to every element-level check.
const cssPath = resolveLinkedCssPath(fileDir, href);
try {
const css = profileStep(profile, {
engine: 'static-html',
@@ -987,7 +1013,14 @@ function collectStaticCssText(root, fileDir, profile, filePath, modules) {
detail: href,
}, () => fs.readFileSync(cssPath, 'utf-8'));
styleTexts.push(css);
} catch { /* skip unreadable */ }
} catch {
if (!warnedMissingStylesheets.has(cssPath)) {
warnedMissingStylesheets.add(cssPath);
process.stderr.write(
`impeccable detect: could not read linked stylesheet ${href} (resolved to ${cssPath}); color and custom-property rules will be incomplete\n`
);
}
}
}
return styleTexts.join('\n');
}
@@ -1,7 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
import { OVERUSED_FONTS, primaryFontFace } from '../../shared/constants.mjs';
import {
checkSourceDesignSystem,
collectStaticDesignSystemFindings,
@@ -51,9 +51,7 @@ function checkStaticPageTypography(document, window) {
for (const el of document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, td, th, dd, blockquote, figcaption, a, button, label, span, div')) {
const hasText = el.childNodes.some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
if (!hasText) continue;
const ff = window.getComputedStyle(el).fontFamily || '';
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
const primary = primaryFontFace(window.getComputedStyle(el).fontFamily);
if (!primary) continue;
fonts.add(primary);
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
@@ -121,6 +121,24 @@ const ANTIPATTERNS = [
'A large inline SVG that builds a pictorial scene from a pile of primitive shapes reads as placeholder clip art, not illustration. Icons, logos, and data graphics are fine at their scale; a hero-sized visual deserves real artwork, a photograph, or a deliberately drawn graphic.',
skillSection: 'Imagery',
},
{
id: 'organic-clip-path',
category: 'quality',
name: 'Organic contour drawn as clip-path',
description:
'A clip-path polygon with many arbitrary vertices, or a curved clip-path path(), is CSS approximating a torn edge, blob, or silhouette. It reads as the cheap version of the effect and is usually a produced or photographic material replaced with code. Derive an alpha matte from the real image, or ship the shape as a cut-out raster; keep clip-path for geometry (cut corners, diagonals, hexagons).',
skillSection: 'Imagery',
skillGuideline: 'geometric masks standing in for organic contours',
},
{
id: 'buried-raster',
category: 'quality',
name: 'Raster buried under a wash or opacity',
description:
'A background image under a near-opaque gradient wash, or a raster on an element at near-zero opacity, never reaches the screen: the page shows the wash, and the produced texture or photo ships as a compliance token. Let the material show (a tint under 0.9 alpha, a blend mode, an opacity you can see) or remove the file.',
skillSection: 'Imagery',
skillGuideline: 'a produced material must survive to the screen',
},
{
id: 'dark-glow',
category: 'slop',
@@ -9,6 +9,7 @@ import {
WCAG_LARGE_BOLD_TEXT_PX,
WCAG_LARGE_TEXT_PX,
isBrandFontOnOwnDomain,
primaryFontFace,
} from '../shared/constants.mjs';
import {
CSS_NAMED_COLORS,
@@ -331,7 +332,7 @@ function checkIconTile(opts) {
function resolveSerif(fontFamily) {
if (!fontFamily) return { primary: null, isSerif: false };
const tokens = fontFamily.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
const primary = tokens.find(f => f && !GENERIC_FONTS.has(f)) || null;
const primary = primaryFontFace(fontFamily, GENERIC_FONTS);
if (!primary) return { primary: null, isSerif: false };
if (KNOWN_SERIF_FONTS.has(primary)) return { primary, isSerif: true };
if (tokens.includes('serif')) return { primary, isSerif: true };
@@ -682,8 +683,13 @@ function enclosingCssSelector(cssText, index) {
if (!cssText || !Number.isFinite(index)) return null;
const open = cssText.lastIndexOf('{', index);
if (open === -1) return null;
// A match inside an inline style fragment (`style="…"` appended to the
// corpus by buildHtmlPatternCorpora) has no enclosing rule; the previous
// `{` belongs to some other selector.
const closeBeforeIndex = cssText.lastIndexOf('}', index);
if (closeBeforeIndex > open) return null;
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
const raw = cssText.slice(prevClose + 1, open).trim().replace(/\s+/g, ' ');
const raw = cssText.slice(prevClose + 1, open).replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/\s+/g, ' ');
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from`
// and `to` would read as (never-matching) type selectors and get a valid
@@ -1449,6 +1455,95 @@ function scanHtmlForShapeAssembledIllustration(html) {
return findings;
}
// --- Organic clip-path polygons ----------------------------------------------
// A `clip-path: polygon(...)` with many vertices, or `clip-path: path(...)`
// with curves, is CSS approximating an organic contour: a torn edge, a blob,
// a silhouette. The approximation reads as the cheap version of the effect
// (the craft floor's geometric-occlusion-mask ban), and it is the signature
// of a comp's produced material being replaced with code. Geometric clips
// (cut corners, diagonals, hexagons, arrows: few vertices, or vertices on
// the 0/50/100 grid) pass; circle()/inset()/ellipse() pass; a mask-image
// from an alpha matte passes.
const ORGANIC_POLYGON_MIN_VERTICES = 10;
function scanCssTextForOrganicClipPath(styleText) {
const findings = [];
const re = /clip-path\s*:\s*(polygon|path)\s*\(([^)]*(?:\)[^;}]*)?)/gi;
let m;
while ((m = re.exec(styleText)) !== null) {
const kind = m[1].toLowerCase();
const body = m[2];
if (kind === 'path') {
// curves (C, S, Q, T, A, absolute or relative) drawing a contour, not a
// rectilinear M/L/Z outline; letters in path data are only commands
const curves = (body.match(/[CSQTA]/gi) || []).length;
if (curves < 3) continue;
findings.push({ id: 'organic-clip-path', snippet: `clip-path: path() with ${curves} curve segments`, selector: enclosingCssSelector(styleText, m.index) || undefined });
continue;
}
const points = body.split(',').map((p) => p.trim()).filter(Boolean);
if (points.length < ORGANIC_POLYGON_MIN_VERTICES) continue;
// Vertices sitting on a coarse grid (multiples of 25%) are geometric; a
// contour has arbitrary values.
let offGrid = 0;
for (const p of points) {
const nums = p.match(/-?[\d.]+/g) || [];
for (const n of nums) { const v = parseFloat(n); if (Math.abs(v - Math.round(v / 25) * 25) > 0.5) offGrid++; }
}
if (offGrid < points.length) continue;
findings.push({ id: 'organic-clip-path', snippet: `clip-path: polygon() with ${points.length} vertices approximating an organic contour`, selector: enclosingCssSelector(styleText, m.index) || undefined });
}
return findings;
}
// --- Buried raster ------------------------------------------------------------
// A raster (background-image url or <img>) that never reaches the screen:
// under a near-opaque gradient wash in the same background stack, or on an
// element at near-zero opacity. It is how a produced texture "ships" while
// the page shows flat color, and the finish reviewer cannot see it either.
// A tint under 0.9 alpha passes (hero darkening); a blend mode passes
// (multiply/overlay keep the material visible); opacity >= 0.15 passes.
function scanCssTextForBuriedRaster(styleText) {
const findings = [];
// background stacks: split declarations, look for url() + a gradient whose
// stops all carry alpha >= 0.9 (or opaque hex/named colors)
const declRe = /background(?:-image)?\s*:\s*([^;}]+)/gi;
let m;
while ((m = declRe.exec(styleText)) !== null) {
const value = m[1];
if (!/url\(/i.test(value) || !/gradient\(/i.test(value)) continue;
// a blend mode declared in the same rule keeps the raster visible
const ruleStart = styleText.lastIndexOf('{', m.index);
const ruleEnd = styleText.indexOf('}', m.index);
const rule = styleText.slice(ruleStart < 0 ? 0 : ruleStart, ruleEnd < 0 ? styleText.length : ruleEnd);
if (/background-blend-mode\s*:\s*(?!normal)/i.test(rule) || /mix-blend-mode\s*:\s*(?!normal)/i.test(rule)) continue;
// Layers are painted first-on-top: only a wash listed BEFORE the url()
// covers it. An image on top of a gradient is not buried.
const firstUrl = value.search(/url\(/i);
const gradients = [...value.matchAll(/(?:linear|radial|conic)-gradient\([^()]*(?:\([^()]*\)[^()]*)*\)/gi)].filter((gm) => gm.index < firstUrl).map((gm) => gm[0]);
let opaqueWash = false;
// an alpha token normalized to 0..1: '0.8' -> 0.8, '80%' -> 0.8
const alphaOf = (a) => { if (a == null) return 1; const v = parseFloat(a); return String(a).trim().endsWith('%') ? v / 100 : v; };
for (const g of gradients) {
const alphas = [...g.matchAll(/rgba?\(\s*[\d.]+%?\s*,?\s*[\d.]+%?\s*,?\s*[\d.]+%?\s*(?:[,/]\s*([\d.]+%?))?\s*\)|hsla?\([^)]*?(?:[,/]\s*([\d.]+%?))?\s*\)/gi)].map((a) => alphaOf(a[1] ?? a[2]));
const stripped = g.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)/gi, '');
// hex stops: 4- and 8-digit forms carry their own alpha
for (const h of stripped.matchAll(/#([0-9a-f]{3,8})\b/gi)) {
const hex = h[1];
if (hex.length === 4) alphas.push(parseInt(hex[3] + hex[3], 16) / 255);
else if (hex.length === 8) alphas.push(parseInt(hex.slice(6), 16) / 255);
else alphas.push(1);
}
const named = /\b(?:white|black|ivory|beige|linen|snow|cream)\b/i.test(stripped);
if (named) alphas.push(1);
if (alphas.length && alphas.every((a) => !Number.isFinite(a) || a >= 0.9)) { opaqueWash = true; break; }
}
if (!opaqueWash) continue;
findings.push({ id: 'buried-raster', snippet: `raster under a near-opaque gradient wash: ${value.trim().slice(0, 90)}`, selector: enclosingCssSelector(styleText, m.index) || undefined });
}
return findings;
}
// Scoped scan corpora for the page-level pattern checks. CSS-property
// regexes run over the whole source string fire on documentation ABOUT
// css — `<code>background-clip: text</code>` prose, <pre> samples, HTML
@@ -1621,6 +1716,10 @@ function checkHtmlPatterns(html, corpora) {
// Shape-assembled illustrations (large pictorial SVGs built from primitives)
findings.push(...scanHtmlForShapeAssembledIllustration(html));
// Organic clip-path contours and rasters buried under washes or opacity
findings.push(...scanCssTextForOrganicClipPath(styleText));
findings.push(...scanCssTextForBuriedRaster(styleText));
// Auto-scrolling marquees (<marquee> or infinite horizontal loop animations)
findings.push(...scanCssTextForMarquee(styleText, html));
@@ -3091,14 +3190,30 @@ function isNonRenderedText(el, tag, style) {
function checkQuality(opts) {
const { el, tag, style, hasDirectText, textLen, fontSize, lineHeightPx, letterSpacingPx, rect, lineMax = 80, viewportWidth = 0, win = null } = opts;
const findings = [];
// Skip browser extension injected elements. Read the id via getAttribute
// whenever `el.id` is not a string: on a <form> (and other
// [LegacyOverrideBuiltIns] hosts) a named control like <input name="id">
// shadows the builtin `id` getter and returns the control element, whose
// `.startsWith` is undefined and throws (issue #407 — every Shopify product
// form ships an <input name="id">).
// A raster (<img>, or an element with a background url) at near-zero
// opacity never reaches the screen: the produced material ships as a
// compliance token. The CSS-text scan catches the stylesheet form; this
// catches computed opacity on the element itself (both engines).
// Skip browser extension injected elements BEFORE any finding is pushed
// (a low-opacity raster those hosts inject used to be recorded and then
// returned by this very skip). Read the id via getAttribute whenever
// `el.id` is not a string: on a <form> (and other [LegacyOverrideBuiltIns]
// hosts) a named control like <input name="id"> shadows the builtin `id`
// getter and returns the control element, whose `.startsWith` is undefined
// and throws (issue #407 — every Shopify product form ships an
// <input name="id">).
const elId = typeof el.id === 'string' ? el.id : (el.getAttribute?.('id') || '');
if (elId.startsWith('claude-') || elId.startsWith('cic-')) return findings;
{
const op = parseFloat(style.opacity);
if (Number.isFinite(op) && op < 0.15 && op >= 0) {
const bg = String(style.backgroundImage || '');
if (tag === 'img' || /url\(/i.test(bg)) {
const label = tag === 'img' ? (el.getAttribute && el.getAttribute('alt')) || '' : (el.textContent || '').trim().slice(0, 40);
findings.push({ id: 'buried-raster', snippet: `${tag === 'img' ? '<img>' : 'raster background'} at opacity ${op}${label ? ` "${label}"` : ''}` });
}
}
}
// --- Line length too long --- (browser-only: needs rect.width)
if (rect && hasDirectText && QUALITY_TEXT_TAGS.has(tag) && rect.width > 0 && textLen > lineMax) {
@@ -3816,8 +3931,7 @@ function checkTypography() {
const style = getComputedStyle(el);
const ff = style.fontFamily;
if (!ff) continue;
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
const primary = primaryFontFace(ff);
if (!primary) continue;
fontUsage.set(primary, (fontUsage.get(primary) || 0) + 1);
totalTextElements++;
@@ -4062,8 +4176,7 @@ function checkPageTypography(doc, win) {
if (rule.type !== 1) continue;
const ff = rule.style?.fontFamily;
if (!ff) continue;
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
const primary = primaryFontFace(ff);
if (primary) {
fonts.add(primary);
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
@@ -4082,11 +4195,10 @@ function checkPageTypography(doc, win) {
const ffRe = /font-family\s*:\s*([^;}]+)/gi;
let fm;
while ((fm = ffRe.exec(html)) !== null) {
for (const f of fm[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
if (f && !GENERIC_FONTS.has(f)) {
fonts.add(f);
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
}
const primary = primaryFontFace(fm[1]);
if (primary) {
fonts.add(primary);
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
}
}
@@ -5162,6 +5274,36 @@ function checkTextOcclusionDOM() {
// reads as an opaque box.
const effectiveOpacity = effectiveOpacityDOM;
// The part of an element that is actually painted, after every scrolling or
// clipping ancestor has had its say.
//
// getBoundingClientRect reports where a box would be if nothing cut it off,
// so a paragraph half scrolled out of a panel still reports its full height,
// and the half that is clipped away lands wherever the page continues below
// the panel. The elementFromPoint probe then samples coordinates the text is
// not painted at, finds whatever genuinely is painted there, and reports the
// text as buried under it. Any sticky footer or toolbar beneath a scroll
// region produces this, and it is the shape most likely to be waved off as
// noise, which costs the rule its credibility on the findings that are real.
//
// Border box rather than padding box on purpose: it errs toward probing, and
// giving up a scrollbar gutter's width would drop true findings at the right
// edge of a scroller.
const paintedRect = (el, rect) => {
let left = rect.left, top = rect.top, right = rect.right, bottom = rect.bottom;
for (let cur = el.parentElement; cur && cur !== document.documentElement; cur = cur.parentElement) {
let cs; try { cs = getComputedStyle(cur); } catch { continue; }
const clipsX = String(cs.overflowX || 'visible') !== 'visible';
const clipsY = String(cs.overflowY || 'visible') !== 'visible';
if (!clipsX && !clipsY) continue;
let b; try { b = cur.getBoundingClientRect(); } catch { continue; }
if (clipsX) { left = Math.max(left, b.left); right = Math.min(right, b.right); }
if (clipsY) { top = Math.max(top, b.top); bottom = Math.min(bottom, b.bottom); }
if (right - left < 1 || bottom - top < 1) return null;
}
return { left, top, right, bottom, width: right - left, height: bottom - top };
};
// Collect renderable text owners in / near the first viewport for the
// elementFromPoint probe. SVG <text> counts too.
const textEls = [];
@@ -5174,8 +5316,13 @@ function checkTextOcclusionDOM() {
if (text.length < 2) continue;
if (!isPaintedForOcclusion(el)) continue;
if (effectiveOpacity(el) <= 0.02) continue;
let rect; try { rect = el.getBoundingClientRect(); } catch { continue; }
if (rect.width < 6 || rect.height < 6) continue;
let full; try { full = el.getBoundingClientRect(); } catch { continue; }
if (full.width < 6 || full.height < 6) continue;
// Probe only where the text is on screen. A run clipped down to a sliver is
// dropped rather than sampled: a few pixels of visible text cannot support
// a coverage fraction worth reporting either way.
const rect = paintedRect(el, full);
if (!rect || rect.width < 6 || rect.height < 6) continue;
// Viewport-bound probe: keep text whose box overlaps the live viewport.
if (rect.bottom <= 0 || rect.top >= vh) continue;
textEls.push({ el, rect, text, inSvg });
@@ -5444,6 +5591,8 @@ export {
cssLengthToPx,
scanCssTextForPulsingDot,
scanHtmlForShapeAssembledIllustration,
scanCssTextForOrganicClipPath,
scanCssTextForBuriedRaster,
buildHtmlPatternCorpora,
checkHtmlPatterns,
readOwnBackgroundColor,
@@ -56,13 +56,27 @@ function isBrandFontOnOwnDomain(font) {
return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix));
}
const GENERIC_FONTS = new Set([
// Overused-font primary selection skips only CSS generics so a system stack
// keeps the system face as primary; GENERIC_FONTS still includes platform
// faces for design-system/serif resolution.
const CSS_GENERIC_FONTS = new Set([
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
'inherit', 'initial', 'unset', 'revert',
]);
const GENERIC_FONTS = new Set([
...CSS_GENERIC_FONTS,
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
]);
function primaryFontFace(fontFamily, skip = CSS_GENERIC_FONTS) {
return String(fontFamily || '')
.split(',')
.map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())
.find(f => f && !skip.has(f)) || null;
}
// WCAG large text thresholds are defined in points: 18pt normal text and
// 14pt bold text. Browsers expose font-size in CSS pixels at 96px per inch.
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
@@ -104,6 +118,7 @@ export {
BRAND_FONT_DOMAINS,
isBrandFontOnOwnDomain,
GENERIC_FONTS,
primaryFontFace,
WCAG_LARGE_TEXT_PX,
WCAG_LARGE_BOLD_TEXT_PX,
EM_DASH_FLOOR,
@@ -21,22 +21,24 @@ import zlib from 'node:zlib';
const KEYWORD = 'impeccable:prompt';
const args = process.argv.slice(2);
const file = args.find(a => !a.startsWith('--'));
const readMode = args.includes('--read');
const scanMode = args.includes('--scan');
const argOf = (name) => { const i = args.indexOf(name); return i !== -1 ? args[i + 1] : null; };
function promptOf(imagePath) {
const b = fs.readFileSync(imagePath);
let prompt = null;
if (b.length > 8 && b.readUInt32BE(0) === 0x89504e47) prompt = readPngText(b);
else if (b.length > 3 && b[0] === 0xff && b[1] === 0xd8) prompt = readJpegCom(b);
function imageType(buffer) {
if (buffer.length > 8 && buffer.readUInt32BE(0) === 0x89504e47) return 'png';
if (buffer.length > 3 && buffer[0] === 0xff && buffer[1] === 0xd8) return 'jpeg';
return null;
}
function readPrompt(imagePath, buffer = fs.readFileSync(imagePath)) {
const type = imageType(buffer);
let prompt = type === 'png' ? parsePng(buffer).prompt : type === 'jpeg' ? readJpegCom(buffer) : null;
if (prompt == null && fs.existsSync(`${imagePath}.json`)) {
try { prompt = JSON.parse(fs.readFileSync(`${imagePath}.json`, 'utf8')).prompt ?? null; } catch { /* stays null */ }
}
return prompt;
}
if (scanMode) {
if (args.includes('--scan')) {
const targets = args.filter(a => !a.startsWith('--'));
if (targets.length === 0) { console.error('embed-prompt: --scan needs at least one directory'); process.exit(1); }
const RASTER = /\.(png|jpe?g|webp)$/i;
@@ -59,7 +61,7 @@ if (scanMode) {
}
let missing = 0;
for (const raster of rasters) {
if (promptOf(raster) == null) { console.log(`MISSING: ${raster}`); missing++; }
if (readPrompt(raster) == null) { console.log(`MISSING: ${raster}`); missing++; }
}
console.log(`SCAN: ${rasters.length} raster${rasters.length === 1 ? '' : 's'}, ${missing} missing`);
process.exit(missing > 0 ? 3 : 0);
@@ -68,8 +70,7 @@ if (scanMode) {
if (!file || !fs.existsSync(file)) { console.error('embed-prompt: image file required'); process.exit(1); }
const buf = fs.readFileSync(file);
const isPng = buf.length > 8 && buf.readUInt32BE(0) === 0x89504e47;
const isJpeg = buf.length > 3 && buf[0] === 0xff && buf[1] === 0xd8;
const type = imageType(buf);
const crcTable = (() => {
const t = new Uint32Array(256);
@@ -87,22 +88,26 @@ function pngChunk(type, data) {
return out;
}
function readPngText(b) {
let off = 8;
while (off + 12 <= b.length) {
const len = b.readUInt32BE(off);
const type = b.toString('ascii', off + 4, off + 8);
if (type === 'tEXt' || type === 'zTXt') {
const data = b.subarray(off + 8, off + 8 + len);
const nul = data.indexOf(0);
if (nul !== -1 && data.toString('latin1', 0, nul) === KEYWORD) {
if (type === 'tEXt') return data.toString('utf8', nul + 1);
return zlib.inflateSync(data.subarray(nul + 2)).toString('utf8');
}
function parsePng(buffer) {
const chunks = [];
let prompt = null;
let offset = 8;
while (offset + 12 <= buffer.length) {
const length = buffer.readUInt32BE(offset);
const type = buffer.toString('ascii', offset + 4, offset + 8);
const data = buffer.subarray(offset + 8, offset + 8 + length);
const nul = data.indexOf(0);
const promptChunk = (type === 'tEXt' || type === 'zTXt')
&& nul !== -1 && data.toString('latin1', 0, nul) === KEYWORD;
if (prompt == null && promptChunk) {
prompt = type === 'tEXt'
? data.toString('utf8', nul + 1)
: zlib.inflateSync(data.subarray(nul + 2)).toString('utf8');
}
off += 12 + len;
chunks.push({ offset, type, promptChunk, bytes: buffer.subarray(offset, offset + 12 + length) });
offset += 12 + length;
}
return null;
return { chunks, prompt };
}
function readJpegCom(b) {
@@ -121,48 +126,34 @@ function readJpegCom(b) {
}
const sidecar = `${file}.json`;
if (readMode) {
let prompt = null;
if (isPng) prompt = readPngText(buf);
else if (isJpeg) prompt = readJpegCom(buf);
if (prompt == null && fs.existsSync(sidecar)) {
try { prompt = JSON.parse(fs.readFileSync(sidecar, 'utf8')).prompt ?? null; } catch { /* fall through */ }
}
if (args.includes('--read')) {
const prompt = readPrompt(file, buf);
if (prompt == null) { console.error('embed-prompt: no embedded prompt found'); process.exit(2); }
console.log(prompt);
process.exit(0);
}
const prompt = argOf('--prompt') ?? (argOf('--prompt-file') ? fs.readFileSync(argOf('--prompt-file'), 'utf8') : null);
const promptFile = argOf('--prompt-file');
const prompt = argOf('--prompt') ?? (promptFile ? fs.readFileSync(promptFile, 'utf8') : null);
if (!prompt) { console.error('embed-prompt: --prompt or --prompt-file required'); process.exit(1); }
if (isPng) {
if (type === 'png') {
// Insert (or replace) our tEXt chunk immediately before IEND.
const iend = buf.indexOf(Buffer.from('IEND', 'ascii')) - 4;
const { chunks, prompt: existingPrompt } = parsePng(buf);
const iend = chunks.find((chunk) => chunk.type === 'IEND')?.offset ?? -1;
if (iend < 8) { console.error('embed-prompt: malformed PNG'); process.exit(1); }
// Drop any existing chunk with our keyword to keep embedding idempotent.
let body = buf.subarray(8, iend);
const existing = readPngText(buf);
if (existing != null) {
const parts = [];
let off = 8;
while (off + 12 <= buf.length && off < iend + 12) {
const len = buf.readUInt32BE(off);
const type = buf.toString('ascii', off + 4, off + 8);
const chunk = buf.subarray(off, off + 12 + len);
const data = buf.subarray(off + 8, off + 8 + len);
const nul = data.indexOf(0);
const ours = (type === 'tEXt' || type === 'zTXt') && nul !== -1 && data.toString('latin1', 0, nul) === KEYWORD;
if (!ours && type !== 'IEND') parts.push(chunk);
off += 12 + len;
}
body = Buffer.concat(parts).subarray(8 * 0); // parts exclude signature
fs.writeFileSync(file, Buffer.concat([buf.subarray(0, 8), body, pngChunk('tEXt', Buffer.concat([Buffer.from(KEYWORD, 'latin1'), Buffer.from([0]), Buffer.from(prompt, 'utf8')])), pngChunk('IEND', Buffer.alloc(0))]));
} else {
fs.writeFileSync(file, Buffer.concat([buf.subarray(0, iend), pngChunk('tEXt', Buffer.concat([Buffer.from(KEYWORD, 'latin1'), Buffer.from([0]), Buffer.from(prompt, 'utf8')])), buf.subarray(iend)]));
}
const replacing = existingPrompt != null;
const body = replacing
? Buffer.concat(chunks
.filter((chunk) => chunk.offset < iend && !chunk.promptChunk)
.map((chunk) => chunk.bytes))
: buf.subarray(8, iend);
const promptChunk = pngChunk('tEXt', Buffer.concat([Buffer.from(KEYWORD, 'latin1'), Buffer.from([0]), Buffer.from(prompt, 'utf8')]));
const end = replacing ? pngChunk('IEND', Buffer.alloc(0)) : buf.subarray(iend);
fs.writeFileSync(file, Buffer.concat([buf.subarray(0, 8), body, promptChunk, end]));
console.log(`EMBEDDED: ${file} (png tEXt, ${prompt.length} chars)`);
} else if (isJpeg) {
} else if (type === 'jpeg') {
const seg = Buffer.from(`${KEYWORD}\0${prompt}`, 'utf8');
if (seg.length + 2 > 0xffff) { console.error('embed-prompt: prompt too long for a JPEG segment'); process.exit(1); }
const com = Buffer.alloc(4 + seg.length);
@@ -0,0 +1,457 @@
#!/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 <region-id> [--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 <region-id> [--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) => `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=${encodeURIComponent(c.family).replace(/%20/g, '+')}:wght@${c.weight}&display=block">`).join('');
const html = `<!doctype html><html><head><meta charset="utf-8">${links}<style>body{margin:0;background:#fff}div.s{position:absolute;left:0;top:0;white-space:nowrap;color:#000;line-height:1;padding:8px;text-transform:${transform}}</style></head><body></body></html>`;
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 = `<div class="s" style="font-family:'${family}',sans-serif;font-weight:${weight};font-size:${size}px">${text}</div>`;
}, { 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 = `<div class="s" style="font-family:'${family}',sans-serif;font-weight:${weight};font-size:${size}px">${text}</div>`;
}, { 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) => `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=${encodeURIComponent(c.family).replace(/%20/g, '+')}:wght@${c.weight}&display=block">`).join('');
const rowsHtml = top.map((c) => `<div class="row"><div class="lab">${c.family} ${c.weight} · ${c.fontSizePx}px</div><div class="s" style="font-family:'${c.family}';font-weight:${c.weight};font-size:${c.fontSizePx}px;text-transform:${transform}">${text}</div></div>`).join('');
const html = `<!doctype html><html><head><meta charset="utf-8">${links}<style>body{margin:0;background:#fff;padding:12px;font-family:system-ui}img{display:block;max-width:100%}.lab{font:12px system-ui;color:#666;margin:10px 0 2px}.s{white-space:nowrap;line-height:1.05;color:#111}</style></head><body><div class="lab">COMP</div><img src="data:image/png;base64,${compB64}">${rowsHtml}</body></html>`;
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 <text-region-id> | --rank <text-region-id> [--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); });
@@ -15,8 +15,22 @@
* --ref anchors generation on input image(s) via the edits endpoint: pass a
* captured screenshot of a representative existing page when comping a new
* surface for an established world, so the identity comes from the real UI.
*
* node generate-image.mjs --plate <region-id> [--spec .impeccable/build/spec.json] [--quality high]
*
* --plate produces a shipping raster for one raster region of the measured
* comp spec (comp-spec.mjs): it crops the region from the approved comp,
* sends the crop as the reference with the spec's plate prompt (plus any
* --prompt you add), picks the closest supported output size to the region's
* aspect, writes the result to the region's `plate` path, embeds the prompt,
* and scores the plate against the comp crop with comp-diff so a plate that
* does not read as the region is reported (and, with --min, refused) here,
* before it lands on the page. In IMPECCABLE_IMAGE_GEN_FAKE mode the plate is
* the crop itself at 2x, so offline pipelines can walk the plate gate.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import zlib from 'node:zlib';
function arg(name, fallback = null) {
@@ -186,6 +200,154 @@ function parseSize(sizeStr) {
return [Number(m[1]), Number(m[2])];
}
// ---------------------------------------------------------------------------
// Plate mode: one raster region of the measured spec -> a shipping plate.
// ---------------------------------------------------------------------------
const plateId = arg('plate');
let plateCtx = null;
if (plateId) {
const { loadSpec, platePrompt, plateReference, SPEC_PATH } = await import('./comp-spec.mjs');
const { decodePng, encodePng, loadRaster } = await import('./lib/png.mjs');
const { crop, resize } = await import('./lib/raster.mjs');
const specPath = arg('spec', SPEC_PATH);
const spec = loadSpec(specPath);
if (!spec) { console.error(`generate-image: no spec at ${specPath}; run comp-spec.mjs first`); process.exit(1); }
const region = spec.regions.find((r) => r.id === plateId);
if (!region) { console.error(`generate-image: no region ${plateId} in ${specPath}; ids: ${spec.regions.map((r) => r.id).join(', ')}`); process.exit(1); }
if (region.medium !== 'raster') { console.error(`generate-image: region ${plateId} is ${region.medium}, not a plate; set its kind to plate|image|texture in the regions file`); process.exit(1); }
let comp;
try { comp = loadRaster(spec.comp).image; } catch (e) { console.error(`generate-image: cannot read comp ${spec.comp}: ${e.message}`); process.exit(1); }
const ref = plateReference(comp, spec, region);
const refPath = path.join(path.dirname(specPath), 'crops', `${region.id}.png`);
fs.mkdirSync(path.dirname(refPath), { recursive: true });
fs.writeFileSync(refPath, encodePng(ref, { text: { 'impeccable:crop-of': `${spec.comp}#${region.id}` } }));
const out = arg('out', region.plate);
fs.mkdirSync(path.dirname(out), { recursive: true });
// Closest supported size to the region's aspect; the page crops the rest
// with object-fit. The plates gate demands >= 1.5x the region's width
// (capped at 1536), so a square region wider than 682px cannot ship from
// 1024x1024: take the 1536-wide landscape frame instead and let cover crop.
const aspect = region.px.w / region.px.h;
const needW = Math.min(1536, Math.ceil(region.px.w * 1.5));
let size = arg('size');
if (!size) {
if (aspect > 1.2) size = '1536x1024';
else if (aspect < 0.83) size = needW > 1024 ? '1536x1024' : '1024x1536';
else size = needW > 1024 ? '1536x1024' : '1024x1024';
}
const extra = arg('prompt') || (arg('prompt-file') ? fs.readFileSync(arg('prompt-file'), 'utf8') : '');
// Chroma: an ink-on-ground plate (a line drawing, a figure on flat ground)
// is generated on a flat key color and keyed to alpha, so the page's own
// ground shows through instead of a second, mismatched paper. Default on
// for kind plate when the comp region reads as ink over one flat ground;
// --chroma / --no-chroma force it.
const wantsChroma = process.argv.includes('--chroma') ? true : process.argv.includes('--no-chroma') ? false : (region.kind === 'plate' && inkOnGround(region));
const chromaColor = '#00ff00';
const chromaLine = wantsChroma ? ` Render the artwork on a perfectly flat, uniform bright green background (${chromaColor}) that fills every pixel not covered by the artwork; no paper texture, no vignette, no shadow on the green; the green will be removed and the artwork composited onto the page's own surface.` : '';
const prompt = [platePrompt(spec, region), extra, chromaLine].filter(Boolean).join(' ');
plateCtx = { spec, specPath, region, ref, refPath, out, size, prompt, comp, encodePng, resize, chroma: wantsChroma ? chromaColor : null };
if (process.env.IMPECCABLE_IMAGE_GEN_FAKE) {
const up = resize(ref, ref.width * 2, ref.height * 2);
fs.writeFileSync(out, encodePng(up, { text: { 'impeccable:prompt': prompt, 'impeccable:fake': '1' } }));
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'fake', plate: region.id, refs: [refPath] }, null, 2));
console.log(`PLATE: ${out} (${up.width}x${up.height}, fake 2x crop of region ${region.id}, $0.00, no API call)`);
process.exit(0);
}
// fall through to the real call below with the crop as the single --ref
}
/** A region whose crop is dominated by one ground color with a dark second: ink on ground. */
function inkOnGround(region) {
const pal = region.palette || [];
if (pal.length < 2) return false;
return pal[0].coverage >= 0.55;
}
function hexRgb(h) { const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(h); return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : [0, 255, 0]; }
/**
* Key a flat color to alpha with a soft edge: pixels within `hard` of the key
* go fully transparent, within `soft` fade, and green spill on edge pixels is
* pulled toward the ink color. Writes back in place. Returns keyed fraction.
*/
async function keyChroma(file, keyHex) {
const { decodePng, encodePng } = await import('./lib/png.mjs');
const img = decodePng(fs.readFileSync(file));
const [kr, kg, kb] = hexRgb(keyHex);
// sample the actual key from the corners: generators shift the green
const corners = [[2, 2], [img.width - 3, 2], [2, img.height - 3], [img.width - 3, img.height - 3]];
let sr = 0, sg = 0, sb = 0;
for (const [x, y] of corners) { const p = (y * img.width + x) * 4; sr += img.data[p]; sg += img.data[p + 1]; sb += img.data[p + 2]; }
const key = [sr / 4, sg / 4, sb / 4];
const isGreenish = key[1] > 120 && key[1] > key[0] * 1.4 && key[1] > key[2] * 1.4;
const K = isGreenish ? key : [kr, kg, kb];
const hard = 60, soft = 120;
let keyed = 0;
for (let i = 0; i < img.data.length; i += 4) {
const r = img.data[i], g = img.data[i + 1], b = img.data[i + 2];
const d = Math.sqrt((r - K[0]) ** 2 + (g - K[1]) ** 2 + (b - K[2]) ** 2);
// also treat "greener than both other channels by a margin" as key, for gradients the generator adds
const greenDom = g > 150 && g - Math.max(r, b) > 60;
if (d < hard || greenDom) { img.data[i + 3] = 0; keyed++; continue; }
if (d < soft) {
const a = (d - hard) / (soft - hard);
img.data[i + 3] = Math.round(img.data[i + 3] * a);
// despill: pull green down to the mean of the others on the fringe
const m = (r + b) / 2; img.data[i + 1] = Math.round(g * a + m * (1 - a));
}
}
// keep the tEXt chunks (the embedded prompt written before keying)
fs.writeFileSync(file, encodePng(img, { text: img.text && Object.keys(img.text).length ? img.text : null }));
return keyed / (img.data.length / 4);
}
async function scorePlate(ctx, outFile) {
try {
const { compare } = await import('./comp-diff.mjs');
const { decodePng } = await import('./lib/png.mjs');
let plate = decodePng(fs.readFileSync(outFile));
// a keyed plate ships over the page ground: composite it over the region's
// sampled ground before scoring, the way it will show
if (ctx.chroma) {
const { createImage, blit } = await import('./lib/raster.mjs');
const g = (ctx.region.palette && ctx.region.palette[0] && ctx.region.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(plate.width, plate.height, ground);
blit(over, plate, 0, 0);
plate = over;
}
// a plate ships under object-fit: cover, so score it the way it will show
const res = compare({ comp: ctx.ref, build: plate, align: 'cover', kind: ctx.region.kind });
const s = res.whole;
const min = arg('min') ? parseFloat(arg('min')) : null;
const line = `PLATE-SCORE ${ctx.region.id} ${(s.overall * 100).toFixed(0)}% against the comp region (structure ${(s.structure * 100).toFixed(0)}%, color ${(s.color * 100).toFixed(0)}%, detail ${(s.detail * 100).toFixed(0)}%)`;
console.log(line);
const { plateVerdict } = await import('./build-phase.mjs');
const v = plateVerdict(ctx.region, s);
if (!v.ok) console.log(`PLATE-WARN the plate does not read as region ${ctx.region.id}: ${v.reasons.join('; ')}. Open ${outFile} beside ${ctx.refPath} and regenerate before building on it; the plates gate refuses it as it stands.`);
if (min != null && s.overall < min) { console.log(`PLATE-REJECTED below --min ${(min * 100).toFixed(0)}%`); process.exit(3); }
} catch (e) {
console.log(`PLATE-SCORE unavailable: ${e.message}`);
}
}
// A comp written into .impeccable/mocks/ while a direction is dealt but the
// build phases never started is a comp round happening outside the state
// file, and every session cut after it resumes with no state to follow. The
// roll writes .impeccable/build/pending.json; build-phase.mjs start clears
// it. Refuse mock output until start has run (or --force-mock).
{
const outArg = arg('out') || (plateCtx && plateCtx.out) || '';
const intoMocks = /(^|[\\/])\.impeccable[\\/]mocks[\\/]/.test(outArg) && !/[\\/]decision[\\/]/.test(outArg);
const pending = fs.existsSync(path.join('.impeccable', 'build', 'pending.json'));
const state = fs.existsSync(path.join('.impeccable', 'build', 'state.json'));
if (intoMocks && pending && !state && !process.argv.includes('--force-mock')) {
console.error(`generate-image: a direction was chosen (concept-seed rolled) but build-phase.mjs start has not run, so this comp would be generated outside the build's state. Run: node ${path.dirname(fileURLToPath(import.meta.url))}/build-phase.mjs start --direction <seed key> --kind <assigned|pick|challenger|canon> first (it opens the comps phase), then generate. --force-mock overrides.`);
process.exit(4);
}
}
if (process.env.IMPECCABLE_IMAGE_GEN_FAKE) {
const fakePromptFile = arg('prompt-file');
const fakePrompt = fakePromptFile ? fs.readFileSync(fakePromptFile, 'utf8') : arg('prompt');
@@ -209,21 +371,21 @@ if (!key) {
process.exit(1);
}
const promptFile = arg('prompt-file');
const prompt = promptFile ? fs.readFileSync(promptFile, 'utf8') : arg('prompt');
const out = arg('out');
const prompt = plateCtx ? plateCtx.prompt : (promptFile ? fs.readFileSync(promptFile, 'utf8') : arg('prompt'));
const out = plateCtx ? plateCtx.out : arg('out');
if (!prompt || !out) {
console.error('generate-image: --prompt (or --prompt-file) and --out are required.');
process.exit(1);
}
const size = arg('size', '1536x1024');
const quality = arg('quality', 'medium');
const size = plateCtx ? plateCtx.size : arg('size', '1536x1024');
const quality = arg('quality', plateCtx ? 'high' : 'medium');
// Reference images (--ref, repeatable): route through the edits endpoint,
// which accepts input images. This is how a comp for an established world
// inherits the real UI's identity from a captured screenshot instead of a
// prose paraphrase of it; the prompt then describes the NEW surface and the
// reference carries palette, type, and component character.
const refs = (() => {
const found = [];
const found = plateCtx ? [plateCtx.refPath] : [];
for (let i = 0; i < process.argv.length; i += 1) {
if (process.argv[i] === '--ref' && process.argv[i + 1] && !process.argv[i + 1].startsWith('--')) found.push(process.argv[i + 1]);
}
@@ -269,9 +431,17 @@ fs.writeFileSync(out, Buffer.from(b64, 'base64'));
// The prompt travels with the asset: embedded in the file itself (EXIF-class
// metadata via embed-prompt.mjs) so intent survives copies across harnesses,
// plus a sidecar for anything that indexes rather than opens the image.
let embedded = false;
try {
const { spawnSync } = await import('node:child_process');
spawnSync(process.execPath, [new URL('./embed-prompt.mjs', import.meta.url).pathname, out, '--prompt', prompt], { stdio: 'ignore' });
const result = spawnSync(process.execPath, [fileURLToPath(new URL('./embed-prompt.mjs', import.meta.url)), out, '--prompt', prompt], { stdio: 'ignore' });
embedded = !result.error && result.status === 0;
if (!embedded) console.warn('generate-image: failed to embed prompt in the image');
fs.writeFileSync(`${out}.json`, JSON.stringify({ prompt, createdAt: new Date().toISOString(), tool: 'generate-image.mjs', model: 'gpt-image-2', ...(refs.length ? { refs } : {}) }, null, 2));
} catch { /* embedding is best-effort */ }
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); prompt embedded + sidecar at ${out}.json`);
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key); ${embedded ? 'prompt embedded + sidecar' : 'sidecar'} at ${out}.json`);
if (plateCtx && plateCtx.chroma) {
const frac = await keyChroma(out, plateCtx.chroma);
console.log(`PLATE-CHROMA keyed ${(frac * 100).toFixed(0)}% of pixels to alpha (${plateCtx.chroma}); place with a plain <img> over the page's own ground, no background on the plate. If the keyed fraction is under 20% the generator ignored the key: regenerate with --no-chroma and use mix-blend-mode: multiply instead.`);
}
if (plateCtx) await scorePlate(plateCtx, out);
@@ -35,6 +35,7 @@ import {
ensureHookGitExcludes,
normalizeIgnoreValue,
normalizeIgnoreValueEntries,
extractFindingIgnoreValue,
} from './hook-lib.mjs';
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
@@ -713,6 +714,10 @@ function addIgnoreValue(cwd, args) {
throw new Error(`Wildcard value ignores must be scoped with --file <glob>, e.g. ${IMPECCABLE_COMMAND} hooks ignore-value design-system-font-size "*" --file "src/widget.js". To suppress the rule project-wide use ${projectWide}.`);
}
if (parsed.value !== '*' && !extractFindingIgnoreValue({ antipattern: parsed.rule, ignoreValue: parsed.value })) {
throw new Error(`${parsed.rule} has no extractable ignore value. Use ${IMPECCABLE_COMMAND} hooks ignore-value ${parsed.rule} "*" --file <glob> to suppress it in matching files.`);
}
const local = parsed.local;
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local }));
// Key on the file scope too: the same rule/value legitimately appears more than
@@ -765,9 +770,22 @@ function reset(cwd) {
}
} catch { /* ignore */ }
}
return removed.length
? `Reset design hook config and cache (removed: ${removed.join(', ')}).`
: 'No hook config or cache to remove. Already at defaults.';
// `on` writes three things: config, consent, and hook entries in the
// provider manifests. Reset must undo all three (issue #512): a leftover
// manifest entry kept invoking the hook after the config that said "off"
// was deleted. Local destRel only, since `on` never writes the team-shared
// sharedDestRel. No skill-folder gate: a reset mid-uninstall (skill files
// gone, manifest still wired) is the case that most needs the prune.
const pruned = [];
for (const target of HOOK_MANIFEST_TARGETS) {
try {
if (pruneImpeccableHookFromManifest(path.join(cwd, target.destRel))) pruned.push(target.provider);
} catch { /* ignore */ }
}
const parts = [];
if (removed.length) parts.push(`Reset design hook config and cache (removed: ${removed.join(', ')}).`);
if (pruned.length) parts.push(`Removed hook entries from: ${pruned.join(', ')}.`);
return parts.length ? parts.join(' ') : 'No hook config or cache to remove. Already at defaults.';
}
function main() {
+177 -30
View File
@@ -43,6 +43,7 @@
* `cli/engine/detect-antipatterns.mjs` (running from source).
*/
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
@@ -210,12 +211,49 @@ export function getLocalConfigPath(cwd) {
return path.join(cwd, '.impeccable', 'config.local.json');
}
// Where mutable hook state (cache + pending) lives. Defaults to the
// project-local `.impeccable/` dir. When IMPECCABLE_CACHE_ROOT is set, state
// relocates to a per-project subdirectory of that root instead, keyed by a
// slug of the project path (`[:\\/.]` → `-`, mirroring Claude Code's
// `~/.claude/projects/` convention), so project roots stay free of tool
// artifacts (issue #422). User-authored config (config.json,
// config.local.json, design.json) deliberately stays project-local — only
// disposable state relocates.
// Read from process.env (not runHook's injected env): the cache root is a
// machine-scoped setting like CURSOR_PROJECT_DIR, not a per-invocation
// switch. Trim guards against stray whitespace in env files; `~/` (or the
// Windows `~\` spelling) expands via os.homedir(), and when no home dir can
// be determined the expansion is rejected — state falls back to the
// project-local default rather than anchoring under the hook process's cwd.
// Resolving both sides makes the slug deterministic when callers hand in a
// trailing separator or unnormalized cwd. The slug is the readable
// separator-mapped path PLUS an 8-hex sha256 of the resolved path: the
// readable part alone is lossy (`/x/my.app` and `/x/my-app` would both map
// to `-x-my-app` and share state), so the digest disambiguates while keeping
// the dir name human-scannable.
function hookStateDir(cwd) {
const raw = process.env.IMPECCABLE_CACHE_ROOT;
let root = typeof raw === 'string' ? raw.trim() : '';
if (root.startsWith('~/') || root.startsWith('~\\') || root === '~') {
let home = '';
try { home = os.homedir() || ''; } catch { home = ''; }
root = home ? path.join(home, root.slice(2)) : '';
}
if (root) {
const resolved = path.resolve(String(cwd));
const slug = resolved.replace(/[:\\/.]/g, '-');
const digest = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 8);
return path.join(path.resolve(root), `${slug}-${digest}`);
}
return path.join(cwd, '.impeccable');
}
export function getCachePath(cwd) {
return path.join(cwd, '.impeccable', 'hook.cache.json');
return path.join(hookStateDir(cwd), 'hook.cache.json');
}
export function getPendingPath(cwd) {
return path.join(cwd, '.impeccable', 'hook.pending.json');
return path.join(hookStateDir(cwd), 'hook.pending.json');
}
export function resolveProjectCwd(event, fallback = process.cwd()) {
@@ -816,9 +854,9 @@ export function splitFindingsByTier(findings) {
}
// Whether the per-edit pass for this harness should defer non-immediate
// findings to a Stop deep pass. Only Claude Code and Codex dispatch our Stop
// hook; Cursor and GitHub Copilot have no deep pass wired, so deferring for
// them would silently drop the non-immediate rules entirely.
// findings to a Stop deep pass. Claude Code, Codex, and Grok Build dispatch
// our Stop hook; Cursor and GitHub Copilot have no deep pass wired, so
// deferring for them would silently drop the non-immediate rules entirely.
export function perEditTieringActive(config, harness) {
if (harness === 'cursor' || harness === 'github') return false;
return (config?.perEditRules || DEFAULT_CONFIG.perEditRules) !== 'all';
@@ -1251,18 +1289,50 @@ export function resolveHarness(env = {}, event = null) {
const explicit = env?.IMPECCABLE_HOOK_HARNESS;
if (explicit === 'cursor') return 'cursor';
if (explicit === 'github') return 'github';
if (explicit === 'claude' || explicit === 'codex') return 'claude';
// GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
// has no `tool_name`/`tool_input`. That shape is the discriminator.
if (explicit === 'grok') return 'grok';
if (explicit === 'claude') return 'claude';
if (explicit === 'codex') return 'codex';
// Grok Build sends camelCase `toolName`/`toolInput`/`hookEventName` and no
// snake_case pair. GitHub Copilot sends camelCase `toolName`/`toolArgs`.
// Check Grok first: the old GitHub heuristic (`toolName` and no
// `tool_input`) also matches Grok, which is how live PostToolUse was
// classified as Copilot and then skipped with no-file-path (#646).
if (looksLikeGrokEnvelope(event)) return 'grok';
if (event && typeof event === 'object'
&& (typeof event.toolName === 'string' || event.toolArgs !== undefined)
&& event.tool_name === undefined && event.tool_input === undefined) {
return 'github';
}
if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
// Codex turn-scoped events carry `turn_id`. Claude Code does not. Detecting
// it here means an already-installed Codex hook emits the Codex Stop
// contract without rewriting the hook command to set IMPECCABLE_HOOK_HARNESS.
// https://developers.openai.com/codex/hooks#stop
if (typeof event?.turn_id === 'string' && event.turn_id) return 'codex';
return 'claude';
}
function looksLikeGrokEnvelope(event) {
if (!event || typeof event !== 'object') return false;
if (event.hook_event_name !== undefined
|| event.tool_name !== undefined
|| event.tool_input !== undefined) {
return false;
}
if (event.toolArgs !== undefined) return false;
if (typeof event.hookEventName === 'string') return true;
return typeof event.toolName === 'string' && event.toolInput !== undefined;
}
// Stop arrives as Claude's `hook_event_name: "Stop"` or Grok Build's
// `hookEventName: "stop"`. hook.mjs routes on the raw stdin, before any
// normalize, so both casings must match here.
export function isStopEvent(event) {
if (!event || typeof event !== 'object') return false;
const name = event.hook_event_name || event.hookEventName;
return typeof name === 'string' && name.toLowerCase() === 'stop';
}
// GitHub Copilot's postToolUse payload is
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
@@ -1354,9 +1424,36 @@ function normalizeGitHubEvent(event, projectCwd) {
};
}
// Grok Build 1.0.5 (captured 2026-08-24) sends camelCase `toolName` /
// `toolInput` / `sessionId` / `stopHookActive`, plus `cwd` alongside a
// trailing-slashed `workspaceRoot` (every consumer path.resolve()s, so no
// stripping here). Only the fields the hook reads are copied; the event
// name stays camelCase because routing already happened on the raw stdin
// (isStopEvent) and nothing downstream reads `hook_event_name`.
function normalizeGrokEvent(event, projectCwd) {
const cwd = event.cwd || event.workspaceRoot || envProjectDir(projectCwd) || projectCwd;
const sessionId = event.sessionId || event.session_id || 'unknown';
const rawInput = event.toolInput ?? event.tool_input;
const toolInput = rawInput && typeof rawInput === 'object' && !Array.isArray(rawInput)
? { ...rawInput }
: {};
const out = {
...event,
cwd,
session_id: sessionId,
tool_name: event.toolName || event.tool_name || null,
tool_input: toolInput,
};
if (event.stopHookActive !== undefined && event.stop_hook_active === undefined) {
out.stop_hook_active = event.stopHookActive;
}
return out;
}
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
if (!event || typeof event !== 'object') return event;
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
if (harness === 'grok') return normalizeGrokEvent(event, projectCwd);
if (harness !== 'cursor') return event;
const cwd = event.cwd
@@ -1959,7 +2056,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
// findings stop being remembered and a reintroduced one reads as fresh.
// Only the immediate tier is remembered: a deferred finding the per-edit
// pass never reported must still read as fresh to the Stop deep pass.
rememberFindings(cache, sessionId, filePath, immediate);
//
// Grok ignores PostToolUse stdout, so Stop is the user-visible pass.
// Remembering here would dedupe those findings out of Stop. Touch the
// file so Stop has it, and leave the finding list empty.
if (harness === 'grok') {
touchFile(cache, sessionId, filePath);
} else {
rememberFindings(cache, sessionId, filePath, immediate);
}
cacheDirty = true;
if (fresh.length > 0) {
@@ -2055,8 +2160,13 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
// touched-file list for the Stop deep pass, and an already-present
// `.impeccable/` dir marks a project that opted in. A non-UI edit, or a
// clean UI edit in a project with no Impeccable footprint, must be a
// no-op on disk (issues #344, #305).
if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) {
// no-op on disk (issues #344, #305). An existing cache file also counts
// as opted in: under IMPECCABLE_CACHE_ROOT (issue #422) state lives
// outside the project, so the project dir alone can't carry the marker —
// without this, clean-edit editCount bumps would stop persisting the
// moment state relocates. Under stock paths the cache sits inside
// `.impeccable/`, so the extra check changes nothing there.
if (deferredTotal > 0 || (cacheDirty && (fs.existsSync(path.join(projectCwd, '.impeccable')) || fs.existsSync(getCachePath(projectCwd))))) {
persistCache(projectCwd, cache);
}
@@ -2163,8 +2273,11 @@ export const STOP_MAX_FILES = 20;
* { exitCode, stdout, audit, emission? }
*
* Never throws; exits silent (and fast) when the session touched no UI
* files. Output uses the Stop hookSpecificOutput channel: additionalContext
* is delivered to the model and the conversation continues so it can act.
* files. Output goes out on the harness's Stop continuation channel: Claude
* Code and Grok Build read hookSpecificOutput.additionalContext, Codex takes
* a decision: "block" whose reason becomes the continuation prompt. Either
* way the findings reach the model and the conversation continues so it
* can act.
*/
export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), now = Date.now, detector } = {}) {
const audit = { ts: new Date(now()).toISOString(), event: 'Stop' };
@@ -2191,22 +2304,36 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
return result({ skipped: 'stdin-empty', durationMs: Date.now() - started });
}
// Claude Code's Stop-hook contract: `stop_hook_active` is true when this
// hook is being re-invoked only because a prior invocation kept the turn
// alive (here, via hookSpecificOutput.additionalContext). Re-scanning and
// re-blocking now would loop until Claude Code's consecutive-block cap
// force-ends the turn (issue #400). The prior fire already surfaced the
// findings; whether to act on them is the agent's call. Exit fast with no
// output before any scan. Only Claude Code sends this field; other
// harnesses omit it, so the strict `=== true` is a no-op for them. This
// guard makes the loop impossible regardless of the finding cache key's
// line-number sensitivity (out of scope here; see findingCacheKey).
const harness = resolveHarness(env, event);
audit.harness = harness;
event = normalizeHookEvent(event, cwd, harness);
// Stop-hook re-entry guard: `stop_hook_active` is true when this hook is
// being re-invoked only because a prior invocation kept the turn alive
// (Claude Code via hookSpecificOutput.additionalContext, Codex via a
// decision: "block" continuation). Re-scanning and re-blocking now could
// loop (issue #400). The prior fire already surfaced the findings;
// whether to act on them is the agent's call. Exit fast with no output
// before any scan. Claude Code and Codex both send this field: Codex
// mirrors the Claude contract (StopCommandInput in
// codex-rs/hooks/src/schema.rs) and latches it true for the rest of the
// turn once a block is honored (codex-rs/core/src/session/turn.rs). Grok
// sends `stopHookActive`, copied onto the snake_case field above. Cursor
// and GitHub Copilot omit the field, so the strict `=== true` is a no-op
// for them. The guard makes the loop impossible regardless of the finding
// cache key's line-number sensitivity (out of scope here; see
// findingCacheKey).
if (event.stop_hook_active === true) {
return result({ skipped: 'stop-hook-active', durationMs: Date.now() - started });
}
const harness = resolveHarness(env, event);
audit.harness = harness;
// Grok fires Stop twice: `end_turn` (the gate that can inject
// additionalContext) then an observe-only `shutdown`. A second deep
// pass would re-emit the same findings. Claude omits `reason`; only
// skip when Grok named a reason that is not end_turn.
if (harness === 'grok' && typeof event.reason === 'string' && event.reason !== 'end_turn') {
return result({ skipped: 'stop-reason', reason: event.reason, durationMs: Date.now() - started });
}
// A Stop event carries no file, so the session cwd is the project.
// Umbrella-dir launches keyed their per-edit cache to the edited file's
@@ -2241,6 +2368,7 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
const freshGroups = [];
let scanned = 0;
let cacheDirty = false;
for (const filePath of touched) {
if (scanned >= STOP_MAX_FILES) break;
if (hasPathTraversal(filePath) || SENSITIVE_PATH.test(filePath)) continue;
@@ -2261,29 +2389,39 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
let findings;
let detectorThrew = false;
const useHtmlEngine = configuredExt
? configuredExt.engine === 'html'
: (ext === '.html' || ext === '.htm');
if (useHtmlEngine && typeof det.detectHtml === 'function') {
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; }
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
} else {
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; }
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
}
// A detector failure tells us nothing about the file. Leave whatever
// was remembered alone rather than recording an empty scan as truth.
if (detectorThrew) continue;
// Full rule set: no tier split here. Config/inline ignores still apply,
// and the session dedupe drops everything the per-edit pass (or an
// earlier Stop pass) already surfaced.
const filtered = filterFindings(findings || [], content, ext, config);
const fresh = dedupeAgainstCache(filtered, cache, sessionId, filePath);
// Sync to the live scan, including empty. Remembering only `fresh`
// (or skipping the write on a clean Stop) left stale keys in place, so
// a finding that was fixed and later reintroduced never fired again.
rememberFindings(cache, sessionId, filePath, filtered);
cacheDirty = true;
if (fresh.length > 0) {
rememberFindings(cache, sessionId, filePath, fresh);
freshGroups.push({ filePath, findings: fresh });
}
}
audit.scannedFiles = scanned;
if (freshGroups.length === 0) {
if (cacheDirty) persistCache(projectCwd, cache);
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
}
@@ -2300,8 +2438,8 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
);
commitFooterShown(cache, sessionId, text);
// Fresh findings earn the cache write so the next Stop fire is silent
// unless new issues appear; the notice flags ride along.
// Persist the live finding set so the next Stop fire is silent unless
// new issues appear; the notice flags ride along.
persistCache(projectCwd, cache);
return {
exitCode: 0,
@@ -2337,6 +2475,15 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
if (harness === 'github') {
return JSON.stringify({ additionalContext: text });
}
// Codex shares Claude Code's PostToolUse additional-context shape, but its
// Stop schema rejects unknown fields. Findings that should continue the
// turn must be a top-level blocking decision.
// https://developers.openai.com/codex/hooks#stop (schema of record:
// codex-rs/hooks/src/schema.rs, StopCommandOutputWire)
if (harness === 'codex' && eventName === 'Stop') {
if (!String(text ?? '').trim()) return '';
return JSON.stringify({ decision: 'block', reason: text });
}
return JSON.stringify({
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
});
+10 -9
View File
@@ -2,15 +2,17 @@
/**
* Impeccable design hook PostToolUse + Stop entry point.
*
* Reads the Claude Code / Codex / Cursor hook event from stdin and routes by
* `hook_event_name`:
* Reads the Claude Code / Codex / Cursor / Grok Build hook event from stdin
* and routes by Stop vs everything else. Claude uses `hook_event_name:
* "Stop"`; Grok uses `hookEventName: "stop"`.
*
* - PostToolUse: runs the immediate-tier detector rules against the touched
* file and emits a system reminder via
* `hookSpecificOutput.additionalContext` when findings exist.
* `hookSpecificOutput.additionalContext` when findings exist. Grok
* discards that stdout; the scan still warms the session cache for Stop.
* - Stop: runs the FULL detector rule set over every UI file touched this
* session (the deep pass), deduped against what the per-edit pass already
* surfaced, and emits once via the Stop additionalContext channel.
* surfaced, and emits once via the harness-specific continuation channel.
*
* Contract: never break a turn. Always exit 0. Clean files emit a small ack
* unless quiet mode is enabled; a clean Stop pass is silent.
@@ -19,7 +21,7 @@
* subprocess. This file is the thin stdin/stdout adapter.
*/
import { runHook, runStopHook, writeAuditLog } from './hook-lib.mjs';
import { runHook, runStopHook, writeAuditLog, isStopEvent } from './hook-lib.mjs';
async function readStdin() {
if (process.stdin.isTTY) return '';
@@ -28,10 +30,9 @@ async function readStdin() {
return Buffer.concat(chunks).toString('utf-8');
}
function isStopEvent(stdinJson) {
function stdinIsStop(stdinJson) {
try {
const event = JSON.parse(stdinJson);
return event && typeof event === 'object' && event.hook_event_name === 'Stop';
return isStopEvent(JSON.parse(stdinJson));
} catch {
// Malformed stdin falls through to runHook, which audits the skip.
return false;
@@ -48,7 +49,7 @@ async function main() {
let stdinJson = '';
try { stdinJson = await readStdin(); } catch { /* fall through */ }
const run = isStopEvent(stdinJson) ? runStopHook : runHook;
const run = stdinIsStop(stdinJson) ? runStopHook : runHook;
const result = await run({
stdinJson,
env: inheritedEnv,
@@ -329,47 +329,37 @@ function stripBold(s) {
function extractNamedRules(lines) {
const rules = [];
const seen = new Set();
const addRule = (name, body, { allowDuplicate = false } = {}) => {
const key = name.toLowerCase();
if (!allowDuplicate && seen.has(key)) return;
seen.add(key);
rules.push({ name, body });
};
// Style A (Impeccable): "**The X Rule.** body body body" — can span lines.
const joined = lines.join('\n');
const inlineStart = /\*\*(The [^*]+?Rule)\.\*\*/g;
const inlineMatches = [];
let m;
while ((m = inlineStart.exec(joined)) !== null) {
inlineMatches.push({ name: m[1], start: m.index, end: inlineStart.lastIndex });
}
const inlineMatches = [...joined.matchAll(/\*\*(The [^*]+?Rule)\.\*\*/g)];
for (let i = 0; i < inlineMatches.length; i++) {
const mm = inlineMatches[i];
const bodyEnd = i + 1 < inlineMatches.length ? inlineMatches[i + 1].start : joined.length;
const match = inlineMatches[i];
const bodyEnd = inlineMatches[i + 1]?.index ?? joined.length;
const body = joined
.slice(mm.end, bodyEnd)
.slice(match.index + match[0].length, bodyEnd)
.replace(/\n##[^\n]*$/s, '')
.replace(/\n###[^\n]*$/s, '')
.trim();
const name = stripBold(mm.name).trim();
seen.add(name.toLowerCase());
rules.push({ name, body: stripBold(body) });
// Preserve the inline format's historical behavior: repeated inline rules
// remain visible, while the later heading and bullet formats dedupe.
addRule(stripBold(match[1]).trim(), stripBold(body), { allowDuplicate: true });
}
// Style B (Stitch): `### The "X" Rule` or `### The X Fallback`, body is the
// bullets/paragraphs until the next heading. Accept Rule / Fallback / Principle.
for (let i = 0; i < lines.length; i++) {
const h3 = lines[i].match(/^###\s+(.+?)\s*$/);
if (!h3) continue;
const headerName = stripBold(h3[1]).replace(/["“”]/g, '').trim();
for (const subsection of splitSubsections(lines).slice(1)) {
const headerName = stripBold(subsection.name).replace(/["“”]/g, '').trim();
if (!/^The\b.*\b(Rule|Fallback|Principle)\b/i.test(headerName)) continue;
if (seen.has(headerName.toLowerCase())) continue;
const bodyLines = [];
for (let j = i + 1; j < lines.length; j++) {
if (/^##\s|^###\s/.test(lines[j])) break;
bodyLines.push(lines[j]);
}
const body = stripBold(bodyLines.join('\n').replace(/\n+/g, ' ')).trim();
if (body) {
seen.add(headerName.toLowerCase());
rules.push({ name: headerName, body });
}
const body = stripBold(subsection.lines.join('\n').replace(/\n+/g, ' ')).trim();
if (body) addRule(headerName, body);
}
// Style C (Stitch bullet form): "* **The Layering Principle:** body"
@@ -379,9 +369,7 @@ function extractNamedRules(lines) {
if (!mm) continue;
const nameRaw = mm[1].replace(/[.:]\s*$/, '').replace(/["“”]/g, '').trim();
if (!/^The\b.+\b(Rule|Fallback|Principle)$/i.test(nameRaw)) continue;
if (seen.has(nameRaw.toLowerCase())) continue;
seen.add(nameRaw.toLowerCase());
rules.push({ name: nameRaw, body: stripBold(mm[2]).trim() });
addRule(nameRaw, stripBold(mm[2]).trim());
}
return rules;
@@ -0,0 +1,564 @@
/**
* 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 }; });
}
@@ -0,0 +1,130 @@
/**
* 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: "<index text every face was rendered with>",
* 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;
}
@@ -0,0 +1,246 @@
/**
* 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 <img> 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 <svg> with a snippet.
*
* `html` is the artifact source. `pathBudget` counts characters of path
* data (d="..."), points, and polyline/polygon points across the element.
*/
export function svgIllustrations(html, { iconPx = 64, pathBudget = 480, maxPaths = 8 } = {}) {
const out = [];
const re = /<svg\b([^>]*)>([\s\S]*?)<\/svg>/gi;
let m;
while ((m = re.exec(html))) {
const attrs = m[1], body = m[2];
const paths = (body.match(/<path\b/gi) || []).length + (body.match(/<(polyline|polygon|line|circle|ellipse|rect)\b/gi) || []).length;
let budget = 0;
for (const d of body.matchAll(/\sd="([^"]*)"/g)) budget += d[1].length;
for (const pts of body.matchAll(/\spoints="([^"]*)"/g)) budget += pts[1].length;
const vb = /viewBox="\s*[-\d.]+\s+[-\d.]+\s+([\d.]+)\s+([\d.]+)/.exec(attrs);
const w = /\swidth="([\d.]+)(px)?"/.exec(attrs), h = /\sheight="([\d.]+)(px)?"/.exec(attrs);
const long = Math.max(vb ? Math.max(+vb[1], +vb[2]) : 0, w ? +w[1] : 0, h ? +h[1] : 0);
const iconSized = long > 0 && long <= iconPx && paths <= maxPaths;
const uses = /<use\b/i.test(body) && paths === 0; // a sprite reference
if (uses) continue;
if (iconSized && budget <= pathBudget) continue;
if (budget <= pathBudget && paths <= maxPaths && long === 0 && !/<(text|image)\b/i.test(body)) continue; // a tiny inline glyph with no size hint
if (budget > pathBudget || paths > maxPaths || (long > iconPx && paths > 0)) {
const id = /\b(id|class|aria-label|data-region)="([^"]+)"/i.exec(attrs);
out.push({ snippet: `<svg${attrs.slice(0, 80).replace(/\s+/g, ' ')}...> (${paths} shapes, ${budget} chars of path data${long ? `, ${long}px` : ''})`, label: id ? id[2] : null, paths, budget, long });
}
}
return out;
}
@@ -0,0 +1,306 @@
/**
* 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;
}
@@ -0,0 +1,37 @@
/**
* Convert a live-config glob pattern to a RegExp.
*
* Supports `**` across path segments, `*` within one segment, and `?` for one
* character. Callers normalize project-relative paths to forward slashes.
*/
export function livePathGlobToRegex(pattern) {
let re = '';
let i = 0;
while (i < pattern.length) {
const c = pattern[i];
if (c === '*') {
if (pattern[i + 1] === '*') {
if (pattern[i + 2] === '/') {
re += '(?:.*/)?';
i += 3;
} else {
re += '.*';
i += 2;
}
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (/[.+^${}()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
return new RegExp(`^${re}$`);
}
@@ -0,0 +1,281 @@
/**
* 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 `<name>.<ext>.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}` : ''}`);
}
@@ -0,0 +1,194 @@
/**
* 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 };
}
@@ -0,0 +1,242 @@
/**
* Browser-side resolution of project detector waivers for Impeccable live mode.
*
* The live server serializes `.impeccable/config.json` + `config.local.json`
* detector ignores (plus the served-root prefixes from the inject config's
* `files` globs) into `window.__IMPECCABLE_PROJECT_IGNORES__`. This part
* resolves that config against the current page's URL path when a detect scan
* starts, so the overlay suppresses the same findings the CLI and the edit
* hook do (issue #639).
*
* Mirrors filterDetectionFindings in cli/lib/impeccable-config.mjs:
* 1. `ignoreRules` suppress a rule project-wide.
* 2. `ignoreValues` entries with `value: "*"` suppress their rule in the
* files their globs name. The CLI never applies an unscoped wildcard
* (isIgnoredFindingValue returns false for it), so neither does this.
* 3. Remaining `ignoreValues` entries match on the finding's own value;
* those are forwarded as `disabledValues` for the detector bundle to
* apply where the findings are assembled.
* 4. `ignoreFiles` globs that name the page waive it wholesale: the
* resolver reports `skipScan: true` and the detector answers the scan
* with zero findings, mirroring shouldIgnoreDetectionFile in the CLI
* and the edit hook's own ignoreFiles gate.
*
* `pageFiles`, when the server could resolve it, lists the real project
* files the inject config serves. A URL that suffix-matches exactly one of
* them takes that file as its only project identity; an ambiguous or absent
* match falls back to the served-root common ancestor below.
*
* Known gap, unchanged from PR #645: framework apps inject into source files
* (src/routes/about/+page.svelte) while scans see route URLs (/about), so
* entries scoped to source or asset paths never match a page candidate and
* are dropped. That shows the finding, which is the conservative direction.
*
* Kept separate from live-browser.js so the glob and page-scope logic can be
* unit tested in Node (tests/live-browser-ignores.test.mjs) without the full
* overlay UI bundle.
*/
(function (root) {
'use strict';
if (!root) return;
// Keep in step with normalizeIgnoreRule / normalizeIgnoreValue in
// cli/lib/impeccable-config.mjs.
function normalizeIgnoreRule(rule) {
return String(rule || '').trim().toLowerCase();
}
function normalizeIgnoreValue(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
// Keep in step with globToRegex in cli/lib/impeccable-config.mjs.
function globToRegex(glob) {
let re = '^';
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === '*') {
if (glob[i + 1] === '*') {
re += '.*';
i += 2;
if (glob[i] === '/') i += 1;
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (c === '{') {
const end = glob.indexOf('}', i);
if (end === -1) { re += '\\{'; i += 1; continue; }
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
re += `(?:${parts.join('|')})`;
i = end + 1;
} else if (/[.+^$()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
re += '$';
return new RegExp(re);
}
// The project-relative paths this page could be known as. Ignore globs are
// project-relative (prototype/foo.html) and the URL is site-relative
// (/foo.html), because a static server's root usually sits inside the
// project; `roots` carries that prefix. The server reads it from the inject
// config's own `files` globs, which already state where the served pages
// are. Do not derive it from the ignore globs: a single entry scoped to
// prototype/library/** would then lend prototype/library/ as a candidate
// prefix to every page, and that rule would suppress site-wide.
//
// Each prefixed path also contributes its slash suffixes, mirroring
// findingMatchesScopedIgnoreFile in cli/lib/impeccable-config.mjs (which
// matches globs against every path suffix of the finding's file).
//
// One live session is served by one server, so a single document root must
// sit at or above every configured page. The only prefix that can safely
// be asserted is therefore the deepest common ancestor of the glob roots.
// Treating each glob's own prefix as an identity goes wrong in both
// directions: disjoint roots (src/ and public/) invent simultaneous
// identities for one URL, so a waiver scoped to src/foo.html hides a
// finding on a page served from public/foo.html; nested roots (prototype/
// and prototype/library/, from globs at two depths in one tree) are not
// alternatives at all, and demanding a waiver match under both stops
// prototype/index.html from applying anywhere. When the globs share no
// common root, no prefix is asserted and only the URL path itself matches.
function pageCandidates(pathname, roots, pageFiles) {
let pagePath = String(pathname || '');
try {
pagePath = decodeURIComponent(pagePath);
} catch {
// Malformed percent-escape: match on the raw path rather than throwing.
}
pagePath = pagePath.replace(/^\/+/, '');
// A directory URL serves that directory's index, and the ignore globs
// name files. Without this, /news/ never matches prototype/news/index.html.
if (pagePath === '' || pagePath.endsWith('/')) pagePath += 'index.html';
const candidates = new Set();
const addSuffixes = (fullPath) => {
const parts = fullPath.split('/').filter(Boolean);
for (let i = 0; i < parts.length; i++) {
candidates.add(parts.slice(i).join('/'));
}
};
addSuffixes(pagePath);
// The served page list names the real files the inject config serves.
// A URL that suffix-matches exactly one of them has an unambiguous
// project identity; assert that identity and stop guessing from roots
// (PR #645 review: with src/ and public/ both served, /foo.html must not
// borrow src/foo.html's waivers while actually serving public/foo.html).
// Zero matches or several fall through to the common-ancestor fallback:
// ambiguity resolves toward showing the finding.
const knownPages = [];
for (const entry of Array.isArray(pageFiles) ? pageFiles : []) {
if (typeof entry !== 'string' || !entry) continue;
if (entry === pagePath || entry.endsWith('/' + pagePath)) knownPages.push(entry);
}
if (knownPages.length === 1) {
addSuffixes(knownPages[0]);
return [...candidates];
}
const prefixes = [];
for (const entry of Array.isArray(roots) ? roots : []) {
if (typeof entry !== 'string') continue;
prefixes.push(entry.split('/').filter(Boolean));
}
let common = prefixes.length > 0 ? prefixes[0] : [];
for (const segments of prefixes.slice(1)) {
let i = 0;
while (i < common.length && i < segments.length && common[i] === segments[i]) i += 1;
common = common.slice(0, i);
}
if (common.length > 0) addSuffixes(common.join('/') + '/' + pagePath);
return [...candidates];
}
function matchesScope(globs, candidates) {
return globs.some((glob) => {
let re;
try {
re = globToRegex(String(glob));
} catch {
// Malformed glob: skip it, as matchesAnyGlob does in the CLI.
return false;
}
return candidates.some((candidate) => re.test(candidate));
});
}
/**
* Resolve the serialized project ignores for one page.
*
* @param {object} options
* @param {object} options.ignores window.__IMPECCABLE_PROJECT_IGNORES__,
* in whatever state it arrived: absent, null, or hand-edited into the
* wrong shape. Every read tolerates that and degrades to no filtering.
* @param {string} options.pathname location.pathname of the scanned page.
* @returns {{ disabledRules: string[], disabledValues: Array<{rule: string, value: string}>, skipScan: boolean }}
*/
function resolveDetectIgnores({ ignores, pathname } = {}) {
const config = ignores && typeof ignores === 'object' ? ignores : {};
const asArray = (value) => (Array.isArray(value) ? value : []);
const candidates = pageCandidates(pathname, config.roots, config.pageFiles);
// detector.ignoreFiles waives whole files. When any glob names this
// page, the scan itself is skipped; rule and value lists are returned
// empty because nothing will run.
const ignoreFileGlobs = asArray(config.ignoreFiles)
.filter((glob) => typeof glob === 'string' && glob.trim());
if (ignoreFileGlobs.length > 0 && matchesScope(ignoreFileGlobs, candidates)) {
return { disabledRules: [], disabledValues: [], skipScan: true };
}
const disabledRules = new Set(
asArray(config.ignoreRules)
.filter((rule) => typeof rule === 'string')
.map(normalizeIgnoreRule)
.filter(Boolean),
);
const disabledValues = [];
for (const entry of asArray(config.ignoreValues)) {
if (!entry || typeof entry !== 'object') continue;
const rule = normalizeIgnoreRule(entry.rule);
const value = normalizeIgnoreValue(entry.value);
if (!rule || !value) continue;
const files = [
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
...asArray(entry.files).filter((glob) => typeof glob === 'string' && glob.trim()),
];
if (value === '*') {
// Wildcards suppress their rule only inside the files they name.
if (files.length > 0 && matchesScope(files, candidates)) disabledRules.add(rule);
continue;
}
if (files.length > 0 && !matchesScope(files, candidates)) continue;
disabledValues.push({ rule, value });
}
return { disabledRules: [...disabledRules], disabledValues, skipScan: false };
}
root.__IMPECCABLE_LIVE_IGNORES__ = {
version: 1,
resolveDetectIgnores,
};
})(typeof window !== 'undefined' ? window : globalThis);
@@ -11143,10 +11143,36 @@ void main() {
const scanId = String(++detectScanSeq);
activeDetectScanId = scanId;
pendingDetectScanId = scanId;
// Send the project's detector waivers with the scan so the overlay
// filters the same findings the CLI and the edit hook do (issue #639).
// live-browser-ignores.js resolves .impeccable config for this page:
// ignoreRules suppress outright, wildcard ignoreValues suppress their
// rule in the files they name, ignoreFiles that name the page skip the
// scan wholesale, and the rest match on the finding's own value inside
// the detector. Guarded twice: a stale cached live.js without the
// resolver part still scans, and a resolver that throws must not brick
// the detect toggle; both degrade to an unfiltered scan.
const ignoresApi = window.__IMPECCABLE_LIVE_IGNORES__;
let ignores = { disabledRules: [], disabledValues: [], skipScan: false };
if (typeof ignoresApi?.resolveDetectIgnores === 'function') {
try {
ignores = ignoresApi.resolveDetectIgnores({
ignores: window.__IMPECCABLE_PROJECT_IGNORES__,
pathname: location.pathname,
}) || ignores;
} catch (e) {
ignores = { disabledRules: [], disabledValues: [], skipScan: false };
}
}
window.postMessage({
source: 'impeccable-command',
action: 'scan',
config: { scanId },
config: {
scanId,
disabledRules: ignores.disabledRules || [],
disabledValues: ignores.disabledValues || [],
skipScan: ignores.skipScan === true,
},
}, '*');
}
@@ -27,6 +27,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs';
import { livePathGlobToRegex } from './lib/live-path-globs.mjs';
import {
describeInjectArtifacts,
frameworkIgnorePatterns,
@@ -364,7 +365,7 @@ export function resolveFiles(rootDir, config) {
const patterns = config.files;
const userExcludes = Array.isArray(config.exclude) ? config.exclude : [];
const allExcludes = [...HARD_EXCLUDES, ...userExcludes];
const excludeRegexes = allExcludes.map(globToRegex);
const excludeRegexes = allExcludes.map(livePathGlobToRegex);
const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath));
const isGlob = (s) => /[*?[]/.test(s);
@@ -401,47 +402,6 @@ export function resolveFiles(rootDir, config) {
return out;
}
/**
* Convert a glob pattern to a RegExp. Supports:
* ** any number of path segments (including zero)
* * any chars except `/`
* ? any single char except `/`
* Paths are normalized to forward slashes before matching.
*/
function globToRegex(pattern) {
let re = '';
let i = 0;
while (i < pattern.length) {
const c = pattern[i];
if (c === '*') {
if (pattern[i + 1] === '*') {
// ** — any number of segments, including zero. Handle the common
// **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`.
if (pattern[i + 2] === '/') {
re += '(?:.*/)?';
i += 3;
} else {
re += '.*';
i += 2;
}
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (/[.+^${}()|[\]\\]/.test(c)) {
re += '\\' + c;
i += 1;
} else {
re += c;
i += 1;
}
}
return new RegExp('^' + re + '$');
}
// ---------------------------------------------------------------------------
// Core operations
// ---------------------------------------------------------------------------
@@ -238,10 +238,9 @@ export async function completeAcceptHandling(event, base, token) {
});
} catch (err) {
event._completionAck = { ok: false, error: err.message };
return event;
}
if (!event._completionAck) {
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
}
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
return event;
}
@@ -269,9 +268,11 @@ export function printPollEvent(event) {
// Situational plumbing rides with the event itself: `_instructions` is the
// authoritative next step, with real ids and paths substituted, so the
// reference doc can stay lean and can never drift from script behavior.
if (event && typeof event === 'object' && !event._instructions) {
// A wire-supplied value must never win over the locally generated one.
if (event && typeof event === 'object') {
const instructions = instructionsForEvent(event, { scriptsPath: SELF_DIR });
if (instructions) event._instructions = instructions;
else delete event._instructions;
}
console.log(JSON.stringify(event));
}
@@ -48,6 +48,7 @@ import {
writeLiveServerInfo,
} from './lib/impeccable-paths.mjs';
import { countByPage as countPendingByPage } from './live/manual-edits-buffer.mjs';
import { collectProjectDetectorIgnores } from './live/project-ignores.mjs';
import {
createManualApplyController,
summarizeManualApplyFailures,
@@ -181,8 +182,16 @@ function chatAgentLikelyActive() {
// cap at 10 MB to guard against runaway writes from a misbehaving client.
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
const POLLER_OWNED_EVENT_FIELDS = ['_instructions', '_completionAck', '_acceptResult'];
function stripPollerOwnedEventFields(event) {
if (!event || typeof event !== 'object') return;
for (const key of POLLER_OWNED_EVENT_FIELDS) delete event[key];
}
function enqueueEvent(event) {
if (!event) return;
stripPollerOwnedEventFields(event);
// Dedupe by (session, type), except mount failures, which are per-variant:
// variant 2 failing must not be swallowed because variant 1's failure is
// still queued.
@@ -746,9 +755,20 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
commandPrefix: IMPECCABLE_COMMAND_PREFIX,
appRoot: process.cwd(),
parts,
// Read per request rather than cached, so editing the config and
// reloading the tab is enough to pick up a new waiver. Config comes
// from every root the session spans (appRoot, contextRoot, repoRoot):
// in a monorepo the hook and the CLI key it at the repo root, which
// is not the appRoot this process chdir'd onto.
projectIgnores: collectProjectDetectorIgnores({
appRoot: process.cwd(),
contextRoot: LIVE_ROOTS?.contextRoot,
repoRoot: LIVE_ROOTS?.repoRoot,
scriptsDir: __dirname,
}),
});
res.writeHead(200, {
'Content-Type': 'application/javascript',
'Content-Type': 'application/javascript; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
'Pragma': 'no-cache',
});
@@ -757,7 +777,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
}
if (p === '/detect.js' || p === '/') {
if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
res.end(detectScript);
return;
}
@@ -936,15 +956,23 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
const filePath = url.searchParams.get('path');
if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
const absPath = path.resolve(process.cwd(), filePath);
// Confine to the project root. A bare `startsWith(cwd)` string check lets a
// sibling dir whose name extends the root name (projeto -> projeto-backup)
// slip through; compare on the relative path instead (same pattern as
// sessionFileMetadataFromPollReply below). An empty rel means the request
// resolved to the root directory itself, which this file route never serves.
const rel = path.relative(process.cwd(), absPath);
let realRoot, realTarget;
try {
realRoot = fs.realpathSync(process.cwd());
realTarget = fs.realpathSync(absPath);
} catch {
res.writeHead(404); res.end('File not found'); return;
}
// Confine to the project root after symlink resolution. A bare
// `startsWith(cwd)` string check lets a sibling dir whose name extends the
// root name (projeto -> projeto-backup) slip through; compare on the
// relative path instead (same pattern as sessionFileMetadataFromPollReply
// below). An empty rel means the request resolved to the root directory
// itself, which this file route never serves.
const rel = path.relative(realRoot, realTarget);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { res.writeHead(403); res.end('Forbidden'); return; }
let content;
try { content = fs.readFileSync(absPath, 'utf-8'); }
try { content = fs.readFileSync(realTarget, 'utf-8'); }
catch { res.writeHead(404); res.end('File not found'); return; }
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(content);
@@ -1026,6 +1054,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
res.end(JSON.stringify({ error }));
return;
}
stripPollerOwnedEventFields(msg);
if (msg.type === 'agent_phase') {
recordAgentPhase(msg.id, msg.phase, {
...(Number.isFinite(msg.durationMs) ? { durationMs: msg.durationMs } : {}),
+2 -33
View File
@@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url';
import { resolveTargetSelection } from './context.mjs';
import { resolveFiles } from './live-inject.mjs';
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
import { livePathGlobToRegex } from './lib/live-path-globs.mjs';
import { resolveSurfaceBrief } from './lib/surface-briefs.mjs';
import { resolveLiveTarget } from './live-target.mjs';
import { bootInstructions } from './live/instructions.mjs';
@@ -240,7 +241,7 @@ function scanForDrift(rootDir, resolvedFiles, config) {
// Files matching the user's `exclude` globs are intentional omissions,
// not drift. Compile them to regexes so the orphan list stays signal.
const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : [])
.map((p) => globToRegex(p));
.map(livePathGlobToRegex);
const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel));
const orphans = [];
@@ -278,38 +279,6 @@ function scanForDrift(rootDir, resolvedFiles, config) {
};
}
/**
* Same glob-to-regex mapping used by live-inject.mjs. Kept inline here
* to avoid a circular import (live-inject.mjs already imports nothing
* from live.mjs). The two must stay in sync.
*/
function globToRegex(pattern) {
let re = '';
let i = 0;
while (i < pattern.length) {
const c = pattern[i];
if (c === '*') {
if (pattern[i + 1] === '*') {
if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; }
else { re += '.*'; i += 2; }
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (/[.+^${}()|[\]\\]/.test(c)) {
re += '\\' + c;
i += 1;
} else {
re += c;
i += 1;
}
}
return new RegExp('^' + re + '$');
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -6,6 +6,7 @@ import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs'
export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([
Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }),
Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }),
Object.freeze({ name: 'project-ignores', file: 'live-browser-ignores.js' }),
Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }),
]);
@@ -47,6 +48,11 @@ export function assembleLiveBrowserScript({
// so tests can assemble with a stand-in.
uiSurfaces = LIVE_UI_SURFACES,
mountContract = LIVE_CHROME_MOUNT_CONTRACT,
// Project detector waivers ({ ignoreRules, ignoreValues, roots }), read from
// .impeccable config by live-server.mjs. live-browser-ignores.js resolves
// them against the page when a detect scan starts, so the overlay filters
// the same findings the CLI and the edit hook do (issue #639).
projectIgnores = null,
}) {
const prelude =
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
@@ -66,7 +72,8 @@ export function assembleLiveBrowserScript({
// repo's tests, the impeccable-site Live UI lab) import the module directly,
// which is what keeps the two from drifting.
`window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` +
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`;
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n` +
`window.__IMPECCABLE_PROJECT_IGNORES__ = ${JSON.stringify(projectIgnores)};\n`;
const body = parts.map((part) => {
const file = part.file || path.basename(part.path || '');
@@ -0,0 +1,139 @@
/**
* Project detector waivers for the live overlay (issue #639, hardened in the
* PR #645 follow-up). One place decides what the /live.js prelude serializes
* as window.__IMPECCABLE_PROJECT_IGNORES__:
*
* ignoreRules detector.ignoreRules, unioned across every live root.
* ignoreValues detector.ignoreValues entries ({rule, value, files?}),
* deduped across roots; createdAt/reason stay local.
* ignoreFiles detector.ignoreFiles globs, unioned across roots, so a
* wholly waived page scans to zero findings in the overlay
* just as it reports nothing through the CLI and the hook.
* roots served-root prefixes derived from the inject config's own
* `files` globs. Never derived from the ignore globs: one
* entry scoped to prototype/library/** would lend
* prototype/library/ as a candidate prefix to every page,
* and that rule would suppress site-wide (issue #639).
* pageFiles the inject config's `files` expanded to real project
* files, so the browser can resolve a URL to the one file it
* actually serves instead of trying every root (PR #645
* review: with src/ and public/ both served, /foo.html must
* not borrow src/foo.html's waivers while actually serving
* public/foo.html).
*
* Config is read from every root the live session spans: the appRoot the
* server chdir'd onto, plus contextRoot and repoRoot when they differ. The
* edit hook keys the same config at the session cwd (the repo root in a
* monorepo, via resolveCacheCwd), and `impeccable detect` reads it from its
* invocation cwd, so reading only the appRoot silently dropped every waiver
* in exactly the monorepo layouts the roots manifest exists for. Reading is
* additive across roots, matching readConfig's own union of config.json and
* config.local.json.
*
* In a monorepo, roots and pageFiles are serialized repo-relative (the
* appRoot's path inside the repo is prefixed), so waivers spelled from
* either root match through the resolver's suffix expansion.
*/
import fs from 'node:fs';
import path from 'node:path';
import { readConfig } from '../hook-lib.mjs';
import { resolveFiles } from '../live-inject.mjs';
import { resolveLiveConfigPath } from '../lib/impeccable-paths.mjs';
// Serializing thousands of page identities into every /live.js response
// helps nobody; past this cap pageFiles is omitted and the resolver falls
// back to the served-root common ancestor, which is correct, just less
// precise about cross-root duplicates.
const PAGE_FILES_CAP = 500;
export function collectProjectDetectorIgnores({ appRoot, contextRoot, repoRoot, scriptsDir } = {}) {
const configRoots = [];
for (const dir of [appRoot, contextRoot, repoRoot]) {
if (typeof dir !== 'string' || !dir) continue;
const resolved = path.resolve(dir);
if (!configRoots.includes(resolved)) configRoots.push(resolved);
}
if (configRoots.length === 0) configRoots.push(process.cwd());
const ignoreRules = new Set();
const ignoreFiles = new Set();
const valueEntries = new Map();
for (const dir of configRoots) {
// readConfig merges config.json with the gitignored config.local.json
// and type-checks both, exactly as the edit hook reads the same pair.
const config = readConfig(dir);
for (const rule of Array.isArray(config.ignoreRules) ? config.ignoreRules : []) {
if (typeof rule === 'string' && rule.trim()) ignoreRules.add(rule);
}
for (const glob of Array.isArray(config.ignoreFiles) ? config.ignoreFiles : []) {
if (typeof glob === 'string' && glob.trim()) ignoreFiles.add(glob);
}
for (const entry of Array.isArray(config.ignoreValues) ? config.ignoreValues : []) {
if (!entry || typeof entry !== 'object') continue;
// readConfig already normalized rule/value and folded `file` into
// `files`; serve only what the browser matches on.
const serialized = {
rule: entry.rule,
value: entry.value,
...(Array.isArray(entry.files) && entry.files.length > 0 ? { files: entry.files } : {}),
};
const key = JSON.stringify([serialized.rule, serialized.value,
Array.isArray(serialized.files) ? [...serialized.files].sort() : []]);
if (!valueEntries.has(key)) valueEntries.set(key, serialized);
}
}
const served = readLiveServedPages({ appRoot: configRoots[0], repoRoot, scriptsDir });
return {
ignoreRules: [...ignoreRules],
ignoreValues: [...valueEntries.values()],
ignoreFiles: [...ignoreFiles],
roots: served.roots,
pageFiles: served.pageFiles,
};
}
function readLiveServedPages({ appRoot, repoRoot, scriptsDir }) {
let live = null;
try {
const configPath = resolveLiveConfigPath({ cwd: appRoot, scriptsDir });
live = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
} catch {
// No readable inject config: the browser matches URL paths as-is.
return { roots: [], pageFiles: [] };
}
const files = Array.isArray(live?.files)
? live.files.filter((glob) => typeof glob === 'string' && glob)
: [];
// A monorepo appRoot serializes identities repo-relative, so waivers
// spelled from either root match through the resolver's suffix expansion.
let prefix = '';
if (typeof repoRoot === 'string' && repoRoot) {
const rel = path.relative(path.resolve(repoRoot), path.resolve(appRoot)).split(path.sep).join('/');
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) prefix = `${rel}/`;
}
const roots = [...new Set(files.map((glob) => {
const wildcardAt = glob.search(/[*?{]/);
const head = wildcardAt === -1 ? glob : glob.slice(0, wildcardAt);
const cut = head.lastIndexOf('/');
return prefix + (cut > -1 ? head.slice(0, cut + 1) : '');
}))];
let pageFiles = [];
try {
pageFiles = resolveFiles(appRoot, { ...live, files })
.filter((rel) => {
// resolveFiles passes literal entries through even when they do not
// exist; a missing file is nobody's identity.
try { return fs.statSync(path.join(appRoot, rel)).isFile(); } catch { return false; }
})
.map((rel) => prefix + rel);
} catch {
pageFiles = [];
}
if (pageFiles.length > PAGE_FILES_CAP) pageFiles = [];
return { roots, pageFiles };
}
@@ -11,6 +11,8 @@ import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
@@ -45,11 +47,17 @@ export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
if (!hasTemplateMarkers) return null;
const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js'))
|| fs.existsSync(path.join(cwd, 'svelte.config.mjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.cjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.ts'));
const hasKitPackage = packageHasSvelteKit(cwd);
const hasSvelteConfig = Boolean(firstExistingFile(cwd, [
'svelte.config.js',
'svelte.config.mjs',
'svelte.config.cjs',
'svelte.config.ts',
]));
const hasKitPackage = hasAnyDependency(cwd, [
'@sveltejs/kit',
'@sveltejs/vite-plugin-svelte',
'svelte',
]);
if (!hasSvelteConfig && !hasKitPackage) return null;
return {
@@ -260,36 +268,16 @@ function findSvelteKitAppHtml(cwd, config) {
}
function findSvelteKitLayout(cwd) {
const candidates = [
return firstExistingFile(cwd, [
'src/routes/+layout.svelte',
'src/routes/(app)/+layout.svelte',
];
for (const rel of candidates) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return 'src/routes/+layout.svelte';
]) || 'src/routes/+layout.svelte';
}
function defaultSvelteLayout() {
return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`;
}
function packageHasSvelteKit(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
} catch {
return false;
}
}
function fileIncludes(file, text) {
try {
return fs.readFileSync(file, 'utf-8').includes(text);
@@ -19,6 +19,8 @@
import fs from 'node:fs';
import path from 'node:path';
import { firstExistingFile, hasAnyDependency } from './frameworks/detect-utils.mjs';
import { buildLiveScriptSrc } from './frameworks/script-src.mjs';
export const TANSTACK_MARKER_OPEN = '{/* impeccable-live-tanstack-start */}';
@@ -42,8 +44,8 @@ const START_PACKAGES = [
];
export function detectTanStackStartProject(cwd = process.cwd()) {
if (!packageHasTanStackStart(cwd)) return null;
const rootRoute = findRootRouteFile(cwd);
if (!hasAnyDependency(cwd, START_PACKAGES)) return null;
const rootRoute = firstExistingFile(cwd, ROOT_ROUTE_CANDIDATES);
if (!rootRoute) return null;
const ext = path.extname(rootRoute);
@@ -218,29 +220,6 @@ function isManagedComponent(content) {
return String(content || '').includes('impeccable-live-tanstack');
}
function findRootRouteFile(cwd) {
for (const rel of ROOT_ROUTE_CANDIDATES) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return null;
}
function packageHasTanStackStart(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return START_PACKAGES.some((name) => Boolean(deps[name]));
} catch {
return false;
}
}
function relativeImportSpecifier(fromFile, toFile) {
const rel = path.posix.relative(
path.posix.dirname(fromFile.split(path.sep).join('/')),
@@ -1019,7 +1019,9 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// exits on any pick and has no update channel, so a followup payload there
// still gets the goodbye screen, never a loading hand nothing will resolve.
const FOLLOWUP = ${payload.followup === true && Boolean(detachedKey) ? 'true' : 'false'};
const beat = () => { try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
const KEY = ${JSON.stringify(detachedKey || '')};
const keyQ = KEY ? '?key=' + encodeURIComponent(KEY) : '';
const beat = () => { try { navigator.sendBeacon('/heartbeat' + keyQ); } catch { fetch('/heartbeat' + keyQ, { method: 'POST' }); } };
${waiting && waitBudgetMs <= 0 ? '' : 'beat();'}
const beatTimer = setInterval(beat, 5000);
// A dead server must fail loudly: awaiting a rejected fetch here used to
@@ -1030,7 +1032,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// is in flight would overwrite the answer being collected.
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
try {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
} catch {
document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
return;
@@ -1304,7 +1306,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
};
const apply = (value) => {
set(value);
fetch('/build-path', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
fetch('/build-path' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ value }) });
if (value === 'comp') enterComp(); else exitComp();
};
// Flipping to comp starts real generation, so it confirms first; the
@@ -1472,7 +1474,7 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
// re-roll and renewed the delivery deadline.
document.querySelectorAll('.reroll-btn, #canon').forEach(b => b.setAttribute('disabled', ''));
try {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
await fetch('/answer' + keyQ, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
} catch {
document.body.innerHTML = '<div class="done">The question server went away before this choice could land.<br>Tell the agent your pick in the chat instead.</div>';
return;
@@ -1566,8 +1568,39 @@ ${buildPath?.toggle ? `<div id="bp-confirm" role="dialog" aria-modal="true" aria
</script>`;
}
// Browsers omit the :80 suffix on the default HTTP port, so a server on
// --port 80 sees bare loopback hosts and origins.
function allowedHost(host, port) {
if (host === `127.0.0.1:${port}` || host === `localhost:${port}`) return true;
return port === 80 && (host === '127.0.0.1' || host === 'localhost');
}
function allowedOrigin(origin, port) {
if (origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`) return true;
return port === 80 && (origin === 'http://127.0.0.1' || origin === 'http://localhost');
}
function rejectDetachedPost(req, res, url, port) {
if (detachedKey && url.searchParams.get('key') !== detachedKey) {
res.writeHead(401); res.end(); return true;
}
const origin = req.headers.origin;
if (origin && !allowedOrigin(origin, port)) {
res.writeHead(403); res.end(); return true;
}
return false;
}
const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/') {
const { port } = server.address();
if (!allowedHost(req.headers.host, port)) {
res.writeHead(403); res.end(); return;
}
let url;
try { url = new URL(req.url, 'http://127.0.0.1'); }
catch { res.writeHead(400); res.end(); return; }
const pathname = url.pathname;
if (req.method === 'GET' && pathname === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
// A next file the round cannot load has to leave the disk either way:
@@ -1593,7 +1626,8 @@ const server = http.createServer((req, res) => {
res.end(page(awaitingNext));
return;
}
if (req.method === 'POST' && req.url === '/heartbeat') {
if (req.method === 'POST' && pathname === '/heartbeat') {
if (rejectDetachedPost(req, res, url, port)) return;
res.writeHead(204); res.end();
server.lastBeatSeen = Date.now();
if (detachedKey) {
@@ -1609,13 +1643,13 @@ const server = http.createServer((req, res) => {
}
return;
}
if (req.method === 'GET' && req.url === '/next-status') {
if (req.method === 'GET' && pathname === '/next-status') {
const pending = nextFile();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
return;
}
const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)(?:\?.*)?$/);
const imageMatch = req.method === 'GET' && pathname.match(/^\/img\/(\d+)$/);
if (imageMatch) {
const abs = localImages[Number(imageMatch[1])];
if (!abs || !fs.existsSync(abs)) { res.writeHead(404); res.end(); return; }
@@ -1628,27 +1662,34 @@ const server = http.createServer((req, res) => {
fs.createReadStream(abs).pipe(res);
return;
}
if (req.method === 'POST' && req.url === '/build-path') {
if (req.method === 'POST' && pathname === '/build-path') {
if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end('{"ok":true}');
let value = null;
try { value = JSON.parse(body).value; } catch { /* ignore */ }
if (value !== 'comp' && value !== 'code') return;
const wasComp = liveBuildPath === 'comp';
liveBuildPath = value;
// Only a flip TO comp needs the agent mid-round: comps must start
// rendering into the declared slots. The reverse is free.
if (detachedKey && value === 'comp' && !wasComp) {
fs.mkdirSync(QUESTION_DIR, { recursive: true });
fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n');
if (value === 'comp' || value === 'code') {
const wasComp = liveBuildPath === 'comp';
liveBuildPath = value;
// Only a flip TO comp needs the agent mid-round: comps must start
// rendering into the declared slots. The reverse is free.
if (detachedKey && value === 'comp' && !wasComp) {
fs.mkdirSync(QUESTION_DIR, { recursive: true });
fs.writeFileSync(flipFile(detachedKey), JSON.stringify({ buildPath: 'comp' }) + '\n');
}
}
// Answer only once the flip is on disk. Responding first raced the
// caller: the 200 reached the client (a separate process) while this
// one could still be preempted before the write landed, so a poller
// that trusted the 200 could look for the flip file and miss it.
res.writeHead(200, { 'content-type': 'application/json' });
res.end('{"ok":true}');
});
return;
}
if (req.method === 'POST' && req.url === '/answer') {
if (req.method === 'POST' && pathname === '/answer') {
if (rejectDetachedPost(req, res, url, port)) return;
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
+3 -3
View File
@@ -2,7 +2,7 @@
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
"name": "impeccable",
"metadata": {
"description": "Design fluency for AI harnesses. 1 skill, 23 commands, and curated anti-patterns for impeccable frontend design."
"description": "Design fluency for AI harnesses. 1 skill, 24 commands, and curated anti-patterns for impeccable frontend design."
},
"owner": {
"name": "Paul Bakaus",
@@ -11,8 +11,8 @@
"plugins": [
{
"name": "impeccable",
"description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
"version": "4.1.1",
"description": "Design fluency for frontend development. 1 skill with 24 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
"version": "4.1.2",
"author": {
"name": "Paul Bakaus",
"email": "paul@paulbakaus.com"
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "impeccable",
"description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
"version": "4.1.1",
"description": "Design fluency for frontend development. 1 skill with 24 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
"version": "4.1.2",
"author": {
"name": "Paul Bakaus",
"email": "paul@paulbakaus.com"
+11 -62
View File
@@ -20,74 +20,23 @@ When the parent hands you a decision card packet instead of an approved mock, th
## Input Contract
Expect:
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.
- Approved mock path or screenshot reference.
- Crop paths or a contact sheet with crop ids.
- Output directory.
- Required dimensions, format, transparency needs, and avoid list.
- Notes on what should remain semantic HTML/CSS/SVG instead of raster.
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 the source mock is attached but has no filesystem path, use it for visual planning; ask for a path only before cropping or writing assets.
## The job
Defaults unless contradicted:
Every region with `medium: raster` in the spec ships as a plate at its `plate` path. A plate is the region regenerated at asset resolution from the comp crop as reference: same subject, same composition, same palette, same lighting and material, with the UI text and page chrome removed, at 1.5x the comp region's pixel size or more. The page draws text, controls, radius, shadow, and layout in code; the plate carries what code cannot draw. Crops from the comp are references, never shipping pixels: a comp is reference grade and a shipped crop is how a beautiful comp becomes a blurry site.
- `.webp` for opaque photos, backgrounds, and textures.
- `.png` for transparent cutouts, seals, tickets, and illustrations.
- Target production size, or at least 2x display size when dimensions are known. Never default to the small size of a full-page mock crop.
- Remove UI text, navigation, buttons, labels, and body copy.
- Keep physical marks only when the parent says they are part of the asset.
- Remove letterboxing, empty padding, baked card corners, borders, shadows, caption bands, and layout background unless the parent says those pixels are intrinsic.
- Keep the final assets directory clean: only files the build will consume. Source crops, reference crops, masks, and contact sheets go in a sibling `_sources`, `sources`, or review folder.
Per region, in the spec's order:
Ask blockers once, globally. Missing source path/crops or output directory blocks production. Exact dimensions, compression targets, retina variants, and format preferences do not; choose defaults and report them.
1. `node .claude/skills/impeccable/scripts/comp-spec.mjs --crop <id>` writes the reference crop under `.impeccable/build/crops/`.
2. Produce the plate. With the API fallback: `node .claude/skills/impeccable/scripts/generate-image.mjs --plate <id> --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 .claude/skills/impeccable/scripts/comp-spec.mjs --plate-prompt <id>` as the prompt, write the result to the plate path, then run `node .claude/skills/impeccable/scripts/embed-prompt.mjs <plate> --prompt "<the exact 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.
## Workflow
1. Inventory the full approved mock or every assigned crop.
2. Put each visual role in exactly one bucket:
- `produce`: needs generation, image editing, cleanup, cutout work, or a clean plate before it can ship.
- `direct`: ships after format conversion, compression, or renaming because the parent supplied a real standalone source: a project file, stock, or prior production art. A crop from the approved mock is never `direct`, whatever its apparent size.
- `semantic`: build in HTML/CSS/SVG/canvas, no raster output.
3. Crops from the mock are binding visual references, never shipping pixels: a full-page mock's effective resolution is reference grade, and a shipped crop, however close it looks, is how a beautiful comp becomes a blurry site. Every mock-derived asset goes through `produce` as a clean regeneration.
4. Give the parent an execution order for the `produce` bucket.
5. For produced assets, choose the least inventive strategy: image-to-image clean plate, faithful regeneration from crop reference, transparent cutout, texture/pattern reconstruction, stock/project source, or a semantic HTML/CSS/SVG recommendation when raster is wrong.
6. Use the harness's native image tool by default when generation or editing is needed; otherwise use the skill's generate-image.mjs.
7. Remove baked-in UI text, navigation, buttons, body copy, and mock chrome unless the text is part of the asset.
8. Think through the final DOM/CSS representation before generating. If CSS will own radius, clipping, shadows, borders, perspective, responsive cropping, captions, or card frames, do not bake those into the bitmap.
9. Save outputs non-destructively in the requested project directory, and leave the intent with the file: after every generation, run `node .claude/skills/impeccable/scripts/embed-prompt.mjs <asset> --prompt "<the prompt used>"` so the prompt lives inside the image itself. The build thread composes what you made and needs to know what it is looking at, and the embedding survives copies where sidecars get lost.
10. Compare each output against its source crop, opening every image by its workspace-relative path; sandboxed viewers reject absolute paths. If a review/QA tool is available, run it before the final manifest, then retry each major/fatal finding once before finalizing.
Use `texture/pattern extraction` only when the source region is already clean enough to sample as texture. If UI, cards, labels, headings, body copy, or footer chrome must be removed first, classify it as crop-derived cleanup or clean-plate work.
Use `semantic` for dashboards, charts, controls, screenshots of whole UI sections, data widgets, card chrome, app frames, icon toolbars, logos, wordmarks, and anything the final implementation can render crisply in HTML/CSS/SVG/canvas. Ship a screenshot raster only when the parent explicitly says the screenshot itself is the final asset.
Semantic does not mean ignored. For every semantic role, write a concrete implementation handoff for the parent craft agent: the DOM/component layers, CSS-owned visual treatment, SVG/canvas/icon-library pieces, responsive behavior, and which nearby produced raster assets it composes with. For logos and icons, prefer inline SVG/vector or icon-library implementation unless the parent provides a production logo raster.
## Prompt Pattern
Use this shape for image-to-image work:
```text
Use the provided crop as the approved visual reference.
Recreate the same asset as a clean reusable production image at the target component aspect ratio and at least 2x display resolution.
Preserve silhouette, object/scene perspective, camera angle, palette, lighting, material, texture, and visual role.
Remove baked-in UI copy, navigation, buttons, labels, body text, watermarks, and mock chrome unless explicitly part of the asset.
Remove letterboxing, padding, card borders, rounded clipping, CSS shadows, perspective transforms, caption bands, and layout backgrounds that the implementation should create in code.
Do not add new objects. Do not change the concept. Do not redesign the composition.
```
For transparent cutouts: use true alpha when the tool supports it; otherwise generate on a flat chroma-key color that cannot appear in the subject and post-process that color to alpha before shipping the PNG/WebP. Never ship the keyed background as the final asset.
Do not redesign. Do not add objects, restyle, or reinterpret; the comp was approved as it is. Do not touch the page code, the spec, or the comp. Do not produce anything the spec does not list; a region the parent forgot goes back as a one-line note, not a plate.
## Output Contract
Return a complete manifest, grouped by `produce`, `direct`, and `semantic`. For each asset include: `id`, `source_crop`, `output_path` when applicable, `strategy`, `prompt_used` when applicable, `dimensions`, `format`, `transparency`, `deviations`, and `qa_status`.
For each semantic row include `id`, `implementation`, `notes`, and `qa_status`. The `implementation` is a concrete build handoff, not a note that no asset was produced: name the likely HTML/CSS/SVG/canvas/icon/component pieces and the visual responsibilities code owns.
`qa_status` is `accepted`, `needs_parent_review`, or `blocked`. `accepted` only after visual comparison passes. `needs_parent_review` for cut-off subjects, unwanted borders or rounded-card chrome, letterboxing, baked semantic text, low-resolution output, perspective that should have been CSS, missing transparency, or drift from the crop. `blocked` when inputs, permissions, image capability, or asset source quality prevent a credible result.
End with `execution_order`, `blockers`, and `assumptions` sections. Keep blockers global and minimal; per-asset rows carry only asset-specific risks or decisions.
Do not modify implementation code. Do not edit the approved mock. Do not produce final page copy. The parent craft agent owns implementation and final mock fidelity.
Return one line per raster region: `<id> <plate path> <WxH> <score>% <accepted|needs_parent_review|blocked> <one-line note or ->`. 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.
+4 -4
View File
@@ -16,16 +16,16 @@ 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); 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/<id>.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.
## Checks, in order
0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-<width>.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round.
1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/review/hero-repro.png` exists: the hero reproduction checkpoint's capture at the comp's own dimensions; its absence means the reproduction phase ran unproven, a material finding. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all.
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all.
2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition.
4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport.
5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every image-native region of the approved comp shipped as a real asset, not a gradient standing in for one, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind other paint is a compliance token, not a shipped material.
5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every raster region of the spec shipped as its plate (the spec names the file; the page references it; the region's diff row is not `missing`), not a gradient, an inline SVG, or a many-vertex `clip-path` standing in for it, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind a wash is a compliance token, not a shipped material, and the detector's `buried-raster` and `organic-clip-path` findings in the packet are material fixes.
6. **Floor.** Read the craft floor's Refuse list and hold the screenshots against it: kickers and eyebrows, hard offset shadows outside a neobrutalist world, glyph icons, system display faces, gradient text, side stripes, and the rest. A banned element is a material fix even when it matches nothing in the comp: the builder loaded the same ban before writing it, and fidelity to a comp cannot authorize what the floor refuses. The parent's hook findings cover this mechanically where hooks run; this check exists because hookless harnesses reach you with none, and the last two live sessions shipped five kickers past a reviewer that never looked.
Do not run a second detector pass; mechanical findings belong to the parent's hooks.
+3 -3
View File
@@ -1,7 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 4.1.1
version: 4.1.2
user-invocable: true
argument-hint: "[shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]"
license: Apache 2.0
@@ -10,7 +10,7 @@ allowed-tools:
- Bash(node .claude/skills/impeccable/scripts/*)
---
This skill gives you the tools and permission to create design that earns to be called out-of-distribution craft: Whereas before, your design work would have been safe, timid and measured, you now approach every design task as a award-winning design director with impeccable understanding for what makes exceptional design work: production-grade code, peak creativity, a clear POV, deep understanding of the needs of the client and users, and exceptional craft.
This skill gives you the tools and permission to create design that earns to be called out-of-distribution craft: Whereas before, your design work would have been safe, timid and measured, you now approach every design task as an award-winning design director with impeccable understanding for what makes exceptional design work: production-grade code, peak creativity, a clear POV, deep understanding of the needs of the client and users, and exceptional craft.
Core principles:
- Go all out. No hedging, no shortcuts. The deliverable must be complete (except assets the user must provide).
@@ -20,7 +20,7 @@ Core principles:
## Setup
1. Run `node <skill-base-dir>/scripts/context.mjs` once per session, where `<skill-base-dir>` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .claude/skills/impeccable/scripts/...` command in this skill and its references, and `.claude/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target <path>`. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it.
2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing.
2. Load the request's playbook: its Commands-table reference for an explicit/implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Inspect target and incumbent visual truth before editing. When the app cannot run, start with committed visual-regression goldens or screenshot fixtures; verify target and freshness against current tokens, CSS, components, or assets, resolve conflicts, and compare theme/variant captures.
3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work.
## How to design
@@ -1,6 +1,6 @@
### Purpose
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive/backlog for future commands.
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive of that run.
### Hard Invariants
@@ -84,7 +84,7 @@ After Assessment B returns usable CLI findings, reuse them. Do not rerun `detect
Synthesize both assessments into a single report. Do NOT simply concatenate. Weave the findings together, noting where the LLM review and detector agree, where the detector caught issues the LLM missed, and where detector findings are false positives.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is only an archive/backlog for later commands.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is an archive of that run.
Structure your feedback as a design director would:
@@ -197,7 +197,7 @@ Skip this step if the Setup slug was null (vague or root-level target).
IMPECCABLE_CRITIQUE_META='{"target":"<user phrasing>","total_score":<n>,"max_score":<n>,"na_heuristics":"<comma-separated numbers, or empty>","p0_count":<n>,"p1_count":<n>}' \
node .claude/skills/impeccable/scripts/critique-storage.mjs write "<resolved target>" <body-file>
```
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. The helper prints the absolute path it wrote.
`max_score` is the applicable maximum from the heuristic table (40 when every heuristic applied), so a later run can tell a renormalized total from a full one. For a local file target, the helper also records an exact content fingerprint so polish can distinguish the assessed bytes from later edits without relying on Git state or timestamps. The helper prints the absolute path it wrote. Leave that file on disk. Polish closes it; this run does not.
3. **Delete the temp body file** after the write attempt completes, whether the write succeeded or failed. If deletion fails, mention `temp-file cleanup failed: <reason>` briefly in the final output, but do not block the critique.
@@ -15,74 +15,23 @@ When the parent hands you a decision card packet instead of an approved mock, th
## Input Contract
Expect:
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.
- Approved mock path or screenshot reference.
- Crop paths or a contact sheet with crop ids.
- Output directory.
- Required dimensions, format, transparency needs, and avoid list.
- Notes on what should remain semantic HTML/CSS/SVG instead of raster.
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 the source mock is attached but has no filesystem path, use it for visual planning; ask for a path only before cropping or writing assets.
## The job
Defaults unless contradicted:
Every region with `medium: raster` in the spec ships as a plate at its `plate` path. A plate is the region regenerated at asset resolution from the comp crop as reference: same subject, same composition, same palette, same lighting and material, with the UI text and page chrome removed, at 1.5x the comp region's pixel size or more. The page draws text, controls, radius, shadow, and layout in code; the plate carries what code cannot draw. Crops from the comp are references, never shipping pixels: a comp is reference grade and a shipped crop is how a beautiful comp becomes a blurry site.
- `.webp` for opaque photos, backgrounds, and textures.
- `.png` for transparent cutouts, seals, tickets, and illustrations.
- Target production size, or at least 2x display size when dimensions are known. Never default to the small size of a full-page mock crop.
- Remove UI text, navigation, buttons, labels, and body copy.
- Keep physical marks only when the parent says they are part of the asset.
- Remove letterboxing, empty padding, baked card corners, borders, shadows, caption bands, and layout background unless the parent says those pixels are intrinsic.
- Keep the final assets directory clean: only files the build will consume. Source crops, reference crops, masks, and contact sheets go in a sibling `_sources`, `sources`, or review folder.
Per region, in the spec's order:
Ask blockers once, globally. Missing source path/crops or output directory blocks production. Exact dimensions, compression targets, retina variants, and format preferences do not; choose defaults and report them.
1. `node .claude/skills/impeccable/scripts/comp-spec.mjs --crop <id>` writes the reference crop under `.impeccable/build/crops/`.
2. Produce the plate. With the API fallback: `node .claude/skills/impeccable/scripts/generate-image.mjs --plate <id> --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 .claude/skills/impeccable/scripts/comp-spec.mjs --plate-prompt <id>` as the prompt, write the result to the plate path, then run `node .claude/skills/impeccable/scripts/embed-prompt.mjs <plate> --prompt "<the exact 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.
## Workflow
1. Inventory the full approved mock or every assigned crop.
2. Put each visual role in exactly one bucket:
- `produce`: needs generation, image editing, cleanup, cutout work, or a clean plate before it can ship.
- `direct`: ships after format conversion, compression, or renaming because the parent supplied a real standalone source: a project file, stock, or prior production art. A crop from the approved mock is never `direct`, whatever its apparent size.
- `semantic`: build in HTML/CSS/SVG/canvas, no raster output.
3. Crops from the mock are binding visual references, never shipping pixels: a full-page mock's effective resolution is reference grade, and a shipped crop, however close it looks, is how a beautiful comp becomes a blurry site. Every mock-derived asset goes through `produce` as a clean regeneration.
4. Give the parent an execution order for the `produce` bucket.
5. For produced assets, choose the least inventive strategy: image-to-image clean plate, faithful regeneration from crop reference, transparent cutout, texture/pattern reconstruction, stock/project source, or a semantic HTML/CSS/SVG recommendation when raster is wrong.
6. Use the harness's native image tool by default when generation or editing is needed; otherwise use the skill's generate-image.mjs.
7. Remove baked-in UI text, navigation, buttons, body copy, and mock chrome unless the text is part of the asset.
8. Think through the final DOM/CSS representation before generating. If CSS will own radius, clipping, shadows, borders, perspective, responsive cropping, captions, or card frames, do not bake those into the bitmap.
9. Save outputs non-destructively in the requested project directory, and leave the intent with the file: after every generation, run `node .claude/skills/impeccable/scripts/embed-prompt.mjs <asset> --prompt "<the prompt used>"` so the prompt lives inside the image itself. The build thread composes what you made and needs to know what it is looking at, and the embedding survives copies where sidecars get lost.
10. Compare each output against its source crop, opening every image by its workspace-relative path; sandboxed viewers reject absolute paths. If a review/QA tool is available, run it before the final manifest, then retry each major/fatal finding once before finalizing.
Use `texture/pattern extraction` only when the source region is already clean enough to sample as texture. If UI, cards, labels, headings, body copy, or footer chrome must be removed first, classify it as crop-derived cleanup or clean-plate work.
Use `semantic` for dashboards, charts, controls, screenshots of whole UI sections, data widgets, card chrome, app frames, icon toolbars, logos, wordmarks, and anything the final implementation can render crisply in HTML/CSS/SVG/canvas. Ship a screenshot raster only when the parent explicitly says the screenshot itself is the final asset.
Semantic does not mean ignored. For every semantic role, write a concrete implementation handoff for the parent craft agent: the DOM/component layers, CSS-owned visual treatment, SVG/canvas/icon-library pieces, responsive behavior, and which nearby produced raster assets it composes with. For logos and icons, prefer inline SVG/vector or icon-library implementation unless the parent provides a production logo raster.
## Prompt Pattern
Use this shape for image-to-image work:
```text
Use the provided crop as the approved visual reference.
Recreate the same asset as a clean reusable production image at the target component aspect ratio and at least 2x display resolution.
Preserve silhouette, object/scene perspective, camera angle, palette, lighting, material, texture, and visual role.
Remove baked-in UI copy, navigation, buttons, labels, body text, watermarks, and mock chrome unless explicitly part of the asset.
Remove letterboxing, padding, card borders, rounded clipping, CSS shadows, perspective transforms, caption bands, and layout backgrounds that the implementation should create in code.
Do not add new objects. Do not change the concept. Do not redesign the composition.
```
For transparent cutouts: use true alpha when the tool supports it; otherwise generate on a flat chroma-key color that cannot appear in the subject and post-process that color to alpha before shipping the PNG/WebP. Never ship the keyed background as the final asset.
Do not redesign. Do not add objects, restyle, or reinterpret; the comp was approved as it is. Do not touch the page code, the spec, or the comp. Do not produce anything the spec does not list; a region the parent forgot goes back as a one-line note, not a plate.
## Output Contract
Return a complete manifest, grouped by `produce`, `direct`, and `semantic`. For each asset include: `id`, `source_crop`, `output_path` when applicable, `strategy`, `prompt_used` when applicable, `dimensions`, `format`, `transparency`, `deviations`, and `qa_status`.
For each semantic row include `id`, `implementation`, `notes`, and `qa_status`. The `implementation` is a concrete build handoff, not a note that no asset was produced: name the likely HTML/CSS/SVG/canvas/icon/component pieces and the visual responsibilities code owns.
`qa_status` is `accepted`, `needs_parent_review`, or `blocked`. `accepted` only after visual comparison passes. `needs_parent_review` for cut-off subjects, unwanted borders or rounded-card chrome, letterboxing, baked semantic text, low-resolution output, perspective that should have been CSS, missing transparency, or drift from the crop. `blocked` when inputs, permissions, image capability, or asset source quality prevent a credible result.
End with `execution_order`, `blockers`, and `assumptions` sections. Keep blockers global and minimal; per-asset rows carry only asset-specific risks or decisions.
Do not modify implementation code. Do not edit the approved mock. Do not produce final page copy. The parent craft agent owns implementation and final mock fidelity.
Return one line per raster region: `<id> <plate path> <WxH> <score>% <accepted|needs_parent_review|blocked> <one-line note or ->`. 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.
@@ -11,16 +11,16 @@ 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); 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/<id>.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.
## Checks, in order
0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-<width>.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round.
1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/review/hero-repro.png` exists: the hero reproduction checkpoint's capture at the comp's own dimensions; its absence means the reproduction phase ran unproven, a material finding. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all.
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all.
2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition.
4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport.
5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every image-native region of the approved comp shipped as a real asset, not a gradient standing in for one, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind other paint is a compliance token, not a shipped material.
5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every raster region of the spec shipped as its plate (the spec names the file; the page references it; the region's diff row is not `missing`), not a gradient, an inline SVG, or a many-vertex `clip-path` standing in for it, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind a wash is a compliance token, not a shipped material, and the detector's `buried-raster` and `organic-clip-path` findings in the packet are material fixes.
6. **Floor.** Read the craft floor's Refuse list and hold the screenshots against it: kickers and eyebrows, hard offset shadows outside a neobrutalist world, glyph icons, system display faces, gradient text, side stripes, and the rest. A banned element is a material fix even when it matches nothing in the comp: the builder loaded the same ban before writing it, and fidelity to a comp cannot authorize what the floor refuses. The parent's hook findings cover this mechanically where hooks run; this check exists because hookless harnesses reach you with none, and the last two live sessions shipped five kickers past a reviewer that never looked.
Do not run a second detector pass; mechanical findings belong to the parent's hooks.
+4 -4
View File
@@ -2,9 +2,9 @@
Manage the **design detector hook** for the current project.
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write. Grok Build fires the same PostToolUse scan to mark touched files, then surfaces findings on Stop `additionalContext`. Do not expect a Grok per-edit reminder: Grok discards that stdout.
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit.
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code, Codex, and Grok Build, which dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit. Grok also fires an observe-only Stop with `reason: "shutdown"` after `end_turn`; skip that one, scan only `end_turn`.
Every hook is a mechanical pass. The reflexes no scanner catches live in [craft-floor.md](craft-floor.md), which the skill loads before it edits UI, so they apply whether or not a hook is wired. A session with no automatic hook gets one `MANUAL_DETECTOR_REQUIRED` directive from `context.mjs` asking for a single detector run at the end.
@@ -14,7 +14,7 @@ Declare server-side template extensions under **`detector.extensions`** when the
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), Grok Build (`.grok/hooks/impeccable.json` in the project; requires `/hooks-trust` or `--trust`), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands.
@@ -32,7 +32,7 @@ The first argument is the action. Defaults to `status`.
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. |
| `ignore-value <id> "*" --file <glob> [--file <glob>...]` | Turn one rule off in matching files only, leaving it active everywhere else. Repeat `--file`, or use `--file=<glob>` / `--files=<glob>`. A bare `"*"` with no `--file` is refused: use `ignore-rule <id>` if you really mean project-wide. |
| `reset` | Delete the project config, dedup cache, and Cursor pending queue. |
| `reset` | Delete the project config, dedup cache, and Cursor pending queue, and remove the hook's entries from every provider manifest `on` installs, the committed Copilot file included (a team-shared `settings.json` that `on` never writes is never touched). |
## Flow
+37 -12
View File
@@ -46,7 +46,7 @@ The script deals three of your structures; the dice pick which three reach the u
4. Run `node .claude/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen.
5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLES PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .claude/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .claude/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
@@ -70,16 +70,20 @@ Your measured rendition prior: warm, bookish, family, and child-facing subjects
## 5. Record the decision
Before code, state the chosen direction as a contract in the artifact's opening comment, five short blocks, 150 words at most, in a form that survives the production build: an HTML comment in the emitted markup, never only a templating-frontmatter comment, placed as the first child of the document's body in the root layout, never inside a slotted or child component (some compilers, Astro among them, strip a slot's leading comment while keeping deeper ones). After the first production build, grep the built output for the seed key; a contract the build erased is a contract nobody can audit. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, and the seed key the script printed. Close with one more line, FINISH: the run's exit condition, verbatim "unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance". The comment tops the artifact you re-open on every edit, the one reminder that survives a long build: a page that looks complete with the FINISH line undischarged is not done, it is abandoned at the finish line. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
Before code, record the chosen direction as a development-only contract under `## Direction contract` in the relevant surface brief. A direction contract is durable route or artifact strategy, so create or update the brief even when no other surface strategy needs persistence. Keep the contract to six short blocks and 150 words at most. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, and the seed key the script printed. Close with one more line, FINISH: the run's exit condition, verbatim "unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance". The surface brief is the reminder later agents reload across edits and sessions: a page that looks complete with the FINISH line undischarged is not done, it is abandoned at the finish line. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
Never copy the direction contract into implementation source or any browser-delivered artifact. This includes HTML or framework comments, hidden DOM, `<template>` elements, `data-*` attributes, rendered JSX or TSX output, serialized props or state, React Server Component payloads, client bundles, metadata or JSON-LD, accessibility-only text, and files served beside the artifact. A compiler or optimizer removing development metadata is not a safety boundary. Reviewers and documenters receive the contract from the surface brief.
On a new or replacement world, DESIGN.md is written at finish, from the built world, by the shipped documenter (section 7); a rulebook written before the build gets defended against reality instead of describing it, and hands the design-system detector an unstable target. A new world shipped with no DESIGN.md is still an incomplete run. An ordinary extension does not rewrite DESIGN.md.
If the work establishes durable strategy for a route or artifact, read its existing surface brief, then update it:
Read the existing surface brief before updating it:
`node .claude/skills/impeccable/scripts/surface-brief.mjs read <primary-target>`
`node .claude/skills/impeccable/scripts/surface-brief.mjs write <primary-target> <body-file> [related-target ...]`
After writing, read the brief once more and verify that all six contract blocks and the seed key are present before building.
Keep the brief small: scope and visitor mode; audience, job, action/task, proof/content, and constraints; chosen direction and memorable moment; unresolved decisions. Do not copy global product truth or DESIGN.md tokens into it.
On a comp-led build, whenever any image generation is available (a harness-native tool or the API fallback context.mjs reports), the locked direction is visualized before it is built, never skipped: load [visualize.md](visualize.md) and follow it, three compositional options put before the user for approval, the chosen card's decision comp plus two variations. This step is proven to produce the most compositional and ambitious work. On a code-led build the comp round is skipped by contract, never by drift: the ambition it would have carried lives in the direction contract's FIRST VIEWPORT block and named signature interaction, and the finish reviewer audits those promises in behavior.
@@ -88,30 +92,51 @@ For `shape`, return the selected direction to [shape.md](shape.md) and stop befo
## 6. Build with full commitment
When an approved comp exists, the comp is king, and the build happens in phases. The comp is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words, and difficulty never infers a downgrade. Phase one is reproduction: rebuild the comp at its own breakpoint until a screenshot at the comp's width and height overlaps it near pixel-perfectly, materials, components, elevation, assets, and implied design language included. Exactly three concessions exist: fonts (the closest obtainable face), icons (exact match unless the user already chose an icon library), and genuine defects in the generated comp such as spelling errors. Everything else must match, and models systematically believe their HTML, CSS, and SVG recreation succeeded when it did not, so the overlap comparison is the authority, never your conviction: set the screenshot beside the freshly reopened comp image at identical dimensions after every region, never beside your memory of it, and when a region keeps losing that comparison, stop recreating it in code and produce it as a rendered asset composited into the page. The comp also outranks every written record of it: when the recorded brief or inventory commits to less than the comp shows, a softer texture, a sparser field, a sculpted plate reduced to flat CSS, correct the record upward to the comp; qualifiers like subtle, restrained, and low-contrast, and counts rounded down to a comfortable fraction, are how approved materials die between approval and build. A produced material must then survive to the screen: a texture buried under a nearly opaque color wash ships the wash, not the material, so judge every material by the screenshot beside the comp, never by the stylesheet. Every color the brief records gets that comparison by number, not by eye: sample the build screenshot's ground, dominant fields, and accents the same way each record was taken (an interior patch average where the record is an average, both end colors where the record is a gradient) and set each value against its recorded counterpart (sampled from the comp itself when the brief lacks one), and when a texture or tile paints over a base token, measure the net on-screen value, because the eye files a drifted color under the same color word and the number is what catches it. Judge the gap like a colorist, not a diff tool: a difference with a color name (warmer, grayer, darker than the record) is drift to fix, while a few digits of render and compression noise are the same color. Only when reproduction holds does phase two begin: static regions that should live become animated or interactive, reveals and motion are added, then responsiveness across the surface's devices. Where the comp does not cover the whole surface, continue building the remainder inside the comp's recorded world and design language; a component the comp never shows inherits the recorded system's corner language, line weights, and materials, and may not introduce container styles, border weights, or chrome the comp never uses.
Build the assigned direction, not a safer interpretation of it. The form supplies structure, reading order, component conventions, and native motion; the product supplies every fact. Commit every atom: nav, buttons, inputs, and links are rebuilt in the form's vocabulary, and a stock component inside a committed form is a lapse. Land the first build fully committed; the passes that follow exist to make the committed thing clear and effective, never to dilute it. In unattended work, the safe rendition is the known risk.
Build the assigned direction, not a safer interpretation of it. The form supplies structure, reading order, component conventions, and native motion; the product supplies every fact. Commit every atom: nav, buttons, inputs, and links are rebuilt in the form's vocabulary, and a stock component inside a committed form is a lapse. Land the first build fully committed; committing is the hard part, and the passes that follow exist to make the committed thing clear and effective, never to dilute it. In unattended work, the safe rendition is the known risk.
### Comp-led: the comp is a measured contract
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 .claude/skills/impeccable/scripts/build-phase.mjs start --direction <seed key> --kind <assigned|pick|challenger|canon>` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp <approved comp>` when a surface round already locked one.
Then, in order, each closed by `node .claude/skills/impeccable/scripts/build-phase.mjs advance` (every script below lives under `.claude/skills/impeccable/scripts/` and runs with `node`; exit 2 means the gate failed and printed why; fix that and advance again; write nothing for a later phase while an earlier gate is open):
0. **comps.** The comp round from [visualize.md](visualize.md): three compositional comps of the requested surface at its own viewport under `.impeccable/mocks/`, each with a prompt sidecar, put in front of the user; the chosen one's sidecar gets `"approved": true`. The gate counts them and reads the approval; a `start --comp` skips this phase because it already happened.
The comp-led path is a frontier-tier job: it asks the builder to hold a measured layout, place plates at their boxes, and act on numeric readings across a dozen attempts. Smaller or faster models produce a recognisable page and stall under the hero gate; if the model in hand is one of those, say so before the direction round and take the code-led path, or expect the run to end at the hero with its readings unmet.
1. **spec.** Measure the comp: `comp-spec.mjs --comp <comp> --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `comp-spec.mjs --comp <comp> --regions <file>`. The spec carries each region's box, sampled palette, and medium; `comp-spec.mjs --print` is the build's reference from here on. Type is measured, not guessed: `font-match.mjs --measure <text region>` reads the comp's cap height, width class, and weight off the pixels, and `font-match.mjs --rank <region> --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors.
2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (ink on flat ground is generated on a chroma key and keyed to alpha, so it sits on the page's own ground rather than a second paper); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `generate-image.mjs --plate <id>` does one region end to end and scores it against the crop; a harness-native image tool takes the crop (`comp-spec.mjs --crop <id>`) as its input image and `comp-spec.mjs --plate-prompt <id>` as its prompt, then `embed-prompt.mjs`. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason.
3. **hero.** `build-phase.mjs scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r-<id>-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 `<img>`, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `build-phase.mjs record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `comp-diff.mjs`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no 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.
### Code-led
No comp and no apology for it: the ambition lives in the direction contract's FIRST VIEWPORT block and the named signature interaction, and the finish reviewer audits those promises in behavior. The chosen decision comp rides to the finish review as the critique reference.
### Both paths
- **The first viewport is a thesis, not a header.** Demonstrate the mechanism immediately, at the scale the form has in life; do not trap the concept inside a standard hero or card shell. The memory test: if someone left after one viewport, what would they describe an hour later? If the honest answer is a mood, the concept has not committed yet.
- **Prove the hero before building past it.** When an approved comp exists, render the first viewport, capture it at the comp's own pixel dimensions, and set it beside the comp's first viewport before any later section: the hero carries the run's ambition, and every following section inherits its shortfall. Save that capture as `.impeccable/review/hero-repro.png` (create the directory); the finish reviewer verifies it exists, so a skipped checkpoint is a visible checkpoint. Judge scale and density as quantities, a field at a tenth of the comp's coverage or type at half its weight is a different design, and a five-minute retry here is what a rebuild verdict at the finish costs when this check is skipped.
- **Prove, don't claim.** Show the subject doing its job: the interface at work, the mechanism dramatized, specifics a competitor could not copy-paste. Sections that restate a claim in different words add length, not substance. Demonstration data is design material: author it at full fidelity and label it synthetic; claims stay uninventable.
- **Author the assets; never substitute chrome.** Great surfaces live on carefully made content: names, entries, copy, covers, thumbnails, textures. In greenfield work every blank the ask round left open is yours to author at production fidelity; content is authorable, claims are labelable, no section is omittable. An unanswered commercial claim ships as a clearly marked placeholder on the user's replacement list. When image generation exists, producing the design's imagery is part of building, at the scale the composition needs: a viewport that wants atmosphere gets a full-bleed layered scene, and a library of small centered subjects standardized for tidiness forecloses it. Gradients, glass, and generic icon tiles where an authored asset belongs are the gap wearing chrome; icons drawn in the world's own grammar are the remedy, not the target.
- **Build the form's web leverage.** When the chosen world names a technique (canvas, WebGL, view transitions, generative motion), build the technique itself, not a static imitation of it; the graceful fallback serves constrained clients, it is not the default experience.
- **Prove, don't claim.** Show the subject doing its job: the interface at work, the mechanism dramatized, specifics a competitor could not copy-paste. Demonstration data is design material: author it at full fidelity and label it synthetic; claims stay uninventable.
- **Author the assets; never substitute chrome.** Great surfaces live on carefully made content: names, entries, copy, covers, thumbnails, textures. In greenfield work every blank the ask round left open is yours to author at production fidelity; content is authorable, claims are labelable, no section is omittable. Gradients, glass, generic icon tiles, and many-vertex `clip-path` polygons where an authored asset belongs are the gap wearing chrome; the detector flags the last two.
- **Build the form's web leverage.** When the chosen world names a technique (canvas, WebGL, view transitions, generative motion), build the technique itself, not a static imitation of it.
- **Pace the scroll like a studio.** Vary density, scale, image, motion, and quiet inside one grammar; a dense passage earns a quiet one, and the page ends anchored by a real close. One spacing rhythm throughout, with more space above a heading than below it.
- **Use real, verified imagery when the brief implies it.** Search for the subject's physical object rather than the category; one decisive photo beats five mediocre ones. Verify stock URLs resolve.
- **Author motion as material.** The form has native motion, what it does in life between states; give the page that motion once, orchestrated, rather than scattered hover effects. Bound expensive effects and keep content visible by default.
- **Author motion as material.** Give the page the form's native motion once, orchestrated, rather than scattered hover effects. Bound expensive effects and keep content visible by default.
Preserve semantics, accessibility, performance, responsiveness, project conventions, and working behavior.
## 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. When an approved comp exists, the critique is a side-by-side: view the comp region and the build region together, the hero and each section as its own crop at legible scale, never one full-page thumbnail, which hides exactly the failures that matter, crude controls, wrong lettering character, flattened material, behind a superficially similar section order. 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 `node .claude/skills/impeccable/scripts/comp-diff.mjs --comp <approved 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.
After the second inspection round the build thread's polishing is over: no further defect hunts, micro-edit scripts, or rebuilds here; whatever remains ships through the handoffs, where a fresh context does the finding better and cheaper. On the web, where this harness runs no design hook, run `node .claude/skills/impeccable/scripts/detect.mjs --json` on the changed targets once here, fix what is mechanical, and pass the remaining findings to the reviewer; a hookless web build that skips this ships every tell the hook exists to catch. A native platform skips the detector entirely: it reads HTML and CSS and has no verdict on native code, so the reviewer's floor check is the only slop gate and the input packet says so. Capture the screenshots into `.impeccable/review/`, one file per captured viewport (on the web, `desktop.png` and `mobile.png`, plus `user-<width>.png` whenever the user's viewport joined the inspected set; on native, one per device class, such as `phone.png` and `tablet.png`, suffixed per OS on adaptive), creating that directory when the harness does not; the paths you pass the reviewer are its spec, every viewport you inspected is named required in the packet, and that directory is where it looks when a passed path is missing.
Then spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccable_finish_reviewer` in codex; `/impeccable-finish-reviewer` in Cursor; on GitHub Copilot say "Use the impeccable-finish-reviewer agent"), with the original request, confirmed answers, the artifact path, the screenshot paths, the direction contract, existing hook findings, the QUALITY BAR card and approved comp paths (a code-led build has no approved comp; the chosen decision comp rides in that slot as the critique reference, named as such), the craft-floor reference path, and on a native platform the platform reference path(s), [ios.md](ios.md) / [android.md](android.md), both on adaptive, plus one line saying no detector ran, so the reviewer judges in the platform's conventions rather than the web's. The reviewer has no browser; screenshots you fail to pass are checks it cannot run. Never read the shipped agents' definition files before spawning; the harness loads them at spawn, and you owe only the input packet. Wait on any agent with one long timeout rather than a loop of short polls, and spend the wait on the next independent step. Verify the return carries the five contract sections (a recapture return carries one, its recapture list); on an empty or thrashed return, respawn once with the same inputs. This review never runs inside the build thread and never inherits it: spawn the reviewer fresh, with no forked conversation history (`fork_turns: 0` in codex); a reviewer that inherits your transcript inherits your framing, your optimism, and your abstractions, and everything it needs travels in the inputs above. Only a harness with no subagent capability at all substitutes a fresh in-thread pass after stepping fully out of the build context, run from [degraded/finish-reviewer.md](degraded/finish-reviewer.md), and a substituted or failed-and-replaced review is disclosed in one line at finish, never silently.
Then spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccable_finish_reviewer` in codex; `/impeccable-finish-reviewer` in Cursor; on GitHub Copilot say "Use the impeccable-finish-reviewer agent"), with the original request, confirmed answers, the artifact path, the screenshot paths, the direction contract, existing hook findings, the QUALITY BAR card and approved comp paths (a code-led build has no approved comp; the chosen decision comp rides in that slot as the critique reference, named as such), on a comp-led build the build state (`.impeccable/build/state.json`), the spec, and the diff directories (`.impeccable/review/diff/hero/` and `.impeccable/review/diff/final/`, whose side-by-side, heatmap, region pairs, and `report.json` are the fidelity evidence), the craft-floor reference path, and on a native platform the platform reference path(s), [ios.md](ios.md) / [android.md](android.md), both on adaptive, plus one line saying no detector ran, so the reviewer judges in the platform's conventions rather than the web's. The reviewer has no browser; screenshots you fail to pass are checks it cannot run. Never read the shipped agents' definition files before spawning; the harness loads them at spawn, and you owe only the input packet. Wait on any agent with one long timeout rather than a loop of short polls, and spend the wait on the next independent step. Verify the return carries the five contract sections (a recapture return carries one, its recapture list); on an empty or thrashed return, respawn once with the same inputs. This review never runs inside the build thread and never inherits it: spawn the reviewer fresh, with no forked conversation history (`fork_turns: 0` in codex); a reviewer that inherits your transcript inherits your framing, your optimism, and your abstractions, and everything it needs travels in the inputs above. Only a harness with no subagent capability at all substitutes a fresh in-thread pass after stepping fully out of the build context, run from [degraded/finish-reviewer.md](degraded/finish-reviewer.md), and a substituted or failed-and-replaced review is disclosed in one line at finish, never silently.
Act on the disposition word; there are exactly four. **recapture**: the evidence failed, not the build. Recapture what the return names under the capture-validity rules, then run a full review over the new evidence. A review conducted on invalid evidence binds nothing, and a verdict pass may never follow it. **rebuild**: fidelity failed wholesale, not in patches. Skip the fix batch and execute the rebuild immediately: re-derive the named regions, produce the named assets, and send the result back for a fresh full review, never a verdict pass; a rebuild replaces regions wholesale, so the whole matrix runs again over the recaptures. Tell the user what is happening rather than asking permission to fix a failure. Consult the user only on a second rebuild directive, both verdicts on the table, or when rebuilding would discard content the user approved. **ship**: nothing is owed; report the verdict at its scope and continue to the documenter. **fix**: apply the material fixes in one batch, rebuild once, and recapture the same viewports over the same files. A recapture measures positions, loading, and overflow; it cannot measure whether a fix reached the quality the finding named, so send the recaptured screenshots back to the same reviewer for a verdict scoring every material fix resolved, partial, or unresolved (through the harness's agent continuation; without one, run the scoring fresh from [degraded/finish-reviewer.md](degraded/finish-reviewer.md)'s Verdict Pass). Fixes scored partial or unresolved get another batch, recapture, and verdict. Two rounds is the budget an unattended run ends at; an attended session's ceiling belongs to the user, so when the second verdict still lists open items, put the table in front of them and let them choose between shipping as it stands and funding another round. Whoever decides, stop the moment a round resolves nothing, and the reviewer's findings are the only list you work from, never your own re-opened hunt. Do not run a second detector.
+10 -2
View File
@@ -29,10 +29,10 @@ Use the feature yourself at the surface's representative sizes: desktop and mobi
If a prior critique exists, use it as one input:
```bash
node .claude/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>"
node .claude/skills/impeccable/scripts/critique-storage.mjs latest "<resolved target>" --json
```
Exit 0 returns the latest snapshot; incorporate relevant P0/P1 findings and name the snapshot read. Exit 2 means none exists. Perform an independent pass either way.
Exit 0 returns JSON with the latest snapshot's `body` and an exact `snapshot_file` identity. Retain `snapshot_file` until the end of the pass. For a local file target, the helper compares the file's exact current content fingerprint with the fingerprint captured by critique. Unchanged staged, unstaged, or untracked content remains current; any byte change, deletion, or replacement with a non-file closes the backlog it identified while preserving its trend history and exits 2. A URL target has no local fingerprint and remains current until explicitly closed. When current, incorporate relevant P0/P1 findings from `body` and name the snapshot read. Exit 2 means none exists or the target changed. Perform an independent pass either way.
## 3. Triage
@@ -95,3 +95,11 @@ Walk the complete path again with mouse, keyboard, and touch where applicable. C
Follow the quality guidance supplied by `context.mjs` and hooks, then run any other relevant QA commands. Context requests a manual scan only when no automatic detector is active; never add another detector pass. Fix real defects and document only narrow intentional exceptions. A clean scan does not replace visual judgment.
Finish with a source diff: remove accidental churn, orphaned code, redundant values, and temporary artifacts. Ship only when the feature is functionally complete and consistently finished across the path.
When this pass clears every Priority Issue it took from a snapshot, close that snapshot:
```bash
node .claude/skills/impeccable/scripts/critique-storage.mjs close "<resolved target>" "<snapshot_file returned by latest>"
```
This closes only the snapshot this pass actually processed; if a newer critique landed meanwhile, its backlog stays live. Do not close when no snapshot was read, when `snapshot_file` was not retained, or when Priority Issues remain.
@@ -8,7 +8,7 @@ Reason over the signals; there is no score to obey:
- `setup.hasDesign` false while `setup.hasCode` true → `document` (capture the visual system).
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
- `critique.latest` with a low `score` or non-zero `p0` / `p1``polish` (it reads that snapshot as its backlog), or re-run `critique` if the snapshot looks stale.
- `critique.latest` with a low `score` or non-zero `p0` / `p1``polish` (it reads that snapshot as its backlog and closes it when stale or cleared).
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `detect.mjs` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
- Otherwise group by intent (build new / improve what's there / iterate visually), tailored to the current surface and `setup.platform`.
@@ -6,6 +6,8 @@ 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 <seed key> --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. `generate-image.mjs` 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 `generate-image.mjs --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.
- A comp is a designed surface, not a picture of the subject. Lead the prompt with the surface's own structure: the regions this design has, named in order with their scale relationships; a page with no navigation says so instead of inventing one, and an unconventional surface states its unconventional skeleton. A prompt that leads with atmosphere gets a vignette back: the model paints the fish market instead of the fish market's website. Self-check every render: if it could hang as a poster, or reads as a photograph with some text on it, it is not a comp; regenerate with the layout scaffold stated more literally.
@@ -27,30 +29,18 @@ 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 `generate-image.mjs` 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. 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 `generate-image.mjs` 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.
## Inventory implementation fidelity
## After approval: the comp becomes a spec
Before building, read the approved comp as a design system and record it in the brief: component grammar, corner language, line weights, elevation treatment, and the type ramp. Everything the comp does not show gets built from this record; without it the fallback is the model's stock kit of square boxes, 1px grids, bento cells, and hard shadows. Then inventory the comp's major visible ingredients in writing (a short table in the surface brief or working notes; the finish reviewer audits shipped assets against it) and choose an implementation medium for each: semantic HTML/CSS/SVG, existing project asset, generated raster, sourced raster, icon library, canvas/WebGL, or accepted omission. The same inventory names the comp's compositional commitments: navigation items and icons, headline levels and their scale relationship, signature geometry such as seams, masks, and overlaps, and each section's arrangement and density. The primary action gets its own row with its own medium: when the comp dissolves, stamps, erodes, or otherwise physically works the main CTA, that treatment is signature material on the page's most important element, and shrinking it to a border trick is the compliance-token version of commitment. An element never written down is the element the build silently drops; the direction contract's 150 words cannot carry this list, so it lives here.
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.
The record is sampled, never estimated: read the comp's page **ground**, each dominant field, and each accent's actual hex from its pixels (ImageMagick, Python with PIL, any pixel-reading tool on the machine) and write the values into the same record. Take a flat field from any interior pixel, a textured or grainy one as the average of an interior patch (crop a swatch, scale it to one pixel), and a gradient as its two end colors; never sample an edge, where antialiasing blends neighbors into colors the design never chose. An adjective is a direction, not a record: cream covers everything from near-white to beige, charcoal a third of the value scale, and wherever no number pins a color, the rendition prior picks the spot. Sampled values supersede the palette chips on the decision and composition cards: those were authored before this comp existed, and a chip that disagrees with the comp's pixels is a draft the approval retired.
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.
The medium column is where an approved design most often dies, so it obeys a gate: the medium is decided by what the comp region shows, never by what feels buildable in the current stack. A human figure, a product object, machinery, or any material with lighting and depth is raster whatever the stack; so is any texture by name alone: woven cloth, paper grain, fabric, leather, brushed metal need no depth argument, because a CSS gradient is not a texture medium and "layered CSS textures" is not a medium at all. Writing "silhouette" for a photographic figure, or "CSS" for a sculpted panel's finish, is not a medium choice; it is the quiet deletion of the approved design, and it is how a comp full of physical material becomes a flat page with the same section order. Style does not move this boundary: a comp region with perspective, shading, figure drawing, or dense mechanical detail is illustration however line-drawn it looks, and no build session can author illustration as vectors, so it regenerates as raster like any photograph. Authored SVG covers what a session can specify exactly (diagrams with countable elements, controls, flat shape systems) and ends where drawing skill begins; an instruction-manual world keeps its illustrations as line-art illustrations, not diagrams. Produce such regions by regenerating them cleanly, with the approved comp and its embedded prompt as the reference for a fresh render at asset resolution; never crop pixels out of the comp itself, whose effective resolution sits far below asset grade. 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; "no photography on hand" forbids fake proof, not an illustrated hero.
## Plates and provenance
The gate runs both ways: precise geometry, hard-edged shape systems, diagrams, expressive motion, shaders, and anything interactive are vector and GPU territory (SVG, canvas, WebGL), where a raster flattens what should move, scale, and respond. A field or texture built from many small elements carries a quantity commitment either way: write down its approximate density and coverage ("thousands of glyphs over two-thirds of the fold, dense at the top fading into the path"), because a field rebuilt at a tenth of its density passes every checklist and still is not the design. TYPE rows carry the same discipline: name the face's compression class, and render one headline word against the comp before building on it; a visibly wider or lighter silhouette means the face is wrong, and every section built on it inherits the miss. Raster is for what the world paints; code is for what the world draws, animates, or reacts with, and choosing code there is ambition, not economy. Every `produce` entry is produced before the build ships, through the asset producer or in the current thread; an inventory with unproduced entries is an unfinished build, and this gate is where imagery-free pages come from when it is skipped.
Pay special attention to the dominant composition, signature use, image-native content, second-fold system, and any interaction the still image only implies.
The comp is a north star, not something to trace, and know what that allows: translation into semantic, responsive, accessible code, never recomposition. Keeping the palette and mood while redrawing the topology is a second art direction, not an adaptation. Do not rasterize core UI text or controls. Do not substitute a different visual driver after approval without asking.
## Produce only the assets the build needs
Generation context is part of the asset: a build composed by a thread that never saw the prompts places assets it does not understand. Prefer generating build-critical imagery in the build thread when the budget allows; when a subagent produces assets instead, every asset carries its prompt, and the builder reads those prompts before composing. The carrier is uniform across harnesses: after generating any image with any tool, native or `generate-image.mjs` (which does it automatically), run `node .claude/skills/impeccable/scripts/embed-prompt.mjs <image> --prompt "<prompt>"` with the exact string the generation tool received, pasted whole, so the intent lives inside the file and survives copies between machines and harnesses; a summary reconstructed from memory records an asset that was never made. `--read` recovers the prompt from any impeccable-generated image, and `--scan <dir>` lists every raster in a directory still missing one. The embedded prompt plus the asset's row in the written inventory is the raster's **provenance**, and every raster the artifact references carries it; a sourced, stock, or pre-existing raster with no generation prompt embeds its origin instead.
Provenance is owed for the run, not the build phase: a raster created or replaced later, in a fix batch or a reviewer's rebuild, is produced under this same section, prompt embedded and inventory row added, because the inventory is how the next thread knows what ships. A raster a fix abandons or supersedes is deleted from the assets directory in the same batch; an unreferenced raster with no record is a provenance leak, not a spare.
When the harness runs subagents, spawn the shipped asset producer every time, even when the inventory's produce bucket looks empty: its manifest is the independent second opinion on your media, and the runs that skipped the spawn are the runs whose cotton became CSS. An honestly empty manifest costs one cheap spawn; a wrongly empty produce bucket costs the build its materials. Use `impeccable-asset-producer` (`impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent"): give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Without subagents, produce the minimum required assets in the current thread by the book: load [degraded/asset-producer.md](degraded/asset-producer.md) and follow it inline, with whatever generation exists.
Every raster region's plate is produced in the plates phase, before any page code, by the shipped asset producer or in the current thread (`generate-image.mjs --plate <id>`, or the harness image tool with the crop as input and the spec's plate prompt). Generation context is part of the asset: after generating any image with any tool, run `node .claude/skills/impeccable/scripts/embed-prompt.mjs <image> --prompt "<prompt>"` with the exact string the tool received (`generate-image.mjs` does this itself), so the intent lives inside the file; `--read` recovers it, `--scan <dir>` lists rasters still missing one. The embedded prompt plus the region's row in the spec is the raster's **provenance**, and every raster the artifact references carries it; a sourced, stock, or pre-existing raster embeds its origin instead. A raster created or replaced later, in a fix batch or a reviewer's rebuild, is produced the same way; a raster a fix abandons is deleted in the same batch.
Convert images with a converter context.mjs reported at boot (the IMAGE_TOOLS line); probe only when it reported none, at most once per session, never per image.
Return to [new-work.md](new-work.md) for the direction contract, implementation, and the finishing pass.
Return to [new-work.md](new-work.md) for the direction contract, the phased build, and the finishing pass.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,391 @@
#!/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/<id>.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 <png> --build <png> [--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();
@@ -0,0 +1,513 @@
#!/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 <comp> --regions regions.json
* regions.json: { "regions": [ { "id": "exploded-plate", "kind": "plate",
* "grid": "E0:J4", "note": "exploded carburetor line drawing" }, ... ] }
* `grid` is "<colrow>:<colrow>" 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 <colrow>:<colrow>, 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 <png> --grid write .impeccable/build/comp-grid.png (10x10 labeled grid) + palette + bands
comp-spec.mjs --comp <png> --regions <json> 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 <png> --auto band regions when you have no regions file
comp-spec.mjs --print the compact spec
comp-spec.mjs --crop <id> [--out f] [--scale n] reference crop of a region (never a shipping asset)
comp-spec.mjs --plate-prompt <id> 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 <png> --regions <json> 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 <png> (--grid | --regions <json> | --auto) [--spec out.json]\n comp-spec.mjs --print | --crop <id> [--out file] [--scale n] | --plate-prompt <id>');
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: <colrow>:<colrow>, 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 <json> (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();
@@ -90,7 +90,8 @@
*/
import crypto from 'node:crypto';
import { dirname, join, resolve } from 'node:path';
import { dirname, join, relative, resolve } from 'node:path';
import { readFileSync, realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
approvedPoolRevision,
@@ -613,16 +614,22 @@ rivals to your habitual layout, and keep only what makes this product clearer.${
assignment by deal order, so the dice still choose. Verdicts and donations
apply between the challengers, weighed against the leader. The pick card
sits out; the canon stays, as always.`;
const telemetryBlock = data.source === 'api'
? `TELEMETRY: after the user's choice resolves, rerun this script once with
--kind <assigned|pick|challenger|canon> --from ${key} --scope ${scope}${mode ? ` --mode ${mode}` : ''},
adding --chosen <challenger-id> when a dealt challenger won and keeping
--register <safer|bolder> when the resolved round came from a steered hand.
One ping per resolved attended round. The ping is anonymous, the card kind
plus the catalog id when one won; your grounded candidates' names never
leave the machine, and the ping is skipped automatically when DO_NOT_TRACK
or IMPECCABLE_NO_TELEMETRY is set.\n`
: '';
// The one command that follows a resolved choice. It records the choice
// (anonymous telemetry on API-dealt rolls; skipped under DO_NOT_TRACK /
// IMPECCABLE_NO_TELEMETRY) and opens the build's phase machine, whose
// first gate is the comp round on a comp-led build. Every run that skipped
// the comp round did so by treating a separate "telemetry ping" as
// bookkeeping: suppressed with >/dev/null, run after the page was written,
// or never run. So there is no separate ping; the start command is the
// ping, and it is not optional.
const nextCommand = scope === 'direction'
? `AFTER THE CHOICE, run exactly one command and follow what it prints (do not suppress its output; do not write page code before it):
node ${relative(process.cwd(), here) || '.'}/build-phase.mjs start --direction ${key} --kind <assigned|pick|challenger|canon>${data.source === 'api' ? ' [--chosen <challenger-id>]' : ''}${register ? ` --register ${register}` : ''}
It records the choice${data.source === 'api' ? ' (anonymous: card kind plus catalog id; skipped under DO_NOT_TRACK / IMPECCABLE_NO_TELEMETRY)' : ''} and opens the build phases: on a comp-led build the comp round is the first gate (three comps, one approved) and no page code is written before it closes; on a code-led build it prints the contract step. A build without this state file is a build the finish reviewer treats as having skipped the round.\n`
: (data.source === 'api'
? `AFTER THE CHOICE, run once: node ${relative(process.cwd(), here) || '.'}/concept-seed.mjs --kind <assigned|pick|challenger|canon> --from ${key} --scope ${scope}${mode ? ` --mode ${mode}` : ''} (records the choice; the locked card's comp is the approved comp, so then: node ${relative(process.cwd(), here) || '.'}/build-phase.mjs start --comp <that comp>).\n`
: `AFTER THE CHOICE: the locked card's comp is the approved comp; run node ${relative(process.cwd(), here) || '.'}/build-phase.mjs start --comp <that comp> and follow what it prints.\n`);
const telemetryBlock = nextCommand;
const assignedBlock = register === null
? `${scope === 'direction' ? `ASSIGNED INDEX: ${buildIndex}` : `DEALT INDICES: ${dealtIndices.join(', ')} (index ${buildIndex} leads)`}
${promotedInstruction}
@@ -666,7 +673,58 @@ ${restated}
`;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
/**
* What the model must do next, once a direction (or surface structure) is
* chosen. Read from the same config the boot directive reads:
* `.impeccable/config.local.json` over `.impeccable/config.json`,
* `buildPath` comp|code; with neither, comp-led whenever image generation
* exists (an OpenAI key here; a harness-native image tool is invisible to
* this script, so the text names it too), code-led otherwise.
*/
export function nextStepAfterChoice({ key, scope, cwd = process.cwd(), env = process.env } = {}) {
let buildPath = null;
for (const name of ['config.json', 'config.local.json']) {
try {
const raw = JSON.parse(readFileSync(resolve(cwd, '.impeccable', name), 'utf8'));
if (raw?.buildPath === 'comp' || raw?.buildPath === 'code') buildPath = raw.buildPath;
} catch { /* absent */ }
}
const scriptsDir = dirname(fileURLToPath(import.meta.url));
const scripts = relative(cwd, scriptsDir) || '.';
const imageGen = !!env.OPENAI_API_KEY;
const seed = key ? ` --direction ${key}` : '';
if (buildPath === 'code') {
return `NEXT (code-led, from .impeccable config): write the direction contract, then build; no comp round. Load reference/new-work.md section 5 and 6.\n`;
}
const why = buildPath === 'comp' ? 'from .impeccable config' : imageGen ? 'default: image generation is available' : 'default: comp-led unless no image tool exists; if your harness truly has none and there is no OpenAI key, this is code-led and you say so in one line';
if (scope === 'surface') {
return `NEXT (comp-led, ${why}): the locked card's comp is the approved comp. Run: node ${scripts}/build-phase.mjs start --comp <that comp> and follow its NEXT lines. Do not write page code before build-phase.mjs advance has closed the spec, plates, and hero gates.\n`;
}
return `NEXT (comp-led, ${why}): the world is chosen; the composition is not. Run: node ${scripts}/build-phase.mjs start${seed} and follow its NEXT lines: it opens the comps phase (three comps under .impeccable/mocks/, one approved by the user through the decision page or structured question, sidecar "approved": true), then spec, plates, hero, sections, motion, responsive, review. Do not write page code before those gates close. Reference: reference/visualize.md for the comp round.\n`;
}
export function sameMainModulePath(left, right, platform = process.platform) {
if (platform !== 'win32') return left === right;
const normalizeDriveLetter = (value) => value.replace(/^([a-z]):/i, (_, drive) => `${drive.toUpperCase()}:`);
return normalizeDriveLetter(left) === normalizeDriveLetter(right);
}
function isMainModule() {
if (!process.argv[1]) return false;
try {
// Node resolves import.meta.url through symlinks but leaves argv[1] as the
// invoked path. Compare real paths so a linked skill still runs its CLI,
// normalizing the drive-letter casing that Windows junctions can change.
return sameMainModulePath(
realpathSync(process.argv[1]),
realpathSync(fileURLToPath(import.meta.url))
);
} catch {
return false;
}
}
if (isMainModule()) {
const args = process.argv.slice(2);
const fromIdx = args.indexOf('--from');
const scopeIdx = args.indexOf('--scope');
@@ -692,7 +750,27 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
register: registerIdx !== -1 ? args[registerIdx + 1] : undefined,
});
process.stdout.write(sent ? 'choice recorded\n' : 'choice ping skipped\n');
// The choice is resolved; this is the last script output the model
// reads before it decides what to do next, and every run that skipped
// the comp round did so right here: prose 20 KB into new-work.md lost
// to "direction locked, building now". So the ping prints the next
// mandatory step from the recorded build path, and the phase machine
// takes it from there.
process.stdout.write(nextStepAfterChoice({
key: fromIdx !== -1 ? args[fromIdx + 1] : undefined,
scope: scopeIdx !== -1 ? args[scopeIdx + 1] : undefined,
}));
} else {
// A dealt roll leaves a marker the build phase clears: context.mjs and
// detect.mjs read it and refuse to treat page work as done while a
// direction is chosen but the build never started (COMP_ROUND_OPEN).
try {
const { mkdirSync, writeFileSync: wf } = await import('node:fs');
if (scopeIdx !== -1 && args[scopeIdx + 1] === 'direction') {
mkdirSync(resolve(process.cwd(), '.impeccable', 'build'), { recursive: true });
wf(resolve(process.cwd(), '.impeccable', 'build', 'pending.json'), JSON.stringify({ scope: 'direction', at: new Date().toISOString() }, null, 2));
}
} catch { /* marker is best-effort */ }
// Mechanical init gate: prose alone does not keep a model from dealing
// before init, and fresh repos produced exactly that skip (the model
// rolled directions with no PRODUCT.md, so nothing grounded the fusion).

Some files were not shown because too many files have changed in this diff Show More