Compare commits

...
Author SHA1 Message Date
Paul Bakaus 0e8eea2d91 Preserve bounded repair context
Keep repair attempt metadata and nested diagnostics while retaining prompt limits.\n\nAI assistance: Implemented and validated with OpenAI Codex under maintainer authorization.
2026-08-06 09:51:32 -07:00
Paul Bakaus aa1b4e4b02 Harden copy-edit prompt bounds
Bound repair, candidate, and element context consistently and preserve absent source positions as null.\n\nAI assistance: Implemented and validated with OpenAI Codex under maintainer authorization.
2026-08-06 09:37:58 -07:00
Paul Bakaus 6e6b0227b0 Bound copy-edit prompt context
Whitelist and truncate staged operation context before it reaches the local agent prompt.

AI assistance: Implemented and validated with OpenAI Codex under maintainer authorization.
2026-08-06 09:22:41 -07:00
Paul BakausandGitHub a075d89bdb Simplify CSS color channel parsing (#520)
Centralize CSS numeric token parsing and characterize every supported color unit while preserving config and filtering behavior.

AI-assisted: Codex implemented this refactor under pbakaus’s scheduled architecture-simplification authorization.
2026-08-05 15:28:17 -07:00
github-actions[bot] e76b3424d2 Sync generated provider output 2026-08-05 22:27:35 +00:00
Paul BakausandGitHub e46e0da885 Centralize critique snapshot reading (#511)
Make critique storage the single owner of snapshot discovery and frontmatter parsing, and keep context signals focused on summarizing the canonical result.

AI-assisted: Prepared by Codex under pbakaus's scheduled architecture-refactor authorization.
2026-08-05 15:27:02 -07:00
github-actions[bot] b14df98183 Sync generated provider output 2026-08-05 22:26:15 +00:00
Paul BakausandGitHub 6886ab8c0e Fix Codex pinned skill frontmatter (#519)
Emit Codex-compatible top-level keys while preserving the argument hint under metadata. Keep existing Claude-style pin frontmatter unchanged for other harnesses.

AI assistance: Codex implemented and validated this change under maintainer pbakaus's standing authorization.
2026-08-05 15:25:42 -07:00
github-actions[bot] ae5e95101a Sync generated provider output 2026-08-04 21:10:37 +00:00
Paul BakausandGitHub a37b3f6b02 Fix Windows question browser opening (#510)
AI assistance: Codex reproduced the issue, implemented the fix, and ran the validation described in the pull request.
2026-08-04 14:09:59 -07:00
github-actions[bot] d086837dfc Sync generated provider output 2026-08-04 21:09:31 +00:00
Paul BakausandGitHub 80e4dd0d58 Fix Blade files in directory detection (#509)
* Fix Blade directory detection

AI assistance: Codex reproduced the issue, implemented the fix, and ran the validation described in the pull request.

* Fix compound scan suffix matching

AI assistance: Codex addressed review findings and ran the validation described in the pull request.
2026-08-04 14:08:49 -07:00
github-actions[bot] 2f609915eb Sync generated provider output 2026-08-04 20:34:25 +00:00
Paul BakausandClaude Opus 5 ebaf9f1d5b Let a world declare the slop it is personally at risk of
Optional `avoid`, two or three negations of 12 to 160 characters. A world built
from posters is at risk of shouting; one built from instruments is at risk of
dead greys. The global detector cannot know which and the author can, so the
"do not" belongs beside the "do" rather than in a rulebook that applies to
everything equally.

Optional on purpose: 541 entries predate it and none of them are wrong for
lacking it, so nothing needs backfilling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 13:32:08 -07:00
Paul BakausandClaude Opus 5 d417ff1f01 Craft floor: theme the surfaces you did not draw
A well-made site was audited for what separates it from a competent one, and the
answer was not its ingredients. It runs the default stack, Next and Tailwind and
Geist, with no world and no unusual technique. What it has is attention to the
surfaces a browser renders for you: 29 focus-visible rules, 15 scrollbar rules,
and styled text selection, caret, underline offset and scroll behaviour.

Those are the cheapest signal that a page was built rather than assembled, and
the ones a model skips most reliably, because nobody asks for them and nothing
looks broken without them. The floor already covers contrast, depth, spacing,
measure, motion and states; this is the layer under all of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 13:32:08 -07:00
github-actions[bot] e15d8e122f Sync generated provider output 2026-08-04 18:30:42 +00:00
Paul BakausandClaude Opus 5 3b35161000 Flatten the challenger draw so the same worlds stop coming back
A 3-star held two tickets and a 1-star held none. On a pool this size that is
not a nudge, it is the shape of the draw. Measured against the live catalog:
3-star worlds absorbed 57% of the graphic draw from 65 of 163 eligible worlds,
46% of atmosphere from 13 of 43, and 75% of interaction from 15 of 25. The
reviewer's report that the same worlds keep returning is exactly what a rating
multiplier does to a corpus whose thinnest tier holds 25 worlds.

Now a 3-star draws level with a 2-star, and a 1-star draws at half rather than
not at all. Excluding a marginal keep made rating do a job breadth already does
properly: breadth still removes a niche world from the pool entirely, which is
the honest way to say "too narrow to challenge an arbitrary build", while a
1-star records "unexceptional" and is still worth showing sometimes.

Effect on the same catalog: the 3-star share falls to 39% on graphic, 30% on
atmosphere and 60% on interaction. That last one is no longer a weighting
artefact, it is simply what the tier contains, since 15 of its 25 eligible
worlds are rated 3.

Compositions get the same treatment; the two ticket functions had the identical
shape and no reason to disagree. Both tests asserted the old policy directly
and now assert the new one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:26:02 -07:00
Paul BakausandClaude Fable 5 ca7981f669 Comp outranks the brief: close the inventory sandbagging gap
probe-compking-sol-4 (evals) executed its staged inventory faithfully and
still lost the comp: the brief had already recorded the comp's materials
down (low-contrast textures, a 70-path lake against the comp's hundreds,
a sculpted plate as flat CSS), and the build thread never loads
visualize.md, so nothing told it the comp wins that disagreement. The
comp-is-king block now says the record gets corrected upward, that the
comparison runs against the freshly reopened comp rather than memory,
and that a texture under a near-opaque wash is not shipped material.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 11:26:02 -07:00
github-actions[bot] 620ba1fe7d Sync generated provider output 2026-08-04 01:46:09 +00:00
1045c6ca98 Gracefully handle the no-image decision page (#502)
* Gracefully handle the no-image decision page

Tested the new-work path without image generation and fixed what broke:

- A text-only card's back face (First viewport, The case) was unreachable:
  the Details flip chip only rendered inside the media block. Cards with no
  imagery now render their full read on the front and skip the back face.
- A hero/board that fails to load (retired catalog URL, offline shell) sat
  as a dark void with a zoom cursor. The slot now collapses to a field
  painted from the card's own palette with an "artwork unavailable" pill;
  broken inspiration PIPs remove themselves.
- Sketchless catalog art rendered unlabeled as the card's face, reading as
  the promise of the build. It now carries the same "inspiration" label and
  hover title the PIP uses.
- The --schema example pointed at catalog URLs that 404 (missing family
  prefix); updated to the real asset paths and noted the text-only front
  behavior in the schema prose.

Extends e2e test (e) with the front-read and label assertions and adds
test (f) for the broken-image fallback.

AI-assisted (Claude Code).

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix: address PR review bot findings

- cursor[bot]: the unavailable-art scrim painted over the flip chips and
  swallowed their clicks; it now passes pointer events through and the
  chips render above it.
- Copilot: a palette-less card whose art failed still read as a dark void
  and kept the stale Inspiration tooltip; the slot now falls back to the
  graphite field in CSS and the tooltip is removed with the art.

Test (f) now covers both: a broken card with back facts must still flip
via Details, and a palette-less broken card gets the labeled fallback.

AI-assisted (Claude Code).

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-08-03 18:45:32 -07:00
github-actions[bot] d28dbc7a8d Sync generated provider output 2026-08-04 00:33:41 +00:00
667095d216 Harden the test strategy: self-verifying triggers, 40% faster runner, release guards (#501)
* test: harden the test strategy (triggers, runner speed, release guards)

Follow-ups from an end-to-end testing strategy review:

- Suite triggers are now auto-generated from each suite's own file list,
  so change-based CI can never miss a test file again (four files were
  unreachable by their own edits, and tests/lib/detector-bundle.test.js
  triggered core while running in detector). Two new meta-tests pin the
  invariant. Hand-written trigger patterns now carry only source paths
  and fixture dirs; palette dropped from the live triggers since no
  suite tests it.
- The node runner batches all files into one node --test invocation at
  concurrency 4 instead of spawning per file. Default suite drops from
  ~159s to ~100s; the live suite soaked clean three times.
- scripts/release.mjs gets its first tests: 12 scenarios spawning the
  real script inside a disposable git repo with a local bare origin,
  covering every refusal guard plus notes/tweet rendering, all under
  --dry-run.
- skill/scripts/live/ui-core.mjs deleted: zero references repo-wide,
  superseded by the July live rewrite, yet still shipping to users.
  cli/lib/download-providers.js annotated with its cross-repo consumers
  (impeccable-site Pages Functions) so it is not mistaken for dead code.
- CLAUDE.md gains an area-to-suite table for the opt-in suites a change
  owes; AGENTS.md syncs the plugin-e2e commands and obligations.

AI-assisted via Claude Code under maintainer direction.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix: exclude peeled tag lines from release-test origin cleanup

Copilot: git ls-remote --tags emits ^{} peel lines for annotated tags,
which are not deletable refs; --refs filters them so the cleanup loop
survives a future scenario that pushes an annotated tag.

AI-assisted via Claude Code under maintainer direction.

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-08-03 17:33:10 -07:00
Rex LorenzoandGitHub 14d2641685 Fix: keep the node runtime probe clear of cmd.exe metacharacters (#458)
Volta's Windows shims exec through `cmd /C`, which re-parses the argument
list, so the `>=` inside the probe's `node -e` payload was read as output
redirection. The command died with "The filename, directory name, or volume
label syntax is incorrect" before node started, the guard read that as a
missing runtime, and the hook it exists to protect was disabled on every
PostToolUse and Stop. A user on a supported Node 24 got a one-time notice
telling them to install Node 22, then silence.

Clamping with Math.min is the same floor test in the same ES5-only syntax,
with no character cmd.exe can claim. Verified through the Volta shim on Node
24.16.0 and 22.18.0 (exit 0) and against a real Node 20.6.1 binary (exit 1),
so the floor is unchanged. Adds a regression test asserting no `<`, `>`, or
newline reaches any generated `node -e` payload.

Upstream cause: volta-cli/volta#1791.

Prepared with AI assistance (Claude Code).
2026-08-03 15:02:29 -07:00
github-actions[bot] e2761cae80 Sync generated provider output 2026-08-03 21:59:34 +00:00
CypherPoetandGitHub 85f84bf620 🐛 Fix DESIGN.md Layout and Shapes parsing (#481)
* 🐛 Fix DESIGN.md Layout and Shapes parsing

Prepared with AI assistance.

* ♻️ Refine canonical design parser coverage

Prepared with AI assistance.
2026-08-03 14:59:00 -07:00
1a3f588c71 Fix: skip POSIX hook guard on Windows installs (#452) (#453)
* Fix: skip POSIX hook guard on Windows installs (#452)

PowerShell rejects the `[ ! -f ... ] ||` guard at parse time, so Codex
hooks generated by `npx impeccable install` on Windows never ran. Emit
the direct `node "PATH"` invocation there; POSIX output is unchanged.

AI-assisted (Cursor).

Co-authored-by: Cursor <cursoragent@cursor.com>

* Keep the missing-file no-op in Windows-generated hook commands

Greptile review on #453: project hook manifests are committable, so a
bare `node "PATH"` written on Windows loses the silent no-op when a
POSIX teammate without the skill consumes it. Replace the bare form
with a shell-agnostic `node -e` existence guard that parses in
PowerShell, cmd.exe, and sh, and forwards the hook's exit code.

AI-assisted (Cursor).

Co-authored-by: Cursor <cursoragent@cursor.com>

* Use Codex's commandWindows field for the Windows hook guard

Per @PatrickSys on #452: Codex runs hooks through COMSPEC (cmd.exe /C),
not PowerShell, and 0.146.0+ selects a commandWindows manifest field on
Windows. Codex entries now always carry the POSIX guard in command plus
an `if exist` cmd.exe guard in commandWindows (his Windows-tested form),
so one .codex/hooks.json is correct on every OS regardless of where the
install ran. Claude/Cursor keep the node -e wrapper on Windows installs
since their manifests have no per-platform field.

AI-assisted (Cursor).

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:55:01 -07:00
github-actions[bot] 731cd2e6cd Sync generated provider output 2026-08-03 21:54:17 +00:00
Abdul WahabandGitHub b33feacbe9 Fix: unescape YAML quote escapes in DESIGN.md frontmatter scalars (#473)
* Fix: unescape YAML quote escapes in DESIGN.md frontmatter scalars (#428)

parseScalar() stripped a double-quoted scalar's outer quotes without
processing the backslash escapes inside, so a font stack that quotes a
multi-word family the CSS way, e.g.

  fontFamily: "\"IBM Plex Sans\", system-ui, sans-serif"

reached allowedFonts as '\"ibm plex sans' and design-system-font flagged
fonts DESIGN.md declares. Also collapses the doubled-quote escape in
single-quoted scalars and keeps a lone quote literal instead of slicing
it to an empty string. Applied to both copies of the parser
(cli/engine/design-system.mjs and skill/scripts/lib/design-parser.mjs).

Co-authored-by: Cursor Agent (AI-assisted change, reviewed and directed
by a maintainer)

* Decode YAML hex and Unicode escapes in double-quoted scalars

Review follow-up: the escape scanner only handled the simple set, so
\xNN, \uNNNN, and \UNNNNNNNN sequences stayed encoded and an escaped
token like "\x23b8422e" never matched #b8422e in CSS. Decode validated
hex escapes in both parser copies; malformed or out-of-range sequences
stay literal. Regression coverage for all three forms.

Co-authored-by: Cursor Agent (AI-assisted change, reviewed and directed
by a maintainer)

* Complete the YAML 1.2 double-quote escape set

Review follow-up: the escape map omitted the escaped space (\ ) and
non-breaking space (\_) forms, so fonts declared with them kept a
literal backslash in allowedFonts and their CSS declarations were
reported as undeclared. Map the full spec 5.7 set (\a \b \v \f \e
\N \L \P included) in both parser copies instead of chasing one escape
at a time. Regression coverage for both named forms.

Co-authored-by: Cursor Agent (AI-assisted change, reviewed and directed
by a maintainer)
2026-08-03 14:53:36 -07:00
github-actions[bot] df09de3676 Sync generated provider output 2026-08-03 21:52:56 +00:00
Paul BakausandGitHub de7b72843f Fix broken-image findings in source comments (#490)
* Fix broken-image comment false positives

AI assistance was used to reproduce the issue, implement the fix, and add regression coverage.

* Harden JavaScript comment scanning

AI assistance was used to address automated review feedback, add regression coverage, and run validation.

* Handle comments in template expressions

AI assistance was used to reproduce and fix automated review feedback, add regression coverage, and run validation.

* Preserve JSX around URL and regex syntax

AI assistance was used to reproduce and fix automated review feedback, add regression coverage, and run validation.

* Fix regex keyword property context

AI assistance: Codex identified, implemented, and validated this review follow-up under maintainer authorization.

* Handle JSX slash edge cases

AI assistance: Codex addressed review findings and validated this follow-up under maintainer authorization.

* Ignore CSS-in-JS comments

AI assistance: Codex addressed top-level review findings and validated this follow-up under maintainer authorization.

* Handle remaining slash contexts

Fix JavaScript keyword separation and JSX protocol-relative URL classification so comment stripping preserves only live source. Add focused regressions for the reviewed edge cases.\n\nAI assistance: Codex implemented and validated this change under maintainer authorization.

* Handle generic styled templates

Recognize TypeScript generic arguments consistently in CSS-in-JS extraction and comment sanitization. Add focused regressions for extraction and comment-only styled templates.\n\nAI assistance: Codex implemented and validated this change under maintainer authorization.

* Handle nested styled generics

Teach CSS-in-JS extraction and comment sanitization to scan balanced nested TypeScript generic arguments before template literals. Add regressions for live and commented nested-generic styles.\n\nAI assistance disclosure: Codex implemented and validated this review follow-up under maintainer authorization.

* Handle nested source contexts

Keep regex detection correct after postfix operators, distinguish JSX expression comments from protocol-relative text, and scan nested template literals inside CSS-in-JS interpolations. Add focused regressions for each review finding.\n\nAI assistance disclosure: Codex implemented and validated these review follow-ups under maintainer authorization.

* Complete comment-safe source scanning

Recognize regex literals after for-of, comparisons, and block braces without confusing object-literal division. Route grid-background detection through the offset-preserving comment-neutralized source and add negative and positive controls.\n\nAI assistance disclosure: Codex implemented and validated these review follow-ups under maintainer authorization.

* Handle remaining lexer contexts

Recognize JSX attribute expressions and regex literals inside CSS-in-JS interpolations so comment stripping remains source-safe.\n\nAI-assisted: Codex implemented and validated this change under maintainer authorization.

* Align interpolation regex contexts

Match postfix-update and statement-block regex classification in CSS-in-JS interpolation parsing so templates remain extractable.\n\nAI-assisted: Codex implemented and validated this change under maintainer authorization.
2026-08-03 14:52:23 -07:00
Paul BakausandGitHub d6a9891066 Share browser detector bundling (#498)
Centralize the browser-safe module set and source transformation so the browser and extension builders cannot drift.

AI assistance: This refactor was prepared by Codex under pbakaus's scheduled architecture-simplification authorization.
2026-08-03 14:51:46 -07:00
6d2af3f800 Guard the plugin loader contract that PR #494 exposed (#499)
* test: guard the plugin loader contract that PR #494 exposed

The agents manifest key shipped for months and silently loaded zero of
the four subagents; no validator looked at the generated plugin
manifest's shape and claude plugin validate never checks it. Three
layers now do:

- scripts/lib/validate-plugin-manifest.js pins the verified loader
  contract (KNOWN_LOADER_KEYS allowlist, no agents key, trailing-slash
  skills path from issue #86, every skill/agents/*.md shipped in
  plugin/agents/), unit-tested in tests/validate-plugin-manifest.test.js
  including a check of the real committed subtree.
- The same check gates bun run build next to the version-drift guard.
- tests/plugin-e2e.test.mjs installs the committed ./plugin subtree into
  a real Claude Code (sandboxed via CLAUDE_CONFIG_DIR in a temp dir) and
  asserts the component inventory: skill parses, all agents visible,
  hooks discovered. In the default suite; runs in about a second and
  skips cleanly when the claude CLI is absent, so CI is unaffected.

All three failed against the pre-#494 tree for the shipped reason
(Agents 0 of 4) and pass against current main.

AI-assisted via Claude Code under maintainer direction.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix: address PR review bot findings

- Copilot: guard collectPluginManifestFindings against valid JSON that is
  not an object (null, string, number, array) so a broken manifest is a
  finding instead of a build crash; unit test added
- Copilot: update the plugin-e2e header comment, the suite is in the
  default lineup rather than opt-in

AI-assisted via Claude Code under maintainer direction.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix: harden plugin E2E sandbox isolation

Bugbot: create the sandbox CLAUDE_CONFIG_DIR up front and redirect HOME
and USERPROFILE into the temp workDir too, so a CLI code path that
derives config or cache locations from the home directory instead of
CLAUDE_CONFIG_DIR still cannot touch the developer's real Claude config
when the default suite runs.

AI-assisted via Claude Code under maintainer direction.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix: agent parity check mirrors the build's emit rules

Bugbot: the shipped filename is `${claude-name || name}.md` and a
providers: list may exclude claude-code, so comparing raw source
basenames could fail the build on a renamed or provider-scoped agent
with a build:release hint that cannot fix it. The validator now derives
expected filenames the same way the transformer factory does (shared
parseFrontmatter, same providers gate) with unit coverage for renames,
name overrides, and provider-scoped agents.

AI-assisted via Claude Code under maintainer direction.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix: run the plugin E2E through a shell on Windows

Bugbot: the claude CLI is a .cmd shim on Windows and Node refuses to
spawn those via execFile without a shell, so the availability probe
always failed and the suite silently skipped there. Windows now invokes
through a shell with every argument double-quoted (temp paths routinely
contain spaces); the POSIX path is unchanged.

AI-assisted via Claude Code under maintainer direction.

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-08-03 13:13:37 -07:00
github-actions[bot] 69b63d36b6 Sync generated provider output 2026-08-03 18:55:47 +00:00
ingnicolaboccato-labandGitHub baa76cfd05 fix: stop emitting the "agents" key so the four subagents actually load (#494)
build.js derives plugin/.claude-plugin/plugin.json from the root manifest and
injects an `agents` array built from the generated agent files. In Claude Code
that array is exactly what stops them loading.

Verified on a throwaway local marketplace, across the plausible shapes:

  array of file paths (what build.js emits) -> 0 agents load, skills fine
  a string, e.g. "./agents"                 -> whole plugin fails to load
  array containing a directory              -> whole plugin fails to load
  key omitted                               -> all agents load, skills fine

Claude Code discovers agents/*.md on its own, and the identifier it uses is the
file name rather than the frontmatter `name`. So the key is not needed, and any
present form of it is worse than its absence.

Confirmed end to end on this repo: with the key emitted,
`claude plugin details impeccable` reports "Agents (0)"; with it omitted it
reports "Agents (4) impeccable-asset-producer, impeccable-documenter,
impeccable-finish-reviewer, impeccable-manual-edit-applier". The agent files
themselves are still copied by the existing copyDirSync a few lines below —
only the manifest key goes away.

Two things make this hard to notice: with a breaking shape
`claude plugin details` prints "Plugin not found" instead of a validation
error, and `claude plugin validate` does not catch it because it validates the
marketplace manifest, not the plugin manifest. The only reliable signal is the
"Agents (N)" line.

The same defect is reported against another project at
addyosmani/agent-skills#449, with the full reproduction.

Scope note: verified on Claude Code only. plugin/ is the Claude-Code / Grok
subtree, and the Grok manifest is written separately just below, so this does
not touch the other harnesses.
2026-08-03 11:55:14 -07:00
github-actions[bot] 71ccba9f5b Sync generated provider output 2026-08-03 17:55:14 +00:00
Paul BakausandGitHub d69bd093f9 Merge pull request #491 from pbakaus/codex/issue-485-ignore-file-flags
Honor scope flags for ignore-file
2026-08-03 10:54:43 -07:00
dependabot[bot]andGitHub 5dfeba6d3e Bump @babel/parser to 8.0.4 and raise Node floor (#430)
Upgrade @babel/parser from 7.29.7 to 8.0.4 and raise the repository Node 22 minimum from 22.12.0 to 22.18.0 across package metadata, CI, and npm documentation.

No parser API migration was required. Validated with the full local suite and refreshed GitHub CI on Node 22.18.0 and Node 24.

Prepared and validated with AI assistance from OpenAI Codex under maintainer instructions.
2026-08-03 10:46:55 -07:00
Paul Bakaus 2345868c7b Preserve detector extension mappings
Keep unmanaged detector fields when ignore-file updates the canonical detector configuration. Add a regression covering existing extension mappings.\n\nAI assistance: Codex implemented and validated this change under maintainer authorization.
2026-08-03 10:42:07 -07:00
Paul Bakaus 57ce11288f Preserve detector extensions
AI assistance: Codex addressed review feedback and validated this follow-up under maintainer authorization.
2026-08-03 10:23:13 -07:00
dependabot[bot]andGitHub 707794c597 Bump the bun-minor-and-patch group with 5 updates (#489)
Update the coordinated Vercel AI SDK stack and Playwright to their latest compatible patch releases.

Prepared and validated with AI assistance from OpenAI Codex under maintainer automation instructions.
2026-08-03 10:06:25 -07:00
Paul Bakaus ae118ebf57 Migrate legacy advisory settings
AI assistance: Codex identified, implemented, and validated this review follow-up under maintainer authorization.
2026-08-03 10:05:05 -07:00
Paul Bakaus 3125864d1a Preserve advisory detector settings
AI assistance was used to reproduce and fix automated review feedback, add regression coverage, and run validation.
2026-08-03 09:38:02 -07:00
Paul Bakaus dd0279b6bd Document ignore-file scope flags
AI assistance was used to address automated review feedback and validate the documentation correction.
2026-08-03 09:21:13 -07:00
Paul Bakaus b32a02d02f Fix ignore-file flag handling
AI assistance was used to reproduce the issue, implement the fix, and add regression coverage.
2026-08-03 09:17:23 -07:00
github-actions[bot] 9529f07840 Sync generated provider output 2026-08-03 15:26:39 +00:00
Paul BakausandGitHub ad30c67dca Merge pull request #487 from pbakaus/fix/475-drop-craft-from-hint
Drop deprecated craft alias from the generated argument-hint
2026-08-03 08:26:01 -07:00
Abdul WahabandCursor 7d6109b723 Drop deprecated craft alias from the generated argument-hint
craft is a deprecated compatibility alias, but its SKILL_CATEGORIES entry
kept it advertised in every generated SKILL.md argument-hint. Unmapping it
removes it from the hint while the alias keeps routing through the router
table and command-metadata.json.

AI-assisted change (reviewed by maintainer).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 10:36:18 +05:00
github-actions[bot] 33b9a3752b Sync generated provider output 2026-08-03 03:09:47 +00:00
Paul BakausandGitHub bc51310dad Merge pull request #484 from pbakaus/codex/simplify-import-graph
Simplify import graph scanning
2026-08-02 20:09:15 -07:00
github-actions[bot] 0c9ce16248 Sync generated provider output 2026-08-03 03:08:14 +00:00
Paul BakausandGitHub ae2be34fdc Merge pull request #471 from pbakaus/hook-skip-outside-project
fix: skip design-hook scans for files outside the resolved project root
2026-08-02 20:07:31 -07:00
github-actions[bot] 8cf362b6fe Sync generated provider output 2026-08-03 02:57:12 +00:00
Paul BakausandClaude Fable 5 bcfb7efc46 The comp is king: phased reproduction doctrine
Phase one is near-pixel-perfect reproduction at the comp's breakpoint,
with exactly three concessions (closest font, icons unless a library
was chosen, genuine comp defects); the overlap comparison is the
authority because models systematically believe their code recreation
succeeded when it did not, and regions that keep losing the comparison
ship as composited rendered assets instead. Phase two brings the
reproduction to life (interaction, motion, responsiveness), and
anything beyond the comp inherits the recorded system, never invented
container chrome.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 19:56:39 -07:00
Paul BakausandClaude Code 62a2026afc perf: memoize canonicalPath so scan loops resolve the project root once
The containment gate re-canonicalized projectCwd for every target file
in the per-edit and Stop loops. The hook runs as a fresh process per
tool event, so a module-level memo makes it once-per-event work; the
size cap only matters to long-lived importers like the test runner.

Addresses Copilot review feedback on PR #471.

Written with AI assistance (Claude Code).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-08-02 19:27:13 -07:00
Paul Bakaus c90faaab55 Simplify import graph scanning
Replace three duplicate matcher loops with one declarative pattern list while preserving import resolution behavior. Add Sass @use and @forward characterization coverage.\n\nAI-assisted change prepared under pbakaus's scheduled architecture-refactor authorization.
2026-08-02 11:10:33 -07:00
Paul BakausandClaude Code febce52e8d refactor: share the containment gate with hook-before-edit
hook-before-edit.mjs kept its own string-based isInsideProject; it now
uses the shared isScanTargetInsideProject so all three hook passes
apply one containment semantic, symlink canonicalization included.

Because the before-edit hook gates proposed Writes whose target does
not exist yet, canonicalPath now resolves the nearest existing
ancestor and re-appends the remainder instead of falling back to the
raw resolved path — a new file under a symlinked root compares equal
to its canonical project.

Written with AI assistance (Claude Code).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-07-31 18:30:50 -07:00
Paul BakausandGitHub c5e1ddd054 Merge pull request #470 from pbakaus/fix/live-cors-nonlocal-dev-hosts
Fix live mode on non-localhost dev hosts (ddev, Valet): authorize CORS by session token
2026-07-31 18:24:42 -07:00
Paul BakausandClaude Code ae03e9e09c fix: skip design-hook scans for files outside the resolved project root
The per-edit and Stop deep passes gated on sensitive paths, generated
paths, extension, config ignores, and size, but never on containment.
Any file the session touched outside the project (harness scratchpad
dirs under the system temp root, sibling checkouts) was scanned and
judged against THIS project's config and DESIGN.md palette, producing
design-system findings that are wrong by construction.

Both loops now check isScanTargetInsideProject() (audit reason:
outside-project), matching the gate hook-before-edit.mjs already had.
Paths are canonicalized so a symlinked root doesn't split the
comparison. The Stop pass re-checks containment itself because caches
written by older hook versions can still list out-of-project paths.
Umbrella-dir launches (issue #305) are unaffected: their projectCwd
resolves to the edited file's own project root, so containment holds.

Written with AI assistance (Claude Code).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-07-31 18:20:09 -07:00
Paul BakausandClaude Fable 5 60c860f022 Pin the token expression in the manual-edit-stash source assertion
Review follow-up: the regex stopped at the literal ?token= and tolerated
anything after it, so removing the encoded token value from the URL
still passed. Requiring encodeURIComponent(TOKEN) right after the
prefix makes the mutation fail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 18:13:27 -07:00
Paul BakausandClaude Fable 5 675656c18c Assert Vary: Origin on the authorized CORS preflight too
Review follow-up: the reflected-origin contract matters most on the
OPTIONS preflight, where a cached response authorized for one origin
must never be served to another.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 18:06:55 -07:00
Paul BakausandClaude Fable 5 0f80c1f5aa Add regression tests for token-authorized CORS on non-localhost dev hosts
Covers the fix for the ddev breakage reported in #304: the live server
now reflects Access-Control-Allow-Origin for any request bearing the
valid session token, so dev servers on loopback aliases (https://*.ddev.site,
Valet's *.test, hosts-file entries) work again while tokenless remote
origins stay blocked. The server/browser source changes shipped in
b1c5707f; this adds the test coverage that was written alongside them:

- tokenless remote origins get no ACAO on any route, token'd or not
- a non-loopback origin with the valid token is reflected, with
  Vary: Origin, on both the real request and its OPTIONS preflight
- the /manual-edit-stash source assertion tracks the token-bearing URL

Prepared with AI assistance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 18:02:54 -07:00
github-actions[bot] f2f73edb33 Sync generated provider output 2026-08-01 00:55:29 +00:00
Paul BakausandClaude Fable 5 b1c5707fde Cross-harness, cross-OS: boot-time tool detection and native-first image gen
context.mjs now probes cwebp/sips/magick/ffmpeg once (which/where per
OS) and prints IMAGE_TOOLS, replacing macOS-specific prose; the
IMAGE_GEN_AVAILABLE directive leads with the harness-native tool so a
present OpenAI key stops reading as an instruction to bill it; and the
sandboxed board-start guidance sheds codex vocabulary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:54:55 -07:00
github-actions[bot] af56fae571 Sync generated provider output 2026-08-01 00:50:56 +00:00
Paul BakausandClaude Fable 5 1052f6c3a4 Start the decision page escalated in sandboxed harnesses
Sandboxed shells cannot bind the board's port; every codex session paid
one failed start before retrying escalated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:50:19 -07:00
github-actions[bot] 4f10aed4ba Sync generated provider output 2026-08-01 00:50:15 +00:00
Paul BakausandClaude Fable 5 b89b4c41d3 Trim the spawn tax: no agent-def reads, long waits, one converter probe
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:49:40 -07:00
github-actions[bot] 1d4b98ea01 Sync generated provider output 2026-08-01 00:45:49 +00:00
Paul BakausandGitHub 68f13a225e Merge pull request #468 from pbakaus/codex/issue-463-wrapped-characteristics
Fix wrapped Key Characteristics parsing
2026-07-31 17:45:14 -07:00
github-actions[bot] 65a5197439 Sync generated provider output 2026-08-01 00:44:00 +00:00
Paul BakausandGitHub 3a2d3a9c42 Merge pull request #467 from pbakaus/codex/issue-464-seed-components
Fix seed DESIGN.md coverage checks
2026-07-31 17:43:21 -07:00
Paul BakausandGitHub c91f3717d4 Merge pull request #466 from pbakaus/codex/remove-legacy-pattern-parser
Simplify curated pattern loading
2026-07-31 17:41:55 -07:00
Paul Bakaus a3d7b247aa Cover provider seed markers
Recognize both slash- and dollar-prefixed prescribed seed markers and exercise each variant in coverage tests.

AI assistance: Codex addressed Cursor and Copilot review feedback and reran validation under maintainer authorization.
2026-07-31 17:32:57 -07:00
Paul Bakaus c91047d66a Fix seed design coverage
Treat Components as optional only when DESIGN.md carries the prescribed seed marker, while retaining Colors and Typography checks.

AI assistance: Codex reproduced the issue, implemented the fix, and added regression coverage under maintainer authorization.
2026-07-31 17:21:35 -07:00
Paul Bakaus c5eb38a381 Fix wrapped design characteristics
Join indented Markdown bullet continuations and keep them out of Overview philosophy text.

AI assistance: Codex reproduced the issue, implemented the fix, and added regression coverage under maintainer authorization.
2026-07-31 17:21:29 -07:00
github-actions[bot] c83a7eced2 Sync generated provider output 2026-08-01 00:19:54 +00:00
Paul Bakaus 33f824b5b5 Clarify curated pattern source
Describe the catalog as independent of SKILL.md extraction rather than repository content, addressing Copilot's review feedback.

Prepared with Codex assistance under pbakaus's scheduled architecture cleanup authorization.
2026-07-31 17:19:33 -07:00
Paul BakausandGitHub 463ba38860 Merge pull request #461 from pawelad/pawelad/add-antigravity-support
Add Google Antigravity provider support
2026-07-31 17:19:22 -07:00
Paul Bakaus 358fc2e716 Simplify curated pattern loading
Remove the unreachable legacy SKILL.md pattern parser now that readPatterns uses the curated catalog exclusively.

Prepared with Codex assistance under pbakaus's scheduled architecture cleanup authorization.
2026-07-31 17:16:19 -07:00
Paweł Adamczak 1b4a0b9bac Update paths 2026-07-31 18:02:17 +02:00
Paweł Adamczak 2d489f898b Fix Antigravity global skills path to ~/.gemini/config/skills/ 2026-07-31 13:19:56 +02:00
Paweł Adamczak 1efdff9aea Add regression tests for Antigravity provider transform and CLI lifecycle 2026-07-31 13:04:45 +02:00
Paweł Adamczak 0a3c12f78e Add Antigravity install instructions and global auto-detection hints 2026-07-31 13:00:08 +02:00
Paweł Adamczak bf957452c4 Add Google Antigravity provider support 2026-07-31 12:02:37 +02:00
github-actions[bot] 32930818a1 Sync generated provider output 2026-07-30 19:23:56 +00:00
Paul BakausandClaude Fable 5 08c2323b51 Rebuild without asking, spawn the producer always, read the comp as a system
Three lessons from the Tortuga containment-map run. The first rebuild
directive now executes immediately, informing the user instead of
asking permission to fix a failure; consultation waits for a second
rebuild verdict or user-approved content at risk. The asset producer
spawns on every subagent-capable run even when produce looks empty,
because its manifest is the independent check on the inventory's media
and the skipped spawn marks every all-CSS failure to date. And the
inventory now opens by reading the comp as a design system (component
grammar, corners, line weights, elevation, type ramp), because the
sections the comp does not show get built from that record, and without
it the fallback is the stock kit: square boxes, 1px grids, bentos, hard
shadows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 12:23:16 -07:00
github-actions[bot] dc5d9baa0e Sync generated provider output 2026-07-30 18:15:59 +00:00
Paul BakausandClaude Fable 5 7b202c3223 Textures are raster by name alone
The condensation pass folded the old textures-are-raster-by-default
sentence into the medium gate's lighting-and-depth clause, and the next
codex run drove straight through the gap: woven cotton as 'layered CSS
textures', a black nylon band as plain CSS, and a physical evidence-tag
CTA as CSS shapes, so the produce bucket stayed empty and the asset
producer was never called. The gate now names textures explicitly,
woven cloth, paper grain, fabric, leather, brushed metal, with no depth
argument owed, and calls 'layered CSS textures' what it is: not a
medium.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 11:15:25 -07:00
github-actions[bot] 24a014ddcf Sync generated provider output 2026-07-30 17:49:14 +00:00
Paul BakausandClaude Opus 5 166e4481e1 Let a concept record its aesthetic axis values
Three of the six axes cannot be read from a world's prose, and widening their
keywords manufactures signal rather than finding it. Depth's probe matched
worlds that said "no cast shadow anywhere" and "without perspective or depth";
motion and colour strategy describe properties the system rules never state, so
they place 28% and 7%.

An optional axes object on the concept records the value instead. Absent means
inferred from the rules as before, so nothing needs backfilling. Validated
against the axes definition when the caller supplies it, because a typo would
read as "unrecorded" and fall back to a probe already known not to work, which
is the quietest way for this to fail.

This is what makes an assigned wave measurable. If a wave draws "drenched" and
"simulated physics" before designing anything, the world it produces has to
carry those values or the assignment is lost the moment it lands, and occupancy
goes back to guessing at prose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:48:41 -07:00
github-actions[bot] f72fcad7d6 Sync generated provider output 2026-07-30 17:18:13 +00:00
Paul BakausandClaude Fable 5 827dfeb95e The primary action is signature material, not chrome
The Tortuga comp dissolves the Install CTA's edge into the storm's
particles; the build shipped a plain rectangle with four decorative
dots, and neither the builder nor the reviewer's rebuild findings named
it. The inventory now gives the primary action its own row and medium,
naming the shrink-to-border-trick failure as the compliance-token
version of commitment, and the reviewer's fidelity matrix lists the
primary action's treatment among the salient elements, with a
physically-worked CTA rendered as a plain rectangle scored contradicted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 10:17:37 -07:00
github-actions[bot] 5b2df20b85 Sync generated provider output 2026-07-30 17:15:11 +00:00
Paul BakausandGitHub 19b5fa40d0 Merge pull request #456 from pbakaus/codex/issue-436-design-coverage
Fix DESIGN.md frontmatter coverage
2026-07-30 10:14:22 -07:00
github-actions[bot] 42bc53eac9 Sync generated provider output 2026-07-30 17:13:45 +00:00
Paul BakausandClaude Fable 5 38d2c393dc Prove the hero before building past it
The Tortuga run showed the finish machinery working end to end, roll,
challengers, approved comp, honest labels, independent reviewer, an
earned rebuild verdict at the user checkpoint, and still cost the user
a full build to learn the hero undersold the comp: a glyph storm at a
tenth of the approved density under type half as compressed. Both
misses were visible the moment the first viewport rendered.

Two cheap gates front-load that discovery. The build section gains a
hero checkpoint: capture the first viewport and set it beside the
comp's before any later section, judging scale and density as
quantities. The inventory gains the same quantitative discipline:
field and texture regions record density and coverage, and TYPE rows
name the compression class and render one headline word against the
comp before anything is built on the face.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 10:13:04 -07:00
Paul Bakaus f274ca2c01 Reject empty collection coverage
AI assistance: Codex validated and addressed the Greptile empty-collection review finding with focused regression coverage.
2026-07-30 09:43:58 -07:00
Paul Bakaus de9d543825 Reject scalar frontmatter coverage
AI assistance: Codex validated and addressed the Greptile scalar-frontmatter review finding with regression coverage.
2026-07-30 09:34:31 -07:00
Paul Bakaus 7a0489bd91 Require populated frontmatter coverage
AI assistance: Codex validated and addressed the Greptile review finding with focused regression coverage.
2026-07-30 09:23:37 -07:00
Paul Bakaus a209eeb0bd Fix DESIGN.md frontmatter coverage
AI assistance: Codex reproduced the issue, implemented the focused fix, and added regression coverage.
2026-07-30 09:06:27 -07:00
github-actions[bot] 6b342244e9 Sync generated provider output 2026-07-30 04:47:13 +00:00
Paul BakausandClaude Fable 5 adc798debb Arm the degraded-roll rerun with its own safety case
Codex's risk reviewer rejected the network-escalated roll rerun for
'contacting an unspecified external domain' and the assumed export of
project context, so the run degraded to no challengers. Both concerns
are answerable: the script's only network contact is one GET to
impeccable.style/api/roll carrying scope, mode, an eight-hex key, and a
re-roll counter, nothing project-derived. The degraded message now
states that verbatim and tells the model to put the URL and payload in
its approval request, so the reviewer judges the real action instead of
an unknown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 21:46:35 -07:00
Paul BakausandClaude Fable 5 9d1b4bdfac Fix five stale rule counts and the validator blind spots that hid them
single-font's retirement made the detector 59 rules; both READMEs still
said 60 in five places, and the count validator reported clean because
'deterministic detector rules' puts a word the regex never expected
between the qualifier and the noun, and README.npm.md was never in the
checked file list. The regex now tolerates the detector infix, counts
qualified 'issues' claims, and README.npm.md joins the list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 21:35:27 -07:00
468 changed files with 18805 additions and 5947 deletions
@@ -18,7 +18,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s);
## Checks, in order
1. **Persistence.** PRODUCT.md exists. 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 comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comps with no recorded pick mean the approval point was skipped, and that is a material finding.
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, 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, because medium is part of the promise. When no approved comp was supplied, 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 actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. 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. In every material_fixes list, 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, and 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, because medium is part of the promise. When no approved comp was supplied, 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 actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. 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. In every material_fixes list, 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 and that is 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.
@@ -12,6 +12,7 @@ Each of these is a check on the built result, not an intention. Run them togethe
- **Type:** body measure 6575ch, display max 6rem, tracking floor -0.04em, balanced headings, obvious scale and weight steps. Run the real copy at every breakpoint and fix what overflows.
- **Motion:** one authored moment, not scattered effects and not one identical entrance on every section. Exponential ease-out from an already-visible default. Reach past transform and opacity: blur, backdrop-filter, clip-path, mask, and shadow belong to the palette when they stay smooth.
- **States:** hover, disabled, loading, error, empty. Plus real content, working controls, responsive composition, keyboard focus.
- **Browser surfaces:** the parts you did not draw still carry the design. Text selection, the caret, custom scrollbars, focus rings, underline offset, and the numerals in tabular data all ship with browser defaults that belong to no design system. Theme them from the palette. This is the cheapest signal that a page was built rather than assembled, and the one models skip most reliably.
- **Copy:** the product's own language. Controls name their action; errors name the problem and the recovery.
- **Coverage:** every brief requirement present and findable within seconds.
@@ -16,7 +16,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s);
## Checks, in order
1. **Persistence.** PRODUCT.md exists. 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 comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comps with no recorded pick mean the approval point was skipped, and that is a material finding.
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, 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, because medium is part of the promise. When no approved comp was supplied, 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 actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. 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. In every material_fixes list, 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, and 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, because medium is part of the promise. When no approved comp was supplied, 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 actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. 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. In every material_fixes list, 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 and that is 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.
@@ -48,7 +48,7 @@ The script assigns which structure gets built; your top-ranked structure is what
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, in 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. A standing preference gets recorded 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. You may 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, the dealt challengers as alternates carrying their QUALITY BAR cards, and re-roll, steer, plus canon enabled; 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 (run the script with `--schema` for the exact shape); the page renders identity from these fields, and a challenger's catalog image rides as labeled inspiration, never as the promise of the build. Author `canonCard` too: the category standard as one honest card with the 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>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now 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 was closed without an answer: 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 instead run the script without `--start` and let it auto-open and block. Only a session where no browser can open at all, headless, CI, an eval worker, a remote shell with no display, puts the same decision through the structured question tool instead; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry.
When image generation exists, every card also declares a `sketch` path under `.impeccable/sketches/`, the canon card included. Serve the page first, then produce the sketches; the page shimmer-waits per slot and the user may answer before they land. Render every sketch through one shared frame so the comparison stays about direction, never rendering luck: the requested surface's first viewport as a flat, matte design sketch in that card's own palette and type character, deliberately unfinished, no photorealism, no gloss, identical framing across cards; a candidate whose sketch looks more finished than the others has broken the comparison, not won it. The frame's aspect is the surface's own: a native app or mobile-first surface sketches portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen sketched landscape is a broken frame, not a neutral default. The only legible text in a sketch is the product's real name and one real headline; every other text region is greeked, indistinct lines standing where copy will go, because a sketch that renders invented specs, prices, or dates puts claims in front of the user that PRODUCT.md never made. Produce in the order the user reads: the assigned card, then the hand, then canon, each file written the moment it is done. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-sketch packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. A sketch answers which world, never which composition: the comp round still renders its full set, and the chosen card's sketch seeds at most one probe. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version.
When image generation exists, every card also declares a `sketch` path under `.impeccable/sketches/`, 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 sketches; the page shimmer-waits per slot and the user may answer before they land. Render every sketch through one shared frame so the comparison stays about direction, never rendering luck: the requested surface's first viewport as a flat, matte design sketch in that card's own palette and type character, deliberately unfinished, no photorealism, no gloss, identical framing across cards; a candidate whose sketch looks more finished than the others has broken the comparison, not won it. The frame's aspect is the surface's own: a native app or mobile-first surface sketches portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen sketched landscape is a broken frame, not a neutral default. The only legible text in a sketch is the product's real name and one real headline; every other text region is greeked, indistinct lines standing where copy will go, because a sketch that renders invented specs, prices, or dates puts claims in front of the user that PRODUCT.md never made. Produce in the order the user reads: the assigned card, then the hand, then canon, each file written the moment it is done. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-sketch packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. A sketch answers which world, never which composition: the comp round still renders its full set, and the chosen card's sketch seeds at most one probe. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version.
Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. Open the QUALITY BAR board and hero for the world you build the moment the choice lands, even if you viewed another card earlier; the ANSWER line names the chosen card's images (when the harness only reads files or runs sandboxed, download them into the workspace and open the relative path; sandboxed viewers reject absolute paths outside it). They set the craft level the build must reach, a rendered reference's finish, commitment, and art direction, never the composition; your surface serves this product.
@@ -84,9 +84,12 @@ 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. 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.
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.
- **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, 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. 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.
@@ -100,6 +103,6 @@ Preserve semantics, accessibility, performance, responsiveness, project conventi
Inspect desktop and mobile in one batched screenshot round, 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.
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. 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 build that skips this ships every tell the hook exists to catch. Capture desktop and mobile screenshots to files, 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, its direction contract, existing hook findings, the QUALITY BAR card and approved comp paths, and the craft-floor reference path. The reviewer has no browser; screenshots you fail to pass are checks it cannot run. Verify its return carries the five contract sections; on an empty or thrashed return, respawn once with the same inputs before doing anything else. 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 whose tool surface has 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. When the reviewer's first material fix is a rebuild directive, fidelity failed wholesale rather than in patches, so skip the fix batch: put that verdict in front of the user with the named comp regions and let them choose between a re-derivation and shipping as it stands. Otherwise apply the material fixes in one batch, rebuild once, and recapture the same viewports. 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 is deciding, 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. Report the final verdict table to the user as it stands, open items included, under the reviewer's own disposition word: a table with open material findings is never announced as a pass, and never under a softer label than the reviewer wrote. Do not run a second detector.
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. 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 build that skips this ships every tell the hook exists to catch. Capture desktop and mobile screenshots to files, 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, its direction contract, existing hook findings, the QUALITY BAR card and approved comp paths, and the craft-floor reference path. 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 its return carries the five contract sections; on an empty or thrashed return, respawn once with the same inputs before doing anything else. 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 whose tool surface has 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. When the reviewer's first material fix is a rebuild directive, fidelity failed wholesale rather than in patches, so 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 verdict, telling the user what is happening rather than asking permission to fix a failure. The user is consulted only when a second rebuild directive arrives, both verdicts on the table, or when rebuilding would discard content the user approved. Otherwise apply the material fixes in one batch, rebuild once, and recapture the same viewports. 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 is deciding, 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. Report the final verdict table to the user as it stands, open items included, under the reviewer's own disposition word: a table with open material findings is never announced as a pass, and never under a softer label than the reviewer wrote. Do not run a second detector.
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). A clean detector pass is not finished; finished is the contract kept, the comp honored, the review closed, and the system recorded.
@@ -28,11 +28,11 @@ After approval, record the choice where tools can find it: the approved comp's p
## Inventory implementation fidelity
Before building, inventory the approved 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 written 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. An element never written down is the element the build silently drops, and the direction contract's 150 words cannot carry this list, so this inventory is where it lives.
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, because everything the comp does not show gets built from this record, and 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 written 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 or a few decorative pixels is the compliance-token version of commitment. An element never written down is the element the build silently drops, and the direction contract's 150 words cannot carry this list, so this inventory is where it lives.
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; 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 it ends where drawing skill begins; an instruction-manual world does not convert its illustrations into diagrams, it makes them line-art illustrations. 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 instead of producing it 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, so "no photography on hand" forbids fake proof, not an illustrated hero.
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, and so is any texture by that name alone: woven cloth, paper grain, fabric, leather, brushed metal need no depth argument, because a CSS gradient or layered background 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 or a cotton field's weave, 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 it ends where drawing skill begins; an instruction-manual world does not convert its illustrations into diagrams, it makes them line-art illustrations. 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 instead of producing it 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, so "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, and code executed safely and professionally remains first-class there. 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.
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, and code executed safely and professionally remains first-class there. 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.
@@ -42,6 +42,8 @@ Treat the comp as a north star, not something to trace, and know what that allow
Generation context is part of the asset: a build composed by a thread that never saw the prompts places assets it does not understand. So prefer generating build-critical imagery in the build thread when the budget allows, and when a subagent produces assets instead, every asset must carry its prompt, and the builder reads those prompts before composing a single one of them. 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 "<the prompt used>"` so the intent lives inside the file itself and survives copies between machines and harnesses; `--read` recovers it from any impeccable-generated image.
When clean raster ingredients are required and the harness runs subagents, use 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"): give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise 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, the native tool or generate-image.mjs.
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 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 the producer, `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. Otherwise 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, the native tool or generate-image.mjs.
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.
@@ -405,13 +405,18 @@ ASSIGNED INDEX: ${buildIndex}
The assignment exists to refuse the model's ranking rut, never to outrank
the user or the brief. Never expose assignment metadata in user-facing labels.
No challengers this run: the roll service was unreachable and no local
catalog exists. A sandboxed exec tool with no network access causes exactly
this; before accepting degradation, rerun this command once through the
harness's network-enabled command tool. A sandboxed shell without network egress is the most common
cause: if this harness can rerun the command with network access granted,
do that once before proceeding. Otherwise proceed with the grounded
candidates alone; the assignment
above still applies at full strength. Tell the user plainly that this roll
catalog exists. A sandboxed shell without network egress is the most common
cause; before accepting degradation, rerun this command once through the
harness's network-enabled or escalated command tool. When that rerun needs
an approval, state exactly what the approver must know: this script's only
network contact is one GET to https://impeccable.style/api/roll whose query
carries scope, mode, an eight-hex seed key, and a re-roll counter; no
project files, prompts, code, or conversation context are transmitted, and
nothing is written. An approval request naming that URL and payload judges
the real action; a bare "run with network" invites rejection for contacting
an unspecified domain. If the rerun is still refused, proceed with the
grounded candidates alone; the assignment above still applies at full
strength. Tell the user plainly that this roll
ran degraded, with no challengers and no quality-bar boards; do not present
the outcome as a full roll. A degraded roll changes the cards, not the
channel: when a browser can open, present the direction on the decision page
@@ -22,7 +22,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { loadContext, extractPlatform } from './context.mjs';
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
import { readLatestSnapshotAcrossTargets } from './critique-storage.mjs';
/** Is there code here at all, or just context files / an empty repo? */
function hasCode(cwd) {
@@ -34,23 +34,13 @@ function hasCode(cwd) {
}
/**
* The most recent critique snapshot across all targets. Filenames are
* timestamp-prefixed (`<iso>__<slug>.md`), so a lexical sort is chronological.
* Parses the small frontmatter for score + P0/P1 counts.
* Summarize the most recent critique snapshot across all targets.
*/
function latestCritique(cwd) {
try {
const dir = getCritiqueDir(cwd);
if (!fs.existsSync(dir)) return null;
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')).sort();
if (!files.length) return null;
const newest = files[files.length - 1];
const text = fs.readFileSync(path.join(dir, newest), 'utf-8');
const front = text.split('---')[1] || '';
const get = (k) => {
const m = front.match(new RegExp(`^${k}:\\s*(.+)$`, 'm'));
return m ? m[1].trim() : null;
};
const latest = readLatestSnapshotAcrossTargets({ cwd });
if (!latest) return null;
const get = (key) => latest.meta[key] ?? null;
const num = (v) => {
const n = Number(v);
return Number.isFinite(n) ? n : null;
@@ -61,7 +51,7 @@ function latestCritique(cwd) {
p0: num(get('p0')),
p1: num(get('p1')),
timestamp: get('timestamp'),
file: path.relative(cwd, path.join(dir, newest)),
file: path.relative(cwd, latest.path),
};
} catch {
return null;
+20 -3
View File
@@ -27,6 +27,7 @@
* shape rather than the markdown block.
*/
import fs from 'node:fs';
import { spawnSync } from 'node:child_process';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -1146,6 +1147,7 @@ async function cli() {
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
parts.push(buildMissingTargetDirective());
}
appendImageToolsDirective(parts);
appendStalenessDirective(parts, ctx, cliOptions);
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
@@ -1180,6 +1182,7 @@ async function cli() {
`# NATIVE PLATFORM REFERENCE: ${reference.name.toUpperCase()} (reference/${reference.name}.md)\n\n${reference.content.trim()}`,
);
}
appendImageToolsDirective(parts);
appendStalenessDirective(parts, ctx, cliOptions);
if (!ctx.platform) {
// A `## Platform` section that names something we don't recognize (a
@@ -1275,9 +1278,10 @@ function appendImageGenDirective(parts) {
if (!process.env.OPENAI_API_KEY) return;
const scriptsPath = path.dirname(fileURLToPath(import.meta.url));
parts.push([
'IMAGE_GEN_AVAILABLE: An OpenAI key is present, so image generation works even without a harness-native image tool:',
`\`node ${scriptsPath}/generate-image.mjs --prompt "..." --out <file>\` (gpt-image-2, billed to the user's key; say so before the first render).`,
'Prefer the harness-native image tool when one exists. Visualizing a direction before building it measurably strengthens the result.',
'IMAGE_GEN_AVAILABLE: your harness-native image tool is always the first choice for generation; use it whenever one exists.',
'This environment also carries an OpenAI key as the fallback for harnesses with no native tool:',
`\`node ${scriptsPath}/generate-image.mjs --prompt "..." --out <file>\` (gpt-image-2, billed to the user's key; say so before the first render, and never reach for it when a native tool exists).`,
'Visualizing a direction before building it measurably strengthens the result.',
].join(' '));
}
@@ -1332,6 +1336,19 @@ function appendDetectorFallback(parts, ctx) {
// markdown already in memory, a bounded set of stats, or one of the small JSON
// files the boot reads regardless. The deep pass (git drift, token divergence,
// cross-workspace sweep) belongs to the doctor command, not to every session.
// One boot-time probe replaces every session re-deriving its image toolchain:
// harnesses and OSes differ (cwebp, sips on macOS, magick, ffmpeg), and the
// agent should read this line instead of running command -v per image.
function appendImageToolsDirective(parts) {
const probe = process.platform === 'win32' ? 'where' : 'which';
const found = ['cwebp', 'sips', 'magick', 'ffmpeg'].filter((tool) => {
try { return spawnSync(probe, [tool], { stdio: 'ignore' }).status === 0; } catch { return false; }
});
parts.push(found.length
? `IMAGE_TOOLS: available image converters on this machine: ${found.join(', ')}. Use the first suitable one; never probe again this session.`
: 'IMAGE_TOOLS: no image converter found (cwebp, sips, magick, ffmpeg). Ship PNG output unconverted rather than probing per image.');
}
function appendStalenessDirective(parts, ctx, options) {
const projectRoot = ctx.projectRoot || process.cwd();
if (stalenessCheckDisabled([projectRoot, ctx.repoRoot])) return;
@@ -105,28 +105,37 @@ function parseFrontmatter(text) {
}
/**
* Return all snapshot files for `slug`, sorted oldest → newest.
* Return snapshot files matching `suffix`, sorted oldest → newest.
*/
function listSnapshotsForSlug(slug, cwd) {
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/;
function listSnapshots(suffix, cwd) {
const dir = getCritiqueDir(cwd);
if (!fs.existsSync(dir)) return [];
const suffix = `__${slug}.md`;
return fs.readdirSync(dir)
.filter((f) => f.endsWith(suffix))
.filter((f) => SNAPSHOT_FILENAME.test(f) && f.endsWith(suffix))
.sort()
.map((f) => path.join(dir, f));
}
function readLatestSnapshotMatching(suffix, cwd) {
const filePath = listSnapshots(suffix, cwd).at(-1);
if (!filePath) return null;
const body = fs.readFileSync(filePath, 'utf-8');
return { path: filePath, body, meta: parseFrontmatter(body) };
}
/**
* 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() } = {}) {
const all = listSnapshotsForSlug(slug, cwd);
if (!all.length) return null;
const latest = all[all.length - 1];
const body = fs.readFileSync(latest, 'utf-8');
return { path: latest, body, meta: parseFrontmatter(body) };
return readLatestSnapshotMatching(`__${slug}.md`, cwd);
}
/** Return the most recent snapshot across all targets, or null. */
export function readLatestSnapshotAcrossTargets({ cwd = process.cwd() } = {}) {
return readLatestSnapshotMatching('.md', cwd);
}
/**
@@ -134,7 +143,7 @@ export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
* Critique appends a one-line trend to its output using this.
*/
export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
const all = listSnapshotsForSlug(slug, cwd);
const all = listSnapshots(`__${slug}.md`, cwd);
const slice = all.slice(-limit);
return slice.map((file) => parseFrontmatter(fs.readFileSync(file, 'utf-8')));
}
@@ -142,10 +142,73 @@ function stripInlineYamlComment(s) {
return s;
}
// YAML double-quoted scalars process backslash escapes. Stripping the outer
// quotes without unescaping leaves them in place, so a nested font family like
// fontFamily: "\"IBM Plex Sans\", system-ui, sans-serif"
// reaches allowedFonts as '\"ibm plex sans' and never matches the same family
// declared in CSS. Scanner instead of a regex: the escape set is small and the
// backslash handling stays readable.
// The full YAML 1.2 double-quote escape set (spec section 5.7).
const YAML_SIMPLE_ESCAPES = {
'0': '\0',
a: '\x07',
b: '\b',
t: '\t',
n: '\n',
v: '\v',
f: '\f',
r: '\r',
e: '\x1b',
' ': ' ',
'"': '"',
'/': '/',
'\\': '\\',
N: '\u0085',
_: '\u00a0',
L: '\u2028',
P: '\u2029',
};
const YAML_HEX_ESCAPE_LENGTHS = { x: 2, u: 4, U: 8 };
function unescapeYamlDoubleQuoted(body) {
let out = '';
for (let i = 0; i < body.length; i++) {
const ch = body[i];
if (ch !== '\\' || i === body.length - 1) {
out += ch;
continue;
}
const next = body[i + 1];
if (Object.prototype.hasOwnProperty.call(YAML_SIMPLE_ESCAPES, next)) {
out += YAML_SIMPLE_ESCAPES[next];
i++;
continue;
}
// \xNN, \uNNNN, \UNNNNNNNN. Malformed or out-of-range sequences stay
// literal rather than corrupting the rest of the scalar.
const hexLen = YAML_HEX_ESCAPE_LENGTHS[next];
if (hexLen) {
const hex = body.slice(i + 2, i + 2 + hexLen);
const codePoint = hex.length === hexLen && /^[0-9a-fA-F]+$/.test(hex) ? parseInt(hex, 16) : -1;
if (codePoint >= 0 && codePoint <= 0x10ffff) {
out += String.fromCodePoint(codePoint);
i += 1 + hexLen;
continue;
}
}
out += ch;
}
return out;
}
function parseScalar(raw) {
const s = raw.trim();
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
return s.slice(1, -1);
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
return unescapeYamlDoubleQuoted(s.slice(1, -1));
}
// Single-quoted YAML escapes only the quote itself, by doubling it.
if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) {
return s.slice(1, -1).split("''").join("'");
}
if (s === 'true') return true;
if (s === 'false') return false;
@@ -35,6 +35,7 @@ export { detectUrl, createBrowserDetector } from './engines/browser/detect-url.m
export { detectText, extractStyleBlocks, extractCSSinJS } from './engines/regex/detect-text.mjs';
export {
walkDir,
hasScannableExtension,
SCANNABLE_EXTENSIONS,
SKIP_DIRS,
buildImportGraph,
@@ -41,6 +41,221 @@ function shouldRunPageAnalyzers(content, filePath) {
return !ext || PAGE_ANALYZER_EXTS.has(ext);
}
const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
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']);
function isInsideOpeningJsxTag(source) {
const tagStart = source.lastIndexOf('<');
if (tagStart === -1 || !/^<[A-Za-z][\w.:-]*/.test(source.slice(tagStart))) return false;
let quote = '';
for (let cursor = tagStart + 1; cursor < source.length; cursor++) {
const char = source[cursor];
if (quote) {
if (char === '\\') cursor++;
else if (char === quote) quote = '';
} else if (char === "'" || char === '"') {
quote = char;
} else if (char === '>') {
return false;
}
}
return true;
}
/**
* Blank JavaScript comments without moving any following source. Regex
* findings keep their original line numbers, while prose examples inside
* comments cannot masquerade as rendered markup.
*/
function stripJsComments(content, options = {}) {
let state = 'code';
let output = '';
let lastSignificant = '';
let previousSignificant = '';
let antePreviousSignificant = '';
let currentWord = '';
let currentWordPrefix = '';
let wordSeparated = false;
let regexCharClass = false;
let jsxExpressionDepth = 0;
let lastClosedBraceKind = '';
const braceKinds = [];
const templateExpressionDepths = [];
const braceKind = (startsJsxExpression = false) => (
!startsJsxExpression && (
!lastSignificant ||
lastSignificant === ')' ||
lastSignificant === ';' ||
lastSignificant === '}' ||
(previousSignificant === '=' && lastSignificant === '>') ||
BLOCK_BRACE_PREFIX_KEYWORDS.has(currentWord)
) ? 'block' : 'expression'
);
const recordSignificant = (char) => {
if (/\s/.test(char)) {
wordSeparated = true;
return;
}
const isWordChar = /[\w$]/.test(char);
if (isWordChar && (wordSeparated || !currentWord)) {
currentWord = '';
currentWordPrefix = lastSignificant;
} else if (!isWordChar) {
currentWordPrefix = '';
}
wordSeparated = false;
antePreviousSignificant = previousSignificant;
previousSignificant = lastSignificant;
lastSignificant = char;
currentWord = isWordChar ? currentWord + char : '';
};
for (let i = 0; i < content.length; i++) {
const char = content[i];
const next = content[i + 1];
if (state === 'line-comment') {
if (char === '\n') {
output += char;
state = 'code';
} else {
output += ' ';
}
continue;
}
if (state === 'block-comment') {
if (char === '*' && next === '/') {
output += ' ';
i++;
state = 'code';
} else {
output += char === '\n' ? '\n' : ' ';
}
continue;
}
if (state === 'regex') {
output += char;
if (char === '\\' && next) {
output += next;
i++;
} else if (char === '[') {
regexCharClass = true;
} else if (char === ']') {
regexCharClass = false;
} else if (char === '/' && !regexCharClass) {
state = 'code';
recordSignificant('/');
}
continue;
}
if (state === 'template' && char === '$' && next === '{') {
output += '${';
i++;
recordSignificant('$');
recordSignificant('{');
templateExpressionDepths.push(1);
braceKinds.push('expression');
if (jsxExpressionDepth) jsxExpressionDepth++;
state = 'code';
continue;
}
if (state !== 'code') {
output += char;
if (char === '\\' && next) {
output += next;
i++;
} else if (
(state === 'single-quote' && char === "'") ||
(state === 'double-quote' && char === '"') ||
(state === 'template' && char === '`')
) {
state = 'code';
recordSignificant(char);
}
continue;
}
const jsxUrlSeparator = options.jsx && char === '/' && next === '/' &&
jsxExpressionDepth === 0 &&
(output.endsWith('http:') ||
output.endsWith('https:') ||
(/<[A-Za-z](?:[^>]*[^/])?>[^<]*$/.test(output.slice(output.lastIndexOf('\n') + 1)) &&
/^[\w.-]+\.[A-Za-z]{2,}(?=[:/?#\s<]|$)/.test(content.slice(i + 2))));
const afterPostfixUpdate = (lastSignificant === '+' || lastSignificant === '-') &&
previousSignificant === lastSignificant &&
antePreviousSignificant !== lastSignificant;
if (char === '/' && next === '/' && jsxUrlSeparator) {
output += '//';
i++;
recordSignificant('/');
recordSignificant('/');
} else if (char === '/' && next === '/') {
output += ' ';
i++;
state = 'line-comment';
} else if (char === '/' && next === '*') {
output += ' ';
i++;
state = 'block-comment';
} else if (templateExpressionDepths.length && char === '{') {
output += char;
templateExpressionDepths[templateExpressionDepths.length - 1]++;
braceKinds.push(braceKind());
if (jsxExpressionDepth) jsxExpressionDepth++;
recordSignificant(char);
} else if (templateExpressionDepths.length && char === '}') {
output += char;
const depthIndex = templateExpressionDepths.length - 1;
templateExpressionDepths[depthIndex]--;
lastClosedBraceKind = braceKinds.pop() || '';
if (jsxExpressionDepth) jsxExpressionDepth--;
recordSignificant(char);
if (templateExpressionDepths[depthIndex] === 0) {
templateExpressionDepths.pop();
state = 'template';
}
} else if (
char === '/' &&
(!lastSignificant ||
(/[=([{!?:;,&|+\-*%^~<>]/.test(lastSignificant) && !afterPostfixUpdate) ||
(lastSignificant === '}' && lastClosedBraceKind === 'block') ||
(previousSignificant === '=' && lastSignificant === '>') ||
(currentWordPrefix !== '.' && REGEX_PREFIX_KEYWORDS.has(currentWord)))
) {
output += char;
state = 'regex';
regexCharClass = false;
} else {
output += char;
const startsJsxExpression = options.jsx && char === '{' && jsxExpressionDepth === 0 &&
(/<[A-Za-z](?:[^>]*[^/])?>[^<]*$/.test(output.slice(output.lastIndexOf('\n') + 1, -1)) ||
isInsideOpeningJsxTag(output.slice(0, -1)));
if (char === '{') braceKinds.push(braceKind(startsJsxExpression));
else if (char === '}') lastClosedBraceKind = braceKinds.pop() || '';
if (char === '{' && (jsxExpressionDepth || startsJsxExpression)) jsxExpressionDepth++;
else if (char === '}' && jsxExpressionDepth) jsxExpressionDepth--;
recordSignificant(char);
if (char === "'") state = 'single-quote';
else if (char === '"') state = 'double-quote';
else if (char === '`') state = 'template';
}
}
return output;
}
function stripCssComments(content) {
return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' '));
}
function firstOverusedGoogleFont(text) {
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
}
@@ -528,18 +743,198 @@ function extractStyleBlocks(content, ext) {
const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']);
function findQuotedStringEnd(content, start, quote) {
for (let cursor = start + 1; cursor < content.length; cursor++) {
if (content[cursor] === '\\') cursor++;
else if (content[cursor] === quote) return cursor;
}
return -1;
}
function findRegexLiteralEnd(content, start) {
let inCharacterClass = false;
for (let cursor = start + 1; cursor < content.length; cursor++) {
const char = content[cursor];
if (char === '\\') {
cursor++;
} else if (char === '[') {
inCharacterClass = true;
} else if (char === ']') {
inCharacterClass = false;
} else if (char === '/' && !inCharacterClass) {
while (/[A-Za-z]/.test(content[cursor + 1] || '')) cursor++;
return cursor;
} else if (char === '\n' || char === '\r') {
return -1;
}
}
return -1;
}
function findTemplateExpressionEnd(content, start) {
let depth = 1;
let lastSignificant = '';
let previousSignificant = '';
let antePreviousSignificant = '';
let currentWord = '';
let currentWordPrefix = '';
let wordSeparated = false;
let lastClosedBraceKind = '';
const braceKinds = [];
const braceKind = () => (
lastSignificant === ')' ||
lastSignificant === ';' ||
lastSignificant === '}' ||
(previousSignificant === '=' && lastSignificant === '>') ||
BLOCK_BRACE_PREFIX_KEYWORDS.has(currentWord)
? 'block'
: 'expression'
);
const recordSignificant = (char) => {
if (/\s/.test(char)) {
wordSeparated = true;
return;
}
const isWordChar = /[\w$]/.test(char);
if (isWordChar && (wordSeparated || !currentWord)) {
currentWord = '';
currentWordPrefix = lastSignificant;
} else if (!isWordChar) {
currentWordPrefix = '';
}
wordSeparated = false;
antePreviousSignificant = previousSignificant;
previousSignificant = lastSignificant;
lastSignificant = char;
currentWord = isWordChar ? currentWord + char : '';
};
for (let cursor = start; cursor < content.length; cursor++) {
const char = content[cursor];
const next = content[cursor + 1];
const afterPostfixUpdate = (lastSignificant === '+' || lastSignificant === '-') &&
previousSignificant === lastSignificant &&
antePreviousSignificant !== lastSignificant;
if (char === "'" || char === '"') {
cursor = findQuotedStringEnd(content, cursor, char);
if (cursor === -1) return -1;
recordSignificant(')');
} else if (char === '/' && next === '/') {
const lineEnd = content.indexOf('\n', cursor + 2);
if (lineEnd === -1) return -1;
cursor = lineEnd;
} else if (char === '/' && next === '*') {
const commentEnd = content.indexOf('*/', cursor + 2);
if (commentEnd === -1) return -1;
cursor = commentEnd + 1;
} else if (
char === '/' &&
(!lastSignificant ||
(/[=([{!?:;,&|+\-*%^~<>]/.test(lastSignificant) && !afterPostfixUpdate) ||
(lastSignificant === '}' && lastClosedBraceKind === 'block') ||
(previousSignificant === '=' && lastSignificant === '>') ||
(currentWordPrefix !== '.' && REGEX_PREFIX_KEYWORDS.has(currentWord)))
) {
cursor = findRegexLiteralEnd(content, cursor);
if (cursor === -1) return -1;
recordSignificant(')');
} else if (char === '`') {
cursor = findTemplateLiteralEnd(content, cursor);
if (cursor === -1) return -1;
recordSignificant(')');
} else if (char === '{') {
depth++;
braceKinds.push(braceKind());
recordSignificant(char);
} else if (char === '}') {
depth--;
if (depth === 0) return cursor;
lastClosedBraceKind = braceKinds.pop() || '';
recordSignificant(char);
} else {
recordSignificant(char);
}
}
return -1;
}
function findTemplateLiteralEnd(content, start) {
for (let cursor = start + 1; cursor < content.length; cursor++) {
const char = content[cursor];
if (char === '\\') {
cursor++;
} else if (char === '`') {
return cursor;
} else if (char === '$' && content[cursor + 1] === '{') {
cursor = findTemplateExpressionEnd(content, cursor + 2);
if (cursor === -1) return -1;
}
}
return -1;
}
function findCSSinJSTemplates(content) {
const templates = [];
const tagRe = /\b(?:styled(?:\.\w+|\([^)]+\))|css)/g;
let match;
while ((match = tagRe.exec(content)) !== null) {
let cursor = match.index + match[0].length;
while (/\s/.test(content[cursor] || '')) cursor++;
if (content[cursor] === '<') {
let depth = 0;
while (cursor < content.length) {
const char = content[cursor];
if (char === '<') depth++;
else if (char === '>' && content[cursor - 1] !== '=') depth--;
cursor++;
if (depth === 0) break;
}
if (depth !== 0) continue;
while (/\s/.test(content[cursor] || '')) cursor++;
}
if (content[cursor] !== '`') continue;
const contentStart = cursor + 1;
cursor = findTemplateLiteralEnd(content, cursor);
if (cursor === -1) continue;
templates.push({
tagStart: match.index,
contentStart,
contentEnd: cursor,
});
tagRe.lastIndex = cursor + 1;
}
return templates;
}
function extractCSSinJS(content, ext) {
ext = ext.toLowerCase();
if (!CSS_IN_JS_EXTENSIONS.has(ext)) return [];
const blocks = [];
const re = /(?:styled(?:\.\w+|\([^)]+\))|css)\s*`([\s\S]*?)`/g;
let m;
while ((m = re.exec(content)) !== null) {
const before = content.substring(0, m.index);
return findCSSinJSTemplates(content).map((template) => {
const before = content.substring(0, template.tagStart);
const startLine = before.split('\n').length;
blocks.push({ content: m[1], startLine });
return {
content: content.slice(template.contentStart, template.contentEnd),
startLine,
};
});
}
function stripCssInJsComments(content, ext) {
if (!CSS_IN_JS_EXTENSIONS.has(ext.toLowerCase())) return content;
const templates = findCSSinJSTemplates(content);
let output = '';
let cursor = 0;
for (const template of templates) {
output += content.slice(cursor, template.contentStart);
output += stripCssComments(content.slice(template.contentStart, template.contentEnd));
cursor = template.contentEnd;
}
return blocks;
return output + content.slice(cursor);
}
function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, options = {}) {
@@ -627,8 +1022,12 @@ function runTextContentAnalyzers(content, filePath, options = {}) {
function detectText(content, filePath, options = {}) {
const profile = options?.profile;
const findings = [];
const lines = content.split('\n');
const ext = extFromFilePath(filePath);
const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, {
jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx',
}) : content;
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
@@ -661,8 +1060,8 @@ function detectText(content, filePath, options = {}) {
phase: 'source',
ruleId: 'codex-grid-background',
target: filePath,
}, () => scanCssTextForGridBackground(content).map(hit => {
const line = content.substring(0, hit.index).split('\n').length;
}, () => scanCssTextForGridBackground(source).map(hit => {
const line = source.substring(0, hit.index).split('\n').length;
return finding('codex-grid-background', filePath, hit.snippet, line);
})));
@@ -698,16 +1097,17 @@ function detectText(content, filePath, options = {}) {
phase: 'extract',
ruleId: 'css-in-js',
target: filePath,
}, () => extractCSSinJS(content, ext))
: extractCSSinJS(content, ext);
}, () => extractCSSinJS(source, ext))
: extractCSSinJS(source, ext);
for (const block of cssJsBlocks) {
const blockLines = block.content.split('\n');
const blockContent = stripCssComments(block.content);
const blockLines = blockContent.split('\n');
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
profile,
phase: 'css-in-js',
}));
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 1));
findings.push(...pseudoStripeFindings(block.content, block.startLine - 1));
findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 1));
findings.push(...pseudoStripeFindings(blockContent, block.startLine - 1));
}
if (options?.designSystem) {
@@ -26,11 +26,26 @@ const HIDDEN_SOURCE_DIRS = new Set(['.vitepress', '.vuepress', '.storybook']);
const SCANNABLE_EXTENSIONS = new Set([
'.html', '.htm', '.css', '.scss', '.sass', '.less',
'.jsx', '.tsx', '.js', '.ts',
'.vue', '.svelte', '.astro',
'.vue', '.svelte', '.astro', '.blade.php',
]);
const HTML_EXTENSIONS = new Set(['.html', '.htm']);
function hasScannableExtension(filename) {
const lower = filename.toLowerCase();
if (SCANNABLE_EXTENSIONS.has(path.extname(lower))) return true;
for (const ext of SCANNABLE_EXTENSIONS) {
if (ext.indexOf('.', 1) !== -1 && lower.endsWith(ext)) return true;
}
return false;
}
const IMPORT_SPECIFIER_PATTERNS = [
/import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g,
/@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g,
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
];
function walkDir(dir) {
const files = [];
let entries;
@@ -40,7 +55,7 @@ function walkDir(dir) {
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full));
else if (SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(full);
else if (hasScannableExtension(entry.name)) files.push(full);
}
return files;
}
@@ -75,26 +90,11 @@ function buildImportGraph(files) {
const dir = path.dirname(file);
const imports = new Set();
// ES imports: import ... from '...' and import '...'
const esRe = /import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g;
let m;
while ((m = esRe.exec(content)) !== null) {
const resolved = resolveImport(m[1], dir, fileSet);
if (resolved) imports.add(resolved);
}
// CSS @import
const cssRe = /@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g;
while ((m = cssRe.exec(content)) !== null) {
const resolved = resolveImport(m[1], dir, fileSet);
if (resolved) imports.add(resolved);
}
// SCSS @use / @forward
const scssRe = /@(?:use|forward)\s+['"]([^'"]+)['"]/g;
while ((m = scssRe.exec(content)) !== null) {
const resolved = resolveImport(m[1], dir, fileSet);
if (resolved) imports.add(resolved);
for (const pattern of IMPORT_SPECIFIER_PATTERNS) {
for (const match of content.matchAll(pattern)) {
const resolved = resolveImport(match[1], dir, fileSet);
if (resolved) imports.add(resolved);
}
}
graph.set(file, imports);
@@ -203,6 +203,7 @@ export {
SKIP_DIRS,
SCANNABLE_EXTENSIONS,
HTML_EXTENSIONS,
hasScannableExtension,
walkDir,
resolveImport,
buildImportGraph,
@@ -10,7 +10,7 @@
* node hook-admin.mjs off # set enabled: false
* node hook-admin.mjs ignore-rule <rule-id> # append to ignoreRules
* node hook-admin.mjs ignore-rule overused-font --all-values
* node hook-admin.mjs ignore-file <glob> # append to ignoreFiles
* node hook-admin.mjs ignore-file <glob> [--shared|--local] # append to ignoreFiles
* node hook-admin.mjs ignore-value <rule> <value> # append to shared ignoreValues
* node hook-admin.mjs ignore-value <rule> <value> --local
* node hook-admin.mjs ignore-value <rule> "*" --file <glob> # rule off in <glob> only
@@ -166,7 +166,7 @@ function readRawConfigFile(filePath) {
}
}
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem', 'advisoryRules']);
function hookSection(unified) {
return unified && typeof unified === 'object' && !Array.isArray(unified) && unified.hook && typeof unified.hook === 'object' && !Array.isArray(unified.hook)
@@ -200,6 +200,15 @@ function stripDetectorKeys(raw) {
return out;
}
function pickDetectorKeys(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
const out = {};
for (const [key, value] of Object.entries(raw)) {
if (DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
}
return out;
}
// Write hook runtime config under `hook`, leaving detector filters in
// `detector` and preserving sibling keys such as updateCheck.
function writeHookConfig(cwd, hookConfig, opts = {}) {
@@ -207,10 +216,19 @@ function writeHookConfig(cwd, hookConfig, opts = {}) {
if (opts.local) ensureHookGitExcludes(cwd);
const existingRaw = readRawConfigFile(filePath).raw;
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const existingHook = stripDetectorKeys(hookSection(existing));
const existingHookSection = hookSection(existing);
const existingHook = stripDetectorKeys(existingHookSection);
const legacyDetector = pickDetectorKeys(existingHookSection);
// Merge over the existing hook object so fields the merge helpers don't manage
// (consent, quiet, auditLog) survive an Impeccable hooks edit.
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
if (Object.keys(legacyDetector).length > 0) {
const existingDetector = detectorSection(existing) || {};
next.detector = {
...existingDetector,
...mergeDetectorConfig(existingDetector, mergeDetectorConfig(legacyDetector)),
};
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
return filePath;
@@ -222,10 +240,14 @@ function writeDetectorConfig(cwd, detectorConfig, opts = {}) {
const existingRaw = readRawConfigFile(filePath).raw;
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const nextHook = stripDetectorKeys(hookSection(existing));
const existingDetector = mergeDetectorConfig(detectorSection(existing));
const existingDetectorSection = detectorSection(existing) || {};
const existingDetector = mergeDetectorConfig(existingDetectorSection);
const next = {
...existing,
detector: mergeDetectorConfig(detectorConfig, existingDetector),
detector: {
...existingDetectorSection,
...mergeDetectorConfig(detectorConfig, existingDetector),
},
};
if (Object.keys(nextHook).length > 0) next.hook = nextHook;
else delete next.hook;
@@ -259,12 +281,18 @@ function mergeDetectorConfig(existing, seed = null) {
if (seed?.designSystem && typeof seed.designSystem === 'object' && !Array.isArray(seed.designSystem)) {
out.designSystem = { ...seed.designSystem };
}
if (seed?.advisoryRules === 'include' || seed?.advisoryRules === 'exclude') {
out.advisoryRules = seed.advisoryRules;
}
if (base.designSystem && typeof base.designSystem === 'object' && !Array.isArray(base.designSystem)) {
out.designSystem = {
...(out.designSystem || {}),
enabled: base.designSystem.enabled === false ? false : true,
};
}
if (base.advisoryRules === 'include' || base.advisoryRules === 'exclude') {
out.advisoryRules = base.advisoryRules;
}
if (Array.isArray(base.ignoreRules)) {
out.ignoreRules = Array.from(new Set([...out.ignoreRules, ...base.ignoreRules.map(String)]));
}
@@ -558,12 +586,44 @@ function addIgnoreRule(cwd, args) {
return `Added "${rule}" to detector.ignoreRules. Current: ${config.ignoreRules.join(', ')}`;
}
function addIgnoreFile(cwd, glob) {
function parseIgnoreFileArgs(args) {
const positionals = [];
let shared = false;
let local = false;
for (const raw of args) {
const arg = String(raw || '');
if (arg === '--shared') {
shared = true;
} else if (arg === '--local') {
local = true;
} else if (arg === '--reason' || arg.startsWith('--reason=')) {
throw new Error('--reason is not supported for ignore-file because detector.ignoreFiles stores globs only; use ignore-value when a documented rule-specific exception fits');
} else if (arg.startsWith('--')) {
throw new Error(`Unknown ignore-file flag: ${arg}`);
} else {
positionals.push(arg);
}
}
if (shared && local) throw new Error('Pass only one scope flag: --shared or --local');
if (positionals.length > 1) throw new Error('Pass exactly one glob to ignore-file');
return {
glob: positionals[0],
local,
};
}
function addIgnoreFile(cwd, args) {
const parsed = parseIgnoreFileArgs(args);
const glob = parsed.glob;
if (!glob) throw new Error(`Pass a glob, e.g. ${IMPECCABLE_COMMAND} hooks ignore-file "src/legacy/**"`);
const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local: parsed.local }));
if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob);
writeDetectorConfig(cwd, config);
return `Added "${glob}" to detector.ignoreFiles. Current: ${config.ignoreFiles.join(', ')}`;
const target = writeDetectorConfig(cwd, config, { local: parsed.local });
const scope = parsed.local ? 'local detector.ignoreFiles' : 'shared detector.ignoreFiles';
return `Added "${glob}" to ${scope} (${path.relative(cwd, target) || target}). Current: ${config.ignoreFiles.join(', ')}`;
}
// An empty glob used to be dropped by filter(Boolean), so `--file=` reported
@@ -727,7 +787,7 @@ function main() {
case 'on': out = setEnabled(cwd, true); break;
case 'off': out = setEnabled(cwd, false); break;
case 'ignore-rule': out = addIgnoreRule(cwd, rest); break;
case 'ignore-file': out = addIgnoreFile(cwd, rest[0]); break;
case 'ignore-file': out = addIgnoreFile(cwd, rest); break;
case 'ignore-value': out = addIgnoreValue(cwd, rest); break;
case 'reset': out = reset(cwd); break;
}
@@ -23,6 +23,7 @@ import {
designSystemOptions,
filterFindings,
isNativePlatform,
isScanTargetInsideProject,
loadDetector,
matchConfiguredExtension,
matchesAnyGlob,
@@ -161,7 +162,7 @@ function replaceOnce(original, oldString, newString) {
}
function readExistingProjectFile(filePath, cwd) {
if (!isInsideProject(filePath, cwd)) return null;
if (!isScanTargetInsideProject(filePath, cwd)) return null;
if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null;
try {
const stat = fs.statSync(filePath);
@@ -232,7 +233,7 @@ function shellCopiedFileContent(command, cwd) {
const source = shellCopyPaths(command)?.source;
if (!source) return '';
const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source);
if (!isInsideProject(sourcePath, cwd)) return '';
if (!isScanTargetInsideProject(sourcePath, cwd)) return '';
if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return '';
try {
const stat = fs.statSync(sourcePath);
@@ -328,15 +329,6 @@ function relativePath(filePath, cwd) {
}
}
function isInsideProject(filePath, cwd) {
try {
const rel = path.relative(cwd, filePath);
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
} catch {
return false;
}
}
// The static HTML engine reads its input from disk, but preToolUse only has
// the proposed content. Stage it in a temp file so html-engine targets get the
// same DOM-structural rules pre-write that runHook applies post-edit.
@@ -414,7 +406,7 @@ async function main() {
};
if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started });
if (!isInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started });
if (!isScanTargetInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started });
if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
@@ -1335,6 +1335,51 @@ function isInsideProject(filePath, projectCwd) {
}
}
// Resolve a path to its canonical (symlink-free) form. When the path does
// not exist yet — the before-edit hook gates proposed Writes — canonicalize
// the nearest existing ancestor and re-append the remainder, so a new file
// under a symlinked root still compares equal to its canonical project.
// Memoized: the hook runs as a fresh process per tool event, so the cache
// amounts to once-per-event work — the scan loops re-check the same project
// root for every target file. The cap only matters to long-lived importers
// like the test runner.
const canonicalPathCache = new Map();
const CANONICAL_PATH_CACHE_MAX = 1024;
function canonicalPath(p) {
const resolved = path.resolve(p);
if (canonicalPathCache.has(resolved)) return canonicalPathCache.get(resolved);
let canonical = resolved;
let dir = resolved;
const tail = [];
while (true) {
try {
canonical = tail.length ? path.join(fs.realpathSync(dir), ...tail) : fs.realpathSync(dir);
break;
} catch { /* keep climbing */ }
const parent = path.dirname(dir);
if (parent === dir) break;
tail.unshift(path.basename(dir));
dir = parent;
}
if (canonicalPathCache.size >= CANONICAL_PATH_CACHE_MAX) canonicalPathCache.clear();
canonicalPathCache.set(resolved, canonical);
return canonical;
}
// Containment gate shared by the before-edit hook and both scan passes. A
// session routinely touches files that belong to no project or to a
// different one — harness scratchpad dirs under the system temp root,
// sibling checkouts, one-off throwaway HTML — and findings against those are
// judged with THIS project's config and DESIGN.md palette, which is never
// right. Skip them (audit reason: outside-project). Paths are canonicalized
// first so a symlinked root (macOS /tmp -> /private/tmp) doesn't split the
// comparison.
export function isScanTargetInsideProject(filePath, projectCwd) {
if (!filePath || !projectCwd) return false;
return isInsideProject(canonicalPath(filePath), canonicalPath(projectCwd));
}
export function parseStaticStyleImports(content, fromFile, projectCwd) {
if (!content || typeof content !== 'string') return [];
const dir = path.dirname(fromFile);
@@ -1693,6 +1738,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
lastSkip = 'file-missing';
continue;
}
if (!isScanTargetInsideProject(filePath, projectCwd)) {
lastSkip = 'outside-project';
continue;
}
const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes;
if (maxFileBytes > 0) {
@@ -2023,6 +2072,10 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
const relForMatch = relativize(filePath, projectCwd);
if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue;
if (!fs.existsSync(filePath)) continue;
// Caches written before this gate existed can still hold out-of-project
// paths, so the Stop pass re-checks containment rather than trusting
// the per-edit pass to have filtered them.
if (!isScanTargetInsideProject(filePath, projectCwd)) continue;
scanned += 1;
let content = '';
@@ -50,9 +50,36 @@ export function normalizeConceptForm(value) {
.trim();
}
export function validateConceptEntry(concept, { existingForms = new Map() } = {}) {
export function validateConceptEntry(concept, { existingForms = new Map(), axes = null } = {}) {
const errors = [];
const id = concept?.id || '(unknown)';
// Recorded aesthetic axis values. Optional, and absent means the value is
// inferred from the system rules instead. Some axes cannot be inferred at all:
// depth's keyword probe matched worlds that said "no cast shadow anywhere",
// and motion and colour strategy describe properties the rules never state, so
// a wave that assigns those has to record them or the assignment is lost.
// Validated against the axes definition when the caller supplies it, because a
// typo would read as "unrecorded" and silently fall back to a probe that is
// known not to work.
if (concept?.axes !== undefined && concept.axes !== null) {
if (typeof concept.axes !== 'object' || Array.isArray(concept.axes)) {
errors.push(`concept ${id} axes must be an object of axis id to value id`);
} else if (axes) {
const byId = new Map((axes.axes || []).map(axis => [axis.id, axis]));
for (const [axisId, valueId] of Object.entries(concept.axes)) {
const axis = byId.get(axisId);
if (!axis) {
errors.push(`concept ${id} names unknown axis "${axisId}"`);
} else if (!(axis.values || []).some(value => value.id === valueId)) {
errors.push(
`concept ${id} axis "${axisId}" has unknown value "${valueId}" `
+ `(expected one of ${(axis.values || []).map(v => v.id).join(', ')})`
);
}
}
}
}
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(concept?.id || '')) {
errors.push(`invalid concept id: ${String(concept?.id)}`);
}
@@ -82,6 +109,18 @@ export function validateConceptEntry(concept, { existingForms = new Map() } = {}
|| concept.tags.some(tag => typeof tag !== 'string' || !tag.trim())) {
errors.push(`concept ${id} must have exactly three structural tags`);
}
// The slop this world in particular is at risk of. Optional, because 541
// entries predate it and none of them are wrong for lacking it. A world built
// from posters is at risk of shouting and one built from instruments is at
// risk of dead greys; a global detector cannot know which, and the author can.
if (concept?.avoid !== undefined) {
if (!Array.isArray(concept.avoid)
|| concept.avoid.length < 2
|| concept.avoid.length > 3
|| concept.avoid.some(item => typeof item !== 'string' || item.trim().length < 12 || item.trim().length > 160)) {
errors.push(`concept ${id} avoid must be two or three negations of 12160 characters`);
}
}
if (!Array.isArray(concept?.system)
|| concept.system.length !== SYSTEM_PREFIXES.length
|| concept.system.some(rule => typeof rule !== 'string' || rule.trim().length < 12 || rule.trim().length > 180)) {
@@ -2,15 +2,20 @@
// the live-mode design-system panel can render. Deterministic, dependency-free.
//
// Two-layer: YAML frontmatter (machine-readable tokens) + markdown body
// (prose with six canonical H2 sections). When frontmatter is present, it's
// (prose with eight canonical H2 sections). When frontmatter is present, it's
// exposed on `model.frontmatter` alongside the prose-scraped sections;
// consumers can prefer frontmatter values and fall back to prose.
// Array order is also match precedence: matchCanonicalSection's keyword-contained
// pass returns the first entry a heading contains, so reordering this changes
// which section an ambiguous heading resolves to.
const CANONICAL_SECTIONS = [
'Overview',
'Colors',
'Typography',
'Layout',
'Elevation',
'Shapes',
'Components',
"Do's and Don'ts",
];
@@ -115,10 +120,71 @@ function stripInlineYamlComment(s) {
return s;
}
// YAML double-quoted scalars process backslash escapes. Stripping the outer
// quotes without unescaping leaves them in place, so a nested font family like
// fontFamily: "\"IBM Plex Sans\", system-ui, sans-serif"
// keeps its literal backslashes and never matches the same family in CSS.
// The full YAML 1.2 double-quote escape set (spec section 5.7).
const YAML_SIMPLE_ESCAPES = {
'0': '\0',
a: '\x07',
b: '\b',
t: '\t',
n: '\n',
v: '\v',
f: '\f',
r: '\r',
e: '\x1b',
' ': ' ',
'"': '"',
'/': '/',
'\\': '\\',
N: '\u0085',
_: '\u00a0',
L: '\u2028',
P: '\u2029',
};
const YAML_HEX_ESCAPE_LENGTHS = { x: 2, u: 4, U: 8 };
function unescapeYamlDoubleQuoted(body) {
let out = '';
for (let i = 0; i < body.length; i++) {
const ch = body[i];
if (ch !== '\\' || i === body.length - 1) {
out += ch;
continue;
}
const next = body[i + 1];
if (Object.prototype.hasOwnProperty.call(YAML_SIMPLE_ESCAPES, next)) {
out += YAML_SIMPLE_ESCAPES[next];
i++;
continue;
}
// \xNN, \uNNNN, \UNNNNNNNN. Malformed or out-of-range sequences stay
// literal rather than corrupting the rest of the scalar.
const hexLen = YAML_HEX_ESCAPE_LENGTHS[next];
if (hexLen) {
const hex = body.slice(i + 2, i + 2 + hexLen);
const codePoint = hex.length === hexLen && /^[0-9a-fA-F]+$/.test(hex) ? parseInt(hex, 16) : -1;
if (codePoint >= 0 && codePoint <= 0x10ffff) {
out += String.fromCodePoint(codePoint);
i += 1 + hexLen;
continue;
}
}
out += ch;
}
return out;
}
function parseScalar(raw) {
const s = raw.trim();
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
return s.slice(1, -1);
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
return unescapeYamlDoubleQuoted(s.slice(1, -1));
}
// Single-quoted YAML escapes only the quote itself, by doubling it.
if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) {
return s.slice(1, -1).split("''").join("'");
}
if (s === 'true') return true;
if (s === 'false') return false;
@@ -330,17 +396,16 @@ function extractOverview(section) {
if (!section) return null;
const text = section.lines.join('\n');
const northStar = text.match(/\*\*Creative North Star:\s*"([^"]+)"\*\*/);
const keyChars = [];
const keyCharMatch = text.match(/\*\*Key Characteristics:\*\*\s*\n([\s\S]+?)(?:\n##|\n###|$)/);
if (keyCharMatch) {
for (const line of keyCharMatch[1].split('\n')) {
const m = line.match(/^\s*[-*]\s+(.+)$/);
if (m) keyChars.push(stripBold(m[1].trim()));
}
}
const keyChars = keyCharMatch
? collectBullets(keyCharMatch[1].split('\n')).map((bullet) => stripBold(bullet.trim()))
: [];
const prose = keyCharMatch
? text.slice(0, keyCharMatch.index) + text.slice(keyCharMatch.index + keyCharMatch[0].length)
: text;
// Philosophy paragraphs: everything that isn't a rule header or key-char block
const paragraphs = collectParagraphs(section.lines).filter(
const paragraphs = collectParagraphs(prose.split('\n')).filter(
(p) =>
!p.startsWith('**Creative North Star') &&
!p.startsWith('**Key Characteristics')
@@ -602,11 +667,19 @@ function parseTypeBullet(bullet) {
};
}
function extractElevation(section) {
function extractGuidance(section) {
if (!section) return null;
const subs = splitSubsections(section.lines);
return {
subtitle: section.subtitle,
description: collectParagraphs(subs[0].lines).join(' ') || null,
rules: extractNamedRules(section.lines),
};
}
const description = collectParagraphs(subs[0].lines).join(' ') || null;
function extractElevation(section) {
const guidance = extractGuidance(section);
if (!guidance) return null;
const shadows = [];
const seen = new Set();
@@ -631,12 +704,7 @@ function extractElevation(section) {
for (const inline of extractInlineShadows(b)) dedupe(inline);
}
return {
subtitle: section.subtitle,
description,
shadows,
rules: extractNamedRules(section.lines),
};
return { ...guidance, shadows };
}
function extractInlineShadows(text) {
@@ -768,6 +836,15 @@ function extractDosDonts(section) {
// ---------- Coverage assessment ----------
// Sections whose model is description-plus-rules only (see extractGuidance).
const guidanceCoverage = (guidance) =>
guidance
? {
description: Boolean(guidance.description),
rules: guidance.rules.length,
}
: 'missing';
function assessCoverage(model) {
const report = {};
@@ -796,6 +873,8 @@ function assessCoverage(model) {
}
: 'missing';
report.layout = guidanceCoverage(model.layout);
report.elevation = model.elevation
? {
shadows: model.elevation.shadows.length,
@@ -804,6 +883,8 @@ function assessCoverage(model) {
}
: 'missing';
report.shapes = guidanceCoverage(model.shapes);
report.components = model.components
? {
count: model.components.components.length,
@@ -833,7 +914,9 @@ export function parseDesignMd(md) {
overview: extractOverview(sections['Overview']),
colors: extractColors(sections['Colors']),
typography: extractTypography(sections['Typography']),
layout: extractGuidance(sections['Layout']),
elevation: extractElevation(sections['Elevation']),
shapes: extractGuidance(sections['Shapes']),
components: extractComponents(sections['Components']),
dosDonts: extractDosDonts(sections["Do's and Don'ts"]),
};
@@ -0,0 +1,26 @@
import { spawn } from 'node:child_process';
export function browserOpenCommand(url, {
platform = process.platform,
comspec = process.env.ComSpec || process.env.COMSPEC || 'cmd.exe',
} = {}) {
if (platform === 'darwin') return { command: 'open', args: [url] };
if (platform === 'win32') return { command: comspec, args: ['/c', 'start', '', url] };
return { command: 'xdg-open', args: [url] };
}
export function openSystemBrowser(url, {
platform = process.platform,
comspec = process.env.ComSpec || process.env.COMSPEC || 'cmd.exe',
spawnImpl = spawn,
} = {}) {
const { command, args } = browserOpenCommand(url, { platform, comspec });
try {
const child = spawnImpl(command, args, { stdio: 'ignore', detached: true });
child.on('error', () => {});
child.unref();
return true;
} catch {
return false;
}
}
@@ -96,31 +96,38 @@ function* rank(items, input, idFor = item => item.id) {
.map(entry => entry.item);
}
// Two independent exclusions, and either one is enough to hold a world back.
// Rating grades quality: a 3-star earns a second ticket, a 1-star marginal keep
// leaves the pool. Breadth says whether a world can serve an arbitrary build at
// all, so a niche world leaves however good it is, keeping its approval for
// direct briefs. Breadth was split out of rating because the only way to hold a
// narrow world back used to be calling it marginal, which made "excellent but
// narrow" unrecordable and corrupted ratings as a calibration signal.
// Rating sets how many tickets a world holds; breadth decides whether it draws
// at all. A niche world leaves the pool however good it is, keeping its approval
// for direct briefs. Breadth was split out of rating because the only way to
// hold a narrow world back used to be calling it marginal, which made "excellent
// but narrow" unrecordable and corrupted ratings as a calibration signal.
//
// Two tickets for a 3-star, one for everything else, was too sharp. Measured
// against the catalog as it stood: 3-star worlds absorbed 57% of the graphic
// draw from 65 of 163 eligible worlds, 46% of atmosphere from 13 of 43, and
// 75% of interaction from 15 of 25. The reviewer's complaint, that the same
// worlds keep coming back, is what a rating multiplier does to a pool whose
// thinnest tier holds 25 worlds.
//
// So a 3-star no longer outdraws a 2-star, and a 1-star draws at half rather
// than not at all. A marginal keep is still worth showing sometimes: the
// judgement it records is "narrow or unexceptional", not "wrong", and excluding
// it entirely made a rating do a job breadth already does properly.
const RATING_TICKETS = { 1: 1, 2: 2, 3: 2 };
const ticketsForRating = rating => RATING_TICKETS[rating] ?? 2;
function challengerTickets(pool) {
return pool.flatMap(concept => {
const rating = concept.review?.rating;
if (rating === 1 || concept.review?.breadth === 'niche') return [];
return rating === 3
? [{ concept, ticket: 0 }, { concept, ticket: 1 }]
: [{ concept, ticket: 0 }];
if (concept.review?.breadth === 'niche') return [];
return Array.from({ length: ticketsForRating(concept.review?.rating) },
(_, ticket) => ({ concept, ticket }));
});
}
function compositionTickets(pool) {
return pool.flatMap(composition => {
const rating = composition.review?.rating;
if (rating === 1) return [];
return rating === 3
? [{ composition, ticket: 0 }, { composition, ticket: 1 }]
: [{ composition, ticket: 0 }];
});
return pool.flatMap(composition => Array.from(
{ length: ticketsForRating(composition.review?.rating) },
(_, ticket) => ({ composition, ticket })));
}
/**
@@ -117,6 +117,23 @@ export function checkDesignDrift({ designPath, projectRoot, threshold = 25 }) {
* a section can be absent because it never applied, so this is reported as a
* documentation gap for a human to judge, never as an error.
*/
function hasCoverageValue(value) {
if (Array.isArray(value)) return value.some(hasCoverageValue);
if (value && typeof value === 'object') {
return Object.values(value).some(hasCoverageValue);
}
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length > 0 && !/^(?:\[\s*\]|\{\s*\})$/.test(trimmed);
}
return false;
}
const SEED_DESIGN_MARKERS = ['/', '$'].map((prefix) =>
'<!-- SEED: established with the user before implementation; '
+ `re-run ${prefix}impeccable document once there's code to capture the actual tokens and components. -->`
);
export function checkDesignCoverage({ design, designPath, parseDesignMd }) {
if (!design || typeof parseDesignMd !== 'function') return [];
let model;
@@ -125,8 +142,12 @@ export function checkDesignCoverage({ design, designPath, parseDesignMd }) {
} catch {
return [];
}
const missing = ['colors', 'typography', 'components']
.filter((section) => !model[section]);
const isSeed = SEED_DESIGN_MARKERS.some((marker) => design.includes(marker));
const requiredSections = isSeed
? ['colors', 'typography']
: ['colors', 'typography', 'components'];
const missing = requiredSections
.filter((section) => !model[section] && !hasCoverageValue(model.frontmatter?.[section]));
if (!missing.length) return [];
return [finding({
id: 'design-md-coverage',
@@ -3766,7 +3766,10 @@
const container = copyEditContainerContext(contextElement);
if (container) for (const op of ops) op.container = container;
try {
const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash', {
// Token in the query string as well as the body: the URL token is what
// authorizes the CORS preflight when the page runs on a non-loopback
// dev host (ddev, Valet), since the preflight carries no request body.
const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash?token=' + encodeURIComponent(TOKEN), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -7150,7 +7153,10 @@
console.debug('[impeccable] Dropped optional live event:', err);
return null;
}
const doSend = () => fetch('http://localhost:' + PORT + '/events', {
// Token in the query string as well as the body: the URL token is what
// authorizes the CORS preflight when the page runs on a non-loopback
// dev host (ddev, Valet), since the preflight carries no request body.
const doSend = () => fetch('http://localhost:' + PORT + '/events?token=' + encodeURIComponent(TOKEN), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(msg),
@@ -11969,7 +11975,9 @@ void main() {
rules: [
...(md.colors?.rules || []).map((r) => ({ ...r, section: 'colors' })),
...(md.typography?.rules || []).map((r) => ({ ...r, section: 'typography' })),
...(md.layout?.rules || []).map((r) => ({ ...r, section: 'layout' })),
...(md.elevation?.rules || []).map((r) => ({ ...r, section: 'elevation' })),
...(md.shapes?.rules || []).map((r) => ({ ...r, section: 'shapes' })),
],
dos: md.dosDonts?.dos || [],
donts: md.dosDonts?.donts || [],
@@ -689,16 +689,24 @@ function isLoopbackOrigin(origin) {
function createRequestHandler({ detectScript, liveScriptParts }) {
return (req, res) => {
const url = new URL(req.url, `http://localhost:${state.port}`);
// Loopback-restricted CORS. Reflect the caller's Origin only when it is a
// loopback origin, always paired with `Vary: Origin` so an intermediary
// cache never serves a response authorized for one origin to another. A
// remote page (e.g. https://evil.example probing the port from a tab open
// on the same machine) gets no Access-Control-Allow-Origin, so its
// JS-initiated fetch cannot read any response. Requests with no Origin
// header (script tags, curl, the agent's own fetches) are not subject to
// CORS and keep working; no ACAO header is needed for them.
// Token-or-loopback CORS. Reflect the caller's Origin when it is a
// loopback origin OR the request carries the valid session token, always
// paired with `Vary: Origin` so an intermediary cache never serves a
// response authorized for one origin to another. A remote page (e.g.
// https://evil.example probing the port from a tab open on the same
// machine) has no token and gets no Access-Control-Allow-Origin, so its
// JS-initiated fetch cannot read any response. The token branch exists for
// dev servers on non-localhost loopback aliases (ddev's *.ddev.site,
// Valet's *.test, hosts-file entries): the injected classic <script src>
// delivers the token to the page regardless of origin, every overlay
// request carries it in the query string (preflights included, since
// OPTIONS hits the same URL), and a token bearer is already fully
// authorized on every route — the token is the security boundary, not the
// origin. Requests with no Origin header (script tags, curl, the agent's
// own fetches) are not subject to CORS and keep working; no ACAO header
// is needed for them.
const origin = req.headers.origin;
if (origin && isLoopbackOrigin(origin)) {
if (origin && (isLoopbackOrigin(origin) || url.searchParams.get('token') === state.token)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
}
@@ -865,7 +873,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
// { present, parsed, sidecar, hasMd, hasSidecar,
// mdNewerThanJson, parseError?, sidecarError? }
// - parsed: output of parseDesignMd (frontmatter
// + six canonical sections) when DESIGN.md exists.
// + the canonical sections) when DESIGN.md exists.
// - sidecar: .impeccable/design.json contents when present.
// Expected shape: schemaVersion 2, carrying
// extensions + components + narrative.
@@ -1,180 +0,0 @@
/**
* Framework-neutral Impeccable live chrome contract.
*
* The production browser bundle is intentionally plain DOM so Svelte, React,
* Vue, and static adapters can all mount the same chrome. This module is the
* testable contract/inventory for that bundle; live-browser.js mirrors these
* values at runtime because it is served as a standalone script.
*/
export const LIVE_CHROME_MOUNT_CONTRACT = Object.freeze([
'root',
'transport',
'state',
'actions',
]);
export const LIVE_UI_SURFACES = Object.freeze([
{
key: 'global-bottom-bar',
ids: [
'impeccable-live-global-bar',
'impeccable-live-global-bar-brand',
'impeccable-live-pick-toggle',
'impeccable-live-insert-toggle',
'impeccable-live-detect-toggle',
'impeccable-live-detect-badge',
'impeccable-live-design-toggle',
'impeccable-live-page-chat',
'impeccable-live-page-chat-input',
'impeccable-live-page-chat-voice',
],
states: ['rest', 'hover', 'focus-visible', 'pressed', 'active', 'tooltip'],
},
{
key: 'pending-copy-edit-dock',
ids: ['impeccable-live-pending-dock'],
states: ['closed', 'open', 'hover', 'pressed', 'loading', 'rollback', 'keep-fixing'],
},
{
key: 'element-selection-chrome',
ids: [
'impeccable-live-highlight',
'impeccable-live-tooltip',
'impeccable-live-bar',
'impeccable-live-selection-pill',
'impeccable-live-input',
'impeccable-live-configure-voice',
'impeccable-live-configure-bar-tooltip',
],
states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'],
},
{
key: 'action-picker',
ids: ['impeccable-live-picker'],
states: ['closed', 'open', 'option-hover', 'option-focus'],
},
{
key: 'edit-chrome',
ids: ['impeccable-live-edit-badge'],
states: ['enabled', 'disabled', 'editing', 'cancel', 'save', 'edited-content'],
},
{
key: 'generating-row',
ids: ['impeccable-live-bar', 'impeccable-live-shader'],
states: ['action-label', 'animated-dots', 'generating', 'done'],
},
{
key: 'variant-cycling-row',
ids: ['impeccable-live-bar', 'impeccable-live-params-panel'],
states: ['variant-1', 'variant-2', 'variant-3', 'left-disabled', 'right-disabled', 'dot-click', 'accept', 'discard'],
},
{
key: 'variant-params-panel',
ids: ['impeccable-live-params-panel'],
states: ['closed', 'open-above', 'open-below', 'range', 'steps', 'toggle'],
},
{
key: 'saving-confirmed-rows',
ids: ['impeccable-live-bar'],
states: ['saving', 'applying-variant', 'confirmed'],
},
{
key: 'insert-mode-chrome',
ids: [
'impeccable-live-insert-line',
'impeccable-live-insert-placeholder',
'impeccable-live-placeholder-resize',
'impeccable-live-insert-input',
'impeccable-live-insert-voice',
'impeccable-live-insert-create',
'impeccable-live-insert-create-tooltip',
],
states: ['toggle-active', 'line', 'placeholder', 'resize', 'enabled', 'disabled', 'tooltip'],
},
{
key: 'annotation-chrome',
ids: [
'impeccable-live-annot',
'impeccable-live-annot-svg',
'impeccable-live-annot-pins',
'impeccable-live-annot-clear',
],
states: ['overlay', 'drawing', 'pin', 'pin-edit', 'clear'],
},
{
key: 'design-system-panel',
ids: ['impeccable-live-design-host'],
states: ['closed', 'open', 'tabs', 'token-tiles', 'copy'],
},
{
key: 'toasts-and-errors',
ids: ['impeccable-live-toast'],
states: ['normal', 'error', 'no-variants-mounted'],
},
{
key: 'css-isolation-boundary',
ids: ['impeccable-live-root'],
states: ['shadow-root', 'style-tags', 'hostile-css'],
},
]);
export const LIVE_UI_COMPONENT_IDS = Object.freeze([
...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids)),
]);
export function resolveLiveUiRoot(env = globalThis) {
const doc = env?.document;
const explicit = env?.__IMPECCABLE_LIVE_UI_ROOT__
|| env?.window?.__IMPECCABLE_LIVE_UI_ROOT__;
if (explicit && typeof explicit.appendChild === 'function') return explicit;
return doc?.body || null;
}
export function getLiveUiElementById(id, env = globalThis) {
const doc = env?.document;
const root = resolveLiveUiRoot(env);
if (!id) return null;
if (root?.getElementById) {
const found = root.getElementById(id);
if (found) return found;
}
if (root?.querySelector) {
const found = root.querySelector('#' + escapeCssIdent(id));
if (found) return found;
}
return doc?.getElementById?.(id) || null;
}
export function appendToLiveUiRoot(el, env = globalThis) {
const root = resolveLiveUiRoot(env);
if (!root) throw new Error('Impeccable live UI root is not available');
root.appendChild(el);
return el;
}
export function appendStyleToLiveUiRoot(styleEl, env = globalThis) {
const doc = env?.document;
const root = resolveLiveUiRoot(env);
if (root && root !== doc?.body) {
root.appendChild(styleEl);
} else {
(doc?.head || doc?.body || root).appendChild(styleEl);
}
return styleEl;
}
export function activeElementDeep(doc = globalThis.document) {
let active = doc?.activeElement || null;
while (active?.shadowRoot?.activeElement) {
active = active.shadowRoot.activeElement;
}
return active;
}
function escapeCssIdent(value) {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(String(value));
}
return String(value).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
}
+7 -5
View File
@@ -21,7 +21,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
// All known harness directories
const HARNESS_DIRS = [
'.claude', '.cursor', '.gemini', '.codex', '.agents', '.github', '.grok',
'.claude', '.cursor', '.gemini', '.codex', '.agents', '.agent', '.github', '.grok',
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev', '.vibe', '.qoder',
];
@@ -93,15 +93,17 @@ function commandPrefixForSkillsDir(skillsDir) {
return CODEX_HARNESSES.has(basename(dirname(skillsDir))) ? '$' : '/';
}
function generatePinnedSkill(command, metadata, commandPrefix) {
function generatePinnedSkill(command, metadata, commandPrefix, isCodex) {
const desc = metadata[command]?.description || `Shortcut for ${commandPrefix}impeccable ${command}.`;
const hint = metadata[command]?.argumentHint || '[target]';
const providerFrontmatter = isCodex
? `metadata:\n argument-hint: "${hint}"`
: `argument-hint: "${hint}"\nuser-invocable: true`;
return `---
name: ${command}
description: "${desc}"
argument-hint: "${hint}"
user-invocable: true
${providerFrontmatter}
---
${PIN_MARKER}
@@ -128,7 +130,7 @@ function pin(command, projectRoot) {
for (const skillsDir of harnessDirs) {
const commandPrefix = commandPrefixForSkillsDir(skillsDir);
const content = generatePinnedSkill(command, metadata, commandPrefix);
const content = generatePinnedSkill(command, metadata, commandPrefix, commandPrefix === '$');
// Check if skill already exists (and isn't a pin)
const skillDir = join(skillsDir, command);
if (existsSync(skillDir)) {
@@ -79,6 +79,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { openSystemBrowser } from './lib/open-system-browser.mjs';
function arg(name, fallback = null) {
const i = process.argv.indexOf(`--${name}`);
@@ -143,7 +144,7 @@ if (hasFlag('schema')) {
title: 'Choose the visual world',
question: 'The roll assigned Fillmore Handbill. Keep it, take an alternate, or re-roll.',
options: [
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', thesis: 'The gig poster that treats every release like a one-night stand.', palette: ['#e8452c', '#f5d64c', '#1b2a52', '#f3ead8'], materials: ['letterpress', 'split-fountain ink'], viewport: 'A full-bleed dated bill with the product name in warped display type.', risk: 'Reads nostalgic when the type is set timidly.', sketch: '.impeccable/sketches/assigned.webp', hero: 'https://impeccable.style/worlds/cards/fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/fillmore-handbill.webp' },
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', thesis: 'The gig poster that treats every release like a one-night stand.', palette: ['#e8452c', '#f5d64c', '#1b2a52', '#f3ead8'], materials: ['letterpress', 'split-fountain ink'], viewport: 'A full-bleed dated bill with the product name in warped display type.', risk: 'Reads nostalgic when the type is set timidly.', sketch: '.impeccable/sketches/assigned.webp', hero: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill.webp' },
{ id: 'challenger-teletext', label: 'Teletext Service', lineage: 'broadcast teletext magazines', thesis: 'The catalog as a broadcast index: pages, not sections.', case: 'Fuses cleanly: releases map to numbered pages.', sketch: '.impeccable/sketches/challenger-teletext.webp', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
],
reroll: true,
@@ -151,7 +152,7 @@ if (hasFlag('schema')) {
canonCard: { label: 'The category standard', thesis: 'What this category ships, executed impeccably.', viewport: 'The arrangement a visitor expects, at full craft.', sketch: '.impeccable/sketches/canon.webp' },
steer: true,
}, null, 2));
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/sketch accept URLs or local paths; sketch slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the sketch, never as the promise of the build. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. Sketch aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either.');
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/sketch accept URLs or local paths; sketch slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the sketch, never as the promise of the build. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. A card with no imagery at all has no back; its full read renders on the front, so a text-only round loses nothing. Sketch aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either.');
process.exit(0);
}
@@ -321,7 +322,11 @@ function page() {
// and material tags give a text-only direction an immediate identity that
// no generation luck can distort.
const fact = (label, value, cls = '') => value ? `<p class="fact${cls ? ` ${cls}` : ''}"><span class="fact-label">${label}</span>${esc(value)}</p>` : '';
const hasBack = (option) => Boolean(option.viewport || option.case || (option.boardSrc && option.heroSrc));
const hasMedia = (option) => Boolean(option.sketchSrc || option.heroSrc || option.boardSrc);
// The back exists to keep long facts off a card whose front is an image;
// a card with no art has no flip chip to reach it, so it gets no back and
// the full read lives on the front instead.
const hasBack = (option) => hasMedia(option) && Boolean(option.viewport || option.case || (option.boardSrc && option.heroSrc));
const anatomy = (option) => {
const rows = [];
if (option.thesis) rows.push(`<p class="thesis">${esc(option.thesis)}</p>`);
@@ -336,7 +341,15 @@ function page() {
// The front carries only what the choice needs: thesis, identity, and the
// honest risk clamped to two lines. First viewport and the case read on
// the card's back; once the sketch lands, the first viewport is a picture.
rows.push(fact('Risk', option.risk, 'clamp'));
// With no art there is no back, so the full read fills the room the
// image would have taken.
if (hasMedia(option)) {
rows.push(fact('Risk', option.risk, 'clamp'));
} else {
rows.push(fact('First viewport', option.viewport));
rows.push(fact('The case', option.case));
rows.push(fact('Risk', option.risk));
}
if (!option.thesis && option.body) rows.push(`<p class="detail">${esc(option.body)}</p>`);
else if (option.body && option.thesis && !hasBack(option)) rows.push(`<p class="detail more">${esc(option.body)}</p>`);
return rows.join('\n ');
@@ -362,8 +375,11 @@ function page() {
</div>`;
}
if (option.heroSrc || option.boardSrc) {
return `<div class="media">
// Without a sketch the catalog art is the card's face; it stays a
// labeled reference so it never reads as the promise of the build.
return `<div class="media" title="Inspiration: the world this direction draws from. Your page will not look like this image.">
<img src="${esc(option.heroSrc || option.boardSrc)}" alt="">
<p class="media-label">inspiration</p>
<div class="chips">${expandChip}${details}</div>
</div>`;
}
@@ -547,6 +563,17 @@ function page() {
.pip figcaption { position: absolute; left: 0; right: 0; bottom: 0; font-family: var(--ks-mono); font-size: .5rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 3px 0 4px; background: oklch(7% 0.006 95 / 0.72); backdrop-filter: blur(3px); }
.pip:hover { left: 0; bottom: 0; width: 100%; height: 100%; border-radius: 0; z-index: 3; }
.sketch-note { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: var(--ks-mono); font-size: .66rem; letter-spacing: .22em; text-transform: uppercase; color: var(--ks-text-faint); }
/* Catalog art standing in for a sketchless card is a reference, and says so
on its face; the same pill later carries "artwork unavailable". */
.media-label { position: absolute; z-index: 2; left: 10px; bottom: 10px; margin: 0; font-family: var(--ks-mono); font-size: .5rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); padding: 3px 8px 4px; background: oklch(7% 0.006 95 / 0.72); border: 1px solid var(--ks-rule); border-radius: 4px; backdrop-filter: blur(3px); }
/* Art that never arrives collapses to the card's own palette (painted
inline from its swatches) instead of sitting as a dark void wearing a
zoom cursor; the scrim keeps the label legible over saturated fields,
passes clicks through, and the flip chips stay above it. A card with no
palette falls back to the quiet graphite field. */
.media.unavailable { background: linear-gradient(100deg, var(--ks-graphite) 40%, var(--ks-graphite-2) 50%, var(--ks-graphite) 60%); }
.media.unavailable::after { content: ""; position: absolute; inset: 0; z-index: 1; background: oklch(10% 0.008 95 / 0.45); pointer-events: none; }
.media.unavailable .chips { z-index: 2; }
/* A stand-in is honest about being one: dimmed, labeled, and replaced by
the real sketch whenever it lands. */
.media.stand-in img.sketch { filter: brightness(.72) saturate(.85); }
@@ -709,6 +736,36 @@ function page() {
tryLoad();
});
// A declared image that never loads (missing catalog asset, offline shell)
// must not sit as a dark void: the slot collapses to the card's own
// palette, labeled honestly, and the card competes on its facts. Sketch
// slots are excluded; their polling owns the wait.
const artFailed = (img) => {
const m = img.closest('.media');
if (!m || m.classList.contains('sketching') || m.classList.contains('unavailable')) return;
m.classList.add('unavailable');
const colors = [...(img.closest('.card')?.querySelectorAll('.swatches i') || [])].map(i => i.style.background).filter(Boolean);
if (colors.length) m.style.background = 'linear-gradient(135deg, ' + colors.map((c, i) => c + ' ' + Math.round(i * 100 / colors.length) + '% ' + Math.round((i + 1) * 100 / colors.length) + '%').join(', ') + ')';
m.querySelector('.media-label')?.remove();
m.querySelector('.chip.expand')?.remove();
m.removeAttribute('title');
img.remove();
const label = document.createElement('p');
label.className = 'media-label';
label.textContent = 'artwork unavailable';
m.appendChild(label);
};
document.querySelectorAll('.media:not(.sketching) > img').forEach(img => {
if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) artFailed(img);
else img.addEventListener('error', () => artFailed(img), { once: true });
});
// A broken inspiration PIP just leaves; nothing depends on it.
document.querySelectorAll('.pip img').forEach(img => {
const gone = () => img.closest('.pip')?.remove();
if (img.complete && img.naturalWidth === 0) gone();
else img.addEventListener('error', gone, { once: true });
});
// Inspiration PIP opens the full catalog card in the lightbox.
document.querySelectorAll('.pip').forEach(p => p.addEventListener('click', (e) => {
e.stopPropagation();
@@ -920,8 +977,7 @@ server.listen(portArg, '127.0.0.1', () => {
console.log('Waiting for the user to choose in the browser (Ctrl-C aborts)...');
}
if (!hasFlag('no-open')) {
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
try { spawn(opener, [url], { stdio: 'ignore', detached: true }).unref(); } catch { /* URL printed anyway */ }
openSystemBrowser(url);
}
if (timeoutSec > 0) {
setTimeout(() => {
+1 -1
View File
@@ -21,7 +21,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s);
## Checks, in order
1. **Persistence.** PRODUCT.md exists. 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 comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comps with no recorded pick mean the approval point was skipped, and that is a material finding.
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, 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, because medium is part of the promise. When no approved comp was supplied, 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 actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. 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. In every material_fixes list, 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, and 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, because medium is part of the promise. When no approved comp was supplied, 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 actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. 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. In every material_fixes list, 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 and that is 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.
+2 -2
View File
@@ -7,7 +7,7 @@
"hooks": [
{
"type": "command",
"command": "[ ! -f \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\" ] || ! { node -e \"process.exit(parseInt(process.versions.node,10)>=22?0:1)\" 2>/dev/null || { D=\"$HOME/.impeccable\"; [ -f \"$D/node-unsupported\" ] || { mkdir -p \"$D\" 2>/dev/null && : > \"$D/node-unsupported\" 2>/dev/null && printf '%s' '{\"systemMessage\":\"The impeccable design hook is not running: no Node 22 or newer on PATH. Install one, or remove the impeccable hook from your harness settings.\"}'; }; exit 0; }; } || node \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\"",
"command": "[ ! -f \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\" ] || ! { node -e \"process.exit(Math.min(parseInt(process.versions.node,10),22)===22?0:1)\" 2>/dev/null || { D=\"$HOME/.impeccable\"; [ -f \"$D/node-unsupported\" ] || { mkdir -p \"$D\" 2>/dev/null && : > \"$D/node-unsupported\" 2>/dev/null && printf '%s' '{\"systemMessage\":\"The impeccable design hook is not running: no Node 22 or newer on PATH. Install one, or remove the impeccable hook from your harness settings.\"}'; }; exit 0; }; } || node \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\"",
"timeout": 5,
"statusMessage": "Checking UI changes"
}
@@ -19,7 +19,7 @@
"hooks": [
{
"type": "command",
"command": "[ ! -f \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\" ] || ! { node -e \"process.exit(parseInt(process.versions.node,10)>=22?0:1)\" 2>/dev/null || { D=\"$HOME/.impeccable\"; [ -f \"$D/node-unsupported\" ] || { mkdir -p \"$D\" 2>/dev/null && : > \"$D/node-unsupported\" 2>/dev/null && printf '%s' '{\"systemMessage\":\"The impeccable design hook is not running: no Node 22 or newer on PATH. Install one, or remove the impeccable hook from your harness settings.\"}'; }; exit 0; }; } || node \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\"",
"command": "[ ! -f \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\" ] || ! { node -e \"process.exit(Math.min(parseInt(process.versions.node,10),22)===22?0:1)\" 2>/dev/null || { D=\"$HOME/.impeccable\"; [ -f \"$D/node-unsupported\" ] || { mkdir -p \"$D\" 2>/dev/null && : > \"$D/node-unsupported\" 2>/dev/null && printf '%s' '{\"systemMessage\":\"The impeccable design hook is not running: no Node 22 or newer on PATH. Install one, or remove the impeccable hook from your harness settings.\"}'; }; exit 0; }; } || node \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\"",
"timeout": 30,
"statusMessage": "Design deep pass"
}
+1 -1
View File
@@ -3,7 +3,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.0.4
user-invocable: true
argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]"
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
allowed-tools:
- Bash(npx impeccable *)
@@ -12,6 +12,7 @@ Each of these is a check on the built result, not an intention. Run them togethe
- **Type:** body measure 6575ch, display max 6rem, tracking floor -0.04em, balanced headings, obvious scale and weight steps. Run the real copy at every breakpoint and fix what overflows.
- **Motion:** one authored moment, not scattered effects and not one identical entrance on every section. Exponential ease-out from an already-visible default. Reach past transform and opacity: blur, backdrop-filter, clip-path, mask, and shadow belong to the palette when they stay smooth.
- **States:** hover, disabled, loading, error, empty. Plus real content, working controls, responsive composition, keyboard focus.
- **Browser surfaces:** the parts you did not draw still carry the design. Text selection, the caret, custom scrollbars, focus rings, underline offset, and the numerals in tabular data all ship with browser defaults that belong to no design system. Theme them from the palette. This is the cheapest signal that a page was built rather than assembled, and the one models skip most reliably.
- **Copy:** the product's own language. Controls name their action; errors name the problem and the recovery.
- **Coverage:** every brief requirement present and findable within seconds.
@@ -16,7 +16,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s);
## Checks, in order
1. **Persistence.** PRODUCT.md exists. 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 comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comps with no recorded pick mean the approval point was skipped, and that is a material finding.
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, 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, because medium is part of the promise. When no approved comp was supplied, 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 actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. 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. In every material_fixes list, 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, and 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, because medium is part of the promise. When no approved comp was supplied, 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 actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. 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. In every material_fixes list, 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 and that is 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.
@@ -48,7 +48,7 @@ The script assigns which structure gets built; your top-ranked structure is what
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, in 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. A standing preference gets recorded 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. You may 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, the dealt challengers as alternates carrying their QUALITY BAR cards, and re-roll, steer, plus canon enabled; 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 (run the script with `--schema` for the exact shape); the page renders identity from these fields, and a challenger's catalog image rides as labeled inspiration, never as the promise of the build. Author `canonCard` too: the category standard as one honest card with the 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>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now 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 was closed without an answer: 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 instead run the script without `--start` and let it auto-open and block. Only a session where no browser can open at all, headless, CI, an eval worker, a remote shell with no display, puts the same decision through the structured question tool instead; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry.
When image generation exists, every card also declares a `sketch` path under `.impeccable/sketches/`, the canon card included. Serve the page first, then produce the sketches; the page shimmer-waits per slot and the user may answer before they land. Render every sketch through one shared frame so the comparison stays about direction, never rendering luck: the requested surface's first viewport as a flat, matte design sketch in that card's own palette and type character, deliberately unfinished, no photorealism, no gloss, identical framing across cards; a candidate whose sketch looks more finished than the others has broken the comparison, not won it. The frame's aspect is the surface's own: a native app or mobile-first surface sketches portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen sketched landscape is a broken frame, not a neutral default. The only legible text in a sketch is the product's real name and one real headline; every other text region is greeked, indistinct lines standing where copy will go, because a sketch that renders invented specs, prices, or dates puts claims in front of the user that PRODUCT.md never made. Produce in the order the user reads: the assigned card, then the hand, then canon, each file written the moment it is done. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-sketch packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. A sketch answers which world, never which composition: the comp round still renders its full set, and the chosen card's sketch seeds at most one probe. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version.
When image generation exists, every card also declares a `sketch` path under `.impeccable/sketches/`, 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 sketches; the page shimmer-waits per slot and the user may answer before they land. Render every sketch through one shared frame so the comparison stays about direction, never rendering luck: the requested surface's first viewport as a flat, matte design sketch in that card's own palette and type character, deliberately unfinished, no photorealism, no gloss, identical framing across cards; a candidate whose sketch looks more finished than the others has broken the comparison, not won it. The frame's aspect is the surface's own: a native app or mobile-first surface sketches portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen sketched landscape is a broken frame, not a neutral default. The only legible text in a sketch is the product's real name and one real headline; every other text region is greeked, indistinct lines standing where copy will go, because a sketch that renders invented specs, prices, or dates puts claims in front of the user that PRODUCT.md never made. Produce in the order the user reads: the assigned card, then the hand, then canon, each file written the moment it is done. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-sketch packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. A sketch answers which world, never which composition: the comp round still renders its full set, and the chosen card's sketch seeds at most one probe. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version.
Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. Open the QUALITY BAR board and hero for the world you build the moment the choice lands, even if you viewed another card earlier; the ANSWER line names the chosen card's images (when the harness only reads files or runs sandboxed, download them into the workspace and open the relative path; sandboxed viewers reject absolute paths outside it). They set the craft level the build must reach, a rendered reference's finish, commitment, and art direction, never the composition; your surface serves this product.
@@ -86,9 +86,12 @@ 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. 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.
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.
- **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, 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. 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.
@@ -102,6 +105,6 @@ Preserve semantics, accessibility, performance, responsiveness, project conventi
Inspect desktop and mobile in one batched screenshot round, 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.
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. 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 build that skips this ships every tell the hook exists to catch. Capture desktop and mobile screenshots to files, 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, its direction contract, existing hook findings, the QUALITY BAR card and approved comp paths, and the craft-floor reference path. The reviewer has no browser; screenshots you fail to pass are checks it cannot run. Verify its return carries the five contract sections; on an empty or thrashed return, respawn once with the same inputs before doing anything else. 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 whose tool surface has 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. When the reviewer's first material fix is a rebuild directive, fidelity failed wholesale rather than in patches, so skip the fix batch: put that verdict in front of the user with the named comp regions and let them choose between a re-derivation and shipping as it stands. Otherwise apply the material fixes in one batch, rebuild once, and recapture the same viewports. 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 is deciding, 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. Report the final verdict table to the user as it stands, open items included, under the reviewer's own disposition word: a table with open material findings is never announced as a pass, and never under a softer label than the reviewer wrote. Do not run a second detector.
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. 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 build that skips this ships every tell the hook exists to catch. Capture desktop and mobile screenshots to files, 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, its direction contract, existing hook findings, the QUALITY BAR card and approved comp paths, and the craft-floor reference path. 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 its return carries the five contract sections; on an empty or thrashed return, respawn once with the same inputs before doing anything else. 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 whose tool surface has 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. When the reviewer's first material fix is a rebuild directive, fidelity failed wholesale rather than in patches, so 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 verdict, telling the user what is happening rather than asking permission to fix a failure. The user is consulted only when a second rebuild directive arrives, both verdicts on the table, or when rebuilding would discard content the user approved. Otherwise apply the material fixes in one batch, rebuild once, and recapture the same viewports. 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 is deciding, 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. Report the final verdict table to the user as it stands, open items included, under the reviewer's own disposition word: a table with open material findings is never announced as a pass, and never under a softer label than the reviewer wrote. Do not run a second detector.
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). A clean detector pass is not finished; finished is the contract kept, the comp honored, the review closed, and the system recorded.
@@ -28,11 +28,11 @@ After approval, record the choice where tools can find it: the approved comp's p
## Inventory implementation fidelity
Before building, inventory the approved 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 written 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. An element never written down is the element the build silently drops, and the direction contract's 150 words cannot carry this list, so this inventory is where it lives.
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, because everything the comp does not show gets built from this record, and 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 written 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 or a few decorative pixels is the compliance-token version of commitment. An element never written down is the element the build silently drops, and the direction contract's 150 words cannot carry this list, so this inventory is where it lives.
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; 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 it ends where drawing skill begins; an instruction-manual world does not convert its illustrations into diagrams, it makes them line-art illustrations. 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 instead of producing it 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, so "no photography on hand" forbids fake proof, not an illustrated hero.
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, and so is any texture by that name alone: woven cloth, paper grain, fabric, leather, brushed metal need no depth argument, because a CSS gradient or layered background 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 or a cotton field's weave, 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 it ends where drawing skill begins; an instruction-manual world does not convert its illustrations into diagrams, it makes them line-art illustrations. 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 instead of producing it 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, so "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, and code executed safely and professionally remains first-class there. 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.
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, and code executed safely and professionally remains first-class there. 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.
@@ -42,6 +42,8 @@ Treat the comp as a north star, not something to trace, and know what that allow
Generation context is part of the asset: a build composed by a thread that never saw the prompts places assets it does not understand. So prefer generating build-critical imagery in the build thread when the budget allows, and when a subagent produces assets instead, every asset must carry its prompt, and the builder reads those prompts before composing a single one of them. 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 "<the prompt used>"` so the intent lives inside the file itself and survives copies between machines and harnesses; `--read` recovers it from any impeccable-generated image.
When clean raster ingredients are required and the harness runs subagents, use 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"): give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise 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, the native tool or generate-image.mjs.
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 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 the producer, `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. Otherwise 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, the native tool or generate-image.mjs.
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.
@@ -405,13 +405,18 @@ ASSIGNED INDEX: ${buildIndex}
The assignment exists to refuse the model's ranking rut, never to outrank
the user or the brief. Never expose assignment metadata in user-facing labels.
No challengers this run: the roll service was unreachable and no local
catalog exists. A sandboxed exec tool with no network access causes exactly
this; before accepting degradation, rerun this command once through the
harness's network-enabled command tool. A sandboxed shell without network egress is the most common
cause: if this harness can rerun the command with network access granted,
do that once before proceeding. Otherwise proceed with the grounded
candidates alone; the assignment
above still applies at full strength. Tell the user plainly that this roll
catalog exists. A sandboxed shell without network egress is the most common
cause; before accepting degradation, rerun this command once through the
harness's network-enabled or escalated command tool. When that rerun needs
an approval, state exactly what the approver must know: this script's only
network contact is one GET to https://impeccable.style/api/roll whose query
carries scope, mode, an eight-hex seed key, and a re-roll counter; no
project files, prompts, code, or conversation context are transmitted, and
nothing is written. An approval request naming that URL and payload judges
the real action; a bare "run with network" invites rejection for contacting
an unspecified domain. If the rerun is still refused, proceed with the
grounded candidates alone; the assignment above still applies at full
strength. Tell the user plainly that this roll
ran degraded, with no challengers and no quality-bar boards; do not present
the outcome as a full roll. A degraded roll changes the cards, not the
channel: when a browser can open, present the direction on the decision page
@@ -22,7 +22,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { loadContext, extractPlatform } from './context.mjs';
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
import { readLatestSnapshotAcrossTargets } from './critique-storage.mjs';
/** Is there code here at all, or just context files / an empty repo? */
function hasCode(cwd) {
@@ -34,23 +34,13 @@ function hasCode(cwd) {
}
/**
* The most recent critique snapshot across all targets. Filenames are
* timestamp-prefixed (`<iso>__<slug>.md`), so a lexical sort is chronological.
* Parses the small frontmatter for score + P0/P1 counts.
* Summarize the most recent critique snapshot across all targets.
*/
function latestCritique(cwd) {
try {
const dir = getCritiqueDir(cwd);
if (!fs.existsSync(dir)) return null;
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')).sort();
if (!files.length) return null;
const newest = files[files.length - 1];
const text = fs.readFileSync(path.join(dir, newest), 'utf-8');
const front = text.split('---')[1] || '';
const get = (k) => {
const m = front.match(new RegExp(`^${k}:\\s*(.+)$`, 'm'));
return m ? m[1].trim() : null;
};
const latest = readLatestSnapshotAcrossTargets({ cwd });
if (!latest) return null;
const get = (key) => latest.meta[key] ?? null;
const num = (v) => {
const n = Number(v);
return Number.isFinite(n) ? n : null;
@@ -61,7 +51,7 @@ function latestCritique(cwd) {
p0: num(get('p0')),
p1: num(get('p1')),
timestamp: get('timestamp'),
file: path.relative(cwd, path.join(dir, newest)),
file: path.relative(cwd, latest.path),
};
} catch {
return null;
+20 -3
View File
@@ -27,6 +27,7 @@
* shape rather than the markdown block.
*/
import fs from 'node:fs';
import { spawnSync } from 'node:child_process';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -1146,6 +1147,7 @@ async function cli() {
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
parts.push(buildMissingTargetDirective());
}
appendImageToolsDirective(parts);
appendStalenessDirective(parts, ctx, cliOptions);
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
@@ -1180,6 +1182,7 @@ async function cli() {
`# NATIVE PLATFORM REFERENCE: ${reference.name.toUpperCase()} (reference/${reference.name}.md)\n\n${reference.content.trim()}`,
);
}
appendImageToolsDirective(parts);
appendStalenessDirective(parts, ctx, cliOptions);
if (!ctx.platform) {
// A `## Platform` section that names something we don't recognize (a
@@ -1275,9 +1278,10 @@ function appendImageGenDirective(parts) {
if (!process.env.OPENAI_API_KEY) return;
const scriptsPath = path.dirname(fileURLToPath(import.meta.url));
parts.push([
'IMAGE_GEN_AVAILABLE: An OpenAI key is present, so image generation works even without a harness-native image tool:',
`\`node ${scriptsPath}/generate-image.mjs --prompt "..." --out <file>\` (gpt-image-2, billed to the user's key; say so before the first render).`,
'Prefer the harness-native image tool when one exists. Visualizing a direction before building it measurably strengthens the result.',
'IMAGE_GEN_AVAILABLE: your harness-native image tool is always the first choice for generation; use it whenever one exists.',
'This environment also carries an OpenAI key as the fallback for harnesses with no native tool:',
`\`node ${scriptsPath}/generate-image.mjs --prompt "..." --out <file>\` (gpt-image-2, billed to the user's key; say so before the first render, and never reach for it when a native tool exists).`,
'Visualizing a direction before building it measurably strengthens the result.',
].join(' '));
}
@@ -1332,6 +1336,19 @@ function appendDetectorFallback(parts, ctx) {
// markdown already in memory, a bounded set of stats, or one of the small JSON
// files the boot reads regardless. The deep pass (git drift, token divergence,
// cross-workspace sweep) belongs to the doctor command, not to every session.
// One boot-time probe replaces every session re-deriving its image toolchain:
// harnesses and OSes differ (cwebp, sips on macOS, magick, ffmpeg), and the
// agent should read this line instead of running command -v per image.
function appendImageToolsDirective(parts) {
const probe = process.platform === 'win32' ? 'where' : 'which';
const found = ['cwebp', 'sips', 'magick', 'ffmpeg'].filter((tool) => {
try { return spawnSync(probe, [tool], { stdio: 'ignore' }).status === 0; } catch { return false; }
});
parts.push(found.length
? `IMAGE_TOOLS: available image converters on this machine: ${found.join(', ')}. Use the first suitable one; never probe again this session.`
: 'IMAGE_TOOLS: no image converter found (cwebp, sips, magick, ffmpeg). Ship PNG output unconverted rather than probing per image.');
}
function appendStalenessDirective(parts, ctx, options) {
const projectRoot = ctx.projectRoot || process.cwd();
if (stalenessCheckDisabled([projectRoot, ctx.repoRoot])) return;
@@ -105,28 +105,37 @@ function parseFrontmatter(text) {
}
/**
* Return all snapshot files for `slug`, sorted oldest newest.
* Return snapshot files matching `suffix`, sorted oldest newest.
*/
function listSnapshotsForSlug(slug, cwd) {
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/;
function listSnapshots(suffix, cwd) {
const dir = getCritiqueDir(cwd);
if (!fs.existsSync(dir)) return [];
const suffix = `__${slug}.md`;
return fs.readdirSync(dir)
.filter((f) => f.endsWith(suffix))
.filter((f) => SNAPSHOT_FILENAME.test(f) && f.endsWith(suffix))
.sort()
.map((f) => path.join(dir, f));
}
function readLatestSnapshotMatching(suffix, cwd) {
const filePath = listSnapshots(suffix, cwd).at(-1);
if (!filePath) return null;
const body = fs.readFileSync(filePath, 'utf-8');
return { path: filePath, body, meta: parseFrontmatter(body) };
}
/**
* 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() } = {}) {
const all = listSnapshotsForSlug(slug, cwd);
if (!all.length) return null;
const latest = all[all.length - 1];
const body = fs.readFileSync(latest, 'utf-8');
return { path: latest, body, meta: parseFrontmatter(body) };
return readLatestSnapshotMatching(`__${slug}.md`, cwd);
}
/** Return the most recent snapshot across all targets, or null. */
export function readLatestSnapshotAcrossTargets({ cwd = process.cwd() } = {}) {
return readLatestSnapshotMatching('.md', cwd);
}
/**
@@ -134,7 +143,7 @@ export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
* Critique appends a one-line trend to its output using this.
*/
export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
const all = listSnapshotsForSlug(slug, cwd);
const all = listSnapshots(`__${slug}.md`, cwd);
const slice = all.slice(-limit);
return slice.map((file) => parseFrontmatter(fs.readFileSync(file, 'utf-8')));
}
@@ -142,10 +142,73 @@ function stripInlineYamlComment(s) {
return s;
}
// YAML double-quoted scalars process backslash escapes. Stripping the outer
// quotes without unescaping leaves them in place, so a nested font family like
// fontFamily: "\"IBM Plex Sans\", system-ui, sans-serif"
// reaches allowedFonts as '\"ibm plex sans' and never matches the same family
// declared in CSS. Scanner instead of a regex: the escape set is small and the
// backslash handling stays readable.
// The full YAML 1.2 double-quote escape set (spec section 5.7).
const YAML_SIMPLE_ESCAPES = {
'0': '\0',
a: '\x07',
b: '\b',
t: '\t',
n: '\n',
v: '\v',
f: '\f',
r: '\r',
e: '\x1b',
' ': ' ',
'"': '"',
'/': '/',
'\\': '\\',
N: '\u0085',
_: '\u00a0',
L: '\u2028',
P: '\u2029',
};
const YAML_HEX_ESCAPE_LENGTHS = { x: 2, u: 4, U: 8 };
function unescapeYamlDoubleQuoted(body) {
let out = '';
for (let i = 0; i < body.length; i++) {
const ch = body[i];
if (ch !== '\\' || i === body.length - 1) {
out += ch;
continue;
}
const next = body[i + 1];
if (Object.prototype.hasOwnProperty.call(YAML_SIMPLE_ESCAPES, next)) {
out += YAML_SIMPLE_ESCAPES[next];
i++;
continue;
}
// \xNN, \uNNNN, \UNNNNNNNN. Malformed or out-of-range sequences stay
// literal rather than corrupting the rest of the scalar.
const hexLen = YAML_HEX_ESCAPE_LENGTHS[next];
if (hexLen) {
const hex = body.slice(i + 2, i + 2 + hexLen);
const codePoint = hex.length === hexLen && /^[0-9a-fA-F]+$/.test(hex) ? parseInt(hex, 16) : -1;
if (codePoint >= 0 && codePoint <= 0x10ffff) {
out += String.fromCodePoint(codePoint);
i += 1 + hexLen;
continue;
}
}
out += ch;
}
return out;
}
function parseScalar(raw) {
const s = raw.trim();
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
return s.slice(1, -1);
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
return unescapeYamlDoubleQuoted(s.slice(1, -1));
}
// Single-quoted YAML escapes only the quote itself, by doubling it.
if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) {
return s.slice(1, -1).split("''").join("'");
}
if (s === 'true') return true;
if (s === 'false') return false;
@@ -35,6 +35,7 @@ export { detectUrl, createBrowserDetector } from './engines/browser/detect-url.m
export { detectText, extractStyleBlocks, extractCSSinJS } from './engines/regex/detect-text.mjs';
export {
walkDir,
hasScannableExtension,
SCANNABLE_EXTENSIONS,
SKIP_DIRS,
buildImportGraph,
@@ -41,6 +41,221 @@ function shouldRunPageAnalyzers(content, filePath) {
return !ext || PAGE_ANALYZER_EXTS.has(ext);
}
const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
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']);
function isInsideOpeningJsxTag(source) {
const tagStart = source.lastIndexOf('<');
if (tagStart === -1 || !/^<[A-Za-z][\w.:-]*/.test(source.slice(tagStart))) return false;
let quote = '';
for (let cursor = tagStart + 1; cursor < source.length; cursor++) {
const char = source[cursor];
if (quote) {
if (char === '\\') cursor++;
else if (char === quote) quote = '';
} else if (char === "'" || char === '"') {
quote = char;
} else if (char === '>') {
return false;
}
}
return true;
}
/**
* Blank JavaScript comments without moving any following source. Regex
* findings keep their original line numbers, while prose examples inside
* comments cannot masquerade as rendered markup.
*/
function stripJsComments(content, options = {}) {
let state = 'code';
let output = '';
let lastSignificant = '';
let previousSignificant = '';
let antePreviousSignificant = '';
let currentWord = '';
let currentWordPrefix = '';
let wordSeparated = false;
let regexCharClass = false;
let jsxExpressionDepth = 0;
let lastClosedBraceKind = '';
const braceKinds = [];
const templateExpressionDepths = [];
const braceKind = (startsJsxExpression = false) => (
!startsJsxExpression && (
!lastSignificant ||
lastSignificant === ')' ||
lastSignificant === ';' ||
lastSignificant === '}' ||
(previousSignificant === '=' && lastSignificant === '>') ||
BLOCK_BRACE_PREFIX_KEYWORDS.has(currentWord)
) ? 'block' : 'expression'
);
const recordSignificant = (char) => {
if (/\s/.test(char)) {
wordSeparated = true;
return;
}
const isWordChar = /[\w$]/.test(char);
if (isWordChar && (wordSeparated || !currentWord)) {
currentWord = '';
currentWordPrefix = lastSignificant;
} else if (!isWordChar) {
currentWordPrefix = '';
}
wordSeparated = false;
antePreviousSignificant = previousSignificant;
previousSignificant = lastSignificant;
lastSignificant = char;
currentWord = isWordChar ? currentWord + char : '';
};
for (let i = 0; i < content.length; i++) {
const char = content[i];
const next = content[i + 1];
if (state === 'line-comment') {
if (char === '\n') {
output += char;
state = 'code';
} else {
output += ' ';
}
continue;
}
if (state === 'block-comment') {
if (char === '*' && next === '/') {
output += ' ';
i++;
state = 'code';
} else {
output += char === '\n' ? '\n' : ' ';
}
continue;
}
if (state === 'regex') {
output += char;
if (char === '\\' && next) {
output += next;
i++;
} else if (char === '[') {
regexCharClass = true;
} else if (char === ']') {
regexCharClass = false;
} else if (char === '/' && !regexCharClass) {
state = 'code';
recordSignificant('/');
}
continue;
}
if (state === 'template' && char === '$' && next === '{') {
output += '${';
i++;
recordSignificant('$');
recordSignificant('{');
templateExpressionDepths.push(1);
braceKinds.push('expression');
if (jsxExpressionDepth) jsxExpressionDepth++;
state = 'code';
continue;
}
if (state !== 'code') {
output += char;
if (char === '\\' && next) {
output += next;
i++;
} else if (
(state === 'single-quote' && char === "'") ||
(state === 'double-quote' && char === '"') ||
(state === 'template' && char === '`')
) {
state = 'code';
recordSignificant(char);
}
continue;
}
const jsxUrlSeparator = options.jsx && char === '/' && next === '/' &&
jsxExpressionDepth === 0 &&
(output.endsWith('http:') ||
output.endsWith('https:') ||
(/<[A-Za-z](?:[^>]*[^/])?>[^<]*$/.test(output.slice(output.lastIndexOf('\n') + 1)) &&
/^[\w.-]+\.[A-Za-z]{2,}(?=[:/?#\s<]|$)/.test(content.slice(i + 2))));
const afterPostfixUpdate = (lastSignificant === '+' || lastSignificant === '-') &&
previousSignificant === lastSignificant &&
antePreviousSignificant !== lastSignificant;
if (char === '/' && next === '/' && jsxUrlSeparator) {
output += '//';
i++;
recordSignificant('/');
recordSignificant('/');
} else if (char === '/' && next === '/') {
output += ' ';
i++;
state = 'line-comment';
} else if (char === '/' && next === '*') {
output += ' ';
i++;
state = 'block-comment';
} else if (templateExpressionDepths.length && char === '{') {
output += char;
templateExpressionDepths[templateExpressionDepths.length - 1]++;
braceKinds.push(braceKind());
if (jsxExpressionDepth) jsxExpressionDepth++;
recordSignificant(char);
} else if (templateExpressionDepths.length && char === '}') {
output += char;
const depthIndex = templateExpressionDepths.length - 1;
templateExpressionDepths[depthIndex]--;
lastClosedBraceKind = braceKinds.pop() || '';
if (jsxExpressionDepth) jsxExpressionDepth--;
recordSignificant(char);
if (templateExpressionDepths[depthIndex] === 0) {
templateExpressionDepths.pop();
state = 'template';
}
} else if (
char === '/' &&
(!lastSignificant ||
(/[=([{!?:;,&|+\-*%^~<>]/.test(lastSignificant) && !afterPostfixUpdate) ||
(lastSignificant === '}' && lastClosedBraceKind === 'block') ||
(previousSignificant === '=' && lastSignificant === '>') ||
(currentWordPrefix !== '.' && REGEX_PREFIX_KEYWORDS.has(currentWord)))
) {
output += char;
state = 'regex';
regexCharClass = false;
} else {
output += char;
const startsJsxExpression = options.jsx && char === '{' && jsxExpressionDepth === 0 &&
(/<[A-Za-z](?:[^>]*[^/])?>[^<]*$/.test(output.slice(output.lastIndexOf('\n') + 1, -1)) ||
isInsideOpeningJsxTag(output.slice(0, -1)));
if (char === '{') braceKinds.push(braceKind(startsJsxExpression));
else if (char === '}') lastClosedBraceKind = braceKinds.pop() || '';
if (char === '{' && (jsxExpressionDepth || startsJsxExpression)) jsxExpressionDepth++;
else if (char === '}' && jsxExpressionDepth) jsxExpressionDepth--;
recordSignificant(char);
if (char === "'") state = 'single-quote';
else if (char === '"') state = 'double-quote';
else if (char === '`') state = 'template';
}
}
return output;
}
function stripCssComments(content) {
return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' '));
}
function firstOverusedGoogleFont(text) {
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
}
@@ -528,18 +743,198 @@ function extractStyleBlocks(content, ext) {
const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']);
function findQuotedStringEnd(content, start, quote) {
for (let cursor = start + 1; cursor < content.length; cursor++) {
if (content[cursor] === '\\') cursor++;
else if (content[cursor] === quote) return cursor;
}
return -1;
}
function findRegexLiteralEnd(content, start) {
let inCharacterClass = false;
for (let cursor = start + 1; cursor < content.length; cursor++) {
const char = content[cursor];
if (char === '\\') {
cursor++;
} else if (char === '[') {
inCharacterClass = true;
} else if (char === ']') {
inCharacterClass = false;
} else if (char === '/' && !inCharacterClass) {
while (/[A-Za-z]/.test(content[cursor + 1] || '')) cursor++;
return cursor;
} else if (char === '\n' || char === '\r') {
return -1;
}
}
return -1;
}
function findTemplateExpressionEnd(content, start) {
let depth = 1;
let lastSignificant = '';
let previousSignificant = '';
let antePreviousSignificant = '';
let currentWord = '';
let currentWordPrefix = '';
let wordSeparated = false;
let lastClosedBraceKind = '';
const braceKinds = [];
const braceKind = () => (
lastSignificant === ')' ||
lastSignificant === ';' ||
lastSignificant === '}' ||
(previousSignificant === '=' && lastSignificant === '>') ||
BLOCK_BRACE_PREFIX_KEYWORDS.has(currentWord)
? 'block'
: 'expression'
);
const recordSignificant = (char) => {
if (/\s/.test(char)) {
wordSeparated = true;
return;
}
const isWordChar = /[\w$]/.test(char);
if (isWordChar && (wordSeparated || !currentWord)) {
currentWord = '';
currentWordPrefix = lastSignificant;
} else if (!isWordChar) {
currentWordPrefix = '';
}
wordSeparated = false;
antePreviousSignificant = previousSignificant;
previousSignificant = lastSignificant;
lastSignificant = char;
currentWord = isWordChar ? currentWord + char : '';
};
for (let cursor = start; cursor < content.length; cursor++) {
const char = content[cursor];
const next = content[cursor + 1];
const afterPostfixUpdate = (lastSignificant === '+' || lastSignificant === '-') &&
previousSignificant === lastSignificant &&
antePreviousSignificant !== lastSignificant;
if (char === "'" || char === '"') {
cursor = findQuotedStringEnd(content, cursor, char);
if (cursor === -1) return -1;
recordSignificant(')');
} else if (char === '/' && next === '/') {
const lineEnd = content.indexOf('\n', cursor + 2);
if (lineEnd === -1) return -1;
cursor = lineEnd;
} else if (char === '/' && next === '*') {
const commentEnd = content.indexOf('*/', cursor + 2);
if (commentEnd === -1) return -1;
cursor = commentEnd + 1;
} else if (
char === '/' &&
(!lastSignificant ||
(/[=([{!?:;,&|+\-*%^~<>]/.test(lastSignificant) && !afterPostfixUpdate) ||
(lastSignificant === '}' && lastClosedBraceKind === 'block') ||
(previousSignificant === '=' && lastSignificant === '>') ||
(currentWordPrefix !== '.' && REGEX_PREFIX_KEYWORDS.has(currentWord)))
) {
cursor = findRegexLiteralEnd(content, cursor);
if (cursor === -1) return -1;
recordSignificant(')');
} else if (char === '`') {
cursor = findTemplateLiteralEnd(content, cursor);
if (cursor === -1) return -1;
recordSignificant(')');
} else if (char === '{') {
depth++;
braceKinds.push(braceKind());
recordSignificant(char);
} else if (char === '}') {
depth--;
if (depth === 0) return cursor;
lastClosedBraceKind = braceKinds.pop() || '';
recordSignificant(char);
} else {
recordSignificant(char);
}
}
return -1;
}
function findTemplateLiteralEnd(content, start) {
for (let cursor = start + 1; cursor < content.length; cursor++) {
const char = content[cursor];
if (char === '\\') {
cursor++;
} else if (char === '`') {
return cursor;
} else if (char === '$' && content[cursor + 1] === '{') {
cursor = findTemplateExpressionEnd(content, cursor + 2);
if (cursor === -1) return -1;
}
}
return -1;
}
function findCSSinJSTemplates(content) {
const templates = [];
const tagRe = /\b(?:styled(?:\.\w+|\([^)]+\))|css)/g;
let match;
while ((match = tagRe.exec(content)) !== null) {
let cursor = match.index + match[0].length;
while (/\s/.test(content[cursor] || '')) cursor++;
if (content[cursor] === '<') {
let depth = 0;
while (cursor < content.length) {
const char = content[cursor];
if (char === '<') depth++;
else if (char === '>' && content[cursor - 1] !== '=') depth--;
cursor++;
if (depth === 0) break;
}
if (depth !== 0) continue;
while (/\s/.test(content[cursor] || '')) cursor++;
}
if (content[cursor] !== '`') continue;
const contentStart = cursor + 1;
cursor = findTemplateLiteralEnd(content, cursor);
if (cursor === -1) continue;
templates.push({
tagStart: match.index,
contentStart,
contentEnd: cursor,
});
tagRe.lastIndex = cursor + 1;
}
return templates;
}
function extractCSSinJS(content, ext) {
ext = ext.toLowerCase();
if (!CSS_IN_JS_EXTENSIONS.has(ext)) return [];
const blocks = [];
const re = /(?:styled(?:\.\w+|\([^)]+\))|css)\s*`([\s\S]*?)`/g;
let m;
while ((m = re.exec(content)) !== null) {
const before = content.substring(0, m.index);
return findCSSinJSTemplates(content).map((template) => {
const before = content.substring(0, template.tagStart);
const startLine = before.split('\n').length;
blocks.push({ content: m[1], startLine });
return {
content: content.slice(template.contentStart, template.contentEnd),
startLine,
};
});
}
function stripCssInJsComments(content, ext) {
if (!CSS_IN_JS_EXTENSIONS.has(ext.toLowerCase())) return content;
const templates = findCSSinJSTemplates(content);
let output = '';
let cursor = 0;
for (const template of templates) {
output += content.slice(cursor, template.contentStart);
output += stripCssComments(content.slice(template.contentStart, template.contentEnd));
cursor = template.contentEnd;
}
return blocks;
return output + content.slice(cursor);
}
function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, options = {}) {
@@ -627,8 +1022,12 @@ function runTextContentAnalyzers(content, filePath, options = {}) {
function detectText(content, filePath, options = {}) {
const profile = options?.profile;
const findings = [];
const lines = content.split('\n');
const ext = extFromFilePath(filePath);
const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, {
jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx',
}) : content;
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
@@ -661,8 +1060,8 @@ function detectText(content, filePath, options = {}) {
phase: 'source',
ruleId: 'codex-grid-background',
target: filePath,
}, () => scanCssTextForGridBackground(content).map(hit => {
const line = content.substring(0, hit.index).split('\n').length;
}, () => scanCssTextForGridBackground(source).map(hit => {
const line = source.substring(0, hit.index).split('\n').length;
return finding('codex-grid-background', filePath, hit.snippet, line);
})));
@@ -698,16 +1097,17 @@ function detectText(content, filePath, options = {}) {
phase: 'extract',
ruleId: 'css-in-js',
target: filePath,
}, () => extractCSSinJS(content, ext))
: extractCSSinJS(content, ext);
}, () => extractCSSinJS(source, ext))
: extractCSSinJS(source, ext);
for (const block of cssJsBlocks) {
const blockLines = block.content.split('\n');
const blockContent = stripCssComments(block.content);
const blockLines = blockContent.split('\n');
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
profile,
phase: 'css-in-js',
}));
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 1));
findings.push(...pseudoStripeFindings(block.content, block.startLine - 1));
findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 1));
findings.push(...pseudoStripeFindings(blockContent, block.startLine - 1));
}
if (options?.designSystem) {
@@ -26,11 +26,26 @@ const HIDDEN_SOURCE_DIRS = new Set(['.vitepress', '.vuepress', '.storybook']);
const SCANNABLE_EXTENSIONS = new Set([
'.html', '.htm', '.css', '.scss', '.sass', '.less',
'.jsx', '.tsx', '.js', '.ts',
'.vue', '.svelte', '.astro',
'.vue', '.svelte', '.astro', '.blade.php',
]);
const HTML_EXTENSIONS = new Set(['.html', '.htm']);
function hasScannableExtension(filename) {
const lower = filename.toLowerCase();
if (SCANNABLE_EXTENSIONS.has(path.extname(lower))) return true;
for (const ext of SCANNABLE_EXTENSIONS) {
if (ext.indexOf('.', 1) !== -1 && lower.endsWith(ext)) return true;
}
return false;
}
const IMPORT_SPECIFIER_PATTERNS = [
/import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g,
/@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g,
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
];
function walkDir(dir) {
const files = [];
let entries;
@@ -40,7 +55,7 @@ function walkDir(dir) {
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full));
else if (SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(full);
else if (hasScannableExtension(entry.name)) files.push(full);
}
return files;
}
@@ -75,26 +90,11 @@ function buildImportGraph(files) {
const dir = path.dirname(file);
const imports = new Set();
// ES imports: import ... from '...' and import '...'
const esRe = /import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g;
let m;
while ((m = esRe.exec(content)) !== null) {
const resolved = resolveImport(m[1], dir, fileSet);
if (resolved) imports.add(resolved);
}
// CSS @import
const cssRe = /@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g;
while ((m = cssRe.exec(content)) !== null) {
const resolved = resolveImport(m[1], dir, fileSet);
if (resolved) imports.add(resolved);
}
// SCSS @use / @forward
const scssRe = /@(?:use|forward)\s+['"]([^'"]+)['"]/g;
while ((m = scssRe.exec(content)) !== null) {
const resolved = resolveImport(m[1], dir, fileSet);
if (resolved) imports.add(resolved);
for (const pattern of IMPORT_SPECIFIER_PATTERNS) {
for (const match of content.matchAll(pattern)) {
const resolved = resolveImport(match[1], dir, fileSet);
if (resolved) imports.add(resolved);
}
}
graph.set(file, imports);
@@ -203,6 +203,7 @@ export {
SKIP_DIRS,
SCANNABLE_EXTENSIONS,
HTML_EXTENSIONS,
hasScannableExtension,
walkDir,
resolveImport,
buildImportGraph,
@@ -10,7 +10,7 @@
* node hook-admin.mjs off # set enabled: false
* node hook-admin.mjs ignore-rule <rule-id> # append to ignoreRules
* node hook-admin.mjs ignore-rule overused-font --all-values
* node hook-admin.mjs ignore-file <glob> # append to ignoreFiles
* node hook-admin.mjs ignore-file <glob> [--shared|--local] # append to ignoreFiles
* node hook-admin.mjs ignore-value <rule> <value> # append to shared ignoreValues
* node hook-admin.mjs ignore-value <rule> <value> --local
* node hook-admin.mjs ignore-value <rule> "*" --file <glob> # rule off in <glob> only
@@ -166,7 +166,7 @@ function readRawConfigFile(filePath) {
}
}
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem', 'advisoryRules']);
function hookSection(unified) {
return unified && typeof unified === 'object' && !Array.isArray(unified) && unified.hook && typeof unified.hook === 'object' && !Array.isArray(unified.hook)
@@ -200,6 +200,15 @@ function stripDetectorKeys(raw) {
return out;
}
function pickDetectorKeys(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
const out = {};
for (const [key, value] of Object.entries(raw)) {
if (DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
}
return out;
}
// Write hook runtime config under `hook`, leaving detector filters in
// `detector` and preserving sibling keys such as updateCheck.
function writeHookConfig(cwd, hookConfig, opts = {}) {
@@ -207,10 +216,19 @@ function writeHookConfig(cwd, hookConfig, opts = {}) {
if (opts.local) ensureHookGitExcludes(cwd);
const existingRaw = readRawConfigFile(filePath).raw;
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const existingHook = stripDetectorKeys(hookSection(existing));
const existingHookSection = hookSection(existing);
const existingHook = stripDetectorKeys(existingHookSection);
const legacyDetector = pickDetectorKeys(existingHookSection);
// Merge over the existing hook object so fields the merge helpers don't manage
// (consent, quiet, auditLog) survive an Impeccable hooks edit.
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
if (Object.keys(legacyDetector).length > 0) {
const existingDetector = detectorSection(existing) || {};
next.detector = {
...existingDetector,
...mergeDetectorConfig(existingDetector, mergeDetectorConfig(legacyDetector)),
};
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
return filePath;
@@ -222,10 +240,14 @@ function writeDetectorConfig(cwd, detectorConfig, opts = {}) {
const existingRaw = readRawConfigFile(filePath).raw;
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const nextHook = stripDetectorKeys(hookSection(existing));
const existingDetector = mergeDetectorConfig(detectorSection(existing));
const existingDetectorSection = detectorSection(existing) || {};
const existingDetector = mergeDetectorConfig(existingDetectorSection);
const next = {
...existing,
detector: mergeDetectorConfig(detectorConfig, existingDetector),
detector: {
...existingDetectorSection,
...mergeDetectorConfig(detectorConfig, existingDetector),
},
};
if (Object.keys(nextHook).length > 0) next.hook = nextHook;
else delete next.hook;
@@ -259,12 +281,18 @@ function mergeDetectorConfig(existing, seed = null) {
if (seed?.designSystem && typeof seed.designSystem === 'object' && !Array.isArray(seed.designSystem)) {
out.designSystem = { ...seed.designSystem };
}
if (seed?.advisoryRules === 'include' || seed?.advisoryRules === 'exclude') {
out.advisoryRules = seed.advisoryRules;
}
if (base.designSystem && typeof base.designSystem === 'object' && !Array.isArray(base.designSystem)) {
out.designSystem = {
...(out.designSystem || {}),
enabled: base.designSystem.enabled === false ? false : true,
};
}
if (base.advisoryRules === 'include' || base.advisoryRules === 'exclude') {
out.advisoryRules = base.advisoryRules;
}
if (Array.isArray(base.ignoreRules)) {
out.ignoreRules = Array.from(new Set([...out.ignoreRules, ...base.ignoreRules.map(String)]));
}
@@ -558,12 +586,44 @@ function addIgnoreRule(cwd, args) {
return `Added "${rule}" to detector.ignoreRules. Current: ${config.ignoreRules.join(', ')}`;
}
function addIgnoreFile(cwd, glob) {
function parseIgnoreFileArgs(args) {
const positionals = [];
let shared = false;
let local = false;
for (const raw of args) {
const arg = String(raw || '');
if (arg === '--shared') {
shared = true;
} else if (arg === '--local') {
local = true;
} else if (arg === '--reason' || arg.startsWith('--reason=')) {
throw new Error('--reason is not supported for ignore-file because detector.ignoreFiles stores globs only; use ignore-value when a documented rule-specific exception fits');
} else if (arg.startsWith('--')) {
throw new Error(`Unknown ignore-file flag: ${arg}`);
} else {
positionals.push(arg);
}
}
if (shared && local) throw new Error('Pass only one scope flag: --shared or --local');
if (positionals.length > 1) throw new Error('Pass exactly one glob to ignore-file');
return {
glob: positionals[0],
local,
};
}
function addIgnoreFile(cwd, args) {
const parsed = parseIgnoreFileArgs(args);
const glob = parsed.glob;
if (!glob) throw new Error(`Pass a glob, e.g. ${IMPECCABLE_COMMAND} hooks ignore-file "src/legacy/**"`);
const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local: parsed.local }));
if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob);
writeDetectorConfig(cwd, config);
return `Added "${glob}" to detector.ignoreFiles. Current: ${config.ignoreFiles.join(', ')}`;
const target = writeDetectorConfig(cwd, config, { local: parsed.local });
const scope = parsed.local ? 'local detector.ignoreFiles' : 'shared detector.ignoreFiles';
return `Added "${glob}" to ${scope} (${path.relative(cwd, target) || target}). Current: ${config.ignoreFiles.join(', ')}`;
}
// An empty glob used to be dropped by filter(Boolean), so `--file=` reported
@@ -727,7 +787,7 @@ function main() {
case 'on': out = setEnabled(cwd, true); break;
case 'off': out = setEnabled(cwd, false); break;
case 'ignore-rule': out = addIgnoreRule(cwd, rest); break;
case 'ignore-file': out = addIgnoreFile(cwd, rest[0]); break;
case 'ignore-file': out = addIgnoreFile(cwd, rest); break;
case 'ignore-value': out = addIgnoreValue(cwd, rest); break;
case 'reset': out = reset(cwd); break;
}
@@ -23,6 +23,7 @@ import {
designSystemOptions,
filterFindings,
isNativePlatform,
isScanTargetInsideProject,
loadDetector,
matchConfiguredExtension,
matchesAnyGlob,
@@ -161,7 +162,7 @@ function replaceOnce(original, oldString, newString) {
}
function readExistingProjectFile(filePath, cwd) {
if (!isInsideProject(filePath, cwd)) return null;
if (!isScanTargetInsideProject(filePath, cwd)) return null;
if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null;
try {
const stat = fs.statSync(filePath);
@@ -232,7 +233,7 @@ function shellCopiedFileContent(command, cwd) {
const source = shellCopyPaths(command)?.source;
if (!source) return '';
const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source);
if (!isInsideProject(sourcePath, cwd)) return '';
if (!isScanTargetInsideProject(sourcePath, cwd)) return '';
if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return '';
try {
const stat = fs.statSync(sourcePath);
@@ -328,15 +329,6 @@ function relativePath(filePath, cwd) {
}
}
function isInsideProject(filePath, cwd) {
try {
const rel = path.relative(cwd, filePath);
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
} catch {
return false;
}
}
// The static HTML engine reads its input from disk, but preToolUse only has
// the proposed content. Stage it in a temp file so html-engine targets get the
// same DOM-structural rules pre-write that runHook applies post-edit.
@@ -414,7 +406,7 @@ async function main() {
};
if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started });
if (!isInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started });
if (!isScanTargetInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started });
if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
@@ -1335,6 +1335,51 @@ function isInsideProject(filePath, projectCwd) {
}
}
// Resolve a path to its canonical (symlink-free) form. When the path does
// not exist yet — the before-edit hook gates proposed Writes — canonicalize
// the nearest existing ancestor and re-append the remainder, so a new file
// under a symlinked root still compares equal to its canonical project.
// Memoized: the hook runs as a fresh process per tool event, so the cache
// amounts to once-per-event work — the scan loops re-check the same project
// root for every target file. The cap only matters to long-lived importers
// like the test runner.
const canonicalPathCache = new Map();
const CANONICAL_PATH_CACHE_MAX = 1024;
function canonicalPath(p) {
const resolved = path.resolve(p);
if (canonicalPathCache.has(resolved)) return canonicalPathCache.get(resolved);
let canonical = resolved;
let dir = resolved;
const tail = [];
while (true) {
try {
canonical = tail.length ? path.join(fs.realpathSync(dir), ...tail) : fs.realpathSync(dir);
break;
} catch { /* keep climbing */ }
const parent = path.dirname(dir);
if (parent === dir) break;
tail.unshift(path.basename(dir));
dir = parent;
}
if (canonicalPathCache.size >= CANONICAL_PATH_CACHE_MAX) canonicalPathCache.clear();
canonicalPathCache.set(resolved, canonical);
return canonical;
}
// Containment gate shared by the before-edit hook and both scan passes. A
// session routinely touches files that belong to no project or to a
// different one — harness scratchpad dirs under the system temp root,
// sibling checkouts, one-off throwaway HTML — and findings against those are
// judged with THIS project's config and DESIGN.md palette, which is never
// right. Skip them (audit reason: outside-project). Paths are canonicalized
// first so a symlinked root (macOS /tmp -> /private/tmp) doesn't split the
// comparison.
export function isScanTargetInsideProject(filePath, projectCwd) {
if (!filePath || !projectCwd) return false;
return isInsideProject(canonicalPath(filePath), canonicalPath(projectCwd));
}
export function parseStaticStyleImports(content, fromFile, projectCwd) {
if (!content || typeof content !== 'string') return [];
const dir = path.dirname(fromFile);
@@ -1693,6 +1738,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
lastSkip = 'file-missing';
continue;
}
if (!isScanTargetInsideProject(filePath, projectCwd)) {
lastSkip = 'outside-project';
continue;
}
const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes;
if (maxFileBytes > 0) {
@@ -2023,6 +2072,10 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
const relForMatch = relativize(filePath, projectCwd);
if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue;
if (!fs.existsSync(filePath)) continue;
// Caches written before this gate existed can still hold out-of-project
// paths, so the Stop pass re-checks containment rather than trusting
// the per-edit pass to have filtered them.
if (!isScanTargetInsideProject(filePath, projectCwd)) continue;
scanned += 1;
let content = '';
@@ -50,9 +50,36 @@ export function normalizeConceptForm(value) {
.trim();
}
export function validateConceptEntry(concept, { existingForms = new Map() } = {}) {
export function validateConceptEntry(concept, { existingForms = new Map(), axes = null } = {}) {
const errors = [];
const id = concept?.id || '(unknown)';
// Recorded aesthetic axis values. Optional, and absent means the value is
// inferred from the system rules instead. Some axes cannot be inferred at all:
// depth's keyword probe matched worlds that said "no cast shadow anywhere",
// and motion and colour strategy describe properties the rules never state, so
// a wave that assigns those has to record them or the assignment is lost.
// Validated against the axes definition when the caller supplies it, because a
// typo would read as "unrecorded" and silently fall back to a probe that is
// known not to work.
if (concept?.axes !== undefined && concept.axes !== null) {
if (typeof concept.axes !== 'object' || Array.isArray(concept.axes)) {
errors.push(`concept ${id} axes must be an object of axis id to value id`);
} else if (axes) {
const byId = new Map((axes.axes || []).map(axis => [axis.id, axis]));
for (const [axisId, valueId] of Object.entries(concept.axes)) {
const axis = byId.get(axisId);
if (!axis) {
errors.push(`concept ${id} names unknown axis "${axisId}"`);
} else if (!(axis.values || []).some(value => value.id === valueId)) {
errors.push(
`concept ${id} axis "${axisId}" has unknown value "${valueId}" `
+ `(expected one of ${(axis.values || []).map(v => v.id).join(', ')})`
);
}
}
}
}
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(concept?.id || '')) {
errors.push(`invalid concept id: ${String(concept?.id)}`);
}
@@ -82,6 +109,18 @@ export function validateConceptEntry(concept, { existingForms = new Map() } = {}
|| concept.tags.some(tag => typeof tag !== 'string' || !tag.trim())) {
errors.push(`concept ${id} must have exactly three structural tags`);
}
// The slop this world in particular is at risk of. Optional, because 541
// entries predate it and none of them are wrong for lacking it. A world built
// from posters is at risk of shouting and one built from instruments is at
// risk of dead greys; a global detector cannot know which, and the author can.
if (concept?.avoid !== undefined) {
if (!Array.isArray(concept.avoid)
|| concept.avoid.length < 2
|| concept.avoid.length > 3
|| concept.avoid.some(item => typeof item !== 'string' || item.trim().length < 12 || item.trim().length > 160)) {
errors.push(`concept ${id} avoid must be two or three negations of 12160 characters`);
}
}
if (!Array.isArray(concept?.system)
|| concept.system.length !== SYSTEM_PREFIXES.length
|| concept.system.some(rule => typeof rule !== 'string' || rule.trim().length < 12 || rule.trim().length > 180)) {
@@ -2,15 +2,20 @@
// the live-mode design-system panel can render. Deterministic, dependency-free.
//
// Two-layer: YAML frontmatter (machine-readable tokens) + markdown body
// (prose with six canonical H2 sections). When frontmatter is present, it's
// (prose with eight canonical H2 sections). When frontmatter is present, it's
// exposed on `model.frontmatter` alongside the prose-scraped sections;
// consumers can prefer frontmatter values and fall back to prose.
// Array order is also match precedence: matchCanonicalSection's keyword-contained
// pass returns the first entry a heading contains, so reordering this changes
// which section an ambiguous heading resolves to.
const CANONICAL_SECTIONS = [
'Overview',
'Colors',
'Typography',
'Layout',
'Elevation',
'Shapes',
'Components',
"Do's and Don'ts",
];
@@ -115,10 +120,71 @@ function stripInlineYamlComment(s) {
return s;
}
// YAML double-quoted scalars process backslash escapes. Stripping the outer
// quotes without unescaping leaves them in place, so a nested font family like
// fontFamily: "\"IBM Plex Sans\", system-ui, sans-serif"
// keeps its literal backslashes and never matches the same family in CSS.
// The full YAML 1.2 double-quote escape set (spec section 5.7).
const YAML_SIMPLE_ESCAPES = {
'0': '\0',
a: '\x07',
b: '\b',
t: '\t',
n: '\n',
v: '\v',
f: '\f',
r: '\r',
e: '\x1b',
' ': ' ',
'"': '"',
'/': '/',
'\\': '\\',
N: '\u0085',
_: '\u00a0',
L: '\u2028',
P: '\u2029',
};
const YAML_HEX_ESCAPE_LENGTHS = { x: 2, u: 4, U: 8 };
function unescapeYamlDoubleQuoted(body) {
let out = '';
for (let i = 0; i < body.length; i++) {
const ch = body[i];
if (ch !== '\\' || i === body.length - 1) {
out += ch;
continue;
}
const next = body[i + 1];
if (Object.prototype.hasOwnProperty.call(YAML_SIMPLE_ESCAPES, next)) {
out += YAML_SIMPLE_ESCAPES[next];
i++;
continue;
}
// \xNN, \uNNNN, \UNNNNNNNN. Malformed or out-of-range sequences stay
// literal rather than corrupting the rest of the scalar.
const hexLen = YAML_HEX_ESCAPE_LENGTHS[next];
if (hexLen) {
const hex = body.slice(i + 2, i + 2 + hexLen);
const codePoint = hex.length === hexLen && /^[0-9a-fA-F]+$/.test(hex) ? parseInt(hex, 16) : -1;
if (codePoint >= 0 && codePoint <= 0x10ffff) {
out += String.fromCodePoint(codePoint);
i += 1 + hexLen;
continue;
}
}
out += ch;
}
return out;
}
function parseScalar(raw) {
const s = raw.trim();
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
return s.slice(1, -1);
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
return unescapeYamlDoubleQuoted(s.slice(1, -1));
}
// Single-quoted YAML escapes only the quote itself, by doubling it.
if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) {
return s.slice(1, -1).split("''").join("'");
}
if (s === 'true') return true;
if (s === 'false') return false;
@@ -330,17 +396,16 @@ function extractOverview(section) {
if (!section) return null;
const text = section.lines.join('\n');
const northStar = text.match(/\*\*Creative North Star:\s*"([^"]+)"\*\*/);
const keyChars = [];
const keyCharMatch = text.match(/\*\*Key Characteristics:\*\*\s*\n([\s\S]+?)(?:\n##|\n###|$)/);
if (keyCharMatch) {
for (const line of keyCharMatch[1].split('\n')) {
const m = line.match(/^\s*[-*]\s+(.+)$/);
if (m) keyChars.push(stripBold(m[1].trim()));
}
}
const keyChars = keyCharMatch
? collectBullets(keyCharMatch[1].split('\n')).map((bullet) => stripBold(bullet.trim()))
: [];
const prose = keyCharMatch
? text.slice(0, keyCharMatch.index) + text.slice(keyCharMatch.index + keyCharMatch[0].length)
: text;
// Philosophy paragraphs: everything that isn't a rule header or key-char block
const paragraphs = collectParagraphs(section.lines).filter(
const paragraphs = collectParagraphs(prose.split('\n')).filter(
(p) =>
!p.startsWith('**Creative North Star') &&
!p.startsWith('**Key Characteristics')
@@ -602,11 +667,19 @@ function parseTypeBullet(bullet) {
};
}
function extractElevation(section) {
function extractGuidance(section) {
if (!section) return null;
const subs = splitSubsections(section.lines);
return {
subtitle: section.subtitle,
description: collectParagraphs(subs[0].lines).join(' ') || null,
rules: extractNamedRules(section.lines),
};
}
const description = collectParagraphs(subs[0].lines).join(' ') || null;
function extractElevation(section) {
const guidance = extractGuidance(section);
if (!guidance) return null;
const shadows = [];
const seen = new Set();
@@ -631,12 +704,7 @@ function extractElevation(section) {
for (const inline of extractInlineShadows(b)) dedupe(inline);
}
return {
subtitle: section.subtitle,
description,
shadows,
rules: extractNamedRules(section.lines),
};
return { ...guidance, shadows };
}
function extractInlineShadows(text) {
@@ -768,6 +836,15 @@ function extractDosDonts(section) {
// ---------- Coverage assessment ----------
// Sections whose model is description-plus-rules only (see extractGuidance).
const guidanceCoverage = (guidance) =>
guidance
? {
description: Boolean(guidance.description),
rules: guidance.rules.length,
}
: 'missing';
function assessCoverage(model) {
const report = {};
@@ -796,6 +873,8 @@ function assessCoverage(model) {
}
: 'missing';
report.layout = guidanceCoverage(model.layout);
report.elevation = model.elevation
? {
shadows: model.elevation.shadows.length,
@@ -804,6 +883,8 @@ function assessCoverage(model) {
}
: 'missing';
report.shapes = guidanceCoverage(model.shapes);
report.components = model.components
? {
count: model.components.components.length,
@@ -833,7 +914,9 @@ export function parseDesignMd(md) {
overview: extractOverview(sections['Overview']),
colors: extractColors(sections['Colors']),
typography: extractTypography(sections['Typography']),
layout: extractGuidance(sections['Layout']),
elevation: extractElevation(sections['Elevation']),
shapes: extractGuidance(sections['Shapes']),
components: extractComponents(sections['Components']),
dosDonts: extractDosDonts(sections["Do's and Don'ts"]),
};
@@ -0,0 +1,26 @@
import { spawn } from 'node:child_process';
export function browserOpenCommand(url, {
platform = process.platform,
comspec = process.env.ComSpec || process.env.COMSPEC || 'cmd.exe',
} = {}) {
if (platform === 'darwin') return { command: 'open', args: [url] };
if (platform === 'win32') return { command: comspec, args: ['/c', 'start', '', url] };
return { command: 'xdg-open', args: [url] };
}
export function openSystemBrowser(url, {
platform = process.platform,
comspec = process.env.ComSpec || process.env.COMSPEC || 'cmd.exe',
spawnImpl = spawn,
} = {}) {
const { command, args } = browserOpenCommand(url, { platform, comspec });
try {
const child = spawnImpl(command, args, { stdio: 'ignore', detached: true });
child.on('error', () => {});
child.unref();
return true;
} catch {
return false;
}
}
@@ -96,31 +96,38 @@ function* rank(items, input, idFor = item => item.id) {
.map(entry => entry.item);
}
// Two independent exclusions, and either one is enough to hold a world back.
// Rating grades quality: a 3-star earns a second ticket, a 1-star marginal keep
// leaves the pool. Breadth says whether a world can serve an arbitrary build at
// all, so a niche world leaves however good it is, keeping its approval for
// direct briefs. Breadth was split out of rating because the only way to hold a
// narrow world back used to be calling it marginal, which made "excellent but
// narrow" unrecordable and corrupted ratings as a calibration signal.
// Rating sets how many tickets a world holds; breadth decides whether it draws
// at all. A niche world leaves the pool however good it is, keeping its approval
// for direct briefs. Breadth was split out of rating because the only way to
// hold a narrow world back used to be calling it marginal, which made "excellent
// but narrow" unrecordable and corrupted ratings as a calibration signal.
//
// Two tickets for a 3-star, one for everything else, was too sharp. Measured
// against the catalog as it stood: 3-star worlds absorbed 57% of the graphic
// draw from 65 of 163 eligible worlds, 46% of atmosphere from 13 of 43, and
// 75% of interaction from 15 of 25. The reviewer's complaint, that the same
// worlds keep coming back, is what a rating multiplier does to a pool whose
// thinnest tier holds 25 worlds.
//
// So a 3-star no longer outdraws a 2-star, and a 1-star draws at half rather
// than not at all. A marginal keep is still worth showing sometimes: the
// judgement it records is "narrow or unexceptional", not "wrong", and excluding
// it entirely made a rating do a job breadth already does properly.
const RATING_TICKETS = { 1: 1, 2: 2, 3: 2 };
const ticketsForRating = rating => RATING_TICKETS[rating] ?? 2;
function challengerTickets(pool) {
return pool.flatMap(concept => {
const rating = concept.review?.rating;
if (rating === 1 || concept.review?.breadth === 'niche') return [];
return rating === 3
? [{ concept, ticket: 0 }, { concept, ticket: 1 }]
: [{ concept, ticket: 0 }];
if (concept.review?.breadth === 'niche') return [];
return Array.from({ length: ticketsForRating(concept.review?.rating) },
(_, ticket) => ({ concept, ticket }));
});
}
function compositionTickets(pool) {
return pool.flatMap(composition => {
const rating = composition.review?.rating;
if (rating === 1) return [];
return rating === 3
? [{ composition, ticket: 0 }, { composition, ticket: 1 }]
: [{ composition, ticket: 0 }];
});
return pool.flatMap(composition => Array.from(
{ length: ticketsForRating(composition.review?.rating) },
(_, ticket) => ({ composition, ticket })));
}
/**
@@ -117,6 +117,23 @@ export function checkDesignDrift({ designPath, projectRoot, threshold = 25 }) {
* a section can be absent because it never applied, so this is reported as a
* documentation gap for a human to judge, never as an error.
*/
function hasCoverageValue(value) {
if (Array.isArray(value)) return value.some(hasCoverageValue);
if (value && typeof value === 'object') {
return Object.values(value).some(hasCoverageValue);
}
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length > 0 && !/^(?:\[\s*\]|\{\s*\})$/.test(trimmed);
}
return false;
}
const SEED_DESIGN_MARKERS = ['/', '$'].map((prefix) =>
'<!-- SEED: established with the user before implementation; '
+ `re-run ${prefix}impeccable document once there's code to capture the actual tokens and components. -->`
);
export function checkDesignCoverage({ design, designPath, parseDesignMd }) {
if (!design || typeof parseDesignMd !== 'function') return [];
let model;
@@ -125,8 +142,12 @@ export function checkDesignCoverage({ design, designPath, parseDesignMd }) {
} catch {
return [];
}
const missing = ['colors', 'typography', 'components']
.filter((section) => !model[section]);
const isSeed = SEED_DESIGN_MARKERS.some((marker) => design.includes(marker));
const requiredSections = isSeed
? ['colors', 'typography']
: ['colors', 'typography', 'components'];
const missing = requiredSections
.filter((section) => !model[section] && !hasCoverageValue(model.frontmatter?.[section]));
if (!missing.length) return [];
return [finding({
id: 'design-md-coverage',
@@ -3766,7 +3766,10 @@
const container = copyEditContainerContext(contextElement);
if (container) for (const op of ops) op.container = container;
try {
const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash', {
// Token in the query string as well as the body: the URL token is what
// authorizes the CORS preflight when the page runs on a non-loopback
// dev host (ddev, Valet), since the preflight carries no request body.
const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash?token=' + encodeURIComponent(TOKEN), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -7150,7 +7153,10 @@
console.debug('[impeccable] Dropped optional live event:', err);
return null;
}
const doSend = () => fetch('http://localhost:' + PORT + '/events', {
// Token in the query string as well as the body: the URL token is what
// authorizes the CORS preflight when the page runs on a non-loopback
// dev host (ddev, Valet), since the preflight carries no request body.
const doSend = () => fetch('http://localhost:' + PORT + '/events?token=' + encodeURIComponent(TOKEN), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(msg),
@@ -11969,7 +11975,9 @@ void main() {
rules: [
...(md.colors?.rules || []).map((r) => ({ ...r, section: 'colors' })),
...(md.typography?.rules || []).map((r) => ({ ...r, section: 'typography' })),
...(md.layout?.rules || []).map((r) => ({ ...r, section: 'layout' })),
...(md.elevation?.rules || []).map((r) => ({ ...r, section: 'elevation' })),
...(md.shapes?.rules || []).map((r) => ({ ...r, section: 'shapes' })),
],
dos: md.dosDonts?.dos || [],
donts: md.dosDonts?.donts || [],
@@ -689,16 +689,24 @@ function isLoopbackOrigin(origin) {
function createRequestHandler({ detectScript, liveScriptParts }) {
return (req, res) => {
const url = new URL(req.url, `http://localhost:${state.port}`);
// Loopback-restricted CORS. Reflect the caller's Origin only when it is a
// loopback origin, always paired with `Vary: Origin` so an intermediary
// cache never serves a response authorized for one origin to another. A
// remote page (e.g. https://evil.example probing the port from a tab open
// on the same machine) gets no Access-Control-Allow-Origin, so its
// JS-initiated fetch cannot read any response. Requests with no Origin
// header (script tags, curl, the agent's own fetches) are not subject to
// CORS and keep working; no ACAO header is needed for them.
// Token-or-loopback CORS. Reflect the caller's Origin when it is a
// loopback origin OR the request carries the valid session token, always
// paired with `Vary: Origin` so an intermediary cache never serves a
// response authorized for one origin to another. A remote page (e.g.
// https://evil.example probing the port from a tab open on the same
// machine) has no token and gets no Access-Control-Allow-Origin, so its
// JS-initiated fetch cannot read any response. The token branch exists for
// dev servers on non-localhost loopback aliases (ddev's *.ddev.site,
// Valet's *.test, hosts-file entries): the injected classic <script src>
// delivers the token to the page regardless of origin, every overlay
// request carries it in the query string (preflights included, since
// OPTIONS hits the same URL), and a token bearer is already fully
// authorized on every route — the token is the security boundary, not the
// origin. Requests with no Origin header (script tags, curl, the agent's
// own fetches) are not subject to CORS and keep working; no ACAO header
// is needed for them.
const origin = req.headers.origin;
if (origin && isLoopbackOrigin(origin)) {
if (origin && (isLoopbackOrigin(origin) || url.searchParams.get('token') === state.token)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
}
@@ -865,7 +873,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
// { present, parsed, sidecar, hasMd, hasSidecar,
// mdNewerThanJson, parseError?, sidecarError? }
// - parsed: output of parseDesignMd (frontmatter
// + six canonical sections) when DESIGN.md exists.
// + the canonical sections) when DESIGN.md exists.
// - sidecar: .impeccable/design.json contents when present.
// Expected shape: schemaVersion 2, carrying
// extensions + components + narrative.
@@ -1,180 +0,0 @@
/**
* Framework-neutral Impeccable live chrome contract.
*
* The production browser bundle is intentionally plain DOM so Svelte, React,
* Vue, and static adapters can all mount the same chrome. This module is the
* testable contract/inventory for that bundle; live-browser.js mirrors these
* values at runtime because it is served as a standalone script.
*/
export const LIVE_CHROME_MOUNT_CONTRACT = Object.freeze([
'root',
'transport',
'state',
'actions',
]);
export const LIVE_UI_SURFACES = Object.freeze([
{
key: 'global-bottom-bar',
ids: [
'impeccable-live-global-bar',
'impeccable-live-global-bar-brand',
'impeccable-live-pick-toggle',
'impeccable-live-insert-toggle',
'impeccable-live-detect-toggle',
'impeccable-live-detect-badge',
'impeccable-live-design-toggle',
'impeccable-live-page-chat',
'impeccable-live-page-chat-input',
'impeccable-live-page-chat-voice',
],
states: ['rest', 'hover', 'focus-visible', 'pressed', 'active', 'tooltip'],
},
{
key: 'pending-copy-edit-dock',
ids: ['impeccable-live-pending-dock'],
states: ['closed', 'open', 'hover', 'pressed', 'loading', 'rollback', 'keep-fixing'],
},
{
key: 'element-selection-chrome',
ids: [
'impeccable-live-highlight',
'impeccable-live-tooltip',
'impeccable-live-bar',
'impeccable-live-selection-pill',
'impeccable-live-input',
'impeccable-live-configure-voice',
'impeccable-live-configure-bar-tooltip',
],
states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'],
},
{
key: 'action-picker',
ids: ['impeccable-live-picker'],
states: ['closed', 'open', 'option-hover', 'option-focus'],
},
{
key: 'edit-chrome',
ids: ['impeccable-live-edit-badge'],
states: ['enabled', 'disabled', 'editing', 'cancel', 'save', 'edited-content'],
},
{
key: 'generating-row',
ids: ['impeccable-live-bar', 'impeccable-live-shader'],
states: ['action-label', 'animated-dots', 'generating', 'done'],
},
{
key: 'variant-cycling-row',
ids: ['impeccable-live-bar', 'impeccable-live-params-panel'],
states: ['variant-1', 'variant-2', 'variant-3', 'left-disabled', 'right-disabled', 'dot-click', 'accept', 'discard'],
},
{
key: 'variant-params-panel',
ids: ['impeccable-live-params-panel'],
states: ['closed', 'open-above', 'open-below', 'range', 'steps', 'toggle'],
},
{
key: 'saving-confirmed-rows',
ids: ['impeccable-live-bar'],
states: ['saving', 'applying-variant', 'confirmed'],
},
{
key: 'insert-mode-chrome',
ids: [
'impeccable-live-insert-line',
'impeccable-live-insert-placeholder',
'impeccable-live-placeholder-resize',
'impeccable-live-insert-input',
'impeccable-live-insert-voice',
'impeccable-live-insert-create',
'impeccable-live-insert-create-tooltip',
],
states: ['toggle-active', 'line', 'placeholder', 'resize', 'enabled', 'disabled', 'tooltip'],
},
{
key: 'annotation-chrome',
ids: [
'impeccable-live-annot',
'impeccable-live-annot-svg',
'impeccable-live-annot-pins',
'impeccable-live-annot-clear',
],
states: ['overlay', 'drawing', 'pin', 'pin-edit', 'clear'],
},
{
key: 'design-system-panel',
ids: ['impeccable-live-design-host'],
states: ['closed', 'open', 'tabs', 'token-tiles', 'copy'],
},
{
key: 'toasts-and-errors',
ids: ['impeccable-live-toast'],
states: ['normal', 'error', 'no-variants-mounted'],
},
{
key: 'css-isolation-boundary',
ids: ['impeccable-live-root'],
states: ['shadow-root', 'style-tags', 'hostile-css'],
},
]);
export const LIVE_UI_COMPONENT_IDS = Object.freeze([
...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids)),
]);
export function resolveLiveUiRoot(env = globalThis) {
const doc = env?.document;
const explicit = env?.__IMPECCABLE_LIVE_UI_ROOT__
|| env?.window?.__IMPECCABLE_LIVE_UI_ROOT__;
if (explicit && typeof explicit.appendChild === 'function') return explicit;
return doc?.body || null;
}
export function getLiveUiElementById(id, env = globalThis) {
const doc = env?.document;
const root = resolveLiveUiRoot(env);
if (!id) return null;
if (root?.getElementById) {
const found = root.getElementById(id);
if (found) return found;
}
if (root?.querySelector) {
const found = root.querySelector('#' + escapeCssIdent(id));
if (found) return found;
}
return doc?.getElementById?.(id) || null;
}
export function appendToLiveUiRoot(el, env = globalThis) {
const root = resolveLiveUiRoot(env);
if (!root) throw new Error('Impeccable live UI root is not available');
root.appendChild(el);
return el;
}
export function appendStyleToLiveUiRoot(styleEl, env = globalThis) {
const doc = env?.document;
const root = resolveLiveUiRoot(env);
if (root && root !== doc?.body) {
root.appendChild(styleEl);
} else {
(doc?.head || doc?.body || root).appendChild(styleEl);
}
return styleEl;
}
export function activeElementDeep(doc = globalThis.document) {
let active = doc?.activeElement || null;
while (active?.shadowRoot?.activeElement) {
active = active.shadowRoot.activeElement;
}
return active;
}
function escapeCssIdent(value) {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(String(value));
}
return String(value).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
}
+7 -5
View File
@@ -21,7 +21,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
// All known harness directories
const HARNESS_DIRS = [
'.claude', '.cursor', '.gemini', '.codex', '.agents', '.github', '.grok',
'.claude', '.cursor', '.gemini', '.codex', '.agents', '.agent', '.github', '.grok',
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev', '.vibe', '.qoder',
];
@@ -93,15 +93,17 @@ function commandPrefixForSkillsDir(skillsDir) {
return CODEX_HARNESSES.has(basename(dirname(skillsDir))) ? '$' : '/';
}
function generatePinnedSkill(command, metadata, commandPrefix) {
function generatePinnedSkill(command, metadata, commandPrefix, isCodex) {
const desc = metadata[command]?.description || `Shortcut for ${commandPrefix}impeccable ${command}.`;
const hint = metadata[command]?.argumentHint || '[target]';
const providerFrontmatter = isCodex
? `metadata:\n argument-hint: "${hint}"`
: `argument-hint: "${hint}"\nuser-invocable: true`;
return `---
name: ${command}
description: "${desc}"
argument-hint: "${hint}"
user-invocable: true
${providerFrontmatter}
---
${PIN_MARKER}
@@ -128,7 +130,7 @@ function pin(command, projectRoot) {
for (const skillsDir of harnessDirs) {
const commandPrefix = commandPrefixForSkillsDir(skillsDir);
const content = generatePinnedSkill(command, metadata, commandPrefix);
const content = generatePinnedSkill(command, metadata, commandPrefix, commandPrefix === '$');
// Check if skill already exists (and isn't a pin)
const skillDir = join(skillsDir, command);
if (existsSync(skillDir)) {
@@ -79,6 +79,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { openSystemBrowser } from './lib/open-system-browser.mjs';
function arg(name, fallback = null) {
const i = process.argv.indexOf(`--${name}`);
@@ -143,7 +144,7 @@ if (hasFlag('schema')) {
title: 'Choose the visual world',
question: 'The roll assigned Fillmore Handbill. Keep it, take an alternate, or re-roll.',
options: [
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', thesis: 'The gig poster that treats every release like a one-night stand.', palette: ['#e8452c', '#f5d64c', '#1b2a52', '#f3ead8'], materials: ['letterpress', 'split-fountain ink'], viewport: 'A full-bleed dated bill with the product name in warped display type.', risk: 'Reads nostalgic when the type is set timidly.', sketch: '.impeccable/sketches/assigned.webp', hero: 'https://impeccable.style/worlds/cards/fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/fillmore-handbill.webp' },
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', thesis: 'The gig poster that treats every release like a one-night stand.', palette: ['#e8452c', '#f5d64c', '#1b2a52', '#f3ead8'], materials: ['letterpress', 'split-fountain ink'], viewport: 'A full-bleed dated bill with the product name in warped display type.', risk: 'Reads nostalgic when the type is set timidly.', sketch: '.impeccable/sketches/assigned.webp', hero: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill.webp' },
{ id: 'challenger-teletext', label: 'Teletext Service', lineage: 'broadcast teletext magazines', thesis: 'The catalog as a broadcast index: pages, not sections.', case: 'Fuses cleanly: releases map to numbered pages.', sketch: '.impeccable/sketches/challenger-teletext.webp', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
],
reroll: true,
@@ -151,7 +152,7 @@ if (hasFlag('schema')) {
canonCard: { label: 'The category standard', thesis: 'What this category ships, executed impeccably.', viewport: 'The arrangement a visitor expects, at full craft.', sketch: '.impeccable/sketches/canon.webp' },
steer: true,
}, null, 2));
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/sketch accept URLs or local paths; sketch slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the sketch, never as the promise of the build. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. Sketch aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either.');
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/sketch accept URLs or local paths; sketch slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the sketch, never as the promise of the build. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. A card with no imagery at all has no back; its full read renders on the front, so a text-only round loses nothing. Sketch aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either.');
process.exit(0);
}
@@ -321,7 +322,11 @@ function page() {
// and material tags give a text-only direction an immediate identity that
// no generation luck can distort.
const fact = (label, value, cls = '') => value ? `<p class="fact${cls ? ` ${cls}` : ''}"><span class="fact-label">${label}</span>${esc(value)}</p>` : '';
const hasBack = (option) => Boolean(option.viewport || option.case || (option.boardSrc && option.heroSrc));
const hasMedia = (option) => Boolean(option.sketchSrc || option.heroSrc || option.boardSrc);
// The back exists to keep long facts off a card whose front is an image;
// a card with no art has no flip chip to reach it, so it gets no back and
// the full read lives on the front instead.
const hasBack = (option) => hasMedia(option) && Boolean(option.viewport || option.case || (option.boardSrc && option.heroSrc));
const anatomy = (option) => {
const rows = [];
if (option.thesis) rows.push(`<p class="thesis">${esc(option.thesis)}</p>`);
@@ -336,7 +341,15 @@ function page() {
// The front carries only what the choice needs: thesis, identity, and the
// honest risk clamped to two lines. First viewport and the case read on
// the card's back; once the sketch lands, the first viewport is a picture.
rows.push(fact('Risk', option.risk, 'clamp'));
// With no art there is no back, so the full read fills the room the
// image would have taken.
if (hasMedia(option)) {
rows.push(fact('Risk', option.risk, 'clamp'));
} else {
rows.push(fact('First viewport', option.viewport));
rows.push(fact('The case', option.case));
rows.push(fact('Risk', option.risk));
}
if (!option.thesis && option.body) rows.push(`<p class="detail">${esc(option.body)}</p>`);
else if (option.body && option.thesis && !hasBack(option)) rows.push(`<p class="detail more">${esc(option.body)}</p>`);
return rows.join('\n ');
@@ -362,8 +375,11 @@ function page() {
</div>`;
}
if (option.heroSrc || option.boardSrc) {
return `<div class="media">
// Without a sketch the catalog art is the card's face; it stays a
// labeled reference so it never reads as the promise of the build.
return `<div class="media" title="Inspiration: the world this direction draws from. Your page will not look like this image.">
<img src="${esc(option.heroSrc || option.boardSrc)}" alt="">
<p class="media-label">inspiration</p>
<div class="chips">${expandChip}${details}</div>
</div>`;
}
@@ -547,6 +563,17 @@ function page() {
.pip figcaption { position: absolute; left: 0; right: 0; bottom: 0; font-family: var(--ks-mono); font-size: .5rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 3px 0 4px; background: oklch(7% 0.006 95 / 0.72); backdrop-filter: blur(3px); }
.pip:hover { left: 0; bottom: 0; width: 100%; height: 100%; border-radius: 0; z-index: 3; }
.sketch-note { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: var(--ks-mono); font-size: .66rem; letter-spacing: .22em; text-transform: uppercase; color: var(--ks-text-faint); }
/* Catalog art standing in for a sketchless card is a reference, and says so
on its face; the same pill later carries "artwork unavailable". */
.media-label { position: absolute; z-index: 2; left: 10px; bottom: 10px; margin: 0; font-family: var(--ks-mono); font-size: .5rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); padding: 3px 8px 4px; background: oklch(7% 0.006 95 / 0.72); border: 1px solid var(--ks-rule); border-radius: 4px; backdrop-filter: blur(3px); }
/* Art that never arrives collapses to the card's own palette (painted
inline from its swatches) instead of sitting as a dark void wearing a
zoom cursor; the scrim keeps the label legible over saturated fields,
passes clicks through, and the flip chips stay above it. A card with no
palette falls back to the quiet graphite field. */
.media.unavailable { background: linear-gradient(100deg, var(--ks-graphite) 40%, var(--ks-graphite-2) 50%, var(--ks-graphite) 60%); }
.media.unavailable::after { content: ""; position: absolute; inset: 0; z-index: 1; background: oklch(10% 0.008 95 / 0.45); pointer-events: none; }
.media.unavailable .chips { z-index: 2; }
/* A stand-in is honest about being one: dimmed, labeled, and replaced by
the real sketch whenever it lands. */
.media.stand-in img.sketch { filter: brightness(.72) saturate(.85); }
@@ -709,6 +736,36 @@ function page() {
tryLoad();
});
// A declared image that never loads (missing catalog asset, offline shell)
// must not sit as a dark void: the slot collapses to the card's own
// palette, labeled honestly, and the card competes on its facts. Sketch
// slots are excluded; their polling owns the wait.
const artFailed = (img) => {
const m = img.closest('.media');
if (!m || m.classList.contains('sketching') || m.classList.contains('unavailable')) return;
m.classList.add('unavailable');
const colors = [...(img.closest('.card')?.querySelectorAll('.swatches i') || [])].map(i => i.style.background).filter(Boolean);
if (colors.length) m.style.background = 'linear-gradient(135deg, ' + colors.map((c, i) => c + ' ' + Math.round(i * 100 / colors.length) + '% ' + Math.round((i + 1) * 100 / colors.length) + '%').join(', ') + ')';
m.querySelector('.media-label')?.remove();
m.querySelector('.chip.expand')?.remove();
m.removeAttribute('title');
img.remove();
const label = document.createElement('p');
label.className = 'media-label';
label.textContent = 'artwork unavailable';
m.appendChild(label);
};
document.querySelectorAll('.media:not(.sketching) > img').forEach(img => {
if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) artFailed(img);
else img.addEventListener('error', () => artFailed(img), { once: true });
});
// A broken inspiration PIP just leaves; nothing depends on it.
document.querySelectorAll('.pip img').forEach(img => {
const gone = () => img.closest('.pip')?.remove();
if (img.complete && img.naturalWidth === 0) gone();
else img.addEventListener('error', gone, { once: true });
});
// Inspiration PIP opens the full catalog card in the lightbox.
document.querySelectorAll('.pip').forEach(p => p.addEventListener('click', (e) => {
e.stopPropagation();
@@ -920,8 +977,7 @@ server.listen(portArg, '127.0.0.1', () => {
console.log('Waiting for the user to choose in the browser (Ctrl-C aborts)...');
}
if (!hasFlag('no-open')) {
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
try { spawn(opener, [url], { stdio: 'ignore', detached: true }).unref(); } catch { /* URL printed anyway */ }
openSystemBrowser(url);
}
if (timeoutSec > 0) {
setTimeout(() => {
+2 -2
View File
@@ -6,7 +6,7 @@
"hooks": [
{
"type": "command",
"command": "[ ! -f \".codex/skills/impeccable/scripts/hook.mjs\" ] || ! { node -e \"process.exit(parseInt(process.versions.node,10)>=22?0:1)\" 2>/dev/null || { D=\"$HOME/.impeccable\"; [ -f \"$D/node-unsupported\" ] || { mkdir -p \"$D\" 2>/dev/null && : > \"$D/node-unsupported\" 2>/dev/null && printf '%s' '{\"systemMessage\":\"The impeccable design hook is not running: no Node 22 or newer on PATH. Install one, or remove the impeccable hook from your harness settings.\"}'; }; exit 0; }; } || node \".codex/skills/impeccable/scripts/hook.mjs\"",
"command": "[ ! -f \".codex/skills/impeccable/scripts/hook.mjs\" ] || ! { node -e \"process.exit(Math.min(parseInt(process.versions.node,10),22)===22?0:1)\" 2>/dev/null || { D=\"$HOME/.impeccable\"; [ -f \"$D/node-unsupported\" ] || { mkdir -p \"$D\" 2>/dev/null && : > \"$D/node-unsupported\" 2>/dev/null && printf '%s' '{\"systemMessage\":\"The impeccable design hook is not running: no Node 22 or newer on PATH. Install one, or remove the impeccable hook from your harness settings.\"}'; }; exit 0; }; } || node \".codex/skills/impeccable/scripts/hook.mjs\"",
"timeout": 5,
"statusMessage": "Checking UI changes"
}
@@ -18,7 +18,7 @@
"hooks": [
{
"type": "command",
"command": "[ ! -f \".codex/skills/impeccable/scripts/hook.mjs\" ] || ! { node -e \"process.exit(parseInt(process.versions.node,10)>=22?0:1)\" 2>/dev/null || { D=\"$HOME/.impeccable\"; [ -f \"$D/node-unsupported\" ] || { mkdir -p \"$D\" 2>/dev/null && : > \"$D/node-unsupported\" 2>/dev/null && printf '%s' '{\"systemMessage\":\"The impeccable design hook is not running: no Node 22 or newer on PATH. Install one, or remove the impeccable hook from your harness settings.\"}'; }; exit 0; }; } || node \".codex/skills/impeccable/scripts/hook.mjs\"",
"command": "[ ! -f \".codex/skills/impeccable/scripts/hook.mjs\" ] || ! { node -e \"process.exit(Math.min(parseInt(process.versions.node,10),22)===22?0:1)\" 2>/dev/null || { D=\"$HOME/.impeccable\"; [ -f \"$D/node-unsupported\" ] || { mkdir -p \"$D\" 2>/dev/null && : > \"$D/node-unsupported\" 2>/dev/null && printf '%s' '{\"systemMessage\":\"The impeccable design hook is not running: no Node 22 or newer on PATH. Install one, or remove the impeccable hook from your harness settings.\"}'; }; exit 0; }; } || node \".codex/skills/impeccable/scripts/hook.mjs\"",
"timeout": 30,
"statusMessage": "Design deep pass"
}
+1 -1
View File
@@ -20,7 +20,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s);
## Checks, in order
1. **Persistence.** PRODUCT.md exists. 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 comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comps with no recorded pick mean the approval point was skipped, and that is a material finding.
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, 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, because medium is part of the promise. When no approved comp was supplied, 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 actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. 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. In every material_fixes list, 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, and 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, because medium is part of the promise. When no approved comp was supplied, 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 actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. 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. In every material_fixes list, 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 and that is 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.
+1 -1
View File
@@ -3,7 +3,7 @@
"hooks": {
"preToolUse": [
{
"command": "[ ! -f \".cursor/skills/impeccable/scripts/hook-before-edit.mjs\" ] || ! node -e \"process.exit(parseInt(process.versions.node,10)>=22?0:1)\" 2>/dev/null || node \".cursor/skills/impeccable/scripts/hook-before-edit.mjs\"",
"command": "[ ! -f \".cursor/skills/impeccable/scripts/hook-before-edit.mjs\" ] || ! node -e \"process.exit(Math.min(parseInt(process.versions.node,10),22)===22?0:1)\" 2>/dev/null || node \".cursor/skills/impeccable/scripts/hook-before-edit.mjs\"",
"timeout": 5
}
]
@@ -12,6 +12,7 @@ Each of these is a check on the built result, not an intention. Run them togethe
- **Type:** body measure 6575ch, display max 6rem, tracking floor -0.04em, balanced headings, obvious scale and weight steps. Run the real copy at every breakpoint and fix what overflows.
- **Motion:** one authored moment, not scattered effects and not one identical entrance on every section. Exponential ease-out from an already-visible default. Reach past transform and opacity: blur, backdrop-filter, clip-path, mask, and shadow belong to the palette when they stay smooth.
- **States:** hover, disabled, loading, error, empty. Plus real content, working controls, responsive composition, keyboard focus.
- **Browser surfaces:** the parts you did not draw still carry the design. Text selection, the caret, custom scrollbars, focus rings, underline offset, and the numerals in tabular data all ship with browser defaults that belong to no design system. Theme them from the palette. This is the cheapest signal that a page was built rather than assembled, and the one models skip most reliably.
- **Copy:** the product's own language. Controls name their action; errors name the problem and the recovery.
- **Coverage:** every brief requirement present and findable within seconds.
@@ -16,7 +16,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s);
## Checks, in order
1. **Persistence.** PRODUCT.md exists. 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 comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comps with no recorded pick mean the approval point was skipped, and that is a material finding.
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, 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, because medium is part of the promise. When no approved comp was supplied, 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 actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. 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. In every material_fixes list, 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, and 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, because medium is part of the promise. When no approved comp was supplied, 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 actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. 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. In every material_fixes list, 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 and that is 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.
@@ -48,7 +48,7 @@ The script assigns which structure gets built; your top-ranked structure is what
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, in 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. A standing preference gets recorded 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. You may 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, the dealt challengers as alternates carrying their QUALITY BAR cards, and re-roll, steer, plus canon enabled; 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 (run the script with `--schema` for the exact shape); the page renders identity from these fields, and a challenger's catalog image rides as labeled inspiration, never as the promise of the build. Author `canonCard` too: the category standard as one honest card with the same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .cursor/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now 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 was closed without an answer: 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 instead run the script without `--start` and let it auto-open and block. Only a session where no browser can open at all, headless, CI, an eval worker, a remote shell with no display, puts the same decision through the structured question tool instead; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry.
When image generation exists, every card also declares a `sketch` path under `.impeccable/sketches/`, the canon card included. Serve the page first, then produce the sketches; the page shimmer-waits per slot and the user may answer before they land. Render every sketch through one shared frame so the comparison stays about direction, never rendering luck: the requested surface's first viewport as a flat, matte design sketch in that card's own palette and type character, deliberately unfinished, no photorealism, no gloss, identical framing across cards; a candidate whose sketch looks more finished than the others has broken the comparison, not won it. The frame's aspect is the surface's own: a native app or mobile-first surface sketches portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen sketched landscape is a broken frame, not a neutral default. The only legible text in a sketch is the product's real name and one real headline; every other text region is greeked, indistinct lines standing where copy will go, because a sketch that renders invented specs, prices, or dates puts claims in front of the user that PRODUCT.md never made. Produce in the order the user reads: the assigned card, then the hand, then canon, each file written the moment it is done. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-sketch packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. A sketch answers which world, never which composition: the comp round still renders its full set, and the chosen card's sketch seeds at most one probe. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version.
When image generation exists, every card also declares a `sketch` path under `.impeccable/sketches/`, 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 sketches; the page shimmer-waits per slot and the user may answer before they land. Render every sketch through one shared frame so the comparison stays about direction, never rendering luck: the requested surface's first viewport as a flat, matte design sketch in that card's own palette and type character, deliberately unfinished, no photorealism, no gloss, identical framing across cards; a candidate whose sketch looks more finished than the others has broken the comparison, not won it. The frame's aspect is the surface's own: a native app or mobile-first surface sketches portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen sketched landscape is a broken frame, not a neutral default. The only legible text in a sketch is the product's real name and one real headline; every other text region is greeked, indistinct lines standing where copy will go, because a sketch that renders invented specs, prices, or dates puts claims in front of the user that PRODUCT.md never made. Produce in the order the user reads: the assigned card, then the hand, then canon, each file written the moment it is done. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-sketch packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. A sketch answers which world, never which composition: the comp round still renders its full set, and the chosen card's sketch seeds at most one probe. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version.
Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. Open the QUALITY BAR board and hero for the world you build the moment the choice lands, even if you viewed another card earlier; the ANSWER line names the chosen card's images (when the harness only reads files or runs sandboxed, download them into the workspace and open the relative path; sandboxed viewers reject absolute paths outside it). They set the craft level the build must reach, a rendered reference's finish, commitment, and art direction, never the composition; your surface serves this product.
@@ -84,9 +84,12 @@ 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. 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.
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.
- **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, 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. 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.
@@ -100,6 +103,6 @@ Preserve semantics, accessibility, performance, responsiveness, project conventi
Inspect desktop and mobile in one batched screenshot round, 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.
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. Where this harness runs no design hook, run `node .cursor/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 build that skips this ships every tell the hook exists to catch. Capture desktop and mobile screenshots to files, 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, its direction contract, existing hook findings, the QUALITY BAR card and approved comp paths, and the craft-floor reference path. The reviewer has no browser; screenshots you fail to pass are checks it cannot run. Verify its return carries the five contract sections; on an empty or thrashed return, respawn once with the same inputs before doing anything else. 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 whose tool surface has 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. When the reviewer's first material fix is a rebuild directive, fidelity failed wholesale rather than in patches, so skip the fix batch: put that verdict in front of the user with the named comp regions and let them choose between a re-derivation and shipping as it stands. Otherwise apply the material fixes in one batch, rebuild once, and recapture the same viewports. 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 is deciding, 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. Report the final verdict table to the user as it stands, open items included, under the reviewer's own disposition word: a table with open material findings is never announced as a pass, and never under a softer label than the reviewer wrote. Do not run a second detector.
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. Where this harness runs no design hook, run `node .cursor/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 build that skips this ships every tell the hook exists to catch. Capture desktop and mobile screenshots to files, 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, its direction contract, existing hook findings, the QUALITY BAR card and approved comp paths, and the craft-floor reference path. 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 its return carries the five contract sections; on an empty or thrashed return, respawn once with the same inputs before doing anything else. 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 whose tool surface has 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. When the reviewer's first material fix is a rebuild directive, fidelity failed wholesale rather than in patches, so 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 verdict, telling the user what is happening rather than asking permission to fix a failure. The user is consulted only when a second rebuild directive arrives, both verdicts on the table, or when rebuilding would discard content the user approved. Otherwise apply the material fixes in one batch, rebuild once, and recapture the same viewports. 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 is deciding, 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. Report the final verdict table to the user as it stands, open items included, under the reviewer's own disposition word: a table with open material findings is never announced as a pass, and never under a softer label than the reviewer wrote. Do not run a second detector.
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). A clean detector pass is not finished; finished is the contract kept, the comp honored, the review closed, and the system recorded.
@@ -28,11 +28,11 @@ After approval, record the choice where tools can find it: the approved comp's p
## Inventory implementation fidelity
Before building, inventory the approved 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 written 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. An element never written down is the element the build silently drops, and the direction contract's 150 words cannot carry this list, so this inventory is where it lives.
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, because everything the comp does not show gets built from this record, and 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 written 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 or a few decorative pixels is the compliance-token version of commitment. An element never written down is the element the build silently drops, and the direction contract's 150 words cannot carry this list, so this inventory is where it lives.
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; 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 it ends where drawing skill begins; an instruction-manual world does not convert its illustrations into diagrams, it makes them line-art illustrations. 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 instead of producing it 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, so "no photography on hand" forbids fake proof, not an illustrated hero.
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, and so is any texture by that name alone: woven cloth, paper grain, fabric, leather, brushed metal need no depth argument, because a CSS gradient or layered background 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 or a cotton field's weave, 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 it ends where drawing skill begins; an instruction-manual world does not convert its illustrations into diagrams, it makes them line-art illustrations. 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 instead of producing it 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, so "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, and code executed safely and professionally remains first-class there. 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.
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, and code executed safely and professionally remains first-class there. 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.
@@ -42,6 +42,8 @@ Treat the comp as a north star, not something to trace, and know what that allow
Generation context is part of the asset: a build composed by a thread that never saw the prompts places assets it does not understand. So prefer generating build-critical imagery in the build thread when the budget allows, and when a subagent produces assets instead, every asset must carry its prompt, and the builder reads those prompts before composing a single one of them. The carrier is uniform across harnesses: after generating any image with any tool, native or `generate-image.mjs` (which does it automatically), run `node .cursor/skills/impeccable/scripts/embed-prompt.mjs <image> --prompt "<the prompt used>"` so the intent lives inside the file itself and survives copies between machines and harnesses; `--read` recovers it from any impeccable-generated image.
When clean raster ingredients are required and the harness runs subagents, use 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"): give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise 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, the native tool or generate-image.mjs.
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 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 the producer, `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. Otherwise 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, the native tool or generate-image.mjs.
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.
@@ -405,13 +405,18 @@ ASSIGNED INDEX: ${buildIndex}
The assignment exists to refuse the model's ranking rut, never to outrank
the user or the brief. Never expose assignment metadata in user-facing labels.
No challengers this run: the roll service was unreachable and no local
catalog exists. A sandboxed exec tool with no network access causes exactly
this; before accepting degradation, rerun this command once through the
harness's network-enabled command tool. A sandboxed shell without network egress is the most common
cause: if this harness can rerun the command with network access granted,
do that once before proceeding. Otherwise proceed with the grounded
candidates alone; the assignment
above still applies at full strength. Tell the user plainly that this roll
catalog exists. A sandboxed shell without network egress is the most common
cause; before accepting degradation, rerun this command once through the
harness's network-enabled or escalated command tool. When that rerun needs
an approval, state exactly what the approver must know: this script's only
network contact is one GET to https://impeccable.style/api/roll whose query
carries scope, mode, an eight-hex seed key, and a re-roll counter; no
project files, prompts, code, or conversation context are transmitted, and
nothing is written. An approval request naming that URL and payload judges
the real action; a bare "run with network" invites rejection for contacting
an unspecified domain. If the rerun is still refused, proceed with the
grounded candidates alone; the assignment above still applies at full
strength. Tell the user plainly that this roll
ran degraded, with no challengers and no quality-bar boards; do not present
the outcome as a full roll. A degraded roll changes the cards, not the
channel: when a browser can open, present the direction on the decision page
@@ -22,7 +22,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { loadContext, extractPlatform } from './context.mjs';
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
import { readLatestSnapshotAcrossTargets } from './critique-storage.mjs';
/** Is there code here at all, or just context files / an empty repo? */
function hasCode(cwd) {
@@ -34,23 +34,13 @@ function hasCode(cwd) {
}
/**
* The most recent critique snapshot across all targets. Filenames are
* timestamp-prefixed (`<iso>__<slug>.md`), so a lexical sort is chronological.
* Parses the small frontmatter for score + P0/P1 counts.
* Summarize the most recent critique snapshot across all targets.
*/
function latestCritique(cwd) {
try {
const dir = getCritiqueDir(cwd);
if (!fs.existsSync(dir)) return null;
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')).sort();
if (!files.length) return null;
const newest = files[files.length - 1];
const text = fs.readFileSync(path.join(dir, newest), 'utf-8');
const front = text.split('---')[1] || '';
const get = (k) => {
const m = front.match(new RegExp(`^${k}:\\s*(.+)$`, 'm'));
return m ? m[1].trim() : null;
};
const latest = readLatestSnapshotAcrossTargets({ cwd });
if (!latest) return null;
const get = (key) => latest.meta[key] ?? null;
const num = (v) => {
const n = Number(v);
return Number.isFinite(n) ? n : null;
@@ -61,7 +51,7 @@ function latestCritique(cwd) {
p0: num(get('p0')),
p1: num(get('p1')),
timestamp: get('timestamp'),
file: path.relative(cwd, path.join(dir, newest)),
file: path.relative(cwd, latest.path),
};
} catch {
return null;
+20 -3
View File
@@ -27,6 +27,7 @@
* shape rather than the markdown block.
*/
import fs from 'node:fs';
import { spawnSync } from 'node:child_process';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -1146,6 +1147,7 @@ async function cli() {
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
parts.push(buildMissingTargetDirective());
}
appendImageToolsDirective(parts);
appendStalenessDirective(parts, ctx, cliOptions);
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
@@ -1180,6 +1182,7 @@ async function cli() {
`# NATIVE PLATFORM REFERENCE: ${reference.name.toUpperCase()} (reference/${reference.name}.md)\n\n${reference.content.trim()}`,
);
}
appendImageToolsDirective(parts);
appendStalenessDirective(parts, ctx, cliOptions);
if (!ctx.platform) {
// A `## Platform` section that names something we don't recognize (a
@@ -1275,9 +1278,10 @@ function appendImageGenDirective(parts) {
if (!process.env.OPENAI_API_KEY) return;
const scriptsPath = path.dirname(fileURLToPath(import.meta.url));
parts.push([
'IMAGE_GEN_AVAILABLE: An OpenAI key is present, so image generation works even without a harness-native image tool:',
`\`node ${scriptsPath}/generate-image.mjs --prompt "..." --out <file>\` (gpt-image-2, billed to the user's key; say so before the first render).`,
'Prefer the harness-native image tool when one exists. Visualizing a direction before building it measurably strengthens the result.',
'IMAGE_GEN_AVAILABLE: your harness-native image tool is always the first choice for generation; use it whenever one exists.',
'This environment also carries an OpenAI key as the fallback for harnesses with no native tool:',
`\`node ${scriptsPath}/generate-image.mjs --prompt "..." --out <file>\` (gpt-image-2, billed to the user's key; say so before the first render, and never reach for it when a native tool exists).`,
'Visualizing a direction before building it measurably strengthens the result.',
].join(' '));
}
@@ -1332,6 +1336,19 @@ function appendDetectorFallback(parts, ctx) {
// markdown already in memory, a bounded set of stats, or one of the small JSON
// files the boot reads regardless. The deep pass (git drift, token divergence,
// cross-workspace sweep) belongs to the doctor command, not to every session.
// One boot-time probe replaces every session re-deriving its image toolchain:
// harnesses and OSes differ (cwebp, sips on macOS, magick, ffmpeg), and the
// agent should read this line instead of running command -v per image.
function appendImageToolsDirective(parts) {
const probe = process.platform === 'win32' ? 'where' : 'which';
const found = ['cwebp', 'sips', 'magick', 'ffmpeg'].filter((tool) => {
try { return spawnSync(probe, [tool], { stdio: 'ignore' }).status === 0; } catch { return false; }
});
parts.push(found.length
? `IMAGE_TOOLS: available image converters on this machine: ${found.join(', ')}. Use the first suitable one; never probe again this session.`
: 'IMAGE_TOOLS: no image converter found (cwebp, sips, magick, ffmpeg). Ship PNG output unconverted rather than probing per image.');
}
function appendStalenessDirective(parts, ctx, options) {
const projectRoot = ctx.projectRoot || process.cwd();
if (stalenessCheckDisabled([projectRoot, ctx.repoRoot])) return;
@@ -105,28 +105,37 @@ function parseFrontmatter(text) {
}
/**
* Return all snapshot files for `slug`, sorted oldest newest.
* Return snapshot files matching `suffix`, sorted oldest newest.
*/
function listSnapshotsForSlug(slug, cwd) {
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/;
function listSnapshots(suffix, cwd) {
const dir = getCritiqueDir(cwd);
if (!fs.existsSync(dir)) return [];
const suffix = `__${slug}.md`;
return fs.readdirSync(dir)
.filter((f) => f.endsWith(suffix))
.filter((f) => SNAPSHOT_FILENAME.test(f) && f.endsWith(suffix))
.sort()
.map((f) => path.join(dir, f));
}
function readLatestSnapshotMatching(suffix, cwd) {
const filePath = listSnapshots(suffix, cwd).at(-1);
if (!filePath) return null;
const body = fs.readFileSync(filePath, 'utf-8');
return { path: filePath, body, meta: parseFrontmatter(body) };
}
/**
* 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() } = {}) {
const all = listSnapshotsForSlug(slug, cwd);
if (!all.length) return null;
const latest = all[all.length - 1];
const body = fs.readFileSync(latest, 'utf-8');
return { path: latest, body, meta: parseFrontmatter(body) };
return readLatestSnapshotMatching(`__${slug}.md`, cwd);
}
/** Return the most recent snapshot across all targets, or null. */
export function readLatestSnapshotAcrossTargets({ cwd = process.cwd() } = {}) {
return readLatestSnapshotMatching('.md', cwd);
}
/**
@@ -134,7 +143,7 @@ export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
* Critique appends a one-line trend to its output using this.
*/
export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
const all = listSnapshotsForSlug(slug, cwd);
const all = listSnapshots(`__${slug}.md`, cwd);
const slice = all.slice(-limit);
return slice.map((file) => parseFrontmatter(fs.readFileSync(file, 'utf-8')));
}
@@ -142,10 +142,73 @@ function stripInlineYamlComment(s) {
return s;
}
// YAML double-quoted scalars process backslash escapes. Stripping the outer
// quotes without unescaping leaves them in place, so a nested font family like
// fontFamily: "\"IBM Plex Sans\", system-ui, sans-serif"
// reaches allowedFonts as '\"ibm plex sans' and never matches the same family
// declared in CSS. Scanner instead of a regex: the escape set is small and the
// backslash handling stays readable.
// The full YAML 1.2 double-quote escape set (spec section 5.7).
const YAML_SIMPLE_ESCAPES = {
'0': '\0',
a: '\x07',
b: '\b',
t: '\t',
n: '\n',
v: '\v',
f: '\f',
r: '\r',
e: '\x1b',
' ': ' ',
'"': '"',
'/': '/',
'\\': '\\',
N: '\u0085',
_: '\u00a0',
L: '\u2028',
P: '\u2029',
};
const YAML_HEX_ESCAPE_LENGTHS = { x: 2, u: 4, U: 8 };
function unescapeYamlDoubleQuoted(body) {
let out = '';
for (let i = 0; i < body.length; i++) {
const ch = body[i];
if (ch !== '\\' || i === body.length - 1) {
out += ch;
continue;
}
const next = body[i + 1];
if (Object.prototype.hasOwnProperty.call(YAML_SIMPLE_ESCAPES, next)) {
out += YAML_SIMPLE_ESCAPES[next];
i++;
continue;
}
// \xNN, \uNNNN, \UNNNNNNNN. Malformed or out-of-range sequences stay
// literal rather than corrupting the rest of the scalar.
const hexLen = YAML_HEX_ESCAPE_LENGTHS[next];
if (hexLen) {
const hex = body.slice(i + 2, i + 2 + hexLen);
const codePoint = hex.length === hexLen && /^[0-9a-fA-F]+$/.test(hex) ? parseInt(hex, 16) : -1;
if (codePoint >= 0 && codePoint <= 0x10ffff) {
out += String.fromCodePoint(codePoint);
i += 1 + hexLen;
continue;
}
}
out += ch;
}
return out;
}
function parseScalar(raw) {
const s = raw.trim();
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
return s.slice(1, -1);
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
return unescapeYamlDoubleQuoted(s.slice(1, -1));
}
// Single-quoted YAML escapes only the quote itself, by doubling it.
if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) {
return s.slice(1, -1).split("''").join("'");
}
if (s === 'true') return true;
if (s === 'false') return false;
@@ -35,6 +35,7 @@ export { detectUrl, createBrowserDetector } from './engines/browser/detect-url.m
export { detectText, extractStyleBlocks, extractCSSinJS } from './engines/regex/detect-text.mjs';
export {
walkDir,
hasScannableExtension,
SCANNABLE_EXTENSIONS,
SKIP_DIRS,
buildImportGraph,
@@ -41,6 +41,221 @@ function shouldRunPageAnalyzers(content, filePath) {
return !ext || PAGE_ANALYZER_EXTS.has(ext);
}
const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
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']);
function isInsideOpeningJsxTag(source) {
const tagStart = source.lastIndexOf('<');
if (tagStart === -1 || !/^<[A-Za-z][\w.:-]*/.test(source.slice(tagStart))) return false;
let quote = '';
for (let cursor = tagStart + 1; cursor < source.length; cursor++) {
const char = source[cursor];
if (quote) {
if (char === '\\') cursor++;
else if (char === quote) quote = '';
} else if (char === "'" || char === '"') {
quote = char;
} else if (char === '>') {
return false;
}
}
return true;
}
/**
* Blank JavaScript comments without moving any following source. Regex
* findings keep their original line numbers, while prose examples inside
* comments cannot masquerade as rendered markup.
*/
function stripJsComments(content, options = {}) {
let state = 'code';
let output = '';
let lastSignificant = '';
let previousSignificant = '';
let antePreviousSignificant = '';
let currentWord = '';
let currentWordPrefix = '';
let wordSeparated = false;
let regexCharClass = false;
let jsxExpressionDepth = 0;
let lastClosedBraceKind = '';
const braceKinds = [];
const templateExpressionDepths = [];
const braceKind = (startsJsxExpression = false) => (
!startsJsxExpression && (
!lastSignificant ||
lastSignificant === ')' ||
lastSignificant === ';' ||
lastSignificant === '}' ||
(previousSignificant === '=' && lastSignificant === '>') ||
BLOCK_BRACE_PREFIX_KEYWORDS.has(currentWord)
) ? 'block' : 'expression'
);
const recordSignificant = (char) => {
if (/\s/.test(char)) {
wordSeparated = true;
return;
}
const isWordChar = /[\w$]/.test(char);
if (isWordChar && (wordSeparated || !currentWord)) {
currentWord = '';
currentWordPrefix = lastSignificant;
} else if (!isWordChar) {
currentWordPrefix = '';
}
wordSeparated = false;
antePreviousSignificant = previousSignificant;
previousSignificant = lastSignificant;
lastSignificant = char;
currentWord = isWordChar ? currentWord + char : '';
};
for (let i = 0; i < content.length; i++) {
const char = content[i];
const next = content[i + 1];
if (state === 'line-comment') {
if (char === '\n') {
output += char;
state = 'code';
} else {
output += ' ';
}
continue;
}
if (state === 'block-comment') {
if (char === '*' && next === '/') {
output += ' ';
i++;
state = 'code';
} else {
output += char === '\n' ? '\n' : ' ';
}
continue;
}
if (state === 'regex') {
output += char;
if (char === '\\' && next) {
output += next;
i++;
} else if (char === '[') {
regexCharClass = true;
} else if (char === ']') {
regexCharClass = false;
} else if (char === '/' && !regexCharClass) {
state = 'code';
recordSignificant('/');
}
continue;
}
if (state === 'template' && char === '$' && next === '{') {
output += '${';
i++;
recordSignificant('$');
recordSignificant('{');
templateExpressionDepths.push(1);
braceKinds.push('expression');
if (jsxExpressionDepth) jsxExpressionDepth++;
state = 'code';
continue;
}
if (state !== 'code') {
output += char;
if (char === '\\' && next) {
output += next;
i++;
} else if (
(state === 'single-quote' && char === "'") ||
(state === 'double-quote' && char === '"') ||
(state === 'template' && char === '`')
) {
state = 'code';
recordSignificant(char);
}
continue;
}
const jsxUrlSeparator = options.jsx && char === '/' && next === '/' &&
jsxExpressionDepth === 0 &&
(output.endsWith('http:') ||
output.endsWith('https:') ||
(/<[A-Za-z](?:[^>]*[^/])?>[^<]*$/.test(output.slice(output.lastIndexOf('\n') + 1)) &&
/^[\w.-]+\.[A-Za-z]{2,}(?=[:/?#\s<]|$)/.test(content.slice(i + 2))));
const afterPostfixUpdate = (lastSignificant === '+' || lastSignificant === '-') &&
previousSignificant === lastSignificant &&
antePreviousSignificant !== lastSignificant;
if (char === '/' && next === '/' && jsxUrlSeparator) {
output += '//';
i++;
recordSignificant('/');
recordSignificant('/');
} else if (char === '/' && next === '/') {
output += ' ';
i++;
state = 'line-comment';
} else if (char === '/' && next === '*') {
output += ' ';
i++;
state = 'block-comment';
} else if (templateExpressionDepths.length && char === '{') {
output += char;
templateExpressionDepths[templateExpressionDepths.length - 1]++;
braceKinds.push(braceKind());
if (jsxExpressionDepth) jsxExpressionDepth++;
recordSignificant(char);
} else if (templateExpressionDepths.length && char === '}') {
output += char;
const depthIndex = templateExpressionDepths.length - 1;
templateExpressionDepths[depthIndex]--;
lastClosedBraceKind = braceKinds.pop() || '';
if (jsxExpressionDepth) jsxExpressionDepth--;
recordSignificant(char);
if (templateExpressionDepths[depthIndex] === 0) {
templateExpressionDepths.pop();
state = 'template';
}
} else if (
char === '/' &&
(!lastSignificant ||
(/[=([{!?:;,&|+\-*%^~<>]/.test(lastSignificant) && !afterPostfixUpdate) ||
(lastSignificant === '}' && lastClosedBraceKind === 'block') ||
(previousSignificant === '=' && lastSignificant === '>') ||
(currentWordPrefix !== '.' && REGEX_PREFIX_KEYWORDS.has(currentWord)))
) {
output += char;
state = 'regex';
regexCharClass = false;
} else {
output += char;
const startsJsxExpression = options.jsx && char === '{' && jsxExpressionDepth === 0 &&
(/<[A-Za-z](?:[^>]*[^/])?>[^<]*$/.test(output.slice(output.lastIndexOf('\n') + 1, -1)) ||
isInsideOpeningJsxTag(output.slice(0, -1)));
if (char === '{') braceKinds.push(braceKind(startsJsxExpression));
else if (char === '}') lastClosedBraceKind = braceKinds.pop() || '';
if (char === '{' && (jsxExpressionDepth || startsJsxExpression)) jsxExpressionDepth++;
else if (char === '}' && jsxExpressionDepth) jsxExpressionDepth--;
recordSignificant(char);
if (char === "'") state = 'single-quote';
else if (char === '"') state = 'double-quote';
else if (char === '`') state = 'template';
}
}
return output;
}
function stripCssComments(content) {
return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' '));
}
function firstOverusedGoogleFont(text) {
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
}
@@ -528,18 +743,198 @@ function extractStyleBlocks(content, ext) {
const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']);
function findQuotedStringEnd(content, start, quote) {
for (let cursor = start + 1; cursor < content.length; cursor++) {
if (content[cursor] === '\\') cursor++;
else if (content[cursor] === quote) return cursor;
}
return -1;
}
function findRegexLiteralEnd(content, start) {
let inCharacterClass = false;
for (let cursor = start + 1; cursor < content.length; cursor++) {
const char = content[cursor];
if (char === '\\') {
cursor++;
} else if (char === '[') {
inCharacterClass = true;
} else if (char === ']') {
inCharacterClass = false;
} else if (char === '/' && !inCharacterClass) {
while (/[A-Za-z]/.test(content[cursor + 1] || '')) cursor++;
return cursor;
} else if (char === '\n' || char === '\r') {
return -1;
}
}
return -1;
}
function findTemplateExpressionEnd(content, start) {
let depth = 1;
let lastSignificant = '';
let previousSignificant = '';
let antePreviousSignificant = '';
let currentWord = '';
let currentWordPrefix = '';
let wordSeparated = false;
let lastClosedBraceKind = '';
const braceKinds = [];
const braceKind = () => (
lastSignificant === ')' ||
lastSignificant === ';' ||
lastSignificant === '}' ||
(previousSignificant === '=' && lastSignificant === '>') ||
BLOCK_BRACE_PREFIX_KEYWORDS.has(currentWord)
? 'block'
: 'expression'
);
const recordSignificant = (char) => {
if (/\s/.test(char)) {
wordSeparated = true;
return;
}
const isWordChar = /[\w$]/.test(char);
if (isWordChar && (wordSeparated || !currentWord)) {
currentWord = '';
currentWordPrefix = lastSignificant;
} else if (!isWordChar) {
currentWordPrefix = '';
}
wordSeparated = false;
antePreviousSignificant = previousSignificant;
previousSignificant = lastSignificant;
lastSignificant = char;
currentWord = isWordChar ? currentWord + char : '';
};
for (let cursor = start; cursor < content.length; cursor++) {
const char = content[cursor];
const next = content[cursor + 1];
const afterPostfixUpdate = (lastSignificant === '+' || lastSignificant === '-') &&
previousSignificant === lastSignificant &&
antePreviousSignificant !== lastSignificant;
if (char === "'" || char === '"') {
cursor = findQuotedStringEnd(content, cursor, char);
if (cursor === -1) return -1;
recordSignificant(')');
} else if (char === '/' && next === '/') {
const lineEnd = content.indexOf('\n', cursor + 2);
if (lineEnd === -1) return -1;
cursor = lineEnd;
} else if (char === '/' && next === '*') {
const commentEnd = content.indexOf('*/', cursor + 2);
if (commentEnd === -1) return -1;
cursor = commentEnd + 1;
} else if (
char === '/' &&
(!lastSignificant ||
(/[=([{!?:;,&|+\-*%^~<>]/.test(lastSignificant) && !afterPostfixUpdate) ||
(lastSignificant === '}' && lastClosedBraceKind === 'block') ||
(previousSignificant === '=' && lastSignificant === '>') ||
(currentWordPrefix !== '.' && REGEX_PREFIX_KEYWORDS.has(currentWord)))
) {
cursor = findRegexLiteralEnd(content, cursor);
if (cursor === -1) return -1;
recordSignificant(')');
} else if (char === '`') {
cursor = findTemplateLiteralEnd(content, cursor);
if (cursor === -1) return -1;
recordSignificant(')');
} else if (char === '{') {
depth++;
braceKinds.push(braceKind());
recordSignificant(char);
} else if (char === '}') {
depth--;
if (depth === 0) return cursor;
lastClosedBraceKind = braceKinds.pop() || '';
recordSignificant(char);
} else {
recordSignificant(char);
}
}
return -1;
}
function findTemplateLiteralEnd(content, start) {
for (let cursor = start + 1; cursor < content.length; cursor++) {
const char = content[cursor];
if (char === '\\') {
cursor++;
} else if (char === '`') {
return cursor;
} else if (char === '$' && content[cursor + 1] === '{') {
cursor = findTemplateExpressionEnd(content, cursor + 2);
if (cursor === -1) return -1;
}
}
return -1;
}
function findCSSinJSTemplates(content) {
const templates = [];
const tagRe = /\b(?:styled(?:\.\w+|\([^)]+\))|css)/g;
let match;
while ((match = tagRe.exec(content)) !== null) {
let cursor = match.index + match[0].length;
while (/\s/.test(content[cursor] || '')) cursor++;
if (content[cursor] === '<') {
let depth = 0;
while (cursor < content.length) {
const char = content[cursor];
if (char === '<') depth++;
else if (char === '>' && content[cursor - 1] !== '=') depth--;
cursor++;
if (depth === 0) break;
}
if (depth !== 0) continue;
while (/\s/.test(content[cursor] || '')) cursor++;
}
if (content[cursor] !== '`') continue;
const contentStart = cursor + 1;
cursor = findTemplateLiteralEnd(content, cursor);
if (cursor === -1) continue;
templates.push({
tagStart: match.index,
contentStart,
contentEnd: cursor,
});
tagRe.lastIndex = cursor + 1;
}
return templates;
}
function extractCSSinJS(content, ext) {
ext = ext.toLowerCase();
if (!CSS_IN_JS_EXTENSIONS.has(ext)) return [];
const blocks = [];
const re = /(?:styled(?:\.\w+|\([^)]+\))|css)\s*`([\s\S]*?)`/g;
let m;
while ((m = re.exec(content)) !== null) {
const before = content.substring(0, m.index);
return findCSSinJSTemplates(content).map((template) => {
const before = content.substring(0, template.tagStart);
const startLine = before.split('\n').length;
blocks.push({ content: m[1], startLine });
return {
content: content.slice(template.contentStart, template.contentEnd),
startLine,
};
});
}
function stripCssInJsComments(content, ext) {
if (!CSS_IN_JS_EXTENSIONS.has(ext.toLowerCase())) return content;
const templates = findCSSinJSTemplates(content);
let output = '';
let cursor = 0;
for (const template of templates) {
output += content.slice(cursor, template.contentStart);
output += stripCssComments(content.slice(template.contentStart, template.contentEnd));
cursor = template.contentEnd;
}
return blocks;
return output + content.slice(cursor);
}
function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, options = {}) {
@@ -627,8 +1022,12 @@ function runTextContentAnalyzers(content, filePath, options = {}) {
function detectText(content, filePath, options = {}) {
const profile = options?.profile;
const findings = [];
const lines = content.split('\n');
const ext = extFromFilePath(filePath);
const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, {
jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx',
}) : content;
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
@@ -661,8 +1060,8 @@ function detectText(content, filePath, options = {}) {
phase: 'source',
ruleId: 'codex-grid-background',
target: filePath,
}, () => scanCssTextForGridBackground(content).map(hit => {
const line = content.substring(0, hit.index).split('\n').length;
}, () => scanCssTextForGridBackground(source).map(hit => {
const line = source.substring(0, hit.index).split('\n').length;
return finding('codex-grid-background', filePath, hit.snippet, line);
})));
@@ -698,16 +1097,17 @@ function detectText(content, filePath, options = {}) {
phase: 'extract',
ruleId: 'css-in-js',
target: filePath,
}, () => extractCSSinJS(content, ext))
: extractCSSinJS(content, ext);
}, () => extractCSSinJS(source, ext))
: extractCSSinJS(source, ext);
for (const block of cssJsBlocks) {
const blockLines = block.content.split('\n');
const blockContent = stripCssComments(block.content);
const blockLines = blockContent.split('\n');
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
profile,
phase: 'css-in-js',
}));
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 1));
findings.push(...pseudoStripeFindings(block.content, block.startLine - 1));
findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 1));
findings.push(...pseudoStripeFindings(blockContent, block.startLine - 1));
}
if (options?.designSystem) {
@@ -26,11 +26,26 @@ const HIDDEN_SOURCE_DIRS = new Set(['.vitepress', '.vuepress', '.storybook']);
const SCANNABLE_EXTENSIONS = new Set([
'.html', '.htm', '.css', '.scss', '.sass', '.less',
'.jsx', '.tsx', '.js', '.ts',
'.vue', '.svelte', '.astro',
'.vue', '.svelte', '.astro', '.blade.php',
]);
const HTML_EXTENSIONS = new Set(['.html', '.htm']);
function hasScannableExtension(filename) {
const lower = filename.toLowerCase();
if (SCANNABLE_EXTENSIONS.has(path.extname(lower))) return true;
for (const ext of SCANNABLE_EXTENSIONS) {
if (ext.indexOf('.', 1) !== -1 && lower.endsWith(ext)) return true;
}
return false;
}
const IMPORT_SPECIFIER_PATTERNS = [
/import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g,
/@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g,
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
];
function walkDir(dir) {
const files = [];
let entries;
@@ -40,7 +55,7 @@ function walkDir(dir) {
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full));
else if (SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(full);
else if (hasScannableExtension(entry.name)) files.push(full);
}
return files;
}
@@ -75,26 +90,11 @@ function buildImportGraph(files) {
const dir = path.dirname(file);
const imports = new Set();
// ES imports: import ... from '...' and import '...'
const esRe = /import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g;
let m;
while ((m = esRe.exec(content)) !== null) {
const resolved = resolveImport(m[1], dir, fileSet);
if (resolved) imports.add(resolved);
}
// CSS @import
const cssRe = /@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g;
while ((m = cssRe.exec(content)) !== null) {
const resolved = resolveImport(m[1], dir, fileSet);
if (resolved) imports.add(resolved);
}
// SCSS @use / @forward
const scssRe = /@(?:use|forward)\s+['"]([^'"]+)['"]/g;
while ((m = scssRe.exec(content)) !== null) {
const resolved = resolveImport(m[1], dir, fileSet);
if (resolved) imports.add(resolved);
for (const pattern of IMPORT_SPECIFIER_PATTERNS) {
for (const match of content.matchAll(pattern)) {
const resolved = resolveImport(match[1], dir, fileSet);
if (resolved) imports.add(resolved);
}
}
graph.set(file, imports);
@@ -203,6 +203,7 @@ export {
SKIP_DIRS,
SCANNABLE_EXTENSIONS,
HTML_EXTENSIONS,
hasScannableExtension,
walkDir,
resolveImport,
buildImportGraph,
@@ -10,7 +10,7 @@
* node hook-admin.mjs off # set enabled: false
* node hook-admin.mjs ignore-rule <rule-id> # append to ignoreRules
* node hook-admin.mjs ignore-rule overused-font --all-values
* node hook-admin.mjs ignore-file <glob> # append to ignoreFiles
* node hook-admin.mjs ignore-file <glob> [--shared|--local] # append to ignoreFiles
* node hook-admin.mjs ignore-value <rule> <value> # append to shared ignoreValues
* node hook-admin.mjs ignore-value <rule> <value> --local
* node hook-admin.mjs ignore-value <rule> "*" --file <glob> # rule off in <glob> only
@@ -166,7 +166,7 @@ function readRawConfigFile(filePath) {
}
}
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem', 'advisoryRules']);
function hookSection(unified) {
return unified && typeof unified === 'object' && !Array.isArray(unified) && unified.hook && typeof unified.hook === 'object' && !Array.isArray(unified.hook)
@@ -200,6 +200,15 @@ function stripDetectorKeys(raw) {
return out;
}
function pickDetectorKeys(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
const out = {};
for (const [key, value] of Object.entries(raw)) {
if (DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
}
return out;
}
// Write hook runtime config under `hook`, leaving detector filters in
// `detector` and preserving sibling keys such as updateCheck.
function writeHookConfig(cwd, hookConfig, opts = {}) {
@@ -207,10 +216,19 @@ function writeHookConfig(cwd, hookConfig, opts = {}) {
if (opts.local) ensureHookGitExcludes(cwd);
const existingRaw = readRawConfigFile(filePath).raw;
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const existingHook = stripDetectorKeys(hookSection(existing));
const existingHookSection = hookSection(existing);
const existingHook = stripDetectorKeys(existingHookSection);
const legacyDetector = pickDetectorKeys(existingHookSection);
// Merge over the existing hook object so fields the merge helpers don't manage
// (consent, quiet, auditLog) survive an Impeccable hooks edit.
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
if (Object.keys(legacyDetector).length > 0) {
const existingDetector = detectorSection(existing) || {};
next.detector = {
...existingDetector,
...mergeDetectorConfig(existingDetector, mergeDetectorConfig(legacyDetector)),
};
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
return filePath;
@@ -222,10 +240,14 @@ function writeDetectorConfig(cwd, detectorConfig, opts = {}) {
const existingRaw = readRawConfigFile(filePath).raw;
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const nextHook = stripDetectorKeys(hookSection(existing));
const existingDetector = mergeDetectorConfig(detectorSection(existing));
const existingDetectorSection = detectorSection(existing) || {};
const existingDetector = mergeDetectorConfig(existingDetectorSection);
const next = {
...existing,
detector: mergeDetectorConfig(detectorConfig, existingDetector),
detector: {
...existingDetectorSection,
...mergeDetectorConfig(detectorConfig, existingDetector),
},
};
if (Object.keys(nextHook).length > 0) next.hook = nextHook;
else delete next.hook;
@@ -259,12 +281,18 @@ function mergeDetectorConfig(existing, seed = null) {
if (seed?.designSystem && typeof seed.designSystem === 'object' && !Array.isArray(seed.designSystem)) {
out.designSystem = { ...seed.designSystem };
}
if (seed?.advisoryRules === 'include' || seed?.advisoryRules === 'exclude') {
out.advisoryRules = seed.advisoryRules;
}
if (base.designSystem && typeof base.designSystem === 'object' && !Array.isArray(base.designSystem)) {
out.designSystem = {
...(out.designSystem || {}),
enabled: base.designSystem.enabled === false ? false : true,
};
}
if (base.advisoryRules === 'include' || base.advisoryRules === 'exclude') {
out.advisoryRules = base.advisoryRules;
}
if (Array.isArray(base.ignoreRules)) {
out.ignoreRules = Array.from(new Set([...out.ignoreRules, ...base.ignoreRules.map(String)]));
}
@@ -558,12 +586,44 @@ function addIgnoreRule(cwd, args) {
return `Added "${rule}" to detector.ignoreRules. Current: ${config.ignoreRules.join(', ')}`;
}
function addIgnoreFile(cwd, glob) {
function parseIgnoreFileArgs(args) {
const positionals = [];
let shared = false;
let local = false;
for (const raw of args) {
const arg = String(raw || '');
if (arg === '--shared') {
shared = true;
} else if (arg === '--local') {
local = true;
} else if (arg === '--reason' || arg.startsWith('--reason=')) {
throw new Error('--reason is not supported for ignore-file because detector.ignoreFiles stores globs only; use ignore-value when a documented rule-specific exception fits');
} else if (arg.startsWith('--')) {
throw new Error(`Unknown ignore-file flag: ${arg}`);
} else {
positionals.push(arg);
}
}
if (shared && local) throw new Error('Pass only one scope flag: --shared or --local');
if (positionals.length > 1) throw new Error('Pass exactly one glob to ignore-file');
return {
glob: positionals[0],
local,
};
}
function addIgnoreFile(cwd, args) {
const parsed = parseIgnoreFileArgs(args);
const glob = parsed.glob;
if (!glob) throw new Error(`Pass a glob, e.g. ${IMPECCABLE_COMMAND} hooks ignore-file "src/legacy/**"`);
const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local: parsed.local }));
if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob);
writeDetectorConfig(cwd, config);
return `Added "${glob}" to detector.ignoreFiles. Current: ${config.ignoreFiles.join(', ')}`;
const target = writeDetectorConfig(cwd, config, { local: parsed.local });
const scope = parsed.local ? 'local detector.ignoreFiles' : 'shared detector.ignoreFiles';
return `Added "${glob}" to ${scope} (${path.relative(cwd, target) || target}). Current: ${config.ignoreFiles.join(', ')}`;
}
// An empty glob used to be dropped by filter(Boolean), so `--file=` reported
@@ -727,7 +787,7 @@ function main() {
case 'on': out = setEnabled(cwd, true); break;
case 'off': out = setEnabled(cwd, false); break;
case 'ignore-rule': out = addIgnoreRule(cwd, rest); break;
case 'ignore-file': out = addIgnoreFile(cwd, rest[0]); break;
case 'ignore-file': out = addIgnoreFile(cwd, rest); break;
case 'ignore-value': out = addIgnoreValue(cwd, rest); break;
case 'reset': out = reset(cwd); break;
}
@@ -23,6 +23,7 @@ import {
designSystemOptions,
filterFindings,
isNativePlatform,
isScanTargetInsideProject,
loadDetector,
matchConfiguredExtension,
matchesAnyGlob,
@@ -161,7 +162,7 @@ function replaceOnce(original, oldString, newString) {
}
function readExistingProjectFile(filePath, cwd) {
if (!isInsideProject(filePath, cwd)) return null;
if (!isScanTargetInsideProject(filePath, cwd)) return null;
if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null;
try {
const stat = fs.statSync(filePath);
@@ -232,7 +233,7 @@ function shellCopiedFileContent(command, cwd) {
const source = shellCopyPaths(command)?.source;
if (!source) return '';
const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source);
if (!isInsideProject(sourcePath, cwd)) return '';
if (!isScanTargetInsideProject(sourcePath, cwd)) return '';
if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return '';
try {
const stat = fs.statSync(sourcePath);
@@ -328,15 +329,6 @@ function relativePath(filePath, cwd) {
}
}
function isInsideProject(filePath, cwd) {
try {
const rel = path.relative(cwd, filePath);
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
} catch {
return false;
}
}
// The static HTML engine reads its input from disk, but preToolUse only has
// the proposed content. Stage it in a temp file so html-engine targets get the
// same DOM-structural rules pre-write that runHook applies post-edit.
@@ -414,7 +406,7 @@ async function main() {
};
if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started });
if (!isInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started });
if (!isScanTargetInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started });
if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
@@ -1335,6 +1335,51 @@ function isInsideProject(filePath, projectCwd) {
}
}
// Resolve a path to its canonical (symlink-free) form. When the path does
// not exist yet — the before-edit hook gates proposed Writes — canonicalize
// the nearest existing ancestor and re-append the remainder, so a new file
// under a symlinked root still compares equal to its canonical project.
// Memoized: the hook runs as a fresh process per tool event, so the cache
// amounts to once-per-event work — the scan loops re-check the same project
// root for every target file. The cap only matters to long-lived importers
// like the test runner.
const canonicalPathCache = new Map();
const CANONICAL_PATH_CACHE_MAX = 1024;
function canonicalPath(p) {
const resolved = path.resolve(p);
if (canonicalPathCache.has(resolved)) return canonicalPathCache.get(resolved);
let canonical = resolved;
let dir = resolved;
const tail = [];
while (true) {
try {
canonical = tail.length ? path.join(fs.realpathSync(dir), ...tail) : fs.realpathSync(dir);
break;
} catch { /* keep climbing */ }
const parent = path.dirname(dir);
if (parent === dir) break;
tail.unshift(path.basename(dir));
dir = parent;
}
if (canonicalPathCache.size >= CANONICAL_PATH_CACHE_MAX) canonicalPathCache.clear();
canonicalPathCache.set(resolved, canonical);
return canonical;
}
// Containment gate shared by the before-edit hook and both scan passes. A
// session routinely touches files that belong to no project or to a
// different one — harness scratchpad dirs under the system temp root,
// sibling checkouts, one-off throwaway HTML — and findings against those are
// judged with THIS project's config and DESIGN.md palette, which is never
// right. Skip them (audit reason: outside-project). Paths are canonicalized
// first so a symlinked root (macOS /tmp -> /private/tmp) doesn't split the
// comparison.
export function isScanTargetInsideProject(filePath, projectCwd) {
if (!filePath || !projectCwd) return false;
return isInsideProject(canonicalPath(filePath), canonicalPath(projectCwd));
}
export function parseStaticStyleImports(content, fromFile, projectCwd) {
if (!content || typeof content !== 'string') return [];
const dir = path.dirname(fromFile);
@@ -1693,6 +1738,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
lastSkip = 'file-missing';
continue;
}
if (!isScanTargetInsideProject(filePath, projectCwd)) {
lastSkip = 'outside-project';
continue;
}
const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes;
if (maxFileBytes > 0) {
@@ -2023,6 +2072,10 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
const relForMatch = relativize(filePath, projectCwd);
if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue;
if (!fs.existsSync(filePath)) continue;
// Caches written before this gate existed can still hold out-of-project
// paths, so the Stop pass re-checks containment rather than trusting
// the per-edit pass to have filtered them.
if (!isScanTargetInsideProject(filePath, projectCwd)) continue;
scanned += 1;
let content = '';
@@ -50,9 +50,36 @@ export function normalizeConceptForm(value) {
.trim();
}
export function validateConceptEntry(concept, { existingForms = new Map() } = {}) {
export function validateConceptEntry(concept, { existingForms = new Map(), axes = null } = {}) {
const errors = [];
const id = concept?.id || '(unknown)';
// Recorded aesthetic axis values. Optional, and absent means the value is
// inferred from the system rules instead. Some axes cannot be inferred at all:
// depth's keyword probe matched worlds that said "no cast shadow anywhere",
// and motion and colour strategy describe properties the rules never state, so
// a wave that assigns those has to record them or the assignment is lost.
// Validated against the axes definition when the caller supplies it, because a
// typo would read as "unrecorded" and silently fall back to a probe that is
// known not to work.
if (concept?.axes !== undefined && concept.axes !== null) {
if (typeof concept.axes !== 'object' || Array.isArray(concept.axes)) {
errors.push(`concept ${id} axes must be an object of axis id to value id`);
} else if (axes) {
const byId = new Map((axes.axes || []).map(axis => [axis.id, axis]));
for (const [axisId, valueId] of Object.entries(concept.axes)) {
const axis = byId.get(axisId);
if (!axis) {
errors.push(`concept ${id} names unknown axis "${axisId}"`);
} else if (!(axis.values || []).some(value => value.id === valueId)) {
errors.push(
`concept ${id} axis "${axisId}" has unknown value "${valueId}" `
+ `(expected one of ${(axis.values || []).map(v => v.id).join(', ')})`
);
}
}
}
}
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(concept?.id || '')) {
errors.push(`invalid concept id: ${String(concept?.id)}`);
}
@@ -82,6 +109,18 @@ export function validateConceptEntry(concept, { existingForms = new Map() } = {}
|| concept.tags.some(tag => typeof tag !== 'string' || !tag.trim())) {
errors.push(`concept ${id} must have exactly three structural tags`);
}
// The slop this world in particular is at risk of. Optional, because 541
// entries predate it and none of them are wrong for lacking it. A world built
// from posters is at risk of shouting and one built from instruments is at
// risk of dead greys; a global detector cannot know which, and the author can.
if (concept?.avoid !== undefined) {
if (!Array.isArray(concept.avoid)
|| concept.avoid.length < 2
|| concept.avoid.length > 3
|| concept.avoid.some(item => typeof item !== 'string' || item.trim().length < 12 || item.trim().length > 160)) {
errors.push(`concept ${id} avoid must be two or three negations of 12160 characters`);
}
}
if (!Array.isArray(concept?.system)
|| concept.system.length !== SYSTEM_PREFIXES.length
|| concept.system.some(rule => typeof rule !== 'string' || rule.trim().length < 12 || rule.trim().length > 180)) {
@@ -2,15 +2,20 @@
// the live-mode design-system panel can render. Deterministic, dependency-free.
//
// Two-layer: YAML frontmatter (machine-readable tokens) + markdown body
// (prose with six canonical H2 sections). When frontmatter is present, it's
// (prose with eight canonical H2 sections). When frontmatter is present, it's
// exposed on `model.frontmatter` alongside the prose-scraped sections;
// consumers can prefer frontmatter values and fall back to prose.
// Array order is also match precedence: matchCanonicalSection's keyword-contained
// pass returns the first entry a heading contains, so reordering this changes
// which section an ambiguous heading resolves to.
const CANONICAL_SECTIONS = [
'Overview',
'Colors',
'Typography',
'Layout',
'Elevation',
'Shapes',
'Components',
"Do's and Don'ts",
];
@@ -115,10 +120,71 @@ function stripInlineYamlComment(s) {
return s;
}
// YAML double-quoted scalars process backslash escapes. Stripping the outer
// quotes without unescaping leaves them in place, so a nested font family like
// fontFamily: "\"IBM Plex Sans\", system-ui, sans-serif"
// keeps its literal backslashes and never matches the same family in CSS.
// The full YAML 1.2 double-quote escape set (spec section 5.7).
const YAML_SIMPLE_ESCAPES = {
'0': '\0',
a: '\x07',
b: '\b',
t: '\t',
n: '\n',
v: '\v',
f: '\f',
r: '\r',
e: '\x1b',
' ': ' ',
'"': '"',
'/': '/',
'\\': '\\',
N: '\u0085',
_: '\u00a0',
L: '\u2028',
P: '\u2029',
};
const YAML_HEX_ESCAPE_LENGTHS = { x: 2, u: 4, U: 8 };
function unescapeYamlDoubleQuoted(body) {
let out = '';
for (let i = 0; i < body.length; i++) {
const ch = body[i];
if (ch !== '\\' || i === body.length - 1) {
out += ch;
continue;
}
const next = body[i + 1];
if (Object.prototype.hasOwnProperty.call(YAML_SIMPLE_ESCAPES, next)) {
out += YAML_SIMPLE_ESCAPES[next];
i++;
continue;
}
// \xNN, \uNNNN, \UNNNNNNNN. Malformed or out-of-range sequences stay
// literal rather than corrupting the rest of the scalar.
const hexLen = YAML_HEX_ESCAPE_LENGTHS[next];
if (hexLen) {
const hex = body.slice(i + 2, i + 2 + hexLen);
const codePoint = hex.length === hexLen && /^[0-9a-fA-F]+$/.test(hex) ? parseInt(hex, 16) : -1;
if (codePoint >= 0 && codePoint <= 0x10ffff) {
out += String.fromCodePoint(codePoint);
i += 1 + hexLen;
continue;
}
}
out += ch;
}
return out;
}
function parseScalar(raw) {
const s = raw.trim();
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
return s.slice(1, -1);
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
return unescapeYamlDoubleQuoted(s.slice(1, -1));
}
// Single-quoted YAML escapes only the quote itself, by doubling it.
if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) {
return s.slice(1, -1).split("''").join("'");
}
if (s === 'true') return true;
if (s === 'false') return false;
@@ -330,17 +396,16 @@ function extractOverview(section) {
if (!section) return null;
const text = section.lines.join('\n');
const northStar = text.match(/\*\*Creative North Star:\s*"([^"]+)"\*\*/);
const keyChars = [];
const keyCharMatch = text.match(/\*\*Key Characteristics:\*\*\s*\n([\s\S]+?)(?:\n##|\n###|$)/);
if (keyCharMatch) {
for (const line of keyCharMatch[1].split('\n')) {
const m = line.match(/^\s*[-*]\s+(.+)$/);
if (m) keyChars.push(stripBold(m[1].trim()));
}
}
const keyChars = keyCharMatch
? collectBullets(keyCharMatch[1].split('\n')).map((bullet) => stripBold(bullet.trim()))
: [];
const prose = keyCharMatch
? text.slice(0, keyCharMatch.index) + text.slice(keyCharMatch.index + keyCharMatch[0].length)
: text;
// Philosophy paragraphs: everything that isn't a rule header or key-char block
const paragraphs = collectParagraphs(section.lines).filter(
const paragraphs = collectParagraphs(prose.split('\n')).filter(
(p) =>
!p.startsWith('**Creative North Star') &&
!p.startsWith('**Key Characteristics')
@@ -602,11 +667,19 @@ function parseTypeBullet(bullet) {
};
}
function extractElevation(section) {
function extractGuidance(section) {
if (!section) return null;
const subs = splitSubsections(section.lines);
return {
subtitle: section.subtitle,
description: collectParagraphs(subs[0].lines).join(' ') || null,
rules: extractNamedRules(section.lines),
};
}
const description = collectParagraphs(subs[0].lines).join(' ') || null;
function extractElevation(section) {
const guidance = extractGuidance(section);
if (!guidance) return null;
const shadows = [];
const seen = new Set();
@@ -631,12 +704,7 @@ function extractElevation(section) {
for (const inline of extractInlineShadows(b)) dedupe(inline);
}
return {
subtitle: section.subtitle,
description,
shadows,
rules: extractNamedRules(section.lines),
};
return { ...guidance, shadows };
}
function extractInlineShadows(text) {
@@ -768,6 +836,15 @@ function extractDosDonts(section) {
// ---------- Coverage assessment ----------
// Sections whose model is description-plus-rules only (see extractGuidance).
const guidanceCoverage = (guidance) =>
guidance
? {
description: Boolean(guidance.description),
rules: guidance.rules.length,
}
: 'missing';
function assessCoverage(model) {
const report = {};
@@ -796,6 +873,8 @@ function assessCoverage(model) {
}
: 'missing';
report.layout = guidanceCoverage(model.layout);
report.elevation = model.elevation
? {
shadows: model.elevation.shadows.length,
@@ -804,6 +883,8 @@ function assessCoverage(model) {
}
: 'missing';
report.shapes = guidanceCoverage(model.shapes);
report.components = model.components
? {
count: model.components.components.length,
@@ -833,7 +914,9 @@ export function parseDesignMd(md) {
overview: extractOverview(sections['Overview']),
colors: extractColors(sections['Colors']),
typography: extractTypography(sections['Typography']),
layout: extractGuidance(sections['Layout']),
elevation: extractElevation(sections['Elevation']),
shapes: extractGuidance(sections['Shapes']),
components: extractComponents(sections['Components']),
dosDonts: extractDosDonts(sections["Do's and Don'ts"]),
};
@@ -0,0 +1,26 @@
import { spawn } from 'node:child_process';
export function browserOpenCommand(url, {
platform = process.platform,
comspec = process.env.ComSpec || process.env.COMSPEC || 'cmd.exe',
} = {}) {
if (platform === 'darwin') return { command: 'open', args: [url] };
if (platform === 'win32') return { command: comspec, args: ['/c', 'start', '', url] };
return { command: 'xdg-open', args: [url] };
}
export function openSystemBrowser(url, {
platform = process.platform,
comspec = process.env.ComSpec || process.env.COMSPEC || 'cmd.exe',
spawnImpl = spawn,
} = {}) {
const { command, args } = browserOpenCommand(url, { platform, comspec });
try {
const child = spawnImpl(command, args, { stdio: 'ignore', detached: true });
child.on('error', () => {});
child.unref();
return true;
} catch {
return false;
}
}
@@ -96,31 +96,38 @@ function* rank(items, input, idFor = item => item.id) {
.map(entry => entry.item);
}
// Two independent exclusions, and either one is enough to hold a world back.
// Rating grades quality: a 3-star earns a second ticket, a 1-star marginal keep
// leaves the pool. Breadth says whether a world can serve an arbitrary build at
// all, so a niche world leaves however good it is, keeping its approval for
// direct briefs. Breadth was split out of rating because the only way to hold a
// narrow world back used to be calling it marginal, which made "excellent but
// narrow" unrecordable and corrupted ratings as a calibration signal.
// Rating sets how many tickets a world holds; breadth decides whether it draws
// at all. A niche world leaves the pool however good it is, keeping its approval
// for direct briefs. Breadth was split out of rating because the only way to
// hold a narrow world back used to be calling it marginal, which made "excellent
// but narrow" unrecordable and corrupted ratings as a calibration signal.
//
// Two tickets for a 3-star, one for everything else, was too sharp. Measured
// against the catalog as it stood: 3-star worlds absorbed 57% of the graphic
// draw from 65 of 163 eligible worlds, 46% of atmosphere from 13 of 43, and
// 75% of interaction from 15 of 25. The reviewer's complaint, that the same
// worlds keep coming back, is what a rating multiplier does to a pool whose
// thinnest tier holds 25 worlds.
//
// So a 3-star no longer outdraws a 2-star, and a 1-star draws at half rather
// than not at all. A marginal keep is still worth showing sometimes: the
// judgement it records is "narrow or unexceptional", not "wrong", and excluding
// it entirely made a rating do a job breadth already does properly.
const RATING_TICKETS = { 1: 1, 2: 2, 3: 2 };
const ticketsForRating = rating => RATING_TICKETS[rating] ?? 2;
function challengerTickets(pool) {
return pool.flatMap(concept => {
const rating = concept.review?.rating;
if (rating === 1 || concept.review?.breadth === 'niche') return [];
return rating === 3
? [{ concept, ticket: 0 }, { concept, ticket: 1 }]
: [{ concept, ticket: 0 }];
if (concept.review?.breadth === 'niche') return [];
return Array.from({ length: ticketsForRating(concept.review?.rating) },
(_, ticket) => ({ concept, ticket }));
});
}
function compositionTickets(pool) {
return pool.flatMap(composition => {
const rating = composition.review?.rating;
if (rating === 1) return [];
return rating === 3
? [{ composition, ticket: 0 }, { composition, ticket: 1 }]
: [{ composition, ticket: 0 }];
});
return pool.flatMap(composition => Array.from(
{ length: ticketsForRating(composition.review?.rating) },
(_, ticket) => ({ composition, ticket })));
}
/**
@@ -117,6 +117,23 @@ export function checkDesignDrift({ designPath, projectRoot, threshold = 25 }) {
* a section can be absent because it never applied, so this is reported as a
* documentation gap for a human to judge, never as an error.
*/
function hasCoverageValue(value) {
if (Array.isArray(value)) return value.some(hasCoverageValue);
if (value && typeof value === 'object') {
return Object.values(value).some(hasCoverageValue);
}
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed.length > 0 && !/^(?:\[\s*\]|\{\s*\})$/.test(trimmed);
}
return false;
}
const SEED_DESIGN_MARKERS = ['/', '$'].map((prefix) =>
'<!-- SEED: established with the user before implementation; '
+ `re-run ${prefix}impeccable document once there's code to capture the actual tokens and components. -->`
);
export function checkDesignCoverage({ design, designPath, parseDesignMd }) {
if (!design || typeof parseDesignMd !== 'function') return [];
let model;
@@ -125,8 +142,12 @@ export function checkDesignCoverage({ design, designPath, parseDesignMd }) {
} catch {
return [];
}
const missing = ['colors', 'typography', 'components']
.filter((section) => !model[section]);
const isSeed = SEED_DESIGN_MARKERS.some((marker) => design.includes(marker));
const requiredSections = isSeed
? ['colors', 'typography']
: ['colors', 'typography', 'components'];
const missing = requiredSections
.filter((section) => !model[section] && !hasCoverageValue(model.frontmatter?.[section]));
if (!missing.length) return [];
return [finding({
id: 'design-md-coverage',
@@ -3766,7 +3766,10 @@
const container = copyEditContainerContext(contextElement);
if (container) for (const op of ops) op.container = container;
try {
const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash', {
// Token in the query string as well as the body: the URL token is what
// authorizes the CORS preflight when the page runs on a non-loopback
// dev host (ddev, Valet), since the preflight carries no request body.
const res = await fetch('http://localhost:' + PORT + '/manual-edit-stash?token=' + encodeURIComponent(TOKEN), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -7150,7 +7153,10 @@
console.debug('[impeccable] Dropped optional live event:', err);
return null;
}
const doSend = () => fetch('http://localhost:' + PORT + '/events', {
// Token in the query string as well as the body: the URL token is what
// authorizes the CORS preflight when the page runs on a non-loopback
// dev host (ddev, Valet), since the preflight carries no request body.
const doSend = () => fetch('http://localhost:' + PORT + '/events?token=' + encodeURIComponent(TOKEN), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(msg),
@@ -11969,7 +11975,9 @@ void main() {
rules: [
...(md.colors?.rules || []).map((r) => ({ ...r, section: 'colors' })),
...(md.typography?.rules || []).map((r) => ({ ...r, section: 'typography' })),
...(md.layout?.rules || []).map((r) => ({ ...r, section: 'layout' })),
...(md.elevation?.rules || []).map((r) => ({ ...r, section: 'elevation' })),
...(md.shapes?.rules || []).map((r) => ({ ...r, section: 'shapes' })),
],
dos: md.dosDonts?.dos || [],
donts: md.dosDonts?.donts || [],
@@ -689,16 +689,24 @@ function isLoopbackOrigin(origin) {
function createRequestHandler({ detectScript, liveScriptParts }) {
return (req, res) => {
const url = new URL(req.url, `http://localhost:${state.port}`);
// Loopback-restricted CORS. Reflect the caller's Origin only when it is a
// loopback origin, always paired with `Vary: Origin` so an intermediary
// cache never serves a response authorized for one origin to another. A
// remote page (e.g. https://evil.example probing the port from a tab open
// on the same machine) gets no Access-Control-Allow-Origin, so its
// JS-initiated fetch cannot read any response. Requests with no Origin
// header (script tags, curl, the agent's own fetches) are not subject to
// CORS and keep working; no ACAO header is needed for them.
// Token-or-loopback CORS. Reflect the caller's Origin when it is a
// loopback origin OR the request carries the valid session token, always
// paired with `Vary: Origin` so an intermediary cache never serves a
// response authorized for one origin to another. A remote page (e.g.
// https://evil.example probing the port from a tab open on the same
// machine) has no token and gets no Access-Control-Allow-Origin, so its
// JS-initiated fetch cannot read any response. The token branch exists for
// dev servers on non-localhost loopback aliases (ddev's *.ddev.site,
// Valet's *.test, hosts-file entries): the injected classic <script src>
// delivers the token to the page regardless of origin, every overlay
// request carries it in the query string (preflights included, since
// OPTIONS hits the same URL), and a token bearer is already fully
// authorized on every route — the token is the security boundary, not the
// origin. Requests with no Origin header (script tags, curl, the agent's
// own fetches) are not subject to CORS and keep working; no ACAO header
// is needed for them.
const origin = req.headers.origin;
if (origin && isLoopbackOrigin(origin)) {
if (origin && (isLoopbackOrigin(origin) || url.searchParams.get('token') === state.token)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
}
@@ -865,7 +873,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
// { present, parsed, sidecar, hasMd, hasSidecar,
// mdNewerThanJson, parseError?, sidecarError? }
// - parsed: output of parseDesignMd (frontmatter
// + six canonical sections) when DESIGN.md exists.
// + the canonical sections) when DESIGN.md exists.
// - sidecar: .impeccable/design.json contents when present.
// Expected shape: schemaVersion 2, carrying
// extensions + components + narrative.
@@ -1,180 +0,0 @@
/**
* Framework-neutral Impeccable live chrome contract.
*
* The production browser bundle is intentionally plain DOM so Svelte, React,
* Vue, and static adapters can all mount the same chrome. This module is the
* testable contract/inventory for that bundle; live-browser.js mirrors these
* values at runtime because it is served as a standalone script.
*/
export const LIVE_CHROME_MOUNT_CONTRACT = Object.freeze([
'root',
'transport',
'state',
'actions',
]);
export const LIVE_UI_SURFACES = Object.freeze([
{
key: 'global-bottom-bar',
ids: [
'impeccable-live-global-bar',
'impeccable-live-global-bar-brand',
'impeccable-live-pick-toggle',
'impeccable-live-insert-toggle',
'impeccable-live-detect-toggle',
'impeccable-live-detect-badge',
'impeccable-live-design-toggle',
'impeccable-live-page-chat',
'impeccable-live-page-chat-input',
'impeccable-live-page-chat-voice',
],
states: ['rest', 'hover', 'focus-visible', 'pressed', 'active', 'tooltip'],
},
{
key: 'pending-copy-edit-dock',
ids: ['impeccable-live-pending-dock'],
states: ['closed', 'open', 'hover', 'pressed', 'loading', 'rollback', 'keep-fixing'],
},
{
key: 'element-selection-chrome',
ids: [
'impeccable-live-highlight',
'impeccable-live-tooltip',
'impeccable-live-bar',
'impeccable-live-selection-pill',
'impeccable-live-input',
'impeccable-live-configure-voice',
'impeccable-live-configure-bar-tooltip',
],
states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'],
},
{
key: 'action-picker',
ids: ['impeccable-live-picker'],
states: ['closed', 'open', 'option-hover', 'option-focus'],
},
{
key: 'edit-chrome',
ids: ['impeccable-live-edit-badge'],
states: ['enabled', 'disabled', 'editing', 'cancel', 'save', 'edited-content'],
},
{
key: 'generating-row',
ids: ['impeccable-live-bar', 'impeccable-live-shader'],
states: ['action-label', 'animated-dots', 'generating', 'done'],
},
{
key: 'variant-cycling-row',
ids: ['impeccable-live-bar', 'impeccable-live-params-panel'],
states: ['variant-1', 'variant-2', 'variant-3', 'left-disabled', 'right-disabled', 'dot-click', 'accept', 'discard'],
},
{
key: 'variant-params-panel',
ids: ['impeccable-live-params-panel'],
states: ['closed', 'open-above', 'open-below', 'range', 'steps', 'toggle'],
},
{
key: 'saving-confirmed-rows',
ids: ['impeccable-live-bar'],
states: ['saving', 'applying-variant', 'confirmed'],
},
{
key: 'insert-mode-chrome',
ids: [
'impeccable-live-insert-line',
'impeccable-live-insert-placeholder',
'impeccable-live-placeholder-resize',
'impeccable-live-insert-input',
'impeccable-live-insert-voice',
'impeccable-live-insert-create',
'impeccable-live-insert-create-tooltip',
],
states: ['toggle-active', 'line', 'placeholder', 'resize', 'enabled', 'disabled', 'tooltip'],
},
{
key: 'annotation-chrome',
ids: [
'impeccable-live-annot',
'impeccable-live-annot-svg',
'impeccable-live-annot-pins',
'impeccable-live-annot-clear',
],
states: ['overlay', 'drawing', 'pin', 'pin-edit', 'clear'],
},
{
key: 'design-system-panel',
ids: ['impeccable-live-design-host'],
states: ['closed', 'open', 'tabs', 'token-tiles', 'copy'],
},
{
key: 'toasts-and-errors',
ids: ['impeccable-live-toast'],
states: ['normal', 'error', 'no-variants-mounted'],
},
{
key: 'css-isolation-boundary',
ids: ['impeccable-live-root'],
states: ['shadow-root', 'style-tags', 'hostile-css'],
},
]);
export const LIVE_UI_COMPONENT_IDS = Object.freeze([
...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids)),
]);
export function resolveLiveUiRoot(env = globalThis) {
const doc = env?.document;
const explicit = env?.__IMPECCABLE_LIVE_UI_ROOT__
|| env?.window?.__IMPECCABLE_LIVE_UI_ROOT__;
if (explicit && typeof explicit.appendChild === 'function') return explicit;
return doc?.body || null;
}
export function getLiveUiElementById(id, env = globalThis) {
const doc = env?.document;
const root = resolveLiveUiRoot(env);
if (!id) return null;
if (root?.getElementById) {
const found = root.getElementById(id);
if (found) return found;
}
if (root?.querySelector) {
const found = root.querySelector('#' + escapeCssIdent(id));
if (found) return found;
}
return doc?.getElementById?.(id) || null;
}
export function appendToLiveUiRoot(el, env = globalThis) {
const root = resolveLiveUiRoot(env);
if (!root) throw new Error('Impeccable live UI root is not available');
root.appendChild(el);
return el;
}
export function appendStyleToLiveUiRoot(styleEl, env = globalThis) {
const doc = env?.document;
const root = resolveLiveUiRoot(env);
if (root && root !== doc?.body) {
root.appendChild(styleEl);
} else {
(doc?.head || doc?.body || root).appendChild(styleEl);
}
return styleEl;
}
export function activeElementDeep(doc = globalThis.document) {
let active = doc?.activeElement || null;
while (active?.shadowRoot?.activeElement) {
active = active.shadowRoot.activeElement;
}
return active;
}
function escapeCssIdent(value) {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(String(value));
}
return String(value).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
}
+7 -5
View File
@@ -21,7 +21,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
// All known harness directories
const HARNESS_DIRS = [
'.claude', '.cursor', '.gemini', '.codex', '.agents', '.github', '.grok',
'.claude', '.cursor', '.gemini', '.codex', '.agents', '.agent', '.github', '.grok',
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev', '.vibe', '.qoder',
];
@@ -93,15 +93,17 @@ function commandPrefixForSkillsDir(skillsDir) {
return CODEX_HARNESSES.has(basename(dirname(skillsDir))) ? '$' : '/';
}
function generatePinnedSkill(command, metadata, commandPrefix) {
function generatePinnedSkill(command, metadata, commandPrefix, isCodex) {
const desc = metadata[command]?.description || `Shortcut for ${commandPrefix}impeccable ${command}.`;
const hint = metadata[command]?.argumentHint || '[target]';
const providerFrontmatter = isCodex
? `metadata:\n argument-hint: "${hint}"`
: `argument-hint: "${hint}"\nuser-invocable: true`;
return `---
name: ${command}
description: "${desc}"
argument-hint: "${hint}"
user-invocable: true
${providerFrontmatter}
---
${PIN_MARKER}
@@ -128,7 +130,7 @@ function pin(command, projectRoot) {
for (const skillsDir of harnessDirs) {
const commandPrefix = commandPrefixForSkillsDir(skillsDir);
const content = generatePinnedSkill(command, metadata, commandPrefix);
const content = generatePinnedSkill(command, metadata, commandPrefix, commandPrefix === '$');
// Check if skill already exists (and isn't a pin)
const skillDir = join(skillsDir, command);
if (existsSync(skillDir)) {
@@ -79,6 +79,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { openSystemBrowser } from './lib/open-system-browser.mjs';
function arg(name, fallback = null) {
const i = process.argv.indexOf(`--${name}`);
@@ -143,7 +144,7 @@ if (hasFlag('schema')) {
title: 'Choose the visual world',
question: 'The roll assigned Fillmore Handbill. Keep it, take an alternate, or re-roll.',
options: [
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', thesis: 'The gig poster that treats every release like a one-night stand.', palette: ['#e8452c', '#f5d64c', '#1b2a52', '#f3ead8'], materials: ['letterpress', 'split-fountain ink'], viewport: 'A full-bleed dated bill with the product name in warped display type.', risk: 'Reads nostalgic when the type is set timidly.', sketch: '.impeccable/sketches/assigned.webp', hero: 'https://impeccable.style/worlds/cards/fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/fillmore-handbill.webp' },
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', thesis: 'The gig poster that treats every release like a one-night stand.', palette: ['#e8452c', '#f5d64c', '#1b2a52', '#f3ead8'], materials: ['letterpress', 'split-fountain ink'], viewport: 'A full-bleed dated bill with the product name in warped display type.', risk: 'Reads nostalgic when the type is set timidly.', sketch: '.impeccable/sketches/assigned.webp', hero: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill.webp' },
{ id: 'challenger-teletext', label: 'Teletext Service', lineage: 'broadcast teletext magazines', thesis: 'The catalog as a broadcast index: pages, not sections.', case: 'Fuses cleanly: releases map to numbered pages.', sketch: '.impeccable/sketches/challenger-teletext.webp', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
],
reroll: true,
@@ -151,7 +152,7 @@ if (hasFlag('schema')) {
canonCard: { label: 'The category standard', thesis: 'What this category ships, executed impeccably.', viewport: 'The arrangement a visitor expects, at full craft.', sketch: '.impeccable/sketches/canon.webp' },
steer: true,
}, null, 2));
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/sketch accept URLs or local paths; sketch slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the sketch, never as the promise of the build. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. Sketch aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either.');
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/sketch accept URLs or local paths; sketch slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the sketch, never as the promise of the build. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. A card with no imagery at all has no back; its full read renders on the front, so a text-only round loses nothing. Sketch aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either.');
process.exit(0);
}
@@ -321,7 +322,11 @@ function page() {
// and material tags give a text-only direction an immediate identity that
// no generation luck can distort.
const fact = (label, value, cls = '') => value ? `<p class="fact${cls ? ` ${cls}` : ''}"><span class="fact-label">${label}</span>${esc(value)}</p>` : '';
const hasBack = (option) => Boolean(option.viewport || option.case || (option.boardSrc && option.heroSrc));
const hasMedia = (option) => Boolean(option.sketchSrc || option.heroSrc || option.boardSrc);
// The back exists to keep long facts off a card whose front is an image;
// a card with no art has no flip chip to reach it, so it gets no back and
// the full read lives on the front instead.
const hasBack = (option) => hasMedia(option) && Boolean(option.viewport || option.case || (option.boardSrc && option.heroSrc));
const anatomy = (option) => {
const rows = [];
if (option.thesis) rows.push(`<p class="thesis">${esc(option.thesis)}</p>`);
@@ -336,7 +341,15 @@ function page() {
// The front carries only what the choice needs: thesis, identity, and the
// honest risk clamped to two lines. First viewport and the case read on
// the card's back; once the sketch lands, the first viewport is a picture.
rows.push(fact('Risk', option.risk, 'clamp'));
// With no art there is no back, so the full read fills the room the
// image would have taken.
if (hasMedia(option)) {
rows.push(fact('Risk', option.risk, 'clamp'));
} else {
rows.push(fact('First viewport', option.viewport));
rows.push(fact('The case', option.case));
rows.push(fact('Risk', option.risk));
}
if (!option.thesis && option.body) rows.push(`<p class="detail">${esc(option.body)}</p>`);
else if (option.body && option.thesis && !hasBack(option)) rows.push(`<p class="detail more">${esc(option.body)}</p>`);
return rows.join('\n ');
@@ -362,8 +375,11 @@ function page() {
</div>`;
}
if (option.heroSrc || option.boardSrc) {
return `<div class="media">
// Without a sketch the catalog art is the card's face; it stays a
// labeled reference so it never reads as the promise of the build.
return `<div class="media" title="Inspiration: the world this direction draws from. Your page will not look like this image.">
<img src="${esc(option.heroSrc || option.boardSrc)}" alt="">
<p class="media-label">inspiration</p>
<div class="chips">${expandChip}${details}</div>
</div>`;
}
@@ -547,6 +563,17 @@ function page() {
.pip figcaption { position: absolute; left: 0; right: 0; bottom: 0; font-family: var(--ks-mono); font-size: .5rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 3px 0 4px; background: oklch(7% 0.006 95 / 0.72); backdrop-filter: blur(3px); }
.pip:hover { left: 0; bottom: 0; width: 100%; height: 100%; border-radius: 0; z-index: 3; }
.sketch-note { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-family: var(--ks-mono); font-size: .66rem; letter-spacing: .22em; text-transform: uppercase; color: var(--ks-text-faint); }
/* Catalog art standing in for a sketchless card is a reference, and says so
on its face; the same pill later carries "artwork unavailable". */
.media-label { position: absolute; z-index: 2; left: 10px; bottom: 10px; margin: 0; font-family: var(--ks-mono); font-size: .5rem; letter-spacing: .2em; text-transform: uppercase; color: var(--ks-text); padding: 3px 8px 4px; background: oklch(7% 0.006 95 / 0.72); border: 1px solid var(--ks-rule); border-radius: 4px; backdrop-filter: blur(3px); }
/* Art that never arrives collapses to the card's own palette (painted
inline from its swatches) instead of sitting as a dark void wearing a
zoom cursor; the scrim keeps the label legible over saturated fields,
passes clicks through, and the flip chips stay above it. A card with no
palette falls back to the quiet graphite field. */
.media.unavailable { background: linear-gradient(100deg, var(--ks-graphite) 40%, var(--ks-graphite-2) 50%, var(--ks-graphite) 60%); }
.media.unavailable::after { content: ""; position: absolute; inset: 0; z-index: 1; background: oklch(10% 0.008 95 / 0.45); pointer-events: none; }
.media.unavailable .chips { z-index: 2; }
/* A stand-in is honest about being one: dimmed, labeled, and replaced by
the real sketch whenever it lands. */
.media.stand-in img.sketch { filter: brightness(.72) saturate(.85); }
@@ -709,6 +736,36 @@ function page() {
tryLoad();
});
// A declared image that never loads (missing catalog asset, offline shell)
// must not sit as a dark void: the slot collapses to the card's own
// palette, labeled honestly, and the card competes on its facts. Sketch
// slots are excluded; their polling owns the wait.
const artFailed = (img) => {
const m = img.closest('.media');
if (!m || m.classList.contains('sketching') || m.classList.contains('unavailable')) return;
m.classList.add('unavailable');
const colors = [...(img.closest('.card')?.querySelectorAll('.swatches i') || [])].map(i => i.style.background).filter(Boolean);
if (colors.length) m.style.background = 'linear-gradient(135deg, ' + colors.map((c, i) => c + ' ' + Math.round(i * 100 / colors.length) + '% ' + Math.round((i + 1) * 100 / colors.length) + '%').join(', ') + ')';
m.querySelector('.media-label')?.remove();
m.querySelector('.chip.expand')?.remove();
m.removeAttribute('title');
img.remove();
const label = document.createElement('p');
label.className = 'media-label';
label.textContent = 'artwork unavailable';
m.appendChild(label);
};
document.querySelectorAll('.media:not(.sketching) > img').forEach(img => {
if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) artFailed(img);
else img.addEventListener('error', () => artFailed(img), { once: true });
});
// A broken inspiration PIP just leaves; nothing depends on it.
document.querySelectorAll('.pip img').forEach(img => {
const gone = () => img.closest('.pip')?.remove();
if (img.complete && img.naturalWidth === 0) gone();
else img.addEventListener('error', gone, { once: true });
});
// Inspiration PIP opens the full catalog card in the lightbox.
document.querySelectorAll('.pip').forEach(p => p.addEventListener('click', (e) => {
e.stopPropagation();
@@ -920,8 +977,7 @@ server.listen(portArg, '127.0.0.1', () => {
console.log('Waiting for the user to choose in the browser (Ctrl-C aborts)...');
}
if (!hasFlag('no-open')) {
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
try { spawn(opener, [url], { stdio: 'ignore', detached: true }).unref(); } catch { /* URL printed anyway */ }
openSystemBrowser(url);
}
if (timeoutSec > 0) {
setTimeout(() => {
@@ -12,6 +12,7 @@ Each of these is a check on the built result, not an intention. Run them togethe
- **Type:** body measure 6575ch, display max 6rem, tracking floor -0.04em, balanced headings, obvious scale and weight steps. Run the real copy at every breakpoint and fix what overflows.
- **Motion:** one authored moment, not scattered effects and not one identical entrance on every section. Exponential ease-out from an already-visible default. Reach past transform and opacity: blur, backdrop-filter, clip-path, mask, and shadow belong to the palette when they stay smooth.
- **States:** hover, disabled, loading, error, empty. Plus real content, working controls, responsive composition, keyboard focus.
- **Browser surfaces:** the parts you did not draw still carry the design. Text selection, the caret, custom scrollbars, focus rings, underline offset, and the numerals in tabular data all ship with browser defaults that belong to no design system. Theme them from the palette. This is the cheapest signal that a page was built rather than assembled, and the one models skip most reliably.
- **Copy:** the product's own language. Controls name their action; errors name the problem and the recovery.
- **Coverage:** every brief requirement present and findable within seconds.
@@ -16,7 +16,7 @@ Expect: the original request; the confirmed user answers; the artifact path(s);
## Checks, in order
1. **Persistence.** PRODUCT.md exists. 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 comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comps with no recorded pick mean the approval point was skipped, and that is a material finding.
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, 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, because medium is part of the promise. When no approved comp was supplied, 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 actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. 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. In every material_fixes list, 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, and 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, because medium is part of the promise. When no approved comp was supplied, 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 actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. 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. In every material_fixes list, 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 and that is 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.
@@ -48,7 +48,7 @@ The script assigns which structure gets built; your top-ranked structure is what
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, in 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. A standing preference gets recorded 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. You may 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, the dealt challengers as alternates carrying their QUALITY BAR cards, and re-roll, steer, plus canon enabled; 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 (run the script with `--schema` for the exact shape); the page renders identity from these fields, and a challenger's catalog image rides as labeled inspiration, never as the promise of the build. Author `canonCard` too: the category standard as one honest card with the same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .gemini/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now 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 was closed without an answer: 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 instead run the script without `--start` and let it auto-open and block. Only a session where no browser can open at all, headless, CI, an eval worker, a remote shell with no display, puts the same decision through the structured question tool instead; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry.
When image generation exists, every card also declares a `sketch` path under `.impeccable/sketches/`, the canon card included. Serve the page first, then produce the sketches; the page shimmer-waits per slot and the user may answer before they land. Render every sketch through one shared frame so the comparison stays about direction, never rendering luck: the requested surface's first viewport as a flat, matte design sketch in that card's own palette and type character, deliberately unfinished, no photorealism, no gloss, identical framing across cards; a candidate whose sketch looks more finished than the others has broken the comparison, not won it. The frame's aspect is the surface's own: a native app or mobile-first surface sketches portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen sketched landscape is a broken frame, not a neutral default. The only legible text in a sketch is the product's real name and one real headline; every other text region is greeked, indistinct lines standing where copy will go, because a sketch that renders invented specs, prices, or dates puts claims in front of the user that PRODUCT.md never made. Produce in the order the user reads: the assigned card, then the hand, then canon, each file written the moment it is done. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-sketch packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. A sketch answers which world, never which composition: the comp round still renders its full set, and the chosen card's sketch seeds at most one probe. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version.
When image generation exists, every card also declares a `sketch` path under `.impeccable/sketches/`, 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 sketches; the page shimmer-waits per slot and the user may answer before they land. Render every sketch through one shared frame so the comparison stays about direction, never rendering luck: the requested surface's first viewport as a flat, matte design sketch in that card's own palette and type character, deliberately unfinished, no photorealism, no gloss, identical framing across cards; a candidate whose sketch looks more finished than the others has broken the comparison, not won it. The frame's aspect is the surface's own: a native app or mobile-first surface sketches portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen sketched landscape is a broken frame, not a neutral default. The only legible text in a sketch is the product's real name and one real headline; every other text region is greeked, indistinct lines standing where copy will go, because a sketch that renders invented specs, prices, or dates puts claims in front of the user that PRODUCT.md never made. Produce in the order the user reads: the assigned card, then the hand, then canon, each file written the moment it is done. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-sketch packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. A sketch answers which world, never which composition: the comp round still renders its full set, and the chosen card's sketch seeds at most one probe. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version.
Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. Open the QUALITY BAR board and hero for the world you build the moment the choice lands, even if you viewed another card earlier; the ANSWER line names the chosen card's images (when the harness only reads files or runs sandboxed, download them into the workspace and open the relative path; sandboxed viewers reject absolute paths outside it). They set the craft level the build must reach, a rendered reference's finish, commitment, and art direction, never the composition; your surface serves this product.
@@ -84,9 +84,12 @@ 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. 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.
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.
- **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, 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. 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.
@@ -100,6 +103,6 @@ Preserve semantics, accessibility, performance, responsiveness, project conventi
Inspect desktop and mobile in one batched screenshot round, 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.
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. Where this harness runs no design hook, run `node .gemini/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 build that skips this ships every tell the hook exists to catch. Capture desktop and mobile screenshots to files, 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, its direction contract, existing hook findings, the QUALITY BAR card and approved comp paths, and the craft-floor reference path. The reviewer has no browser; screenshots you fail to pass are checks it cannot run. Verify its return carries the five contract sections; on an empty or thrashed return, respawn once with the same inputs before doing anything else. 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 whose tool surface has 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. When the reviewer's first material fix is a rebuild directive, fidelity failed wholesale rather than in patches, so skip the fix batch: put that verdict in front of the user with the named comp regions and let them choose between a re-derivation and shipping as it stands. Otherwise apply the material fixes in one batch, rebuild once, and recapture the same viewports. 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 is deciding, 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. Report the final verdict table to the user as it stands, open items included, under the reviewer's own disposition word: a table with open material findings is never announced as a pass, and never under a softer label than the reviewer wrote. Do not run a second detector.
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. Where this harness runs no design hook, run `node .gemini/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 build that skips this ships every tell the hook exists to catch. Capture desktop and mobile screenshots to files, 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, its direction contract, existing hook findings, the QUALITY BAR card and approved comp paths, and the craft-floor reference path. 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 its return carries the five contract sections; on an empty or thrashed return, respawn once with the same inputs before doing anything else. 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 whose tool surface has 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. When the reviewer's first material fix is a rebuild directive, fidelity failed wholesale rather than in patches, so 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 verdict, telling the user what is happening rather than asking permission to fix a failure. The user is consulted only when a second rebuild directive arrives, both verdicts on the table, or when rebuilding would discard content the user approved. Otherwise apply the material fixes in one batch, rebuild once, and recapture the same viewports. 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 is deciding, 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. Report the final verdict table to the user as it stands, open items included, under the reviewer's own disposition word: a table with open material findings is never announced as a pass, and never under a softer label than the reviewer wrote. Do not run a second detector.
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). A clean detector pass is not finished; finished is the contract kept, the comp honored, the review closed, and the system recorded.
@@ -28,11 +28,11 @@ After approval, record the choice where tools can find it: the approved comp's p
## Inventory implementation fidelity
Before building, inventory the approved 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 written 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. An element never written down is the element the build silently drops, and the direction contract's 150 words cannot carry this list, so this inventory is where it lives.
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, because everything the comp does not show gets built from this record, and 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 written 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 or a few decorative pixels is the compliance-token version of commitment. An element never written down is the element the build silently drops, and the direction contract's 150 words cannot carry this list, so this inventory is where it lives.
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; 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 it ends where drawing skill begins; an instruction-manual world does not convert its illustrations into diagrams, it makes them line-art illustrations. 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 instead of producing it 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, so "no photography on hand" forbids fake proof, not an illustrated hero.
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, and so is any texture by that name alone: woven cloth, paper grain, fabric, leather, brushed metal need no depth argument, because a CSS gradient or layered background 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 or a cotton field's weave, 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 it ends where drawing skill begins; an instruction-manual world does not convert its illustrations into diagrams, it makes them line-art illustrations. 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 instead of producing it 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, so "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, and code executed safely and professionally remains first-class there. 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.
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, and code executed safely and professionally remains first-class there. 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.
@@ -42,6 +42,8 @@ Treat the comp as a north star, not something to trace, and know what that allow
Generation context is part of the asset: a build composed by a thread that never saw the prompts places assets it does not understand. So prefer generating build-critical imagery in the build thread when the budget allows, and when a subagent produces assets instead, every asset must carry its prompt, and the builder reads those prompts before composing a single one of them. The carrier is uniform across harnesses: after generating any image with any tool, native or `generate-image.mjs` (which does it automatically), run `node .gemini/skills/impeccable/scripts/embed-prompt.mjs <image> --prompt "<the prompt used>"` so the intent lives inside the file itself and survives copies between machines and harnesses; `--read` recovers it from any impeccable-generated image.
When clean raster ingredients are required and the harness runs subagents, use 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"): give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise 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, the native tool or generate-image.mjs.
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 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 the producer, `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. Otherwise 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, the native tool or generate-image.mjs.
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.
@@ -405,13 +405,18 @@ ASSIGNED INDEX: ${buildIndex}
The assignment exists to refuse the model's ranking rut, never to outrank
the user or the brief. Never expose assignment metadata in user-facing labels.
No challengers this run: the roll service was unreachable and no local
catalog exists. A sandboxed exec tool with no network access causes exactly
this; before accepting degradation, rerun this command once through the
harness's network-enabled command tool. A sandboxed shell without network egress is the most common
cause: if this harness can rerun the command with network access granted,
do that once before proceeding. Otherwise proceed with the grounded
candidates alone; the assignment
above still applies at full strength. Tell the user plainly that this roll
catalog exists. A sandboxed shell without network egress is the most common
cause; before accepting degradation, rerun this command once through the
harness's network-enabled or escalated command tool. When that rerun needs
an approval, state exactly what the approver must know: this script's only
network contact is one GET to https://impeccable.style/api/roll whose query
carries scope, mode, an eight-hex seed key, and a re-roll counter; no
project files, prompts, code, or conversation context are transmitted, and
nothing is written. An approval request naming that URL and payload judges
the real action; a bare "run with network" invites rejection for contacting
an unspecified domain. If the rerun is still refused, proceed with the
grounded candidates alone; the assignment above still applies at full
strength. Tell the user plainly that this roll
ran degraded, with no challengers and no quality-bar boards; do not present
the outcome as a full roll. A degraded roll changes the cards, not the
channel: when a browser can open, present the direction on the decision page
@@ -22,7 +22,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { loadContext, extractPlatform } from './context.mjs';
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
import { readLatestSnapshotAcrossTargets } from './critique-storage.mjs';
/** Is there code here at all, or just context files / an empty repo? */
function hasCode(cwd) {
@@ -34,23 +34,13 @@ function hasCode(cwd) {
}
/**
* The most recent critique snapshot across all targets. Filenames are
* timestamp-prefixed (`<iso>__<slug>.md`), so a lexical sort is chronological.
* Parses the small frontmatter for score + P0/P1 counts.
* Summarize the most recent critique snapshot across all targets.
*/
function latestCritique(cwd) {
try {
const dir = getCritiqueDir(cwd);
if (!fs.existsSync(dir)) return null;
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')).sort();
if (!files.length) return null;
const newest = files[files.length - 1];
const text = fs.readFileSync(path.join(dir, newest), 'utf-8');
const front = text.split('---')[1] || '';
const get = (k) => {
const m = front.match(new RegExp(`^${k}:\\s*(.+)$`, 'm'));
return m ? m[1].trim() : null;
};
const latest = readLatestSnapshotAcrossTargets({ cwd });
if (!latest) return null;
const get = (key) => latest.meta[key] ?? null;
const num = (v) => {
const n = Number(v);
return Number.isFinite(n) ? n : null;
@@ -61,7 +51,7 @@ function latestCritique(cwd) {
p0: num(get('p0')),
p1: num(get('p1')),
timestamp: get('timestamp'),
file: path.relative(cwd, path.join(dir, newest)),
file: path.relative(cwd, latest.path),
};
} catch {
return null;
+20 -3
View File
@@ -27,6 +27,7 @@
* shape rather than the markdown block.
*/
import fs from 'node:fs';
import { spawnSync } from 'node:child_process';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -1146,6 +1147,7 @@ async function cli() {
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
parts.push(buildMissingTargetDirective());
}
appendImageToolsDirective(parts);
appendStalenessDirective(parts, ctx, cliOptions);
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
@@ -1180,6 +1182,7 @@ async function cli() {
`# NATIVE PLATFORM REFERENCE: ${reference.name.toUpperCase()} (reference/${reference.name}.md)\n\n${reference.content.trim()}`,
);
}
appendImageToolsDirective(parts);
appendStalenessDirective(parts, ctx, cliOptions);
if (!ctx.platform) {
// A `## Platform` section that names something we don't recognize (a
@@ -1275,9 +1278,10 @@ function appendImageGenDirective(parts) {
if (!process.env.OPENAI_API_KEY) return;
const scriptsPath = path.dirname(fileURLToPath(import.meta.url));
parts.push([
'IMAGE_GEN_AVAILABLE: An OpenAI key is present, so image generation works even without a harness-native image tool:',
`\`node ${scriptsPath}/generate-image.mjs --prompt "..." --out <file>\` (gpt-image-2, billed to the user's key; say so before the first render).`,
'Prefer the harness-native image tool when one exists. Visualizing a direction before building it measurably strengthens the result.',
'IMAGE_GEN_AVAILABLE: your harness-native image tool is always the first choice for generation; use it whenever one exists.',
'This environment also carries an OpenAI key as the fallback for harnesses with no native tool:',
`\`node ${scriptsPath}/generate-image.mjs --prompt "..." --out <file>\` (gpt-image-2, billed to the user's key; say so before the first render, and never reach for it when a native tool exists).`,
'Visualizing a direction before building it measurably strengthens the result.',
].join(' '));
}
@@ -1332,6 +1336,19 @@ function appendDetectorFallback(parts, ctx) {
// markdown already in memory, a bounded set of stats, or one of the small JSON
// files the boot reads regardless. The deep pass (git drift, token divergence,
// cross-workspace sweep) belongs to the doctor command, not to every session.
// One boot-time probe replaces every session re-deriving its image toolchain:
// harnesses and OSes differ (cwebp, sips on macOS, magick, ffmpeg), and the
// agent should read this line instead of running command -v per image.
function appendImageToolsDirective(parts) {
const probe = process.platform === 'win32' ? 'where' : 'which';
const found = ['cwebp', 'sips', 'magick', 'ffmpeg'].filter((tool) => {
try { return spawnSync(probe, [tool], { stdio: 'ignore' }).status === 0; } catch { return false; }
});
parts.push(found.length
? `IMAGE_TOOLS: available image converters on this machine: ${found.join(', ')}. Use the first suitable one; never probe again this session.`
: 'IMAGE_TOOLS: no image converter found (cwebp, sips, magick, ffmpeg). Ship PNG output unconverted rather than probing per image.');
}
function appendStalenessDirective(parts, ctx, options) {
const projectRoot = ctx.projectRoot || process.cwd();
if (stalenessCheckDisabled([projectRoot, ctx.repoRoot])) return;
@@ -105,28 +105,37 @@ function parseFrontmatter(text) {
}
/**
* Return all snapshot files for `slug`, sorted oldest newest.
* Return snapshot files matching `suffix`, sorted oldest newest.
*/
function listSnapshotsForSlug(slug, cwd) {
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/;
function listSnapshots(suffix, cwd) {
const dir = getCritiqueDir(cwd);
if (!fs.existsSync(dir)) return [];
const suffix = `__${slug}.md`;
return fs.readdirSync(dir)
.filter((f) => f.endsWith(suffix))
.filter((f) => SNAPSHOT_FILENAME.test(f) && f.endsWith(suffix))
.sort()
.map((f) => path.join(dir, f));
}
function readLatestSnapshotMatching(suffix, cwd) {
const filePath = listSnapshots(suffix, cwd).at(-1);
if (!filePath) return null;
const body = fs.readFileSync(filePath, 'utf-8');
return { path: filePath, body, meta: parseFrontmatter(body) };
}
/**
* 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() } = {}) {
const all = listSnapshotsForSlug(slug, cwd);
if (!all.length) return null;
const latest = all[all.length - 1];
const body = fs.readFileSync(latest, 'utf-8');
return { path: latest, body, meta: parseFrontmatter(body) };
return readLatestSnapshotMatching(`__${slug}.md`, cwd);
}
/** Return the most recent snapshot across all targets, or null. */
export function readLatestSnapshotAcrossTargets({ cwd = process.cwd() } = {}) {
return readLatestSnapshotMatching('.md', cwd);
}
/**
@@ -134,7 +143,7 @@ export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
* Critique appends a one-line trend to its output using this.
*/
export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
const all = listSnapshotsForSlug(slug, cwd);
const all = listSnapshots(`__${slug}.md`, cwd);
const slice = all.slice(-limit);
return slice.map((file) => parseFrontmatter(fs.readFileSync(file, 'utf-8')));
}
@@ -142,10 +142,73 @@ function stripInlineYamlComment(s) {
return s;
}
// YAML double-quoted scalars process backslash escapes. Stripping the outer
// quotes without unescaping leaves them in place, so a nested font family like
// fontFamily: "\"IBM Plex Sans\", system-ui, sans-serif"
// reaches allowedFonts as '\"ibm plex sans' and never matches the same family
// declared in CSS. Scanner instead of a regex: the escape set is small and the
// backslash handling stays readable.
// The full YAML 1.2 double-quote escape set (spec section 5.7).
const YAML_SIMPLE_ESCAPES = {
'0': '\0',
a: '\x07',
b: '\b',
t: '\t',
n: '\n',
v: '\v',
f: '\f',
r: '\r',
e: '\x1b',
' ': ' ',
'"': '"',
'/': '/',
'\\': '\\',
N: '\u0085',
_: '\u00a0',
L: '\u2028',
P: '\u2029',
};
const YAML_HEX_ESCAPE_LENGTHS = { x: 2, u: 4, U: 8 };
function unescapeYamlDoubleQuoted(body) {
let out = '';
for (let i = 0; i < body.length; i++) {
const ch = body[i];
if (ch !== '\\' || i === body.length - 1) {
out += ch;
continue;
}
const next = body[i + 1];
if (Object.prototype.hasOwnProperty.call(YAML_SIMPLE_ESCAPES, next)) {
out += YAML_SIMPLE_ESCAPES[next];
i++;
continue;
}
// \xNN, \uNNNN, \UNNNNNNNN. Malformed or out-of-range sequences stay
// literal rather than corrupting the rest of the scalar.
const hexLen = YAML_HEX_ESCAPE_LENGTHS[next];
if (hexLen) {
const hex = body.slice(i + 2, i + 2 + hexLen);
const codePoint = hex.length === hexLen && /^[0-9a-fA-F]+$/.test(hex) ? parseInt(hex, 16) : -1;
if (codePoint >= 0 && codePoint <= 0x10ffff) {
out += String.fromCodePoint(codePoint);
i += 1 + hexLen;
continue;
}
}
out += ch;
}
return out;
}
function parseScalar(raw) {
const s = raw.trim();
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
return s.slice(1, -1);
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
return unescapeYamlDoubleQuoted(s.slice(1, -1));
}
// Single-quoted YAML escapes only the quote itself, by doubling it.
if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) {
return s.slice(1, -1).split("''").join("'");
}
if (s === 'true') return true;
if (s === 'false') return false;
@@ -35,6 +35,7 @@ export { detectUrl, createBrowserDetector } from './engines/browser/detect-url.m
export { detectText, extractStyleBlocks, extractCSSinJS } from './engines/regex/detect-text.mjs';
export {
walkDir,
hasScannableExtension,
SCANNABLE_EXTENSIONS,
SKIP_DIRS,
buildImportGraph,
@@ -41,6 +41,221 @@ function shouldRunPageAnalyzers(content, filePath) {
return !ext || PAGE_ANALYZER_EXTS.has(ext);
}
const JS_SOURCE_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs']);
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']);
function isInsideOpeningJsxTag(source) {
const tagStart = source.lastIndexOf('<');
if (tagStart === -1 || !/^<[A-Za-z][\w.:-]*/.test(source.slice(tagStart))) return false;
let quote = '';
for (let cursor = tagStart + 1; cursor < source.length; cursor++) {
const char = source[cursor];
if (quote) {
if (char === '\\') cursor++;
else if (char === quote) quote = '';
} else if (char === "'" || char === '"') {
quote = char;
} else if (char === '>') {
return false;
}
}
return true;
}
/**
* Blank JavaScript comments without moving any following source. Regex
* findings keep their original line numbers, while prose examples inside
* comments cannot masquerade as rendered markup.
*/
function stripJsComments(content, options = {}) {
let state = 'code';
let output = '';
let lastSignificant = '';
let previousSignificant = '';
let antePreviousSignificant = '';
let currentWord = '';
let currentWordPrefix = '';
let wordSeparated = false;
let regexCharClass = false;
let jsxExpressionDepth = 0;
let lastClosedBraceKind = '';
const braceKinds = [];
const templateExpressionDepths = [];
const braceKind = (startsJsxExpression = false) => (
!startsJsxExpression && (
!lastSignificant ||
lastSignificant === ')' ||
lastSignificant === ';' ||
lastSignificant === '}' ||
(previousSignificant === '=' && lastSignificant === '>') ||
BLOCK_BRACE_PREFIX_KEYWORDS.has(currentWord)
) ? 'block' : 'expression'
);
const recordSignificant = (char) => {
if (/\s/.test(char)) {
wordSeparated = true;
return;
}
const isWordChar = /[\w$]/.test(char);
if (isWordChar && (wordSeparated || !currentWord)) {
currentWord = '';
currentWordPrefix = lastSignificant;
} else if (!isWordChar) {
currentWordPrefix = '';
}
wordSeparated = false;
antePreviousSignificant = previousSignificant;
previousSignificant = lastSignificant;
lastSignificant = char;
currentWord = isWordChar ? currentWord + char : '';
};
for (let i = 0; i < content.length; i++) {
const char = content[i];
const next = content[i + 1];
if (state === 'line-comment') {
if (char === '\n') {
output += char;
state = 'code';
} else {
output += ' ';
}
continue;
}
if (state === 'block-comment') {
if (char === '*' && next === '/') {
output += ' ';
i++;
state = 'code';
} else {
output += char === '\n' ? '\n' : ' ';
}
continue;
}
if (state === 'regex') {
output += char;
if (char === '\\' && next) {
output += next;
i++;
} else if (char === '[') {
regexCharClass = true;
} else if (char === ']') {
regexCharClass = false;
} else if (char === '/' && !regexCharClass) {
state = 'code';
recordSignificant('/');
}
continue;
}
if (state === 'template' && char === '$' && next === '{') {
output += '${';
i++;
recordSignificant('$');
recordSignificant('{');
templateExpressionDepths.push(1);
braceKinds.push('expression');
if (jsxExpressionDepth) jsxExpressionDepth++;
state = 'code';
continue;
}
if (state !== 'code') {
output += char;
if (char === '\\' && next) {
output += next;
i++;
} else if (
(state === 'single-quote' && char === "'") ||
(state === 'double-quote' && char === '"') ||
(state === 'template' && char === '`')
) {
state = 'code';
recordSignificant(char);
}
continue;
}
const jsxUrlSeparator = options.jsx && char === '/' && next === '/' &&
jsxExpressionDepth === 0 &&
(output.endsWith('http:') ||
output.endsWith('https:') ||
(/<[A-Za-z](?:[^>]*[^/])?>[^<]*$/.test(output.slice(output.lastIndexOf('\n') + 1)) &&
/^[\w.-]+\.[A-Za-z]{2,}(?=[:/?#\s<]|$)/.test(content.slice(i + 2))));
const afterPostfixUpdate = (lastSignificant === '+' || lastSignificant === '-') &&
previousSignificant === lastSignificant &&
antePreviousSignificant !== lastSignificant;
if (char === '/' && next === '/' && jsxUrlSeparator) {
output += '//';
i++;
recordSignificant('/');
recordSignificant('/');
} else if (char === '/' && next === '/') {
output += ' ';
i++;
state = 'line-comment';
} else if (char === '/' && next === '*') {
output += ' ';
i++;
state = 'block-comment';
} else if (templateExpressionDepths.length && char === '{') {
output += char;
templateExpressionDepths[templateExpressionDepths.length - 1]++;
braceKinds.push(braceKind());
if (jsxExpressionDepth) jsxExpressionDepth++;
recordSignificant(char);
} else if (templateExpressionDepths.length && char === '}') {
output += char;
const depthIndex = templateExpressionDepths.length - 1;
templateExpressionDepths[depthIndex]--;
lastClosedBraceKind = braceKinds.pop() || '';
if (jsxExpressionDepth) jsxExpressionDepth--;
recordSignificant(char);
if (templateExpressionDepths[depthIndex] === 0) {
templateExpressionDepths.pop();
state = 'template';
}
} else if (
char === '/' &&
(!lastSignificant ||
(/[=([{!?:;,&|+\-*%^~<>]/.test(lastSignificant) && !afterPostfixUpdate) ||
(lastSignificant === '}' && lastClosedBraceKind === 'block') ||
(previousSignificant === '=' && lastSignificant === '>') ||
(currentWordPrefix !== '.' && REGEX_PREFIX_KEYWORDS.has(currentWord)))
) {
output += char;
state = 'regex';
regexCharClass = false;
} else {
output += char;
const startsJsxExpression = options.jsx && char === '{' && jsxExpressionDepth === 0 &&
(/<[A-Za-z](?:[^>]*[^/])?>[^<]*$/.test(output.slice(output.lastIndexOf('\n') + 1, -1)) ||
isInsideOpeningJsxTag(output.slice(0, -1)));
if (char === '{') braceKinds.push(braceKind(startsJsxExpression));
else if (char === '}') lastClosedBraceKind = braceKinds.pop() || '';
if (char === '{' && (jsxExpressionDepth || startsJsxExpression)) jsxExpressionDepth++;
else if (char === '}' && jsxExpressionDepth) jsxExpressionDepth--;
recordSignificant(char);
if (char === "'") state = 'single-quote';
else if (char === '"') state = 'double-quote';
else if (char === '`') state = 'template';
}
}
return output;
}
function stripCssComments(content) {
return content.replace(/\/\*[\s\S]*?\*\//g, comment => comment.replace(/[^\n]/g, ' '));
}
function firstOverusedGoogleFont(text) {
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
}
@@ -528,18 +743,198 @@ function extractStyleBlocks(content, ext) {
const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']);
function findQuotedStringEnd(content, start, quote) {
for (let cursor = start + 1; cursor < content.length; cursor++) {
if (content[cursor] === '\\') cursor++;
else if (content[cursor] === quote) return cursor;
}
return -1;
}
function findRegexLiteralEnd(content, start) {
let inCharacterClass = false;
for (let cursor = start + 1; cursor < content.length; cursor++) {
const char = content[cursor];
if (char === '\\') {
cursor++;
} else if (char === '[') {
inCharacterClass = true;
} else if (char === ']') {
inCharacterClass = false;
} else if (char === '/' && !inCharacterClass) {
while (/[A-Za-z]/.test(content[cursor + 1] || '')) cursor++;
return cursor;
} else if (char === '\n' || char === '\r') {
return -1;
}
}
return -1;
}
function findTemplateExpressionEnd(content, start) {
let depth = 1;
let lastSignificant = '';
let previousSignificant = '';
let antePreviousSignificant = '';
let currentWord = '';
let currentWordPrefix = '';
let wordSeparated = false;
let lastClosedBraceKind = '';
const braceKinds = [];
const braceKind = () => (
lastSignificant === ')' ||
lastSignificant === ';' ||
lastSignificant === '}' ||
(previousSignificant === '=' && lastSignificant === '>') ||
BLOCK_BRACE_PREFIX_KEYWORDS.has(currentWord)
? 'block'
: 'expression'
);
const recordSignificant = (char) => {
if (/\s/.test(char)) {
wordSeparated = true;
return;
}
const isWordChar = /[\w$]/.test(char);
if (isWordChar && (wordSeparated || !currentWord)) {
currentWord = '';
currentWordPrefix = lastSignificant;
} else if (!isWordChar) {
currentWordPrefix = '';
}
wordSeparated = false;
antePreviousSignificant = previousSignificant;
previousSignificant = lastSignificant;
lastSignificant = char;
currentWord = isWordChar ? currentWord + char : '';
};
for (let cursor = start; cursor < content.length; cursor++) {
const char = content[cursor];
const next = content[cursor + 1];
const afterPostfixUpdate = (lastSignificant === '+' || lastSignificant === '-') &&
previousSignificant === lastSignificant &&
antePreviousSignificant !== lastSignificant;
if (char === "'" || char === '"') {
cursor = findQuotedStringEnd(content, cursor, char);
if (cursor === -1) return -1;
recordSignificant(')');
} else if (char === '/' && next === '/') {
const lineEnd = content.indexOf('\n', cursor + 2);
if (lineEnd === -1) return -1;
cursor = lineEnd;
} else if (char === '/' && next === '*') {
const commentEnd = content.indexOf('*/', cursor + 2);
if (commentEnd === -1) return -1;
cursor = commentEnd + 1;
} else if (
char === '/' &&
(!lastSignificant ||
(/[=([{!?:;,&|+\-*%^~<>]/.test(lastSignificant) && !afterPostfixUpdate) ||
(lastSignificant === '}' && lastClosedBraceKind === 'block') ||
(previousSignificant === '=' && lastSignificant === '>') ||
(currentWordPrefix !== '.' && REGEX_PREFIX_KEYWORDS.has(currentWord)))
) {
cursor = findRegexLiteralEnd(content, cursor);
if (cursor === -1) return -1;
recordSignificant(')');
} else if (char === '`') {
cursor = findTemplateLiteralEnd(content, cursor);
if (cursor === -1) return -1;
recordSignificant(')');
} else if (char === '{') {
depth++;
braceKinds.push(braceKind());
recordSignificant(char);
} else if (char === '}') {
depth--;
if (depth === 0) return cursor;
lastClosedBraceKind = braceKinds.pop() || '';
recordSignificant(char);
} else {
recordSignificant(char);
}
}
return -1;
}
function findTemplateLiteralEnd(content, start) {
for (let cursor = start + 1; cursor < content.length; cursor++) {
const char = content[cursor];
if (char === '\\') {
cursor++;
} else if (char === '`') {
return cursor;
} else if (char === '$' && content[cursor + 1] === '{') {
cursor = findTemplateExpressionEnd(content, cursor + 2);
if (cursor === -1) return -1;
}
}
return -1;
}
function findCSSinJSTemplates(content) {
const templates = [];
const tagRe = /\b(?:styled(?:\.\w+|\([^)]+\))|css)/g;
let match;
while ((match = tagRe.exec(content)) !== null) {
let cursor = match.index + match[0].length;
while (/\s/.test(content[cursor] || '')) cursor++;
if (content[cursor] === '<') {
let depth = 0;
while (cursor < content.length) {
const char = content[cursor];
if (char === '<') depth++;
else if (char === '>' && content[cursor - 1] !== '=') depth--;
cursor++;
if (depth === 0) break;
}
if (depth !== 0) continue;
while (/\s/.test(content[cursor] || '')) cursor++;
}
if (content[cursor] !== '`') continue;
const contentStart = cursor + 1;
cursor = findTemplateLiteralEnd(content, cursor);
if (cursor === -1) continue;
templates.push({
tagStart: match.index,
contentStart,
contentEnd: cursor,
});
tagRe.lastIndex = cursor + 1;
}
return templates;
}
function extractCSSinJS(content, ext) {
ext = ext.toLowerCase();
if (!CSS_IN_JS_EXTENSIONS.has(ext)) return [];
const blocks = [];
const re = /(?:styled(?:\.\w+|\([^)]+\))|css)\s*`([\s\S]*?)`/g;
let m;
while ((m = re.exec(content)) !== null) {
const before = content.substring(0, m.index);
return findCSSinJSTemplates(content).map((template) => {
const before = content.substring(0, template.tagStart);
const startLine = before.split('\n').length;
blocks.push({ content: m[1], startLine });
return {
content: content.slice(template.contentStart, template.contentEnd),
startLine,
};
});
}
function stripCssInJsComments(content, ext) {
if (!CSS_IN_JS_EXTENSIONS.has(ext.toLowerCase())) return content;
const templates = findCSSinJSTemplates(content);
let output = '';
let cursor = 0;
for (const template of templates) {
output += content.slice(cursor, template.contentStart);
output += stripCssComments(content.slice(template.contentStart, template.contentEnd));
cursor = template.contentEnd;
}
return blocks;
return output + content.slice(cursor);
}
function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, options = {}) {
@@ -627,8 +1022,12 @@ function runTextContentAnalyzers(content, filePath, options = {}) {
function detectText(content, filePath, options = {}) {
const profile = options?.profile;
const findings = [];
const lines = content.split('\n');
const ext = extFromFilePath(filePath);
const commentStrippedSource = JS_SOURCE_EXTS.has(ext) ? stripJsComments(content, {
jsx: ext === '.js' || ext === '.jsx' || ext === '.tsx',
}) : content;
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
@@ -661,8 +1060,8 @@ function detectText(content, filePath, options = {}) {
phase: 'source',
ruleId: 'codex-grid-background',
target: filePath,
}, () => scanCssTextForGridBackground(content).map(hit => {
const line = content.substring(0, hit.index).split('\n').length;
}, () => scanCssTextForGridBackground(source).map(hit => {
const line = source.substring(0, hit.index).split('\n').length;
return finding('codex-grid-background', filePath, hit.snippet, line);
})));
@@ -698,16 +1097,17 @@ function detectText(content, filePath, options = {}) {
phase: 'extract',
ruleId: 'css-in-js',
target: filePath,
}, () => extractCSSinJS(content, ext))
: extractCSSinJS(content, ext);
}, () => extractCSSinJS(source, ext))
: extractCSSinJS(source, ext);
for (const block of cssJsBlocks) {
const blockLines = block.content.split('\n');
const blockContent = stripCssComments(block.content);
const blockLines = blockContent.split('\n');
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
profile,
phase: 'css-in-js',
}));
findings.push(...scanInsetStripeCss(block.content, filePath, block.startLine - 1));
findings.push(...pseudoStripeFindings(block.content, block.startLine - 1));
findings.push(...scanInsetStripeCss(blockContent, filePath, block.startLine - 1));
findings.push(...pseudoStripeFindings(blockContent, block.startLine - 1));
}
if (options?.designSystem) {
@@ -26,11 +26,26 @@ const HIDDEN_SOURCE_DIRS = new Set(['.vitepress', '.vuepress', '.storybook']);
const SCANNABLE_EXTENSIONS = new Set([
'.html', '.htm', '.css', '.scss', '.sass', '.less',
'.jsx', '.tsx', '.js', '.ts',
'.vue', '.svelte', '.astro',
'.vue', '.svelte', '.astro', '.blade.php',
]);
const HTML_EXTENSIONS = new Set(['.html', '.htm']);
function hasScannableExtension(filename) {
const lower = filename.toLowerCase();
if (SCANNABLE_EXTENSIONS.has(path.extname(lower))) return true;
for (const ext of SCANNABLE_EXTENSIONS) {
if (ext.indexOf('.', 1) !== -1 && lower.endsWith(ext)) return true;
}
return false;
}
const IMPORT_SPECIFIER_PATTERNS = [
/import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g,
/@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g,
/@(?:use|forward)\s+['"]([^'"]+)['"]/g,
];
function walkDir(dir) {
const files = [];
let entries;
@@ -40,7 +55,7 @@ function walkDir(dir) {
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full));
else if (SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(full);
else if (hasScannableExtension(entry.name)) files.push(full);
}
return files;
}
@@ -75,26 +90,11 @@ function buildImportGraph(files) {
const dir = path.dirname(file);
const imports = new Set();
// ES imports: import ... from '...' and import '...'
const esRe = /import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g;
let m;
while ((m = esRe.exec(content)) !== null) {
const resolved = resolveImport(m[1], dir, fileSet);
if (resolved) imports.add(resolved);
}
// CSS @import
const cssRe = /@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g;
while ((m = cssRe.exec(content)) !== null) {
const resolved = resolveImport(m[1], dir, fileSet);
if (resolved) imports.add(resolved);
}
// SCSS @use / @forward
const scssRe = /@(?:use|forward)\s+['"]([^'"]+)['"]/g;
while ((m = scssRe.exec(content)) !== null) {
const resolved = resolveImport(m[1], dir, fileSet);
if (resolved) imports.add(resolved);
for (const pattern of IMPORT_SPECIFIER_PATTERNS) {
for (const match of content.matchAll(pattern)) {
const resolved = resolveImport(match[1], dir, fileSet);
if (resolved) imports.add(resolved);
}
}
graph.set(file, imports);
@@ -203,6 +203,7 @@ export {
SKIP_DIRS,
SCANNABLE_EXTENSIONS,
HTML_EXTENSIONS,
hasScannableExtension,
walkDir,
resolveImport,
buildImportGraph,
@@ -10,7 +10,7 @@
* node hook-admin.mjs off # set enabled: false
* node hook-admin.mjs ignore-rule <rule-id> # append to ignoreRules
* node hook-admin.mjs ignore-rule overused-font --all-values
* node hook-admin.mjs ignore-file <glob> # append to ignoreFiles
* node hook-admin.mjs ignore-file <glob> [--shared|--local] # append to ignoreFiles
* node hook-admin.mjs ignore-value <rule> <value> # append to shared ignoreValues
* node hook-admin.mjs ignore-value <rule> <value> --local
* node hook-admin.mjs ignore-value <rule> "*" --file <glob> # rule off in <glob> only
@@ -166,7 +166,7 @@ function readRawConfigFile(filePath) {
}
}
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem', 'advisoryRules']);
function hookSection(unified) {
return unified && typeof unified === 'object' && !Array.isArray(unified) && unified.hook && typeof unified.hook === 'object' && !Array.isArray(unified.hook)
@@ -200,6 +200,15 @@ function stripDetectorKeys(raw) {
return out;
}
function pickDetectorKeys(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
const out = {};
for (const [key, value] of Object.entries(raw)) {
if (DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
}
return out;
}
// Write hook runtime config under `hook`, leaving detector filters in
// `detector` and preserving sibling keys such as updateCheck.
function writeHookConfig(cwd, hookConfig, opts = {}) {
@@ -207,10 +216,19 @@ function writeHookConfig(cwd, hookConfig, opts = {}) {
if (opts.local) ensureHookGitExcludes(cwd);
const existingRaw = readRawConfigFile(filePath).raw;
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const existingHook = stripDetectorKeys(hookSection(existing));
const existingHookSection = hookSection(existing);
const existingHook = stripDetectorKeys(existingHookSection);
const legacyDetector = pickDetectorKeys(existingHookSection);
// Merge over the existing hook object so fields the merge helpers don't manage
// (consent, quiet, auditLog) survive an Impeccable hooks edit.
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
if (Object.keys(legacyDetector).length > 0) {
const existingDetector = detectorSection(existing) || {};
next.detector = {
...existingDetector,
...mergeDetectorConfig(existingDetector, mergeDetectorConfig(legacyDetector)),
};
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
return filePath;
@@ -222,10 +240,14 @@ function writeDetectorConfig(cwd, detectorConfig, opts = {}) {
const existingRaw = readRawConfigFile(filePath).raw;
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const nextHook = stripDetectorKeys(hookSection(existing));
const existingDetector = mergeDetectorConfig(detectorSection(existing));
const existingDetectorSection = detectorSection(existing) || {};
const existingDetector = mergeDetectorConfig(existingDetectorSection);
const next = {
...existing,
detector: mergeDetectorConfig(detectorConfig, existingDetector),
detector: {
...existingDetectorSection,
...mergeDetectorConfig(detectorConfig, existingDetector),
},
};
if (Object.keys(nextHook).length > 0) next.hook = nextHook;
else delete next.hook;
@@ -259,12 +281,18 @@ function mergeDetectorConfig(existing, seed = null) {
if (seed?.designSystem && typeof seed.designSystem === 'object' && !Array.isArray(seed.designSystem)) {
out.designSystem = { ...seed.designSystem };
}
if (seed?.advisoryRules === 'include' || seed?.advisoryRules === 'exclude') {
out.advisoryRules = seed.advisoryRules;
}
if (base.designSystem && typeof base.designSystem === 'object' && !Array.isArray(base.designSystem)) {
out.designSystem = {
...(out.designSystem || {}),
enabled: base.designSystem.enabled === false ? false : true,
};
}
if (base.advisoryRules === 'include' || base.advisoryRules === 'exclude') {
out.advisoryRules = base.advisoryRules;
}
if (Array.isArray(base.ignoreRules)) {
out.ignoreRules = Array.from(new Set([...out.ignoreRules, ...base.ignoreRules.map(String)]));
}
@@ -558,12 +586,44 @@ function addIgnoreRule(cwd, args) {
return `Added "${rule}" to detector.ignoreRules. Current: ${config.ignoreRules.join(', ')}`;
}
function addIgnoreFile(cwd, glob) {
function parseIgnoreFileArgs(args) {
const positionals = [];
let shared = false;
let local = false;
for (const raw of args) {
const arg = String(raw || '');
if (arg === '--shared') {
shared = true;
} else if (arg === '--local') {
local = true;
} else if (arg === '--reason' || arg.startsWith('--reason=')) {
throw new Error('--reason is not supported for ignore-file because detector.ignoreFiles stores globs only; use ignore-value when a documented rule-specific exception fits');
} else if (arg.startsWith('--')) {
throw new Error(`Unknown ignore-file flag: ${arg}`);
} else {
positionals.push(arg);
}
}
if (shared && local) throw new Error('Pass only one scope flag: --shared or --local');
if (positionals.length > 1) throw new Error('Pass exactly one glob to ignore-file');
return {
glob: positionals[0],
local,
};
}
function addIgnoreFile(cwd, args) {
const parsed = parseIgnoreFileArgs(args);
const glob = parsed.glob;
if (!glob) throw new Error(`Pass a glob, e.g. ${IMPECCABLE_COMMAND} hooks ignore-file "src/legacy/**"`);
const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local: parsed.local }));
if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob);
writeDetectorConfig(cwd, config);
return `Added "${glob}" to detector.ignoreFiles. Current: ${config.ignoreFiles.join(', ')}`;
const target = writeDetectorConfig(cwd, config, { local: parsed.local });
const scope = parsed.local ? 'local detector.ignoreFiles' : 'shared detector.ignoreFiles';
return `Added "${glob}" to ${scope} (${path.relative(cwd, target) || target}). Current: ${config.ignoreFiles.join(', ')}`;
}
// An empty glob used to be dropped by filter(Boolean), so `--file=` reported
@@ -727,7 +787,7 @@ function main() {
case 'on': out = setEnabled(cwd, true); break;
case 'off': out = setEnabled(cwd, false); break;
case 'ignore-rule': out = addIgnoreRule(cwd, rest); break;
case 'ignore-file': out = addIgnoreFile(cwd, rest[0]); break;
case 'ignore-file': out = addIgnoreFile(cwd, rest); break;
case 'ignore-value': out = addIgnoreValue(cwd, rest); break;
case 'reset': out = reset(cwd); break;
}
@@ -23,6 +23,7 @@ import {
designSystemOptions,
filterFindings,
isNativePlatform,
isScanTargetInsideProject,
loadDetector,
matchConfiguredExtension,
matchesAnyGlob,
@@ -161,7 +162,7 @@ function replaceOnce(original, oldString, newString) {
}
function readExistingProjectFile(filePath, cwd) {
if (!isInsideProject(filePath, cwd)) return null;
if (!isScanTargetInsideProject(filePath, cwd)) return null;
if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null;
try {
const stat = fs.statSync(filePath);
@@ -232,7 +233,7 @@ function shellCopiedFileContent(command, cwd) {
const source = shellCopyPaths(command)?.source;
if (!source) return '';
const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source);
if (!isInsideProject(sourcePath, cwd)) return '';
if (!isScanTargetInsideProject(sourcePath, cwd)) return '';
if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return '';
try {
const stat = fs.statSync(sourcePath);
@@ -328,15 +329,6 @@ function relativePath(filePath, cwd) {
}
}
function isInsideProject(filePath, cwd) {
try {
const rel = path.relative(cwd, filePath);
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
} catch {
return false;
}
}
// The static HTML engine reads its input from disk, but preToolUse only has
// the proposed content. Stage it in a temp file so html-engine targets get the
// same DOM-structural rules pre-write that runHook applies post-edit.
@@ -414,7 +406,7 @@ async function main() {
};
if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started });
if (!isInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started });
if (!isScanTargetInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started });
if (SENSITIVE_PATH.test(filePath)) return allow({ ...audit, skipped: 'sensitive', durationMs: Date.now() - started });
if (GENERATED_PATH.test(filePath)) return allow({ ...audit, skipped: 'generated', durationMs: Date.now() - started });
@@ -1335,6 +1335,51 @@ function isInsideProject(filePath, projectCwd) {
}
}
// Resolve a path to its canonical (symlink-free) form. When the path does
// not exist yet — the before-edit hook gates proposed Writes — canonicalize
// the nearest existing ancestor and re-append the remainder, so a new file
// under a symlinked root still compares equal to its canonical project.
// Memoized: the hook runs as a fresh process per tool event, so the cache
// amounts to once-per-event work — the scan loops re-check the same project
// root for every target file. The cap only matters to long-lived importers
// like the test runner.
const canonicalPathCache = new Map();
const CANONICAL_PATH_CACHE_MAX = 1024;
function canonicalPath(p) {
const resolved = path.resolve(p);
if (canonicalPathCache.has(resolved)) return canonicalPathCache.get(resolved);
let canonical = resolved;
let dir = resolved;
const tail = [];
while (true) {
try {
canonical = tail.length ? path.join(fs.realpathSync(dir), ...tail) : fs.realpathSync(dir);
break;
} catch { /* keep climbing */ }
const parent = path.dirname(dir);
if (parent === dir) break;
tail.unshift(path.basename(dir));
dir = parent;
}
if (canonicalPathCache.size >= CANONICAL_PATH_CACHE_MAX) canonicalPathCache.clear();
canonicalPathCache.set(resolved, canonical);
return canonical;
}
// Containment gate shared by the before-edit hook and both scan passes. A
// session routinely touches files that belong to no project or to a
// different one — harness scratchpad dirs under the system temp root,
// sibling checkouts, one-off throwaway HTML — and findings against those are
// judged with THIS project's config and DESIGN.md palette, which is never
// right. Skip them (audit reason: outside-project). Paths are canonicalized
// first so a symlinked root (macOS /tmp -> /private/tmp) doesn't split the
// comparison.
export function isScanTargetInsideProject(filePath, projectCwd) {
if (!filePath || !projectCwd) return false;
return isInsideProject(canonicalPath(filePath), canonicalPath(projectCwd));
}
export function parseStaticStyleImports(content, fromFile, projectCwd) {
if (!content || typeof content !== 'string') return [];
const dir = path.dirname(fromFile);
@@ -1693,6 +1738,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
lastSkip = 'file-missing';
continue;
}
if (!isScanTargetInsideProject(filePath, projectCwd)) {
lastSkip = 'outside-project';
continue;
}
const maxFileBytes = config.limits?.maxFileBytes ?? DEFAULT_CONFIG.limits.maxFileBytes;
if (maxFileBytes > 0) {
@@ -2023,6 +2072,10 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
const relForMatch = relativize(filePath, projectCwd);
if (matchesAnyGlob(relForMatch, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) continue;
if (!fs.existsSync(filePath)) continue;
// Caches written before this gate existed can still hold out-of-project
// paths, so the Stop pass re-checks containment rather than trusting
// the per-edit pass to have filtered them.
if (!isScanTargetInsideProject(filePath, projectCwd)) continue;
scanned += 1;
let content = '';
@@ -50,9 +50,36 @@ export function normalizeConceptForm(value) {
.trim();
}
export function validateConceptEntry(concept, { existingForms = new Map() } = {}) {
export function validateConceptEntry(concept, { existingForms = new Map(), axes = null } = {}) {
const errors = [];
const id = concept?.id || '(unknown)';
// Recorded aesthetic axis values. Optional, and absent means the value is
// inferred from the system rules instead. Some axes cannot be inferred at all:
// depth's keyword probe matched worlds that said "no cast shadow anywhere",
// and motion and colour strategy describe properties the rules never state, so
// a wave that assigns those has to record them or the assignment is lost.
// Validated against the axes definition when the caller supplies it, because a
// typo would read as "unrecorded" and silently fall back to a probe that is
// known not to work.
if (concept?.axes !== undefined && concept.axes !== null) {
if (typeof concept.axes !== 'object' || Array.isArray(concept.axes)) {
errors.push(`concept ${id} axes must be an object of axis id to value id`);
} else if (axes) {
const byId = new Map((axes.axes || []).map(axis => [axis.id, axis]));
for (const [axisId, valueId] of Object.entries(concept.axes)) {
const axis = byId.get(axisId);
if (!axis) {
errors.push(`concept ${id} names unknown axis "${axisId}"`);
} else if (!(axis.values || []).some(value => value.id === valueId)) {
errors.push(
`concept ${id} axis "${axisId}" has unknown value "${valueId}" `
+ `(expected one of ${(axis.values || []).map(v => v.id).join(', ')})`
);
}
}
}
}
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(concept?.id || '')) {
errors.push(`invalid concept id: ${String(concept?.id)}`);
}
@@ -82,6 +109,18 @@ export function validateConceptEntry(concept, { existingForms = new Map() } = {}
|| concept.tags.some(tag => typeof tag !== 'string' || !tag.trim())) {
errors.push(`concept ${id} must have exactly three structural tags`);
}
// The slop this world in particular is at risk of. Optional, because 541
// entries predate it and none of them are wrong for lacking it. A world built
// from posters is at risk of shouting and one built from instruments is at
// risk of dead greys; a global detector cannot know which, and the author can.
if (concept?.avoid !== undefined) {
if (!Array.isArray(concept.avoid)
|| concept.avoid.length < 2
|| concept.avoid.length > 3
|| concept.avoid.some(item => typeof item !== 'string' || item.trim().length < 12 || item.trim().length > 160)) {
errors.push(`concept ${id} avoid must be two or three negations of 12160 characters`);
}
}
if (!Array.isArray(concept?.system)
|| concept.system.length !== SYSTEM_PREFIXES.length
|| concept.system.some(rule => typeof rule !== 'string' || rule.trim().length < 12 || rule.trim().length > 180)) {
@@ -2,15 +2,20 @@
// the live-mode design-system panel can render. Deterministic, dependency-free.
//
// Two-layer: YAML frontmatter (machine-readable tokens) + markdown body
// (prose with six canonical H2 sections). When frontmatter is present, it's
// (prose with eight canonical H2 sections). When frontmatter is present, it's
// exposed on `model.frontmatter` alongside the prose-scraped sections;
// consumers can prefer frontmatter values and fall back to prose.
// Array order is also match precedence: matchCanonicalSection's keyword-contained
// pass returns the first entry a heading contains, so reordering this changes
// which section an ambiguous heading resolves to.
const CANONICAL_SECTIONS = [
'Overview',
'Colors',
'Typography',
'Layout',
'Elevation',
'Shapes',
'Components',
"Do's and Don'ts",
];
@@ -115,10 +120,71 @@ function stripInlineYamlComment(s) {
return s;
}
// YAML double-quoted scalars process backslash escapes. Stripping the outer
// quotes without unescaping leaves them in place, so a nested font family like
// fontFamily: "\"IBM Plex Sans\", system-ui, sans-serif"
// keeps its literal backslashes and never matches the same family in CSS.
// The full YAML 1.2 double-quote escape set (spec section 5.7).
const YAML_SIMPLE_ESCAPES = {
'0': '\0',
a: '\x07',
b: '\b',
t: '\t',
n: '\n',
v: '\v',
f: '\f',
r: '\r',
e: '\x1b',
' ': ' ',
'"': '"',
'/': '/',
'\\': '\\',
N: '\u0085',
_: '\u00a0',
L: '\u2028',
P: '\u2029',
};
const YAML_HEX_ESCAPE_LENGTHS = { x: 2, u: 4, U: 8 };
function unescapeYamlDoubleQuoted(body) {
let out = '';
for (let i = 0; i < body.length; i++) {
const ch = body[i];
if (ch !== '\\' || i === body.length - 1) {
out += ch;
continue;
}
const next = body[i + 1];
if (Object.prototype.hasOwnProperty.call(YAML_SIMPLE_ESCAPES, next)) {
out += YAML_SIMPLE_ESCAPES[next];
i++;
continue;
}
// \xNN, \uNNNN, \UNNNNNNNN. Malformed or out-of-range sequences stay
// literal rather than corrupting the rest of the scalar.
const hexLen = YAML_HEX_ESCAPE_LENGTHS[next];
if (hexLen) {
const hex = body.slice(i + 2, i + 2 + hexLen);
const codePoint = hex.length === hexLen && /^[0-9a-fA-F]+$/.test(hex) ? parseInt(hex, 16) : -1;
if (codePoint >= 0 && codePoint <= 0x10ffff) {
out += String.fromCodePoint(codePoint);
i += 1 + hexLen;
continue;
}
}
out += ch;
}
return out;
}
function parseScalar(raw) {
const s = raw.trim();
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
return s.slice(1, -1);
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) {
return unescapeYamlDoubleQuoted(s.slice(1, -1));
}
// Single-quoted YAML escapes only the quote itself, by doubling it.
if (s.length >= 2 && s.startsWith("'") && s.endsWith("'")) {
return s.slice(1, -1).split("''").join("'");
}
if (s === 'true') return true;
if (s === 'false') return false;
@@ -330,17 +396,16 @@ function extractOverview(section) {
if (!section) return null;
const text = section.lines.join('\n');
const northStar = text.match(/\*\*Creative North Star:\s*"([^"]+)"\*\*/);
const keyChars = [];
const keyCharMatch = text.match(/\*\*Key Characteristics:\*\*\s*\n([\s\S]+?)(?:\n##|\n###|$)/);
if (keyCharMatch) {
for (const line of keyCharMatch[1].split('\n')) {
const m = line.match(/^\s*[-*]\s+(.+)$/);
if (m) keyChars.push(stripBold(m[1].trim()));
}
}
const keyChars = keyCharMatch
? collectBullets(keyCharMatch[1].split('\n')).map((bullet) => stripBold(bullet.trim()))
: [];
const prose = keyCharMatch
? text.slice(0, keyCharMatch.index) + text.slice(keyCharMatch.index + keyCharMatch[0].length)
: text;
// Philosophy paragraphs: everything that isn't a rule header or key-char block
const paragraphs = collectParagraphs(section.lines).filter(
const paragraphs = collectParagraphs(prose.split('\n')).filter(
(p) =>
!p.startsWith('**Creative North Star') &&
!p.startsWith('**Key Characteristics')
@@ -602,11 +667,19 @@ function parseTypeBullet(bullet) {
};
}
function extractElevation(section) {
function extractGuidance(section) {
if (!section) return null;
const subs = splitSubsections(section.lines);
return {
subtitle: section.subtitle,
description: collectParagraphs(subs[0].lines).join(' ') || null,
rules: extractNamedRules(section.lines),
};
}
const description = collectParagraphs(subs[0].lines).join(' ') || null;
function extractElevation(section) {
const guidance = extractGuidance(section);
if (!guidance) return null;
const shadows = [];
const seen = new Set();
@@ -631,12 +704,7 @@ function extractElevation(section) {
for (const inline of extractInlineShadows(b)) dedupe(inline);
}
return {
subtitle: section.subtitle,
description,
shadows,
rules: extractNamedRules(section.lines),
};
return { ...guidance, shadows };
}
function extractInlineShadows(text) {
@@ -768,6 +836,15 @@ function extractDosDonts(section) {
// ---------- Coverage assessment ----------
// Sections whose model is description-plus-rules only (see extractGuidance).
const guidanceCoverage = (guidance) =>
guidance
? {
description: Boolean(guidance.description),
rules: guidance.rules.length,
}
: 'missing';
function assessCoverage(model) {
const report = {};
@@ -796,6 +873,8 @@ function assessCoverage(model) {
}
: 'missing';
report.layout = guidanceCoverage(model.layout);
report.elevation = model.elevation
? {
shadows: model.elevation.shadows.length,
@@ -804,6 +883,8 @@ function assessCoverage(model) {
}
: 'missing';
report.shapes = guidanceCoverage(model.shapes);
report.components = model.components
? {
count: model.components.components.length,
@@ -833,7 +914,9 @@ export function parseDesignMd(md) {
overview: extractOverview(sections['Overview']),
colors: extractColors(sections['Colors']),
typography: extractTypography(sections['Typography']),
layout: extractGuidance(sections['Layout']),
elevation: extractElevation(sections['Elevation']),
shapes: extractGuidance(sections['Shapes']),
components: extractComponents(sections['Components']),
dosDonts: extractDosDonts(sections["Do's and Don'ts"]),
};
@@ -0,0 +1,26 @@
import { spawn } from 'node:child_process';
export function browserOpenCommand(url, {
platform = process.platform,
comspec = process.env.ComSpec || process.env.COMSPEC || 'cmd.exe',
} = {}) {
if (platform === 'darwin') return { command: 'open', args: [url] };
if (platform === 'win32') return { command: comspec, args: ['/c', 'start', '', url] };
return { command: 'xdg-open', args: [url] };
}
export function openSystemBrowser(url, {
platform = process.platform,
comspec = process.env.ComSpec || process.env.COMSPEC || 'cmd.exe',
spawnImpl = spawn,
} = {}) {
const { command, args } = browserOpenCommand(url, { platform, comspec });
try {
const child = spawnImpl(command, args, { stdio: 'ignore', detached: true });
child.on('error', () => {});
child.unref();
return true;
} catch {
return false;
}
}

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