Compare commits

...
Author SHA1 Message Date
copilot-swe-agent[bot]andGitHub 1098879103 Fix: skip recoverEmptyCycling in injectVariantsFromSource when GENERATING
Server-side preflight writes the scaffold to source before variants are
ready, triggering a full Astro page reload via HMR. After reload the
browser calls injectVariantsFromSource, finds the empty scaffold wrapper
(no variants yet), and calls recoverEmptyCycling which destroys the
session. When the agent then writes real variants and posts done, there
is no active session to receive it, so the browser never enters CYCLING.

Fix: in GENERATING state, an empty wrapper is expected (the agent is
still writing variants). Return early without touching the session so the
next HMR or done SSE can deliver the full variant set.

Fixes the intermittent astro-vite7 CYCLING timeout in live-e2e smoke.
2026-07-18 21:50:14 +00:00
copilot-swe-agent[bot]andGitHub a3d7009e33 Initial plan 2026-07-18 21:19:43 +00:00
Paul Bakaus e0afd4fcea Drop the progressive benchmark, remove dead wrap scaffolding
Review fallout from removing progressive publication.

The Live benchmark existed to compare atomic against progressive delivery:
compareModelBackedReports measures goToFirstVariantMs improvement of one
over the other. With progressive gone it measures nothing against nothing.
Worse, benchmark-live.mjs still passed `progressive` to bootFixtureSession,
which no longer accepts it, so `--delivery progressive` was silently
ignored and would have emitted reports labeled progressive that actually
ran atomic. Silent wrong data is worse than a crash. It was built for
progressive, so it goes with progressive: benchmark-live.mjs, its lib, its
test, and the bench:live script. If an atomic latency baseline is wanted
later, that is a smaller thing built on purpose.

live-wrap.mjs: sourceOriginalLines was assigned and never read.

Both found by review bots on #381 (Copilot).

Assisted-by: Claude Code
2026-07-18 14:11:07 -07:00
Paul Bakaus 3600edc5e9 Live: polling rework, source locks, preflight scaffolding, Vue previews
Carved out of #371, minus progressive publication. Everything here works
against real project source the way main's Live already does: the agent
writes variants into the file the browser loaded, HMR fires, Accept
promotes and carbonizes. Nothing is staged anywhere.

Poll lanes. Events now carry an explicit priority: accept/discard/exit
ahead of manual_edit_apply/steer/carbonize_cleanup ahead of generate. A
long generate can no longer sit in front of the Accept the user just
clicked. leaseEvent claims its lease before awaiting, so a slow prepare
cannot hand the same event to two pollers.

Source locks. A per-file mutex around every accept and discard path, keyed
on a digest of the absolute path. Staleness is decided by owner-pid
liveness rather than mtime, so a wedged lock clears when its owner dies
instead of after an arbitrary timeout, and a slow-but-live accept is never
stolen from. Only the owning process can release a lock.

Preflight scaffolding. The server runs live-wrap (or live-insert) before
the poll returns and hands the result back as event.scaffold. That walk is
measured at ~7.6s on a large repo; moving it off the agent's critical path
removes a deterministic tool round trip without touching the generated
design. Falls back cleanly to the agent running the helper itself.

Vue previews. previewMode: "vue-component" for Nuxt/Vue targets, matching
the existing Svelte component path: variants compile as real SFCs from a
dev-only directory so the route is never rewritten during generation, and
Vite mounts them without invalidating page state. Accept is the only route
write. Includes a Vue attr tokenizer that normalizes shorthand bindings
(@x, :x, #x) to their canonical forms.

Accept hardening. Every thrown failure now returns mode: 'error' rather
than an ambiguous unhandled result, so a real failure is never classified
as a deliberate manual handoff and silently dropped. The marker search
skips node_modules/.git/dist/build/.impeccable.

Shared CLI arg parsing extracted to scripts/lib/cli-args.mjs.

Assisted-by: Claude Code
2026-07-18 14:11:07 -07:00
Paul BakausandClaude 97dbaad4a4 Flag inset stripes written with the two-length box-shadow form
box-shadow takes <length>{2,4}: only the two offsets are required, so
`inset 4px 0 red` is valid and paints the same single-edge stripe as
`inset 4px 0 0 red`. The scan demanded a third length, so the short form was
silently missed.

Blur and spread now default to 0 when omitted, which is exactly the stripe shape
the rule looks for. The neutral-color and blur/spread exclusions still hold:
`inset 4px 0 #000` and `inset 4px 0 5px var(--brand-accent)` both pass. Fixture
covers both orders of the short form plus those two exclusions, and fails against
the previous regex.

Third false negative found in this rule (after trailing `inset` and literal
neutral colors), all from the same cause: the scan was written against one
spelling of the syntax rather than the grammar.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 12:24:16 -07:00
Paul BakausandClaude c654acb005 Rip out the dead isolated-preview mode and the private repo's job
Comparing this branch's live against main's turned up two whole features that
never made sense here. -2,466 lines.

1. The isolated source-artifact preview was never switched on.

`scaffoldSourceArtifactSession` is only reachable via live-wrap's `--isolated`,
and nothing passes it: not the server's preflight, not live.md, nothing. Proved
it end-to-end — the default wrap writes markers straight into real source and
creates no previews/ session. So the mode was wired through three modules,
carried its own accept/discard branches, browser branches, server metadata
resolution, preview-mode classifier entry, and test suites, and none of it could
run.

Worse, live.md documented it as the active path and told the agent "The true
source is only the publisher's hash fence and must remain byte-identical until
Accept." That is false: the wrapper lands in source at scaffold time and each
revision rewrites it. An agent following that sentence believes source is
protected when it isn't, and the leftover artifacts are what made accept resolve
the wrong file in the first real run. live.md now describes what actually
happens, including that markers are visible in source until Accept or Discard.

Removed: source-artifact.mjs, --isolated, the preflight's isolated option, the
accept/discard branches, four dead browser branches, the server's previews/
resolution, the classifier entry, and their tests. Kept the previews/ gitignore
pattern: an ignore line for a directory that cannot exist is free, and a test
pins it.

2. Quality judging belongs to the private evals repo, which says so.

runner/live/README.md there is explicit: the public repo owns protocol
correctness, framework coverage, timing, source commit, recovery, and a
rubric-free evidence bundle; the private repo owns the task corpus, baselines,
comparative judges, and release-quality decisions — "Do not add quality rubrics,
competitor comparisons, or broad fixture corpora to the public Live benchmark."

This branch added exactly those: an LLM judge scoring 1-10 on "off-brand,
generic-AI" (live-rendered-quality.mjs, judge-live-rendered.mjs), a
cross-provider comparison with a BRAND_CONTRACT rubric (live-provider-benchmark
.mjs, benchmark-live-providers.mjs), and a brand-fidelity fixture corpus. All
removed, with bench:live:providers and their suite entries.

Also removed tests/framework-fixtures/README.md's "External quality-eval
fixtures" section: it documented a bench:live workflow using --fixture-dir,
--agent=codex, --action and --evidence-bundle, none of which benchmark-live.mjs
implements, plus an evidenceCapture block nothing reads.

Kept: timing benchmarks (the public repo's half of that boundary), progressive
publication, the source lock, poll lanes, and Nuxt/Vue component previews.

Coverage note: deleting the isolated suites took the only tests for
`source_locked` classification with them, so the plain wrapper path — now the
only non-component preview — gets equivalent accept and discard coverage. Both
new tests fail if mode:'error' is removed.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 19:17:38 -07:00
Paul BakausandClaude c7b67b3832 Drop the live generator subagent; fix the artifact decoy that broke accept
The first real Claude Code Live run failed, and the subagent was not the cause.

Root cause: progressive publication stages each revision as
`.impeccable/live/artifacts/<id>-r<n>.<source-ext>`, nothing ever deleted them,
and findSessionFile's walker skipped only node_modules/.git/dist/build. It
searches src, app, pages, ... then `.`; a project whose source is not under one of
those (this repo's own site lives in site/pages/) falls through to the `.` walk,
where dot-directories sort before letters. So accept found the artifact instead of
the real file. Two outcomes, both reproduced: where isGeneratedFile returns true
it declines with mode: 'fallback' (what the run hit, after which the agent
hand-carbonized several hundred lines across three stylesheets, including
unrequested drive-by edits); where it returns false, accept writes the variant
into the throwaway artifact and reports handled: true while real source never
changes.

The E2E suite could not have caught this. Every fixture puts source under `src/`,
which is searched before the `.` walk can reach `.impeccable`. Five framework
fixtures and three progressive scenarios pass because of fixture layout, not
because the path works. I read that as evidence and shouldn't have.

- Never search `.impeccable`: it is Impeccable's own state, never project source.
- Retire a session's staged artifacts on accept/discard, so they cannot outlive
  the session and become a decoy for anything else that walks the tree.
- Regression tests use a site/pages layout with artifacts present. All three fail
  against the previous code.

Generator subagent removed, on both harnesses:
The parent must hand-compress the design system into the handoff, and compression
is lossy. Measured on the real run: a 6,826-char handoff carrying exactly one
token reference, after the parent had itself read kinpaku-tokens.css. The subagent
then spent 3 of its first 9 turns hunting DESIGN.md, gave up, and emitted 0
var(--token) uses and 22 raw oklch literals — violating its own spec's "Never
invent raw colors when tokens exist" — including a 1:1 gold-on-gold contrast bug.
Isolation is not a benefit here; knowing the design system is the job. Generation
stays in the main thread, which already holds the tokens and writes them from the
first byte, so carbonize is a move rather than a translation.

Copy edits keep their subagent: applying a known set of ops to a named file is
self-contained, so an isolated context costs nothing. That is the line.

Progressive delivery stays for Codex and Claude Code, main-thread driven. Claude
Code keeps the full benefit because its poll is a background task. Codex's poll
blocks the foreground, so with no subagent the user sees variant 1 early via HMR
but cannot accept it until the trio finishes; that is the cost of the
simplification and it is worth naming.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 18:56:23 -07:00
Paul BakausandClaude 1b194d9751 Stop the progressive benchmark agent inventing a second variant on count:1
`Math.max(1, event.count - 1)` floored the tail request at one variant, so a
one-variant request fetched a second direction and assembled two. Ask for
`count - 1` and return the first variant untouched when there is no tail.

Latent rather than live: the only caller hardcodes `count: 3`. The reason it is
worth fixing is the caller inconsistency it exposed. tests/live-e2e/agent.mjs
gates its split-progressive path on `event.count > 1`; benchmark-live-providers.mjs
had no such guard, so it would have run the tail for a one-variant request, and
the parallel strategy would have assembled its three fixed lanes regardless of
what was asked for. Guard the caller the same way.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 17:39:54 -07:00
Paul BakausandClaude e2ef633b95 Finish the failed-accept classification my last commit only half did
All three new findings are the same root cause, and it is my incomplete fix:
operationFailure only covered results built from a *thrown* error. Two paths it
missed:

- Two catches wrote the failure result as a multi-line literal, so the
  single-line replace skipped them. The Vue accept catch was still bare, exactly
  as reported; the Svelte one too, though its failures happened to be caught by
  completion.mjs's Svelte-only special case.
- The accept implementations also *return* `{handled: false, error}` for their own
  checks (variant missing, template empty, original text ambiguous). Those never
  throw, so no catch ran and no `mode` was set.

Both layers now agree, because each is reachable on its own:

- live-accept marks any unhandled preview-path result via markPreviewFailure,
  keyed on `previewMode` — a clean discriminator, since only the preview branches
  set it and a plain wrapper never does. This is what the agent reads:
  reference/live.md routes on `mode`, so without it the agent was told "read
  file, find markers, edit" for a preview that has no markers in source.
- completion.mjs replaces its arbitrary svelte-component special case with the
  set of preview modes whose variants live outside the user's source. That case
  existed for precisely this reason; Vue and source-artifact were simply never
  added, so the identical failure on those paths acknowledged as success.

The plain wrapper keeps its manual handoff, which is the one shape with editable
markers in source. Both deliberate handoffs (mode: 'fallback' and markers not
found) still classify as agent_done, now pinned by a test so the generalization
cannot swallow them.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 17:26:55 -07:00
Paul BakausandClaude 6cbb7ce8d1 Classify failed accepts as errors, and fix parallel lane race/all misuse
Two of the three new findings, plus the bug that chasing them exposed in my own
earlier fix. The third is mitigated rather than broken; details below.

Failed accepts reported success:
live/completion.mjs only classifies a result as `error` when it carries
`mode: 'error'`. Everything else unhandled falls through to `agent_done` with an
ok ack, which is deliberate for the documented fallback paths (two tests pin it)
but wrong for a real failure. So `accept_receipt_conflict` reported success, and
reference/live.md's `handled: false` without `mode` bullet told the agent to
"read file, find markers, edit" — hand-applying a second accept on top of the one
the receipt already recorded.

The same hole swallowed `source_locked`, which is mine: the earlier commit made
lock contention return clean JSON so the agent could retry, but the classifier
turned that failure into agent_done/ok, so the accept was dequeued and silently
lost. Mark genuine failures with `mode: 'error'` through one `operationFailure`
helper, and give live.md a `mode: "error"` bullet with per-error guidance: retry
the same command on `source_locked`, never hand-edit, and on a receipt conflict
report what the session actually resolved to. The deliberate fallback and
markers-not-found handoffs stay untouched.

parallel-compact lane orchestration:
`Promise.race` settles on the first *settlement*, so one lane failing fast
rejected the whole first-variant step while two lanes were still on their way to
succeeding. `Promise.any` now takes the first success and only a total wipeout is
fatal, reporting every lane's reason. The tail step's `Promise.all` surfaced a
raw lane error non-deterministically; `Promise.allSettled` now reports how many
lanes failed and why. Added a `requestImpl` seam so lane orchestration is
testable without a provider key.

Not a defect: the browser releasing Accept before the source write. That is the
intended optimistic design, and it is safe because poll-lanes ranks accept at
priority 0 against generate at 2, so a queued accept is always leased before a
generate the user queues afterwards, even if the generate arrived first. Its
source write lands inside the poll script before the next generate preflights.
That invariant is load-bearing and had no tests at all; poll-lanes.mjs now has a
suite covering it plus lease and type filtering.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 16:10:57 -07:00
Paul BakausandClaude 529184bbe4 Fix inset-order detection, the unlocked artifact discard, and stray boolean flags
Three of the four open review findings. The fourth is declined below.

- The inset-stripe scan only matched layers starting with `inset`, but the keyword
  is order-independent: `box-shadow: 4px 0 0 var(--brand-accent) inset` paints the
  same stripe and was silently missed. Strip the keyword wherever it sits, but
  only as a standalone token, so a color like var(--inset-accent) is not mangled
  into `var(-- -accent)` and quietly reclassified as neutral. The fixture now
  covers both orders plus that token, and a trailing-inset neutral still passes.
- The source-artifact discard deleted the preview without the source lock, unlike
  every other discard path. Take the lock. Narrower than reported, though: the
  server journals `discard_requested` as a fenced phase before live-accept runs
  and the publisher checks it three times, so a publish could never land on a
  discarded session. What this actually prevents is deleting the artifact under a
  publisher mid-critical-section, turning a clean stale_generation_epoch into an
  ENOENT crash.
- benchmark-live-providers.mjs still compared `--headed` and `--skip-cleanup-control`
  against a boolean sentinel, so the `=true` spelling silently did nothing. My
  gap: I introduced boolFlag and converted benchmark-live.mjs but not this one.
  skipCleanupControl is now read once rather than twice, so the two call sites
  cannot drift.

Declined: tightening the selector guard that skips `active` / `current` /
`selected` tokens. It does cause false negatives on names like `.selected-feature`,
but the rule's contract makes selection and focus indicators its one exception,
and `.active-tab` / `.current-step` / `.selected-row` are syntactically identical
to `.selected-feature`. No regex separates them, so tightening the guard trades
missed stripes for false positives on exactly the case the rule exempts. The
conservative skip is the intended behavior.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 15:58:27 -07:00
Paul BakausandClaude fc620b9620 Bring Live progressive delivery and the generator subagent to Claude Code
Almost none of this branch's Live work was actually Codex-specific. The publisher,
the fences, the source locks and the browser's partial-arrival UI are plain node
and DOM with zero provider references, and the progressive E2E already passes on
five frameworks driven by a non-Codex agent. The Codex-only part was policy prose
and one frontmatter line, so Claude Code shipped the progressive browser UI it
could never trigger.

Progressive delivery, Codex and Claude Code:
- Add a `live-progressive` capability tag and opt codex, agents, and claude-code
  in. A provider block takes one tag, so naming harnesses would have meant
  duplicating the recipe per tag; a capability reads better than a provider list
  anyway. Cursor and everyone else keep the atomic path until their poll loop is
  known not to stall on the extra publish calls.
- Claude Code publishes variant 1 as soon as it validates rather than waiting to
  write the whole trio in one edit. Nothing about the arrival path needed
  changing: the publisher writes, framework HMR pushes, and the browser's
  MutationObserver counts variants. The parent conversation was never in that
  path, which is why Claude Code's lack of subagent progress streaming does not
  matter here.

Generator subagent:
- Drop `providers: codex` from impeccable-live-generator. The build already maps
  its frontmatter correctly for Claude Code, and impeccable-manual-edit-applier
  has shipped to .claude/agents/ this way all along.
- The reason differs per harness, so the reference says so: Codex delegates to
  unblock a foreground poll, Claude Code delegates to keep a long session's
  screenshots and variant CSS out of the main context. Follows the existing
  manual-edit-applier convention: both agent names, and an inline fallback when
  native subagents are unavailable.

Fixes found on the way:
- The two publish commands hardcoded `.agents/skills/impeccable/scripts/` while
  the other thirteen commands in live.md use {{scripts_path}}. Correct only for
  the Codex repo-skills bundle; it would have pointed Claude Code at a directory
  its install never creates. The shipped .codex variant was already internally
  inconsistent. Now covered by a test.
- `--agent=codex` resolved to the canned fake agent, because the flag parsed as
  `x === 'llm' ? 'llm' : 'fake'`. The private evals Live runner passes exactly
  that, so a real-harness run would have scored deterministic stub variants and
  reported them as Codex output. Unknown values for --agent, --scenario and
  --delivery now fail loudly.
- live-reference tests now compile with each provider's real providerTags instead
  of hand-written lists, so a providers.js misconfiguration fails in tests rather
  than shipping.

Verified: progressive E2E green on vite8-react-plain against a real Vite server
and Chromium; every provider variant's publish and poll paths now agree; Cursor
and Gemini still compile to atomic only.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 15:43:34 -07:00
Paul BakausandClaude 79656d1ce8 Correct the prose-gate docs and write down the no-bump-in-a-PR rule
CLAUDE.md said the prose validator "deliberately skips skill/", which is only
half true and cost a build failure this week: validateProse skips it, but
validateSkillProse then scans skill/**/*.md and fails the build on em dashes plus
the phrases with no technical reading. Document both gates, which files each one
reads, and the line that actually matters in practice: an em dash in
skill/reference/*.md fails the build, one in a skill/scripts/*.mjs comment does
not. Each claim was checked against a real `bun run build`.

Also record that feature PRs do not bump manifest versions or add changelog
entries. It was not written down anywhere: not CLAUDE.md, not AGENTS.md, not the
PR template. CLAUDE.md's "Bump when: CLI code changes" reads as an instruction to
bump inside the PR that touches cli/, so say plainly that it names which
component a change belongs to rather than when to edit the manifest.

Put the rule in AGENTS.md too. That is the guide the agents opening PRs here
actually read, so a rule about PR hygiene living only in CLAUDE.md would not
reach them.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 15:17:25 -07:00
Paul BakausandClaude f148496f67 Let hooks ignore-value scope a rule to files, and stop churning the config
Fallout from suppressing the overlay's font-size findings: the narrowest
exception detector.ignoreValues supports was unreachable from the path the hook
tells the model to use, so the guidance steered to the blunt instrument instead.

- hook-admin's ignore-value now takes --file / --files / --file= / --files=,
  matching `impeccable ignores add-value`, which already had them. Without it the
  only file-scoped option was ignore-file, which silences every rule for a path
  permanently, including rules not yet written.
- A bare wildcard value is now refused with a message pointing at either --file
  or ignore-rule. Previously `ignore-value <rule> "*"` quietly wrote a
  project-wide suppression from a single file's finding.
- ignore-value keyed entries on rule+value only, so a second scope for the same
  rule overwrote the first instead of coexisting. Key on the file scope too.
- An unknown flag folded into the value: `ignore-value overused-font Inter
  --shard` stored "inter --shard", matched nothing, and reported success. Reject
  it, as the sibling command does.

Config churn: normalizeIgnoreValueEntries runs on every write and emitted keys as
rule, value, files, reason, createdAt while the config on disk uses createdAt
before reason. Any edit therefore rewrote every untouched entry (35 churned lines
for a one-line change). Pin the canonical order in both copies of the normalizer
and in ignores.mjs, and add a test that the two copies cannot drift apart.

Also point the hook's own footer and reference/hooks.md at the file-scoped form
first, and say plainly what ignore-file costs.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 15:06:08 -07:00
Paul BakausandClaude 331c2f2696 Scope design-system-font-size off the injected live overlay
live-browser.js builds a self-contained UI that renders over arbitrary host
pages, so its inline type scale is deliberately independent of DESIGN.md, which
documents the impeccable website's ramp. The rule fired 32 times there and is
the only rule that fires on that file.

Suppress it as a file-scoped value wildcard rather than via ignoreFiles: an
ignoreFiles glob would silence every rule for the file, and the overlay is real
user-facing chrome where a future contrast or side-tab finding should still be
heard. Scoped to this one file, so the rule keeps working everywhere else.

Written by hand because hook-admin's ignore-value cannot emit the `files` array
that detector.ignoreValues supports and existing entries already use.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 14:51:26 -07:00
Paul BakausandClaude 60c4fa25db Stop telling users a busy agent is disconnected
The agent-poll indicator tracks whether a poll is parked, which is the right
signal for "can steering reach the agent right now" and is why the flag itself
is left alone. But it goes quiet for two different reasons, and both got the
same copy: "Agent disconnected - run live-poll.mjs to connect".

Under the one-shot foreground polling that live.md calls the primary contract,
no poll is parked while the agent works, so the second reason is every normal
generation. For its whole duration the bar told the user a healthy session was
broken and advised them to start a poll loop that was already running.

Pick the copy from the live state, which the browser already tracks: GENERATING
and SAVING mean the agent holds work it was handed, so say it is working. Every
other state with no parked poll keeps the original, actionable wording. The
aria-label carries the same distinction, since the tooltip is mouse-only.

The text is derived at read time rather than cached, because the live state moves
between the 5s status polls and a finished generation would otherwise keep
reading "Agent is working" until the next one landed. Deriving it also keeps the
read out of setLiveState, which runs long before agentPollingConnected's
declaration and would hit its temporal dead zone.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 14:23:34 -07:00
Paul BakausandClaude 917d3afcf2 Route the last two benchmark scripts through the shared argv parser
Follow-up on review feedback. The previous commit consolidated four of the six
Live benchmark parsers and left these two on their own hand-rolled `arg()`,
which was the inconsistency the first pass was meant to remove.

- benchmark-live-control.mjs and benchmark-live-init.mjs parsed --iterations
  with Number(), so a non-numeric value became NaN and `index < NaN` ran the
  benchmark zero times before failing on the metrics file. They also accepted
  only the space-separated form, so --iterations=20 silently measured the
  default. Both now use parseArgs + positiveIntFlag, which throws on a value
  that was clearly meant as a number.
- benchmark-live-control.mjs read the metrics file with no handling for the case
  where the run produced nothing: a missing file surfaced as a raw ENOENT stack
  and a malformed line as a bare SyntaxError. Report both with a diagnostic
  naming the file and the env var that populates it.
- summarize() now reports a `samples` count and nulls instead of letting
  percentile() read past an empty array, where the NaN serialized to null and a
  report of nothing measured looked like a real measurement.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 14:13:32 -07:00
Paul BakausandClaude 4e381305e1 Fix source-safety, detector, and lock defects in Live polling work
Addresses the review findings on #371, plus several the bots did not catch.
All fixes have regression coverage that fails on the prior code.

Source corruption:
- Vue accept dropped valueless root attrs (disabled, v-cloak) and, worse,
  rewrote @click="x" as a literal click="x" DOM attribute, because the attr
  parser was name-anchored and skipped the sigil. Tokenize the whole Vue attr
  grammar and normalize shorthands so accept round-trips directives.
- --variant was interpolated unescaped into a RegExp, so --variant '.*' matched
  the original block first and reported a successful accept while silently
  restoring the original. Validate against the digits pattern the browser and
  the /events schema already enforce.
- --id reached path.join unvalidated, so --id ../../../../etc/evil wrote and
  read receipts outside the project. Hoist the existing safeSessionId check
  into impeccable-paths and apply it at every id-to-path sink.

Accept/lock correctness:
- Plain HTML/JSX accept and discard did not catch SOURCE_LOCKED, so contention
  exited non-zero with empty stdout and the agent got no JSON to retry on.
- Lock staleness was mtime-only and never read the pid it records: a holder
  whose critical section outran 60s had its live lock swept, admitting a second
  writer to the same file, while a crashed holder blocked accepts for a full
  60s. Decide staleness by owner liveness, and release only our own lock.

Detector:
- isNeutralColor only parses computed color forms, so routing authored CSS
  through it reported inset 4px 0 0 #000 / black / #e5e7eb as chromatic
  side-tab stripes. Add an authored-color neutrality test covering hex and
  named neutrals; the fixture had no literal-color cases at all.
- Rule line numbers were off by one for every rule after the first, and
  commented-out CSS was scanned as live rules.

Server:
- An error reply carries no sourceEventType, and inferSourceEventType returned
  undefined, which acknowledgePendingEvent treats as a wildcard: a stale
  generate worker's failure consumed the user's queued Accept, which then
  reached no agent and left the browser in SAVING forever.
- The generate preflight spawned live-wrap.mjs synchronously inside the request
  handler, freezing the single-threaded server for the whole scaffold (~7.6s
  measured on this repo, 15s ceiling) and stalling Accept/Discard/SSE. Make it
  async, claiming the lease before the first await so no event double-delivers.
- Every browser checkpoint was echoed back as variant_progress, so a Tune
  slider drag remounted the preview under the user's cursor and latched the
  *_reviewable phases from the wrong trigger. Gate on the reason.

Cleanup:
- Collapse four divergent benchmark argv parsers into scripts/lib/cli-args.mjs.
  Three silently misread flags: --iterations 20 benchmarked 5, --agent llm ran
  the fake agent, --median-target=0.4 used the default threshold.
- Drop a snapshot cache this branch made write-only (it grew per session for
  the server's lifetime and was never read), a dead exported reconcile helper,
  and the unused deferReply branch.

Prepared with AI assistance under maintainer direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 13:48:44 -07:00
Paul Bakaus c6ac34b929 Improve Live polling responsiveness and reliability
Restore foreground/background polling as the primary harness architecture, add progressive publication and framework-safe previews, and harden quality and regression coverage. The experimental app-server runtime is intentionally excluded.\n\nPrepared with AI assistance under maintainer direction.
2026-07-15 17:05:30 -07:00
Abdul WahabandGitHub 8259c28209 Fix light-mode command demo contrast (#370)
Scope dark compatibility rules to dark mode and guard the homepage and docs theme contracts.

AI-assisted-by: OpenAI Codex
2026-07-14 07:53:39 -07:00
dependabot[bot]andGitHub f2049c2b76 chore(deps): bump the bun-minor-and-patch group with 10 updates (#368)
AI assistance: validated and merged by Codex during the weekly dependency sweep.
2026-07-13 10:05:40 -07:00
Paul BakausandGitHub 630fc2682a Base sheriff stale clock on blocker age (#364) 2026-07-10 12:02:52 -07:00
Paul BakausandGitHub da99645a58 Add OpenAI plugin submission bundle (#363)
* Add OpenAI plugin submission bundle

Build a Codex-native OpenAI plugin with bundled hooks, public listing metadata, submission guidance, privacy coverage, and regression tests.

AI assistance: OpenAI Codex prepared and validated these changes under maintainer direction.

* Fix provider script command rendering

Replace heuristic rewrites across executable scripts with one explicit provider marker, render pinned shortcuts per target harness, and remove the personal email from the public publisher manifest.

Addresses automated review feedback on PR #363.

AI assistance: OpenAI Codex prepared and validated these changes under maintainer direction.
2026-07-09 17:09:13 -07:00
github-actions[bot] 4c5b3aa45a Sync generated provider output 2026-07-09 23:20:50 +00:00
51e5af258e Expand init to capture positioning, conversion, and proof context (#315)
* Add positioning and conversion questions to init flow

Expand init.md so PRODUCT.md captures audience splits, positioning,
and brand-register conversion/proof context before design work starts.

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

* Fix init over-inference by raising the evidence bar for skipping questions.

Sparse repos were letting the model treat weak guesses as settled answers; Step 3 now asks unless the codebase provides strong, explicit evidence.

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

* Improve init interview order and PRODUCT.md proof output shape.

Ask positioning in round 1, actively collect proof assets, and give Proof & conversion a plain bullet skeleton so generated PRODUCT.md stays lean.

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

* Fix init interview bundling and write-time padding, verified via harness runs

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

* Revert init reference follow-up rule to advisory wording on line 88

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

* Tighten init interview rules after harness runs: split register, options, prose

Settle split register before brand-only questions, require standalone emotions
and confirmed secondary audiences, forbid compound options, and keep PRODUCT.md
bold minimal.

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

* Ask brand-register init questions in magazine-editor voice, no skill jargon

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

* Fix init chat fallback to ask one question at a time

When no structured question tool exists, init should ask in chat with
lettered options and wait for each answer instead of dumping a list.

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

* Resolve init review comments: split purpose question, gate template section

Purpose and success are now separate questions, and docs-stated purpose
is framed as a hypothesis below the strong-evidence bar rather than a
competing always-ask rule. The PRODUCT.md template now tells product
register to omit the Conversion & proof section including its heading.

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

* Keep belief-sequence question out of skill jargon

Ask what visitors must believe in plain words; map the answer to the
template belief ladder in a parenthetical instead of leading with the term.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Abdul Wahab <abdulwahab@Abduls-MacBook-Pro-2.local>
2026-07-09 16:20:20 -07:00
Paul BakausandGitHub 0d1c34e9d0 Fix: support Node 22 CLI installs (#361)
Lower the CLI engine floor to Node 22.12 so npx no longer falls back to stale 2.x releases for Node 22/23 users.

Add Node 22.12 CI coverage while preserving the stable required test check, and document the 3.2.1 CLI release notes including the detector and installer fixes already waiting on main.

AI-assisted-by: Codex
2026-07-09 11:34:07 -07:00
Paul BakausandGitHub fb6d3e9791 Soften sheriff stale classification (#360) 2026-07-09 10:19:18 -07:00
Abdul WahabandGitHub e34e53f140 Fix docs UI polish (#358)
* Fix docs UI polish

* Add CI retrigger spacing

* Remove CI retrigger spacing

* Fix docs demo after panel light mode

* Revert "Fix docs demo after panel light mode"

This reverts commit 3b2ffd37af.

* Scope docs demo after panel by theme

* Use lacquer black for docs demo after panel

* Use lacquer token for docs demo after panel
2026-07-09 09:19:35 -07:00
github-actions[bot] 4e715d0f35 Sync generated provider output 2026-07-09 16:14:46 +00:00
f40e2f8f0a Add mechanical pre-scan for typeset and layout (#345)
* Add mechanical pre-scan for typeset and layout commands.

Introduce --scope filtering, layout/type rule scopes, DESIGN.md font-size validation, and pre-scan steps in the skill references so agents run detect before LLM judgment.

Fixes #149

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

* Add isolated sub-agent orchestration for typeset and layout pre-scans.

Run the mechanical detector and visual assessment in parallel sub-agents so deterministic findings cannot anchor LLM judgment, matching the critique pattern Paul requested on PR #345.

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

* Fix: reject bare --scope so detect never scans unscoped by mistake.

When --scope had no value, the CLI dropped the flag and ran a full scan instead of failing, which could silently use the wrong rule set during typeset/layout pre-scans.

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

* Fix: require both typeset and layout assessments in sub-agents.

Close a loophole where agents ran only the mechanical pre-scan inline by interpreting "running both" as permitting one inline assessment.

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

---------

Co-authored-by: Abdul Wahab <abdulwahab@Abduls-MacBook-Pro-2.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 08:29:21 -07:00
c11cc7b58c Route native projects to native command variants (audit, adapt) (#357)
* Route native projects to native command variants for audit and adapt

Follow-up to #269. The web audit.md and adapt.md carried "translate this
yourself" Platform notes, so a native invocation paid for the full web
file (~1.8k / ~2.6k tokens, mostly inapplicable) and did error-prone
run-time translation. Authored with AI assistance (Claude Code) under
maintainer direction.

- New reference/audit.native.md and reference/adapt.native.md: authored
  native content (VoiceOver/TalkBack, platform conformance, adaptivity
  dimensions; phone-to-tablet, platform-to-platform, web-to-native
  strategies). One variant per command covers ios, android, and
  adaptive; per-OS specifics stay in the platform refs Setup loads
  regardless.
- SKILL.src.md: Commands table lists the variants; Setup step 2 reads
  the variant instead of the web file when the platform is native.
- audit.md / adapt.md: Platform sections replaced with a one-line
  web-only guard pointing at the variant.
- animate.md / layout.md: Platform sections deleted; the Motion and
  Layout sections of the already-loaded platform refs carry that
  content. Web users now pay zero tokens for the platform axis in
  these files.
- Skill-behavior scenario 15 pins the route-instead behavior (passes
  live on claude-sonnet-4-6); CLAUDE.md documents the variant
  convention.

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

* Phrase command-reference routing as one rule, not rule-plus-exception

Copilot review catch: step 2 said "MUST read reference/<command>.md"
and then carved out the native variant, which invites loading both
files. Now a single rule: read the web reference or the table's native
variant, one file, not both. Scenario 15 re-verified live. Applied with
AI assistance (Claude Code) under maintainer direction.

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

* Anchor native runs in animate/layout, drop loaded-refs assumption

Review-thread fixes, applied with AI assistance (Claude Code) under
maintainer direction:

- Greptile: deleting the animate/layout Platform sections left native
  runs alone with web tooling instructions (CSS keyframes, GSAP, Grid,
  clamp()). Restore a one-line anchor in each pointing at the loaded
  platform reference's Motion / Layout section (~20 tokens, not the old
  restatements).
- Bugbot: audit.native.md and adapt.native.md asserted the platform
  refs were "already loaded in Setup", but the command reference loads
  at step 2, before step 5. Now they instruct: read the platform
  reference first if Setup hasn't already.

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

* Carry the native-variant rule into routing rules 2 and 3

Bugbot catch: Setup step 2 routed native projects to the variant, but
routing rules 2 and 3 (the operative text at command time) still said
to load the generic reference file. Both now reference the same
one-file variant rule. Applied with AI assistance (Claude Code) under
maintainer direction.

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

* Point animate/layout native anchors at the files, not "loaded" refs

Bugbot catch, same class as the variant wording fix: the anchor lines
said "the loaded platform reference" but command files load at step 2,
before the platform refs at step 5. Both anchors now name the files and
instruct reading them first if Setup hasn't already. Applied with AI
assistance (Claude Code) under maintainer direction.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 07:30:49 -07:00
3e38e595c7 Add platform axis (web / ios / android / adaptive) (#269)
* Add a platform axis (web / ios / android / adaptive) to the skill

Orthogonal to register: register decides whether design IS or SERVES the
product; platform decides the delivery target and which native conventions
apply. Set `## Platform` in PRODUCT.md; a missing field defaults to `web`,
so legacy projects are unaffected.

- extractPlatform() in skill/scripts/context.mjs (mirrors extractRegister);
  the CLI appends a NEXT STEP directive to read the native reference(s).
  `adaptive` (Flutter / RN / KMP shipping both iOS and Android) loads both
  ios.md and android.md.
- New reference/ios.md (Apple HIG distilled) and reference/android.md
  (Material 3 distilled); reference/web.md is a thin pointer. The native
  refs frame register's role as narrow: platform conformance is the bar,
  brand lives in the expressive layer the platform gives you, never by
  breaking the rails.
- Setup step 5 loads the native reference(s) when platform is native. Live
  mode and the detect CLI stay web-only, gated off ios/android/adaptive.
- init asks platform right after register; adapt/audit/animate/layout carry
  short platform divergence notes; all secondary spots thread `adaptive`.
- a11y stays in audit.md (loading it at design time makes output timid), so
  the native refs carry no Accessibility section; audit.md's Platform
  section owns native a11y.
- Tests: extractPlatform unit coverage + skill-behavior scenario 10
  (PRODUCT.md platform ios -> agent loads ios.md).

Source-first: only skill/, scripts/, tests/, CLAUDE.md, NOTICE.md, the
changelog and version are committed; the sync workflow regenerates the
provider trees and ./plugin on merge.

ios.md / android.md are distilled from the MIT-licensed
ehmo/platform-design-skills; attribution in NOTICE.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Address review: gate web tools on native platforms, drop version churn

Maintainer-review fixes applied with AI assistance (Claude Code), on top
of the rebased platform-axis commit:

- Design hook (post-edit and Cursor pre-edit) now resolves the project
  platform via loadContext + extractPlatform and skips its web rule scan
  for ios / android / adaptive projects, so React Native / Flutter code
  never draws web-shaped findings (new hook-lib resolveProjectPlatform /
  isNativePlatform helpers, covered by unit and subprocess tests).
- context.mjs CLI warns on an unrecognized ## Platform value (e.g. a
  toolchain name like `flutter`) instead of silently defaulting to web;
  extractRegister / extractPlatform now share extractSectionValue.
- Removed reference/web.md: nothing loaded it; CLAUDE.md carries the
  "web has no extra rulebook" explanation.
- init.md: skip live-mode config (Step 6) for native platforms; note the
  per-app PRODUCT.md pattern for repos shipping web + native.
- android.md: Material-everywhere apps that also ship on iPhone still
  owe iOS OS guarantees (safe areas, Reduce Motion, edge-swipe back).
- ios.md: reworded a design-time line that framed Dynamic Type as an
  accessibility check (a11y stays owned by audit.md).
- Renumbered the new skill-behavior scenario to 14 after main's 10-13;
  updated CLAUDE.md scenario list; added android + unrecognized-value
  CLI test cases.
- No version or changelog changes: versioning happens at release time.

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

* Tighten platform reference prose

Editorial pass on the platform-axis text, applied with AI assistance
(Claude Code) under maintainer direction:

- ios.md / android.md rewritten to house style: single-line paragraphs
  (no hard wraps), one-sentence scope intro, deduplicated intro/slop-test,
  register-compression down to two sentences. In-file attribution
  paragraphs removed (NOTICE.md owns attribution); "read on top of the
  register reference" cruft removed (SKILL step 5 and the context.mjs
  directive already say it). Bans sections dropped: they restated the
  rules above them; the two additive items (tab-bar overload,
  hover-dependent affordances) folded into rules. ~40% smaller each.
- Sub-command Platform sections (adapt, audit, animate, layout), SKILL
  step 5, init.md platform prose, and the context.mjs directive trimmed
  the same way.

Build (prose validators, counts) and both test runners green.

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

* Treat an empty PRODUCT.md section as absent, not the next heading

Copilot review catch: extractSectionValue read the next `## ...` heading
as the section value when a field was left empty, which made the CLI
warn "value `## Product Purpose` is not recognized". Stop at the next
heading and return null instead. Regression tests for extractPlatform,
extractRegister, and the CLI warning path. Applied with AI assistance
(Claude Code) under maintainer direction.

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

* Only read a token list of both native targets as adaptive

Bugbot catch: after the exact platform tokens failed, any Platform line
containing the words ios and android was classified adaptive, so
negated or explanatory prose ("web only, not ios or android") silently
loaded both native refs and skipped the hook, with no warning. The
combo parse now accepts only list separators and the two platform
words; anything else falls through to the CLI's unrecognized-value
WARNING. Regression tests added. Applied with AI assistance (Claude
Code) under maintainer direction.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Paul Bakaus <paul.bakaus@gmail.com>
2026-07-08 17:11:31 -07:00
Paul BakausandGitHub 149396d91f Add PR sheriff automation (#356)
* Add GitHub sheriff test coverage

* Fix sheriff bot review feedback

* Make sheriff maintainer waits explicit

* Fix sheriff waiting label edge cases

* Fix stale review blockers in sheriff

* Fix sheriff contributor commit detection
2026-07-08 13:35:27 -07:00
Doan Bac TamandGitHub a5310c9cda Add Grok Build install instructions to README (#306)
* Add Grok Build install instructions to README

* Trim Grok install docs to match Claude plugin style
2026-07-07 18:50:54 -07:00
Paul BakausandGitHub 0417d2014e Add issue-first contribution guardrails (#353) 2026-07-07 18:26:20 -07:00
github-actions[bot] 18dec816de Sync generated provider output 2026-07-08 01:02:58 +00:00
1a46353b29 Don't force init on scoped commands when PRODUCT.md is missing (#277)
* Don't force init on scoped commands when PRODUCT.md is missing

Setup step 1 told the agent: "If it reports NO_PRODUCT_MD, stop and
follow reference/init.md before doing anything else." For a project with
no PRODUCT.md, that turned every scoped request (polish, critique, audit,
layout, ...) into a full from-scratch init detour. The user asks to
polish one button and the skill instead starts writing PRODUCT.md from
the beginning. Faced with that gate, agents also frequently abandon the
command and do an ad-hoc pass without loading the command reference.

Make the gate command-aware. A missing PRODUCT.md still routes into init
for the from-scratch build flows where captured product context is the
point (init, craft, shape). For any other command, a scoped request
against existing code, the code is the context: proceed with the
requested command, infer the register from the surface in focus, and
offer /impeccable init once as a suggestion rather than a blocker.

- skill/SKILL.src.md: rewrite the step 1 NO_PRODUCT_MD rule; reconcile
  the no-argument routing rule so it leads the menu with init instead of
  silently jumping into it; extend the craft init-then-resume footnote to
  cover shape, now also a from-scratch flow.
- skill/scripts/context.mjs: soften the NO_PRODUCT_MD message to defer to
  the step 1 rule instead of "Stop the current task"; refresh the stale
  file-level JSDoc that still described the old empty-stdout signal.
- tests/skill-behavior/scenarios.test.mjs: add scenario 10 (scoped
  command + no PRODUCT.md proceeds without forcing init) and scenario 11
  (shape + no PRODUCT.md still diverts into init). Scenario 1 (craft
  diverts) stays green and pins the build path.

Source-only per repo convention; provider and plugin copies are
regenerated by the maintainer's build:skills sync.

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

* Fix missing-context routing for build intent

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Paul Bakaus <paul.bakaus@gmail.com>
2026-07-07 18:02:30 -07:00
github-actions[bot] e813a16d22 Sync generated provider output 2026-07-08 00:25:53 +00:00
49ae0384b9 Fix live variant cycling hydration mismatch on SSR frameworks (#287) (#288)
* Fix live variant cycling hydration mismatch on SSR frameworks

Drive variant visibility and range/toggle --p-* custom properties through
an injected session stylesheet instead of mutating hidden/style on
server-rendered variant divs. Fixes flaky nextjs-app-router expectConsoleClean
failures (issue #287), same pattern as scroll-anchor (#276) and pick-cursor (#286).

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

* Refactor variant-state stylesheet for readability

Extract named display constants (VARIANT_HIDE_DECL / VARIANT_SHOW_DECL) and
small variantStateSelector / variantParamDecls helpers so the rule-building is
self-documenting. Restore the scroll-lock comment to startScrollLock. No
behavior change; regression guards updated to match.

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

* Fix: keep variant-state stylesheet in sync on first-reveal and paramless cycle

Stop refreshParamsPanel from removing the injected variant-state sheet
during GENERATING first-reveal, and re-sync the sheet when cycling to a
paramless variant so stale --p-* rules do not persist. Harden the
updateVariantStateStylesheet guard to num == null || num < 1.

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

* Fix: apply tuned --p-* inline for client-mounted Svelte component variants

Svelte component sessions mount into [data-impeccable-component-mount]
with no [data-impeccable-variant="N"] wrapper for the state stylesheet to
target. Restore inline --p-* on the client-mounted element for range/toggle
params while keeping the SSR div path on the injected stylesheet.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 17:25:22 -07:00
github-actions[bot] 1d8f051454 Sync generated provider output 2026-07-08 00:24:07 +00:00
Abdul WahabandGitHub ca121aa35f Fix: file-scoped wildcard ignores suppress non-value-bearing rules (#296) (#309)
A file-scoped wildcard ignore (add-value <rule> "*" --file <glob>) silently no-op'd for rules with no extractable value, such as side-tab. isIgnoredFindingValue bailed on an empty value before the wildcard/file-scope branch could run.

Require a value only on the specific-value path; let the scoped wildcard match on rule + file. Mirrored in skill/scripts/hook-lib.mjs for CLI/hook parity.
2026-07-07 17:23:39 -07:00
Abdul WahabandGitHub a99bb976b7 Fix README case study link (#329) 2026-07-07 17:21:58 -07:00
Abdul WahabandGitHub cec76bb681 Polish website spacing and control alignment (#331)
* Fix designing live context alignment

* Center dark theme toggle icon

* Tighten designing avoid list spacing

* Tighten remaining site marker spacing
2026-07-07 17:21:15 -07:00
0e6e888932 Fix designing phase nav jump (#337)
Co-authored-by: Abdul Wahab <abdulwahab@Abduls-MacBook-Pro-2.local>
2026-07-07 17:20:32 -07:00
c775e03c1d Fix Pi global install path (#338)
* Fix Pi global install path

* Simplify Pi skills-path helpers and consolidate tests

One userProviderSkillsDir helper owns the HOME_SKILLS_DIR_OVERRIDES
lookup, read paths share existingSkillsDirs, and the five Pi install
tests collapse into two that keep the same coverage: global detection
plus the agent-path write, and project scope in a home-rooted repo.

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

* Respect requested scope when resolving Pi skills dirs

An explicit install scope now narrows providerSkillsDirCandidates to
the matching layout, so a project-scope install in a home-rooted repo
no longer matches an existing global Pi install and get swallowed by
the already-installed refresh path. Update/check flows still probe
both layouts since they have no scope. Covers the T-Rex repro in the
home-rooted regression test.

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

* Refresh every existing Pi layout on unscoped update

deduplicateProviders keeps one entry per existing layout instead of
only the first, so unscoped check/update refresh both ~/.pi/agent/skills
and ~/.pi/skills when a home-rooted repo holds copies in each. Home-dir
detection now compares realpaths, since findProjectRoot resolves
symlinks while homedir() does not.

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

---------

Co-authored-by: Abdul Wahab <abdulwahab@Abduls-MacBook-Pro-2.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 17:19:13 -07:00
github-actions[bot] 0092df907b Sync generated provider output 2026-07-08 00:18:17 +00:00
Abdul WahabandGitHub 7b2c2a1f23 Fix Impeccable setup path guidance (#341) 2026-07-07 17:17:48 -07:00
github-actions[bot] 3cfa1dfaa2 Sync generated provider output 2026-07-08 00:16:39 +00:00
Dustin PersekandGitHub 9f49cb85cc Fix Google Fonts css2 family parsing (#349) 2026-07-07 17:16:11 -07:00
Abdul WahabandGitHub 60d32e1e58 Fix DeepSeek Svelte live submit assertion (#342) 2026-07-07 17:11:04 -07:00
e199cd92f3 Fix Neo Mirai agenda timeline (#343)
* Fix Neo Mirai agenda timeline alignment

* Fix Neo Mirai manifesto action icon

---------

Co-authored-by: Abdul Wahab <abdulwahab@Abduls-MacBook-Pro-2.local>
2026-07-07 17:08:33 -07:00
7190295f3d Fix designing phase nav and wheel layering (#348)
Co-authored-by: Abdul Wahab <abdulwahab@Abduls-MacBook-Pro-2.local>
2026-07-06 14:18:05 -07:00
github-actions[bot] 410552b00e Sync generated provider output 2026-07-06 21:17:52 +00:00
95b67ffa83 Add configurable detector extensions for server-side templates (#347)
* Add configurable detector extensions for server-side templates (#316)

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

* Use imperative voice for detector.extensions guidance in hooks.md

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

* Route html-engine extensions through detectHtml in the Cursor pre-write gate

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

* Prefer the longest matching suffix in matchConfiguredExtension

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

---------

Co-authored-by: Abdul Wahab <abdulwahab@Abduls-MacBook-Pro-2.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 14:17:23 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
9927634d4f chore(deps): bump the bun-minor-and-patch group with 10 updates (#350)
Bumps the bun-minor-and-patch group with 10 updates:

| Package | From | To |
| --- | --- | --- |
| [@ai-sdk/anthropic](https://github.com/vercel/ai/tree/HEAD/packages/anthropic) | `4.0.7` | `4.0.8` |
| [@ai-sdk/openai](https://github.com/vercel/ai/tree/HEAD/packages/openai) | `4.0.7` | `4.0.8` |
| [@anthropic-ai/claude-agent-sdk](https://github.com/anthropics/claude-agent-sdk-typescript) | `0.3.195` | `0.3.201` |
| [@anthropic-ai/sdk](https://github.com/anthropics/anthropic-sdk-typescript) | `0.107.0` | `0.110.0` |
| @paper-design/shaders | `0.0.76` | `0.0.77` |
| [ai](https://github.com/vercel/ai/tree/HEAD/packages/ai) | `7.0.14` | `7.0.16` |
| [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) | `7.0.3` | `7.0.6` |
| [motion](https://github.com/motiondivision/motion) | `12.42.0` | `12.42.2` |
| [wrangler](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/wrangler) | `4.105.0` | `4.107.0` |
| [puppeteer](https://github.com/puppeteer/puppeteer) | `25.2.1` | `25.3.0` |


Updates `@ai-sdk/anthropic` from 4.0.7 to 4.0.8
- [Release notes](https://github.com/vercel/ai/releases)
- [Changelog](https://github.com/vercel/ai/blob/main/packages/anthropic/CHANGELOG.md)
- [Commits](https://github.com/vercel/ai/commits/@ai-sdk/anthropic@4.0.8/packages/anthropic)

Updates `@ai-sdk/openai` from 4.0.7 to 4.0.8
- [Release notes](https://github.com/vercel/ai/releases)
- [Changelog](https://github.com/vercel/ai/blob/main/packages/openai/CHANGELOG.md)
- [Commits](https://github.com/vercel/ai/commits/@ai-sdk/openai@4.0.8/packages/openai)

Updates `@anthropic-ai/claude-agent-sdk` from 0.3.195 to 0.3.201
- [Release notes](https://github.com/anthropics/claude-agent-sdk-typescript/releases)
- [Changelog](https://github.com/anthropics/claude-agent-sdk-typescript/blob/main/CHANGELOG.md)
- [Commits](https://github.com/anthropics/claude-agent-sdk-typescript/compare/v0.3.195...v0.3.201)

Updates `@anthropic-ai/sdk` from 0.107.0 to 0.110.0
- [Release notes](https://github.com/anthropics/anthropic-sdk-typescript/releases)
- [Changelog](https://github.com/anthropics/anthropic-sdk-typescript/blob/main/CHANGELOG.md)
- [Commits](https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.107.0...sdk-v0.110.0)

Updates `@paper-design/shaders` from 0.0.76 to 0.0.77

Updates `ai` from 7.0.14 to 7.0.16
- [Release notes](https://github.com/vercel/ai/releases)
- [Changelog](https://github.com/vercel/ai/blob/main/packages/ai/CHANGELOG.md)
- [Commits](https://github.com/vercel/ai/commits/ai@7.0.16/packages/ai)

Updates `astro` from 7.0.3 to 7.0.6
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/astro@7.0.6/packages/astro)

Updates `motion` from 12.42.0 to 12.42.2
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.42.0...v12.42.2)

Updates `wrangler` from 4.105.0 to 4.107.0
- [Release notes](https://github.com/cloudflare/workers-sdk/releases)
- [Commits](https://github.com/cloudflare/workers-sdk/commits/wrangler@4.107.0/packages/wrangler)

Updates `puppeteer` from 25.2.1 to 25.3.0
- [Release notes](https://github.com/puppeteer/puppeteer/releases)
- [Changelog](https://github.com/puppeteer/puppeteer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/puppeteer/puppeteer/compare/puppeteer-v25.2.1...puppeteer-v25.3.0)

---
updated-dependencies:
- dependency-name: "@ai-sdk/anthropic"
  dependency-version: 4.0.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: bun-minor-and-patch
- dependency-name: "@ai-sdk/openai"
  dependency-version: 4.0.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: bun-minor-and-patch
- dependency-name: "@anthropic-ai/claude-agent-sdk"
  dependency-version: 0.3.201
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: bun-minor-and-patch
- dependency-name: "@anthropic-ai/sdk"
  dependency-version: 0.110.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: bun-minor-and-patch
- dependency-name: "@paper-design/shaders"
  dependency-version: 0.0.77
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: bun-minor-and-patch
- dependency-name: ai
  dependency-version: 7.0.16
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: bun-minor-and-patch
- dependency-name: astro
  dependency-version: 7.0.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: bun-minor-and-patch
- dependency-name: motion
  dependency-version: 12.42.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: bun-minor-and-patch
- dependency-name: wrangler
  dependency-version: 4.107.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: bun-minor-and-patch
- dependency-name: puppeteer
  dependency-version: 25.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: bun-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 10:08:37 -07:00
github-actions[bot] 88f52ac4e6 Sync generated provider output 2026-07-06 00:12:08 +00:00
751ec31dd6 Fix: stop the design hook from creating .impeccable/ in unrelated projects (#346)
The PostToolUse hook was writing hook.cache.json after every edit, even
when nothing was scanned or recorded. Gate the persist to earned writes
only, and key the cache to the edited file's project root when the
session starts from an umbrella directory.

Fixes #344, #305

Co-authored-by: Abdul Wahab <abdulwahab@Abduls-MacBook-Pro-2.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 17:11:39 -07:00
Paul BakausandGitHub 582f23eae3 Bump AI SDK packages to v7 (#336)
* Bump AI SDK packages to v7

* Use AI SDK v7 responseMessages in skill behavior harness
2026-07-03 19:21:20 -07:00
github-actions[bot] a20bbfc752 Sync generated provider output 2026-07-04 02:15:49 +00:00
7501e67b55 Fix live toast stale callback race (#271)
Co-authored-by: Jean-Claude <273834277+jjoanna2-debug@users.noreply.github.com>
2026-07-03 19:15:20 -07:00
dependabot[bot]andGitHub 9798bb7235 chore(deps): bump actions/cache from 5 to 6 (#325)
Bump actions/cache from v5 to v6 in CI cache steps.
2026-07-03 18:01:21 -07:00
dependabot[bot]andGitHub 67e73f47a6 chore(deps): bump the bun-minor-and-patch group with 7 updates (#320)
Bump the bun-minor-and-patch dependency group with 7 updates.
2026-07-03 18:01:06 -07:00
Paul BakausandClaude Opus 4.8 1fe9c41759 Replace Alumni Sans Pinstripe with Alumni Sans across the type system
The Pinstripe display face was single-weight, so every `font-weight` on it
was inert — the documented h1/h2 weight split never actually rendered.
Switch --ks-font-display (and --ks-font-wordmark) to plain Alumni Sans, which
honors weight, and set the display scale intentionally:

- Display / h1  -> weight 100 (thin hairline hero)
- Headline / h2 -> weight 300 via --ks-type-headline-weight (light anchor)
- Wordmark 400, body 400, title 500 unchanged

Centralize h2 weight: the eight section-title sites that hardcoded 600 now
read var(--ks-type-headline-weight), so h2 weight is a single lever.

Google Fonts now loads Alumni Sans wght@100;300;...;700 and no longer pulls
the Pinstripe family. DESIGN.md, design.json, and the token/CSS comments are
updated to match (family, weights, Two-Face and Weight-Inversion rules).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 17:32:47 -07:00
44c27a72af Fix Codex plugin hook load failure; bump skill to 3.9.1 (#333)
Codex loads bundled plugin lifecycle hooks from `hooks/hooks.json` using a
strict schema that accepts only the top-level `hooks` field. The
plugin-packaged manifest carried a top-level `description`, so Codex rejected
the whole manifest with `unknown field description, expected hooks` and the
post-edit design detector never registered (issue #330).

Drop `description` from `buildClaudePluginHooksManifest()` and regenerate
`plugin/hooks/hooks.json`. The Claude Code plugin path is unaffected (it only
reads the `hooks` object). Add a regression assertion for the plugin artifact
and bump the skill version to 3.9.1.


Claude-Session: https://claude.ai/code/session_013GTTHY6uHwESUyAgjUEm7x

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-01 11:03:55 -07:00
Paul BakausandClaude Opus 4.8 a82f02d1a1 Fix skill release tweet CTA to npx impeccable install
The generated skill-release tweet pointed at the deprecated
`npx skills add pbakaus/impeccable`; the canonical install/update path
is `npx impeccable install`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 01:58:34 -07:00
Paul BakausandClaude Opus 4.8 e83e437cdd Release prep: skill v3.9.0, CLI v3.2.0
Bump skill 3.8.0 -> 3.9.0 (plugin.json, marketplace.json, plugin/ subtree,
regenerated provider harness output) and CLI 3.1.0 -> 3.2.0 (package.json).

Changelog (site/pages/changelog.astro):
- Skill v3.9.0: codex grid-background ban, /impeccable bolder design-system
  lock, critique sub-agent independence on non-Claude/Codex harnesses,
  bundled helpers under strict-permission harnesses, Codex hook manifest fix.
- CLI v3.2.0: codex-grid-background detector rule, external skills-symlink
  preservation on first install.

Also: gitignore nested hook.cache.json/hook.pending.json copies (anchored
patterns missed the generated harness dirs), and repoint CLAUDE.md/AGENTS.md
changelog docs at changelog.astro with concise, user-facing-only tone guidance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 01:50:04 -07:00
github-actions[bot] f604d31d54 Sync generated provider output 2026-07-01 08:30:06 +00:00
f5c1bd65ae Add codex-grid-background detector rule (#328)
* Add codex-grid-background detector rule

Detects the Codex two-axis grid-line background tell: a single background
value carrying two or more hairline `linear-gradient(... 1px, transparent
1px)` layers (one per axis), usually paired with a repeating
`background-size` cell. Gated behind --gpt like the sibling codex tells,
off by default.

Counts hairline stops within a single background declaration (not across
the page) so unrelated single-axis ruled lines don't add up to a false
flag, and matches the stop directly rather than parsing whole gradient
layers, since colors like oklch(...) carry nested parens.

Extends the gpt-tells fixture with one flag case and two pass cases
(single-axis rule, two-color blend), regenerates the browser detector
bundle, and bumps the rule count 44 -> 45.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Require tiling background-size for codex-grid-background

Address review: two hairline gradients alone draw a fixed crosshair, not a
grid. Scope detection to a single style block (CSS rule body or inline
style attr) and require both >=2 hairline stops AND a tiling
`background-size` px cell in the same block, matching the skill rule's
"plus background-size" wording. Add a crosshair-without-tiling pass case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Scope codex-grid-background hairline count to background values

Address review: count hairline stops only inside background/background-image
declaration values, not the whole style block, so a hairline in an unrelated
property (mask-image, border-image) can't stand in for the grid's second
axis. Add a bg+mask-image hairline pass case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 01:29:40 -07:00
github-actions[bot] 5844c40177 Sync generated provider output 2026-07-01 07:13:03 +00:00
Paul Bakaus 9dc97ce648 small update to our own DESIGN.md 2026-07-01 00:12:32 -07:00
Paul Bakaus b3108c1697 Clarify bolder design-system boundaries 2026-07-01 00:12:32 -07:00
Paul Bakaus 4ac0348032 Add Codex grid background slop rule 2026-07-01 00:12:32 -07:00
github-actions[bot] 7f0262f809 Sync generated provider output 2026-07-01 06:56:53 +00:00
Paul Bakaus 1a3f5d78bd Fix Codex hook manifest schema 2026-06-30 23:56:06 -07:00
github-actions[bot] c979ac37c3 Sync generated provider output 2026-06-29 07:31:56 +00:00
Paul Bakaus bcd16381cf harden critique so that it runs in sub-agents more often in harnesses other than Claude and Codex 2026-06-29 00:31:25 -07:00
Paul Bakaus 19e0174da2 update HARNESSES.md with latest updates/imfo 2026-06-29 00:31:25 -07:00
KamranandGitHub 88227f7935 Add README .gitignore snippet for ephemeral .impeccable output (#314)
* Add .gitignore seeding to init for ephemeral .impeccable output

Init now runs ensure-gitignore.mjs to write a marked block to the shared, committed .gitignore so screenshots, live session/preview/cache dirs, hook caches, and per-dev config.local.json never pollute git status across the team. Shared artifacts (config.json, live/config.json, design.json, critique/*.md) stay tracked. Unlike the existing hook/live runtime helpers, which write machine-local .git/info/exclude lazily, this targets .gitignore at init time so every clone is covered up front.

* Fix: unanchored patterns + git-aware tracking for init gitignore

Cursor Bugbot on PR #314 flagged two issues. (1) Patterns were root-anchored (/.impeccable/...) so they missed a nested monorepo .impeccable (apps/web/.impeccable/...); dropped the leading slash to match HOOK_LOCAL_IGNORE_PATTERNS / LIVE_IGNORE_PATTERNS. (2) detectTrackedArtifacts used fs.existsSync, reporting untracked/ignored files as committed; replaced with git ls-files based analyzeTracked that returns gitAvailable, tracked (confirmed shared artifacts), and needsUntrack (committed ephemeral files -> git rm --cached candidates). init Step 7 wording updated to match.

* Pivot to docs-only .gitignore snippet per maintainer feedback

Reverts the automated init Step 7 and the ensure-gitignore.mjs helper/script tests. Adds a copy-paste .gitignore block to the README instead, covering ephemeral .impeccable/ output (screenshots, live session/preview/cache dirs, hook caches, per-dev config.local.json) while keeping shared artifacts (config.json, live/config.json, design.json, critique/*.md) tracked. Patterns are unanchored so they also cover a nested monorepo .impeccable under apps/web/.
2026-06-28 21:04:09 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Paul Bakaus
3590bf9e37 chore(deps-dev): bump astro from 6.4.7 to 7.0.0 (#292)
Bumps [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) from 6.4.7 to 7.0.0.
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/astro@7.0.0/packages/astro)

---
updated-dependencies:
- dependency-name: astro
  dependency-version: 7.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Paul Bakaus <paul.bakaus@gmail.com>
2026-06-25 17:51:01 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
616820dcff chore(deps): bump actions/checkout from 6 to 7 in the github-actions group
Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-25 17:35:15 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a4ff58ef51 chore(deps): bump the bun-minor-and-patch group with 9 updates
Bumps the bun-minor-and-patch group with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [@ai-sdk/anthropic](https://github.com/vercel/ai/tree/HEAD/packages/anthropic) | `3.0.84` | `3.0.85` |
| [@ai-sdk/google](https://github.com/vercel/ai/tree/HEAD/packages/google) | `3.0.82` | `3.0.83` |
| [@ai-sdk/openai](https://github.com/vercel/ai/tree/HEAD/packages/openai) | `3.0.71` | `3.0.74` |
| [@anthropic-ai/claude-agent-sdk](https://github.com/anthropics/claude-agent-sdk-typescript) | `0.3.178` | `0.3.185` |
| [@anthropic-ai/sdk](https://github.com/anthropics/anthropic-sdk-typescript) | `0.104.2` | `0.105.0` |
| [@google/genai](https://github.com/googleapis/js-genai) | `2.8.0` | `2.9.0` |
| [ai](https://github.com/vercel/ai/tree/HEAD/packages/ai) | `6.0.206` | `6.0.208` |
| [wrangler](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/wrangler) | `4.100.0` | `4.103.0` |
| [puppeteer](https://github.com/puppeteer/puppeteer) | `25.1.0` | `25.2.0` |


Updates `@ai-sdk/anthropic` from 3.0.84 to 3.0.85
- [Release notes](https://github.com/vercel/ai/releases)
- [Changelog](https://github.com/vercel/ai/blob/@ai-sdk/anthropic@3.0.85/packages/anthropic/CHANGELOG.md)
- [Commits](https://github.com/vercel/ai/commits/@ai-sdk/anthropic@3.0.85/packages/anthropic)

Updates `@ai-sdk/google` from 3.0.82 to 3.0.83
- [Release notes](https://github.com/vercel/ai/releases)
- [Changelog](https://github.com/vercel/ai/blob/@ai-sdk/google@3.0.83/packages/google/CHANGELOG.md)
- [Commits](https://github.com/vercel/ai/commits/@ai-sdk/google@3.0.83/packages/google)

Updates `@ai-sdk/openai` from 3.0.71 to 3.0.74
- [Release notes](https://github.com/vercel/ai/releases)
- [Changelog](https://github.com/vercel/ai/blob/@ai-sdk/openai@3.0.74/packages/openai/CHANGELOG.md)
- [Commits](https://github.com/vercel/ai/commits/@ai-sdk/openai@3.0.74/packages/openai)

Updates `@anthropic-ai/claude-agent-sdk` from 0.3.178 to 0.3.185
- [Release notes](https://github.com/anthropics/claude-agent-sdk-typescript/releases)
- [Changelog](https://github.com/anthropics/claude-agent-sdk-typescript/blob/main/CHANGELOG.md)
- [Commits](https://github.com/anthropics/claude-agent-sdk-typescript/compare/v0.3.178...v0.3.185)

Updates `@anthropic-ai/sdk` from 0.104.2 to 0.105.0
- [Release notes](https://github.com/anthropics/anthropic-sdk-typescript/releases)
- [Changelog](https://github.com/anthropics/anthropic-sdk-typescript/blob/main/CHANGELOG.md)
- [Commits](https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.104.2...sdk-v0.105.0)

Updates `@google/genai` from 2.8.0 to 2.9.0
- [Release notes](https://github.com/googleapis/js-genai/releases)
- [Changelog](https://github.com/googleapis/js-genai/blob/main/CHANGELOG.md)
- [Commits](https://github.com/googleapis/js-genai/compare/v2.8.0...v2.9.0)

Updates `ai` from 6.0.206 to 6.0.208
- [Release notes](https://github.com/vercel/ai/releases)
- [Changelog](https://github.com/vercel/ai/blob/ai@6.0.208/packages/ai/CHANGELOG.md)
- [Commits](https://github.com/vercel/ai/commits/ai@6.0.208/packages/ai)

Updates `wrangler` from 4.100.0 to 4.103.0
- [Release notes](https://github.com/cloudflare/workers-sdk/releases)
- [Commits](https://github.com/cloudflare/workers-sdk/commits/wrangler@4.103.0/packages/wrangler)

Updates `puppeteer` from 25.1.0 to 25.2.0
- [Release notes](https://github.com/puppeteer/puppeteer/releases)
- [Changelog](https://github.com/puppeteer/puppeteer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/puppeteer/puppeteer/compare/puppeteer-v25.1.0...puppeteer-v25.2.0)

---
updated-dependencies:
- dependency-name: "@ai-sdk/anthropic"
  dependency-version: 3.0.85
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: bun-minor-and-patch
- dependency-name: "@ai-sdk/google"
  dependency-version: 3.0.83
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: bun-minor-and-patch
- dependency-name: "@ai-sdk/openai"
  dependency-version: 3.0.74
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: bun-minor-and-patch
- dependency-name: "@anthropic-ai/claude-agent-sdk"
  dependency-version: 0.3.185
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: bun-minor-and-patch
- dependency-name: "@anthropic-ai/sdk"
  dependency-version: 0.105.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: bun-minor-and-patch
- dependency-name: "@google/genai"
  dependency-version: 2.9.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: bun-minor-and-patch
- dependency-name: ai
  dependency-version: 6.0.208
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: bun-minor-and-patch
- dependency-name: wrangler
  dependency-version: 4.103.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: bun-minor-and-patch
- dependency-name: puppeteer
  dependency-version: 25.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: bun-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-25 17:35:04 -07:00
da2cda06ed Point DESIGN.md spec links at open-source GitHub spec (#299)
* Point DESIGN.md spec links at the open-source GitHub spec.

The Stitch docs site is client-rendered and unreliable for agent fetch; the
google-labs-code/design.md repo tracks the latest machine-readable spec.

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

* Sync plugin and harness copies after DESIGN.md spec link update.

build:release copies skill/reference into plugin/ and all harness dirs, so
refresh those generated outputs here instead of leaving plugin/ stale.

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

* Use raw GitHub URL for DESIGN.md spec in agent-facing refs.

The blob URL serves HTML; raw.githubusercontent.com returns plain markdown
that agents can fetch directly.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 17:21:48 -07:00
Abdul WahabandGitHub 467efe4632 Fix: preserve external ~/.claude/skills symlink on first install (#295) (#308)
* Fix: preserve external skills symlink on first install (#295)

* Fix review comments: target-based in-project link detection (#295, #308)

- isInProjectProviderLink now inspects the symlink TARGET lexically instead of comparing shared realpaths, so two providers pointing at the same external dir are no longer misflagged as in-project (cursor High / greptile P1).
- A dangling in-project cross-provider link is now correctly replaced with a real per-provider dir (cursor Medium).
- Adds regression tests for both scenarios.
2026-06-25 17:21:02 -07:00
github-actions[bot] 2520317f94 Sync generated provider output 2026-06-26 00:17:03 +00:00
Abdul WahabandGitHub b7d2ad5589 Fix: allow skill's bundled node helpers under strict-permission harnesses (#301) (#310)
The skill declared only `Bash(npx impeccable *)` in allowed-tools, but Setup and the no-arg menu shell out to `node {{scripts_path}}/*.mjs`. Under a default-deny Claude Code allowlist those calls are blocked, so Setup fails on context.mjs.

Add a provider-aware `Bash(node {{scripts_path}}/*)` entry and resolve {{scripts_path}} in the frontmatter (the build previously substituted it only in the body). Provider-aware rather than the hardcoded `.claude/...` path the issue suggested, since five providers honor allowed-tools with different script dirs.
2026-06-25 17:16:32 -07:00
Paul BakausandClaude Opus 4.8 d2ab4ddee6 Make Copilot built-in note a callout block under the Install header
Promote the inline GitHub Copilot aside to a proper note block placed
directly under "Step 1. Install", with the Copilot glyph. Full hairline
frame + faint gold ground (no side-stripe, which the detector flags as the
side-tab tell); gold icon carries the accent. Add a reusable .docs-note
style to docs-kinpaku.css so it tracks the docs theme tokens.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:01:16 -07:00
Paul BakausandClaude Opus 4.8 a031d5de92 Add GitHub Copilot app built-in note to setup guide
The Get started section tells Copilot-app users the skill is built in
(enable under Settings → Experimental) so they skip a needless install;
the setup guide's Step 1 only listed Copilot as an npx install target.
Add the matching note right after the install command for consistency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:20:59 -07:00
Paul BakausandClaude Opus 4.8 867fab2188 Redesign Get started install block as tabbed method selector
Replace the static Install/First run/Update boxes with a tabbed "Install
via" selector (impeccable / marketplace / skills.sh). Switching a tab swaps
the install and update commands together, with a per-method note.

- impeccable tab marked recommended with a gold star; carries a Node 24+
  requirement and a collapsed "Why one command, many builds" diagram that
  animates impeccable branching per harness. The diagram foregrounds the
  model-specific slop rules compiled into the Gemini and Codex builds
  (verified against skill/SKILL.src.md provider tags).
- GitHub Copilot is built into the app, so it's a quiet de-boxed callout
  under the tabs rather than a tab, catching Copilot users before they
  install something they don't need.
- Add claude-mark.png (transparent-background Claude starburst) for the
  marketplace tab.
- Tabs baseline-align with the "INSTALL VIA" label; diagram scales and the
  tablist wraps cleanly on mobile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 01:23:23 +09:00
Paul BakausandClaude Opus 4.8 609bbfbd5b Update GitHub star counter to 40k
Repo passed 40k stars (40,008).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 05:42:51 +09:00
Paul BakausandClaude Opus 4.8 da18929df0 Release skill v3.8.0 and CLI v3.1.0
Bump skill to 3.8.0 (GitHub Copilot design hooks, monorepo-aware
context) and CLI to 3.1.0 (inline detector ignore comments, fail-loudly
on unknown subcommands). Add changelog entries and sync generated
provider output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 05:41:24 +09:00
github-actions[bot] a110ec5ed7 Sync generated provider output 2026-06-21 13:01:51 +00:00
8eedb150c5 Fix React hydration mismatch from live pick-cursor class on SSR roots (#286)
* Fix React hydration mismatch from live pick-cursor class on SSR roots

Entering pick mode toggled a `impeccable-live-pick-cursor` class on
`document.documentElement` (and the insert-axis cursor wrote an inline
`style.cursor` on it). `<html>`/`<body>` are server-rendered by frameworks
like Next.js App Router, so a client-only attribute the server HTML never
emitted makes React 19 log "a tree hydrated but some attributes of the server
rendered HTML didn't match" on the next Fast-Refresh re-render. It surfaced as
a console.error that flaked the nextjs-app-router live-e2e fixture's
expectConsoleClean probe.

This is the same root-cause class as the scroll-anchor lock fixed in #276
(client mutation of a hydrated SSR root), but a separate offender that fix did
not cover. Apply the same shape: drive the pick / insert cursor entirely
through the textContent of one injected `<style>` keyed by PICK_CURSOR_STYLE_ID,
never by a class or inline style on `<html>`. Same computed effect (global
`cursor` rule, reverted inside the overlay chrome), recreated on activation and
removed on teardown.

Regression guard updated to pin the new shape: no
`document.documentElement.classList.*` mutation anywhere in the overlay, the
cursor applied through the injected style, and the style removed by id on exit.

Verified end-to-end: the nextjs-app-router live-e2e fixture now passes the full
click -> Go -> cycle -> accept cycle with a clean console.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Remove now-dead pageInteractionCursorActive flag

The flag's only reader was the old inline-style cleanup branch in
syncPageInteractionCursor, which the stylesheet refactor removed. It is now
write-only, so drop the declaration and both writes (Greptile review). No
behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 22:01:17 +09:00
github-actions[bot] 55d11fb2ad Sync generated provider output 2026-06-21 12:42:04 +00:00
776c019041 Add inline, in-file ignore comments for the detector (#283) (#285)
* Add inline, in-file ignore comments for the detector (issue #283)

Complement config ignores with eslint-disable-style waivers that live where
they apply and travel with the file when it leaves the repo. The motivating
case is a generated/exported standalone document that legitimately uses a
first-party brand typeface (on the overused-font list) and is later scanned
without .impeccable/config.json present.

Marker is comment-syntax-agnostic (works in //, /* */, <!-- -->, #, {/* */}):

  impeccable-disable <rule>[, <rule>...] [-- reason | : reason]   whole file
  impeccable-disable-line <rule>...                               same line
  impeccable-disable-next-line <rule>...                          next line

Bare directive or * means every rule; reason is optional and discarded at
scan time. Behavior is suppression, for parity with config ignores.

Implementation:
- New pure module cli/engine/shared/inline-ignores.mjs (parser + filter, no
  Node deps). Static-HTML findings have no line number, so only whole-file
  directives apply there -- exactly the standalone-document case; the
  regex/text engine additionally honors the line-scoped forms.
- Wired into detectText and detectHtml, gated by options.inlineIgnores.
- detect CLI applies inline ignores by default; --no-inline-ignores skips
  just them, --no-config skips config and inline ignores together.

Docs: config.md (new section), detector.md, README. skill/reference/hooks.md
reversed its prior "inline comments are not supported" guidance and now points
the agent to inline waivers for the travels-with-the-file case. Changelog 3.x.

Tests: tests/inline-ignores.test.mjs (parser units, detectText/detectHtml
integration, CLI end-to-end), registered in the detector suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Reconcile design hook wording with inline ignores

Two hook-side fixes prompted by review of the new inline-ignore feature:

1. Clean-ack steer line. The old line ("Keep typography hierarchy, spacing
   rhythm, and color contrast intentional on the next change.") read as an
   odd non-sequitur after "No anti-patterns." Reworded the whole clean ack to
   say what it means: a clean scan only clears the deterministic rule set, not
   overall design quality, so keep following the design system and skill
   guidance. Now: "Design hook scanned X. No deterministic design-quality
   issues found. That does not mean the design is good: keep following the
   project design system and the impeccable skill guidance."

2. Directive footer. It still told the agent "Do not add source comments such
   as `impeccable: ignore`; those pollute the code and do not suppress hook
   findings." That is now misleading: the hook runs the same detector engine
   as the CLI, which honors inline `impeccable-disable` waivers, so they DO
   suppress hook findings (consistent with config ignores, which filterFindings
   already honors). Reworded to: don't silence a real finding to skip fixing
   it; suppress only after the user confirms intent; prefer a config ignore,
   and reach for an inline `impeccable-disable <rule>` comment only when the
   waiver must travel with a file that leaves the repo.

Added a hook test asserting an inline `impeccable-disable-line` comment makes
the hook scan the file clean (locks in the cross-cutting behavior), and updated
the clean-ack / footer assertions to the new wording.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Address review on inline-ignores parser

- Case-insensitive fast-path bail-out (Cursor): the cheap substring guard was
  lowercase-only while DIRECTIVE_RE has the `i` flag, so a mixed-case marker
  like `Impeccable-Disable` skipped parsing entirely and never suppressed.
  Switched the guard to `/impeccable-disable/i.test(...)`. Added a regression
  test.
- Removed the unreachable `-->` branch from TRAILING_CLOSER_RE (Greptile):
  `--+>` already matches `-->` and any longer dash run.
- Replaced the always-truthy lazy-match + `if (sep)` reason strip with an
  explicit first-separator slice (Greptile): clearer and drops the dead branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Align inline-ignore line numbering with the detector (CRLF/CR endings)

parseInlineIgnores split lines with /\r\n|\r|\n/, but detectText numbers lines
with split('\n'). On classic `\r`-only endings the two diverged, so a
disable-line / disable-next-line directive could key a different line than the
finding it should waive (Cursor review). Split on '\n' only, matching the
detector exactly; the directive regex already excludes '\r', so a trailing '\r'
on CRLF files is never captured into the rule list. Added a CRLF regression test
through the real detectText.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:41:36 +09:00
github-actions[bot] 68a15b6be4 Sync generated provider output 2026-06-20 15:00:27 +00:00
42be79eab5 Fix monorepo target-selection edge cases from #213 review (#282)
Two Cursor Bugbot Medium findings on the merged monorepo context PR:

- Excluded packages still listed: discoverTargetCandidates added every glob
  match but never applied negated workspace patterns, so an excluded package
  (e.g. "!packages/internal") showed up as a selectable target even though
  resolveWorkspaceProjectRoot sends it back to the repo root. Now filtered
  with the same isExcludedByWorkspacePattern check the resolver uses.
- Empty app list blocks root: resolveTargetSelection returned
  TARGET_SELECTION_REQUIRED whenever projectRoot === repoRoot, even with zero
  discoverable child apps (e.g. `workspaces: ["."]`), leaving an unanswerable
  prompt. It now returns null (use the repo root as the project) when there
  are no candidates.

Also documents two Greptile P2 clarity notes (the four contextSourceStatus
labels incl. the dual meaning of 'fallback', and the deliberate
isMonorepoRoot-before-hasGitBoundary ordering in findMonorepoRoot).

Adds regression tests for both behaviors.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 07:59:56 -07:00
github-actions[bot] 1e4e74793a Sync generated provider output 2026-06-20 10:50:06 +00:00
Abdul WahabandGitHub 0306b41949 Add monorepo context support (#213)
Context files (PRODUCT.md / DESIGN.md) resolve child-first then fall back to the repo root, and /impeccable live lets the user pick a child app in a monorepo. Single-app behavior is unchanged. Closes #202. Co-Authored-By: abdulwahabone
2026-06-20 19:49:37 +09:00
Abdul WahabandGitHub f1e9b3df3a Fix: fail loudly on unknown CLI subcommands (#270)
Unknown/mistyped CLI subcommands now print 'Unknown command' and exit non-zero instead of silently routing to the detector. Closes #266. Version bump and changelog entry deferred (batching). Co-Authored-By: abdulwahabone
2026-06-20 19:34:38 +09:00
2f9dc05978 Give GitHub Copilot equal prominence in harness listings (#280)
Audit of every user-facing surface that enumerates supported harnesses
found GitHub Copilot missing or buried in several. Bring it to parity with
Claude Code, Codex, Cursor, and Gemini.

Missing -> added:
- site/content/reference/hooks.md: the public /docs/hooks page (tagline,
  the post-edit list, and the manifest table) now covers GitHub Copilot,
  including the `.github/hooks/impeccable.json` surface and the
  default-branch/trust note. (Only skill/reference/hooks.md was updated in
  the feature PR; this is the website doc.)
- README.md Design hook section + the manifest surface list.
- site/content/tutorials/getting-started.md hook note.
- site/pages/faq.astro tool-specific setup list and the docs-links list.
- PRODUCT.md audience line and README.npm.md suite description.

Prominence + naming:
- README "Supported Tools" and the homepage hero logo row: move GitHub
  Copilot up to third (after Claude Code) instead of trailing.
- site/pages/designing: list GitHub Copilot earlier, full name.
- README "Supported Tools": the harness link now points at GitHub Copilot
  (github.com/features/copilot) instead of the unrelated VS Code entry.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 03:27:06 -07:00
github-actions[bot] 221064858e Sync generated provider output 2026-06-20 09:24:47 +00:00
41ff946121 Add GitHub Copilot hook support (CLI + cloud agent) (#279)
* Add GitHub Copilot hook support (CLI + cloud agent)

Wire the Impeccable design detector into GitHub Copilot's hook system so
direct file edits get the same post-edit design feedback the Claude Code,
Codex, and Cursor harnesses already receive.

GitHub Copilot's contract differs from the existing harnesses (verified
against Copilot CLI 1.0.63):
- Repo-level manifest at `.github/hooks/impeccable.json` (read by both the
  CLI, once committed to the default branch, and the cloud/app agent).
- Flat `postToolUse` entries with `bash`/`timeoutSec` and a full-match
  `matcher` regex; the file-editing tools are `edit` and `create`.
- The stdin event uses camelCase `toolName`/`toolArgs`, where `toolArgs` is
  a JSON *string* carrying the touched file under `path`.
- Context is injected via a top-level `additionalContext` string.

Changes:
- hooks.js: buildGitHubHooksManifest() + route `github` in hooksJsonFor().
- providers.js: emitHooks/hooksManifestRel for the github provider.
- hook-lib.mjs: detect the github harness, normalize the camelCase event
  (parse the JSON-string toolArgs -> tool_input.file_path), and emit the
  `additionalContext` payload shape.
- hook-admin.mjs / skills.mjs: install + idempotent-repair the
  `.github/hooks/impeccable.json` manifest (bash-aware marker stripping).
- hooks.md: document GitHub Copilot as a supported harness.
- Tests for the builder, routing, event normalization, and end-to-end run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Cover Copilot apply_patch edits in the hook (live-verified)

The first cut matched only `edit|create`, the tool names `copilot -p` uses.
A live trace against Copilot CLI 1.0.63 in an interactive session showed it
edits files via `apply_patch`, whose toolArgs is a raw OpenAI-format patch
string (`*** Begin Patch` / `*** Add File:`), not JSON. With the narrow
matcher the hook command never ran.

- hooks.js / hook-admin.mjs: matcher -> `edit|create|apply_patch`.
- hook-lib.mjs: normalizeGitHubEvent now routes apply_patch's raw patch
  string into tool_input.command (reusing the existing parseApplyPatchPaths /
  resolveTargetFiles plumbing) and only JSON-parses toolArgs for the
  edit/create/view tools. tool_name is normalized to apply_patch so the patch
  path is extracted even if a future build relabels the tool.
- Tests: apply_patch matcher assertions, event normalization, and an
  end-to-end runHook covering the interactive/cloud path.

Verified live: a trusted interactive `apply_patch` edit fires the hook and
returns the expected `additionalContext` design reminder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Address review feedback + add changelog entry

- hook-lib.mjs (Bugbot, low): looksLikeApplyPatch no longer misroutes an
  edit/create event whose edited *content* contains apply_patch markers. A
  real apply_patch payload is a raw string that does not parse as JSON; an
  edit payload is a JSON object, so only non-JSON-object strings are treated
  as apply_patch. Edit events keep extracting `path`. Adds a regression test.
- skills.mjs (Bugbot, medium): document why `.github` is intentionally
  excluded from hookScriptPathForProvider. Its hook manifest is committed and
  shared (read by the Copilot cloud agent and teammates), so the command must
  stay portable via `$(git rev-parse ...)`; rewriting it to a machine-local
  absolute path would break those. GitHub skills are project-scoped, so the
  project-relative path resolves.
- changelog: add an Upcoming (v3.x placeholder) entry for the Copilot hook.
  Version is not bumped yet (batching with other changes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 02:24:18 -07:00
793feda5a0 Guard plugin/skill version drift in the build (issue #274) (#278)
* Guard plugin/skill version drift in the build (issue #274)

The Claude Code marketplace installs from the committed ./plugin subtree,
so a version disagreement between the hand-edited manifests and the
generated subtree ships stale content under a wrong version. This is the
class of bug reported in #274: a version bump that doesn't regenerate
./plugin (e.g. PR #252, where root plugin.json was 3.7.0 while
plugin/.claude-plugin/plugin.json was still 3.6.0) merges a drift window
onto main, and marketplace/Cowork installs pull the stale subtree.

Add a build-time validator that treats root .claude-plugin/plugin.json
as the source of truth and fails the build if any of these disagree:
  - .claude-plugin/marketplace.json plugins[0].version (hand-edited; the
    post-merge sync workflow never bumps versions, so it can't repair a
    mismatch here)
  - plugin/.claude-plugin/plugin.json version (generated subtree)
  - plugin/skills/impeccable/SKILL.md frontmatter version (bundled skill)

It only fires on an inconsistent bump; PRs that don't touch versions keep
every file in agreement and stay silent. The pure comparison lives in
scripts/lib/validate-plugin-versions.js with direct unit coverage; build.js
owns the logging and the non-zero exit. Documents the regenerate-on-bump
step in CLAUDE.md's Versioning section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Harden version-drift collector against malformed/incomplete manifests

Address Greptile review on #278:

- Wrap every file read/parse in a sentinel helper (extractFromFile) so a
  half-edited manifest — the exact state during a version bump — yields a
  clean "could not parse (...)" diagnostic naming the file instead of a raw
  JSON.parse stack trace out of build().
- Report a present-but-malformed root plugin.json, or one missing its
  `version` field, as an explicit error. Previously `undefined` version
  short-circuited the build wrapper's `source == null` guard and passed
  silently. collectPluginVersions now returns an `errors` array; build.js
  fails on errors + mismatches combined, and only the genuinely-absent root
  manifest is a no-op skip.

Adds 4 unit tests: malformed checked manifest, malformed root, missing
version field, and the absent-root no-errors case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Make SKILL.md frontmatter version read CRLF-tolerant

Address Cursor Bugbot review on #278: readSkillFrontmatterVersion only
matched `\n` delimiters, while the shared parseFrontmatter in
scripts/lib/utils.js accepts `\r?\n`. A bundled SKILL.md saved with CRLF
line endings would parse to a null version and trip a false mismatch
against root plugin.json even when the version line is correct.

Match the shared parser's `\r?\n` tolerance and drop the `$` anchor on
the version line (it would not match before a `\r`). Adds CRLF coverage
for both readSkillFrontmatterVersion and collectPluginVersions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Re-trigger CI (no file change)

CI did not fire for 5cda9f6b; force a fresh run on the current tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 15:51:16 +09:00
github-actions[bot] c0d50e36da Sync generated provider output 2026-06-20 04:51:03 +00:00
67e8757401 Fix React hydration mismatch from live scroll-lock on SSR roots (#276)
* Fix React hydration mismatch from live scroll-lock on SSR roots

The live overlay's startScrollLock disabled the browser's scroll
anchoring by setting `overflow-anchor: none` as an inline style on
`<html>` and `<body>`. On frameworks that server-render those roots
(notably Next.js App Router), that client-only inline style desyncs from
the server HTML, so React 19 logs "a tree hydrated but some attributes
of the server rendered HTML didn't match" on the next Fast-Refresh
re-render. It surfaced as a flaky failure of the nextjs-app-router
live-e2e fixture's expectConsoleClean probe.

Inject the suppression as a `<style>` rule keyed by a stable id instead
of mutating inline styles on hydrated host elements. Same computed
effect, but React no longer sees a client-only attribute on `<html>` /
`<body>`. The rule is recreated on every startScrollLock and removed on
teardown, so reload survival (driven by the persisted scroll key) is
unchanged.

Adds a regression guard pinning the new shape (no inline overflowAnchor
mutation on html/body; injected <style> created and removed by id).
Verified end-to-end: the nextjs-app-router live-e2e fixture now passes
the expectConsoleClean probe deterministically.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Relax regression-guard regex spans to {0,400}

Address Greptile review: the {0,200}/{0,220}/{0,160} character-span
limits between the injected-style constructs were tight enough that an
innocent refactor or added comment inside startScrollLock could silently
break the shape-check. Widen each segment to {0,400}; the guard still
passes on the fix and still fails when the inline html/body overflowAnchor
mutation is reintroduced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 13:50:32 +09:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1fd1eb11bc chore(deps-dev): bump the bun-minor-and-patch group across 1 directory with 9 updates (#248)
Bumps the bun-minor-and-patch group with 9 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@ai-sdk/anthropic](https://github.com/vercel/ai/tree/HEAD/packages/anthropic) | `3.0.81` | `3.0.84` |
| [@ai-sdk/google](https://github.com/vercel/ai/tree/HEAD/packages/google) | `3.0.80` | `3.0.82` |
| [@ai-sdk/openai](https://github.com/vercel/ai/tree/HEAD/packages/openai) | `3.0.68` | `3.0.71` |
| [@anthropic-ai/claude-agent-sdk](https://github.com/anthropics/claude-agent-sdk-typescript) | `0.3.168` | `0.3.178` |
| [@anthropic-ai/sdk](https://github.com/anthropics/anthropic-sdk-typescript) | `0.102.0` | `0.104.2` |
| [ai](https://github.com/vercel/ai/tree/HEAD/packages/ai) | `6.0.197` | `6.0.206` |
| [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) | `6.4.4` | `6.4.7` |
| [playwright](https://github.com/microsoft/playwright) | `1.60.0` | `1.61.0` |
| [wrangler](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/wrangler) | `4.98.0` | `4.100.0` |



Updates `@ai-sdk/anthropic` from 3.0.81 to 3.0.84
- [Release notes](https://github.com/vercel/ai/releases)
- [Changelog](https://github.com/vercel/ai/blob/@ai-sdk/anthropic@3.0.84/packages/anthropic/CHANGELOG.md)
- [Commits](https://github.com/vercel/ai/commits/@ai-sdk/anthropic@3.0.84/packages/anthropic)

Updates `@ai-sdk/google` from 3.0.80 to 3.0.82
- [Release notes](https://github.com/vercel/ai/releases)
- [Changelog](https://github.com/vercel/ai/blob/@ai-sdk/google@3.0.82/packages/google/CHANGELOG.md)
- [Commits](https://github.com/vercel/ai/commits/@ai-sdk/google@3.0.82/packages/google)

Updates `@ai-sdk/openai` from 3.0.68 to 3.0.71
- [Release notes](https://github.com/vercel/ai/releases)
- [Changelog](https://github.com/vercel/ai/blob/@ai-sdk/openai@3.0.71/packages/openai/CHANGELOG.md)
- [Commits](https://github.com/vercel/ai/commits/@ai-sdk/openai@3.0.71/packages/openai)

Updates `@anthropic-ai/claude-agent-sdk` from 0.3.168 to 0.3.178
- [Release notes](https://github.com/anthropics/claude-agent-sdk-typescript/releases)
- [Changelog](https://github.com/anthropics/claude-agent-sdk-typescript/blob/main/CHANGELOG.md)
- [Commits](https://github.com/anthropics/claude-agent-sdk-typescript/compare/v0.3.168...v0.3.178)

Updates `@anthropic-ai/sdk` from 0.102.0 to 0.104.2
- [Release notes](https://github.com/anthropics/anthropic-sdk-typescript/releases)
- [Changelog](https://github.com/anthropics/anthropic-sdk-typescript/blob/main/CHANGELOG.md)
- [Commits](https://github.com/anthropics/anthropic-sdk-typescript/compare/sdk-v0.102.0...sdk-v0.104.2)

Updates `ai` from 6.0.197 to 6.0.206
- [Release notes](https://github.com/vercel/ai/releases)
- [Changelog](https://github.com/vercel/ai/blob/ai@6.0.206/packages/ai/CHANGELOG.md)
- [Commits](https://github.com/vercel/ai/commits/ai@6.0.206/packages/ai)

Updates `astro` from 6.4.4 to 6.4.7
- [Release notes](https://github.com/withastro/astro/releases)
- [Changelog](https://github.com/withastro/astro/blob/main/packages/astro/CHANGELOG.md)
- [Commits](https://github.com/withastro/astro/commits/astro@6.4.7/packages/astro)

Updates `playwright` from 1.60.0 to 1.61.0
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.60.0...v1.61.0)

Updates `wrangler` from 4.98.0 to 4.100.0
- [Release notes](https://github.com/cloudflare/workers-sdk/releases)
- [Commits](https://github.com/cloudflare/workers-sdk/commits/wrangler@4.100.0/packages/wrangler)

---
updated-dependencies:
- dependency-name: "@ai-sdk/anthropic"
  dependency-version: 3.0.84
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: bun-minor-and-patch
- dependency-name: "@ai-sdk/google"
  dependency-version: 3.0.82
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: bun-minor-and-patch
- dependency-name: "@ai-sdk/openai"
  dependency-version: 3.0.71
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: bun-minor-and-patch
- dependency-name: "@anthropic-ai/claude-agent-sdk"
  dependency-version: 0.3.177
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: bun-minor-and-patch
- dependency-name: "@anthropic-ai/sdk"
  dependency-version: 0.104.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: bun-minor-and-patch
- dependency-name: ai
  dependency-version: 6.0.205
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: bun-minor-and-patch
- dependency-name: astro
  dependency-version: 6.4.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: bun-minor-and-patch
- dependency-name: playwright
  dependency-version: 1.61.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: bun-minor-and-patch
- dependency-name: wrangler
  dependency-version: 4.100.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: bun-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-19 21:37:29 -07:00
github-actions[bot] a42d4a7060 Sync generated provider output 2026-06-20 04:29:05 +00:00
a1560fb0f5 Fix misleading npx hints in live-mode poll/wrap scripts (#275)
* Replace npx hints in live scripts with bundled-script paths

The live-mode poll/wrap scripts are invoked by the agent via
`node {{scripts_path}}/live-*.mjs`, never through the `npx impeccable`
CLI. Their help text and runtime error hints still pointed at
`npx impeccable poll|live|wrap`, which is misleading and, for the
error paths, not directly runnable.

- Docstrings/comments (never executed): switch to the
  `node <scripts_path>/...` convention already used by live-server.mjs.
- Runtime-printed error/usage strings: resolve the script's own dir via
  import.meta.url and print a real, copy-pasteable absolute path instead
  of a placeholder.

Verified by triggering the error paths from the synced bundle and by
running the live-mode E2E (vite8-react-modal) through the full cycle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Quote script paths in runtime hints to handle spaces

Paths containing spaces (e.g. /Users/john doe/...) would otherwise
produce a non-runnable command. Addresses Greptile review feedback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 21:28:35 -07:00
d5403f9d65 Bump extension to v1.2.1 and add consolidated changelog (#265)
Release bump covering the recent extension fixes that ship together:
toolbar badge count parity (#262), local file:// scan failure messaging
(#258), and the Kinpaku popup theme (#260).

Merge after #264, #261, and #263.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 21:51:15 -07:00
1f4021b16c Fix: make Chrome extension toolbar badge count anti-patterns (#264)
The toolbar badge counted flagged elements (state.findings.length) while
the popup and DevTools panel counted total anti-pattern findings, so the
same scan showed two numbers (e.g. 21 vs 34 on the design-system page).
Since the surfaces are labeled "anti-patterns", count total findings in
the badge too so all three agree.

Closes #262

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 21:50:36 -07:00
046a8593f5 Fix: surface scan failures in extension popup for local files (#261)
* Fix: surface scan failures in extension popup for local files

Scanning a local file:// page with "Allow access to file URLs" off left
the popup stuck on "Scanning..." because the blocked content-script
injection returned silently. ensureContentScriptInjected() now returns the
real error, and sendScanToTab() sends a scan-failed message that the popup
renders as a small line, with a permission hint shown only for file:// tabs.

Fixes #258

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

* Improve: report the actual error when a non-file scan fails

The generic "This page can't be scanned." gave no reason. Non-file failures
now read "Couldn't scan this page: <error>" so the user sees what Chrome
reported instead of a dead end.

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

* Fix: scope popup broadcasts to the active tab

The popup acted on every findings-updated / scan-failed / overlays broadcast
regardless of which tab it targeted, so a background or DevTools-driven
rescan on another tab could reset the button or show a spurious error. Cache
the active tab id and ignore broadcasts for other tabs.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-18 21:49:16 -07:00
Abdul WahabandGitHub e371c99f08 Update extension popup to Kinpaku theme (#263)
* Update extension popup to Kinpaku theme

* Fix popup light mode contrast
2026-06-18 21:48:37 -07:00
github-actions[bot] d949abd180 Sync generated provider output 2026-06-19 01:47:38 +00:00
Abdul WahabandGitHub 07667ed08f Add quiet mode to detect CLI (#259) 2026-06-18 18:47:02 -07:00
Paul Bakaus 1c897a09c8 Polish docs page 2026-06-17 17:49:38 +09:00
Paul Bakaus 617b3a6e5e Polish live mode and slop pages 2026-06-17 17:38:07 +09:00
Paul Bakaus c7539c867d Fix live picker sizing and divider detection 2026-06-17 13:10:53 +09:00
Paul Bakaus 4f50db2bca Fix live picker steer sizing 2026-06-17 12:36:58 +09:00
github-actions[bot] f726894373 Sync generated provider output 2026-06-17 03:00:20 +00:00
Paul BakausandGitHub 99a284a0d9 Fix live page editable focus handling (#256) 2026-06-16 19:59:48 -07:00
github-actions[bot] b86f2cc353 Sync generated provider output 2026-06-17 02:51:20 +00:00
Paul BakausandGitHub 8b0c895703 [codex] Fix CLI skill update detection (#257)
* Fix CLI skill update detection

* Preserve linked skills during install refresh

* Keep existing installs working offline

* Respect provider scope during install refresh
2026-06-16 19:50:40 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1268f10b76 chore(deps): bump actions/cache from 4 to 5 in the github-actions group (#249)
Bumps the github-actions group with 1 update: [actions/cache](https://github.com/actions/cache).


Updates `actions/cache` from 4 to 5
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-16 06:56:23 -07:00
Paul BakausandClaude Opus 4.8 795e8ed5e5 fix(skill): bundle detector config dependency so critique runs (#254)
The bundled detector's cli/main.mjs imports ../../lib/impeccable-config.mjs,
which in the source CLI resolves to cli/lib/impeccable-config.mjs. The skill
build only copies cli/engine/** into scripts/detector/**, leaving that
dependency behind, so from the bundled scripts/detector/cli/main.mjs the same
import resolved to scripts/lib/impeccable-config.mjs and failed with
"Cannot find module .../lib/impeccable-config.mjs". /impeccable critique (and
any detector-backed command) crashed on startup for every provider since #252.

Teach the detector bundler to copy out-of-bundle engine dependencies into the
skill's scripts/lib/, and add a build test that walks every bundled script and
asserts each relative import resolves to another bundled file, so a future
out-of-bundle dependency fails the test instead of the user.

Skill v3.7.1 (patch). CLI unchanged — the engine resolves fine in the CLI's
own tree; only the skill bundling was wrong.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 22:49:31 +09:00
753 changed files with 58101 additions and 6997 deletions
+14 -11
View File
@@ -1,6 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 3.9.1
---
Designs and iterates production-grade frontend interfaces. Real working code, committed design choices, exceptional craft.
@@ -9,11 +10,12 @@ Designs and iterates production-grade frontend interfaces. Real working code, co
You MUST do these steps before proceeding:
1. Run `node .agents/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/<command>.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
1. Run `node .agents/skills/impeccable/scripts/context.mjs` once per session; if the runtime shows this skill's loaded base directory, run `node <skill-base-dir>/scripts/context.mjs` instead. Keep cwd/workdir at the user's project, not the skill directory. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and append `--target <path>` to the same command. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`:** divert into `reference/init.md` first when the user invoked `init`, `teach`, `craft`, or `shape`, or when their wording clearly maps to one of those from-scratch build flows (for example: "build/create/make a landing page", "design a new app", or "shape a feature"). Captured product context is the point of those flows. For any other command, a scoped evaluate / refine / enhance / fix / iterate request against existing code, do **not** divert into init. The existing code is the context: proceed with the requested command, infer the register from the surface in focus (step 4), and offer `$impeccable init` once as a suggestion the user can take later. A missing PRODUCT.md must never block a scoped request. If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read the command's reference next: **`reference/<command>.md`, or the native variant from the Commands table** (e.g. `reference/audit.native.md`) **when the project platform is native** (`ios` / `android` / `adaptive`, per the `context.mjs` directive). One file, not both. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
5. **If the project is brand-new (no existing CSS tokens / theme / committed brand colors found in step 3)**, run `node .agents/skills/impeccable/scripts/palette.mjs` to receive a brand seed color and composition guidance. This is the anchor for your primary brand color. Compose the rest of the palette (bg, surface, ink, accent, muted) around it per the script's instructions. Use OKLCH throughout. **Skip this step only if step 3 found committed brand colors in existing tokens; in that case identity-preservation wins.**
5. **If PRODUCT.md's `## Platform` is `ios` or `android`**, also read `reference/<platform>.md` (HIG / Material 3 conventions). `adaptive` (cross-platform, ships both) reads both files. `web`, absent, or unrecognized: nothing extra to read. `context.mjs` prints the directive when one applies.
6. **If the project is brand-new (no existing CSS tokens / theme / committed brand colors found in step 3)**, run `node .agents/skills/impeccable/scripts/palette.mjs` to receive a brand seed color and composition guidance. This is the anchor for your primary brand color. Compose the rest of the palette (bg, surface, ink, accent, muted) around it per the script's instructions. Use OKLCH throughout. **Skip this step only if step 3 found committed brand colors in existing tokens; in that case identity-preservation wins.**
## Design guidance
@@ -92,6 +94,7 @@ Match-and-refuse. If you're about to write any of these, rewrite the element wit
- **`border-radius: 32px+` on cards / sections / inputs.** You over-round. Cards top out at 1216px; full-pill is fine for tags/buttons. Picking 24/28/32/40px on a card is the codex tell; no brand wants "insanely rounded".
- **Hand-drawn / sketchy SVG illustrations.** Class names like `loose-sketch`, `*-sketch`, `doodle`, `wavy`; `feTurbulence` / `feDisplacementMap` "paper grain" filters; 5-to-30 path crude scenes meant to depict a tangible subject (an otter, a table-and-fork, an album cover). All of these read as amateurish, not whimsical. If you can't render the scene with real assets, ship no illustration. Don't attempt sketchy SVG as a fallback.
- **`repeating-linear-gradient(...)` stripe backgrounds.** Diagonal stripes in `body:before` or section backgrounds are pure codex decoration. Don't.
- **Decorative grid backgrounds.** Two-axis CSS grid overlays built from `linear-gradient(... 1px, transparent 1px)` plus `background-size` are a Codex tell unless the surface is an actual canvas, map, blueprint, or measurement tool. Use product structure, real artifacts, or a plain surface instead.
- **Meta-criticism copy.** Naming a concept then layering an ironic modifier, or staging a strawman to "correct" it. Make the specific claim instead.
### The AI slop test
@@ -113,7 +116,7 @@ If someone could look at this interface and say "AI made that" without doubt, it
| `document` | Build | Generate DESIGN.md from existing project code | [reference/document.md](reference/document.md) |
| `extract [target]` | Build | Pull reusable tokens and components into design system | [reference/extract.md](reference/extract.md) |
| `critique [target]` | Evaluate | UX design review with heuristic scoring | [reference/critique.md](reference/critique.md) |
| `audit [target]` | Evaluate | Technical quality checks (a11y, perf, responsive) | [reference/audit.md](reference/audit.md) |
| `audit [target]` | Evaluate | Technical quality checks (a11y, perf, responsive) | [reference/audit.md](reference/audit.md) · native: [reference/audit.native.md](reference/audit.native.md) |
| `polish [target]` | Refine | Final quality pass before shipping | [reference/polish.md](reference/polish.md) |
| `bolder [target]` | Refine | Amplify safe or bland designs | [reference/bolder.md](reference/bolder.md) |
| `quieter [target]` | Refine | Tone down aggressive or overstimulating designs | [reference/quieter.md](reference/quieter.md) |
@@ -127,7 +130,7 @@ If someone could look at this interface and say "AI made that" without doubt, it
| `delight [target]` | Enhance | Add personality and memorable touches | [reference/delight.md](reference/delight.md) |
| `overdrive [target]` | Enhance | Push past conventional limits | [reference/overdrive.md](reference/overdrive.md) |
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) |
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) |
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
| `live` | Iterate | Visual variant mode: pick elements in the browser, generate alternatives | [reference/live.md](reference/live.md) |
@@ -135,26 +138,26 @@ Plus three management commands: `pin <command>`, `unpin <command>`, and `hooks <
### Routing rules
1. **No argument**: the user is asking "what should I do?" Make the menu context-aware instead of static. Setup has already run `context.mjs`; if that reported `NO_PRODUCT_MD` you are already in init (setup), so finish that and skip this. Otherwise run `node .agents/skills/impeccable/scripts/context-signals.mjs` once and read its JSON, then lead with the **2-3 highest-value next commands**, each with a one-line reason pulled from the signals, followed by the full menu (the table above, grouped by category). **Never auto-run a command; the recommendation is a suggestion the user confirms.**
1. **No argument**: the user is asking "what should I do?" Make the menu context-aware instead of static. Setup has already run `context.mjs`; if that reported `NO_PRODUCT_MD` the project has no captured context yet, so lead the menu with `$impeccable init` as the top recommendation (one line on why) and still show the rest below; don't silently jump into init. Otherwise run `node .agents/skills/impeccable/scripts/context-signals.mjs` once and read its JSON, then lead with the **2-3 highest-value next commands**, each with a one-line reason pulled from the signals, followed by the full menu (the table above, grouped by category). **Never auto-run a command; the recommendation is a suggestion the user confirms.**
Reason over the signals; there is no score to obey:
- `setup.hasDesign` false while `setup.hasCode` true → `document` (capture the visual system).
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `$impeccable critique <surface>` is a strong default.
- `critique.latest` with a low `score` or non-zero `p0` / `p1``polish` (it reads that snapshot as its backlog), or re-run `critique` if the snapshot looks stale.
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`.
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `detect.mjs` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
- Otherwise group by intent exactly as init's "Recommend starting points" step does (build new / improve what's there / iterate visually), tailored to `setup.register`.
**If `scan.targets` is non-empty, run `node .agents/skills/impeccable/scripts/detect.mjs --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `node .agents/skills/impeccable/scripts/detect.mjs --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
Keep it to 2-3 pointed picks with the exact command to type. The menu stays the fallback; the recommendation is the lede.
2. **First word matches a command** (table above OR `pin` / `unpin` / `hooks`): load its reference file and follow its instructions. Everything after the command name is the target.
3. **First word doesn't match, but the intent clearly maps to one command** (e.g. "fix the spacing" → `layout`, "rewrite this error message" → `clarify`, "the colors feel flat" → `colorize`): load that command's reference and proceed as if invoked. If two commands could fit, ask once which.
2. **First word matches a command** (table above OR `pin` / `unpin` / `hooks`): load its reference file (on native platforms, the table's native variant; Setup step 2's one-file rule) and follow its instructions. Everything after the command name is the target.
3. **First word doesn't match, but the intent clearly maps to one command** (e.g. "fix the spacing" → `layout`, "rewrite this error message" → `clarify`, "the colors feel flat" → `colorize`): load that command's reference (same native-variant rule) and proceed as if invoked. If two commands could fit, ask once which.
4. **No clear command match**: general design invocation. Apply the setup steps, the General rules, and the loaded register reference, using the full argument as context.
Setup (context gathering, register) is already loaded by then; sub-commands don't re-invoke `$impeccable`.
If the first word is `craft`, setup still runs first, but [reference/craft.md](reference/craft.md) owns the rest of the flow. If setup invokes `init` as a blocker, finish init, refresh context, then resume the original command and target.
If the first word is `craft` or `shape`, or routing rule 3 clearly maps the user's intent to either command, setup still runs first, but the matching reference ([reference/craft.md](reference/craft.md) or [reference/shape.md](reference/shape.md)) owns the rest of the flow. Both are from-scratch build flows: if setup invokes `init` as a blocker, finish init, refresh context, then resume the original command and target.
`teach` is a deprecated alias for `init`: if the user types it, load [reference/init.md](reference/init.md) and proceed as if they ran `init`.
@@ -2,6 +2,7 @@
Adapt an existing design to a different context: another screen size, device, platform, or use case. The trap is treating adaptation as scaling. The job is rethinking the experience for the new context.
**Web only** (mobile web included). Native platforms (`ios` / `android` / `adaptive`) route to [adapt.native.md](adapt.native.md) instead; if the project is native, switch to it now.
---
@@ -0,0 +1,58 @@
> **Additional context needed**: target platforms/devices and usage contexts.
Adapt an existing **native** design (`ios` / `android` / `adaptive`) to a different context: another device class, orientation, platform, or origin. The trap is treating adaptation as scaling. The job is rethinking the experience for the new context, inside the platform conventions of [ios.md](ios.md) / [android.md](android.md); read the target platform's reference before planning if Setup hasn't already.
## Assess Adaptation Challenge
1. **Source context**: what was it designed for, and what assumptions did it make? (Phone-only? Portrait-only? One platform's idioms? A website?)
2. **Target context**: which device class (phone, tablet, foldable), orientation, platform, and usage posture (one-handed on the go vs two-handed at rest)?
3. **What breaks**: navigation that doesn't fit the target, layouts that stretch instead of restructure, gestures or controls that don't exist there?
## Adaptation Strategies
### Phone → Tablet (iPad / large screens)
- **Restructure, don't stretch.** A scaled-up phone UI on a tablet is the failure mode. Use size classes (iOS) / window size classes (Android) to switch structure.
- **Navigation changes shape**: tab bar stays or becomes a sidebar on iPad; Android navigation bar becomes a rail or drawer on expanded width.
- **Use the width**: split view / master-detail (list + detail side by side), multi-column grids, popovers where phones used sheets.
- **Multitasking is a size, not an edge case**: iPad Split View and Android multi-window can hand you a phone-width window on a tablet; size-class-driven layout handles both for free.
### Orientation & foldables
- Landscape restructures (side-by-side panes, repositioned controls); never clip or letterbox. Lock orientation only when the task truly demands it.
- Foldables (Android): react to posture and hinge via window size classes; test folded, unfolded, and tabletop.
### Platform → platform (iOS ↔ Android)
Translate idioms; never transplant them:
| iOS | Android |
|---|---|
| Tab bar | Navigation bar / rail / drawer |
| Edge-swipe back, back chevron | Predictive Back gesture / button |
| Switch, segmented control, system pickers | Material switch, chips, Material pickers |
| Action sheet | Bottom sheet / Material dialog |
| SF Symbols, SF Pro, Dynamic Type | Material Symbols, Roboto, sp scaling |
| Semantic system colors, materials | Material color roles, tonal elevation |
| System push/sheet transitions | Container transform, shared-axis, fade-through |
Rebuild navigation and controls in the target's vocabulary; carry over the brand's expressive layer (palette intent, type accent, motion personality) through the target's theming system.
### Web → native (porting a website or web app)
Reconform, don't reflow. Replace web navigation with the platform's model, HTML-shaped controls with platform controls, hover affordances with touch-first ones, and px-based type with Dynamic Type / sp. Then treat the result to the full platform reference; the slop test there is the acceptance bar.
## Implement & Verify
- Drive structure from **size classes / window size classes**, never from device-model checks.
- Respect safe areas and window insets in every new configuration (notch, hinge, status bar, keyboard).
- Test on simulators for breadth, then real hardware for truth: at least one phone and one tablet per shipped platform, both orientations, split-screen where supported.
When the adaptation feels native to each context, hand off to `$impeccable polish` for the final pass.
**NEVER**:
- Ship a stretched phone layout on a tablet
- Port one platform's controls or navigation onto the other
- Hide core functionality on smaller devices (if it matters, make it work)
- Lock orientation to dodge a layout bug
- Trust simulators alone (posture, gestures, and performance need hardware)
@@ -0,0 +1,40 @@
# Android platform
For native Android apps: Jetpack Compose, Android Views, React Native, Expo, Flutter shipping to Android hardware.
On native, register narrows. Material Design 3 governs structure, navigation, and interaction whatever the register; brand expresses through Material's theming (color roles, type scale, shape, motion). A Material-everywhere cross-platform app that also ships to iPhone still owes iOS its OS guarantees on that hardware: safe-area insets, Reduce Motion, edge-swipe back.
## The Android slop test
Would a fluent Android user trust this app, or trip on off-spec components? The most common tell is an iOS app wearing Android's skin: a bottom-only navigation copied from iPhone, a back arrow that ignores the system Back gesture, Cupertino-shaped switches and dialogs. Material 3 is the rulebook; follow its components and theme the brand through it.
## Layout & structure
- **Material navigation, matched to size.** Navigation bar (bottom, 35 destinations) on compact width; navigation rail or drawer on expanded width. Never ship a phone bottom-bar untouched on a tablet.
- **System Back always works.** Honor the predictive Back gesture and Back button; never trap the user or hijack the gesture.
- **Edge-to-edge with window insets.** Apply the status bar, navigation bar, display cutout, and IME insets so content never hides behind system bars or the keyboard.
- **Top app bar for screen context**; pair with a FAB when the screen has a single primary action.
## Touch targets
- **48×48 dp minimum** for every touch target, with at least 8 dp between them.
## Typography
- **Material type scale.** Display, Headline, Title, Body, Label roles (large/medium/small each). Map text to roles; never hand-pick sizes per screen.
- **Roboto is the system face**; theme a brand face in through the type scale, keeping body, labels, and controls legible and consistent.
- **sp units, never fixed px**, so type follows the system font-size setting.
## Color & theming
- **Material color roles** (primary, on-primary, surface, surface-variant, secondary-container, outline, error). Role tokens resolve light/dark and contrast variants automatically; raw hex breaks there.
- **Dynamic Color (Material You)** where it fits: derive the scheme from the user's wallpaper on Android 12+, with a static fallback.
- **Dark theme is a first-class scheme.** Design and test it; never a quick invert.
- **Tonal elevation.** Convey elevation through the standard surface tonal levels (plus shadow where appropriate); no arbitrary drop shadows.
## Components & motion
- **Material components.** Buttons (filled / tonal / outlined / text), FAB, switches, chips, snackbars, bottom sheets, Material dialogs, navigation bar/rail/drawer. Never port iOS controls or invent equivalents.
- **One FAB, one primary action.** Never stack FABs or spend one on a secondary task.
- **Snackbars for transient feedback** (actionable when useful, never a toast for that); dialogs only for decisions that must interrupt.
- **Material motion patterns.** Container transform, shared-axis, fade-through, with standard easing and durations; honor the system Remove animations setting with a crossfade or instant cut.
@@ -10,6 +10,8 @@ Brand: motion is part of the voice; one well-rehearsed entrance beats scattered
Product: 150250 ms on most transitions. Motion conveys state: feedback, reveal, loading, transitions between views. No page-load choreography; users are in a task and won't wait for it.
Native (`ios` / `android` / `adaptive`): implementation follows the Motion section of [ios.md](ios.md) / [android.md](android.md) (read it first if Setup hasn't already): system transitions and OS Reduce Motion, never the web tooling below.
---
## Assess Animation Opportunities
@@ -2,6 +2,8 @@ Run systematic **technical** quality checks and generate a comprehensive report.
This is a code-level audit, not a design critique. Check what's measurable and verifiable in the implementation.
**Web only.** Native platforms (`ios` / `android` / `adaptive`) route to [audit.native.md](audit.native.md) instead; if the project is native, switch to it now.
## Diagnostic Scan
Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the criteria below.
@@ -0,0 +1,139 @@
Run systematic **technical** quality checks on a native app (`ios` / `android` / `adaptive`) and generate a comprehensive report. Don't fix issues; document them for other commands to address.
This is a code-level audit, not a design critique. Audit from source (SwiftUI / UIKit / Compose / React Native / Flutter); no browser tooling or `detect.mjs` applies. Score against the platform reference(s): [ios.md](ios.md) / [android.md](android.md), both for `adaptive`. Read them before scoring if Setup hasn't already. The report skeleton mirrors [audit.md](audit.md); keep the two in sync when changing it.
## Diagnostic Scan
Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the criteria below.
### 1. Accessibility (VoiceOver / TalkBack)
**Check for**:
- **Missing labels**: interactive elements without accessibility labels, traits/roles, or state announcements
- **Reading and focus order**: illogical traversal, unreachable controls, focus lost on navigation
- **Text scaling**: fixed point sizes defeating Dynamic Type (iOS) or px instead of sp (Android); layouts that clip or overlap at large sizes
- **Touch targets**: below 44 pt (iOS) / 48 dp (Android), or crammed without spacing
- **Reduce Motion ignored**: parallax and large slides with no crossfade alternative
- **Contrast**: text failing contrast in either appearance, light or dark
**Score 0-4**: 0=Screen reader unusable, 1=Major gaps (unlabeled controls, no scaling), 2=Partial (labels exist, order or scaling breaks), 3=Good (minor gaps), 4=Excellent (labeled, ordered, scales cleanly, Reduce Motion honored)
### 2. Performance
**Check for**:
- **Slow startup**: heavy work on launch before first frame
- **Unvirtualized lists**: long content without FlatList / LazyColumn / List recycling
- **Main-thread jank**: synchronous work in scroll or gesture paths, dropped frames on 60/120 Hz
- **Wasted rendering**: unnecessary re-renders (React Native) or recompositions (Compose); missing memoization/keys
- **Image handling**: full-size images decoded for thumbnails, no caching
- **App weight**: bloated JS bundle or binary, unused dependencies
**Score 0-4**: 0=Janky everywhere, 1=Major problems (unvirtualized lists, slow launch), 2=Partial, 3=Good (minor improvements possible), 4=Excellent (fast launch, smooth scroll, lean)
### 3. Appearance & Theming
**Check for**:
- **Hard-coded colors**: raw hex instead of semantic system colors (iOS) / Material color roles (Android) / design tokens
- **Broken dark appearance**: missing dark variants, poor contrast in dark, quick inverts
- **Dynamic Color** (Android 12+): no static fallback scheme, or ignored where it fits
- **Off-platform materials**: hand-rolled blur/glassmorphism instead of system materials or tonal elevation
**Score 0-4**: 0=Hard-coded everything, 1=Minimal tokens, 2=Partial (tokens exist, inconsistently used), 3=Good (minor hard-coded values), 4=Excellent (semantic throughout, both appearances first-class)
### 4. Platform Conformance (CRITICAL)
Score against the loaded platform reference(s), including their slop tests. **Check for**:
- **Broken system gestures**: edge-swipe back disabled (iOS), predictive Back hijacked (Android)
- **Inset violations**: content under the notch, Dynamic Island, home indicator, status bar, or keyboard
- **Off-platform navigation**: custom global nav, overloaded tab bars, iOS patterns on Android or vice versa
- **Web-shaped controls**: HTML-style buttons, custom toggles, hover-dependent affordances
- **Icon drift**: mixed icon sets instead of SF Symbols / Material Symbols
- **AI tells**: the shared absolute bans still apply (AI palette, gradient text, hero metrics)
**Score 0-4**: 0=Web port (nothing native), 1=Heavy violations (3-4 kinds), 2=Some (1-2 noticeable), 3=Mostly conformant (subtle issues), 4=Fully native (a fluent user trusts every screen)
### 5. Adaptivity
**Check for**:
- **Stretched phone layouts**: tablet/iPad rendering a scaled-up phone UI instead of using size classes / window size classes
- **Orientation breakage**: landscape clipping, ignored, or locked without reason
- **Keyboard/IME handling**: inputs hidden behind the keyboard, no inset adjustment
- **Multitasking**: iPad Split View / Android multi-window breaking layout
- **Foldables**: hinge-unaware layouts on posture change (Android)
**Score 0-4**: 0=One screen size only, 1=Major breakage (landscape or tablet broken), 2=Partial, 3=Good (minor edge cases), 4=Excellent (adapts across sizes, orientations, and windowing)
## Generate Report
### Audit Health Score
| # | Dimension | Score | Key Finding |
|---|-----------|-------|-------------|
| 1 | Accessibility | ? | [most critical issue or "--"] |
| 2 | Performance | ? | |
| 3 | Appearance & Theming | ? | |
| 4 | Platform Conformance | ? | |
| 5 | Adaptivity | ? | |
| **Total** | | **??/20** | **[Rating band]** |
**Rating bands**: 18-20 Excellent (minor polish), 14-17 Good (address weak dimensions), 10-13 Acceptable (significant work needed), 6-9 Poor (major overhaul), 0-5 Critical (fundamental issues)
### Platform Conformance Verdict
**Start here.** Pass/fail: does this read as a native app or a ported website? List specific violations. Be brutally honest.
### Executive Summary
- Audit Health Score: **??/20** ([rating band])
- Total issues found (count by severity: P0/P1/P2/P3)
- Top 3-5 critical issues
- Recommended next steps
### Detailed Findings by Severity
Tag every issue with **P0-P3 severity**:
- **P0 Blocking**: Prevents task completion. Fix immediately
- **P1 Major**: Significant difficulty or platform-guideline violation. Fix before release
- **P2 Minor**: Annoyance, workaround exists. Fix in next pass
- **P3 Polish**: Nice-to-fix, no real user impact. Fix if time permits
For each issue, document:
- **[P?] Issue name**
- **Location**: Screen, file, line
- **Category**: Accessibility / Performance / Theming / Conformance / Adaptivity
- **Impact**: How it affects users
- **Guideline**: The HIG / Material rule it violates (if applicable)
- **Recommendation**: How to fix it
- **Suggested command**: Which command to use (prefer: $impeccable adapt, $impeccable animate, $impeccable audit, $impeccable bolder, $impeccable clarify, $impeccable colorize, $impeccable critique, $impeccable delight, $impeccable distill, $impeccable document, $impeccable harden, $impeccable layout, $impeccable onboard, $impeccable optimize, $impeccable overdrive, $impeccable polish, $impeccable quieter, $impeccable shape, $impeccable typeset)
### Patterns & Systemic Issues
Identify recurring problems that indicate systemic gaps rather than one-off mistakes:
- "Hard-coded colors appear in 15+ screens, should use semantic colors"
- "Touch targets consistently below 44 pt throughout the tab bar and list rows"
### Positive Findings
Note what's working well: good practices to maintain and replicate.
## Recommended Actions
List recommended commands in priority order (P0 first, then P1, then P2):
1. **[P?] `$command-name`**: Brief description (specific context from audit findings)
2. **[P?] `$command-name`**: Brief description (specific context)
**Rules**: Only recommend commands from: $impeccable adapt, $impeccable animate, $impeccable audit, $impeccable bolder, $impeccable clarify, $impeccable colorize, $impeccable critique, $impeccable delight, $impeccable distill, $impeccable document, $impeccable harden, $impeccable layout, $impeccable onboard, $impeccable optimize, $impeccable overdrive, $impeccable polish, $impeccable quieter, $impeccable shape, $impeccable typeset. Map findings to the most appropriate command. End with `$impeccable polish` as the final step if any fixes were recommended.
After presenting the summary, tell the user:
> You can ask me to run these one at a time, all at once, or in any order you prefer.
>
> Re-run `$impeccable audit` after fixes to see your score improve.
**IMPORTANT**: Be thorough but actionable. Too many P3 issues creates noise. Focus on what actually matters.
**NEVER**:
- Report issues without explaining impact (why does this matter?)
- Provide generic recommendations (be specific and actionable)
- Skip positive findings (celebrate what works)
- Forget to prioritize (everything can't be P0)
- Report false positives without verification
+66 -59
View File
@@ -1,12 +1,12 @@
When asked for "bolder," AI defaults to the same tired tricks: cyan/purple gradients, glassmorphism, neon accents on dark backgrounds, gradient text on metrics. These are the opposite of bold. Reject them first, then increase visual impact and personality through stronger hierarchy, committed scale, and decisive type.
When asked for "bolder," AI defaults to the same tired tricks: cyan/purple gradients, glassmorphism, neon accents on dark backgrounds, gradient text on metrics. These are the opposite of bold. Reject them first, then increase visual impact by making the existing design language more decisive, specific, and committed.
---
## Register
Brand: "bolder" means distinctive. Extreme scale, unexpected color, typographic risk, committed POV.
Brand: "bolder" means distinctive. Express a stronger point of view through hierarchy, pacing, proportion, copy, evidence, and one committed visual idea.
Product: "bolder" rarely means theatrics; those undermine trust. It means stronger hierarchy, clearer weight contrast, one sharper accent, more committed density. The amplification is in clarity, not drama.
Product: "bolder" rarely means theatrics; those undermine trust. It means stronger hierarchy, clearer weight contrast, sharper information density, and more decisive prioritization. The amplification is in clarity, not drama.
---
@@ -15,98 +15,105 @@ Product: "bolder" rarely means theatrics; those undermine trust. It means strong
Analyze what makes the design feel too safe or boring:
1. **Identify weakness sources**:
- **Generic choices**: System fonts, basic colors, standard layouts
- **Timid scale**: Everything is medium-sized with no drama
- **Low contrast**: Everything has similar visual weight
- **Static**: No motion, no energy, no life
- **Predictable**: Standard patterns with no surprises
- **Flat hierarchy**: Nothing stands out or commands attention
- **Generic choices**: The page could belong to any product in the category.
- **Timid scale**: Everything is medium-sized with no clear lead.
- **Low contrast**: Important and supporting elements have similar visual weight.
- **Static**: The surface has no meaningful moment of emphasis.
- **Predictable**: The composition follows a default pattern without a point of view.
- **Flat hierarchy**: Nothing stands out or commands attention.
2. **Understand the context**:
- What's the brand personality? (How far can we push?)
- What's the purpose? (Marketing can be bolder than financial dashboards)
- Who's the audience? (What will resonate?)
- What are the constraints? (Brand guidelines, accessibility, performance)
- What is the brand personality?
- What is the purpose of this surface?
- Who is the audience?
- What design system, tokens, components, and visual conventions already exist?
If any of these are unclear from the codebase, STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer.
**CRITICAL**: "Bolder" doesn't mean chaotic or garish. It means distinctive, memorable, and confident. Think intentional drama, not random chaos.
**CRITICAL**: "Bolder" does not mean chaotic or garish. It means distinctive, memorable, and confident. Think intentional drama, not random noise.
**WARNING - AI SLOP TRAP**: Review ALL the DON'T guidelines from the parent impeccable skill (already loaded in this context) before proceeding. Bold means distinctive, not "more effects."
## Design-System Lock
If the project has `DESIGN.md`, tokens, theme variables, or established component styles, treat that system as the boundary. Make the existing language stronger before adding new language.
Do not invent new colors, gradients, radii, shadows, fonts, decorative backgrounds, or effects just because the request says "bolder." A bolder pass should usually change emphasis, proportion, rhythm, density, contrast, copy, artifact specificity, and layout relationships while staying inside the documented system.
If the existing system is genuinely too limited to express the bolder direction, stop and ask the user before expanding it. Name the exact additions, the role each would play, and why the current system cannot do the job. If the user approves expansion, update the design system or tokens alongside the implementation.
## Plan Amplification
Create a strategy to increase impact while maintaining coherence:
- **Focal point**: What should be the hero moment? (Pick ONE, make it amazing)
- **Personality direction**: Maximalist chaos? Elegant drama? Playful energy? Dark moody? Choose a lane.
- **Risk budget**: How experimental can we be? Push boundaries within constraints.
- **Hierarchy amplification**: Make big things BIGGER, small things smaller (increase contrast)
- **Focal point**: Pick one thing the viewer should remember, then make the rest support it.
- **System levers**: Identify which existing tokens, components, layout patterns, and copy structures can carry more weight.
- **Risk budget**: Decide how far the surface can push while still feeling like the same product or brand.
- **Hierarchy amplification**: Increase contrast between primary, secondary, and tertiary content instead of making every element louder.
**IMPORTANT**: Bold design must still be usable. Impact without function is just decoration.
## Amplify the Design
Systematically increase impact across these dimensions:
Systematically increase impact through intention, not a menu of effects:
### Typography Amplification
- **Replace generic fonts**: Swap system fonts for distinctive choices (see the parent skill's typography guidelines and the [Reference Material section of typeset.md](typeset.md#reference-material) for inspiration)
- **Extreme scale**: Create dramatic size jumps (3x-5x differences, not 1.5x)
- **Weight contrast**: Pair 900 weights with 200 weights, not 600 with 400
- **Unexpected choices**: Variable fonts, display fonts for headlines, condensed/extended widths, monospace as intentional accent (not as lazy "dev tool" default)
- Strengthen the existing type hierarchy before changing typefaces.
- Make important text meaningfully more dominant, and make supporting text quieter.
- Use weight, measure, spacing, and line breaks to sharpen the point of view.
- Add or replace fonts only after user-approved design-system expansion.
### Color Intensification
- **Increase saturation**: Shift to more vibrant, energetic colors (but not neon)
- **Bold palette**: Introduce unexpected color combinations. Avoid the purple-blue gradient AI slop
- **Dominant color strategy**: Let one bold color own 60% of the design
- **Sharp accents**: High-contrast accent colors that pop
- **Tinted neutrals**: Replace pure grays with tinted grays that harmonize with your palette
- **Rich gradients**: Intentional multi-stop gradients (not generic purple-to-blue)
### Color Amplification
- Use the existing palette more decisively before adding colors.
- Shift the proportion, placement, and contrast of documented colors to clarify meaning.
- Treat any new color, gradient, or tint ramp as a design-system expansion that requires user approval.
- Keep color tied to hierarchy, state, or brand meaning; do not use it as surface decoration.
### Spatial Drama
- **Extreme scale jumps**: Make important elements 3-5x larger than surroundings
- **Break the grid**: Let hero elements escape containers and cross boundaries
- **Asymmetric layouts**: Replace centered, balanced layouts with tension-filled asymmetry
- **Generous space**: Use white space dramatically (100-200px gaps, not 20-40px)
- **Overlap**: Layer elements intentionally for depth
### Spatial Amplification
- Change proportion, density, alignment, and sequencing so the composition has a stronger point of view.
- Create clearer contrast between dense evidence and open breathing room.
- Let layout express priority and narrative order before adding ornament.
- Preserve responsive behavior and avoid text overflow at every breakpoint.
### Visual Effects
- **Dramatic shadows**: Large, soft shadows for elevation (but not generic drop shadows on rounded rectangles)
- **Background treatments**: Mesh patterns, noise textures, geometric patterns, intentional gradients (not purple-to-blue)
- **Texture & depth**: Grain, halftone, duotone, layered elements. NOT glassmorphism (it's overused AI slop)
- **Borders & frames**: Thick borders, decorative frames, custom shapes (not rounded rectangles with colored border on one side)
- **Custom elements**: Illustrative elements, custom icons, decorative details that reinforce brand
### Surface Amplification
- Use existing surface, border, radius, and shadow rules more deliberately.
- Remove timid half-measures: either give an element a clear role or simplify it.
- Add texture, depth, illustration, or decorative treatments only when already established by the system or explicitly approved.
- Make real product artifacts, imagery, data, or copy carry attention before reaching for effects.
### Motion & Animation
- **Hero moment**: One signature entrance, once. Not on every visit and not on every section.
- **Micro-interactions**: Satisfying hover effects, click feedback, state changes.
- **Transitions**: Smooth, noticeable transitions using ease-out-quart/quint/expo (not bounce or elastic, which cheapen the effect).
- **Bolder scroll-fade-rise on every section.** That's the saturated AI default, the opposite of bold.
- Design one meaningful moment of emphasis when motion genuinely supports the point.
- Make interaction feedback feel more decisive without becoming distracting.
- Keep transitions smooth and intentional.
- **Bolder != scroll-fade-rise on every section.** That's the saturated AI default, the opposite of bold.
### Composition Boldness
- **Hero moments**: Create clear focal points with dramatic treatment
- **Diagonal flows**: Escape horizontal/vertical rigidity with diagonal arrangements
- **Full-bleed elements**: Use full viewport width/height for impact
- **Unexpected proportions**: Golden ratio? Throw it out. Try 70/30, 80/20 splits
- Make the dominant idea unmistakable.
- Use layout tension, sequencing, contrast, and restraint to create a stronger read.
- Let the page's structure communicate priority before adding decorative layers.
- If every element is louder, the composition is not bolder; it is flatter.
**NEVER**:
- Add effects randomly without purpose (chaos ≠ bold)
- Sacrifice readability for aesthetics (body text must be readable)
- Make everything bold (then nothing is bold; you need contrast)
- Ignore accessibility (bold design must still meet WCAG standards)
- Overwhelm with motion (animation fatigue is real)
- Copy trendy aesthetics blindly (bold means distinctive, not derivative)
- Add undocumented design-system primitives without user approval
- Add effects randomly without purpose
- Hide weak hierarchy behind decoration
- Sacrifice readability for aesthetics
- Make everything bold; contrast is the point
- Ignore accessibility
- Overwhelm with motion
- Copy trendy aesthetics blindly
## Verify Quality
Ensure amplification maintains usability and coherence:
- **System-faithful**: Did the pass make the existing design language stronger before adding anything new?
- **No undocumented drift**: Are new colors, gradients, shadows, radii, fonts, and effects either absent or explicitly approved and documented?
- **NOT AI slop**: Does this look like every other AI-generated "bold" design? If yes, start over.
- **Still functional**: Can users accomplish tasks without distraction?
- **Coherent**: Does everything feel intentional and unified?
- **Memorable**: Will users remember this experience?
- **Performant**: Do all these effects run smoothly?
- **Accessible**: Does it still meet accessibility standards?
- **Memorable**: Will users remember this experience for the intended reason?
- **Performant and accessible**: Does the result stay fast, readable, responsive, and WCAG-conscious?
**The test**: If you showed this to someone and said "AI made this bolder," would they believe you immediately? If yes, you've failed. Bold means distinctive, not "more AI effects."
@@ -5,8 +5,9 @@ Resolve one stable target, run two independent assessments, synthesize a design
### Hard Invariants
- Assessment A (design review) and Assessment B (detector/browser evidence) are both required.
- Assessment A and B MUST run as two isolated sub-agents whenever a sub-agent/Task tool is exposed. Running them inline in this context is "possible" but is NOT permitted; it is a degraded run. Inline is allowed ONLY when no sub-agent tool exists (or the user declined, on harnesses that ask).
- If you degrade for any reason, the report's first line MUST be a banner: `⚠️ DEGRADED: single-context (<reason>)`. A silent degraded critique is a failed critique.
- Assessment A must finish before detector findings enter the parent synthesis context. Detector output is deterministic, but it still anchors judgment.
- If sub-agents are unavailable, fall back sequentially: finish and record Assessment A first, then run Assessment B, then synthesize.
- A skipped detector is a failed critique run unless `detect.mjs` is missing or crashes after a real attempt.
- Viewable targets require browser inspection when available.
- Any local server started only for critique visualization must run in the background, have a recorded stop method, and be stopped before final reporting unless the user asks to keep it.
@@ -27,14 +28,21 @@ Resolve one stable target, run two independent assessments, synthesize a design
### Assessment Orchestration
Delegate Assessment A and Assessment B to separate sub-agents when possible. They must not see each other's output. Do not show findings to the user until synthesis.
Delegate Assessment A and Assessment B to separate sub-agents. They must not see each other's output. Do not show findings to the user until synthesis.
Codex sub-agent gate:
Sub-agent gate (all harnesses):
- Unless a harness-specific gate below overrides this, spawn A and B as two isolated, parallel sub-agents whenever a sub-agent/Task tool is exposed. This is the default and is mandatory; do not run them inline because it is faster.
- "Unavailable" means exactly one thing: no sub-agent/Task tool is exposed in this session (or, on harnesses that ask, the user declined). It does not mean inconvenient.
- If and only if sub-agents are unavailable, fall back sequentially: finish and record Assessment A, then run Assessment B, then synthesize, and emit the degraded banner.
- Whichever path you take, declare it in the report header (see Report header provenance). Skipping sub-agents without the banner is the most common failure of this command.
Codex sub-agent gate (overrides the default above; Codex's permission model requires asking before spawning):
- Asking is the normal path, not a degradation. Approving and spawning is the dual-agent path; do not emit the degraded banner just for asking.
- If `spawn_agent` is exposed and the user explicitly allowed sub-agents, delegation, or parallel agent work, spawn A and B immediately.
- If `spawn_agent` is exposed but the user did not explicitly allow sub-agents, ask exactly once: "Impeccable critique is designed to run two independent sub-agents for an unanchored assessment. May I use sub-agents for this critique?" Then stop until the user answers.
- If allowed, spawn A and B. If declined, run sequentially and report `Assessment independence: degraded (sub-agents declined by user)`.
- If `spawn_agent` is not exposed, do not ask; run sequentially and report `Assessment independence: degraded (spawn_agent unavailable in this session)`.
- If spawning fails after permission, run sequentially and report `Assessment independence: degraded (sub-agent spawn failed: <exact error>)`.
- If allowed, spawn A and B. If declined, run sequentially and lead the report with `⚠️ DEGRADED: single-context (sub-agents declined by user)`.
- If `spawn_agent` is not exposed, do not ask; run sequentially and lead with `⚠️ DEGRADED: single-context (spawn_agent unavailable in this session)`.
- If spawning fails after permission, run sequentially and lead with `⚠️ DEGRADED: single-context (sub-agent spawn failed: <exact error>)`.
Prefer `fork_context: false` with self-contained prompts containing cwd, target, live URL, references, product context, and output contract. If using `fork_context: true`, omit `agent_type`, `model`, and `reasoning_effort`.
If browser automation is available, each assessment creates its own new tab. Never reuse an existing tab, even if it is already at the right URL.
@@ -69,7 +77,7 @@ node .agents/skills/impeccable/scripts/detect.mjs --json [target]
Browser visualization is required for a viewable target when browser automation is available. Use a localhost dev/static URL for local files; avoid `file://` unless the available browser explicitly supports this workflow. Overlay flow:
1. Create a fresh tab and navigate.
1. Create a fresh tab and navigate. Prefer the harness's native/browser-canvas screenshot path before hand-rolling a Playwright/Puppeteer script; only fall back to a custom script when no native browser tool is exposed.
2. Preflight mutable injection by setting `document.title` and appending a `<script>` tag. Read-only evaluate APIs do not count.
3. If mutation is unavailable, skip live server, browser presentation, and injection; report fallback signal.
4. If mutation is available, start `node .agents/skills/impeccable/scripts/live-server.mjs --background`, present the browser if supported, label `[Human]`, scroll top, inject `http://localhost:PORT/detect.js`, wait 2-3 seconds, read `impeccable` console messages, then stop the live server.
@@ -93,6 +101,12 @@ Codex final-answer note: `$impeccable critique` produces a report artifact, so t
Structure your feedback as a design director would:
#### Report header provenance
The report's first line MUST declare how the assessments were run, so a degraded run is never silent:
- Dual-agent: `Method: dual-agent (A: <agent-id> · B: <agent-id>)`
- Degraded: `⚠️ DEGRADED: single-context (<reason, e.g. no sub-agent tool exposed>)`
#### Design Health Score
> *Consult the [Heuristics Scoring Guide](#heuristics-scoring-guide) section below.*
@@ -1,6 +1,6 @@
Generate a `DESIGN.md` file at the project root that captures the current visual design system, so AI agents generating new screens stay on-brand.
DESIGN.md follows the [official Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/): YAML frontmatter carrying machine-readable design tokens, followed by a markdown body with exactly six sections in a fixed order. **Tokens are normative; prose provides context for how to apply them.** Sections may be omitted when not relevant, but **do not reorder them and do not rename them**. Section headers must match the spec character-for-character so the file stays parseable by other DESIGN.md-aware tools (Stitch itself, awesome-design-md, skill-rest, etc.).
DESIGN.md follows the [official DESIGN.md format spec](https://raw.githubusercontent.com/google-labs-code/design.md/main/docs/spec.md): YAML frontmatter carrying machine-readable design tokens, followed by a markdown body with exactly six sections in a fixed order. **Tokens are normative; prose provides context for how to apply them.** Sections may be omitted when not relevant, but **do not reorder them and do not rename them**. Section headers must match the spec character-for-character so the file stays parseable by other DESIGN.md-aware tools (Stitch itself, awesome-design-md, skill-rest, etc.).
## The frontmatter: token schema
+8 -6
View File
@@ -2,13 +2,15 @@
Manage the **design detector hook** for the current project.
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code and Codex use `PostToolUse` and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies.
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project).
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands.
@@ -51,7 +53,7 @@ Prefer the narrowest exception:
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism.
- Prefer config ignores (the commands above) by default; they keep suppressions in one reviewable place. Reach for an inline comment only when the waiver must travel with a single file that leaves the repo (a generated/exported standalone document, an emailed HTML file). The supported marker is `impeccable-disable <rule>` (whole file) or `impeccable-disable-line` / `impeccable-disable-next-line` (one line), in any comment syntax, with an optional reason after `:` or `--`. The detector honors it by default; `--no-inline-ignores` or `--no-config` bypasses it.
Example value-specific exception:
@@ -79,10 +81,10 @@ node .agents/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca
## Constraints
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent. One exception: `detector.extensions` has no admin action, so when the user asks to cover a template stack, edit that one field in `.impeccable/config.json` directly and leave the rest of the file untouched.
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
- Cursor can block a proposed write when the detector finds a real issue. Claude Code and Codex do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, and `.cursor/hooks.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks.
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
## Failure modes
+64 -15
View File
@@ -3,7 +3,7 @@
The setup command for a project. One codebase crawl feeds everything it writes:
- **PRODUCT.md** (strategic): root project file for register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why".
- **DESIGN.md** (visual): root project file for visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks".
- **DESIGN.md** (visual): root project file for visual theme, color palette, typography, components, layout. Follows the [DESIGN.md format spec](https://raw.githubusercontent.com/google-labs-code/design.md/main/docs/spec.md). Answers "how it looks".
- **`.impeccable/live/config.json`** (live mode): pre-configured so `$impeccable live` boots straight into variant mode with no first-time detour.
It closes by pointing the user at the best command to run next. Every other impeccable command reads PRODUCT.md and DESIGN.md before doing any work.
@@ -16,6 +16,7 @@ Decision tree:
- **Neither file exists (empty project or no context yet)**: do Steps 2-4 (write PRODUCT.md), then decide on DESIGN.md based on whether there's code to analyze.
- **PRODUCT.md exists, DESIGN.md missing**: skip to Step 5 and offer to run `$impeccable document` for DESIGN.md.
- **PRODUCT.md exists but has no `## Register` section (legacy)**: add it. Infer a hypothesis from the codebase (see Step 2), confirm with the user, write the field.
- **PRODUCT.md exists but has no `## Platform` section (legacy)**: add it the same way, but only when the project is native (`ios` / `android` / `adaptive`) or the user wants it explicit; a missing field already means `web`.
- **Both exist**: STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. Ask which file to refresh. Skip the one the user doesn't want changed.
- **Just DESIGN.md exists (unusual)**: do Steps 2-4 to produce PRODUCT.md.
@@ -41,26 +42,34 @@ Also form a **register hypothesis** from what you find:
Register is a hypothesis at this point, not a decision; Step 3 confirms it.
Also form a **platform hypothesis**:
- Native signals: React Native / Expo (`react-native`, `expo`), Flutter (`pubspec.yaml`, `flutter`), SwiftUI / UIKit (`.swift`, `.xcodeproj`, an `ios/` app target), Jetpack Compose / Android (`build.gradle`, an `android/` app module, `AndroidManifest.xml`). An `ios/` and/or `android/` directory that is a real app target, not just a Capacitor/Cordova wrapper around a website.
- Web signals (the default): a web framework (Vite, Next, Nuxt, SvelteKit, Astro), an HTML entry, a CSS/Tailwind setup, no native app target.
Values: `web` / `ios` / `android` / `adaptive` (one codebase, ships both, adapts per OS). Mobile web is still `web`. Like register, this is a hypothesis; Step 3 confirms it.
Note what you've learned and what remains unclear. Also note any rough edges worth a follow-up command (thin hierarchy, flat or gray palette, missing error/empty states, dull copy); Step 7 turns these into concrete recommendations without re-analyzing.
## Step 3: Ask strategic questions (for PRODUCT.md)
STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. Ask only about what you couldn't infer from the codebase.
STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. Ask about anything the codebase doesn't answer with strong, explicit evidence.
### Interview mode, not confirmation mode
If the repo is empty or the user's brief is sparse, run a short interview before proposing PRODUCT.md. Do **not** turn a one-sentence request into a complete inferred PRODUCT.md and ask for blanket confirmation.
- Use the harness's structured question tool when one exists. Otherwise, ask directly in chat and stop.
- Ask **2-3 questions per round**, then wait for answers.
- Use the harness's structured question tool when one exists. Otherwise, ask directly in chat and stop: one question at a time, with lettered options where the crawl suggests likely answers, waiting for each answer before the next.
- Keep skill vocabulary (register, belief ladder, anti-references) out of question text; ask for the thing in words the user would use. For the brand register, ask like a magazine editor profiling the brand: curious and narrative, drawing out the story, the feel, and what a visitor should come to believe.
- Ask in focused rounds and wait for answers between them. Keep **one topic per question**; add rounds rather than fold several topics into one either-or choice. Options obey the same rule: an option answers only the question asked; never write a compound option that bundles a feeling with a business outcome or names an additional audience.
- Use inferred answers as hypotheses or options, not as finished facts.
- Complete at least one real user-answer round before drafting PRODUCT.md, unless every required answer is directly discoverable from repo docs.
- Round 1 should establish register, users/purpose, and desired outcome.
- Round 2 should establish brand personality or references, anti-references, and accessibility needs.
- Round 1 should establish register, platform, users, purpose, positioning, and desired outcome.
- Round 2 should establish brand personality or references, anti-references, and accessibility needs, plus conversion & proof for the brand register.
### Minimum viable interview
Ask enough to complete PRODUCT.md. At minimum, cover register confirmation, users and purpose, brand personality, anti-references, and accessibility needs unless each answer is directly discoverable from repo context. After at least one interview round, you may propose inferred answers, but the user must confirm them before you write PRODUCT.md. Never synthesize PRODUCT.md from the original task prompt alone.
Ask enough to complete PRODUCT.md. At minimum, cover register confirmation, **platform confirmation** (`web` / `ios` / `android` / `adaptive`), users, purpose, positioning, brand personality, anti-references, and accessibility needs (plus conversion & proof for the brand register) unless each answer is directly discoverable from repo context. Never let the template's default `web` stand unconfirmed for a native or cross-platform repo. After at least one interview round, you may propose inferred answers, but the user must confirm them before you write PRODUCT.md. Never synthesize PRODUCT.md from the original task prompt alone.
### Register (ask first; it shapes everything below)
@@ -68,20 +77,42 @@ Every design task is either **brand** (marketing, landing, campaign, long-form c
If Step 2 produced a clear hypothesis, lead with it: *"From the codebase, this looks like a [brand / product] surface. Does that match your intent, or should we treat it differently?"*
If the signal is genuinely split (e.g. a product with a big marketing landing), STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. Ask which register describes the **primary** surface. The register can be overridden per task later, but PRODUCT.md carries one default.
If the signal is genuinely split (e.g. a product with a big marketing landing), STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. Ask which register describes the **primary** surface. The register can be overridden per task later, but PRODUCT.md carries one default. Settle the default before drafting any register-dependent questions; never batch brand-only questions (Conversion & proof) into the same round as the question that decides the register.
### Platform (ask right after register)
Every project targets **web** (includes responsive mobile web), **ios**, **android**, or **adaptive** (one codebase, ships both, adapts per OS: Flutter, React Native, KMP). Platform picks the native rulebook: HIG for `ios`, Material 3 for `android`, both for `adaptive`, none for `web`.
If Step 2 produced a clear hypothesis, lead with it: *"From the codebase, this looks like a [web / ios / android / adaptive] project. Does that match?"* For cross-platform apps, decide by the **design language the app renders**, not the toolchain: one look on both platforms (Flutter's Material-everywhere default) takes that platform's value; genuine per-OS adaptation (Cupertino on iOS, Material on Android) is `adaptive`. When in doubt, `web`.
A monorepo shipping both a website and a native app gets a PRODUCT.md per app, each with its own `## Platform`; the root PRODUCT.md carries the primary surface's platform.
### Users & Purpose
- Who uses this? What's their context when using it?
- What job are they trying to get done?
- For brand: what emotions should the interface evoke? (confidence, delight, calm, urgency)
- What is this for? A purpose stated in README or docs is a hypothesis, not strong evidence; confirm it, don't transcribe it.
- What does success look like?
- If more than one kind of user is plausible, confirm a primary and secondary audience; don't manufacture a split that isn't there. An audience implied by another answer (a success metric, a CTA) is still unconfirmed; ask before writing it as secondary.
- If the surface speaks to a different audience than the people who use the product, ask the user to name both.
- For brand: what emotions should the interface evoke? (confidence, delight, calm, urgency) Ask this standalone; don't fold emotions into the success question.
- For product: what workflow are they in? What's the primary task on any given screen?
### Positioning
- In one line, what does this do that nothing else does? The single strategic claim every screen reinforces.
### Brand & Personality
- How would you describe the brand personality in 3 words?
- Reference sites or apps that capture the right feel? What specifically about them?
- Push for specific named references with the *specific* thing about them that fits this brand, not generic "modern" adjectives or category-bucket lanes.
- What should this explicitly NOT look like? Any anti-references?
### Conversion & proof (brand register only)
- What's the primary CTA?
- What's the secondary fallback, for visitors not ready for the primary?
- The one line a visitor should remember after 10 seconds.
- What must the visitor believe, in order, before taking the primary CTA? (The template's belief ladder.)
- What proof is on hand? Ask the user to hand over any testimonials, case studies, press, or client/partner logos they already have. If you can receive files directly, collect them; otherwise create `.impeccable/assets/proof/` and ask the user to add files there. Reference supplied files by path; record text proof inline.
### Accessibility & Inclusion
- Specific accessibility requirements? (WCAG level, known user needs)
- Considerations for reduced motion, color blindness, or other accommodations?
@@ -90,7 +121,7 @@ Skip questions where the answer is already clear. **Do NOT ask about colors, fon
## Step 4: Write PRODUCT.md
Write PRODUCT.md only after the user has confirmed the strategic answers from Step 3. If an inferred answer is uncertain or unconfirmed, ask before writing.
Write PRODUCT.md only after the user has confirmed the strategic answers from Step 3. If an inferred answer is uncertain or unconfirmed, ask before writing. Confirmed means what the user actually said yes to; do not pad a confirmed answer with extras they never picked (additional anti-references, audiences, roadmap claims, a WCAG level), whether drawn from the crawl, another answer, or your own option text. If an extra belongs in the doc, ask about it first.
Synthesize into a strategic document:
@@ -101,12 +132,26 @@ Synthesize into a strategic document:
product
## Platform
web
## Users
[Who they are, their context, the job to be done]
[Who they are, their context, the job to be done. Primary audience; a secondary audience or a surface-vs-user split only when they apply.]
## Product Purpose
[What this product does, why it exists, what success looks like]
## Positioning
[The single strategic claim every screen reinforces. Not a visual rule, not an anti-reference.]
## Conversion & proof
[Brand register only. Product register: omit this section entirely, heading included.]
- Primary and secondary CTA: [...]
- The line a visitor remembers after 10 seconds: [...]
- Belief ladder: [...]
- Proof on hand: [testimonials, case studies, press, or logos, referenced by path]
## Brand Personality
[Voice, tone, 3-word personality, emotional goals]
@@ -120,7 +165,9 @@ product
[WCAG level, known user needs, considerations]
```
Register is either `brand` or `product` as a bare value. No prose, no commentary.
Register is either `brand` or `product` as a bare value. No prose, no commentary. Platform is `web`, `ios`, `android`, or `adaptive`, also a bare value; omit the section only on legacy files you're leaving untouched, otherwise write `web` explicitly.
Write fields as prose, and use bold sparingly: only where a word carries a decision, never as a label lead-in on every line.
Write to `PROJECT_ROOT/PRODUCT.md`. If `.impeccable.md` existed, the loader already renamed it; merge into that content rather than starting from scratch.
@@ -137,6 +184,8 @@ If the user prefers to skip, mention they can run `$impeccable document` any tim
## Step 6: Configure live mode (when code exists)
**Skip this step when the platform is native** (`ios` / `android` / `adaptive`): live mode drives a browser overlay. A hybrid wrapper or Expo web target serving HTML doesn't change that.
If the project has code with HTML entries and a dev server (the same "code exists" condition that puts `$impeccable document` in scan mode), pre-configure live mode now. You already identified the framework and the served HTML entry in Step 2, so this is nearly free, and it spares the user the first-time setup detour when they later run `$impeccable live`.
**Skip this step for empty / pre-implementation projects** (nothing to inject into yet). Tell the user live mode will configure itself the first time they run it once there's code.
@@ -154,16 +203,16 @@ Writing the config file is harmless and needs no consent; only the CSP **source-
## Step 7: Recommend starting points, then wrap up
Summarize tersely:
- Register captured (brand / product)
- Register captured (brand / product) and platform captured (web / ios / android / adaptive)
- What was written (PRODUCT.md, DESIGN.md, live config, or a subset)
- The 3-5 strategic principles from PRODUCT.md that will guide future work
- If DESIGN.md or live config is pending, one line on how to set it up later
Then recommend the **best commands to run next**, drawn from what your Step 2 crawl already surfaced. Do not run a fresh analysis here; surface observations you already have. Tailor to register and to what you saw, offer the 2-4 most relevant (not a menu dump), and give the exact command to type. Group by intent:
Then recommend the **best commands to run next**, drawn from what your Step 2 crawl already surfaced. Do not run a fresh analysis here; surface observations you already have. Tailor to register **and platform**, offer the 2-4 most relevant (not a menu dump), and give the exact command to type. Group by intent:
- **Build something new**: `$impeccable craft <feature>` (shape, then build end-to-end) or `$impeccable shape <feature>` (plan first). Lead with this for empty or early-stage projects.
- **Improve what's there**: name the specific surface. `$impeccable critique <page>` for a scored UX review; `$impeccable audit <area>` for a11y / perf / responsive checks; `$impeccable polish <component>` for a pre-ship pass. When the crawl flagged a specific weakness, point the matching command at it: thin hierarchy or spacing → `layout`, flat or gray palette → `colorize`, missing error / empty states → `harden` or `onboard`, dull or unclear copy → `clarify`.
- **Iterate visually**: `$impeccable live` (configured in Step 6) to pick elements in the browser and generate variants in place.
- **Iterate visually** (web only): `$impeccable live` (configured in Step 6) to pick elements in the browser and generate variants in place. **Skip this group for native platforms.**
The full command menu is one bare `$impeccable` away; keep this list short and pointed.
@@ -0,0 +1,45 @@
# iOS platform
For native iOS / iPadOS apps: SwiftUI, UIKit, React Native, Expo, Flutter shipping to Apple hardware.
On native, register narrows. HIG conformance governs structure, navigation, and interaction whatever the register; brand expresses through the expressive layer the platform provides (tint, type, motion, content). Calm, Duolingo, and Spotify carry strong identity entirely inside HIG conventions.
## The iOS slop test
Would a fluent iPhone user trust this app, or pause at off-spec controls? The tell is "ported from a website": reinvented navigation bars, custom back gestures, web-shaped buttons, hover-dependent affordances. Default to the platform's components; depart only for a reason the user would thank you for.
## Layout & structure
- **Safe area.** Lay out inside the safe-area insets. No controls under the notch, Dynamic Island, home indicator, or rounded corners.
- **System navigation.** Tab bar for 25 top-level sections (sections, never actions), navigation stack for hierarchy, sheet for self-contained tasks. No custom global nav, no mixed metaphors.
- **Edge-swipe back stays alive.** The left-edge back gesture is muscle memory; never disable or overlay it.
- **Large titles** on top-level screens, collapsing to inline on scroll. Deep detail screens stay inline.
## Touch targets
- **44×44 pt minimum** for every tappable control, with breathing room between adjacent targets.
## Typography
- **Dynamic Type.** Use the system text styles (Large Title through Caption) so text follows the user's reading size. No hard-coded point sizes.
- **San Francisco carries the UI.** Body, labels, and controls stay on SF Pro / SF Compact; a brand face may appear in display moments.
- **11 pt floor**; Body is 17 pt.
## Color & materials
- **Semantic system colors** (label, secondaryLabel, systemBackground, separator, tint). They adapt to Dark Mode and increased contrast automatically; raw hex breaks there.
- **Dark Mode is a first-class appearance.** Design and test both.
- **One tint color** drives interactive elements; decoration is not its job.
- **System materials** for blur and translucency behind bars and sheets; no hand-rolled glassmorphism.
## Components & controls
- **Platform controls.** Switch, segmented control, stepper, system pickers, action sheets, alerts, context menus, swipe actions. Reinventing these for flavor is the most common native slop.
- **SF Symbols** for iconography: baseline-aligned, Dynamic Type-aware, weight and scale variants. Don't mix in a web icon set.
- **Deliberate modality.** Sheet for a focused dismissible sub-task, full-screen cover for immersion. Clear Cancel/Done; honor swipe-to-dismiss unless data loss requires a guard.
- **Grouped/inset lists** for settings-shaped content; no bespoke card stacks.
## Motion
- **System transitions.** Push slides, sheets rise, dismiss reverses the entrance. Custom transitions that fight the navigation model disorient.
- **Honor Reduce Motion.** Crossfade instead of parallax and large slides.
+25 -1
View File
@@ -8,11 +8,33 @@ Brand: asymmetric compositions, fluid spacing with `clamp()`, intentional grid-b
Product: predictable grids, consistent densities, familiar navigation patterns. Responsive behavior is structural (collapse sidebar, responsive table), not fluid typography. Consistency IS an affordance.
Native (`ios` / `android` / `adaptive`): structure follows the Layout section of [ios.md](ios.md) / [android.md](android.md) (read it first if Setup hasn't already): platform navigation, insets, and touch targets, never the CSS tooling below.
---
## Two isolated assessments (required)
Spawn two parallel sub-agents whenever a sub-agent/Task tool is exposed: one for the layout assessment, one for the mechanical pre-scan. If the harness needs explicit user permission for sub-agents, stop and ask before proceeding. Isolation is the point: detector output anchors visual judgment toward what the scan can see, so neither sub-agent gets the other's output. Each assessment runs in its own sub-agent; running either one in this context when a sub-agent tool exists is not permitted, even when it is faster; the fallback below is only for sessions with no sub-agent tool. Give each a self-contained prompt (target files, register, documented spacing scale when present, and its instructions below); do not assume it can read this file.
**Sub-agent A (layout assessment)**: give it the full [Assess Current Layout](#assess-current-layout) checklist below, verbatim, in its prompt. It works through every item and returns per-item findings citing file, selector, or value.
**Sub-agent B (mechanical pre-scan)**: run the bundled detector scoped to layout:
```bash
node .agents/skills/impeccable/scripts/detect.mjs --json --scope layout [target files or dirs]
```
A missing `node` on PATH is not permission to skip: hunt for a runtime (`command -v node`, nvm or Homebrew paths, the harness's own bundled node) and run it by full path. If none exists, halt the scan and report that Node must be installed (the parent relays this to the user); do **not** substitute grep for the detector or proceed unscanned. The detector abstains on arbitrary Tailwind spacing (`gap-[13px]`, `p-[7px]`) and ad-hoc `z-index` stacks, so when the project documents a spacing scale, also grep `gap-\[`, `p[trblxy]?-\[`, `m[trblxy]?-\[`, `z-\[` and judge those hits against it. Return the findings JSON plus the grep verdicts.
**If no sub-agent tool is exposed (or the user declined)**: run both yourself, assessment first, pre-scan second, so the deterministic findings can't anchor the visual judgment. Keep that order even when the scan feels quicker to start with.
**Synthesize** once both are done: merge into a single findings list, noting where they agree and what each caught alone. Fix every finding, or list it as a deliberate exception for the user to accept. A clean scan is a floor, not a verdict: a monotone grid with uniform spacing passes every detector rule, which is exactly what the assessment exists to catch. State in your final summary which path ran (parallel sub-agents or single-context fallback).
---
## Assess Current Layout
Analyze what's weak about the current spatial design:
This checklist is sub-agent A's brief (on the fallback path, work through it yourself before the pre-scan). Analyze what's weak about the current spatial design:
1. **Spacing**:
- Is spacing consistent or arbitrary? (Random padding/margin values)
@@ -138,6 +160,8 @@ Create a systematic plan:
- **Consistency**: Is the spacing system applied uniformly?
- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
Answer each item above by citing the file, selector, or value that satisfies it; never a bare yes. Then re-run the pre-scan and fix until the count of unresolved items and unaccepted findings is zero.
When the rhythm and hierarchy land, hand off to `$impeccable polish` for the final pass.
## Live-mode signature params
+1 -1
View File
@@ -10,7 +10,7 @@ Codex: run live helper commands, the app dev server, and any dependency-installi
Execute in order. No step skipped, no step reordered.
1. `live.mjs`: boot.
1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .agents/skills/impeccable/scripts/live.mjs --target <path>` instead; then run the rest of this live session from the returned `projectRoot`.
2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once.
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
+23 -1
View File
@@ -10,9 +10,29 @@ Product: system fonts and familiar sans stacks are legitimate here. One well-tun
---
## Two isolated assessments (required)
Spawn two parallel sub-agents whenever a sub-agent/Task tool is exposed: one for the typography assessment, one for the mechanical pre-scan. If the harness needs explicit user permission for sub-agents, stop and ask before proceeding. Isolation is the point: detector output anchors visual judgment toward what the scan can see, so neither sub-agent gets the other's output. Each assessment runs in its own sub-agent; running either one in this context when a sub-agent tool exists is not permitted, even when it is faster; the fallback below is only for sessions with no sub-agent tool. Give each a self-contained prompt (target files, register, **DESIGN.md** content when present, and its instructions below); do not assume it can read this file.
**Sub-agent A (typography assessment)**: give it the full [Assess Current Typography](#assess-current-typography) checklist below, verbatim, in its prompt. It works through every item and returns per-item findings citing file, selector, or value.
**Sub-agent B (mechanical pre-scan)**: run the bundled detector scoped to type:
```bash
node .agents/skills/impeccable/scripts/detect.mjs --json --scope type [target files or dirs]
```
A missing `node` on PATH is not permission to skip: hunt for a runtime (`command -v node`, nvm or Homebrew paths, the harness's own bundled node) and run it by full path. If none exists, halt the scan and report that Node must be installed (the parent relays this to the user); do **not** substitute grep for the detector or proceed unscanned. The scan checks literal font sizes against the **DESIGN.md** ramp but abstains on `em`, `%`, `clamp()`, and line-heights, so also grep `font-size\s*:`, `fontSize`, `text-\[`, `leading-\[` and judge those hits against the spec. Return the findings JSON plus the grep verdicts.
**If no sub-agent tool is exposed (or the user declined)**: run both yourself, assessment first, pre-scan second, so the deterministic findings can't anchor the visual judgment. Keep that order even when the scan feels quicker to start with.
**Synthesize** once both are done: merge into a single findings list, noting where they agree and what each caught alone. Fix every finding, or list it as a deliberate exception for the user to accept. A clean scan is a floor, not a verdict: a generic font stack at a flat scale passes every detector rule, which is exactly what the assessment exists to catch. State in your final summary which path ran (parallel sub-agents or single-context fallback).
---
## Assess Current Typography
Analyze what's weak or generic about the current type:
This checklist is sub-agent A's brief (on the fallback path, work through it yourself before the pre-scan). Analyze what's weak or generic about the current type:
1. **Font choices**:
- Are we using invisible defaults? (Inter, Roboto, Arial, Open Sans, system defaults)
@@ -109,6 +129,8 @@ Build a clear type scale:
- **Performance**: Are web fonts loading efficiently without layout shift?
- **Accessibility**: Does text meet WCAG contrast ratios? Is it zoomable to 200%?
Answer each item above by citing the file, selector, or value that satisfies it; never a bare yes. Then re-run the pre-scan and fix until the count of unresolved items and unaccepted findings is zero.
When the type carries the hierarchy on its own, hand off to `$impeccable polish` for the final pass.
## Live-mode signature params
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* Context-signals gatherer for the bare `{{command_prefix}}impeccable`
* Context-signals gatherer for the bare Impeccable invocation
* (no-argument) path. Collects cheap, deterministic signals about the current
* project and emits them as JSON.
*
@@ -21,7 +21,7 @@ import net from 'node:net';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { loadContext, extractRegister } from './context.mjs';
import { loadContext, extractRegister, extractPlatform } from './context.mjs';
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
/** Is there code here at all, or just context files / an empty repo? */
@@ -197,6 +197,7 @@ export async function gatherSignals(cwd = process.cwd()) {
designPath: ctx.designPath,
hasCode: hasCode(cwd),
register: extractRegister(ctx.product),
platform: extractPlatform(ctx.product),
},
critique: { latest: latestCritique(cwd) },
git,
+788 -45
View File
@@ -1,15 +1,18 @@
/**
* Context loader: prints PRODUCT.md (and DESIGN.md if present) as one
* markdown block on stdout, or exits with empty stdout when no PRODUCT.md
* is found anywhere. The skill keys off "empty stdout" to branch into the
* init flow.
* markdown block on stdout, or prints a `NO_PRODUCT_MD:` message when no
* PRODUCT.md is found anywhere. The skill keys off that message to branch:
* from-scratch build commands (init / teach / craft / shape) and clear
* build/shape intent divert into the init flow, while scoped commands proceed
* using the existing code as context.
*
* Path resolution (first match wins):
* 1. cwd, if PRODUCT.md or DESIGN.md is there
* 2. .agents/context/ then docs/
* 3. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) power-user
* 1. Active project root, if PRODUCT.md or DESIGN.md is there
* 2. Active project .agents/context/ then docs/
* 3. Monorepo root context, using the same order, as a per-file fallback
* 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) power-user
* escape hatch, only consulted when defaults are empty
* 4. cwd as a "nothing found" default
* 5. Active project root as a "nothing found" default
*
* `resolveContextDir()` and `loadContext()` are also exported for the
* server-side scripts (live.mjs, live-server.mjs) that need the structured
@@ -19,10 +22,26 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseTargetOptions } from './lib/target-args.mjs';
import { IMPECCABLE_COMMAND } from './lib/provider.mjs';
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
const FALLBACK_DIRS = ['.agents/context', 'docs'];
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
const WORKSPACE_DISCOVERY_IGNORED_DIRS = new Set([
'node_modules',
'.git',
'dist',
'build',
'.next',
'.nuxt',
'.svelte-kit',
'.turbo',
'.cache',
'coverage',
]);
// ─── Update check ──────────────────────────────────────────────────────────
// Piggyback a lightweight skill-version check on the once-per-session boot.
@@ -38,41 +57,623 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o
const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week
const FETCH_TIMEOUT_MS = 1200;
export function resolveContextDir(cwd = process.cwd()) {
if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
return cwd;
}
for (const rel of FALLBACK_DIRS) {
const candidate = path.resolve(cwd, rel);
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
return candidate;
}
}
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
if (envDir && envDir.trim()) {
const trimmed = envDir.trim();
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
}
return cwd;
export function resolveContextDir(cwd = process.cwd(), options = {}) {
return resolveContext(cwd, options).contextDir;
}
export function loadContext(cwd = process.cwd()) {
const contextDir = resolveContextDir(cwd);
const productPath = firstExisting(contextDir, PRODUCT_NAMES);
const designPath = firstExisting(contextDir, DESIGN_NAMES);
export function loadContext(cwd = process.cwd(), options = {}) {
const resolved = resolveContext(cwd, options);
const absCwd = path.resolve(cwd);
const productPath = resolved.productPath;
const designPath = resolved.designPath;
const product = productPath ? safeRead(productPath) : null;
const design = designPath ? safeRead(designPath) : null;
return {
hasProduct: !!product,
product,
productPath: productPath ? path.relative(cwd, productPath) : null,
productPath: productPath ? path.relative(absCwd, productPath) : null,
hasDesign: !!design,
design,
designPath: designPath ? path.relative(cwd, designPath) : null,
contextDir,
designPath: designPath ? path.relative(absCwd, designPath) : null,
contextDir: resolved.contextDir,
productContextDir: productPath ? path.dirname(productPath) : null,
designContextDir: designPath ? path.dirname(designPath) : null,
projectRoot: resolved.projectRoot,
repoRoot: resolved.repoRoot,
isMonorepo: resolved.isMonorepo,
};
}
function resolveContext(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd);
const project = resolveProject(absCwd, options);
const projectContextDir = resolveLocalContextDir(project.projectRoot);
const rootContextDir = project.isMonorepo && project.repoRoot !== project.projectRoot
? resolveLocalContextDir(project.repoRoot)
: null;
let productPath =
(projectContextDir ? firstExisting(projectContextDir, PRODUCT_NAMES) : null)
|| (rootContextDir ? firstExisting(rootContextDir, PRODUCT_NAMES) : null);
let designPath =
(projectContextDir ? firstExisting(projectContextDir, DESIGN_NAMES) : null)
|| (rootContextDir ? firstExisting(rootContextDir, DESIGN_NAMES) : null);
let envContextDir = null;
if (!productPath && !designPath) {
envContextDir = resolveEnvContextDir(absCwd);
if (envContextDir) {
productPath = firstExisting(envContextDir, PRODUCT_NAMES);
designPath = firstExisting(envContextDir, DESIGN_NAMES);
}
}
return {
contextDir: productPath
? path.dirname(productPath)
: designPath
? path.dirname(designPath)
: envContextDir || project.projectRoot,
productPath,
designPath,
projectRoot: project.projectRoot,
repoRoot: project.repoRoot,
isMonorepo: project.isMonorepo,
targetDir: project.targetDir,
};
}
export function resolveProjectRoot(cwd = process.cwd(), options = {}) {
return resolveProject(cwd, options).projectRoot;
}
export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
if (hasTargetOption(options)) return null;
const project = resolveProject(cwd);
if (
!project.isMonorepo
|| !project.projectRoot
|| !project.repoRoot
|| path.resolve(project.projectRoot) !== path.resolve(project.repoRoot)
) {
return null;
}
const targetCandidates = discoverTargetCandidates(project.repoRoot);
// No discoverable child apps (e.g. `workspaces: ["."]`, a root-only workspace,
// or a marker file with no apps/packages children): there is nothing to choose,
// so treat the repo root as the active project rather than blocking on an empty
// selection prompt that the user cannot answer.
if (targetCandidates.length === 0) return null;
return {
targetPath: null,
projectRoot: project.projectRoot,
repoRoot: project.repoRoot,
targetCandidates,
};
}
function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options);
let repoRoot = findMonorepoRoot(targetDir);
if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
repoRoot = cwdRepoRoot;
}
}
if (!repoRoot) {
return {
targetDir,
projectRoot: absCwd,
repoRoot: absCwd,
isMonorepo: false,
};
}
return {
targetDir,
projectRoot: resolveWorkspaceProjectRoot(repoRoot, targetDir) || repoRoot,
repoRoot,
isMonorepo: true,
};
}
function isPathInside(candidate, root) {
const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
}
function resolveLocalContextDir(root) {
if (firstExisting(root, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
return root;
}
for (const rel of FALLBACK_DIRS) {
const candidate = path.resolve(root, rel);
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
return candidate;
}
}
return null;
}
function resolveEnvContextDir(cwd) {
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
if (!envDir || !envDir.trim()) return null;
const trimmed = envDir.trim();
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
}
function resolveTargetDir(cwd, options = {}) {
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
if (!targetPath || !String(targetPath).trim()) return cwd;
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
try {
const stat = fs.statSync(abs);
return stat.isDirectory() ? abs : path.dirname(abs);
} catch {
return path.extname(abs) ? path.dirname(abs) : abs;
}
}
function findMonorepoRoot(startDir) {
let dir = path.resolve(startDir);
const homeDir = path.resolve(os.homedir());
while (true) {
if (dir === homeDir) return null;
// isMonorepoRoot is checked before hasGitBoundary on purpose: a workspace
// root that also carries its own .git is still recognized. The trade-off is
// deliberate — a directory with a monorepo *marker* but no workspace patterns
// and no apps/packages children is not a monorepo root, so its .git stops
// traversal and a further-up root is not searched. The nested .git is treated
// as an independent project boundary, which is the intended isolation.
if (isMonorepoRoot(dir)) return dir;
if (hasGitBoundary(dir)) return null;
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
function isMonorepoRoot(dir) {
if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true;
if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false;
return hasFallbackWorkspaceChildren(dir);
}
function hasGitBoundary(dir) {
return fs.existsSync(path.join(dir, '.git'));
}
function hasFallbackWorkspaceChildren(dir) {
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
const base = path.join(dir, name);
let entries;
try {
entries = fs.readdirSync(base, { withFileTypes: true });
} catch {
continue;
}
if (entries.some((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))) return true;
}
return false;
}
function discoverTargetCandidates(repoRoot) {
const roots = new Map();
const patterns = readWorkspacePatterns(repoRoot);
for (const pattern of patterns) {
for (const root of discoverRootsForPattern(repoRoot, pattern)) {
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
}
}
if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) {
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
const base = path.join(repoRoot, name);
let entries;
try {
entries = fs.readdirSync(base, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
const root = path.join(base, entry.name);
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
}
}
}
return [...roots.entries()]
.filter(([rel]) => rel && !rel.startsWith('..'))
// Honor negated workspace patterns (e.g. "!packages/internal"). resolveWorkspaceProjectRoot
// sends an excluded package back to the repo root, so an excluded folder must not appear as a
// selectable target — choosing it would silently resolve to the root instead.
.filter(([rel]) => !isExcludedByWorkspacePattern(rel.split('/').filter(Boolean), patterns))
.sort(([a], [b]) => a.localeCompare(b))
.map(([rel, root]) => {
const targetExample = findTargetExample(repoRoot, root);
return {
name: path.basename(root),
path: rel,
targetExample,
...resolveCandidateContextSummary(repoRoot, root, targetExample),
};
});
}
function resolveCandidateContextSummary(repoRoot, projectRoot, targetPath) {
const ctx = resolveContext(repoRoot, { targetPath });
return {
productStatus: contextSourceStatus(ctx.productPath, repoRoot, projectRoot),
productPath: contextSourcePath(ctx.productPath, repoRoot),
designStatus: contextSourceStatus(ctx.designPath, repoRoot, projectRoot),
designPath: contextSourcePath(ctx.designPath, repoRoot),
};
}
// Selection candidates surface one of four statuses: 'child' (a canonical
// PRODUCT.md/DESIGN.md directly in the app root), 'inherited' (resolved from the
// repo root in a monorepo), 'missing' (no file found), and 'fallback'. 'fallback'
// intentionally covers two non-canonical locations: a file inside the project
// root but in a subdirectory (FALLBACK_DIRS, e.g. `.agents/context/`), and a file
// outside both the project and repo roots (IMPECCABLE_CONTEXT_DIR override).
function contextSourceStatus(filePath, repoRoot, projectRoot) {
if (!filePath) return 'missing';
const absPath = path.resolve(filePath);
const absProjectRoot = path.resolve(projectRoot);
const absRepoRoot = path.resolve(repoRoot);
if (isPathInsideOrEqual(absPath, absProjectRoot)) {
return path.dirname(absPath) === absProjectRoot ? 'child' : 'fallback';
}
if (absProjectRoot !== absRepoRoot && isPathInsideOrEqual(absPath, absRepoRoot)) {
return 'inherited';
}
return 'fallback';
}
function contextSourcePath(filePath, repoRoot) {
if (!filePath) return null;
const rel = path.relative(repoRoot, filePath);
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
return rel.split(path.sep).join('/');
}
return filePath;
}
function discoverRootsForPattern(repoRoot, rawPattern) {
const pattern = normalizeWorkspacePattern(rawPattern);
if (!pattern || pattern.startsWith('!')) return [];
const segments = pattern.split('/').filter(Boolean);
if (!segments.length) return [];
const firstGlobIndex = segments.findIndex((segment) => segment.includes('*'));
const literalPrefix = firstGlobIndex === -1 ? segments : segments.slice(0, firstGlobIndex);
const base = path.join(repoRoot, ...literalPrefix);
if (!fs.existsSync(base)) return [];
if (segments.includes('**')) {
const packageRoots = [];
walkDirs(base, (dir) => {
if (dir !== base && isCandidateProjectRoot(dir)) packageRoots.push(dir);
});
if (packageRoots.length) return packageRoots;
return directChildDirs(base);
}
return expandSimplePattern(repoRoot, segments);
}
function expandSimplePattern(repoRoot, patternSegments, index = 0, current = repoRoot) {
if (index >= patternSegments.length) return fs.existsSync(current) ? [current] : [];
const segment = patternSegments[index];
if (!segment.includes('*')) {
return expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, segment));
}
let entries;
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
return [];
}
const roots = [];
for (const entry of entries) {
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
if (!segmentMatches(segment, entry.name)) continue;
roots.push(...expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, entry.name)));
}
return roots;
}
function directChildDirs(dir) {
try {
return fs.readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))
.map((entry) => path.join(dir, entry.name));
} catch {
return [];
}
}
function walkDirs(root, visit) {
let entries;
try {
entries = fs.readdirSync(root, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
const dir = path.join(root, entry.name);
visit(dir);
walkDirs(dir, visit);
}
}
function isCandidateProjectRoot(dir) {
return !!(
fs.existsSync(path.join(dir, 'package.json'))
|| firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|| fs.existsSync(path.join(dir, 'src'))
|| fs.existsSync(path.join(dir, 'app'))
|| fs.existsSync(path.join(dir, 'pages'))
|| fs.existsSync(path.join(dir, 'public'))
);
}
function isIgnoredWorkspaceDiscoveryDir(name) {
return name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(name);
}
function findTargetExample(repoRoot, projectRoot) {
const examples = [
'src/App.jsx',
'src/App.tsx',
'src/main.jsx',
'src/main.tsx',
'src/index.jsx',
'src/index.ts',
'app/page.tsx',
'pages/index.tsx',
'public/index.html',
];
for (const rel of examples) {
const abs = path.join(projectRoot, rel);
if (fs.existsSync(abs)) return path.relative(repoRoot, abs).split(path.sep).join('/');
}
return path.relative(repoRoot, projectRoot).split(path.sep).join('/');
}
function resolveWorkspaceProjectRoot(repoRoot, targetDir) {
const rel = path.relative(repoRoot, targetDir);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot;
const relSegments = rel.split(path.sep).filter(Boolean);
const patterns = readWorkspacePatterns(repoRoot);
const excluded = isExcludedByWorkspacePattern(relSegments, patterns);
if (!excluded) {
for (const pattern of patterns) {
const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern);
if (projectRoot) return projectRoot;
}
}
if (excluded) return repoRoot;
if (
relSegments.length >= 2
&& MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0])
) {
return path.join(repoRoot, relSegments[0], relSegments[1]);
}
const nearest = nearestProjectLikeRoot(repoRoot, targetDir);
if (nearest) return nearest;
return repoRoot;
}
function isExcludedByWorkspacePattern(relSegments, patterns) {
return patterns.some((rawPattern) => {
const pattern = normalizeWorkspacePattern(rawPattern);
if (!pattern.startsWith('!')) return false;
return workspacePatternMatchesRel(pattern.slice(1), relSegments);
});
}
function nearestProjectLikeRoot(repoRoot, targetDir) {
let dir = path.resolve(targetDir);
const stop = path.resolve(repoRoot);
while (dir && dir !== stop) {
if (
firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|| fs.existsSync(path.join(dir, 'package.json'))
) {
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
function nearestPackageRootBetween(repoRoot, targetDir, stopDir) {
let dir = path.resolve(targetDir);
const stop = path.resolve(stopDir || repoRoot);
const root = path.resolve(repoRoot);
while (dir && dir !== stop && isPathInsideOrEqual(dir, root)) {
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
function isPathInsideOrEqual(candidate, root) {
return path.resolve(candidate) === path.resolve(root) || isPathInside(candidate, root);
}
function workspacePatternMatchesRel(pattern, relSegments) {
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
if (!patternSegments.length) return false;
if (patternSegments.includes('**')) {
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
const literalPrefix = firstGlobIndex === -1
? patternSegments
: patternSegments.slice(0, firstGlobIndex);
if (relSegments.length < literalPrefix.length + 1) return false;
for (let i = 0; i < literalPrefix.length; i++) {
if (!segmentMatches(literalPrefix[i], relSegments[i])) return false;
}
return true;
}
if (relSegments.length < patternSegments.length) return false;
for (let i = 0; i < patternSegments.length; i++) {
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
}
return true;
}
function readWorkspacePatterns(repoRoot) {
return [
...readPackageWorkspaces(repoRoot),
...readPnpmWorkspaces(repoRoot),
...readLernaWorkspaces(repoRoot),
].filter(Boolean);
}
function readPackageWorkspaces(repoRoot) {
const pkg = readJson(path.join(repoRoot, 'package.json'));
const workspaces = pkg?.workspaces;
if (Array.isArray(workspaces)) return workspaces;
if (Array.isArray(workspaces?.packages)) return workspaces.packages;
return [];
}
function readLernaWorkspaces(repoRoot) {
const lerna = readJson(path.join(repoRoot, 'lerna.json'));
return Array.isArray(lerna?.packages) ? lerna.packages : [];
}
function readPnpmWorkspaces(repoRoot) {
try {
const body = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf-8');
const patterns = [];
let inPackages = false;
for (const line of body.split(/\r?\n/)) {
const trimmed = stripYamlInlineComment(line).trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const flowMatch = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
if (flowMatch) {
patterns.push(...parseYamlFlowList(flowMatch[1]));
inPackages = false;
continue;
}
if (/^packages:\s*$/.test(trimmed)) {
inPackages = true;
continue;
}
if (inPackages && /^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
if (inPackages) {
const match = trimmed.match(/^-\s*(.+)$/);
if (match) patterns.push(unquoteYamlValue(match[1]));
}
}
return patterns;
} catch {
return [];
}
}
function stripYamlInlineComment(line) {
let quote = null;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if ((ch === '"' || ch === "'") && line[i - 1] !== '\\') {
quote = quote === ch ? null : quote || ch;
continue;
}
if (ch === '#' && !quote) return line.slice(0, i);
}
return line;
}
function parseYamlFlowList(body) {
const items = [];
let quote = null;
let current = '';
for (let i = 0; i < body.length; i++) {
const ch = body[i];
if ((ch === '"' || ch === "'") && body[i - 1] !== '\\') {
quote = quote === ch ? null : quote || ch;
current += ch;
continue;
}
if (ch === ',' && !quote) {
const value = unquoteYamlValue(current);
if (value) items.push(value);
current = '';
continue;
}
current += ch;
}
const value = unquoteYamlValue(current);
if (value) items.push(value);
return items;
}
function unquoteYamlValue(value) {
return String(value || '')
.trim()
.replace(/^['"]|['"]$/g, '');
}
function readJson(filePath) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch {
return null;
}
}
function projectRootFromWorkspacePattern(repoRoot, relSegments, rawPattern) {
const pattern = normalizeWorkspacePattern(rawPattern);
if (!pattern || pattern.startsWith('!')) return null;
const patternSegments = pattern.split('/').filter(Boolean);
if (!patternSegments.length) return null;
if (patternSegments.includes('**')) {
return projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments);
}
if (relSegments.length < patternSegments.length) return null;
for (let i = 0; i < patternSegments.length; i++) {
if (!segmentMatches(patternSegments[i], relSegments[i])) return null;
}
return path.join(repoRoot, ...relSegments.slice(0, patternSegments.length));
}
function projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments) {
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
const literalPrefix = firstGlobIndex === -1
? patternSegments
: patternSegments.slice(0, firstGlobIndex);
if (relSegments.length < literalPrefix.length + 1) return null;
for (let i = 0; i < literalPrefix.length; i++) {
if (!segmentMatches(literalPrefix[i], relSegments[i])) return null;
}
const prefixDir = path.join(repoRoot, ...literalPrefix);
const targetDir = path.join(repoRoot, ...relSegments);
const packageRoot = nearestPackageRootBetween(repoRoot, targetDir, prefixDir);
if (packageRoot) return packageRoot;
return path.join(repoRoot, ...relSegments.slice(0, literalPrefix.length + 1));
}
function normalizeWorkspacePattern(pattern) {
return String(pattern || '')
.trim()
.replace(/^['"]|['"]$/g, '')
.replace(/^\.\//, '')
.replace(/\/+$/, '');
}
function segmentMatches(patternSegment, relSegment) {
if (patternSegment === '*') return true;
if (!patternSegment.includes('*')) return patternSegment === relSegment;
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
return re.test(relSegment);
}
function firstExisting(dir, names) {
for (const name of names) {
const abs = path.join(dir, name);
@@ -89,24 +690,64 @@ function safeRead(p) {
}
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Read the first non-empty line under a bare `## <heading>` section of
* PRODUCT.md (e.g. `## Register`, `## Platform`). Returns null when the
* section is absent. The heading match is exact (`\s*$`) so near-miss
* headings like `## Register guidelines` don't shadow the real field.
*/
export function extractSectionValue(product, heading) {
if (!product) return null;
const headingRe = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, 'i');
const lines = product.split('\n');
for (let i = 0; i < lines.length; i++) {
if (headingRe.test(lines[i].trim())) {
for (let j = i + 1; j < lines.length; j++) {
const next = lines[j].trim();
// A new heading before any value means the section is empty.
if (/^#{1,6}\s/.test(next)) return null;
if (next) return next;
}
}
}
return null;
}
/**
* Pull the register (`brand` or `product`) out of PRODUCT.md by looking
* for a `## Register` section and reading the first non-empty line that
* follows it. Returns null when the file is legacy / register-less.
*/
export function extractRegister(product) {
if (!product) return null;
const lines = product.split('\n');
for (let i = 0; i < lines.length; i++) {
if (/^##\s+Register\b/i.test(lines[i].trim())) {
for (let j = i + 1; j < lines.length; j++) {
const next = lines[j].trim();
if (!next) continue;
const word = next.toLowerCase();
if (word === 'brand' || word === 'product') return word;
return null;
}
}
const word = (extractSectionValue(product, 'Register') || '').toLowerCase();
return word === 'brand' || word === 'product' ? word : null;
}
/**
* Pull the platform (`web`, `ios`, `android`, or `adaptive`) out of PRODUCT.md
* by looking for a `## Platform` section and reading the first non-empty line
* that follows it. `adaptive` is for cross-platform apps (Flutter, React
* Native) that ship both iOS and Android from one codebase; a line that names
* both targets (e.g. `ios, android`) is also read as `adaptive`. Returns null
* when the file is legacy / platform-less, which the skill treats as `web`
* (the default the general rules already assume).
*/
export function extractPlatform(product) {
const value = (extractSectionValue(product, 'Platform') || '').toLowerCase();
if (!value) return null;
if (value === 'web' || value === 'ios' || value === 'android' || value === 'adaptive') return value;
// A short list naming both native targets (`ios, android`, `ios and
// android`) = adaptive. Only list separators and the two platform words may
// appear; anything else (prose, negations) is unrecognized and falls
// through to the CLI's WARNING path.
const tokens = value.split(/[\s,+&/]+/).filter(t => t && t !== 'and');
if (tokens.length >= 2 && tokens.every(t => t === 'ios' || t === 'android')
&& tokens.includes('ios') && tokens.includes('android')) {
return 'adaptive';
}
return null;
}
@@ -233,7 +874,24 @@ async function computeUpdateDirective(now = Date.now()) {
}
async function cli() {
const ctx = loadContext(process.cwd());
let cliOptions;
try {
cliOptions = parseCliOptions(process.argv.slice(2));
} catch (err) {
if (err?.name === 'TargetArgError') {
process.stderr.write(`${err.message}\n`);
process.exit(1);
}
throw err;
}
const targetProvided = hasTargetOption(cliOptions);
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
const selection = resolveTargetSelection(process.cwd(), cliOptions);
if (selection) {
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
process.exit(0);
}
const ctx = loadContext(process.cwd(), cliOptions);
const updateDirective = await computeUpdateDirective();
if (!ctx.hasProduct) {
@@ -241,9 +899,16 @@ async function cli() {
// — cheap models miss the empty case more often than the explicit one.
const parts = [
'NO_PRODUCT_MD: This project has no PRODUCT.md yet. ' +
'Stop the current task, load reference/init.md, and follow its ' +
'instructions to write PRODUCT.md before resuming.',
'Follow SKILL.md Setup step 1: for `init`, `teach`, `craft`, `shape`, ' +
'or wording that clearly maps to a from-scratch build/shape flow, load ' +
'reference/init.md and write PRODUCT.md first; for any other (scoped) ' +
'command against existing code, proceed using the code as context and ' +
`offer \`${IMPECCABLE_COMMAND} init\` as a suggestion (do not block).`,
];
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
parts.push(buildMissingTargetDirective());
}
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
process.exit(0);
@@ -252,15 +917,93 @@ async function cli() {
if (ctx.hasDesign) {
parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`);
}
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
parts.push(buildMissingTargetDirective());
}
const register = extractRegister(ctx.product);
const next = register
? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.`
: `NEXT STEP: You MUST now read the matching register reference (\`reference/brand.md\` or \`reference/product.md\`) before producing any design output. Pick based on PRODUCT.md above.`;
parts.push(next);
const platform = extractPlatform(ctx.product);
const nativeRefs =
platform === 'adaptive' ? ['ios', 'android'] : platform === 'ios' || platform === 'android' ? [platform] : [];
if (nativeRefs.length) {
const refList = nativeRefs.map(p => `\`reference/${p}.md\``).join(' and ');
const label = platform === 'adaptive' ? '`adaptive` (both iOS and Android)' : `\`${platform}\``;
parts.push(
`NEXT STEP: This project targets ${label}. Also read ${refList} for native conventions, in addition to the register reference.`,
);
} else if (!platform) {
// A `## Platform` section that names something we don't recognize (a
// toolchain like `flutter`, a typo) would otherwise silently fall back to
// web — the wrong default exactly when the user tried to say "native".
const rawPlatform = extractSectionValue(ctx.product, 'Platform');
if (rawPlatform) {
parts.push(
`WARNING: PRODUCT.md's \`## Platform\` value \`${rawPlatform}\` is not recognized; treating the project as \`web\`. Valid values are \`web\`, \`ios\`, \`android\`, or \`adaptive\` (cross-platform, ships both). If this project is native, fix the field (name the design language the app renders, not the toolchain) and surface it to the user.`,
);
}
}
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
}
function parseCliOptions(args) {
return parseTargetOptions(args, { strict: true });
}
function hasTargetOption(options) {
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
}
function pathExistsForTarget(cwd, targetPath) {
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
return fs.existsSync(abs);
}
function buildResolvedContextDirective(ctx, options, { targetExists = null } = {}) {
const targetPath = hasTargetOption(options) ? options.targetPath : null;
return `RESOLVED_CONTEXT:\n${JSON.stringify({
targetPath,
...(targetPath ? { targetExists } : {}),
projectRoot: ctx.projectRoot,
repoRoot: ctx.repoRoot,
productPath: ctx.productPath,
designPath: ctx.designPath,
}, null, 2)}`;
}
function shouldWarnMissingTarget(ctx, targetProvided, targetExists = null) {
if (ctx.isMonorepo && targetProvided && targetExists === false) return true;
return !!(
ctx.isMonorepo
&& (!targetProvided || targetExists === false)
&& ctx.projectRoot
&& ctx.repoRoot
&& path.resolve(ctx.projectRoot) === path.resolve(ctx.repoRoot)
);
}
function buildMissingTargetDirective() {
const script = process.argv[1] || 'context.mjs';
return (
'MONOREPO_TARGET_REQUIRED: This is a monorepo and context.mjs ran without --target. ' +
'If the user named a file, route, or child app, do not answer from this output. ' +
`Rerun \`node ${script} --target <path>\` and answer from that run's RESOLVED_CONTEXT fields.`
);
}
function buildTargetSelectionDirective(selection) {
return (
`TARGET_SELECTION_REQUIRED:\n${JSON.stringify(selection, null, 2)}\n\n` +
'Show each app with its productStatus/productPath and designStatus/designPath so the user can see child overrides, inherited root files, fallback files, or missing files before choosing. ' +
'Ask the user which app Impeccable should use, then rerun Impeccable helper commands from that child app cwd using this same scripts directory. ' +
'Use `--target <path>` only as a fallback when changing cwd is not possible, or when the user explicitly named a file/path.'
);
}
// Run cli() only when this module is the entry point. Compare realpaths
// rather than endsWith(): a loose suffix match also fires for unrelated
// scripts like `load-context.mjs`, and realpath tolerates symlinked
@@ -2,11 +2,11 @@
/**
* Critique persistence helper.
*
* Each run of /impeccable critique writes a per-target snapshot to
* Each critique run writes a per-target snapshot to
* .impeccable/critique/<timestamp>__<slug>.md
* with a small YAML frontmatter carrying the score + P0/P1 counts.
*
* /impeccable polish reads the latest matching snapshot at start as its
* The polish workflow reads the latest matching snapshot at start as its
* fix backlog. No other skill auto-reads critique output.
*
* The slug is derived mechanically from the *resolved* primary artifact
@@ -2,6 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { loadDesignSystemForCwd } from '../design-system.mjs';
import { RULE_SCOPES, filterByScopes } from '../registry/antipatterns.mjs';
import { createBrowserDetector, detectUrl } from '../engines/browser/detect-url.mjs';
import { detectHtml } from '../engines/static-html/detect-html.mjs';
import { detectText } from '../engines/regex/detect-text.mjs';
@@ -22,6 +23,10 @@ import {
// Output formatting
// ---------------------------------------------------------------------------
function formatFindingSummary(count) {
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
}
function formatFindings(findings, jsonMode) {
if (jsonMode) return JSON.stringify(findings, null, 2);
@@ -39,7 +44,7 @@ function formatFindings(findings, jsonMode) {
out.push(`${item.description}`);
}
}
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
out.push(`\n${formatFindingSummary(findings.length)}`);
return out.join('\n');
}
@@ -86,9 +91,14 @@ Scan files or URLs for UI anti-patterns and design quality issues.
Options:
--json Output results as JSON
--quiet In text mode, only print the final findings count
--gpt Also report GPT-specific provider tells (off by default)
--gemini Also report Gemini-specific provider tells (off by default)
--no-config Do not apply project config, detector ignores, or DESIGN.md
--scope <name> Only report rules in the given design domain
(type, layout). Comma-separated.
--no-config Do not apply project config, detector ignores, inline
ignore comments, or DESIGN.md
--no-inline-ignores Do not honor in-file impeccable-disable* ignore comments
--no-design-system Do not load local DESIGN.md / .impeccable/design.json context
--help Show this help message
@@ -97,6 +107,14 @@ Project config:
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
and detector.designSystem.enabled.
Inline ignores:
In-file comments waive a finding where it lives and travel with the file:
<!-- impeccable-disable overused-font -- exported brand doc -->
.brand { font-family: Inter } /* impeccable-disable-line overused-font */
// impeccable-disable-next-line bounce-easing: intentional bounce
impeccable-disable applies to the whole file; -line / -next-line are scoped.
List one or more rule ids (comma-separated), or omit them / use * for all.
Detection modes:
HTML files Static HTML/CSS analysis (default, catches linked CSS)
Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
@@ -118,6 +136,7 @@ async function detectCli() {
});
if (args[0] === 'detect') args = args.slice(1);
const jsonMode = args.includes('--json');
const quietMode = args.includes('--quiet');
const helpMode = args.includes('--help');
// --fast (regex-only) is deprecated: since the jsdom removal, the static
// HTML/CSS analysis is fast and covers every rule, so the regex-only path
@@ -135,9 +154,41 @@ async function detectCli() {
const providers = [];
if (args.includes('--gpt')) providers.push('gpt');
if (args.includes('--gemini')) providers.push('gemini');
const scopes = [];
for (let i = 0; i < args.length; i++) {
if (args[i] !== '--scope' && !args[i].startsWith('--scope=')) continue;
const inline = args[i].startsWith('--scope=');
const value = inline ? args[i].slice('--scope='.length) : args[i + 1];
const parsed = (value && !value.startsWith('--'))
? value.split(',').map(s => s.trim()).filter(Boolean)
: [];
// A bare `--scope` would otherwise fall out of `targets` and scan unscoped;
// fail loudly so a mistyped pre-scan never runs the wrong rule set.
if (parsed.length === 0) {
process.stderr.write(
`Error: --scope requires a value. Valid scopes: ${[...RULE_SCOPES].join(', ')}\n`,
);
process.exit(1);
}
scopes.push(...parsed);
args.splice(i, inline ? 1 : 2);
i -= 1;
}
const unknownScopes = scopes.filter(s => !RULE_SCOPES.has(s));
if (unknownScopes.length > 0) {
process.stderr.write(
`Error: unknown --scope value(s): ${unknownScopes.join(', ')}. Valid scopes: ${[...RULE_SCOPES].join(', ')}\n`,
);
process.exit(1);
}
const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false;
const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null;
const scanOptions = designSystem ? { providers, designSystem } : { providers };
// Inline `impeccable-disable*` waivers are part of the scanned file, so they
// apply by default. `--no-config` (raw scan) and the dedicated
// `--no-inline-ignores` both turn them off.
const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores');
const scanOptions = { providers, inlineIgnores: inlineIgnoresEnabled };
if (designSystem) scanOptions.designSystem = designSystem;
const targets = args.filter(a => !a.startsWith('--'));
if (helpMode) { printUsage(); process.exit(0); }
@@ -169,8 +220,8 @@ async function detectCli() {
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON mode to avoid polluting output)
if (!jsonMode) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
if (!jsonMode && !quietMode) {
const fwConfig = detectFrameworkConfig(resolved);
if (fwConfig) {
const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint);
@@ -200,7 +251,7 @@ async function detectCli() {
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
// Warn and confirm if scanning many files (static HTML/CSS processes each HTML file)
if (files.length > 50 && process.stdin.isTTY && !jsonMode) {
if (files.length > 50 && process.stdin.isTTY && !jsonMode && !quietMode) {
process.stderr.write(
`\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` +
`Scanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\n` +
@@ -255,9 +306,11 @@ async function detectCli() {
}
allFindings = filterDetectionFindings(allFindings, detectionConfig);
allFindings = filterByScopes(allFindings, scopes);
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(2);
}
@@ -9,6 +9,8 @@ const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
const FALLBACK_DIRS = ['.agents/context', 'docs'];
const COLOR_CHANNEL_TOLERANCE = 6;
const RADIUS_TOLERANCE_PX = 0.5;
const FONT_SIZE_TOLERANCE_PX = 0.5;
const FONT_SIZE_LITERAL_RE = /^-?[\d.]+(?:px|rem)$/;
const CSS_COLOR_RE = /#[0-9a-f]{3,8}\b|rgba?\([^)]+\)|oklch\([^)]+\)|hsla?\([^)]+\)/gi;
const FONT_DECL_RE = /font-family\s*:\s*([^;}\n]+)/gi;
@@ -16,6 +18,9 @@ const FONT_JS_RE = /fontFamily\s*[:=]\s*["'`]([^"'`]+)["'`]/g;
const GOOGLE_FONT_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi;
const BORDER_RADIUS_RE = /border-radius\s*:\s*([^;}\n]+)/gi;
const BORDER_RADIUS_JS_RE = /borderRadius\s*[:=]\s*["'`]([^"'`]+)["'`]/g;
const FONT_SIZE_DECL_RE = /font-size\s*:\s*([^;}\n]+)/gi;
const FONT_SIZE_JS_RE = /fontSize\s*[:=]\s*["'`]([^"'`]+)["'`]/g;
const TAILWIND_FONT_SIZE_RE = /\btext-\[(-?[\d.]+(?:px|rem))\]/g;
const STATIC_DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']);
function firstExisting(dir, names) {
@@ -283,6 +288,18 @@ function addTypographyFonts(out, typography) {
}
}
function addTypographySizes(out, typography) {
if (!typography || typeof typography !== 'object') return;
for (const role of Object.values(typography)) {
if (!role || typeof role !== 'object') continue;
const raw = String(role.fontSize ?? '').trim().toLowerCase();
if (!FONT_SIZE_LITERAL_RE.test(raw)) continue;
const px = resolveLengthPx(raw, 16);
if (px == null || !Number.isFinite(px) || px <= 0) continue;
out.allowedFontSizes.push({ value: raw, px });
}
}
function addRoundedScale(out, rounded) {
if (!rounded || typeof rounded !== 'object') return;
for (const [rawName, value] of Object.entries(rounded)) {
@@ -340,10 +357,12 @@ function normalizeDesignSystem(input = {}) {
allowedFonts: new Set(),
allowedColorKeys: new Map(),
allowedRadii: [],
allowedFontSizes: [],
hasPillRadius: false,
};
addTypographyFonts(out, frontmatter.typography);
addTypographySizes(out, frontmatter.typography);
addColorObject(out, frontmatter.colors);
addSidecarColors(out, sidecar);
addRoundedScale(out, frontmatter.rounded);
@@ -352,6 +371,7 @@ function normalizeDesignSystem(input = {}) {
out.hasFonts = out.allowedFonts.size > 0;
out.hasColors = out.allowedColorKeys.size > 0;
out.hasRadii = out.allowedRadii.length > 0;
out.hasFontSizes = out.allowedFontSizes.length > 0;
return out;
}
@@ -418,6 +438,17 @@ function isAllowedRadiusRaw(raw, designSystem) {
return designSystem.allowedRadii.some(entry => Math.abs(entry.px - px) <= RADIUS_TOLERANCE_PX);
}
function isAllowedFontSizeRaw(raw, designSystem) {
if (!designSystem?.hasFontSizes) return true;
const text = String(raw || '').trim().toLowerCase().replace(/\s*!important\s*$/, '');
if (!FONT_SIZE_LITERAL_RE.test(text)) return true;
const px = resolveLengthPx(text, 16);
if (px == null || !Number.isFinite(px) || px <= 0) return true;
return designSystem.allowedFontSizes.some(
entry => Math.abs(entry.px - px) <= FONT_SIZE_TOLERANCE_PX,
);
}
function lineLooksCommented(line) {
const trimmed = String(line || '').trim();
return trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*') || trimmed.startsWith('<!--');
@@ -509,6 +540,18 @@ function checkRadiusValue(value, filePath, line, designSystem, context) {
return findings;
}
function checkFontSizeValue(value, filePath, line, designSystem, context) {
const token = String(value || '').trim();
if (isAllowedFontSizeRaw(token, designSystem)) return [];
return [makeDesignFinding(
'design-system-font-size',
filePath,
`${context}: ${token} is off the DESIGN.md type ramp`,
line,
{ ignoreValue: token },
)];
}
function checkSourceDesignSystem(content, filePath, options = {}) {
const designSystem = options.designSystem;
if (!designSystem?.present) return [];
@@ -567,6 +610,18 @@ function checkSourceDesignSystem(content, filePath, options = {}) {
findings.push(...checkRadiusValue(match[1], filePath, lineNum, designSystem, 'borderRadius'));
}
}
if (designSystem.hasFontSizes) {
for (const match of line.matchAll(FONT_SIZE_DECL_RE)) {
findings.push(...checkFontSizeValue(match[1], filePath, lineNum, designSystem, 'font-size'));
}
for (const match of line.matchAll(FONT_SIZE_JS_RE)) {
findings.push(...checkFontSizeValue(match[1], filePath, lineNum, designSystem, 'fontSize'));
}
for (const match of line.matchAll(TAILWIND_FONT_SIZE_RE)) {
findings.push(...checkFontSizeValue(match[1], filePath, lineNum, designSystem, 'text-[…] class'));
}
}
}
return dedupeDesignFindings(findings);
@@ -581,6 +636,8 @@ function sampleText(el) {
return text ? ` "${text.slice(0, 40)}"` : '';
}
// Font-size design-system checks are source-scan-only (see checkSourceDesignSystem).
// Computed font-size cascades and clamp() ramps resolve to off-ramp px in the browser.
function collectStaticDesignSystemFindings(document, window, filePath, designSystem) {
if (!designSystem?.present) return [];
const findings = [];
@@ -698,6 +755,12 @@ function canonicalDesignFindingKey(item) {
const label = String(value || '').trim().toLowerCase();
return label ? `${item.antipattern}:radius:${label}` : null;
}
if (item.antipattern === 'design-system-font-size') {
const px = resolveLengthPx(String(value || '').trim(), 16);
if (px != null && Number.isFinite(px)) return `${item.antipattern}:font-size:${Math.round(px * 100) / 100}`;
const label = String(value || '').trim().toLowerCase();
return label ? `${item.antipattern}:font-size:${label}` : null;
}
return null;
}
@@ -744,6 +807,7 @@ export {
isAllowedFont,
isAllowedColorRaw,
isAllowedRadiusRaw,
isAllowedFontSizeRaw,
checkSourceDesignSystem,
collectStaticDesignSystemFindings,
mergeDesignSystemFindings,
@@ -123,6 +123,7 @@ const ANTIPATTERNS = [
{
id: 'overused-font',
category: 'slop',
scopes: ['type'],
name: 'Overused font',
description:
'Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.',
@@ -132,6 +133,7 @@ const ANTIPATTERNS = [
{
id: 'single-font',
category: 'slop',
scopes: ['type'],
name: 'Single font for everything',
description:
'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
@@ -141,6 +143,7 @@ const ANTIPATTERNS = [
{
id: 'flat-type-hierarchy',
category: 'slop',
scopes: ['type'],
name: 'Flat type hierarchy',
description:
'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
@@ -177,6 +180,7 @@ const ANTIPATTERNS = [
{
id: 'nested-cards',
category: 'slop',
scopes: ['layout'],
name: 'Nested cards',
description:
'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
@@ -186,6 +190,7 @@ const ANTIPATTERNS = [
{
id: 'monotonous-spacing',
category: 'slop',
scopes: ['layout'],
name: 'Monotonous spacing',
description:
'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
@@ -213,6 +218,7 @@ const ANTIPATTERNS = [
{
id: 'icon-tile-stack',
category: 'slop',
scopes: ['layout'],
name: 'Icon tile stacked above heading',
description:
'A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.',
@@ -222,6 +228,7 @@ const ANTIPATTERNS = [
{
id: 'italic-serif-display',
category: 'slop',
scopes: ['type'],
name: 'Italic serif display headline',
description:
'Oversized italic serif (Fraunces, Recoleta, Playfair, Newsreader-italic) as the primary hero headline reads as taste in isolation but has become the universal AI-startup landing page hero. Set roman, or move to a non-serif display face. Editorial / magazine register may legitimately want this — judge by context.',
@@ -231,6 +238,7 @@ const ANTIPATTERNS = [
{
id: 'hero-eyebrow-chip',
category: 'slop',
scopes: ['type'],
name: 'Hero eyebrow / pill chip',
description:
'A tiny uppercase letter-spaced label sitting immediately above an oversized hero headline — or the same shape rendered as a pill chip — is now the default AI SaaS hero. Drop the eyebrow, integrate the kicker into the headline, or run it as a navigation breadcrumb instead.',
@@ -240,6 +248,7 @@ const ANTIPATTERNS = [
{
id: 'repeated-section-kickers',
category: 'slop',
scopes: ['type'],
severity: 'advisory',
name: 'Repeated section kicker labels',
description:
@@ -250,6 +259,7 @@ const ANTIPATTERNS = [
{
id: 'numbered-section-markers',
category: 'slop',
scopes: ['layout'],
severity: 'advisory',
name: 'Numbered section markers (01 / 02 / 03)',
description:
@@ -287,6 +297,7 @@ const ANTIPATTERNS = [
{
id: 'oversized-h1',
category: 'slop',
scopes: ['type'],
name: 'Oversized hero headline',
description:
'A full-sentence headline set at display size ends up dominating the viewport, leaving no room for anything else above the fold. A punchy one- or two-word headline at that size is fine — the problem is a long headline blown up too large. Set long headlines smaller, or tighten the copy.',
@@ -296,6 +307,7 @@ const ANTIPATTERNS = [
{
id: 'extreme-negative-tracking',
category: 'slop',
scopes: ['type'],
name: 'Crushed letter spacing',
description:
'Letter-spacing pulled tighter than the point where characters keep their own shapes costs legibility. Tighten display type optically, not destructively.',
@@ -341,6 +353,7 @@ const ANTIPATTERNS = [
{
id: 'line-length',
category: 'quality',
scopes: ['type', 'layout'],
name: 'Line length too long',
description:
'Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.',
@@ -350,6 +363,7 @@ const ANTIPATTERNS = [
{
id: 'cramped-padding',
category: 'quality',
scopes: ['layout'],
name: 'Cramped padding',
description:
'Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 1216px) of padding inside bordered, outlined, or colored containers.',
@@ -359,6 +373,7 @@ const ANTIPATTERNS = [
{
id: 'body-text-viewport-edge',
category: 'quality',
scopes: ['layout'],
name: 'Body text touching viewport edge',
description:
'Body paragraphs render flush against the left or right viewport edge with no container providing horizontal padding. Wrap content in a container with at least 16px (ideally 24-32px) of horizontal padding, or apply max-width with mx-auto.',
@@ -366,6 +381,7 @@ const ANTIPATTERNS = [
{
id: 'tight-leading',
category: 'quality',
scopes: ['type'],
name: 'Tight line height',
description:
'Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.',
@@ -373,6 +389,7 @@ const ANTIPATTERNS = [
{
id: 'skipped-heading',
category: 'quality',
scopes: ['type'],
name: 'Skipped heading level',
description:
'Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.',
@@ -380,6 +397,7 @@ const ANTIPATTERNS = [
{
id: 'justified-text',
category: 'quality',
scopes: ['type'],
name: 'Justified text',
description:
'Justified text without hyphenation creates uneven word spacing ("rivers of white"). Use text-align: left for body text, or enable hyphens: auto if you must justify.',
@@ -387,6 +405,7 @@ const ANTIPATTERNS = [
{
id: 'tiny-text',
category: 'quality',
scopes: ['type'],
name: 'Tiny body text',
description:
'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
@@ -394,6 +413,7 @@ const ANTIPATTERNS = [
{
id: 'all-caps-body',
category: 'quality',
scopes: ['type'],
name: 'All-caps body text',
description:
'Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.',
@@ -403,6 +423,7 @@ const ANTIPATTERNS = [
{
id: 'wide-tracking',
category: 'quality',
scopes: ['type'],
name: 'Wide letter spacing on body text',
description:
'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.',
@@ -410,6 +431,7 @@ const ANTIPATTERNS = [
{
id: 'text-overflow',
category: 'quality',
scopes: ['layout'],
name: 'Content overflowing its container',
description:
'Content renders wider than its container, spilling out or forcing a horizontal scrollbar. Let text wrap, constrain widths, or give the region a deliberate scroll affordance.',
@@ -419,6 +441,7 @@ const ANTIPATTERNS = [
{
id: 'clipped-overflow-container',
category: 'quality',
scopes: ['layout'],
name: 'Positioned child clipped by overflow container',
description:
'A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.',
@@ -428,6 +451,7 @@ const ANTIPATTERNS = [
{
id: 'design-system-font',
category: 'quality',
scopes: ['type'],
name: 'Font outside DESIGN.md',
description:
'A font is used that is not declared in DESIGN.md typography. Use the documented type system or update DESIGN.md if this is an intentional brand addition.',
@@ -454,6 +478,17 @@ const ANTIPATTERNS = [
skillSection: 'Visual Details',
skillGuideline: 'border radius outside the project design system',
},
{
id: 'design-system-font-size',
category: 'quality',
severity: 'advisory',
scopes: ['type'],
name: 'Font size outside DESIGN.md',
description:
'A literal font-size is off the type ramp documented in DESIGN.md typography. Use a documented size step or update the design system if the new step is intentional.',
skillSection: 'Typography',
skillGuideline: 'font size outside the project design system',
},
// ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
{
@@ -478,6 +513,17 @@ const ANTIPATTERNS = [
skillSection: 'Visual Details',
skillGuideline: 'repeating-gradient decorative stripes',
},
{
id: 'codex-grid-background',
category: 'slop',
severity: 'advisory',
gated: 'gpt',
name: 'Decorative grid-line background',
description:
'A two-axis grid drawn with hairline linear-gradient layers ("1px, transparent 1px" on both axes) is a recurring generated-UI signature. Reserve grid overlays for actual canvas, map, blueprint, or measurement surfaces; elsewhere use product structure or a plain surface.',
skillSection: 'Visual Details',
skillGuideline: 'two-axis grid-line gradient background',
},
{
id: 'theater-slop-phrase',
category: 'slop',
@@ -617,6 +663,36 @@ function colorToHex(c) {
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
}
// --- cli/engine/shared/fonts.mjs ---
const GOOGLE_FONTS_URL_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi;
function normalizeGoogleFontFamilyParam(value) {
return String(value || '')
.split('|')
.map(part => part.split(':')[0].trim().toLowerCase())
.filter(Boolean);
}
function extractGoogleFontFamilies(text) {
const families = [];
if (!text) return families;
GOOGLE_FONTS_URL_RE.lastIndex = 0;
let urlMatch;
while ((urlMatch = GOOGLE_FONTS_URL_RE.exec(text)) !== null) {
const url = urlMatch[0];
const queryStart = url.indexOf('?');
if (queryStart === -1) continue;
const params = new URLSearchParams(url.slice(queryStart + 1).replace(/&amp;/g, '&'));
for (const value of params.getAll('family')) {
families.push(...normalizeGoogleFontFamilyParam(value));
}
}
return families;
}
// --- cli/engine/rules/checks.mjs ---
const DETECTOR_IS_BROWSER = typeof window !== 'undefined';
@@ -1172,6 +1248,42 @@ function checkHtmlPatterns(html) {
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });
}
// --- Provider tells (gated): two-axis grid-line background (Codex/GPT) ---
// The Codex grid tell is two hairline `linear-gradient(... <color> 1px,
// transparent 1px)` layers (one per axis) tiled by a repeating
// `background-size` cell. Both signals must co-occur in the SAME style block
// (a CSS rule body or one inline `style="..."`): two hairline stops WITHOUT a
// tiling background-size is a fixed crosshair, not a grid, and a single
// hairline is a legitimate ruled line. Scoping to one block also stops
// unrelated single-axis rules on separate elements from adding up across the
// page. Count hairlines only inside `background`/`background-image` values so
// a hairline in an unrelated property (mask-image, border-image) can't stand
// in for the second axis. Colors like `oklch(96% 0.012 82 / 0.055)` carry
// nested parens, so match the hairline stop directly rather than parsing
// whole gradient layers.
{
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
const gridSizeRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
let blk;
while ((blk = blockRe.exec(html)) !== null) {
const block = blk[1] || blk[2] || blk[3] || '';
if (!gridSizeRe.test(block)) continue;
let hairlineCount = 0;
let bm;
bgDeclRe.lastIndex = 0;
while ((bm = bgDeclRe.exec(block)) !== null) {
const stops = bm[1].match(hairlineRe);
if (stops) hairlineCount += stops.length;
}
if (hairlineCount >= 2) {
findings.push({ id: 'codex-grid-background', snippet: 'two-axis grid-line gradient background' });
break;
}
}
}
// --- Provider tells (gated): "X theater" framing copy (GPT) ---
// Lives here (regex-on-HTML) rather than in the text-content analyzers so it
// runs in the bundled browser path too, not just the CLI/static path.
@@ -2635,14 +2747,9 @@ function checkPageTypography(doc, win) {
// Check Google Fonts links in HTML
const html = doc.documentElement?.outerHTML || '';
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
let m;
while ((m = gfRe.exec(html)) !== null) {
const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase());
for (const f of families) {
fonts.add(f);
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
}
for (const f of extractGoogleFontFamilies(html)) {
fonts.add(f);
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
}
// Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM)
@@ -1,6 +1,9 @@
import { GENERIC_FONTS } from '../../shared/constants.mjs';
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
import { isNeutralColor } from '../../shared/color.mjs';
import { extractGoogleFontFamilies } from '../../shared/fonts.mjs';
import { checkSourceDesignSystem } from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
import { finding } from '../../findings.mjs';
import { filterByProviders } from '../../registry/antipatterns.mjs';
import { profileFindings, profileStep } from '../../profile/profiler.mjs';
@@ -36,11 +39,16 @@ function shouldRunPageAnalyzers(content, filePath) {
return !ext || PAGE_ANALYZER_EXTS.has(ext);
}
function firstOverusedGoogleFont(text) {
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
}
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i);
if (!m) return false;
const c = m[1].toLowerCase();
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
if (hex) {
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
@@ -57,10 +65,10 @@ function isNeutralBorderColor(str) {
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 2 : n >= 4; },
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 2 : n >= 3; },
fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },
{ id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
@@ -85,9 +93,12 @@ const REGEX_MATCHERS = [
{ id: 'overused-font', regex: /font-family\s*:\s*['"]?(Inter|Roboto|Open Sans|Lato|Montserrat|Arial|Helvetica|Fraunces|Geist Sans|Geist Mono|Geist|Mona Sans|Plus Jakarta Sans|Space Grotesk|Recoleta|Instrument Sans|Instrument Serif)\b/gi,
test: () => true,
fmt: (m) => m[0] },
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat|Fraunces|Plus\+Jakarta\+Sans|Space\+Grotesk|Instrument\+Sans|Instrument\+Serif|Mona\+Sans|Geist)\b/gi,
test: () => true,
fmt: (m) => `Google Fonts: ${m[1].replace(/\+/g, ' ')}` },
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi,
test: (m) => {
m.overusedGoogleFont = firstOverusedGoogleFont(m[0]);
return Boolean(m.overusedGoogleFont);
},
fmt: (m) => `Google Fonts: ${m.overusedGoogleFont || firstOverusedGoogleFont(m[0])}` },
// --- Gradient text ---
{ id: 'gradient-text', regex: /background-clip\s*:\s*text|-webkit-background-clip\s*:\s*text/gi,
test: (m, line) => /gradient/i.test(line),
@@ -167,10 +178,7 @@ const REGEX_ANALYZERS = [
if (f && !GENERIC_FONTS.has(f)) fonts.add(f);
}
}
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
while ((m = gfRe.exec(content)) !== null) {
for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) fonts.add(f);
}
for (const f of extractGoogleFontFamilies(content)) fonts.add(f);
if (fonts.size !== 1 || content.split('\n').length < 20) return [];
const name = [...fonts][0];
const lines = content.split('\n');
@@ -547,7 +555,10 @@ function detectText(content, filePath, options = {}) {
}
}
return filterByProviders(deduped, options?.providers);
const byProvider = filterByProviders(deduped, options?.providers);
// Inline `impeccable-disable*` waivers travel with the file; honor them unless
// explicitly bypassed (`--no-config` / `--no-inline-ignores`).
return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, content);
}
export {
@@ -8,6 +8,7 @@ import {
mergeDesignSystemFindings,
} from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
import { finding } from '../../findings.mjs';
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import {
@@ -223,7 +224,11 @@ async function detectHtml(filePath, options = {}) {
}
}
return filterByProviders(findings, options.providers);
const byProvider = filterByProviders(findings, options.providers);
// Static-HTML findings carry no line number, so only whole-file
// `impeccable-disable` directives apply here — exactly the standalone-document
// waiver this primitive targets. Bypassed by `--no-config` / `--no-inline-ignores`.
return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, html);
}
export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml };
@@ -21,6 +21,7 @@ const ANTIPATTERNS = [
{
id: 'overused-font',
category: 'slop',
scopes: ['type'],
name: 'Overused font',
description:
'Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.',
@@ -30,6 +31,7 @@ const ANTIPATTERNS = [
{
id: 'single-font',
category: 'slop',
scopes: ['type'],
name: 'Single font for everything',
description:
'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
@@ -39,6 +41,7 @@ const ANTIPATTERNS = [
{
id: 'flat-type-hierarchy',
category: 'slop',
scopes: ['type'],
name: 'Flat type hierarchy',
description:
'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
@@ -75,6 +78,7 @@ const ANTIPATTERNS = [
{
id: 'nested-cards',
category: 'slop',
scopes: ['layout'],
name: 'Nested cards',
description:
'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
@@ -84,6 +88,7 @@ const ANTIPATTERNS = [
{
id: 'monotonous-spacing',
category: 'slop',
scopes: ['layout'],
name: 'Monotonous spacing',
description:
'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
@@ -111,6 +116,7 @@ const ANTIPATTERNS = [
{
id: 'icon-tile-stack',
category: 'slop',
scopes: ['layout'],
name: 'Icon tile stacked above heading',
description:
'A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.',
@@ -120,6 +126,7 @@ const ANTIPATTERNS = [
{
id: 'italic-serif-display',
category: 'slop',
scopes: ['type'],
name: 'Italic serif display headline',
description:
'Oversized italic serif (Fraunces, Recoleta, Playfair, Newsreader-italic) as the primary hero headline reads as taste in isolation but has become the universal AI-startup landing page hero. Set roman, or move to a non-serif display face. Editorial / magazine register may legitimately want this — judge by context.',
@@ -129,6 +136,7 @@ const ANTIPATTERNS = [
{
id: 'hero-eyebrow-chip',
category: 'slop',
scopes: ['type'],
name: 'Hero eyebrow / pill chip',
description:
'A tiny uppercase letter-spaced label sitting immediately above an oversized hero headline — or the same shape rendered as a pill chip — is now the default AI SaaS hero. Drop the eyebrow, integrate the kicker into the headline, or run it as a navigation breadcrumb instead.',
@@ -138,6 +146,7 @@ const ANTIPATTERNS = [
{
id: 'repeated-section-kickers',
category: 'slop',
scopes: ['type'],
severity: 'advisory',
name: 'Repeated section kicker labels',
description:
@@ -148,6 +157,7 @@ const ANTIPATTERNS = [
{
id: 'numbered-section-markers',
category: 'slop',
scopes: ['layout'],
severity: 'advisory',
name: 'Numbered section markers (01 / 02 / 03)',
description:
@@ -185,6 +195,7 @@ const ANTIPATTERNS = [
{
id: 'oversized-h1',
category: 'slop',
scopes: ['type'],
name: 'Oversized hero headline',
description:
'A full-sentence headline set at display size ends up dominating the viewport, leaving no room for anything else above the fold. A punchy one- or two-word headline at that size is fine — the problem is a long headline blown up too large. Set long headlines smaller, or tighten the copy.',
@@ -194,6 +205,7 @@ const ANTIPATTERNS = [
{
id: 'extreme-negative-tracking',
category: 'slop',
scopes: ['type'],
name: 'Crushed letter spacing',
description:
'Letter-spacing pulled tighter than the point where characters keep their own shapes costs legibility. Tighten display type optically, not destructively.',
@@ -239,6 +251,7 @@ const ANTIPATTERNS = [
{
id: 'line-length',
category: 'quality',
scopes: ['type', 'layout'],
name: 'Line length too long',
description:
'Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.',
@@ -248,6 +261,7 @@ const ANTIPATTERNS = [
{
id: 'cramped-padding',
category: 'quality',
scopes: ['layout'],
name: 'Cramped padding',
description:
'Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 1216px) of padding inside bordered, outlined, or colored containers.',
@@ -257,6 +271,7 @@ const ANTIPATTERNS = [
{
id: 'body-text-viewport-edge',
category: 'quality',
scopes: ['layout'],
name: 'Body text touching viewport edge',
description:
'Body paragraphs render flush against the left or right viewport edge with no container providing horizontal padding. Wrap content in a container with at least 16px (ideally 24-32px) of horizontal padding, or apply max-width with mx-auto.',
@@ -264,6 +279,7 @@ const ANTIPATTERNS = [
{
id: 'tight-leading',
category: 'quality',
scopes: ['type'],
name: 'Tight line height',
description:
'Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.',
@@ -271,6 +287,7 @@ const ANTIPATTERNS = [
{
id: 'skipped-heading',
category: 'quality',
scopes: ['type'],
name: 'Skipped heading level',
description:
'Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.',
@@ -278,6 +295,7 @@ const ANTIPATTERNS = [
{
id: 'justified-text',
category: 'quality',
scopes: ['type'],
name: 'Justified text',
description:
'Justified text without hyphenation creates uneven word spacing ("rivers of white"). Use text-align: left for body text, or enable hyphens: auto if you must justify.',
@@ -285,6 +303,7 @@ const ANTIPATTERNS = [
{
id: 'tiny-text',
category: 'quality',
scopes: ['type'],
name: 'Tiny body text',
description:
'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
@@ -292,6 +311,7 @@ const ANTIPATTERNS = [
{
id: 'all-caps-body',
category: 'quality',
scopes: ['type'],
name: 'All-caps body text',
description:
'Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.',
@@ -301,6 +321,7 @@ const ANTIPATTERNS = [
{
id: 'wide-tracking',
category: 'quality',
scopes: ['type'],
name: 'Wide letter spacing on body text',
description:
'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.',
@@ -308,6 +329,7 @@ const ANTIPATTERNS = [
{
id: 'text-overflow',
category: 'quality',
scopes: ['layout'],
name: 'Content overflowing its container',
description:
'Content renders wider than its container, spilling out or forcing a horizontal scrollbar. Let text wrap, constrain widths, or give the region a deliberate scroll affordance.',
@@ -317,6 +339,7 @@ const ANTIPATTERNS = [
{
id: 'clipped-overflow-container',
category: 'quality',
scopes: ['layout'],
name: 'Positioned child clipped by overflow container',
description:
'A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.',
@@ -326,6 +349,7 @@ const ANTIPATTERNS = [
{
id: 'design-system-font',
category: 'quality',
scopes: ['type'],
name: 'Font outside DESIGN.md',
description:
'A font is used that is not declared in DESIGN.md typography. Use the documented type system or update DESIGN.md if this is an intentional brand addition.',
@@ -352,6 +376,17 @@ const ANTIPATTERNS = [
skillSection: 'Visual Details',
skillGuideline: 'border radius outside the project design system',
},
{
id: 'design-system-font-size',
category: 'quality',
severity: 'advisory',
scopes: ['type'],
name: 'Font size outside DESIGN.md',
description:
'A literal font-size is off the type ramp documented in DESIGN.md typography. Use a documented size step or update the design system if the new step is intentional.',
skillSection: 'Typography',
skillGuideline: 'font size outside the project design system',
},
// ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
{
@@ -376,6 +411,17 @@ const ANTIPATTERNS = [
skillSection: 'Visual Details',
skillGuideline: 'repeating-gradient decorative stripes',
},
{
id: 'codex-grid-background',
category: 'slop',
severity: 'advisory',
gated: 'gpt',
name: 'Decorative grid-line background',
description:
'A two-axis grid drawn with hairline linear-gradient layers ("1px, transparent 1px" on both axes) is a recurring generated-UI signature. Reserve grid overlays for actual canvas, map, blueprint, or measurement surfaces; elsewhere use product structure or a plain surface.',
skillSection: 'Visual Details',
skillGuideline: 'two-axis grid-line gradient background',
},
{
id: 'theater-slop-phrase',
category: 'slop',
@@ -437,12 +483,32 @@ function filterByProviders(findings, providers = []) {
});
}
// Set of scope tags rules can declare (e.g. 'type', 'layout'). Used by the
// CLI --scope flag to narrow output to one design domain.
const RULE_SCOPES = new Set(
ANTIPATTERNS.flatMap(rule => rule.scopes || []),
);
// Keep only findings whose rule declares at least one of the requested
// scopes. An empty scope list means no filtering (default CLI behavior).
function filterByScopes(findings, scopes = []) {
if (!scopes || scopes.length === 0) return findings;
const enabled = new Set(scopes);
return findings.filter(f => {
const rule = getAntipattern(f.antipattern);
return (rule?.scopes || []).some(scope => enabled.has(scope));
});
}
export {
ANTIPATTERNS,
RULE_SCOPES,
RULE_ENGINE_SUPPORT,
GATED_PROVIDERS,
getAntipattern,
getRulesForCategory,
getRuleEngineSupport,
filterByProviders,
filterByScopes,
};
@@ -18,6 +18,7 @@ import {
parseRgb,
relativeLuminance,
} from '../shared/color.mjs';
import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
const DETECTOR_IS_BROWSER = typeof window !== 'undefined';
@@ -573,6 +574,42 @@ function checkHtmlPatterns(html) {
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });
}
// --- Provider tells (gated): two-axis grid-line background (Codex/GPT) ---
// The Codex grid tell is two hairline `linear-gradient(... <color> 1px,
// transparent 1px)` layers (one per axis) tiled by a repeating
// `background-size` cell. Both signals must co-occur in the SAME style block
// (a CSS rule body or one inline `style="..."`): two hairline stops WITHOUT a
// tiling background-size is a fixed crosshair, not a grid, and a single
// hairline is a legitimate ruled line. Scoping to one block also stops
// unrelated single-axis rules on separate elements from adding up across the
// page. Count hairlines only inside `background`/`background-image` values so
// a hairline in an unrelated property (mask-image, border-image) can't stand
// in for the second axis. Colors like `oklch(96% 0.012 82 / 0.055)` carry
// nested parens, so match the hairline stop directly rather than parsing
// whole gradient layers.
{
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
const gridSizeRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
let blk;
while ((blk = blockRe.exec(html)) !== null) {
const block = blk[1] || blk[2] || blk[3] || '';
if (!gridSizeRe.test(block)) continue;
let hairlineCount = 0;
let bm;
bgDeclRe.lastIndex = 0;
while ((bm = bgDeclRe.exec(block)) !== null) {
const stops = bm[1].match(hairlineRe);
if (stops) hairlineCount += stops.length;
}
if (hairlineCount >= 2) {
findings.push({ id: 'codex-grid-background', snippet: 'two-axis grid-line gradient background' });
break;
}
}
}
// --- Provider tells (gated): "X theater" framing copy (GPT) ---
// Lives here (regex-on-HTML) rather than in the text-content analyzers so it
// runs in the bundled browser path too, not just the CLI/static path.
@@ -2036,14 +2073,9 @@ function checkPageTypography(doc, win) {
// Check Google Fonts links in HTML
const html = doc.documentElement?.outerHTML || '';
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
let m;
while ((m = gfRe.exec(html)) !== null) {
const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase());
for (const f of families) {
fonts.add(f);
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
}
for (const f of extractGoogleFontFamilies(html)) {
fonts.add(f);
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
}
// Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM)
@@ -0,0 +1,30 @@
const GOOGLE_FONTS_URL_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi;
function normalizeGoogleFontFamilyParam(value) {
return String(value || '')
.split('|')
.map(part => part.split(':')[0].trim().toLowerCase())
.filter(Boolean);
}
function extractGoogleFontFamilies(text) {
const families = [];
if (!text) return families;
GOOGLE_FONTS_URL_RE.lastIndex = 0;
let urlMatch;
while ((urlMatch = GOOGLE_FONTS_URL_RE.exec(text)) !== null) {
const url = urlMatch[0];
const queryStart = url.indexOf('?');
if (queryStart === -1) continue;
const params = new URLSearchParams(url.slice(queryStart + 1).replace(/&amp;/g, '&'));
for (const value of params.getAll('family')) {
families.push(...normalizeGoogleFontFamilyParam(value));
}
}
return families;
}
export { extractGoogleFontFamilies };
@@ -0,0 +1,148 @@
/**
* Inline, in-file ignore directives eslint-disable-style waivers that live at
* the point they apply and travel with the artifact instead of (or alongside)
* an ignore in `.impeccable/config.json`.
*
* A config ignore is the right default for repo-wide policy. This complements it
* for the one case config can't cover: a waiver that belongs to a single file and
* needs to follow that file when it leaves the repo a generated/exported
* standalone document, an emailed HTML file, a snippet scanned out of context.
*
* Comment-syntax-agnostic: the directive is a raw token matched anywhere on a
* line, so the same marker works across every comment style impeccable scans
* `//`, `/* *\/`, `<!-- -->`, `#`, `{/* *\/}`, `{# #}`. Trailing comment closers
* are stripped before the rule list is parsed.
*
* Syntax (reason optional; eslint `--` or biome `:` separator):
*
* impeccable-disable <rule>[, <rule>...] [-- reason] whole file
* impeccable-disable-line <rule>... [-- reason] the same line
* impeccable-disable-next-line <rule>... [-- reason] the following line
* impeccable-disable bare / `*` = every rule
*
* Examples:
*
* <!-- impeccable-disable overused-font -- exported brand doc, font is first-party -->
* .brand { font-family: Inter; } /* impeccable-disable-line overused-font *\/
* // impeccable-disable-next-line bounce-easing: intentional playful affordance
*
* Behavior is suppression, for parity with config ignores: a matched directive
* drops the finding. The inline reason is self-documenting in the diff; it is not
* required and is discarded at scan time (only used here to keep reason words out
* of the parsed rule list).
*/
const DIRECTIVE_RE = /impeccable-(disable-next-line|disable-line|disable)\b[ \t]*([^\n\r]*)/gi;
// Trailing comment closers, so `*/`, `*/}`, `-->`, `*}`, `#}`, `%>`, `}}` don't
// leak into the rule list. Anchored to end-of-line; the leading `\s*` mops up the
// space before the closer. `--+>` covers `-->` and any longer dash run.
const TRAILING_CLOSER_RE = /\s*(?:\*\/\}?|--+>|\*\}|#\}|%>|\}\})\s*$/;
function normalizeRule(token) {
return String(token || '').trim().toLowerCase();
}
// Split the directive remainder into rule tokens, dropping any human reason that
// follows an eslint-style `--` or biome-style `:` separator. Rule ids only ever
// contain single hyphens (`overused-font`, `bounce-easing`), so `--` and `:`
// are unambiguous separators.
function parseRuleList(remainder) {
let text = String(remainder || '').replace(TRAILING_CLOSER_RE, '').trim();
// Cut off a human reason at the first `--` (eslint) or `:` (biome) separator.
const reasonSep = text.match(/\s*(?:--+|:)\s*/);
if (reasonSep) text = text.slice(0, reasonSep.index);
const tokens = text.split(/[\s,]+/).map(normalizeRule).filter(Boolean);
if (tokens.length === 0 || tokens.includes('*')) return ['*'];
return tokens;
}
function addRules(set, rules) {
for (const rule of rules) set.add(rule);
}
function getSet(map, key) {
let set = map.get(key);
if (!set) {
set = new Set();
map.set(key, set);
}
return set;
}
/**
* Parse every inline ignore directive in a file's raw text.
*
* Returns sets keyed by the 1-based line the directive *targets* so matching is a
* direct lookup:
* - file: rules disabled for the whole file
* - line: line -> rules disabled on that exact line (disable-line)
* - nextLine: line -> rules disabled on that line (disable-next-line on line-1)
*
* `*` in any set means "every rule".
*/
function parseInlineIgnores(content) {
const result = { file: new Set(), line: new Map(), nextLine: new Map() };
const text = typeof content === 'string' ? content : '';
// Cheap bail-out: the substring must be present for any directive to exist.
// Case-insensitive to match DIRECTIVE_RE's `i` flag (e.g. `Impeccable-Disable`).
if (!/impeccable-disable/i.test(text)) return result;
// Split on `\n` only, exactly as detectText numbers lines, so directive line
// keys line up with finding `line` values (incl. on `\r`-only line endings).
// The directive regex excludes `\r`, so a trailing `\r` on `\r\n` files is
// never captured into the rule list.
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
DIRECTIVE_RE.lastIndex = 0;
let m;
while ((m = DIRECTIVE_RE.exec(lines[i])) !== null) {
const variant = m[1].toLowerCase();
const rules = parseRuleList(m[2]);
if (variant === 'disable') {
addRules(result.file, rules);
} else if (variant === 'disable-line') {
addRules(getSet(result.line, i + 1), rules);
} else {
// disable-next-line on line i+1 targets line i+2.
addRules(getSet(result.nextLine, i + 2), rules);
}
}
}
return result;
}
function setMatches(set, rule) {
return Boolean(set) && (set.has('*') || set.has(rule));
}
function isInlineIgnored(finding, directives) {
const rule = normalizeRule(finding && finding.antipattern);
if (!rule) return false;
if (setMatches(directives.file, rule)) return true;
const line = Number(finding && finding.line) || 0;
if (line > 0) {
if (setMatches(directives.line.get(line), rule)) return true;
if (setMatches(directives.nextLine.get(line), rule)) return true;
}
return false;
}
function hasDirectives(directives) {
return directives.file.size > 0 || directives.line.size > 0 || directives.nextLine.size > 0;
}
/**
* Drop findings waived by an inline directive in the same file's source text.
* Findings without a usable line number (e.g. static-HTML page-level findings)
* are only matched by whole-file directives which is the standalone-document
* case this primitive exists for.
*/
function applyInlineIgnores(findings, content) {
if (!Array.isArray(findings) || findings.length === 0) return findings;
const directives = parseInlineIgnores(content);
if (!hasDirectives(directives)) return findings;
return findings.filter((finding) => !isInlineIgnored(finding, directives));
}
export { parseInlineIgnores, applyInlineIgnores, isInlineIgnored };
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* `/impeccable hooks <on|off|status|reset>` manage the design hook runtime
* The Impeccable hooks command manages the design hook runtime
* via the `hook` key and shared detector ignores via the `detector` key in
* .impeccable/config.json / .impeccable/config.local.json.
*
@@ -21,6 +21,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { IMPECCABLE_COMMAND } from './lib/provider.mjs';
import {
getConfigPath,
@@ -75,7 +76,6 @@ const HOOK_MANIFEST_TARGETS = [
skillRel: '.agents/skills/impeccable',
destRel: '.codex/hooks.json',
manifest: () => ({
description: 'Impeccable design detector: runs after Edit/Write/apply_patch on UI files and surfaces findings as system reminders.',
hooks: {
PostToolUse: [
{
@@ -83,7 +83,7 @@ const HOOK_MANIFEST_TARGETS = [
hooks: [
{
type: 'command',
command: 'node "$(git rev-parse --show-toplevel)/.agents/skills/impeccable/scripts/hook.mjs"',
command: 'node ".agents/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
@@ -109,6 +109,28 @@ const HOOK_MANIFEST_TARGETS = [
},
}),
},
{
// GitHub Copilot reads repo-level hooks from `.github/hooks/*.json`. The same
// manifest is honored by the CLI (once committed to the default branch) and
// the cloud/app agent. Schema differs: lowercase `postToolUse`, flat entries,
// `bash`/`timeoutSec`, and a `matcher` regex against the `edit`/`create` tools.
provider: '.github',
skillRel: '.github/skills/impeccable',
destRel: '.github/hooks/impeccable.json',
manifest: () => ({
version: 1,
hooks: {
postToolUse: [
{
type: 'command',
matcher: 'edit|create|apply_patch',
bash: 'node "$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs"',
timeoutSec: TIMEOUT_SECONDS,
},
],
},
}),
},
];
function readRawConfigFile(filePath) {
@@ -163,7 +185,7 @@ function writeHookConfig(cwd, hookConfig, opts = {}) {
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const existingHook = stripDetectorKeys(hookSection(existing));
// Merge over the existing hook object so fields the merge helpers don't manage
// (consent, quiet, auditLog) survive a `/impeccable hooks` edit.
// (consent, quiet, auditLog) survive an Impeccable hooks edit.
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
@@ -400,7 +422,10 @@ function valueHasImpeccableHookMarker(value) {
function stripImpeccableHookEntry(entry) {
if (!entry || typeof entry !== 'object') return entry;
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
// `command`/`args`: Claude/Codex/Cursor. `bash`/`powershell`: GitHub Copilot's
// flat entry shape, where the marker lives under the shell-command keys.
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)
|| valueHasImpeccableHookMarker(entry.bash) || valueHasImpeccableHookMarker(entry.powershell)) {
return null;
}
if (!Array.isArray(entry.hooks)) return entry;
@@ -489,9 +514,9 @@ function parseIgnoreRuleArgs(args) {
function addIgnoreRule(cwd, args) {
const parsed = parseIgnoreRuleArgs(args);
const rule = parsed.rule;
if (!rule) throw new Error('Pass a rule id, e.g. /impeccable hooks ignore-rule side-tab');
if (!rule) throw new Error(`Pass a rule id, e.g. ${IMPECCABLE_COMMAND} hooks ignore-rule side-tab`);
if (rule === 'overused-font' && !parsed.allValues) {
throw new Error('overused-font is value-specific by default. Use /impeccable hooks ignore-value overused-font <font> for a confirmed font, or /impeccable hooks ignore-rule overused-font --all-values only when the user asked to ignore overused fonts generally.');
throw new Error(`overused-font is value-specific by default. Use ${IMPECCABLE_COMMAND} hooks ignore-value overused-font <font> for a confirmed font, or ${IMPECCABLE_COMMAND} hooks ignore-rule overused-font --all-values only when the user asked to ignore overused fonts generally.`);
}
const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
if (!config.ignoreRules.includes(rule)) config.ignoreRules.push(rule);
@@ -500,7 +525,7 @@ function addIgnoreRule(cwd, args) {
}
function addIgnoreFile(cwd, glob) {
if (!glob) throw new Error('Pass a glob, e.g. /impeccable hooks ignore-file "src/legacy/**"');
if (!glob) throw new Error(`Pass a glob, e.g. ${IMPECCABLE_COMMAND} hooks ignore-file "src/legacy/**"`);
const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob);
writeDetectorConfig(cwd, config);
@@ -545,7 +570,7 @@ function parseIgnoreValueArgs(args) {
function addIgnoreValue(cwd, args) {
const parsed = parseIgnoreValueArgs(args);
if (!parsed.rule || !parsed.value) {
throw new Error('Pass a rule id and value, e.g. /impeccable hooks ignore-value overused-font Inter');
throw new Error(`Pass a rule id and value, e.g. ${IMPECCABLE_COMMAND} hooks ignore-value overused-font Inter`);
}
if (parsed.shared && parsed.local) {
@@ -11,6 +11,7 @@
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
@@ -21,13 +22,17 @@ import {
appendDesignSystemNote,
designSystemOptions,
filterFindings,
isNativePlatform,
loadDetector,
matchConfiguredExtension,
matchesAnyGlob,
persistCache,
readCache,
readConfig,
renderTemplate,
resolveCacheCwd,
resolveProjectCwd,
resolveProjectPlatform,
truthy,
writeAuditLog,
} from './hook-lib.mjs';
@@ -332,6 +337,22 @@ function isInsideProject(filePath, cwd) {
}
}
// 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.
async function detectProposedHtml(detector, content, filePath, scanOptions) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-pre-'));
const tmpFile = path.join(dir, path.basename(filePath));
try {
fs.writeFileSync(tmpFile, content);
const findings = await detector.detectHtml(tmpFile, scanOptions);
// Findings carry the temp path; remap so file-scoped ignores still match.
return (findings || []).map((f) => (f && typeof f === 'object' ? { ...f, file: filePath } : f));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
function cursorBlockMessage(findings, filePath, config, cwd) {
const rendered = renderTemplate(findings, filePath, config, { cwd });
const blocked = rendered.replace(
@@ -379,9 +400,12 @@ async function main() {
return allow({ skipped: 'stdin-empty' });
}
const cwd = resolveProjectCwd(event);
const sessionCwd = resolveProjectCwd(event);
const started = Date.now();
const filePath = proposedFilePath(event, cwd);
const filePath = proposedFilePath(event, sessionCwd);
// Re-key config/cache to the edited file's project root when the session
// was launched from a non-project umbrella directory (issue #305).
const cwd = resolveCacheCwd(filePath, sessionCwd);
const audit = {
harness: 'cursor',
cwd,
@@ -394,9 +418,13 @@ async function main() {
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 });
// Config is read before the extension gate so `detector.extensions` entries
// (e.g. `.blade.php` template files, issue #316) can widen it.
const config = readConfig(cwd);
const ext = path.extname(filePath).toLowerCase();
audit.ext = ext;
if (!ALLOWED_EXTS.has(ext)) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
const configuredExt = matchConfiguredExtension(filePath, config.extensions);
audit.ext = configuredExt ? configuredExt.ext : ext;
if (!ALLOWED_EXTS.has(ext) && !configuredExt) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
const contentResult = proposedContent(event, cwd, filePath);
if (contentResult && typeof contentResult === 'object' && contentResult.skipped) {
@@ -405,9 +433,14 @@ async function main() {
const content = typeof contentResult === 'string' ? contentResult : '';
if (!content) return allow({ ...audit, skipped: 'no-proposed-content', durationMs: Date.now() - started });
const config = readConfig(cwd);
if (config.enabled === false) return allow({ ...audit, skipped: 'config-disabled', durationMs: Date.now() - started });
// Web rule engine, native project: stand aside (see resolveProjectPlatform).
const platform = resolveProjectPlatform(cwd);
if (isNativePlatform(platform)) {
return allow({ ...audit, skipped: 'native-platform', platform, durationMs: Date.now() - started });
}
const rel = relativePath(filePath, cwd);
if (matchesAnyGlob(rel, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) {
return allow({ ...audit, skipped: 'config-ignore-file', durationMs: Date.now() - started });
@@ -419,9 +452,16 @@ async function main() {
}
const scanOptions = designSystemOptions(config, detector, cwd);
// Mirror runHook's engine routing so template issues the HTML engine catches
// post-edit cannot slip past the pre-write gate.
const useHtmlEngine = configuredExt
? configuredExt.engine === 'html'
: (ext === '.html' || ext === '.htm');
let findings = [];
try {
findings = await detector.detectText(content, filePath, scanOptions);
findings = useHtmlEngine && typeof detector.detectHtml === 'function'
? await detectProposedHtml(detector, content, filePath, scanOptions)
: await detector.detectText(content, filePath, scanOptions);
} catch {
return allow({ ...audit, error: 'detector-threw', durationMs: Date.now() - started });
}
+267 -28
View File
@@ -9,15 +9,17 @@
* ENVELOPE_PREFIX, ALLOWED_EXTS, ACK_EXTS, SENSITIVE_PATH, GENERATED_PATH, TRUTHY
* truthy(value)
* readConfig(cwd) / DEFAULT_CONFIG / getConfigPath(cwd) / getLocalConfigPath(cwd)
* resolveProjectPlatform(cwd) / isNativePlatform(platform)
* normalizeIgnoreValue(value)
* readCache(cwd) / persistCache(cwd, cache)
* readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd)
* bumpEditCount(cache, sessionId, filePath) -> number
* suppressionNotice(filePath)
* filterFindings(findings, content, ext, config)
* matchConfiguredExtension(filePath, extensions)
* dedupeAgainstCache(findings, cache, sessionId, filePath)
* renderTemplate(findings, filePath, config, opts)
* renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts)
* shouldEmitAckForFile(filePath)
* shouldEmitAckForFile(filePath, config?)
* writeAuditLog(env, entry)
* loadDetector() -> Promise<{ detectText, detectHtml }>
* matchesAnyGlob(filePath, globs)
@@ -35,8 +37,11 @@
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { pathToFileURL, fileURLToPath } from 'node:url';
import { extractPlatform, loadContext } from './context.mjs';
import { IMPECCABLE_COMMAND } from './lib/provider.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -77,6 +82,7 @@ export const DEFAULT_CONFIG = Object.freeze({
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
extensions: [],
limits: { maxFindings: 5, maxChars: 8000 },
});
@@ -134,6 +140,59 @@ export function resolveProjectCwd(event, fallback = process.cwd()) {
|| fallback;
}
function looksLikeProjectRoot(dir) {
return ['.git', 'package.json', '.impeccable'].some((marker) => {
try { return fs.existsSync(path.join(dir, marker)); } catch { return false; }
});
}
// Where `.impeccable/` (cache + config) lives for this event. Normally the
// session cwd, untouched. But when the agent was launched from an umbrella
// directory that is not itself a project (no .git, package.json, or
// .impeccable), key to the edited file's nearest project root instead, so a
// multi-project launch dir doesn't accumulate a shared cross-project cache
// (issue #305). Climbing stops at the home dir, falling back to the session
// cwd when no marker is found.
export function resolveCacheCwd(primaryFile, sessionCwd) {
const base = path.resolve(sessionCwd || process.cwd());
if (!primaryFile || typeof primaryFile !== 'string' || hasPathTraversal(primaryFile)) return base;
if (looksLikeProjectRoot(base)) return base;
let dir;
try {
dir = path.dirname(path.resolve(primaryFile));
} catch {
return base;
}
const home = path.resolve(os.homedir());
while (true) {
if (dir === home) return base;
if (looksLikeProjectRoot(dir)) return dir;
const parent = path.dirname(dir);
if (parent === dir) return base;
dir = parent;
}
}
// The detector's rules are web rules (HTML/CSS shapes), but a React Native or
// Flutter project is made of the exact extensions the hook watches (.tsx, .ts,
// .js), so without this gate every native screen edit would draw web-shaped
// findings that contradict the native platform references. PRODUCT.md's
// `## Platform` field decides: `ios` / `android` / `adaptive` projects skip
// the scan entirely. Resolution goes through loadContext so the hook reads the
// same PRODUCT.md the skill does (alternate context dirs, monorepo fallback).
export function resolveProjectPlatform(cwd) {
try {
const ctx = loadContext(cwd);
return extractPlatform(ctx && ctx.product);
} catch {
return null;
}
}
export function isNativePlatform(platform) {
return platform === 'ios' || platform === 'android' || platform === 'adaptive';
}
export function readConfig(cwd) {
const config = cloneDefaultConfig();
// Hook runtime settings live under `hook`; detector filters live under
@@ -168,6 +227,7 @@ function cloneDefaultConfig() {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
extensions: [],
designSystem: { ...DEFAULT_CONFIG.designSystem },
limits: { ...DEFAULT_CONFIG.limits },
};
@@ -190,9 +250,55 @@ function applyDetectorConfigSource(config, raw) {
if (Array.isArray(raw.ignoreValues)) {
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
}
if (Array.isArray(raw.extensions)) {
config.extensions = mergeExtensions(config.extensions, raw.extensions);
}
return config;
}
// Extra scanned extensions from `detector.extensions` config. Entries are
// `{ ext, engine }` (engine 'html' | 'text', default 'html' — the common case
// for server-side templates) or bare strings as shorthand. Extensions are
// matched against the end of the filename, not path.extname, so double
// extensions like `.blade.php` and `.html.erb` work (issue #316).
function normalizeExtensionEntries(entries) {
if (!Array.isArray(entries)) return [];
const out = [];
for (const entry of entries) {
const raw = typeof entry === 'string' ? entry : entry?.ext;
if (typeof raw !== 'string') continue;
let ext = raw.trim().toLowerCase();
if (!ext) continue;
if (!ext.startsWith('.')) ext = `.${ext}`;
const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html';
out.push({ ext, engine });
}
return out;
}
function mergeExtensions(existing, incoming) {
const map = new Map();
for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry);
for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry);
return Array.from(map.values());
}
export function matchConfiguredExtension(filePath, extensions) {
if (!Array.isArray(extensions) || extensions.length === 0) return null;
const name = path.basename(String(filePath || '')).toLowerCase();
if (!name) return null;
// The longest matching suffix wins, so `.blade.php` beats a broader `.php`
// entry regardless of config order.
let best = null;
for (const entry of normalizeExtensionEntries(extensions)) {
if (name.length > entry.ext.length && name.endsWith(entry.ext)
&& (!best || entry.ext.length > best.ext.length)) {
best = entry;
}
}
return best;
}
function applyConfigSource(config, raw) {
if (!raw || typeof raw !== 'object') return config;
if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) {
@@ -556,7 +662,7 @@ export function bumpEditCount(cache, sessionId, filePath) {
}
export function suppressionNotice(filePath) {
return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run /impeccable audit to revisit.`;
return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run ${IMPECCABLE_COMMAND} audit to revisit.`;
}
// Glob → RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
@@ -628,11 +734,13 @@ export function filterFindings(findings, _content, _ext, config) {
function isIgnoredFindingValue(finding, ignoreValues) {
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
const rule = normalizeIgnoreRule(finding.antipattern);
if (!rule) return false;
// File-scoped wildcards suppress rules with no extractable value, such as side-tab.
const value = extractFindingIgnoreValue(finding);
if (!rule || !value) return false;
return ignoreValues.some((entry) => {
if (entry.rule !== rule) return false;
const wildcardValue = entry.value === '*';
if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
if (!wildcardValue && (!value || !ignoreValueMatches(rule, entry.value, value))) return false;
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
return findingMatchesScopedIgnoreFile(finding, entry.files);
});
@@ -770,7 +878,7 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
const lines = shown.map((f) => formatFindingLine(f));
const more = remaining > 0
? `... and ${remaining} more (see /impeccable audit).`
? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).`
: null;
const footer = directiveFooter(display);
@@ -814,7 +922,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
shownCount += shown.length;
const hidden = group.findings.length - shown.length;
if (hidden > 0) {
lines.push(`- ... ${hidden} more in ${display} (see /impeccable audit).`);
lines.push(`- ... ${hidden} more in ${display} (see ${IMPECCABLE_COMMAND} audit).`);
}
}
@@ -830,7 +938,7 @@ function clampGroupedToBudget(header, lines, footer, maxChars) {
const assemble = (linesArr, omitted) => [
header,
...linesArr,
...(omitted ? ['... and more (see /impeccable audit).'] : []),
...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []),
'',
footer,
].join('\n');
@@ -863,7 +971,7 @@ function clampToBudget(header, lines, more, footer, maxChars) {
let assembled = assemble(working, moreText);
while (assembled.length > maxChars && working.length > 1) {
working.pop();
moreText = '... and more (see /impeccable audit).';
moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`;
assembled = assemble(working, moreText);
}
if (assembled.length > maxChars) {
@@ -895,7 +1003,7 @@ function formatFindingIgnoreCommand(finding) {
const value = extractFindingIgnoreValueRaw(finding);
const valueArg = quoteCommandArg(value);
const reason = quoteCommandArg(`User confirmed ${value} is intentional`);
return `/impeccable hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`;
return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`;
}
function quoteCommandArg(value) {
@@ -959,13 +1067,114 @@ export function resolveTargetFiles(event, projectCwd) {
export function resolveHarness(env = {}, event = null) {
const explicit = env?.IMPECCABLE_HOOK_HARNESS;
if (explicit === 'cursor') return 'cursor';
if (explicit === 'github') return 'github';
if (explicit === 'claude' || explicit === 'codex') return 'claude';
// GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
// has no `tool_name`/`tool_input`. That shape is the discriminator.
if (event && typeof event === 'object'
&& (typeof event.toolName === 'string' || event.toolArgs !== undefined)
&& event.tool_name === undefined && event.tool_input === undefined) {
return 'github';
}
if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
return 'claude';
}
// GitHub Copilot's postToolUse payload is
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
// `toolArgs` shape depends on the tool: the `edit`/`create`/`view` tools send a
// JSON *string* (double-encoded) carrying the file under `path`, e.g.
// "{\"path\":\"/abs/app.tsx\",\"old_str\":\"...\",\"new_str\":\"...\"}",
// while `apply_patch` sends a raw OpenAI-format patch string (handled below in
// normalizeGitHubEvent). The detector reads the file from disk after the tool
// ran, so only the path (not the proposed content) is needed here.
export function parseGitHubToolArgs(toolArgs) {
if (toolArgs && typeof toolArgs === 'object' && !Array.isArray(toolArgs)) return toolArgs;
if (typeof toolArgs === 'string' && toolArgs.trim()) {
try {
const parsed = JSON.parse(toolArgs);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
return {};
}
// Copilot's `apply_patch` tool (used by interactive sessions and the cloud
// agent) sends a raw OpenAI-format patch string in toolArgs, not JSON:
// *** Begin Patch
// *** Add File: /abs/app.css
// +body { ... }
// *** End Patch
// The `view`/`edit`/`create` tools (seen in `copilot -p` runs) instead send a
// JSON string with the path under `path`. Both must map onto the internal shape.
const APPLY_PATCH_MARKER = /\*\*\* (?:Begin Patch|Add File:|Update File:|Delete File:)/;
function looksLikeApplyPatch(rawArgs) {
if (typeof rawArgs !== 'string' || !APPLY_PATCH_MARKER.test(rawArgs)) return false;
// Guard against an edit/create payload whose edited *content* happens to
// contain patch markers: that payload is a JSON object string, whereas a real
// apply_patch payload is a raw patch string that does not parse as JSON. Only
// treat non-JSON-object strings as apply_patch so edit events still get their
// `path` extracted.
try {
const parsed = JSON.parse(rawArgs);
if (parsed && typeof parsed === 'object') return false;
} catch { /* not JSON → genuine raw patch */ }
return true;
}
function applyPatchText(rawArgs) {
if (typeof rawArgs === 'string') {
if (APPLY_PATCH_MARKER.test(rawArgs)) return rawArgs;
// Defensive: a future Copilot build might JSON-wrap the patch.
const parsed = parseGitHubToolArgs(rawArgs);
return parsed.patch || parsed.input || parsed.command || '';
}
if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
return rawArgs.patch || rawArgs.input || rawArgs.command || '';
}
return '';
}
function normalizeGitHubEvent(event, projectCwd) {
const cwd = event.cwd || envProjectDir(projectCwd) || projectCwd;
const sessionId = event.sessionId || event.session_id || 'unknown';
const toolName = event.toolName || event.tool_name || null;
const toolInput = event.tool_input && typeof event.tool_input === 'object' ? { ...event.tool_input } : {};
const rawArgs = event.toolArgs;
let normalizedToolName = toolName;
if (toolName === 'apply_patch' || looksLikeApplyPatch(rawArgs)) {
// resolveTargetFiles() reads the touched paths from tool_input.command when
// tool_name is 'apply_patch', so normalize the name even if a future build
// sends the patch under a different tool label.
const patch = applyPatchText(rawArgs);
if (patch) {
toolInput.command = patch;
normalizedToolName = 'apply_patch';
}
} else {
const args = parseGitHubToolArgs(rawArgs);
const filePath = args.path || args.file_path || args.filePath || args.target_file;
if (typeof filePath === 'string' && filePath) toolInput.file_path = filePath;
}
return {
...event,
cwd,
session_id: sessionId,
tool_name: normalizedToolName,
tool_input: toolInput,
};
}
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
if (!event || typeof event !== 'object' || harness !== 'cursor') return event;
if (!event || typeof event !== 'object') return event;
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
if (harness !== 'cursor') return event;
const cwd = event.cwd
|| (Array.isArray(event.workspace_roots) && event.workspace_roots[0])
@@ -1200,12 +1409,12 @@ export function setDetectorForTesting(impl) {
// session" so the model knows it's a re-mind, not a new finding.
// ────────────────────────────────────────────────────────────────────────
const STEER_LINE = 'Keep typography hierarchy, spacing rhythm, and color contrast intentional on the next change.';
const STEER_LINE = 'That does not mean the design is good: keep following the project design system and the impeccable skill guidance.';
export function renderCleanAck(filePath, opts = {}) {
const cwd = opts.cwd || process.cwd();
const display = relativize(filePath, cwd);
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No anti-patterns. ${STEER_LINE}`;
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No deterministic design-quality issues found. ${STEER_LINE}`;
}
export function renderPendingAck(filePath, knownFindings, opts = {}) {
@@ -1218,8 +1427,12 @@ export function renderPendingAck(filePath, knownFindings, opts = {}) {
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} finding(s) flagged earlier this session (${sample}${more}). Handle them before finalizing — the previous reminder still applies.`;
}
export function shouldEmitAckForFile(filePath) {
return ACK_EXTS.has(path.extname(String(filePath || '')).toLowerCase());
export function shouldEmitAckForFile(filePath, config = null) {
if (ACK_EXTS.has(path.extname(String(filePath || '')).toLowerCase())) return true;
// Configured html-engine extensions are declared UI markup, so they get the
// clean/pending acks; text-engine ones stay quiet like plain .ts/.js.
const configured = matchConfiguredExtension(filePath, config?.extensions);
return Boolean(configured && configured.engine === 'html');
}
export function designSystemOptions(config, detector, projectCwd) {
@@ -1235,7 +1448,7 @@ export function designSystemOptions(config, detector, projectCwd) {
export function appendDesignSystemNote(text, scanOptions) {
if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text;
return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run /impeccable document to refresh the design-system sidecar.`;
return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`;
}
// The directive footer is the part of the hook output that steers model
@@ -1252,16 +1465,16 @@ export function appendDesignSystemNote(text, scanOptions) {
// raw envelope. Asking the model to surface the resolution in its
// reply is the cheapest way to make the feedback loop visible.
function directiveFooter(display, opts = {}) {
const ignoreFileCommand = `/impeccable hooks ignore-file ${quoteCommandArg(display)}`;
const ignoreFileCommand = `${IMPECCABLE_COMMAND} hooks ignore-file ${quoteCommandArg(display)}`;
const fileIgnoreGuidance = opts.grouped
? 'run `/impeccable hooks ignore-file <path>` for the specific file'
? `run \`${IMPECCABLE_COMMAND} hooks ignore-file <path>\` for the specific file`
: `run \`${ignoreFileCommand}\``;
return [
'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',
'',
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
'',
`Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
`Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable <rule>\` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`${IMPECCABLE_COMMAND} hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`${IMPECCABLE_COMMAND} hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`${IMPECCABLE_COMMAND} hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`,
].join('\n');
}
@@ -1301,9 +1514,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
event = normalizeHookEvent(event, cwd, harness);
audit.harness = harness;
const projectCwd = event.cwd || cwd;
const sessionCwd = event.cwd || cwd;
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, sessionCwd), sessionCwd);
const projectCwd = resolveCacheCwd(primaryFiles[0], sessionCwd);
audit.cwd = projectCwd;
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd);
const primaryFileSet = new Set(primaryFiles);
const targetFiles = expandScanTargets(primaryFiles, projectCwd);
audit.session = event.session_id || null;
@@ -1318,11 +1532,16 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
return result({ skipped: 'config-disabled', durationMs: Date.now() - started });
}
const platform = resolveProjectPlatform(projectCwd);
if (isNativePlatform(platform)) {
return result({ skipped: 'native-platform', platform, durationMs: Date.now() - started });
}
const cache = readCache(projectCwd);
const sessionId = event.session_id || 'unknown';
const det = detector || await loadDetector();
if (!det || typeof det.detectText !== 'function') {
persistCache(projectCwd, cache);
// Cache is not mutated yet at this point; nothing to persist.
return result({ skipped: 'detector-missing', durationMs: Date.now() - started });
}
const scanOptions = designSystemOptions(config, det, projectCwd);
@@ -1334,6 +1553,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
let detectorThrewAny = false;
let lastSkip = 'no-scannable-file';
let suppressedHit = false;
let cacheDirty = false;
for (const filePath of targetFiles) {
audit.file = filePath;
@@ -1348,8 +1568,9 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
}
const ext = path.extname(filePath).toLowerCase();
audit.ext = ext;
if (!ALLOWED_EXTS.has(ext)) {
const configuredExt = matchConfiguredExtension(filePath, config.extensions);
audit.ext = configuredExt ? configuredExt.ext : ext;
if (!ALLOWED_EXTS.has(ext) && !configuredExt) {
lastSkip = 'extension';
continue;
}
@@ -1366,6 +1587,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
if (primaryFileSet.has(filePath)) {
const editCount = bumpEditCount(cache, sessionId, filePath);
cacheDirty = true;
audit.editCount = editCount;
if (editCount > EDIT_COUNT_THRESHOLD) {
@@ -1382,7 +1604,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
const content = fs.readFileSync(filePath, 'utf-8');
let findings;
let detectorThrew = false;
if ((ext === '.html' || ext === '.htm') && typeof det.detectHtml === 'function') {
const useHtmlEngine = configuredExt
? configuredExt.engine === 'html'
: (ext === '.html' || ext === '.htm');
if (useHtmlEngine && typeof det.detectHtml === 'function') {
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
} else {
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
@@ -1395,6 +1620,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
if (fresh.length > 0) {
rememberFindings(cache, sessionId, filePath, fresh);
cacheDirty = true;
freshGroups.push({ filePath, findings: fresh });
continue;
}
@@ -1412,7 +1638,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
}
}
persistCache(projectCwd, cache);
// Persist only when the write is earned: fresh findings justify creating
// `.impeccable/` (dedup and suppression need it), and an already-present
// `.impeccable/` dir marks a project that opted in. A non-UI edit, or a
// clean UI edit in a project with no Impeccable footprint, must be a
// no-op on disk (issues #344, #305).
if (freshGroups.length > 0
|| (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) {
persistCache(projectCwd, cache);
}
if (freshGroups.length > 0) {
const firstGroup = freshGroups[0];
@@ -1447,7 +1681,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
}
if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath)) {
if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) {
const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions);
return {
exitCode: 0,
@@ -1481,7 +1715,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
};
}
if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath)) {
if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath, config)) {
const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions);
return {
exitCode: 0,
@@ -1520,6 +1754,11 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
if (harness === 'cursor') {
return JSON.stringify({ additional_context: text });
}
// GitHub Copilot's postToolUse hook injects context via a top-level
// `additionalContext` string (alongside an optional `modifiedResult`).
if (harness === 'github') {
return JSON.stringify({ additionalContext: text });
}
return JSON.stringify({
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
});
@@ -0,0 +1,640 @@
/**
* CLI-side reader/writer for the unified `.impeccable` config.
*
* The CLI (published to npm) and the skill scripts (bundled into the install)
* live in separate trees and cannot share runtime code, so this duplicates a
* small slice of skill/scripts/hook-lib.mjs the config-path layout, detector
* ignore semantics, and the `.git/info/exclude` handling. Keep the schema,
* ignore filtering, and exclude marker in sync if either side changes.
*
* Schema (config.json shared / config.local.json gitignored, per-developer):
* {
* "detector": { "ignoreRules": [], "ignoreFiles": [], "ignoreValues": [], "designSystem": { "enabled": true } },
* "hook": { "consent": "accepted" | "declined", ... },
* "updateCheck": bool
* }
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
import { join, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
export function getConfigPath(root) {
return join(root, '.impeccable', 'config.json');
}
export function getLocalConfigPath(root) {
return join(root, '.impeccable', 'config.local.json');
}
function safeReadJson(filePath) {
try {
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
} catch {
return null;
}
}
function hookSection(raw) {
return raw && raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
function detectorSection(raw) {
return raw && raw.detector && typeof raw.detector === 'object' && !Array.isArray(raw.detector) ? raw.detector : null;
}
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
const DEFAULT_DETECTION_CONFIG = Object.freeze({
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { enabled: true },
});
function cloneDetectionConfig() {
return {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { ...DEFAULT_DETECTION_CONFIG.designSystem },
};
}
function cloneRawDetectionConfig() {
return {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
};
}
function applyDetectionConfigSource(config, raw) {
if (!raw || typeof raw !== 'object') return config;
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
config.designSystem = {
...config.designSystem,
enabled: raw.designSystem.enabled === false ? false : true,
};
}
if (Array.isArray(raw.ignoreRules)) {
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
}
if (Array.isArray(raw.ignoreFiles)) {
config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
}
if (Array.isArray(raw.ignoreValues)) {
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
}
return config;
}
function uniqueStrings(values) {
return Array.from(new Set(values.map(String)));
}
/**
* Detector filters shared by `npx impeccable detect` and the design hook.
* `hook.enabled` remains hook lifecycle state; manual CLI scans still run when
* the hook is disabled, but they honor the same ignore rules and design-system
* toggle.
*/
export function readDetectionConfig(root) {
const config = cloneDetectionConfig();
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const raw = safeReadJson(filePath);
// Back-compat: old builds stored detector filters under hook.*.
applyDetectionConfigSource(config, hookSection(raw));
applyDetectionConfigSource(config, detectorSection(raw));
}
return config;
}
export function readRawDetectionConfig(root, opts = {}) {
const raw = safeReadJson(opts.local ? getLocalConfigPath(root) : getConfigPath(root));
const config = cloneRawDetectionConfig();
applyDetectionConfigSource(config, hookSection(raw));
applyDetectionConfigSource(config, detectorSection(raw));
return config;
}
export function writeDetectionConfig(root, detectorConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(root) : getConfigPath(root);
if (opts.local) ensureConfigGitExclude(root);
const existing = safeReadJson(filePath) || {};
const existingHook = hookSection(existing);
const nextHook = stripDetectorKeys(existingHook);
const nextDetector = {
...(detectorSection(existing) || {}),
...normalizeDetectionConfigForWrite(detectorConfig),
};
const next = {
...existing,
detector: nextDetector,
};
if (nextHook && Object.keys(nextHook).length > 0) {
next.hook = nextHook;
} else {
delete next.hook;
}
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
return filePath;
}
function normalizeDetectionConfigForWrite(config) {
const out = {};
if (Array.isArray(config?.ignoreRules)) {
out.ignoreRules = uniqueStrings(config.ignoreRules.map((rule) => normalizeIgnoreRule(rule)).filter(Boolean));
}
if (Array.isArray(config?.ignoreFiles)) {
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
}
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
out.designSystem = {
enabled: config.designSystem.enabled === false ? false : true,
};
}
return out;
}
function stripDetectorKeys(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const out = {};
for (const [key, value] of Object.entries(raw)) {
if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
}
return out;
}
export function normalizeIgnoreValue(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
function normalizeIgnoreRule(rule) {
return String(rule || '').trim().toLowerCase();
}
function colorIgnoreKey(value) {
const color = parseIgnoreColor(value);
if (!color) return '';
return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
}
function parseIgnoreColor(value) {
const text = String(value || '').trim().toLowerCase();
if (!text) return null;
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
if (hex) return parseHexIgnoreColor(hex[1]);
const rgb = text.match(/^rgba?\((.*)\)$/i);
if (rgb) {
const parts = splitColorArgs(rgb[1]);
if (parts.length < 3 || parts.length > 4) return null;
const r = parseRgbChannel(parts[0]);
const g = parseRgbChannel(parts[1]);
const b = parseRgbChannel(parts[2]);
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
if ([r, g, b, a].some((v) => v === null)) return null;
return { r, g, b, a };
}
const hsl = text.match(/^hsla?\((.*)\)$/i);
if (hsl) {
const parts = splitColorArgs(hsl[1]);
if (parts.length < 3 || parts.length > 4) return null;
const h = parseHueChannel(parts[0]);
const s = parsePercentChannel(parts[1]);
const l = parsePercentChannel(parts[2]);
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
if ([h, s, l, a].some((v) => v === null)) return null;
return hslToRgb(h, s, l, a);
}
return null;
}
function parseHexIgnoreColor(hex) {
if (hex.length === 3 || hex.length === 4) {
const r = parseInt(hex[0] + hex[0], 16);
const g = parseInt(hex[1] + hex[1], 16);
const b = parseInt(hex[2] + hex[2], 16);
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
return { r, g, b, a };
}
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
return { r, g, b, a };
}
function splitColorArgs(body) {
const text = String(body || '').trim();
if (!text) return [];
if (text.includes(',')) {
const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
const last = parts[parts.length - 1];
if (last && last.includes('/')) {
const split = last.split('/').map((part) => part.trim()).filter(Boolean);
return [...parts.slice(0, -1), ...split];
}
return parts;
}
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
}
function parseRgbChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const scaled = match[2] ? value * 2.55 : value;
if (scaled < 0 || scaled > 255) return null;
return Math.round(scaled);
}
function parseAlphaChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const alpha = match[2] ? value / 100 : value;
return alpha >= 0 && alpha <= 1 ? alpha : null;
}
function parseHueChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const unit = match[2] || 'deg';
if (unit === 'turn') return value * 360;
if (unit === 'rad') return value * (180 / Math.PI);
if (unit === 'grad') return value * 0.9;
return value;
}
function parsePercentChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)%$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
return value >= 0 && value <= 100 ? value / 100 : null;
}
function hslToRgb(hue, saturation, lightness, alpha) {
const h = (((hue % 360) + 360) % 360) / 360;
if (saturation === 0) {
const gray = clampByte(Math.round(lightness * 255));
return { r: gray, g: gray, b: gray, a: alpha };
}
const q = lightness < 0.5
? lightness * (1 + saturation)
: lightness + saturation - lightness * saturation;
const p = 2 * lightness - q;
const toRgb = (t) => {
let channel = t;
if (channel < 0) channel += 1;
if (channel > 1) channel -= 1;
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
if (channel < 1 / 2) return q;
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
return p;
};
return {
r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
g: clampByte(Math.round(toRgb(h) * 255)),
b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
a: alpha,
};
}
function clampByte(value) {
return Math.min(255, Math.max(0, value));
}
function ignoreValueMatches(rule, entryValue, findingValue) {
if (entryValue === findingValue) return true;
if (rule !== 'design-system-color') return false;
const entryColor = colorIgnoreKey(entryValue);
return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
}
export function normalizeIgnoreValueEntries(entries) {
if (!Array.isArray(entries)) return [];
const out = [];
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
const rule = normalizeIgnoreRule(entry.rule);
const value = normalizeIgnoreValue(entry.value);
if (!rule || !value) continue;
const normalized = { rule, value };
const files = uniqueStrings([
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
]);
if (files.length > 0) normalized.files = files;
if (typeof entry.reason === 'string' && entry.reason.trim()) {
normalized.reason = entry.reason.trim();
}
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
normalized.createdAt = entry.createdAt.trim();
}
out.push(normalized);
}
return out;
}
function mergeIgnoreValues(existing, incoming) {
const map = new Map();
for (const entry of normalizeIgnoreValueEntries(existing)) {
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
}
for (const entry of normalizeIgnoreValueEntries(incoming)) {
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
}
return Array.from(map.values());
}
function ignoreValueFilesKey(files) {
return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';
}
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
function globToRegex(glob) {
let re = '^';
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === '*') {
if (glob[i + 1] === '*') {
re += '.*';
i += 2;
if (glob[i] === '/') i += 1;
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (c === '{') {
const end = glob.indexOf('}', i);
if (end === -1) { re += '\\{'; i += 1; continue; }
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
re += `(?:${parts.join('|')})`;
i = end + 1;
} else if (/[.+^$()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
re += '$';
return new RegExp(re);
}
export function matchesAnyGlob(filePath, globs) {
if (!Array.isArray(globs) || globs.length === 0) return false;
const normalized = String(filePath || '').split(sep).join('/');
for (const glob of globs) {
try {
const re = globToRegex(String(glob));
if (re.test(normalized)) return true;
const base = normalized.split('/').pop();
if (re.test(base)) return true;
} catch {
/* malformed glob, skip */
}
}
return false;
}
export function shouldIgnoreDetectionFile(filePath, root, config) {
const globs = config?.ignoreFiles || [];
if (!Array.isArray(globs) || globs.length === 0) return false;
const raw = String(filePath || '').trim();
if (!raw) return false;
if (matchesAnyGlob(raw, globs)) return true;
try {
const abs = isAbsolute(raw) ? raw : resolve(root, raw);
if (matchesAnyGlob(abs, globs)) return true;
const rel = relative(root, abs);
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
return matchesAnyGlob(rel, globs);
}
} catch {
/* ignore */
}
return false;
}
export function filterDetectionFindings(findings, config) {
if (!Array.isArray(findings) || findings.length === 0) return [];
const ignoreRules = new Set((config?.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
const ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
return findings.filter((finding) => {
if (!finding || typeof finding !== 'object') return false;
if (ignoreRules.has(normalizeIgnoreRule(finding.antipattern))) return false;
if (isIgnoredFindingValue(finding, ignoreValues)) return false;
return true;
});
}
function isIgnoredFindingValue(finding, ignoreValues) {
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
const rule = normalizeIgnoreRule(finding.antipattern);
if (!rule) return false;
// File-scoped wildcards suppress rules with no extractable value, such as side-tab.
const value = extractFindingIgnoreValue(finding);
return ignoreValues.some((entry) => {
if (entry.rule !== rule) return false;
const wildcardValue = entry.value === '*';
if (!wildcardValue && (!value || !ignoreValueMatches(rule, entry.value, value))) return false;
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
return findingMatchesScopedIgnoreFile(finding, entry.files);
});
}
function findingMatchesScopedIgnoreFile(finding, globs) {
const filePath = String(finding?.file || '').trim();
if (!filePath) return false;
if (matchesAnyGlob(filePath, globs)) return true;
const normalized = filePath.split(sep).join('/');
const parts = normalized.split('/').filter(Boolean);
for (let i = 0; i < parts.length; i++) {
const suffix = parts.slice(i).join('/');
if (matchesAnyGlob(suffix, globs)) return true;
}
return false;
}
export function extractFindingIgnoreValue(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
const directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
]);
if (!directValueRules.has(rule)) return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
}
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
if (direct) return direct;
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
for (const text of candidates) {
if (rule === 'bounce-easing') {
const motion = extractMotionIgnoreValue(text);
if (motion) return motion;
continue;
}
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return cleanIgnoreValueDisplay(primary[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return cleanIgnoreValueDisplay(family[1]);
const google = text.match(/[?&]family=([^&:;\n]+)/i);
if (google) {
try {
return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
} catch {
return cleanIgnoreValueDisplay(google[1]);
}
}
}
return '';
}
function extractMotionIgnoreValue(text) {
const tailwind = text.match(/\banimate-bounce\b/i);
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
if (animation) {
const token = animation[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
if (token) return cleanIgnoreValueDisplay(token);
}
return '';
}
function cleanIgnoreValueDisplay(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ');
}
/**
* The recorded design-hook decision: 'accepted' | 'declined' | undefined.
* config.local.json (per-developer) overrides config.json.
*/
export function getHookConsent(root) {
let consent;
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const hook = hookSection(safeReadJson(filePath));
if (hook && (hook.consent === 'accepted' || hook.consent === 'declined')) consent = hook.consent;
}
return consent;
}
/**
* Persist the per-developer decision to config.local.json, preserving any
* sibling keys, and ensure the file is gitignored.
*/
export function setHookConsent(root, value) {
const filePath = getLocalConfigPath(root);
const existing = safeReadJson(filePath) || {};
const hook = hookSection(existing) || {};
const next = { ...existing, hook: { ...hook, consent: value } };
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
ensureConfigGitExclude(root);
return filePath;
}
const EXCLUDE_OPEN = '# impeccable-config-ignore-start';
const EXCLUDE_CLOSE = '# impeccable-config-ignore-end';
const EXCLUDE_PATTERNS = ['.impeccable/config.local.json'];
/**
* Add config.local.json to `.git/info/exclude` so a developer's decision is
* never committed. Idempotent via marker comments. Best-effort; returns false
* when there is no resolvable git dir.
*/
export function ensureConfigGitExclude(root) {
try {
const gitDir = resolveGitDir(root);
if (!gitDir) return false;
const target = join(gitDir, 'info', 'exclude');
const existing = existsSync(target) ? readFileSync(target, 'utf-8') : '';
const block = [EXCLUDE_OPEN, ...EXCLUDE_PATTERNS, EXCLUDE_CLOSE].join('\n');
const markerRe = new RegExp(`${escapeRegExp(EXCLUDE_OPEN)}[\\s\\S]*?${escapeRegExp(EXCLUDE_CLOSE)}`);
let updated;
if (markerRe.test(existing)) {
updated = existing.replace(markerRe, block);
} else {
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
updated = `${prefix}${block}\n`;
}
if (updated !== existing) {
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, updated);
}
return true;
} catch {
return false;
}
}
function resolveGitDir(root) {
const dotGit = join(root, '.git');
if (!existsSync(dotGit)) return null;
try {
if (statSync(dotGit).isDirectory()) return dotGit;
// A `.git` file (worktree/submodule) points elsewhere: "gitdir: <path>".
const match = readFileSync(dotGit, 'utf-8').match(/gitdir:\s*(.+)/);
if (match) {
const resolved = match[1].trim();
return isAbsolute(resolved) ? resolved : join(root, resolved);
}
} catch {
/* fall through */
}
return null;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -1,50 +1,53 @@
import fs from 'node:fs';
import path from 'node:path';
import { resolveProjectRoot } from '../context.mjs';
export { IMPECCABLE_COMMAND_PREFIX } from './provider.mjs';
export const IMPECCABLE_DIR = '.impeccable';
export const LIVE_DIR = 'live';
export const CRITIQUE_DIR = 'critique';
export function getImpeccableDir(cwd = process.cwd()) {
return path.join(cwd, IMPECCABLE_DIR);
export function getImpeccableDir(cwd = process.cwd(), options = {}) {
return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR);
}
export function getDesignSidecarPath(cwd = process.cwd()) {
return path.join(getImpeccableDir(cwd), 'design.json');
export function getDesignSidecarPath(cwd = process.cwd(), options = {}) {
return path.join(getImpeccableDir(cwd, options), 'design.json');
}
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) {
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) {
const projectRoot = resolveProjectRoot(cwd, options);
const candidates = [
getDesignSidecarPath(cwd),
path.join(cwd, 'DESIGN.json'),
getDesignSidecarPath(cwd, options),
path.join(projectRoot, 'DESIGN.json'),
];
const contextLegacy = path.join(contextDir, 'DESIGN.json');
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
return candidates;
}
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
return firstExisting(getDesignSidecarCandidates(cwd, contextDir));
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) {
return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options));
}
export function getLiveDir(cwd = process.cwd()) {
return path.join(getImpeccableDir(cwd), LIVE_DIR);
export function getLiveDir(cwd = process.cwd(), options = {}) {
return path.join(getImpeccableDir(cwd, options), LIVE_DIR);
}
export function getLiveConfigPath(cwd = process.cwd()) {
return path.join(getLiveDir(cwd), 'config.json');
export function getLiveConfigPath(cwd = process.cwd(), options = {}) {
return path.join(getLiveDir(cwd, options), 'config.json');
}
export function getLegacyLiveConfigPath(scriptsDir) {
return path.join(scriptsDir, 'config.json');
}
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) {
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) {
if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) {
const configured = env.IMPECCABLE_LIVE_CONFIG.trim();
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
}
const primary = getLiveConfigPath(cwd);
const primary = getLiveConfigPath(cwd, { targetPath });
if (fs.existsSync(primary)) return primary;
if (scriptsDir) {
const legacy = getLegacyLiveConfigPath(scriptsDir);
@@ -53,16 +56,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p
return primary;
}
export function getLiveServerPath(cwd = process.cwd()) {
return path.join(getLiveDir(cwd), 'server.json');
export function getLiveServerPath(cwd = process.cwd(), options = {}) {
return path.join(getLiveDir(cwd, options), 'server.json');
}
export function getLegacyLiveServerPath(cwd = process.cwd()) {
return path.join(cwd, '.impeccable-live.json');
export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) {
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json');
}
export function readLiveServerInfo(cwd = process.cwd()) {
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
export function readLiveServerInfo(cwd = process.cwd(), options = {}) {
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
try {
const info = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) {
@@ -88,37 +91,37 @@ export function isLiveServerPidReachable(pid) {
}
}
export function writeLiveServerInfo(cwd = process.cwd(), info) {
const filePath = getLiveServerPath(cwd);
export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) {
const filePath = getLiveServerPath(cwd, options);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(info));
return filePath;
}
export function removeLiveServerInfo(cwd = process.cwd()) {
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
export function removeLiveServerInfo(cwd = process.cwd(), options = {}) {
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
try { fs.unlinkSync(filePath); } catch {}
}
}
export function getLiveSessionsDir(cwd = process.cwd()) {
return path.join(getLiveDir(cwd), 'sessions');
export function getLiveSessionsDir(cwd = process.cwd(), options = {}) {
return path.join(getLiveDir(cwd, options), 'sessions');
}
export function getLegacyLiveSessionsDir(cwd = process.cwd()) {
return path.join(cwd, '.impeccable-live', 'sessions');
export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) {
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions');
}
export function getLiveAnnotationsDir(cwd = process.cwd()) {
return path.join(getLiveDir(cwd), 'annotations');
export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
return path.join(getLiveDir(cwd, options), 'annotations');
}
export function getCritiqueDir(cwd = process.cwd()) {
return path.join(getImpeccableDir(cwd), CRITIQUE_DIR);
export function getCritiqueDir(cwd = process.cwd(), options = {}) {
return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR);
}
export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) {
return path.join(cwd, '.impeccable-live', 'annotations');
export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations');
}
function firstExisting(paths) {
@@ -0,0 +1,4 @@
// Source scripts default to slash commands. The provider build replaces only
// this exact declaration, avoiding heuristic rewrites across executable code.
export const IMPECCABLE_COMMAND_PREFIX = "$";
export const IMPECCABLE_COMMAND = `${IMPECCABLE_COMMAND_PREFIX}impeccable`;
@@ -0,0 +1,42 @@
class TargetArgError extends Error {
constructor(message, code) {
super(message);
this.name = 'TargetArgError';
this.code = code;
}
}
export function parseTargetPath(args = [], { strict = false } = {}) {
let targetPath = null;
for (let i = 0; i < args.length; i++) {
const arg = String(args[i]);
if (arg === '--target' || arg === '-t') {
const next = args[i + 1];
if (next && !String(next).startsWith('-')) {
targetPath = String(next);
i++;
continue;
}
if (strict) {
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
}
continue;
}
if (arg.startsWith('--target=')) {
const value = arg.slice('--target='.length);
if (value) {
targetPath = value;
continue;
}
if (strict) {
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
}
}
}
return targetPath;
}
export function parseTargetOptions(args = [], options = {}) {
const targetPath = parseTargetPath(args, options);
return targetPath ? { targetPath } : {};
}
+248 -94
View File
@@ -57,7 +57,8 @@
const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 };
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint
const PREFIX = 'impeccable-live';
const PICK_CURSOR_CLASS = PREFIX + '-pick-cursor';
const IMPECCABLE_COMMAND = (window.__IMPECCABLE_COMMAND_PREFIX__ || '/') + 'impeccable';
const PICK_CURSOR_STYLE_ID = PREFIX + '-pick-cursor-style';
const MANUAL_APPLY_STATE_TTL_MS = 15 * 60 * 1000;
const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({
prefix: PREFIX,
@@ -152,6 +153,8 @@
let scrollLockTargetY = null;
let scrollLockRaf = null;
let scrollLockAbort = null;
const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock';
const VARIANT_STATE_STYLE_ID = 'impeccable-variant-state';
// Dedicated key for scroll position - SEPARATE from LS_KEY so that
// saveSession's state updates don't clobber a carefully-captured scrollY.
@@ -1915,45 +1918,45 @@
syncPageInteractionCursor();
}
let pageInteractionCursorActive = false;
function ensurePickCursorStyle() {
if (document.getElementById(PREFIX + '-pick-cursor-style')) return;
const style = document.createElement('style');
style.id = PREFIX + '-pick-cursor-style';
/**
* Drive the page-level pick / insert cursor through the textContent of one
* injected <style>, never by mutating <html> (className or inline style).
* Frameworks that server-render the <html>/<body> roots (Next.js App Router)
* report a React 19 hydration mismatch when the client adds an attribute the
* server HTML never emitted, so a `class`/inline `style` toggled on
* `document.documentElement` trips "a tree hydrated but some attributes ...
* didn't match" on the next Fast-Refresh re-render. Keying the cursor off a
* stable-id <style> keeps the effect off the hydrated host elements (same
* shape as the scroll-anchor lock). A falsy cursor clears the rule.
*/
function setPageInteractionCursor(cursor) {
let style = document.getElementById(PICK_CURSOR_STYLE_ID);
if (!cursor) {
if (style) style.textContent = '';
return;
}
if (!style) {
style = document.createElement('style');
style.id = PICK_CURSOR_STYLE_ID;
// Styles the host page, not the chrome - inside the adapter's shadow UI
// root (uiAppendStyle's target) these selectors would match nothing.
(document.head || document.documentElement).appendChild(style);
}
style.textContent =
'html.' + PICK_CURSOR_CLASS + ' * { cursor: crosshair !important; }\n'
+ 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"],\n'
+ 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"] * { cursor: revert !important; }';
// Styles the host page, not the chrome - inside the adapter's shadow UI
// root (uiAppendStyle's target) these selectors would match nothing.
document.head.appendChild(style);
'* { cursor: ' + cursor + ' !important; }\n'
+ '[id^="' + PREFIX + '"],\n'
+ '[id^="' + PREFIX + '"] * { cursor: revert !important; }';
}
/** Page-level cursor while pick or insert mode is targeting page elements. */
function syncPageInteractionCursor() {
const pickCursor = state === 'PICKING' && pickActive && !insertActive;
let axisCursor = '';
if (state === 'PICKING' && insertActive) {
axisCursor = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : '';
}
if (pickCursor) {
ensurePickCursorStyle();
document.documentElement.classList.add(PICK_CURSOR_CLASS);
document.documentElement.style.cursor = '';
pageInteractionCursorActive = true;
return;
}
document.documentElement.classList.remove(PICK_CURSOR_CLASS);
if (axisCursor) {
document.documentElement.style.cursor = axisCursor;
pageInteractionCursorActive = true;
} else if (pageInteractionCursorActive) {
document.documentElement.style.cursor = '';
pageInteractionCursorActive = false;
let cursor = '';
if (state === 'PICKING' && pickActive && !insertActive) {
cursor = 'crosshair';
} else if (state === 'PICKING' && insertActive && insertHoverAnchor) {
cursor = cursorForInsertAxis(insertHoverAxis || 'column');
}
setPageInteractionCursor(cursor);
}
/**
@@ -3034,16 +3037,26 @@
function applyParamValue(variantEl, param, value) {
if (!variantEl) return;
const attr = 'data-p-' + param.id;
if (param.kind === 'range') {
variantEl.style.setProperty('--p-' + param.id, String(value));
} else if (param.kind === 'toggle') {
if (param.kind === 'toggle') {
const on = !!value;
variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
if (on) variantEl.setAttribute(attr, 'on');
else variantEl.removeAttribute(attr);
} else if (param.kind === 'steps') {
variantEl.setAttribute(attr, String(value));
}
// Svelte component variants are client-mounted into
// [data-impeccable-component-mount] with no [data-impeccable-variant="N"]
// wrapper for the state stylesheet to target, and the element is not SSR'd,
// so there is no React hydration to mismatch. Drive range/toggle --p-* inline
// on the mounted element so scoped preview CSS resolves them.
if (svelteComponentSession?.sessionId === currentSessionId) {
if (param.kind === 'range') variantEl.style.setProperty('--p-' + param.id, String(value));
else if (param.kind === 'toggle') variantEl.style.setProperty('--p-' + param.id, value ? '1' : '0');
return;
}
// range/toggle --p-* custom properties are driven through the injected
// variant-state stylesheet so we never mutate inline style on SSR'd divs.
updateVariantStateStylesheet(currentSessionId, visibleVariant);
}
function applyParamDefaults(variantEl, params) {
@@ -4713,6 +4726,7 @@
paramsCurrentValues = {};
tuneOpen = false;
hideParamsPanel();
if (currentSessionId && visibleVariant) updateVariantStateStylesheet(currentSessionId, visibleVariant);
return;
}
applyParamDefaults(variantEl, params);
@@ -4770,20 +4784,7 @@
function isVariantShown(el) {
if (!el) return false;
if (el.hidden) return false;
if (el.style?.display === 'none') return false;
return true;
}
function setVariantShown(el, shown) {
if (!el) return;
if (shown) {
el.removeAttribute('hidden');
el.style.display = '';
} else {
el.setAttribute('hidden', '');
el.style.display = 'none';
}
return getComputedStyle(el).display !== 'none';
}
function scheduleCyclingBarSync(sessionId, variantNum) {
@@ -4822,11 +4823,7 @@
}
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false;
for (const child of wrapper.children) {
const v = child.dataset ? child.dataset.impeccableVariant : null;
if (!v) continue;
setVariantShown(child, v === String(num));
}
updateVariantStateStylesheet(sessionId, num);
// Unconditional refresh - covers first-reveal (no-op if state isn't
// CYCLING yet, the subsequent CYCLING transition triggers its own
// refresh) and every cycle step.
@@ -5491,6 +5488,7 @@
if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; }
if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; }
stopScrollLock();
removeVariantStateStylesheet();
clearSession();
clearHandled();
resetSessionFileMeta();
@@ -5805,6 +5803,68 @@
return variantDiv;
}
// Variant visibility and range/toggle params are expressed through ONE
// injected stylesheet, never inline attributes on the variant divs. Those
// divs are scaffolded into page source, so SSR frameworks (Next.js App
// Router) server-render them; toggling their `hidden` / inline `style` /
// `--p-*` client-side trips a React 19 hydration mismatch on the next
// Fast-Refresh re-render — the same failure mode the scroll-anchor (#276)
// and pick-cursor (#286) fixes address. A stylesheet rule has the same
// computed effect without mutating any hydrated element's attributes.
// (steps params keep driving `data-p-*` attributes, matching scoped CSS.)
const VARIANT_HIDE_DECL = 'display: none !important;';
const VARIANT_SHOW_DECL = 'display: block !important;';
// Build a direct-child variant selector for a session. With `num`, targets a
// single variant (`… > [data-impeccable-variant="N"]`); without it, targets
// every variant via the bare `[data-impeccable-variant]` attribute.
function variantStateSelector(sessionId, num) {
const wrapper = '[data-impeccable-variants="' + sessionId + '"]';
const variant = num == null
? '[data-impeccable-variant]'
: '[data-impeccable-variant="' + num + '"]';
return wrapper + ' > ' + variant;
}
// Serialize the visible variant's knob values into `--p-<id>` custom-property
// declarations. Only range (number) and toggle (boolean) values become a
// custom property; steps params drive `data-p-*` attributes instead.
function variantParamDecls(values) {
return Object.entries(values || {})
.map(([id, val]) => {
if (typeof val === 'number') return ' --p-' + id + ': ' + val + ';';
if (typeof val === 'boolean') return ' --p-' + id + ': ' + (val ? '1' : '0') + ';';
return '';
})
.join('');
}
function updateVariantStateStylesheet(sessionId, num) {
if (!sessionId || num == null || num < 1) return;
let styleEl = document.getElementById(VARIANT_STATE_STYLE_ID);
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = VARIANT_STATE_STYLE_ID;
(document.head || document.documentElement).appendChild(styleEl);
}
// Hide every variant except the visible one (incl. the SSR'd "original").
const hideOthers = variantStateSelector(sessionId)
+ ':not([data-impeccable-variant="' + num + '"]) { ' + VARIANT_HIDE_DECL + ' }';
// Force-show the visible variant (beats the source inline display:none on
// v2/v3) and apply its knob values as custom properties.
const showVisible = variantStateSelector(sessionId, num)
+ ' { ' + VARIANT_SHOW_DECL + variantParamDecls(paramsCurrentValues) + ' }';
styleEl.textContent = hideOthers + '\n' + showVisible + '\n';
}
function removeVariantStateStylesheet() {
document.getElementById(VARIANT_STATE_STYLE_ID)?.remove();
}
// Hold window.scrollY at a fixed value across DOM mutations inside the
// session's wrapper (HMR patches, variant inserts, cycle swaps).
function startScrollLock(sessionId, initialTargetY) {
@@ -5815,10 +5875,22 @@
try { history.scrollRestoration = 'manual'; } catch {}
const prevHtmlAnchor = document.documentElement.style.overflowAnchor;
const prevBodyAnchor = document.body.style.overflowAnchor;
document.documentElement.style.overflowAnchor = 'none';
document.body.style.overflowAnchor = 'none';
// Suppress the browser's scroll-anchoring on the scroll root so it can't
// fight our manual scroll correction. Apply this as a stylesheet rule, not
// as inline `style` on <html>/<body>: those elements are server-rendered by
// frameworks like Next.js App Router, and mutating their inline style makes
// React 19 report a hydration mismatch on the next Fast-Refresh re-render.
// A <style> rule has the same computed effect without touching any hydrated
// element's attributes. Like the inline version, it is recreated on every
// startScrollLock call, so reload survival (driven by the persisted scroll
// key) is unaffected.
let anchorLockStyle = document.getElementById(SCROLL_ANCHOR_LOCK_ID);
if (!anchorLockStyle) {
anchorLockStyle = document.createElement('style');
anchorLockStyle.id = SCROLL_ANCHOR_LOCK_ID;
anchorLockStyle.textContent = 'html,body{overflow-anchor:none !important;}';
(document.head || document.documentElement).appendChild(anchorLockStyle);
}
const correct = (why) => {
scrollLockRaf = null;
@@ -5853,8 +5925,7 @@
scrollLockAbort = new AbortController();
scrollLockAbort.signal.addEventListener('abort', () => {
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
document.body.style.overflowAnchor = prevBodyAnchor;
document.getElementById(SCROLL_ANCHOR_LOCK_ID)?.remove();
}, { once: true });
const sig = { signal: scrollLockAbort.signal };
// Track whether the most recent scroll came from a user gesture. We
@@ -6075,7 +6146,7 @@
switch (msg.type) {
case 'connected':
hasProjectContext = !!msg.hasProjectContext;
if (!hasProjectContext) showToast('No PRODUCT.md found. Variants will be brand-agnostic. Run /impeccable init to generate one.', 7000);
if (!hasProjectContext) showToast(`No PRODUCT.md found. Variants will be brand-agnostic. Run ${IMPECCABLE_COMMAND} init to generate one.`, 7000);
console.log('[impeccable] Live mode connected.');
syncAgentPollingUi(!!msg.agentPolling);
startAgentStatusPoll();
@@ -6489,10 +6560,13 @@
) {
return;
}
if (isPageEditableElement(deepActive) && !isInlineEditActive(deepActive)) {
return;
}
// While a contenteditable text-leaf is focused, let the browser handle
// all keys except Escape. Escape cancels the current edit (restores
// original text) and blurs without saving, staying in CONFIGURING.
if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) {
if (e.target.isContentEditable && isInlineEditActive(e.target)) {
if (e.key !== 'Escape') return;
e.preventDefault();
e.stopPropagation();
@@ -7621,6 +7695,7 @@ void main() {
stopScrollTracking();
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
stopScrollLock();
removeVariantStateStylesheet();
clearScrollY();
clearSession();
resetSessionFileMeta();
@@ -7882,6 +7957,7 @@ void main() {
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; }
stopScrollLock();
removeVariantStateStylesheet();
clearScrollY();
finalizeInsertSession();
clearSession();
@@ -7913,7 +7989,7 @@ void main() {
const barTopFromBottom = barRect && barRect.height > 0
? Math.max(16, window.innerHeight - barRect.top + 12)
: 16;
toastEl = el('div', {
const currentToast = el('div', {
position: 'fixed', bottom: barTopFromBottom + 'px', left: '50%',
transform: 'translateX(-50%) translateY(8px)',
background: C.ink, color: C.white,
@@ -7923,19 +7999,24 @@ void main() {
transition: 'opacity 0.25s ' + EASE + ', transform 0.25s ' + EASE,
pointerEvents: 'none', maxWidth: '420px', textAlign: 'center',
});
toastEl.id = PREFIX + '-toast';
toastEl.textContent = message;
uiAppend(toastEl);
toastEl = currentToast;
currentToast.id = PREFIX + '-toast';
currentToast.textContent = message;
uiAppend(currentToast);
requestAnimationFrame(() => {
toastEl.style.opacity = '1';
toastEl.style.transform = 'translateX(-50%) translateY(0)';
if (toastEl !== currentToast) return;
currentToast.style.opacity = '1';
currentToast.style.transform = 'translateX(-50%) translateY(0)';
});
setTimeout(() => {
if (toastEl) {
toastEl.style.opacity = '0';
toastEl.style.transform = 'translateX(-50%) translateY(8px)';
setTimeout(() => { if (toastEl) { toastEl.remove(); toastEl = null; } }, 250);
}
if (toastEl !== currentToast) return;
currentToast.style.opacity = '0';
currentToast.style.transform = 'translateX(-50%) translateY(8px)';
setTimeout(() => {
if (toastEl !== currentToast) return;
currentToast.remove();
toastEl = null;
}, 250);
}, duration);
}
@@ -8186,7 +8267,7 @@ void main() {
let voiceInterimBase = '';
/** @type {{ mode: 'steer'|'configure', input: HTMLInputElement, submit: () => void, beforeStart?: () => void } | null} */
let voiceCtx = null;
const PAGE_CHAT_COLLAPSED_W = '88px';
const PAGE_CHAT_COLLAPSED_W = '104px';
const PAGE_CHAT_PROCESSING_W = '76px';
const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…';
const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…';
@@ -8197,7 +8278,7 @@ void main() {
const GLOBAL_BAR_SECTION_GAP = 8;
const GLOBAL_BAR_INNER_GAP = 2;
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
const PAGE_CHAT_EXPANDED_W = 'min(280px, 38vw)';
const PAGE_CHAT_EXPANDED_MAX_W = 280;
const ICON_PAGE_CHAT =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
const ICON_PAGE_VOICE =
@@ -8277,6 +8358,52 @@ void main() {
return barPaletteForTheme(globalBarEl?.dataset.theme || detectPageTheme());
}
function globalBarModeToggles() {
return [
uiGetById(PREFIX + '-pick-toggle'),
uiGetById(PREFIX + '-insert-toggle'),
uiGetById(PREFIX + '-detect-toggle'),
uiGetById(PREFIX + '-design-toggle'),
].filter(Boolean);
}
function applyGlobalBarLabelState(expandInactive, forceCollapse = false) {
globalBarModeToggles().forEach((toggle) => {
if (forceCollapse) toggle._collapseLabel?.(true);
else if (expandInactive || toggle.dataset.active === 'true') toggle._expandLabel?.();
else toggle._collapseLabel?.();
});
}
function syncGlobalBarExpandedLabels(expanded = globalBarEl?.matches(':hover')) {
const expandInactive = !!(expanded && !pageChatExpanded);
applyGlobalBarLabelState(expandInactive, pageChatExpanded);
if (expandInactive && globalBarEl && globalBarEl.scrollWidth > window.innerWidth - 16) {
applyGlobalBarLabelState(false);
}
}
function pageChatCollapsedWidthPx() {
const parsed = parseFloat(PAGE_CHAT_COLLAPSED_W);
return Number.isFinite(parsed) ? parsed : 104;
}
function pageChatExpandedWidth() {
if (!pageChatEl || !globalBarEl) return PAGE_CHAT_EXPANDED_MAX_W + 'px';
const currentChatWidth = pageChatEl.getBoundingClientRect().width || pageChatCollapsedWidthPx();
const barWidth = Math.max(globalBarEl.getBoundingClientRect().width || 0, globalBarEl.scrollWidth || 0);
const nonChatWidth = Math.max(0, barWidth - currentChatWidth);
const available = window.innerWidth - 16 - nonChatWidth;
const next = Math.max(pageChatCollapsedWidthPx(), Math.min(PAGE_CHAT_EXPANDED_MAX_W, available));
return Math.round(next) + 'px';
}
function syncPageChatExpandedWidth() {
if (!pageChatEl || !pageChatExpanded) return;
pageChatEl.style.width = pageChatExpandedWidth();
}
function syncPageChatChrome() {
if (!pageChatEl) return;
const P = pageChatPalette();
@@ -8312,6 +8439,21 @@ void main() {
&& !steerLocked;
}
function isPageEditableElement(el) {
if (!el || own(el)) return false;
if (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || '')) return true;
return !!el.isContentEditable;
}
function isInlineEditActive(el) {
return !!el && inlineEditRows.some((r) => r.el === el);
}
function isPageEditableActive() {
const active = activeElementDeep();
return isPageEditableElement(active) && !isInlineEditActive(active);
}
function pageHasHostTextSelection() {
const sel = window.getSelection?.();
if (!sel || sel.isCollapsed) return false;
@@ -8325,6 +8467,7 @@ void main() {
function shouldSteerAutoFocus() {
return shouldFocusSteerChat()
&& !steerFocusSuspended
&& !isPageEditableActive()
&& performance.now() >= steerFocusPauseUntil;
}
@@ -8562,7 +8705,8 @@ void main() {
if (!pageChatEl || !pageChatInput) return false;
pageChatExpanded = true;
pageChatEl.dataset.expanded = 'true';
pageChatEl.style.width = PAGE_CHAT_EXPANDED_W;
syncGlobalBarExpandedLabels(false);
pageChatEl.style.width = pageChatExpandedWidth();
pageChatEl.style.cursor = steerLocked ? 'default' : 'text';
pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED;
if (pageChatHint) {
@@ -8657,7 +8801,7 @@ void main() {
pageChatEl.setAttribute('aria-label', 'Steer the page');
pageChatExpanded = keepExpanded;
pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false';
pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.width = keepExpanded ? pageChatExpandedWidth() : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
if (pageChatInput) {
pageChatInput.disabled = false;
@@ -8971,6 +9115,7 @@ void main() {
pageChatEl.dataset.expanded = 'false';
pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
syncGlobalBarExpandedLabels(globalBarEl?.matches(':hover'));
if (blur) {
pageChatInput.blur();
pageChatInput.style.pointerEvents = 'none';
@@ -9270,6 +9415,7 @@ void main() {
zIndex: Z.bar + 5,
display: 'flex', alignItems: 'stretch',
gap: '0',
width: 'max-content',
background: P.surface,
border: '1px solid ' + P.border,
borderRadius: '8px',
@@ -9277,6 +9423,8 @@ void main() {
fontFamily: FONT, fontSize: '12px', lineHeight: '1',
opacity: '0',
overflow: 'hidden', // clip the full-bleed brand mark to the bar radius
maxWidth: 'calc(100vw - 16px)',
boxSizing: 'border-box',
transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE,
});
globalBarEl.id = PREFIX + '-global-bar';
@@ -9325,6 +9473,7 @@ void main() {
const inner = el('div', {
display: 'flex', alignItems: 'center',
padding: '4px 5px 4px ' + GLOBAL_BAR_INNER_PAD_LEFT + 'px', gap: GLOBAL_BAR_INNER_GAP + 'px',
flex: '0 0 auto',
});
inner.id = PREFIX + '-global-bar-inner';
globalBarEl.appendChild(inner);
@@ -9333,7 +9482,10 @@ void main() {
function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) {
const b = el('button', {
position: 'relative',
display: 'inline-flex', alignItems: 'center',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
boxSizing: 'border-box',
flex: '0 0 auto',
minWidth: '30px',
padding: '6px 8px', borderRadius: '7px',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500',
@@ -9352,8 +9504,8 @@ void main() {
if (!labelEl) return;
labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; labelEl.style.transform = 'translateX(0)';
};
const collapse = () => {
if (!labelEl || b.dataset.active === 'true') return;
const collapse = (force = false) => {
if (!labelEl || (!force && b.dataset.active === 'true')) return;
labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; labelEl.style.transform = 'translateX(-4px)';
};
// Per-button hover only changes color (no layout). The label expand/
@@ -9604,6 +9756,7 @@ void main() {
width: '1px', height: '18px',
background: P.hairline,
margin: '0 4px 0 2px',
flexShrink: '0',
});
inner.appendChild(divider);
@@ -9620,6 +9773,7 @@ void main() {
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
padding: '0', boxSizing: 'border-box',
width: '24px', height: '24px', borderRadius: '6px',
flexShrink: '0',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
@@ -9632,16 +9786,16 @@ void main() {
exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); });
inner.appendChild(exitBtn);
// Bar-level hover: expand every toggle's label at once; collapse on leave.
// Bar-level hover: expand mode labels unless Steer is using the space.
// Buttons with dataset.active="true" ignore collapse (their label stays).
const toggles = [pickBtn, insertBtn, detectBtn, designBtn];
globalBarEl.addEventListener('mouseenter', () => {
toggles.forEach((t) => t._expandLabel && t._expandLabel());
syncGlobalBarExpandedLabels(true);
syncPageChatExpandedWidth();
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
globalBarEl.addEventListener('mouseleave', () => {
toggles.forEach((t) => t._collapseLabel && t._collapseLabel());
syncGlobalBarExpandedLabels(false);
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
@@ -9659,6 +9813,7 @@ void main() {
pendingDockResizeObserver.observe(globalBarEl);
}
window.addEventListener('resize', positionPendingDock);
window.addEventListener('resize', syncPageChatExpandedWidth);
requestAnimationFrame(() => {
globalBarEl.style.opacity = '1';
@@ -9705,9 +9860,7 @@ void main() {
// If the bar is currently under the cursor, keep all labels expanded -
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
// would collapse its label while the user's mouse is still on the bar.
if (globalBarEl && globalBarEl.matches(':hover')) {
[pickToggle, insertToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.());
}
syncGlobalBarExpandedLabels(globalBarEl && globalBarEl.matches(':hover'));
if (detectBadge) {
detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none';
@@ -9896,7 +10049,8 @@ void main() {
// Remove detection overlays
window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
setLiveState('IDLE');
document.getElementById(PREFIX + '-pick-cursor-style')?.remove();
document.getElementById(PICK_CURSOR_STYLE_ID)?.remove();
removeVariantStateStylesheet();
window.__IMPECCABLE_LIVE_INIT__ = false;
console.log('[impeccable] Live mode exited.');
}
@@ -10385,7 +10539,7 @@ void main() {
if (designState.present === false) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.innerHTML = `<strong>No DESIGN.md yet</strong>Create one by running <code>/impeccable document</code> in your terminal, then re-open this panel.`;
empty.innerHTML = `<strong>No DESIGN.md yet</strong>Create one by running <code>${IMPECCABLE_COMMAND} document</code> in your terminal, then re-open this panel.`;
body.appendChild(empty);
return;
}
@@ -10415,7 +10569,7 @@ void main() {
box.className = 'stale';
box.innerHTML = `
<span class="stale-dot"></span>
<span class="stale-text"><strong>DESIGN.md is newer than .impeccable/design.json.</strong> Run <code>/impeccable document</code> to refresh the sidecar.</span>
<span class="stale-text"><strong>DESIGN.md is newer than .impeccable/design.json.</strong> Run <code>${IMPECCABLE_COMMAND} document</code> to refresh the sidecar.</span>
`;
return box;
}
@@ -10423,7 +10577,7 @@ void main() {
function renderParsedMdCta() {
const box = document.createElement('div');
box.className = 'parsed-md-cta';
box.innerHTML = `<strong>Basic view</strong>This panel reads the tokens in your <code>DESIGN.md</code> frontmatter. Running <code>/impeccable document</code> also generates a <code>.impeccable/design.json</code> sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`;
box.innerHTML = `<strong>Basic view</strong>This panel reads the tokens in your <code>DESIGN.md</code> frontmatter. Running <code>${IMPECCABLE_COMMAND} document</code> also generates a <code>.impeccable/design.json</code> sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`;
return box;
}
+16 -11
View File
@@ -2,11 +2,11 @@
* CLI client for the live variant mode poll/reply protocol.
*
* Usage:
* npx impeccable poll # Block until browser event, print JSON
* npx impeccable poll --stream # Experimental: keep polling; one JSON line per event
* npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly
* npx impeccable poll --reply <id> done # Reply "done" to event <id>
* npx impeccable poll --reply <id> error "msg" # Reply with error
* node <scripts_path>/live-poll.mjs # Block until browser event, print JSON
* node <scripts_path>/live-poll.mjs --stream # Experimental: keep polling; one JSON line per event
* node <scripts_path>/live-poll.mjs --timeout=600000 # Custom timeout (ms); default is long-poll friendly
* node <scripts_path>/live-poll.mjs --reply <id> done # Reply "done" to event <id>
* node <scripts_path>/live-poll.mjs --reply <id> error "msg" # Reply with error
*/
import { execFileSync } from 'node:child_process';
@@ -15,6 +15,11 @@ import { fileURLToPath } from 'node:url';
import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
// Absolute path to a sibling script in this skill's scripts dir, so runtime
// error hints print a directly-runnable command instead of a placeholder.
const SELF_DIR = path.dirname(fileURLToPath(import.meta.url));
const scriptCmd = (name) => `node "${path.join(SELF_DIR, name)}"`;
// Node's built-in fetch (undici under the hood) enforces a 300s headers
// timeout that can't be lowered per-request. We cap each request below
// that ceiling and loop in `pollOnce` to synthesize a long poll without
@@ -27,7 +32,7 @@ const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_ed
function readServerInfo() {
const record = readLiveServerInfo(process.cwd());
if (!record) {
console.error('No running live server found. Start one with: npx impeccable live');
console.error(`No running live server found. Start one with: ${scriptCmd('live.mjs')}`);
process.exit(1);
}
return record.info;
@@ -82,7 +87,7 @@ export function parseReplyArgs(args) {
}
function validateReplyArgs({ id, status }) {
const usage = "Usage: npx impeccable poll --reply <id> <status> [--file path] [--data '<json>'] [message]";
const usage = `Usage: ${scriptCmd('live-poll.mjs')} --reply <id> <status> [--file path] [--data '<json>'] [message]`;
if (!id || id.startsWith('--')) {
const err = new Error(`${usage}\nMissing event id after --reply.`);
err.code = 'INVALID_REPLY_ARGS';
@@ -283,11 +288,11 @@ export async function runPollStream(base, token, {
function handlePollError(err) {
if (err.code === 'AUTH_FAILED') {
console.error(err.message);
console.error('Try restarting: npx impeccable live stop && npx impeccable live');
console.error(`Try restarting: ${scriptCmd('live-server.mjs')} stop && ${scriptCmd('live.mjs')}`);
process.exit(1);
}
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
console.error(`Live server not running. Start one with: ${scriptCmd('live.mjs')}`);
process.exit(1);
}
if (err.code === 'ACK_TIMEOUT') {
@@ -331,7 +336,7 @@ Harness note:
const info = readServerInfo();
const base = `http://localhost:${info.port}`;
// Reply mode: npx impeccable poll --reply <id> <status> [--file path] [--data '<json>'] [message]
// Reply mode: node <scripts_path>/live-poll.mjs --reply <id> <status> [--file path] [--data '<json>'] [message]
if (args.includes('--reply')) {
let reply;
try {
@@ -345,7 +350,7 @@ Harness note:
await postReply(base, info.token, reply);
} catch (err) {
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
console.error(`Live server not running. Start one with: ${scriptCmd('live.mjs')}`);
} else {
console.error('Reply failed:', err.message);
}
@@ -21,7 +21,7 @@ import path from 'node:path';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
import { parseDesignMd } from './lib/design-parser.mjs';
import { resolveContextDir } from './context.mjs';
import { loadContext } from './context.mjs';
import {
assembleLiveBrowserScript,
assertLiveBrowserScriptParts,
@@ -36,6 +36,7 @@ import {
getDesignSidecarPath,
getLiveDir,
getLiveAnnotationsDir,
IMPECCABLE_COMMAND_PREFIX,
readLiveServerInfo,
removeLiveServerInfo,
resolveDesignSidecarPath,
@@ -55,7 +56,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
// DESIGN.json fallback for existing projects.
const CONTEXT_DIR = resolveContextDir(process.cwd());
const PROJECT_CONTEXT = loadContext(process.cwd());
const CONTEXT_DIR = PROJECT_CONTEXT.contextDir;
const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath)
: null;
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
@@ -371,10 +376,7 @@ function hasProjectContext() {
// PRODUCT.md carries brand voice / anti-references — that's what determines
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
// concern, surfaced by the design panel's own empty state.
try {
fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
return true;
} catch { return false; }
return !!PROJECT_CONTEXT.hasProduct;
}
function statOrNull(filePath) {
@@ -412,6 +414,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
token: state.token,
port: state.port,
vocabulary: LIVE_COMMANDS,
commandPrefix: IMPECCABLE_COMMAND_PREFIX,
parts,
});
res.writeHead(200, {
@@ -549,8 +552,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
const mdPath = DESIGN_MD_PATH;
const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
const mdStat = statOrNull(mdPath);
const jsonStat = statOrNull(jsonPath);
@@ -0,0 +1,30 @@
import path from 'node:path';
import { resolveProjectRoot } from './context.mjs';
import { parseTargetPath } from './lib/target-args.mjs';
export function resolveLiveTarget(cwd = process.cwd(), args = []) {
const originalCwd = path.resolve(cwd);
let targetPath = null;
try {
targetPath = parseTargetPath(args, { strict: true });
} catch (err) {
if (err?.name === 'TargetArgError') {
process.stderr.write(`${err.message}\n`);
process.exit(1);
}
throw err;
}
const absoluteTargetPath = targetPath
? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath)
: null;
const projectRoot = targetPath
? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath })
: originalCwd;
return {
originalCwd,
projectRoot,
targetPath,
absoluteTargetPath,
targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {},
};
}
@@ -2,7 +2,7 @@
* CLI helper: find an element in source and wrap it in a variant container.
*
* Usage:
* npx impeccable wrap --id SESSION_ID --count N --query "hero-combined-left" [--file path]
* node <scripts_path>/live-wrap.mjs --id SESSION_ID --count N --query "hero-combined-left" [--file path]
*
* Searches project files for the element matching the query (class name, ID, or
* text snippet), wraps it with the variant scaffolding, and prints the file path
+72 -21
View File
@@ -21,14 +21,16 @@ import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadContext } from './context.mjs';
import { loadContext, resolveTargetSelection } from './context.mjs';
import { resolveFiles } from './live-inject.mjs';
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
import { resolveLiveTarget } from './live-target.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
async function liveCli() {
const args = process.argv.slice(2);
const liveTarget = resolveLiveTarget(process.cwd(), args);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node live.mjs
@@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command:
- Starts (or reuses) the live server in the background
- Injects the browser script tag
- Reads PRODUCT.md / DESIGN.md for project context
- In monorepos, choose a child app first; --target <path> is the fallback/manual path
On success, prints a JSON blob with:
{ ok, serverPort, serverToken, pageFile, hasContext, context }
{ ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath }
On target_selection_required, prints:
{ ok: false, error: "target_selection_required", targetCandidates }
On config_missing, prints:
{ ok: false, error: "config_missing", configPath, hint }
The agent should then:
1. If config_missing, create the config and re-run this script
2. Optionally open the project's dev/preview URL in the browser (see reference/live.mdnot serverPort)
3. Enter the poll loop: node live-poll.mjs`);
1. If target_selection_required, ask which app to use and rerun from that child cwd
2. If config_missing, create the config and re-run this script
3. Optionally open the project's dev/preview URL in the browser (see reference/live.mdnot serverPort)
4. Enter the poll loop: node live-poll.mjs`);
process.exit(0);
}
const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions);
if (targetSelection) {
console.log(JSON.stringify({
ok: false,
error: 'target_selection_required',
...targetSelection,
hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target <path> only as a fallback or explicit path diagnostic.',
}, null, 2));
process.exit(0);
}
const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions);
const activeCwd = ctx.projectRoot;
const outputTargetPath = liveTarget.targetPath || null;
const missingContext = missingLiveContext(ctx);
if (missingContext.length > 0) {
console.log(JSON.stringify({
ok: false,
error: 'context_missing',
missing: missingContext,
nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document',
targetPath: outputTargetPath,
projectRoot: ctx.projectRoot,
repoRoot: ctx.repoRoot,
productPath: ctx.productPath,
designPath: ctx.designPath,
}, null, 2));
process.exit(0);
}
// 1. Check config (fail fast if missing — no point starting anything else)
const checkOut = runScript('live-inject.mjs', ['--check']);
const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd });
const checkResult = safeParse(checkOut);
if (!checkResult || !checkResult.ok) {
console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut }));
console.log(JSON.stringify({
...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }),
targetPath: outputTargetPath,
projectRoot: ctx.projectRoot,
repoRoot: ctx.repoRoot,
}));
process.exit(0);
}
// 2. Start server (or reuse existing)
const serverInfo = ensureServerRunning();
const serverInfo = ensureServerRunning(activeCwd);
if (!serverInfo) {
console.log(JSON.stringify({ ok: false, error: 'server_start_failed' }));
process.exit(1);
}
// 3. Inject the script tag at the current port
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]);
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd });
const injectResult = safeParse(injectOut);
if (!injectResult || !injectResult.ok) {
console.log(JSON.stringify({
@@ -80,22 +123,23 @@ The agent should then:
process.exit(1);
}
// 4. Load PRODUCT.md + DESIGN.md context.
const ctx = loadContext(process.cwd());
// 5. Compute drift-heal: compare resolved inject targets against the
// 4. Compute drift-heal: compare resolved inject targets against the
// project's HTML files. Orphans are HTML files not covered by config.
// Warning only — the agent decides whether to act.
const resolvedFiles = resolveFiles(process.cwd(), checkResult.config);
const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config);
const resolvedFiles = resolveFiles(activeCwd, checkResult.config);
const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config);
// 6. Emit everything the agent needs
// 5. Emit everything the agent needs
console.log(JSON.stringify({
ok: true,
serverPort: serverInfo.port,
serverToken: serverInfo.token,
pageFiles: resolvedFiles,
liveConfigPath: checkResult.path,
configDrift: drift,
targetPath: outputTargetPath,
projectRoot: ctx.projectRoot,
repoRoot: ctx.repoRoot,
hasProduct: ctx.hasProduct,
product: ctx.product,
productPath: ctx.productPath,
@@ -105,6 +149,13 @@ The agent should then:
}, null, 2));
}
function missingLiveContext(ctx) {
const missing = [];
if (!ctx.hasProduct) missing.push('PRODUCT.md');
if (!ctx.hasDesign) missing.push('DESIGN.md');
return missing;
}
/**
* Drift-heal scan. Walks the project for HTML files under common
* page-source directories (public/, src/, app/, pages/) and reports any
@@ -201,11 +252,11 @@ function globToRegex(pattern) {
// Helpers
// ---------------------------------------------------------------------------
function runScript(name, args) {
function runScript(name, args, options = {}) {
const scriptPath = path.join(__dirname, name);
const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
try {
return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 });
return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 });
} catch (err) {
// execSync throws on non-zero exit; return stdout if any
return err.stdout || err.message || '';
@@ -219,10 +270,10 @@ function safeParse(out) {
/**
* Return { pid, port, token } for the running live server, starting one if needed.
*/
function ensureServerRunning() {
function ensureServerRunning(cwd = process.cwd()) {
// Try to reuse an existing server
try {
const existing = readLiveServerInfo(process.cwd())?.info;
const existing = readLiveServerInfo(cwd)?.info;
if (existing && existing.pid) {
try {
process.kill(existing.pid, 0); // throws if dead
@@ -232,7 +283,7 @@ function ensureServerRunning() {
} catch { /* no PID file */ }
// Start a new server
const out = runScript('live-server.mjs', ['--background']);
const out = runScript('live-server.mjs', ['--background'], { cwd });
return safeParse(out);
}
@@ -32,10 +32,11 @@ export function readLiveBrowserScriptParts(parts, readFile = (filePath) => fs.re
}));
}
export function assembleLiveBrowserScript({ token, port, vocabulary, parts }) {
export function assembleLiveBrowserScript({ token, port, vocabulary, commandPrefix = '/', parts }) {
const prelude =
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
`window.__IMPECCABLE_PORT__ = ${port};\n` +
`window.__IMPECCABLE_COMMAND_PREFIX__ = ${JSON.stringify(commandPrefix)};\n` +
// Canonical command vocabulary (values + labels + icons). live-browser.js
// builds its action picker from this instead of an inline copy.
`window.__IMPECCABLE_VOCAB__ = ${JSON.stringify(vocabulary)};\n`;
+16 -9
View File
@@ -6,7 +6,7 @@
* node <scripts_path>/pin.mjs pin <command>
* node <scripts_path>/pin.mjs unpin <command>
*
* `pin audit` creates a lightweight /audit skill that redirects to /impeccable audit.
* `pin audit` creates a lightweight audit skill that redirects to Impeccable's audit workflow.
* `unpin audit` removes that shortcut.
*
* The script discovers harness directories (.claude/skills, .cursor/skills, etc.)
@@ -14,7 +14,7 @@
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
import { join, resolve, dirname } from 'node:path';
import { basename, join, resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -25,6 +25,8 @@ const HARNESS_DIRS = [
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev',
];
const CODEX_HARNESSES = new Set(['.codex', '.agents']);
// Valid sub-command names
const VALID_COMMANDS = [
'craft', 'init', 'extract', 'document', 'shape',
@@ -87,8 +89,12 @@ function loadCommandMetadata() {
/**
* Generate a pinned skill's SKILL.md content.
*/
function generatePinnedSkill(command, metadata) {
const desc = metadata[command]?.description || `Shortcut for /impeccable ${command}.`;
function commandPrefixForSkillsDir(skillsDir) {
return CODEX_HARNESSES.has(basename(dirname(skillsDir))) ? '$' : '/';
}
function generatePinnedSkill(command, metadata, commandPrefix) {
const desc = metadata[command]?.description || `Shortcut for ${commandPrefix}impeccable ${command}.`;
const hint = metadata[command]?.argumentHint || '[target]';
return `---
@@ -100,9 +106,9 @@ user-invocable: true
${PIN_MARKER}
This is a pinned shortcut for \`{{command_prefix}}impeccable ${command}\`.
This is a pinned shortcut for \`${commandPrefix}impeccable ${command}\`.
Invoke {{command_prefix}}impeccable ${command}, passing along any arguments provided here, and follow its instructions.
Invoke ${commandPrefix}impeccable ${command}, passing along any arguments provided here, and follow its instructions.
`;
}
@@ -118,10 +124,11 @@ function pin(command, projectRoot) {
return false;
}
const content = generatePinnedSkill(command, metadata);
let created = 0;
for (const skillsDir of harnessDirs) {
const commandPrefix = commandPrefixForSkillsDir(skillsDir);
const content = generatePinnedSkill(command, metadata, commandPrefix);
// Check if skill already exists (and isn't a pin)
const skillDir = join(skillsDir, command);
if (existsSync(skillDir)) {
@@ -143,7 +150,7 @@ function pin(command, projectRoot) {
if (created > 0) {
console.log(`\nPinned '${command}' as a standalone shortcut in ${created} location(s).`);
console.log(`You can now use /${command} directly.`);
console.log('Use the pinned command directly in each harness.');
}
return created > 0;
@@ -177,7 +184,7 @@ function unpin(command, projectRoot) {
if (removed > 0) {
console.log(`\nUnpinned '${command}' from ${removed} location(s).`);
console.log(`Use /impeccable ${command} to access it.`);
console.log(`Use Impeccable's '${command}' workflow directly to access it.`);
} else {
console.log(`No pinned '${command}' shortcut found.`);
}
+1 -1
View File
@@ -12,7 +12,7 @@
{
"name": "impeccable",
"description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
"version": "3.7.0",
"version": "3.9.1",
"author": {
"name": "Paul Bakaus",
"email": "paul@paulbakaus.com"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "impeccable",
"description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
"version": "3.7.0",
"version": "3.9.1",
"author": {
"name": "Paul Bakaus",
"email": "paul@paulbakaus.com"
+14 -12
View File
@@ -1,12 +1,13 @@
---
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: 3.7.0
version: 3.9.1
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]"
license: Apache 2.0
allowed-tools:
- Bash(npx impeccable *)
- Bash(node .claude/skills/impeccable/scripts/*)
---
Designs and iterates production-grade frontend interfaces. Real working code, committed design choices, exceptional craft.
@@ -15,11 +16,12 @@ Designs and iterates production-grade frontend interfaces. Real working code, co
You MUST do these steps before proceeding:
1. Run `node .claude/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/<command>.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
1. Run `node .claude/skills/impeccable/scripts/context.mjs` once per session; if the runtime shows this skill's loaded base directory, run `node <skill-base-dir>/scripts/context.mjs` instead. Keep cwd/workdir at the user's project, not the skill directory. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and append `--target <path>` to the same command. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`:** divert into `reference/init.md` first when the user invoked `init`, `teach`, `craft`, or `shape`, or when their wording clearly maps to one of those from-scratch build flows (for example: "build/create/make a landing page", "design a new app", or "shape a feature"). Captured product context is the point of those flows. For any other command, a scoped evaluate / refine / enhance / fix / iterate request against existing code, do **not** divert into init. The existing code is the context: proceed with the requested command, infer the register from the surface in focus (step 4), and offer `/impeccable init` once as a suggestion the user can take later. A missing PRODUCT.md must never block a scoped request. If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read the command's reference next: **`reference/<command>.md`, or the native variant from the Commands table** (e.g. `reference/audit.native.md`) **when the project platform is native** (`ios` / `android` / `adaptive`, per the `context.mjs` directive). One file, not both. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
5. **If the project is brand-new (no existing CSS tokens / theme / committed brand colors found in step 3)**, run `node .claude/skills/impeccable/scripts/palette.mjs` to receive a brand seed color and composition guidance. This is the anchor for your primary brand color. Compose the rest of the palette (bg, surface, ink, accent, muted) around it per the script's instructions. Use OKLCH throughout. **Skip this step only if step 3 found committed brand colors in existing tokens; in that case identity-preservation wins.**
5. **If PRODUCT.md's `## Platform` is `ios` or `android`**, also read `reference/<platform>.md` (HIG / Material 3 conventions). `adaptive` (cross-platform, ships both) reads both files. `web`, absent, or unrecognized: nothing extra to read. `context.mjs` prints the directive when one applies.
6. **If the project is brand-new (no existing CSS tokens / theme / committed brand colors found in step 3)**, run `node .claude/skills/impeccable/scripts/palette.mjs` to receive a brand seed color and composition guidance. This is the anchor for your primary brand color. Compose the rest of the palette (bg, surface, ink, accent, muted) around it per the script's instructions. Use OKLCH throughout. **Skip this step only if step 3 found committed brand colors in existing tokens; in that case identity-preservation wins.**
## Design guidance
@@ -108,7 +110,7 @@ If someone could look at this interface and say "AI made that" without doubt, it
| `document` | Build | Generate DESIGN.md from existing project code | [reference/document.md](reference/document.md) |
| `extract [target]` | Build | Pull reusable tokens and components into design system | [reference/extract.md](reference/extract.md) |
| `critique [target]` | Evaluate | UX design review with heuristic scoring | [reference/critique.md](reference/critique.md) |
| `audit [target]` | Evaluate | Technical quality checks (a11y, perf, responsive) | [reference/audit.md](reference/audit.md) |
| `audit [target]` | Evaluate | Technical quality checks (a11y, perf, responsive) | [reference/audit.md](reference/audit.md) · native: [reference/audit.native.md](reference/audit.native.md) |
| `polish [target]` | Refine | Final quality pass before shipping | [reference/polish.md](reference/polish.md) |
| `bolder [target]` | Refine | Amplify safe or bland designs | [reference/bolder.md](reference/bolder.md) |
| `quieter [target]` | Refine | Tone down aggressive or overstimulating designs | [reference/quieter.md](reference/quieter.md) |
@@ -122,7 +124,7 @@ If someone could look at this interface and say "AI made that" without doubt, it
| `delight [target]` | Enhance | Add personality and memorable touches | [reference/delight.md](reference/delight.md) |
| `overdrive [target]` | Enhance | Push past conventional limits | [reference/overdrive.md](reference/overdrive.md) |
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) |
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) |
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
| `live` | Iterate | Visual variant mode: pick elements in the browser, generate alternatives | [reference/live.md](reference/live.md) |
@@ -130,26 +132,26 @@ Plus three management commands: `pin <command>`, `unpin <command>`, and `hooks <
### Routing rules
1. **No argument**: the user is asking "what should I do?" Make the menu context-aware instead of static. Setup has already run `context.mjs`; if that reported `NO_PRODUCT_MD` you are already in init (setup), so finish that and skip this. Otherwise run `node .claude/skills/impeccable/scripts/context-signals.mjs` once and read its JSON, then lead with the **2-3 highest-value next commands**, each with a one-line reason pulled from the signals, followed by the full menu (the table above, grouped by category). **Never auto-run a command; the recommendation is a suggestion the user confirms.**
1. **No argument**: the user is asking "what should I do?" Make the menu context-aware instead of static. Setup has already run `context.mjs`; if that reported `NO_PRODUCT_MD` the project has no captured context yet, so lead the menu with `/impeccable init` as the top recommendation (one line on why) and still show the rest below; don't silently jump into init. Otherwise run `node .claude/skills/impeccable/scripts/context-signals.mjs` once and read its JSON, then lead with the **2-3 highest-value next commands**, each with a one-line reason pulled from the signals, followed by the full menu (the table above, grouped by category). **Never auto-run a command; the recommendation is a suggestion the user confirms.**
Reason over the signals; there is no score to obey:
- `setup.hasDesign` false while `setup.hasCode` true → `document` (capture the visual system).
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
- `critique.latest` with a low `score` or non-zero `p0` / `p1``polish` (it reads that snapshot as its backlog), or re-run `critique` if the snapshot looks stale.
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`.
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `detect.mjs` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
- Otherwise group by intent exactly as init's "Recommend starting points" step does (build new / improve what's there / iterate visually), tailored to `setup.register`.
**If `scan.targets` is non-empty, run `node .claude/skills/impeccable/scripts/detect.mjs --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `node .claude/skills/impeccable/scripts/detect.mjs --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
Keep it to 2-3 pointed picks with the exact command to type. The menu stays the fallback; the recommendation is the lede.
2. **First word matches a command** (table above OR `pin` / `unpin` / `hooks`): load its reference file and follow its instructions. Everything after the command name is the target.
3. **First word doesn't match, but the intent clearly maps to one command** (e.g. "fix the spacing" → `layout`, "rewrite this error message" → `clarify`, "the colors feel flat" → `colorize`): load that command's reference and proceed as if invoked. If two commands could fit, ask once which.
2. **First word matches a command** (table above OR `pin` / `unpin` / `hooks`): load its reference file (on native platforms, the table's native variant; Setup step 2's one-file rule) and follow its instructions. Everything after the command name is the target.
3. **First word doesn't match, but the intent clearly maps to one command** (e.g. "fix the spacing" → `layout`, "rewrite this error message" → `clarify`, "the colors feel flat" → `colorize`): load that command's reference (same native-variant rule) and proceed as if invoked. If two commands could fit, ask once which.
4. **No clear command match**: general design invocation. Apply the setup steps, the General rules, and the loaded register reference, using the full argument as context.
Setup (context gathering, register) is already loaded by then; sub-commands don't re-invoke `/impeccable`.
If the first word is `craft`, setup still runs first, but [reference/craft.md](reference/craft.md) owns the rest of the flow. If setup invokes `init` as a blocker, finish init, refresh context, then resume the original command and target.
If the first word is `craft` or `shape`, or routing rule 3 clearly maps the user's intent to either command, setup still runs first, but the matching reference ([reference/craft.md](reference/craft.md) or [reference/shape.md](reference/shape.md)) owns the rest of the flow. Both are from-scratch build flows: if setup invokes `init` as a blocker, finish init, refresh context, then resume the original command and target.
`teach` is a deprecated alias for `init`: if the user types it, load [reference/init.md](reference/init.md) and proceed as if they ran `init`.
@@ -2,6 +2,7 @@
Adapt an existing design to a different context: another screen size, device, platform, or use case. The trap is treating adaptation as scaling. The job is rethinking the experience for the new context.
**Web only** (mobile web included). Native platforms (`ios` / `android` / `adaptive`) route to [adapt.native.md](adapt.native.md) instead; if the project is native, switch to it now.
---
@@ -0,0 +1,58 @@
> **Additional context needed**: target platforms/devices and usage contexts.
Adapt an existing **native** design (`ios` / `android` / `adaptive`) to a different context: another device class, orientation, platform, or origin. The trap is treating adaptation as scaling. The job is rethinking the experience for the new context, inside the platform conventions of [ios.md](ios.md) / [android.md](android.md); read the target platform's reference before planning if Setup hasn't already.
## Assess Adaptation Challenge
1. **Source context**: what was it designed for, and what assumptions did it make? (Phone-only? Portrait-only? One platform's idioms? A website?)
2. **Target context**: which device class (phone, tablet, foldable), orientation, platform, and usage posture (one-handed on the go vs two-handed at rest)?
3. **What breaks**: navigation that doesn't fit the target, layouts that stretch instead of restructure, gestures or controls that don't exist there?
## Adaptation Strategies
### Phone → Tablet (iPad / large screens)
- **Restructure, don't stretch.** A scaled-up phone UI on a tablet is the failure mode. Use size classes (iOS) / window size classes (Android) to switch structure.
- **Navigation changes shape**: tab bar stays or becomes a sidebar on iPad; Android navigation bar becomes a rail or drawer on expanded width.
- **Use the width**: split view / master-detail (list + detail side by side), multi-column grids, popovers where phones used sheets.
- **Multitasking is a size, not an edge case**: iPad Split View and Android multi-window can hand you a phone-width window on a tablet; size-class-driven layout handles both for free.
### Orientation & foldables
- Landscape restructures (side-by-side panes, repositioned controls); never clip or letterbox. Lock orientation only when the task truly demands it.
- Foldables (Android): react to posture and hinge via window size classes; test folded, unfolded, and tabletop.
### Platform → platform (iOS ↔ Android)
Translate idioms; never transplant them:
| iOS | Android |
|---|---|
| Tab bar | Navigation bar / rail / drawer |
| Edge-swipe back, back chevron | Predictive Back gesture / button |
| Switch, segmented control, system pickers | Material switch, chips, Material pickers |
| Action sheet | Bottom sheet / Material dialog |
| SF Symbols, SF Pro, Dynamic Type | Material Symbols, Roboto, sp scaling |
| Semantic system colors, materials | Material color roles, tonal elevation |
| System push/sheet transitions | Container transform, shared-axis, fade-through |
Rebuild navigation and controls in the target's vocabulary; carry over the brand's expressive layer (palette intent, type accent, motion personality) through the target's theming system.
### Web → native (porting a website or web app)
Reconform, don't reflow. Replace web navigation with the platform's model, HTML-shaped controls with platform controls, hover affordances with touch-first ones, and px-based type with Dynamic Type / sp. Then treat the result to the full platform reference; the slop test there is the acceptance bar.
## Implement & Verify
- Drive structure from **size classes / window size classes**, never from device-model checks.
- Respect safe areas and window insets in every new configuration (notch, hinge, status bar, keyboard).
- Test on simulators for breadth, then real hardware for truth: at least one phone and one tablet per shipped platform, both orientations, split-screen where supported.
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
**NEVER**:
- Ship a stretched phone layout on a tablet
- Port one platform's controls or navigation onto the other
- Hide core functionality on smaller devices (if it matters, make it work)
- Lock orientation to dodge a layout bug
- Trust simulators alone (posture, gestures, and performance need hardware)
@@ -0,0 +1,40 @@
# Android platform
For native Android apps: Jetpack Compose, Android Views, React Native, Expo, Flutter shipping to Android hardware.
On native, register narrows. Material Design 3 governs structure, navigation, and interaction whatever the register; brand expresses through Material's theming (color roles, type scale, shape, motion). A Material-everywhere cross-platform app that also ships to iPhone still owes iOS its OS guarantees on that hardware: safe-area insets, Reduce Motion, edge-swipe back.
## The Android slop test
Would a fluent Android user trust this app, or trip on off-spec components? The most common tell is an iOS app wearing Android's skin: a bottom-only navigation copied from iPhone, a back arrow that ignores the system Back gesture, Cupertino-shaped switches and dialogs. Material 3 is the rulebook; follow its components and theme the brand through it.
## Layout & structure
- **Material navigation, matched to size.** Navigation bar (bottom, 35 destinations) on compact width; navigation rail or drawer on expanded width. Never ship a phone bottom-bar untouched on a tablet.
- **System Back always works.** Honor the predictive Back gesture and Back button; never trap the user or hijack the gesture.
- **Edge-to-edge with window insets.** Apply the status bar, navigation bar, display cutout, and IME insets so content never hides behind system bars or the keyboard.
- **Top app bar for screen context**; pair with a FAB when the screen has a single primary action.
## Touch targets
- **48×48 dp minimum** for every touch target, with at least 8 dp between them.
## Typography
- **Material type scale.** Display, Headline, Title, Body, Label roles (large/medium/small each). Map text to roles; never hand-pick sizes per screen.
- **Roboto is the system face**; theme a brand face in through the type scale, keeping body, labels, and controls legible and consistent.
- **sp units, never fixed px**, so type follows the system font-size setting.
## Color & theming
- **Material color roles** (primary, on-primary, surface, surface-variant, secondary-container, outline, error). Role tokens resolve light/dark and contrast variants automatically; raw hex breaks there.
- **Dynamic Color (Material You)** where it fits: derive the scheme from the user's wallpaper on Android 12+, with a static fallback.
- **Dark theme is a first-class scheme.** Design and test it; never a quick invert.
- **Tonal elevation.** Convey elevation through the standard surface tonal levels (plus shadow where appropriate); no arbitrary drop shadows.
## Components & motion
- **Material components.** Buttons (filled / tonal / outlined / text), FAB, switches, chips, snackbars, bottom sheets, Material dialogs, navigation bar/rail/drawer. Never port iOS controls or invent equivalents.
- **One FAB, one primary action.** Never stack FABs or spend one on a secondary task.
- **Snackbars for transient feedback** (actionable when useful, never a toast for that); dialogs only for decisions that must interrupt.
- **Material motion patterns.** Container transform, shared-axis, fade-through, with standard easing and durations; honor the system Remove animations setting with a crossfade or instant cut.
@@ -10,6 +10,8 @@ Brand: motion is part of the voice; one well-rehearsed entrance beats scattered
Product: 150250 ms on most transitions. Motion conveys state: feedback, reveal, loading, transitions between views. No page-load choreography; users are in a task and won't wait for it.
Native (`ios` / `android` / `adaptive`): implementation follows the Motion section of [ios.md](ios.md) / [android.md](android.md) (read it first if Setup hasn't already): system transitions and OS Reduce Motion, never the web tooling below.
---
## Assess Animation Opportunities
@@ -2,6 +2,8 @@ Run systematic **technical** quality checks and generate a comprehensive report.
This is a code-level audit, not a design critique. Check what's measurable and verifiable in the implementation.
**Web only.** Native platforms (`ios` / `android` / `adaptive`) route to [audit.native.md](audit.native.md) instead; if the project is native, switch to it now.
## Diagnostic Scan
Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the criteria below.
@@ -0,0 +1,139 @@
Run systematic **technical** quality checks on a native app (`ios` / `android` / `adaptive`) and generate a comprehensive report. Don't fix issues; document them for other commands to address.
This is a code-level audit, not a design critique. Audit from source (SwiftUI / UIKit / Compose / React Native / Flutter); no browser tooling or `detect.mjs` applies. Score against the platform reference(s): [ios.md](ios.md) / [android.md](android.md), both for `adaptive`. Read them before scoring if Setup hasn't already. The report skeleton mirrors [audit.md](audit.md); keep the two in sync when changing it.
## Diagnostic Scan
Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the criteria below.
### 1. Accessibility (VoiceOver / TalkBack)
**Check for**:
- **Missing labels**: interactive elements without accessibility labels, traits/roles, or state announcements
- **Reading and focus order**: illogical traversal, unreachable controls, focus lost on navigation
- **Text scaling**: fixed point sizes defeating Dynamic Type (iOS) or px instead of sp (Android); layouts that clip or overlap at large sizes
- **Touch targets**: below 44 pt (iOS) / 48 dp (Android), or crammed without spacing
- **Reduce Motion ignored**: parallax and large slides with no crossfade alternative
- **Contrast**: text failing contrast in either appearance, light or dark
**Score 0-4**: 0=Screen reader unusable, 1=Major gaps (unlabeled controls, no scaling), 2=Partial (labels exist, order or scaling breaks), 3=Good (minor gaps), 4=Excellent (labeled, ordered, scales cleanly, Reduce Motion honored)
### 2. Performance
**Check for**:
- **Slow startup**: heavy work on launch before first frame
- **Unvirtualized lists**: long content without FlatList / LazyColumn / List recycling
- **Main-thread jank**: synchronous work in scroll or gesture paths, dropped frames on 60/120 Hz
- **Wasted rendering**: unnecessary re-renders (React Native) or recompositions (Compose); missing memoization/keys
- **Image handling**: full-size images decoded for thumbnails, no caching
- **App weight**: bloated JS bundle or binary, unused dependencies
**Score 0-4**: 0=Janky everywhere, 1=Major problems (unvirtualized lists, slow launch), 2=Partial, 3=Good (minor improvements possible), 4=Excellent (fast launch, smooth scroll, lean)
### 3. Appearance & Theming
**Check for**:
- **Hard-coded colors**: raw hex instead of semantic system colors (iOS) / Material color roles (Android) / design tokens
- **Broken dark appearance**: missing dark variants, poor contrast in dark, quick inverts
- **Dynamic Color** (Android 12+): no static fallback scheme, or ignored where it fits
- **Off-platform materials**: hand-rolled blur/glassmorphism instead of system materials or tonal elevation
**Score 0-4**: 0=Hard-coded everything, 1=Minimal tokens, 2=Partial (tokens exist, inconsistently used), 3=Good (minor hard-coded values), 4=Excellent (semantic throughout, both appearances first-class)
### 4. Platform Conformance (CRITICAL)
Score against the loaded platform reference(s), including their slop tests. **Check for**:
- **Broken system gestures**: edge-swipe back disabled (iOS), predictive Back hijacked (Android)
- **Inset violations**: content under the notch, Dynamic Island, home indicator, status bar, or keyboard
- **Off-platform navigation**: custom global nav, overloaded tab bars, iOS patterns on Android or vice versa
- **Web-shaped controls**: HTML-style buttons, custom toggles, hover-dependent affordances
- **Icon drift**: mixed icon sets instead of SF Symbols / Material Symbols
- **AI tells**: the shared absolute bans still apply (AI palette, gradient text, hero metrics)
**Score 0-4**: 0=Web port (nothing native), 1=Heavy violations (3-4 kinds), 2=Some (1-2 noticeable), 3=Mostly conformant (subtle issues), 4=Fully native (a fluent user trusts every screen)
### 5. Adaptivity
**Check for**:
- **Stretched phone layouts**: tablet/iPad rendering a scaled-up phone UI instead of using size classes / window size classes
- **Orientation breakage**: landscape clipping, ignored, or locked without reason
- **Keyboard/IME handling**: inputs hidden behind the keyboard, no inset adjustment
- **Multitasking**: iPad Split View / Android multi-window breaking layout
- **Foldables**: hinge-unaware layouts on posture change (Android)
**Score 0-4**: 0=One screen size only, 1=Major breakage (landscape or tablet broken), 2=Partial, 3=Good (minor edge cases), 4=Excellent (adapts across sizes, orientations, and windowing)
## Generate Report
### Audit Health Score
| # | Dimension | Score | Key Finding |
|---|-----------|-------|-------------|
| 1 | Accessibility | ? | [most critical issue or "--"] |
| 2 | Performance | ? | |
| 3 | Appearance & Theming | ? | |
| 4 | Platform Conformance | ? | |
| 5 | Adaptivity | ? | |
| **Total** | | **??/20** | **[Rating band]** |
**Rating bands**: 18-20 Excellent (minor polish), 14-17 Good (address weak dimensions), 10-13 Acceptable (significant work needed), 6-9 Poor (major overhaul), 0-5 Critical (fundamental issues)
### Platform Conformance Verdict
**Start here.** Pass/fail: does this read as a native app or a ported website? List specific violations. Be brutally honest.
### Executive Summary
- Audit Health Score: **??/20** ([rating band])
- Total issues found (count by severity: P0/P1/P2/P3)
- Top 3-5 critical issues
- Recommended next steps
### Detailed Findings by Severity
Tag every issue with **P0-P3 severity**:
- **P0 Blocking**: Prevents task completion. Fix immediately
- **P1 Major**: Significant difficulty or platform-guideline violation. Fix before release
- **P2 Minor**: Annoyance, workaround exists. Fix in next pass
- **P3 Polish**: Nice-to-fix, no real user impact. Fix if time permits
For each issue, document:
- **[P?] Issue name**
- **Location**: Screen, file, line
- **Category**: Accessibility / Performance / Theming / Conformance / Adaptivity
- **Impact**: How it affects users
- **Guideline**: The HIG / Material rule it violates (if applicable)
- **Recommendation**: How to fix it
- **Suggested command**: Which command to use (prefer: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable delight, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset)
### Patterns & Systemic Issues
Identify recurring problems that indicate systemic gaps rather than one-off mistakes:
- "Hard-coded colors appear in 15+ screens, should use semantic colors"
- "Touch targets consistently below 44 pt throughout the tab bar and list rows"
### Positive Findings
Note what's working well: good practices to maintain and replicate.
## Recommended Actions
List recommended commands in priority order (P0 first, then P1, then P2):
1. **[P?] `/command-name`**: Brief description (specific context from audit findings)
2. **[P?] `/command-name`**: Brief description (specific context)
**Rules**: Only recommend commands from: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable delight, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset. Map findings to the most appropriate command. End with `/impeccable polish` as the final step if any fixes were recommended.
After presenting the summary, tell the user:
> You can ask me to run these one at a time, all at once, or in any order you prefer.
>
> Re-run `/impeccable audit` after fixes to see your score improve.
**IMPORTANT**: Be thorough but actionable. Too many P3 issues creates noise. Focus on what actually matters.
**NEVER**:
- Report issues without explaining impact (why does this matter?)
- Provide generic recommendations (be specific and actionable)
- Skip positive findings (celebrate what works)
- Forget to prioritize (everything can't be P0)
- Report false positives without verification
+66 -59
View File
@@ -1,12 +1,12 @@
When asked for "bolder," AI defaults to the same tired tricks: cyan/purple gradients, glassmorphism, neon accents on dark backgrounds, gradient text on metrics. These are the opposite of bold. Reject them first, then increase visual impact and personality through stronger hierarchy, committed scale, and decisive type.
When asked for "bolder," AI defaults to the same tired tricks: cyan/purple gradients, glassmorphism, neon accents on dark backgrounds, gradient text on metrics. These are the opposite of bold. Reject them first, then increase visual impact by making the existing design language more decisive, specific, and committed.
---
## Register
Brand: "bolder" means distinctive. Extreme scale, unexpected color, typographic risk, committed POV.
Brand: "bolder" means distinctive. Express a stronger point of view through hierarchy, pacing, proportion, copy, evidence, and one committed visual idea.
Product: "bolder" rarely means theatrics; those undermine trust. It means stronger hierarchy, clearer weight contrast, one sharper accent, more committed density. The amplification is in clarity, not drama.
Product: "bolder" rarely means theatrics; those undermine trust. It means stronger hierarchy, clearer weight contrast, sharper information density, and more decisive prioritization. The amplification is in clarity, not drama.
---
@@ -15,98 +15,105 @@ Product: "bolder" rarely means theatrics; those undermine trust. It means strong
Analyze what makes the design feel too safe or boring:
1. **Identify weakness sources**:
- **Generic choices**: System fonts, basic colors, standard layouts
- **Timid scale**: Everything is medium-sized with no drama
- **Low contrast**: Everything has similar visual weight
- **Static**: No motion, no energy, no life
- **Predictable**: Standard patterns with no surprises
- **Flat hierarchy**: Nothing stands out or commands attention
- **Generic choices**: The page could belong to any product in the category.
- **Timid scale**: Everything is medium-sized with no clear lead.
- **Low contrast**: Important and supporting elements have similar visual weight.
- **Static**: The surface has no meaningful moment of emphasis.
- **Predictable**: The composition follows a default pattern without a point of view.
- **Flat hierarchy**: Nothing stands out or commands attention.
2. **Understand the context**:
- What's the brand personality? (How far can we push?)
- What's the purpose? (Marketing can be bolder than financial dashboards)
- Who's the audience? (What will resonate?)
- What are the constraints? (Brand guidelines, accessibility, performance)
- What is the brand personality?
- What is the purpose of this surface?
- Who is the audience?
- What design system, tokens, components, and visual conventions already exist?
If any of these are unclear from the codebase, STOP and call the AskUserQuestion tool to clarify.
**CRITICAL**: "Bolder" doesn't mean chaotic or garish. It means distinctive, memorable, and confident. Think intentional drama, not random chaos.
**CRITICAL**: "Bolder" does not mean chaotic or garish. It means distinctive, memorable, and confident. Think intentional drama, not random noise.
**WARNING - AI SLOP TRAP**: Review ALL the DON'T guidelines from the parent impeccable skill (already loaded in this context) before proceeding. Bold means distinctive, not "more effects."
## Design-System Lock
If the project has `DESIGN.md`, tokens, theme variables, or established component styles, treat that system as the boundary. Make the existing language stronger before adding new language.
Do not invent new colors, gradients, radii, shadows, fonts, decorative backgrounds, or effects just because the request says "bolder." A bolder pass should usually change emphasis, proportion, rhythm, density, contrast, copy, artifact specificity, and layout relationships while staying inside the documented system.
If the existing system is genuinely too limited to express the bolder direction, stop and ask the user before expanding it. Name the exact additions, the role each would play, and why the current system cannot do the job. If the user approves expansion, update the design system or tokens alongside the implementation.
## Plan Amplification
Create a strategy to increase impact while maintaining coherence:
- **Focal point**: What should be the hero moment? (Pick ONE, make it amazing)
- **Personality direction**: Maximalist chaos? Elegant drama? Playful energy? Dark moody? Choose a lane.
- **Risk budget**: How experimental can we be? Push boundaries within constraints.
- **Hierarchy amplification**: Make big things BIGGER, small things smaller (increase contrast)
- **Focal point**: Pick one thing the viewer should remember, then make the rest support it.
- **System levers**: Identify which existing tokens, components, layout patterns, and copy structures can carry more weight.
- **Risk budget**: Decide how far the surface can push while still feeling like the same product or brand.
- **Hierarchy amplification**: Increase contrast between primary, secondary, and tertiary content instead of making every element louder.
**IMPORTANT**: Bold design must still be usable. Impact without function is just decoration.
## Amplify the Design
Systematically increase impact across these dimensions:
Systematically increase impact through intention, not a menu of effects:
### Typography Amplification
- **Replace generic fonts**: Swap system fonts for distinctive choices (see the parent skill's typography guidelines and the [Reference Material section of typeset.md](typeset.md#reference-material) for inspiration)
- **Extreme scale**: Create dramatic size jumps (3x-5x differences, not 1.5x)
- **Weight contrast**: Pair 900 weights with 200 weights, not 600 with 400
- **Unexpected choices**: Variable fonts, display fonts for headlines, condensed/extended widths, monospace as intentional accent (not as lazy "dev tool" default)
- Strengthen the existing type hierarchy before changing typefaces.
- Make important text meaningfully more dominant, and make supporting text quieter.
- Use weight, measure, spacing, and line breaks to sharpen the point of view.
- Add or replace fonts only after user-approved design-system expansion.
### Color Intensification
- **Increase saturation**: Shift to more vibrant, energetic colors (but not neon)
- **Bold palette**: Introduce unexpected color combinations. Avoid the purple-blue gradient AI slop
- **Dominant color strategy**: Let one bold color own 60% of the design
- **Sharp accents**: High-contrast accent colors that pop
- **Tinted neutrals**: Replace pure grays with tinted grays that harmonize with your palette
- **Rich gradients**: Intentional multi-stop gradients (not generic purple-to-blue)
### Color Amplification
- Use the existing palette more decisively before adding colors.
- Shift the proportion, placement, and contrast of documented colors to clarify meaning.
- Treat any new color, gradient, or tint ramp as a design-system expansion that requires user approval.
- Keep color tied to hierarchy, state, or brand meaning; do not use it as surface decoration.
### Spatial Drama
- **Extreme scale jumps**: Make important elements 3-5x larger than surroundings
- **Break the grid**: Let hero elements escape containers and cross boundaries
- **Asymmetric layouts**: Replace centered, balanced layouts with tension-filled asymmetry
- **Generous space**: Use white space dramatically (100-200px gaps, not 20-40px)
- **Overlap**: Layer elements intentionally for depth
### Spatial Amplification
- Change proportion, density, alignment, and sequencing so the composition has a stronger point of view.
- Create clearer contrast between dense evidence and open breathing room.
- Let layout express priority and narrative order before adding ornament.
- Preserve responsive behavior and avoid text overflow at every breakpoint.
### Visual Effects
- **Dramatic shadows**: Large, soft shadows for elevation (but not generic drop shadows on rounded rectangles)
- **Background treatments**: Mesh patterns, noise textures, geometric patterns, intentional gradients (not purple-to-blue)
- **Texture & depth**: Grain, halftone, duotone, layered elements. NOT glassmorphism (it's overused AI slop)
- **Borders & frames**: Thick borders, decorative frames, custom shapes (not rounded rectangles with colored border on one side)
- **Custom elements**: Illustrative elements, custom icons, decorative details that reinforce brand
### Surface Amplification
- Use existing surface, border, radius, and shadow rules more deliberately.
- Remove timid half-measures: either give an element a clear role or simplify it.
- Add texture, depth, illustration, or decorative treatments only when already established by the system or explicitly approved.
- Make real product artifacts, imagery, data, or copy carry attention before reaching for effects.
### Motion & Animation
- **Hero moment**: One signature entrance, once. Not on every visit and not on every section.
- **Micro-interactions**: Satisfying hover effects, click feedback, state changes.
- **Transitions**: Smooth, noticeable transitions using ease-out-quart/quint/expo (not bounce or elastic, which cheapen the effect).
- **Bolder scroll-fade-rise on every section.** That's the saturated AI default, the opposite of bold.
- Design one meaningful moment of emphasis when motion genuinely supports the point.
- Make interaction feedback feel more decisive without becoming distracting.
- Keep transitions smooth and intentional.
- **Bolder != scroll-fade-rise on every section.** That's the saturated AI default, the opposite of bold.
### Composition Boldness
- **Hero moments**: Create clear focal points with dramatic treatment
- **Diagonal flows**: Escape horizontal/vertical rigidity with diagonal arrangements
- **Full-bleed elements**: Use full viewport width/height for impact
- **Unexpected proportions**: Golden ratio? Throw it out. Try 70/30, 80/20 splits
- Make the dominant idea unmistakable.
- Use layout tension, sequencing, contrast, and restraint to create a stronger read.
- Let the page's structure communicate priority before adding decorative layers.
- If every element is louder, the composition is not bolder; it is flatter.
**NEVER**:
- Add effects randomly without purpose (chaos ≠ bold)
- Sacrifice readability for aesthetics (body text must be readable)
- Make everything bold (then nothing is bold; you need contrast)
- Ignore accessibility (bold design must still meet WCAG standards)
- Overwhelm with motion (animation fatigue is real)
- Copy trendy aesthetics blindly (bold means distinctive, not derivative)
- Add undocumented design-system primitives without user approval
- Add effects randomly without purpose
- Hide weak hierarchy behind decoration
- Sacrifice readability for aesthetics
- Make everything bold; contrast is the point
- Ignore accessibility
- Overwhelm with motion
- Copy trendy aesthetics blindly
## Verify Quality
Ensure amplification maintains usability and coherence:
- **System-faithful**: Did the pass make the existing design language stronger before adding anything new?
- **No undocumented drift**: Are new colors, gradients, shadows, radii, fonts, and effects either absent or explicitly approved and documented?
- **NOT AI slop**: Does this look like every other AI-generated "bold" design? If yes, start over.
- **Still functional**: Can users accomplish tasks without distraction?
- **Coherent**: Does everything feel intentional and unified?
- **Memorable**: Will users remember this experience?
- **Performant**: Do all these effects run smoothly?
- **Accessible**: Does it still meet accessibility standards?
- **Memorable**: Will users remember this experience for the intended reason?
- **Performant and accessible**: Does the result stay fast, readable, responsive, and WCAG-conscious?
**The test**: If you showed this to someone and said "AI made this bolder," would they believe you immediately? If yes, you've failed. Bold means distinctive, not "more AI effects."
@@ -5,8 +5,9 @@ Resolve one stable target, run two independent assessments, synthesize a design
### Hard Invariants
- Assessment A (design review) and Assessment B (detector/browser evidence) are both required.
- Assessment A and B MUST run as two isolated sub-agents whenever a sub-agent/Task tool is exposed. Running them inline in this context is "possible" but is NOT permitted; it is a degraded run. Inline is allowed ONLY when no sub-agent tool exists (or the user declined, on harnesses that ask).
- If you degrade for any reason, the report's first line MUST be a banner: `⚠️ DEGRADED: single-context (<reason>)`. A silent degraded critique is a failed critique.
- Assessment A must finish before detector findings enter the parent synthesis context. Detector output is deterministic, but it still anchors judgment.
- If sub-agents are unavailable, fall back sequentially: finish and record Assessment A first, then run Assessment B, then synthesize.
- A skipped detector is a failed critique run unless `detect.mjs` is missing or crashes after a real attempt.
- Viewable targets require browser inspection when available.
- Any local server started only for critique visualization must run in the background, have a recorded stop method, and be stopped before final reporting unless the user asks to keep it.
@@ -27,7 +28,13 @@ Resolve one stable target, run two independent assessments, synthesize a design
### Assessment Orchestration
Delegate Assessment A and Assessment B to separate sub-agents when possible. They must not see each other's output. Do not show findings to the user until synthesis.
Delegate Assessment A and Assessment B to separate sub-agents. They must not see each other's output. Do not show findings to the user until synthesis.
Sub-agent gate (all harnesses):
- Unless a harness-specific gate below overrides this, spawn A and B as two isolated, parallel sub-agents whenever a sub-agent/Task tool is exposed. This is the default and is mandatory; do not run them inline because it is faster.
- "Unavailable" means exactly one thing: no sub-agent/Task tool is exposed in this session (or, on harnesses that ask, the user declined). It does not mean inconvenient.
- If and only if sub-agents are unavailable, fall back sequentially: finish and record Assessment A, then run Assessment B, then synthesize, and emit the degraded banner.
- Whichever path you take, declare it in the report header (see Report header provenance). Skipping sub-agents without the banner is the most common failure of this command.
If browser automation is available, each assessment creates its own new tab. Never reuse an existing tab, even if it is already at the right URL.
@@ -61,7 +68,7 @@ node .claude/skills/impeccable/scripts/detect.mjs --json [target]
Browser visualization is required for a viewable target when browser automation is available. Use a localhost dev/static URL for local files; avoid `file://` unless the available browser explicitly supports this workflow. Overlay flow:
1. Create a fresh tab and navigate.
1. Create a fresh tab and navigate. Prefer the harness's native/browser-canvas screenshot path before hand-rolling a Playwright/Puppeteer script; only fall back to a custom script when no native browser tool is exposed.
2. Preflight mutable injection by setting `document.title` and appending a `<script>` tag. Read-only evaluate APIs do not count.
3. If mutation is unavailable, skip live server, browser presentation, and injection; report fallback signal.
4. If mutation is available, start `node .claude/skills/impeccable/scripts/live-server.mjs --background`, present the browser if supported, label `[Human]`, scroll top, inject `http://localhost:PORT/detect.js`, wait 2-3 seconds, read `impeccable` console messages, then stop the live server.
@@ -79,6 +86,12 @@ The chat response is the primary user-facing deliverable. Present the full struc
Structure your feedback as a design director would:
#### Report header provenance
The report's first line MUST declare how the assessments were run, so a degraded run is never silent:
- Dual-agent: `Method: dual-agent (A: <agent-id> · B: <agent-id>)`
- Degraded: `⚠️ DEGRADED: single-context (<reason, e.g. no sub-agent tool exposed>)`
#### Design Health Score
> *Consult the [Heuristics Scoring Guide](#heuristics-scoring-guide) section below.*
@@ -1,6 +1,6 @@
Generate a `DESIGN.md` file at the project root that captures the current visual design system, so AI agents generating new screens stay on-brand.
DESIGN.md follows the [official Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/): YAML frontmatter carrying machine-readable design tokens, followed by a markdown body with exactly six sections in a fixed order. **Tokens are normative; prose provides context for how to apply them.** Sections may be omitted when not relevant, but **do not reorder them and do not rename them**. Section headers must match the spec character-for-character so the file stays parseable by other DESIGN.md-aware tools (Stitch itself, awesome-design-md, skill-rest, etc.).
DESIGN.md follows the [official DESIGN.md format spec](https://raw.githubusercontent.com/google-labs-code/design.md/main/docs/spec.md): YAML frontmatter carrying machine-readable design tokens, followed by a markdown body with exactly six sections in a fixed order. **Tokens are normative; prose provides context for how to apply them.** Sections may be omitted when not relevant, but **do not reorder them and do not rename them**. Section headers must match the spec character-for-character so the file stays parseable by other DESIGN.md-aware tools (Stitch itself, awesome-design-md, skill-rest, etc.).
## The frontmatter: token schema
+8 -6
View File
@@ -2,13 +2,15 @@
Manage the **design detector hook** for the current project.
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code and Codex use `PostToolUse` and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies.
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project).
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands.
@@ -51,7 +53,7 @@ Prefer the narrowest exception:
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism.
- Prefer config ignores (the commands above) by default; they keep suppressions in one reviewable place. Reach for an inline comment only when the waiver must travel with a single file that leaves the repo (a generated/exported standalone document, an emailed HTML file). The supported marker is `impeccable-disable <rule>` (whole file) or `impeccable-disable-line` / `impeccable-disable-next-line` (one line), in any comment syntax, with an optional reason after `:` or `--`. The detector honors it by default; `--no-inline-ignores` or `--no-config` bypasses it.
Example value-specific exception:
@@ -79,10 +81,10 @@ node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca
## Constraints
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent. One exception: `detector.extensions` has no admin action, so when the user asks to cover a template stack, edit that one field in `.impeccable/config.json` directly and leave the rest of the file untouched.
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
- Cursor can block a proposed write when the detector finds a real issue. Claude Code and Codex do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, and `.cursor/hooks.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks.
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
## Failure modes
+64 -15
View File
@@ -3,7 +3,7 @@
The setup command for a project. One codebase crawl feeds everything it writes:
- **PRODUCT.md** (strategic): root project file for register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why".
- **DESIGN.md** (visual): root project file for visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks".
- **DESIGN.md** (visual): root project file for visual theme, color palette, typography, components, layout. Follows the [DESIGN.md format spec](https://raw.githubusercontent.com/google-labs-code/design.md/main/docs/spec.md). Answers "how it looks".
- **`.impeccable/live/config.json`** (live mode): pre-configured so `/impeccable live` boots straight into variant mode with no first-time detour.
It closes by pointing the user at the best command to run next. Every other impeccable command reads PRODUCT.md and DESIGN.md before doing any work.
@@ -16,6 +16,7 @@ Decision tree:
- **Neither file exists (empty project or no context yet)**: do Steps 2-4 (write PRODUCT.md), then decide on DESIGN.md based on whether there's code to analyze.
- **PRODUCT.md exists, DESIGN.md missing**: skip to Step 5 and offer to run `/impeccable document` for DESIGN.md.
- **PRODUCT.md exists but has no `## Register` section (legacy)**: add it. Infer a hypothesis from the codebase (see Step 2), confirm with the user, write the field.
- **PRODUCT.md exists but has no `## Platform` section (legacy)**: add it the same way, but only when the project is native (`ios` / `android` / `adaptive`) or the user wants it explicit; a missing field already means `web`.
- **Both exist**: STOP and call the AskUserQuestion tool to clarify. Ask which file to refresh. Skip the one the user doesn't want changed.
- **Just DESIGN.md exists (unusual)**: do Steps 2-4 to produce PRODUCT.md.
@@ -41,26 +42,34 @@ Also form a **register hypothesis** from what you find:
Register is a hypothesis at this point, not a decision; Step 3 confirms it.
Also form a **platform hypothesis**:
- Native signals: React Native / Expo (`react-native`, `expo`), Flutter (`pubspec.yaml`, `flutter`), SwiftUI / UIKit (`.swift`, `.xcodeproj`, an `ios/` app target), Jetpack Compose / Android (`build.gradle`, an `android/` app module, `AndroidManifest.xml`). An `ios/` and/or `android/` directory that is a real app target, not just a Capacitor/Cordova wrapper around a website.
- Web signals (the default): a web framework (Vite, Next, Nuxt, SvelteKit, Astro), an HTML entry, a CSS/Tailwind setup, no native app target.
Values: `web` / `ios` / `android` / `adaptive` (one codebase, ships both, adapts per OS). Mobile web is still `web`. Like register, this is a hypothesis; Step 3 confirms it.
Note what you've learned and what remains unclear. Also note any rough edges worth a follow-up command (thin hierarchy, flat or gray palette, missing error/empty states, dull copy); Step 7 turns these into concrete recommendations without re-analyzing.
## Step 3: Ask strategic questions (for PRODUCT.md)
STOP and call the AskUserQuestion tool to clarify. Ask only about what you couldn't infer from the codebase.
STOP and call the AskUserQuestion tool to clarify. Ask about anything the codebase doesn't answer with strong, explicit evidence.
### Interview mode, not confirmation mode
If the repo is empty or the user's brief is sparse, run a short interview before proposing PRODUCT.md. Do **not** turn a one-sentence request into a complete inferred PRODUCT.md and ask for blanket confirmation.
- Use the harness's structured question tool when one exists. Otherwise, ask directly in chat and stop.
- Ask **2-3 questions per round**, then wait for answers.
- Use the harness's structured question tool when one exists. Otherwise, ask directly in chat and stop: one question at a time, with lettered options where the crawl suggests likely answers, waiting for each answer before the next.
- Keep skill vocabulary (register, belief ladder, anti-references) out of question text; ask for the thing in words the user would use. For the brand register, ask like a magazine editor profiling the brand: curious and narrative, drawing out the story, the feel, and what a visitor should come to believe.
- Ask in focused rounds and wait for answers between them. Keep **one topic per question**; add rounds rather than fold several topics into one either-or choice. Options obey the same rule: an option answers only the question asked; never write a compound option that bundles a feeling with a business outcome or names an additional audience.
- Use inferred answers as hypotheses or options, not as finished facts.
- Complete at least one real user-answer round before drafting PRODUCT.md, unless every required answer is directly discoverable from repo docs.
- Round 1 should establish register, users/purpose, and desired outcome.
- Round 2 should establish brand personality or references, anti-references, and accessibility needs.
- Round 1 should establish register, platform, users, purpose, positioning, and desired outcome.
- Round 2 should establish brand personality or references, anti-references, and accessibility needs, plus conversion & proof for the brand register.
### Minimum viable interview
Ask enough to complete PRODUCT.md. At minimum, cover register confirmation, users and purpose, brand personality, anti-references, and accessibility needs unless each answer is directly discoverable from repo context. After at least one interview round, you may propose inferred answers, but the user must confirm them before you write PRODUCT.md. Never synthesize PRODUCT.md from the original task prompt alone.
Ask enough to complete PRODUCT.md. At minimum, cover register confirmation, **platform confirmation** (`web` / `ios` / `android` / `adaptive`), users, purpose, positioning, brand personality, anti-references, and accessibility needs (plus conversion & proof for the brand register) unless each answer is directly discoverable from repo context. Never let the template's default `web` stand unconfirmed for a native or cross-platform repo. After at least one interview round, you may propose inferred answers, but the user must confirm them before you write PRODUCT.md. Never synthesize PRODUCT.md from the original task prompt alone.
### Register (ask first; it shapes everything below)
@@ -68,20 +77,42 @@ Every design task is either **brand** (marketing, landing, campaign, long-form c
If Step 2 produced a clear hypothesis, lead with it: *"From the codebase, this looks like a [brand / product] surface. Does that match your intent, or should we treat it differently?"*
If the signal is genuinely split (e.g. a product with a big marketing landing), STOP and call the AskUserQuestion tool to clarify. Ask which register describes the **primary** surface. The register can be overridden per task later, but PRODUCT.md carries one default.
If the signal is genuinely split (e.g. a product with a big marketing landing), STOP and call the AskUserQuestion tool to clarify. Ask which register describes the **primary** surface. The register can be overridden per task later, but PRODUCT.md carries one default. Settle the default before drafting any register-dependent questions; never batch brand-only questions (Conversion & proof) into the same round as the question that decides the register.
### Platform (ask right after register)
Every project targets **web** (includes responsive mobile web), **ios**, **android**, or **adaptive** (one codebase, ships both, adapts per OS: Flutter, React Native, KMP). Platform picks the native rulebook: HIG for `ios`, Material 3 for `android`, both for `adaptive`, none for `web`.
If Step 2 produced a clear hypothesis, lead with it: *"From the codebase, this looks like a [web / ios / android / adaptive] project. Does that match?"* For cross-platform apps, decide by the **design language the app renders**, not the toolchain: one look on both platforms (Flutter's Material-everywhere default) takes that platform's value; genuine per-OS adaptation (Cupertino on iOS, Material on Android) is `adaptive`. When in doubt, `web`.
A monorepo shipping both a website and a native app gets a PRODUCT.md per app, each with its own `## Platform`; the root PRODUCT.md carries the primary surface's platform.
### Users & Purpose
- Who uses this? What's their context when using it?
- What job are they trying to get done?
- For brand: what emotions should the interface evoke? (confidence, delight, calm, urgency)
- What is this for? A purpose stated in README or docs is a hypothesis, not strong evidence; confirm it, don't transcribe it.
- What does success look like?
- If more than one kind of user is plausible, confirm a primary and secondary audience; don't manufacture a split that isn't there. An audience implied by another answer (a success metric, a CTA) is still unconfirmed; ask before writing it as secondary.
- If the surface speaks to a different audience than the people who use the product, ask the user to name both.
- For brand: what emotions should the interface evoke? (confidence, delight, calm, urgency) Ask this standalone; don't fold emotions into the success question.
- For product: what workflow are they in? What's the primary task on any given screen?
### Positioning
- In one line, what does this do that nothing else does? The single strategic claim every screen reinforces.
### Brand & Personality
- How would you describe the brand personality in 3 words?
- Reference sites or apps that capture the right feel? What specifically about them?
- Push for specific named references with the *specific* thing about them that fits this brand, not generic "modern" adjectives or category-bucket lanes.
- What should this explicitly NOT look like? Any anti-references?
### Conversion & proof (brand register only)
- What's the primary CTA?
- What's the secondary fallback, for visitors not ready for the primary?
- The one line a visitor should remember after 10 seconds.
- What must the visitor believe, in order, before taking the primary CTA? (The template's belief ladder.)
- What proof is on hand? Ask the user to hand over any testimonials, case studies, press, or client/partner logos they already have. If you can receive files directly, collect them; otherwise create `.impeccable/assets/proof/` and ask the user to add files there. Reference supplied files by path; record text proof inline.
### Accessibility & Inclusion
- Specific accessibility requirements? (WCAG level, known user needs)
- Considerations for reduced motion, color blindness, or other accommodations?
@@ -90,7 +121,7 @@ Skip questions where the answer is already clear. **Do NOT ask about colors, fon
## Step 4: Write PRODUCT.md
Write PRODUCT.md only after the user has confirmed the strategic answers from Step 3. If an inferred answer is uncertain or unconfirmed, ask before writing.
Write PRODUCT.md only after the user has confirmed the strategic answers from Step 3. If an inferred answer is uncertain or unconfirmed, ask before writing. Confirmed means what the user actually said yes to; do not pad a confirmed answer with extras they never picked (additional anti-references, audiences, roadmap claims, a WCAG level), whether drawn from the crawl, another answer, or your own option text. If an extra belongs in the doc, ask about it first.
Synthesize into a strategic document:
@@ -101,12 +132,26 @@ Synthesize into a strategic document:
product
## Platform
web
## Users
[Who they are, their context, the job to be done]
[Who they are, their context, the job to be done. Primary audience; a secondary audience or a surface-vs-user split only when they apply.]
## Product Purpose
[What this product does, why it exists, what success looks like]
## Positioning
[The single strategic claim every screen reinforces. Not a visual rule, not an anti-reference.]
## Conversion & proof
[Brand register only. Product register: omit this section entirely, heading included.]
- Primary and secondary CTA: [...]
- The line a visitor remembers after 10 seconds: [...]
- Belief ladder: [...]
- Proof on hand: [testimonials, case studies, press, or logos, referenced by path]
## Brand Personality
[Voice, tone, 3-word personality, emotional goals]
@@ -120,7 +165,9 @@ product
[WCAG level, known user needs, considerations]
```
Register is either `brand` or `product` as a bare value. No prose, no commentary.
Register is either `brand` or `product` as a bare value. No prose, no commentary. Platform is `web`, `ios`, `android`, or `adaptive`, also a bare value; omit the section only on legacy files you're leaving untouched, otherwise write `web` explicitly.
Write fields as prose, and use bold sparingly: only where a word carries a decision, never as a label lead-in on every line.
Write to `PROJECT_ROOT/PRODUCT.md`. If `.impeccable.md` existed, the loader already renamed it; merge into that content rather than starting from scratch.
@@ -137,6 +184,8 @@ If the user prefers to skip, mention they can run `/impeccable document` any tim
## Step 6: Configure live mode (when code exists)
**Skip this step when the platform is native** (`ios` / `android` / `adaptive`): live mode drives a browser overlay. A hybrid wrapper or Expo web target serving HTML doesn't change that.
If the project has code with HTML entries and a dev server (the same "code exists" condition that puts `/impeccable document` in scan mode), pre-configure live mode now. You already identified the framework and the served HTML entry in Step 2, so this is nearly free, and it spares the user the first-time setup detour when they later run `/impeccable live`.
**Skip this step for empty / pre-implementation projects** (nothing to inject into yet). Tell the user live mode will configure itself the first time they run it once there's code.
@@ -154,16 +203,16 @@ Writing the config file is harmless and needs no consent; only the CSP **source-
## Step 7: Recommend starting points, then wrap up
Summarize tersely:
- Register captured (brand / product)
- Register captured (brand / product) and platform captured (web / ios / android / adaptive)
- What was written (PRODUCT.md, DESIGN.md, live config, or a subset)
- The 3-5 strategic principles from PRODUCT.md that will guide future work
- If DESIGN.md or live config is pending, one line on how to set it up later
Then recommend the **best commands to run next**, drawn from what your Step 2 crawl already surfaced. Do not run a fresh analysis here; surface observations you already have. Tailor to register and to what you saw, offer the 2-4 most relevant (not a menu dump), and give the exact command to type. Group by intent:
Then recommend the **best commands to run next**, drawn from what your Step 2 crawl already surfaced. Do not run a fresh analysis here; surface observations you already have. Tailor to register **and platform**, offer the 2-4 most relevant (not a menu dump), and give the exact command to type. Group by intent:
- **Build something new**: `/impeccable craft <feature>` (shape, then build end-to-end) or `/impeccable shape <feature>` (plan first). Lead with this for empty or early-stage projects.
- **Improve what's there**: name the specific surface. `/impeccable critique <page>` for a scored UX review; `/impeccable audit <area>` for a11y / perf / responsive checks; `/impeccable polish <component>` for a pre-ship pass. When the crawl flagged a specific weakness, point the matching command at it: thin hierarchy or spacing → `layout`, flat or gray palette → `colorize`, missing error / empty states → `harden` or `onboard`, dull or unclear copy → `clarify`.
- **Iterate visually**: `/impeccable live` (configured in Step 6) to pick elements in the browser and generate variants in place.
- **Iterate visually** (web only): `/impeccable live` (configured in Step 6) to pick elements in the browser and generate variants in place. **Skip this group for native platforms.**
The full command menu is one bare `/impeccable` away; keep this list short and pointed.
@@ -0,0 +1,45 @@
# iOS platform
For native iOS / iPadOS apps: SwiftUI, UIKit, React Native, Expo, Flutter shipping to Apple hardware.
On native, register narrows. HIG conformance governs structure, navigation, and interaction whatever the register; brand expresses through the expressive layer the platform provides (tint, type, motion, content). Calm, Duolingo, and Spotify carry strong identity entirely inside HIG conventions.
## The iOS slop test
Would a fluent iPhone user trust this app, or pause at off-spec controls? The tell is "ported from a website": reinvented navigation bars, custom back gestures, web-shaped buttons, hover-dependent affordances. Default to the platform's components; depart only for a reason the user would thank you for.
## Layout & structure
- **Safe area.** Lay out inside the safe-area insets. No controls under the notch, Dynamic Island, home indicator, or rounded corners.
- **System navigation.** Tab bar for 25 top-level sections (sections, never actions), navigation stack for hierarchy, sheet for self-contained tasks. No custom global nav, no mixed metaphors.
- **Edge-swipe back stays alive.** The left-edge back gesture is muscle memory; never disable or overlay it.
- **Large titles** on top-level screens, collapsing to inline on scroll. Deep detail screens stay inline.
## Touch targets
- **44×44 pt minimum** for every tappable control, with breathing room between adjacent targets.
## Typography
- **Dynamic Type.** Use the system text styles (Large Title through Caption) so text follows the user's reading size. No hard-coded point sizes.
- **San Francisco carries the UI.** Body, labels, and controls stay on SF Pro / SF Compact; a brand face may appear in display moments.
- **11 pt floor**; Body is 17 pt.
## Color & materials
- **Semantic system colors** (label, secondaryLabel, systemBackground, separator, tint). They adapt to Dark Mode and increased contrast automatically; raw hex breaks there.
- **Dark Mode is a first-class appearance.** Design and test both.
- **One tint color** drives interactive elements; decoration is not its job.
- **System materials** for blur and translucency behind bars and sheets; no hand-rolled glassmorphism.
## Components & controls
- **Platform controls.** Switch, segmented control, stepper, system pickers, action sheets, alerts, context menus, swipe actions. Reinventing these for flavor is the most common native slop.
- **SF Symbols** for iconography: baseline-aligned, Dynamic Type-aware, weight and scale variants. Don't mix in a web icon set.
- **Deliberate modality.** Sheet for a focused dismissible sub-task, full-screen cover for immersion. Clear Cancel/Done; honor swipe-to-dismiss unless data loss requires a guard.
- **Grouped/inset lists** for settings-shaped content; no bespoke card stacks.
## Motion
- **System transitions.** Push slides, sheets rise, dismiss reverses the entrance. Custom transitions that fight the navigation model disorient.
- **Honor Reduce Motion.** Crossfade instead of parallax and large slides.
+25 -1
View File
@@ -8,11 +8,33 @@ Brand: asymmetric compositions, fluid spacing with `clamp()`, intentional grid-b
Product: predictable grids, consistent densities, familiar navigation patterns. Responsive behavior is structural (collapse sidebar, responsive table), not fluid typography. Consistency IS an affordance.
Native (`ios` / `android` / `adaptive`): structure follows the Layout section of [ios.md](ios.md) / [android.md](android.md) (read it first if Setup hasn't already): platform navigation, insets, and touch targets, never the CSS tooling below.
---
## Two isolated assessments (required)
Spawn two parallel sub-agents whenever a sub-agent/Task tool is exposed: one for the layout assessment, one for the mechanical pre-scan. If the harness needs explicit user permission for sub-agents, stop and ask before proceeding. Isolation is the point: detector output anchors visual judgment toward what the scan can see, so neither sub-agent gets the other's output. Each assessment runs in its own sub-agent; running either one in this context when a sub-agent tool exists is not permitted, even when it is faster; the fallback below is only for sessions with no sub-agent tool. Give each a self-contained prompt (target files, register, documented spacing scale when present, and its instructions below); do not assume it can read this file.
**Sub-agent A (layout assessment)**: give it the full [Assess Current Layout](#assess-current-layout) checklist below, verbatim, in its prompt. It works through every item and returns per-item findings citing file, selector, or value.
**Sub-agent B (mechanical pre-scan)**: run the bundled detector scoped to layout:
```bash
node .claude/skills/impeccable/scripts/detect.mjs --json --scope layout [target files or dirs]
```
A missing `node` on PATH is not permission to skip: hunt for a runtime (`command -v node`, nvm or Homebrew paths, the harness's own bundled node) and run it by full path. If none exists, halt the scan and report that Node must be installed (the parent relays this to the user); do **not** substitute grep for the detector or proceed unscanned. The detector abstains on arbitrary Tailwind spacing (`gap-[13px]`, `p-[7px]`) and ad-hoc `z-index` stacks, so when the project documents a spacing scale, also grep `gap-\[`, `p[trblxy]?-\[`, `m[trblxy]?-\[`, `z-\[` and judge those hits against it. Return the findings JSON plus the grep verdicts.
**If no sub-agent tool is exposed (or the user declined)**: run both yourself, assessment first, pre-scan second, so the deterministic findings can't anchor the visual judgment. Keep that order even when the scan feels quicker to start with.
**Synthesize** once both are done: merge into a single findings list, noting where they agree and what each caught alone. Fix every finding, or list it as a deliberate exception for the user to accept. A clean scan is a floor, not a verdict: a monotone grid with uniform spacing passes every detector rule, which is exactly what the assessment exists to catch. State in your final summary which path ran (parallel sub-agents or single-context fallback).
---
## Assess Current Layout
Analyze what's weak about the current spatial design:
This checklist is sub-agent A's brief (on the fallback path, work through it yourself before the pre-scan). Analyze what's weak about the current spatial design:
1. **Spacing**:
- Is spacing consistent or arbitrary? (Random padding/margin values)
@@ -138,6 +160,8 @@ Create a systematic plan:
- **Consistency**: Is the spacing system applied uniformly?
- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
Answer each item above by citing the file, selector, or value that satisfies it; never a bare yes. Then re-run the pre-scan and fix until the count of unresolved items and unaccepted findings is zero.
When the rhythm and hierarchy land, hand off to `/impeccable polish` for the final pass.
## Live-mode signature params
+1 -1
View File
@@ -8,7 +8,7 @@ A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR
Execute in order. No step skipped, no step reordered.
1. `live.mjs`: boot.
1. `live.mjs`: boot. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and run `node .claude/skills/impeccable/scripts/live.mjs --target <path>` instead; then run the rest of this live session from the returned `projectRoot`.
2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once.
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
+23 -1
View File
@@ -10,9 +10,29 @@ Product: system fonts and familiar sans stacks are legitimate here. One well-tun
---
## Two isolated assessments (required)
Spawn two parallel sub-agents whenever a sub-agent/Task tool is exposed: one for the typography assessment, one for the mechanical pre-scan. If the harness needs explicit user permission for sub-agents, stop and ask before proceeding. Isolation is the point: detector output anchors visual judgment toward what the scan can see, so neither sub-agent gets the other's output. Each assessment runs in its own sub-agent; running either one in this context when a sub-agent tool exists is not permitted, even when it is faster; the fallback below is only for sessions with no sub-agent tool. Give each a self-contained prompt (target files, register, **DESIGN.md** content when present, and its instructions below); do not assume it can read this file.
**Sub-agent A (typography assessment)**: give it the full [Assess Current Typography](#assess-current-typography) checklist below, verbatim, in its prompt. It works through every item and returns per-item findings citing file, selector, or value.
**Sub-agent B (mechanical pre-scan)**: run the bundled detector scoped to type:
```bash
node .claude/skills/impeccable/scripts/detect.mjs --json --scope type [target files or dirs]
```
A missing `node` on PATH is not permission to skip: hunt for a runtime (`command -v node`, nvm or Homebrew paths, the harness's own bundled node) and run it by full path. If none exists, halt the scan and report that Node must be installed (the parent relays this to the user); do **not** substitute grep for the detector or proceed unscanned. The scan checks literal font sizes against the **DESIGN.md** ramp but abstains on `em`, `%`, `clamp()`, and line-heights, so also grep `font-size\s*:`, `fontSize`, `text-\[`, `leading-\[` and judge those hits against the spec. Return the findings JSON plus the grep verdicts.
**If no sub-agent tool is exposed (or the user declined)**: run both yourself, assessment first, pre-scan second, so the deterministic findings can't anchor the visual judgment. Keep that order even when the scan feels quicker to start with.
**Synthesize** once both are done: merge into a single findings list, noting where they agree and what each caught alone. Fix every finding, or list it as a deliberate exception for the user to accept. A clean scan is a floor, not a verdict: a generic font stack at a flat scale passes every detector rule, which is exactly what the assessment exists to catch. State in your final summary which path ran (parallel sub-agents or single-context fallback).
---
## Assess Current Typography
Analyze what's weak or generic about the current type:
This checklist is sub-agent A's brief (on the fallback path, work through it yourself before the pre-scan). Analyze what's weak or generic about the current type:
1. **Font choices**:
- Are we using invisible defaults? (Inter, Roboto, Arial, Open Sans, system defaults)
@@ -109,6 +129,8 @@ Build a clear type scale:
- **Performance**: Are web fonts loading efficiently without layout shift?
- **Accessibility**: Does text meet WCAG contrast ratios? Is it zoomable to 200%?
Answer each item above by citing the file, selector, or value that satisfies it; never a bare yes. Then re-run the pre-scan and fix until the count of unresolved items and unaccepted findings is zero.
When the type carries the hierarchy on its own, hand off to `/impeccable polish` for the final pass.
## Live-mode signature params
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* Context-signals gatherer for the bare `{{command_prefix}}impeccable`
* Context-signals gatherer for the bare Impeccable invocation
* (no-argument) path. Collects cheap, deterministic signals about the current
* project and emits them as JSON.
*
@@ -21,7 +21,7 @@ import net from 'node:net';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { loadContext, extractRegister } from './context.mjs';
import { loadContext, extractRegister, extractPlatform } from './context.mjs';
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
/** Is there code here at all, or just context files / an empty repo? */
@@ -197,6 +197,7 @@ export async function gatherSignals(cwd = process.cwd()) {
designPath: ctx.designPath,
hasCode: hasCode(cwd),
register: extractRegister(ctx.product),
platform: extractPlatform(ctx.product),
},
critique: { latest: latestCritique(cwd) },
git,
+788 -45
View File
@@ -1,15 +1,18 @@
/**
* Context loader: prints PRODUCT.md (and DESIGN.md if present) as one
* markdown block on stdout, or exits with empty stdout when no PRODUCT.md
* is found anywhere. The skill keys off "empty stdout" to branch into the
* init flow.
* markdown block on stdout, or prints a `NO_PRODUCT_MD:` message when no
* PRODUCT.md is found anywhere. The skill keys off that message to branch:
* from-scratch build commands (init / teach / craft / shape) and clear
* build/shape intent divert into the init flow, while scoped commands proceed
* using the existing code as context.
*
* Path resolution (first match wins):
* 1. cwd, if PRODUCT.md or DESIGN.md is there
* 2. .agents/context/ then docs/
* 3. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) power-user
* 1. Active project root, if PRODUCT.md or DESIGN.md is there
* 2. Active project .agents/context/ then docs/
* 3. Monorepo root context, using the same order, as a per-file fallback
* 4. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) power-user
* escape hatch, only consulted when defaults are empty
* 4. cwd as a "nothing found" default
* 5. Active project root as a "nothing found" default
*
* `resolveContextDir()` and `loadContext()` are also exported for the
* server-side scripts (live.mjs, live-server.mjs) that need the structured
@@ -19,10 +22,26 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseTargetOptions } from './lib/target-args.mjs';
import { IMPECCABLE_COMMAND } from './lib/provider.mjs';
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
const FALLBACK_DIRS = ['.agents/context', 'docs'];
const MONOREPO_MARKER_FILES = ['pnpm-workspace.yaml', 'turbo.json', 'nx.json', 'lerna.json'];
const MONOREPO_FALLBACK_PROJECT_DIRS = ['apps', 'packages'];
const WORKSPACE_DISCOVERY_IGNORED_DIRS = new Set([
'node_modules',
'.git',
'dist',
'build',
'.next',
'.nuxt',
'.svelte-kit',
'.turbo',
'.cache',
'coverage',
]);
// ─── Update check ──────────────────────────────────────────────────────────
// Piggyback a lightweight skill-version check on the once-per-session boot.
@@ -38,41 +57,623 @@ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to o
const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week
const FETCH_TIMEOUT_MS = 1200;
export function resolveContextDir(cwd = process.cwd()) {
if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
return cwd;
}
for (const rel of FALLBACK_DIRS) {
const candidate = path.resolve(cwd, rel);
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
return candidate;
}
}
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
if (envDir && envDir.trim()) {
const trimmed = envDir.trim();
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
}
return cwd;
export function resolveContextDir(cwd = process.cwd(), options = {}) {
return resolveContext(cwd, options).contextDir;
}
export function loadContext(cwd = process.cwd()) {
const contextDir = resolveContextDir(cwd);
const productPath = firstExisting(contextDir, PRODUCT_NAMES);
const designPath = firstExisting(contextDir, DESIGN_NAMES);
export function loadContext(cwd = process.cwd(), options = {}) {
const resolved = resolveContext(cwd, options);
const absCwd = path.resolve(cwd);
const productPath = resolved.productPath;
const designPath = resolved.designPath;
const product = productPath ? safeRead(productPath) : null;
const design = designPath ? safeRead(designPath) : null;
return {
hasProduct: !!product,
product,
productPath: productPath ? path.relative(cwd, productPath) : null,
productPath: productPath ? path.relative(absCwd, productPath) : null,
hasDesign: !!design,
design,
designPath: designPath ? path.relative(cwd, designPath) : null,
contextDir,
designPath: designPath ? path.relative(absCwd, designPath) : null,
contextDir: resolved.contextDir,
productContextDir: productPath ? path.dirname(productPath) : null,
designContextDir: designPath ? path.dirname(designPath) : null,
projectRoot: resolved.projectRoot,
repoRoot: resolved.repoRoot,
isMonorepo: resolved.isMonorepo,
};
}
function resolveContext(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd);
const project = resolveProject(absCwd, options);
const projectContextDir = resolveLocalContextDir(project.projectRoot);
const rootContextDir = project.isMonorepo && project.repoRoot !== project.projectRoot
? resolveLocalContextDir(project.repoRoot)
: null;
let productPath =
(projectContextDir ? firstExisting(projectContextDir, PRODUCT_NAMES) : null)
|| (rootContextDir ? firstExisting(rootContextDir, PRODUCT_NAMES) : null);
let designPath =
(projectContextDir ? firstExisting(projectContextDir, DESIGN_NAMES) : null)
|| (rootContextDir ? firstExisting(rootContextDir, DESIGN_NAMES) : null);
let envContextDir = null;
if (!productPath && !designPath) {
envContextDir = resolveEnvContextDir(absCwd);
if (envContextDir) {
productPath = firstExisting(envContextDir, PRODUCT_NAMES);
designPath = firstExisting(envContextDir, DESIGN_NAMES);
}
}
return {
contextDir: productPath
? path.dirname(productPath)
: designPath
? path.dirname(designPath)
: envContextDir || project.projectRoot,
productPath,
designPath,
projectRoot: project.projectRoot,
repoRoot: project.repoRoot,
isMonorepo: project.isMonorepo,
targetDir: project.targetDir,
};
}
export function resolveProjectRoot(cwd = process.cwd(), options = {}) {
return resolveProject(cwd, options).projectRoot;
}
export function resolveTargetSelection(cwd = process.cwd(), options = {}) {
if (hasTargetOption(options)) return null;
const project = resolveProject(cwd);
if (
!project.isMonorepo
|| !project.projectRoot
|| !project.repoRoot
|| path.resolve(project.projectRoot) !== path.resolve(project.repoRoot)
) {
return null;
}
const targetCandidates = discoverTargetCandidates(project.repoRoot);
// No discoverable child apps (e.g. `workspaces: ["."]`, a root-only workspace,
// or a marker file with no apps/packages children): there is nothing to choose,
// so treat the repo root as the active project rather than blocking on an empty
// selection prompt that the user cannot answer.
if (targetCandidates.length === 0) return null;
return {
targetPath: null,
projectRoot: project.projectRoot,
repoRoot: project.repoRoot,
targetCandidates,
};
}
function resolveProject(cwd = process.cwd(), options = {}) {
const absCwd = path.resolve(cwd);
const targetDir = resolveTargetDir(absCwd, options);
let repoRoot = findMonorepoRoot(targetDir);
if (!repoRoot && targetDir !== absCwd) {
const cwdRepoRoot = findMonorepoRoot(absCwd);
if (cwdRepoRoot && isPathInside(targetDir, cwdRepoRoot)) {
repoRoot = cwdRepoRoot;
}
}
if (!repoRoot) {
return {
targetDir,
projectRoot: absCwd,
repoRoot: absCwd,
isMonorepo: false,
};
}
return {
targetDir,
projectRoot: resolveWorkspaceProjectRoot(repoRoot, targetDir) || repoRoot,
repoRoot,
isMonorepo: true,
};
}
function isPathInside(candidate, root) {
const rel = path.relative(root, candidate);
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
}
function resolveLocalContextDir(root) {
if (firstExisting(root, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
return root;
}
for (const rel of FALLBACK_DIRS) {
const candidate = path.resolve(root, rel);
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
return candidate;
}
}
return null;
}
function resolveEnvContextDir(cwd) {
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
if (!envDir || !envDir.trim()) return null;
const trimmed = envDir.trim();
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
}
function resolveTargetDir(cwd, options = {}) {
const targetPath = options && typeof options === 'object' ? options.targetPath : null;
if (!targetPath || !String(targetPath).trim()) return cwd;
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
try {
const stat = fs.statSync(abs);
return stat.isDirectory() ? abs : path.dirname(abs);
} catch {
return path.extname(abs) ? path.dirname(abs) : abs;
}
}
function findMonorepoRoot(startDir) {
let dir = path.resolve(startDir);
const homeDir = path.resolve(os.homedir());
while (true) {
if (dir === homeDir) return null;
// isMonorepoRoot is checked before hasGitBoundary on purpose: a workspace
// root that also carries its own .git is still recognized. The trade-off is
// deliberate — a directory with a monorepo *marker* but no workspace patterns
// and no apps/packages children is not a monorepo root, so its .git stops
// traversal and a further-up root is not searched. The nested .git is treated
// as an independent project boundary, which is the intended isolation.
if (isMonorepoRoot(dir)) return dir;
if (hasGitBoundary(dir)) return null;
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
function isMonorepoRoot(dir) {
if (readWorkspacePatterns(dir).some((pattern) => !normalizeWorkspacePattern(pattern).startsWith('!'))) return true;
if (!MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(dir, file)))) return false;
return hasFallbackWorkspaceChildren(dir);
}
function hasGitBoundary(dir) {
return fs.existsSync(path.join(dir, '.git'));
}
function hasFallbackWorkspaceChildren(dir) {
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
const base = path.join(dir, name);
let entries;
try {
entries = fs.readdirSync(base, { withFileTypes: true });
} catch {
continue;
}
if (entries.some((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))) return true;
}
return false;
}
function discoverTargetCandidates(repoRoot) {
const roots = new Map();
const patterns = readWorkspacePatterns(repoRoot);
for (const pattern of patterns) {
for (const root of discoverRootsForPattern(repoRoot, pattern)) {
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
}
}
if (MONOREPO_MARKER_FILES.some((file) => fs.existsSync(path.join(repoRoot, file)))) {
for (const name of MONOREPO_FALLBACK_PROJECT_DIRS) {
const base = path.join(repoRoot, name);
let entries;
try {
entries = fs.readdirSync(base, { withFileTypes: true });
} catch {
continue;
}
for (const entry of entries) {
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
const root = path.join(base, entry.name);
roots.set(path.relative(repoRoot, root).split(path.sep).join('/'), root);
}
}
}
return [...roots.entries()]
.filter(([rel]) => rel && !rel.startsWith('..'))
// Honor negated workspace patterns (e.g. "!packages/internal"). resolveWorkspaceProjectRoot
// sends an excluded package back to the repo root, so an excluded folder must not appear as a
// selectable target — choosing it would silently resolve to the root instead.
.filter(([rel]) => !isExcludedByWorkspacePattern(rel.split('/').filter(Boolean), patterns))
.sort(([a], [b]) => a.localeCompare(b))
.map(([rel, root]) => {
const targetExample = findTargetExample(repoRoot, root);
return {
name: path.basename(root),
path: rel,
targetExample,
...resolveCandidateContextSummary(repoRoot, root, targetExample),
};
});
}
function resolveCandidateContextSummary(repoRoot, projectRoot, targetPath) {
const ctx = resolveContext(repoRoot, { targetPath });
return {
productStatus: contextSourceStatus(ctx.productPath, repoRoot, projectRoot),
productPath: contextSourcePath(ctx.productPath, repoRoot),
designStatus: contextSourceStatus(ctx.designPath, repoRoot, projectRoot),
designPath: contextSourcePath(ctx.designPath, repoRoot),
};
}
// Selection candidates surface one of four statuses: 'child' (a canonical
// PRODUCT.md/DESIGN.md directly in the app root), 'inherited' (resolved from the
// repo root in a monorepo), 'missing' (no file found), and 'fallback'. 'fallback'
// intentionally covers two non-canonical locations: a file inside the project
// root but in a subdirectory (FALLBACK_DIRS, e.g. `.agents/context/`), and a file
// outside both the project and repo roots (IMPECCABLE_CONTEXT_DIR override).
function contextSourceStatus(filePath, repoRoot, projectRoot) {
if (!filePath) return 'missing';
const absPath = path.resolve(filePath);
const absProjectRoot = path.resolve(projectRoot);
const absRepoRoot = path.resolve(repoRoot);
if (isPathInsideOrEqual(absPath, absProjectRoot)) {
return path.dirname(absPath) === absProjectRoot ? 'child' : 'fallback';
}
if (absProjectRoot !== absRepoRoot && isPathInsideOrEqual(absPath, absRepoRoot)) {
return 'inherited';
}
return 'fallback';
}
function contextSourcePath(filePath, repoRoot) {
if (!filePath) return null;
const rel = path.relative(repoRoot, filePath);
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
return rel.split(path.sep).join('/');
}
return filePath;
}
function discoverRootsForPattern(repoRoot, rawPattern) {
const pattern = normalizeWorkspacePattern(rawPattern);
if (!pattern || pattern.startsWith('!')) return [];
const segments = pattern.split('/').filter(Boolean);
if (!segments.length) return [];
const firstGlobIndex = segments.findIndex((segment) => segment.includes('*'));
const literalPrefix = firstGlobIndex === -1 ? segments : segments.slice(0, firstGlobIndex);
const base = path.join(repoRoot, ...literalPrefix);
if (!fs.existsSync(base)) return [];
if (segments.includes('**')) {
const packageRoots = [];
walkDirs(base, (dir) => {
if (dir !== base && isCandidateProjectRoot(dir)) packageRoots.push(dir);
});
if (packageRoots.length) return packageRoots;
return directChildDirs(base);
}
return expandSimplePattern(repoRoot, segments);
}
function expandSimplePattern(repoRoot, patternSegments, index = 0, current = repoRoot) {
if (index >= patternSegments.length) return fs.existsSync(current) ? [current] : [];
const segment = patternSegments[index];
if (!segment.includes('*')) {
return expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, segment));
}
let entries;
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
return [];
}
const roots = [];
for (const entry of entries) {
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
if (!segmentMatches(segment, entry.name)) continue;
roots.push(...expandSimplePattern(repoRoot, patternSegments, index + 1, path.join(current, entry.name)));
}
return roots;
}
function directChildDirs(dir) {
try {
return fs.readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && !isIgnoredWorkspaceDiscoveryDir(entry.name))
.map((entry) => path.join(dir, entry.name));
} catch {
return [];
}
}
function walkDirs(root, visit) {
let entries;
try {
entries = fs.readdirSync(root, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (!entry.isDirectory() || isIgnoredWorkspaceDiscoveryDir(entry.name)) continue;
const dir = path.join(root, entry.name);
visit(dir);
walkDirs(dir, visit);
}
}
function isCandidateProjectRoot(dir) {
return !!(
fs.existsSync(path.join(dir, 'package.json'))
|| firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|| fs.existsSync(path.join(dir, 'src'))
|| fs.existsSync(path.join(dir, 'app'))
|| fs.existsSync(path.join(dir, 'pages'))
|| fs.existsSync(path.join(dir, 'public'))
);
}
function isIgnoredWorkspaceDiscoveryDir(name) {
return name.startsWith('.') || WORKSPACE_DISCOVERY_IGNORED_DIRS.has(name);
}
function findTargetExample(repoRoot, projectRoot) {
const examples = [
'src/App.jsx',
'src/App.tsx',
'src/main.jsx',
'src/main.tsx',
'src/index.jsx',
'src/index.ts',
'app/page.tsx',
'pages/index.tsx',
'public/index.html',
];
for (const rel of examples) {
const abs = path.join(projectRoot, rel);
if (fs.existsSync(abs)) return path.relative(repoRoot, abs).split(path.sep).join('/');
}
return path.relative(repoRoot, projectRoot).split(path.sep).join('/');
}
function resolveWorkspaceProjectRoot(repoRoot, targetDir) {
const rel = path.relative(repoRoot, targetDir);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return repoRoot;
const relSegments = rel.split(path.sep).filter(Boolean);
const patterns = readWorkspacePatterns(repoRoot);
const excluded = isExcludedByWorkspacePattern(relSegments, patterns);
if (!excluded) {
for (const pattern of patterns) {
const projectRoot = projectRootFromWorkspacePattern(repoRoot, relSegments, pattern);
if (projectRoot) return projectRoot;
}
}
if (excluded) return repoRoot;
if (
relSegments.length >= 2
&& MONOREPO_FALLBACK_PROJECT_DIRS.includes(relSegments[0])
) {
return path.join(repoRoot, relSegments[0], relSegments[1]);
}
const nearest = nearestProjectLikeRoot(repoRoot, targetDir);
if (nearest) return nearest;
return repoRoot;
}
function isExcludedByWorkspacePattern(relSegments, patterns) {
return patterns.some((rawPattern) => {
const pattern = normalizeWorkspacePattern(rawPattern);
if (!pattern.startsWith('!')) return false;
return workspacePatternMatchesRel(pattern.slice(1), relSegments);
});
}
function nearestProjectLikeRoot(repoRoot, targetDir) {
let dir = path.resolve(targetDir);
const stop = path.resolve(repoRoot);
while (dir && dir !== stop) {
if (
firstExisting(dir, [...PRODUCT_NAMES, ...DESIGN_NAMES])
|| fs.existsSync(path.join(dir, 'package.json'))
) {
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
function nearestPackageRootBetween(repoRoot, targetDir, stopDir) {
let dir = path.resolve(targetDir);
const stop = path.resolve(stopDir || repoRoot);
const root = path.resolve(repoRoot);
while (dir && dir !== stop && isPathInsideOrEqual(dir, root)) {
if (fs.existsSync(path.join(dir, 'package.json'))) return dir;
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
function isPathInsideOrEqual(candidate, root) {
return path.resolve(candidate) === path.resolve(root) || isPathInside(candidate, root);
}
function workspacePatternMatchesRel(pattern, relSegments) {
const patternSegments = normalizeWorkspacePattern(pattern).split('/').filter(Boolean);
if (!patternSegments.length) return false;
if (patternSegments.includes('**')) {
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
const literalPrefix = firstGlobIndex === -1
? patternSegments
: patternSegments.slice(0, firstGlobIndex);
if (relSegments.length < literalPrefix.length + 1) return false;
for (let i = 0; i < literalPrefix.length; i++) {
if (!segmentMatches(literalPrefix[i], relSegments[i])) return false;
}
return true;
}
if (relSegments.length < patternSegments.length) return false;
for (let i = 0; i < patternSegments.length; i++) {
if (!segmentMatches(patternSegments[i], relSegments[i])) return false;
}
return true;
}
function readWorkspacePatterns(repoRoot) {
return [
...readPackageWorkspaces(repoRoot),
...readPnpmWorkspaces(repoRoot),
...readLernaWorkspaces(repoRoot),
].filter(Boolean);
}
function readPackageWorkspaces(repoRoot) {
const pkg = readJson(path.join(repoRoot, 'package.json'));
const workspaces = pkg?.workspaces;
if (Array.isArray(workspaces)) return workspaces;
if (Array.isArray(workspaces?.packages)) return workspaces.packages;
return [];
}
function readLernaWorkspaces(repoRoot) {
const lerna = readJson(path.join(repoRoot, 'lerna.json'));
return Array.isArray(lerna?.packages) ? lerna.packages : [];
}
function readPnpmWorkspaces(repoRoot) {
try {
const body = fs.readFileSync(path.join(repoRoot, 'pnpm-workspace.yaml'), 'utf-8');
const patterns = [];
let inPackages = false;
for (const line of body.split(/\r?\n/)) {
const trimmed = stripYamlInlineComment(line).trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const flowMatch = trimmed.match(/^packages:\s*\[(.*)\]\s*$/);
if (flowMatch) {
patterns.push(...parseYamlFlowList(flowMatch[1]));
inPackages = false;
continue;
}
if (/^packages:\s*$/.test(trimmed)) {
inPackages = true;
continue;
}
if (inPackages && /^[A-Za-z0-9_-]+:\s*/.test(trimmed)) break;
if (inPackages) {
const match = trimmed.match(/^-\s*(.+)$/);
if (match) patterns.push(unquoteYamlValue(match[1]));
}
}
return patterns;
} catch {
return [];
}
}
function stripYamlInlineComment(line) {
let quote = null;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if ((ch === '"' || ch === "'") && line[i - 1] !== '\\') {
quote = quote === ch ? null : quote || ch;
continue;
}
if (ch === '#' && !quote) return line.slice(0, i);
}
return line;
}
function parseYamlFlowList(body) {
const items = [];
let quote = null;
let current = '';
for (let i = 0; i < body.length; i++) {
const ch = body[i];
if ((ch === '"' || ch === "'") && body[i - 1] !== '\\') {
quote = quote === ch ? null : quote || ch;
current += ch;
continue;
}
if (ch === ',' && !quote) {
const value = unquoteYamlValue(current);
if (value) items.push(value);
current = '';
continue;
}
current += ch;
}
const value = unquoteYamlValue(current);
if (value) items.push(value);
return items;
}
function unquoteYamlValue(value) {
return String(value || '')
.trim()
.replace(/^['"]|['"]$/g, '');
}
function readJson(filePath) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch {
return null;
}
}
function projectRootFromWorkspacePattern(repoRoot, relSegments, rawPattern) {
const pattern = normalizeWorkspacePattern(rawPattern);
if (!pattern || pattern.startsWith('!')) return null;
const patternSegments = pattern.split('/').filter(Boolean);
if (!patternSegments.length) return null;
if (patternSegments.includes('**')) {
return projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments);
}
if (relSegments.length < patternSegments.length) return null;
for (let i = 0; i < patternSegments.length; i++) {
if (!segmentMatches(patternSegments[i], relSegments[i])) return null;
}
return path.join(repoRoot, ...relSegments.slice(0, patternSegments.length));
}
function projectRootFromDoubleStarPattern(repoRoot, relSegments, patternSegments) {
const firstGlobIndex = patternSegments.findIndex((segment) => segment.includes('*'));
const literalPrefix = firstGlobIndex === -1
? patternSegments
: patternSegments.slice(0, firstGlobIndex);
if (relSegments.length < literalPrefix.length + 1) return null;
for (let i = 0; i < literalPrefix.length; i++) {
if (!segmentMatches(literalPrefix[i], relSegments[i])) return null;
}
const prefixDir = path.join(repoRoot, ...literalPrefix);
const targetDir = path.join(repoRoot, ...relSegments);
const packageRoot = nearestPackageRootBetween(repoRoot, targetDir, prefixDir);
if (packageRoot) return packageRoot;
return path.join(repoRoot, ...relSegments.slice(0, literalPrefix.length + 1));
}
function normalizeWorkspacePattern(pattern) {
return String(pattern || '')
.trim()
.replace(/^['"]|['"]$/g, '')
.replace(/^\.\//, '')
.replace(/\/+$/, '');
}
function segmentMatches(patternSegment, relSegment) {
if (patternSegment === '*') return true;
if (!patternSegment.includes('*')) return patternSegment === relSegment;
const re = new RegExp(`^${escapeRegExp(patternSegment).replace(/\\\*/g, '[^/]*')}$`);
return re.test(relSegment);
}
function firstExisting(dir, names) {
for (const name of names) {
const abs = path.join(dir, name);
@@ -89,24 +690,64 @@ function safeRead(p) {
}
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Read the first non-empty line under a bare `## <heading>` section of
* PRODUCT.md (e.g. `## Register`, `## Platform`). Returns null when the
* section is absent. The heading match is exact (`\s*$`) so near-miss
* headings like `## Register guidelines` don't shadow the real field.
*/
export function extractSectionValue(product, heading) {
if (!product) return null;
const headingRe = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, 'i');
const lines = product.split('\n');
for (let i = 0; i < lines.length; i++) {
if (headingRe.test(lines[i].trim())) {
for (let j = i + 1; j < lines.length; j++) {
const next = lines[j].trim();
// A new heading before any value means the section is empty.
if (/^#{1,6}\s/.test(next)) return null;
if (next) return next;
}
}
}
return null;
}
/**
* Pull the register (`brand` or `product`) out of PRODUCT.md by looking
* for a `## Register` section and reading the first non-empty line that
* follows it. Returns null when the file is legacy / register-less.
*/
export function extractRegister(product) {
if (!product) return null;
const lines = product.split('\n');
for (let i = 0; i < lines.length; i++) {
if (/^##\s+Register\b/i.test(lines[i].trim())) {
for (let j = i + 1; j < lines.length; j++) {
const next = lines[j].trim();
if (!next) continue;
const word = next.toLowerCase();
if (word === 'brand' || word === 'product') return word;
return null;
}
}
const word = (extractSectionValue(product, 'Register') || '').toLowerCase();
return word === 'brand' || word === 'product' ? word : null;
}
/**
* Pull the platform (`web`, `ios`, `android`, or `adaptive`) out of PRODUCT.md
* by looking for a `## Platform` section and reading the first non-empty line
* that follows it. `adaptive` is for cross-platform apps (Flutter, React
* Native) that ship both iOS and Android from one codebase; a line that names
* both targets (e.g. `ios, android`) is also read as `adaptive`. Returns null
* when the file is legacy / platform-less, which the skill treats as `web`
* (the default the general rules already assume).
*/
export function extractPlatform(product) {
const value = (extractSectionValue(product, 'Platform') || '').toLowerCase();
if (!value) return null;
if (value === 'web' || value === 'ios' || value === 'android' || value === 'adaptive') return value;
// A short list naming both native targets (`ios, android`, `ios and
// android`) = adaptive. Only list separators and the two platform words may
// appear; anything else (prose, negations) is unrecognized and falls
// through to the CLI's WARNING path.
const tokens = value.split(/[\s,+&/]+/).filter(t => t && t !== 'and');
if (tokens.length >= 2 && tokens.every(t => t === 'ios' || t === 'android')
&& tokens.includes('ios') && tokens.includes('android')) {
return 'adaptive';
}
return null;
}
@@ -233,7 +874,24 @@ async function computeUpdateDirective(now = Date.now()) {
}
async function cli() {
const ctx = loadContext(process.cwd());
let cliOptions;
try {
cliOptions = parseCliOptions(process.argv.slice(2));
} catch (err) {
if (err?.name === 'TargetArgError') {
process.stderr.write(`${err.message}\n`);
process.exit(1);
}
throw err;
}
const targetProvided = hasTargetOption(cliOptions);
const targetExists = targetProvided ? pathExistsForTarget(process.cwd(), cliOptions.targetPath) : null;
const selection = resolveTargetSelection(process.cwd(), cliOptions);
if (selection) {
process.stdout.write(buildTargetSelectionDirective(selection) + '\n');
process.exit(0);
}
const ctx = loadContext(process.cwd(), cliOptions);
const updateDirective = await computeUpdateDirective();
if (!ctx.hasProduct) {
@@ -241,9 +899,16 @@ async function cli() {
// — cheap models miss the empty case more often than the explicit one.
const parts = [
'NO_PRODUCT_MD: This project has no PRODUCT.md yet. ' +
'Stop the current task, load reference/init.md, and follow its ' +
'instructions to write PRODUCT.md before resuming.',
'Follow SKILL.md Setup step 1: for `init`, `teach`, `craft`, `shape`, ' +
'or wording that clearly maps to a from-scratch build/shape flow, load ' +
'reference/init.md and write PRODUCT.md first; for any other (scoped) ' +
'command against existing code, proceed using the code as context and ' +
`offer \`${IMPECCABLE_COMMAND} init\` as a suggestion (do not block).`,
];
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
parts.push(buildMissingTargetDirective());
}
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
process.exit(0);
@@ -252,15 +917,93 @@ async function cli() {
if (ctx.hasDesign) {
parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`);
}
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
parts.push(buildMissingTargetDirective());
}
const register = extractRegister(ctx.product);
const next = register
? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.`
: `NEXT STEP: You MUST now read the matching register reference (\`reference/brand.md\` or \`reference/product.md\`) before producing any design output. Pick based on PRODUCT.md above.`;
parts.push(next);
const platform = extractPlatform(ctx.product);
const nativeRefs =
platform === 'adaptive' ? ['ios', 'android'] : platform === 'ios' || platform === 'android' ? [platform] : [];
if (nativeRefs.length) {
const refList = nativeRefs.map(p => `\`reference/${p}.md\``).join(' and ');
const label = platform === 'adaptive' ? '`adaptive` (both iOS and Android)' : `\`${platform}\``;
parts.push(
`NEXT STEP: This project targets ${label}. Also read ${refList} for native conventions, in addition to the register reference.`,
);
} else if (!platform) {
// A `## Platform` section that names something we don't recognize (a
// toolchain like `flutter`, a typo) would otherwise silently fall back to
// web — the wrong default exactly when the user tried to say "native".
const rawPlatform = extractSectionValue(ctx.product, 'Platform');
if (rawPlatform) {
parts.push(
`WARNING: PRODUCT.md's \`## Platform\` value \`${rawPlatform}\` is not recognized; treating the project as \`web\`. Valid values are \`web\`, \`ios\`, \`android\`, or \`adaptive\` (cross-platform, ships both). If this project is native, fix the field (name the design language the app renders, not the toolchain) and surface it to the user.`,
);
}
}
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
}
function parseCliOptions(args) {
return parseTargetOptions(args, { strict: true });
}
function hasTargetOption(options) {
return !!(options && typeof options.targetPath === 'string' && options.targetPath.trim());
}
function pathExistsForTarget(cwd, targetPath) {
const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath);
return fs.existsSync(abs);
}
function buildResolvedContextDirective(ctx, options, { targetExists = null } = {}) {
const targetPath = hasTargetOption(options) ? options.targetPath : null;
return `RESOLVED_CONTEXT:\n${JSON.stringify({
targetPath,
...(targetPath ? { targetExists } : {}),
projectRoot: ctx.projectRoot,
repoRoot: ctx.repoRoot,
productPath: ctx.productPath,
designPath: ctx.designPath,
}, null, 2)}`;
}
function shouldWarnMissingTarget(ctx, targetProvided, targetExists = null) {
if (ctx.isMonorepo && targetProvided && targetExists === false) return true;
return !!(
ctx.isMonorepo
&& (!targetProvided || targetExists === false)
&& ctx.projectRoot
&& ctx.repoRoot
&& path.resolve(ctx.projectRoot) === path.resolve(ctx.repoRoot)
);
}
function buildMissingTargetDirective() {
const script = process.argv[1] || 'context.mjs';
return (
'MONOREPO_TARGET_REQUIRED: This is a monorepo and context.mjs ran without --target. ' +
'If the user named a file, route, or child app, do not answer from this output. ' +
`Rerun \`node ${script} --target <path>\` and answer from that run's RESOLVED_CONTEXT fields.`
);
}
function buildTargetSelectionDirective(selection) {
return (
`TARGET_SELECTION_REQUIRED:\n${JSON.stringify(selection, null, 2)}\n\n` +
'Show each app with its productStatus/productPath and designStatus/designPath so the user can see child overrides, inherited root files, fallback files, or missing files before choosing. ' +
'Ask the user which app Impeccable should use, then rerun Impeccable helper commands from that child app cwd using this same scripts directory. ' +
'Use `--target <path>` only as a fallback when changing cwd is not possible, or when the user explicitly named a file/path.'
);
}
// Run cli() only when this module is the entry point. Compare realpaths
// rather than endsWith(): a loose suffix match also fires for unrelated
// scripts like `load-context.mjs`, and realpath tolerates symlinked
@@ -2,11 +2,11 @@
/**
* Critique persistence helper.
*
* Each run of /impeccable critique writes a per-target snapshot to
* Each critique run writes a per-target snapshot to
* .impeccable/critique/<timestamp>__<slug>.md
* with a small YAML frontmatter carrying the score + P0/P1 counts.
*
* /impeccable polish reads the latest matching snapshot at start as its
* The polish workflow reads the latest matching snapshot at start as its
* fix backlog. No other skill auto-reads critique output.
*
* The slug is derived mechanically from the *resolved* primary artifact
@@ -2,6 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { loadDesignSystemForCwd } from '../design-system.mjs';
import { RULE_SCOPES, filterByScopes } from '../registry/antipatterns.mjs';
import { createBrowserDetector, detectUrl } from '../engines/browser/detect-url.mjs';
import { detectHtml } from '../engines/static-html/detect-html.mjs';
import { detectText } from '../engines/regex/detect-text.mjs';
@@ -22,6 +23,10 @@ import {
// Output formatting
// ---------------------------------------------------------------------------
function formatFindingSummary(count) {
return `${count} anti-pattern${count === 1 ? '' : 's'} found.`;
}
function formatFindings(findings, jsonMode) {
if (jsonMode) return JSON.stringify(findings, null, 2);
@@ -39,7 +44,7 @@ function formatFindings(findings, jsonMode) {
out.push(`${item.description}`);
}
}
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
out.push(`\n${formatFindingSummary(findings.length)}`);
return out.join('\n');
}
@@ -86,9 +91,14 @@ Scan files or URLs for UI anti-patterns and design quality issues.
Options:
--json Output results as JSON
--quiet In text mode, only print the final findings count
--gpt Also report GPT-specific provider tells (off by default)
--gemini Also report Gemini-specific provider tells (off by default)
--no-config Do not apply project config, detector ignores, or DESIGN.md
--scope <name> Only report rules in the given design domain
(type, layout). Comma-separated.
--no-config Do not apply project config, detector ignores, inline
ignore comments, or DESIGN.md
--no-inline-ignores Do not honor in-file impeccable-disable* ignore comments
--no-design-system Do not load local DESIGN.md / .impeccable/design.json context
--help Show this help message
@@ -97,6 +107,14 @@ Project config:
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
and detector.designSystem.enabled.
Inline ignores:
In-file comments waive a finding where it lives and travel with the file:
<!-- impeccable-disable overused-font -- exported brand doc -->
.brand { font-family: Inter } /* impeccable-disable-line overused-font */
// impeccable-disable-next-line bounce-easing: intentional bounce
impeccable-disable applies to the whole file; -line / -next-line are scoped.
List one or more rule ids (comma-separated), or omit them / use * for all.
Detection modes:
HTML files Static HTML/CSS analysis (default, catches linked CSS)
Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
@@ -118,6 +136,7 @@ async function detectCli() {
});
if (args[0] === 'detect') args = args.slice(1);
const jsonMode = args.includes('--json');
const quietMode = args.includes('--quiet');
const helpMode = args.includes('--help');
// --fast (regex-only) is deprecated: since the jsdom removal, the static
// HTML/CSS analysis is fast and covers every rule, so the regex-only path
@@ -135,9 +154,41 @@ async function detectCli() {
const providers = [];
if (args.includes('--gpt')) providers.push('gpt');
if (args.includes('--gemini')) providers.push('gemini');
const scopes = [];
for (let i = 0; i < args.length; i++) {
if (args[i] !== '--scope' && !args[i].startsWith('--scope=')) continue;
const inline = args[i].startsWith('--scope=');
const value = inline ? args[i].slice('--scope='.length) : args[i + 1];
const parsed = (value && !value.startsWith('--'))
? value.split(',').map(s => s.trim()).filter(Boolean)
: [];
// A bare `--scope` would otherwise fall out of `targets` and scan unscoped;
// fail loudly so a mistyped pre-scan never runs the wrong rule set.
if (parsed.length === 0) {
process.stderr.write(
`Error: --scope requires a value. Valid scopes: ${[...RULE_SCOPES].join(', ')}\n`,
);
process.exit(1);
}
scopes.push(...parsed);
args.splice(i, inline ? 1 : 2);
i -= 1;
}
const unknownScopes = scopes.filter(s => !RULE_SCOPES.has(s));
if (unknownScopes.length > 0) {
process.stderr.write(
`Error: unknown --scope value(s): ${unknownScopes.join(', ')}. Valid scopes: ${[...RULE_SCOPES].join(', ')}\n`,
);
process.exit(1);
}
const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false;
const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null;
const scanOptions = designSystem ? { providers, designSystem } : { providers };
// Inline `impeccable-disable*` waivers are part of the scanned file, so they
// apply by default. `--no-config` (raw scan) and the dedicated
// `--no-inline-ignores` both turn them off.
const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores');
const scanOptions = { providers, inlineIgnores: inlineIgnoresEnabled };
if (designSystem) scanOptions.designSystem = designSystem;
const targets = args.filter(a => !a.startsWith('--'));
if (helpMode) { printUsage(); process.exit(0); }
@@ -169,8 +220,8 @@ async function detectCli() {
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON mode to avoid polluting output)
if (!jsonMode) {
// Check for framework dev server config (skip in JSON/quiet modes to avoid polluting output)
if (!jsonMode && !quietMode) {
const fwConfig = detectFrameworkConfig(resolved);
if (fwConfig) {
const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint);
@@ -200,7 +251,7 @@ async function detectCli() {
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
// Warn and confirm if scanning many files (static HTML/CSS processes each HTML file)
if (files.length > 50 && process.stdin.isTTY && !jsonMode) {
if (files.length > 50 && process.stdin.isTTY && !jsonMode && !quietMode) {
process.stderr.write(
`\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` +
`Scanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\n` +
@@ -255,9 +306,11 @@ async function detectCli() {
}
allFindings = filterDetectionFindings(allFindings, detectionConfig);
allFindings = filterByScopes(allFindings, scopes);
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
else if (quietMode) process.stderr.write(formatFindingSummary(allFindings.length) + '\n');
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(2);
}
@@ -9,6 +9,8 @@ const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
const FALLBACK_DIRS = ['.agents/context', 'docs'];
const COLOR_CHANNEL_TOLERANCE = 6;
const RADIUS_TOLERANCE_PX = 0.5;
const FONT_SIZE_TOLERANCE_PX = 0.5;
const FONT_SIZE_LITERAL_RE = /^-?[\d.]+(?:px|rem)$/;
const CSS_COLOR_RE = /#[0-9a-f]{3,8}\b|rgba?\([^)]+\)|oklch\([^)]+\)|hsla?\([^)]+\)/gi;
const FONT_DECL_RE = /font-family\s*:\s*([^;}\n]+)/gi;
@@ -16,6 +18,9 @@ const FONT_JS_RE = /fontFamily\s*[:=]\s*["'`]([^"'`]+)["'`]/g;
const GOOGLE_FONT_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi;
const BORDER_RADIUS_RE = /border-radius\s*:\s*([^;}\n]+)/gi;
const BORDER_RADIUS_JS_RE = /borderRadius\s*[:=]\s*["'`]([^"'`]+)["'`]/g;
const FONT_SIZE_DECL_RE = /font-size\s*:\s*([^;}\n]+)/gi;
const FONT_SIZE_JS_RE = /fontSize\s*[:=]\s*["'`]([^"'`]+)["'`]/g;
const TAILWIND_FONT_SIZE_RE = /\btext-\[(-?[\d.]+(?:px|rem))\]/g;
const STATIC_DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']);
function firstExisting(dir, names) {
@@ -283,6 +288,18 @@ function addTypographyFonts(out, typography) {
}
}
function addTypographySizes(out, typography) {
if (!typography || typeof typography !== 'object') return;
for (const role of Object.values(typography)) {
if (!role || typeof role !== 'object') continue;
const raw = String(role.fontSize ?? '').trim().toLowerCase();
if (!FONT_SIZE_LITERAL_RE.test(raw)) continue;
const px = resolveLengthPx(raw, 16);
if (px == null || !Number.isFinite(px) || px <= 0) continue;
out.allowedFontSizes.push({ value: raw, px });
}
}
function addRoundedScale(out, rounded) {
if (!rounded || typeof rounded !== 'object') return;
for (const [rawName, value] of Object.entries(rounded)) {
@@ -340,10 +357,12 @@ function normalizeDesignSystem(input = {}) {
allowedFonts: new Set(),
allowedColorKeys: new Map(),
allowedRadii: [],
allowedFontSizes: [],
hasPillRadius: false,
};
addTypographyFonts(out, frontmatter.typography);
addTypographySizes(out, frontmatter.typography);
addColorObject(out, frontmatter.colors);
addSidecarColors(out, sidecar);
addRoundedScale(out, frontmatter.rounded);
@@ -352,6 +371,7 @@ function normalizeDesignSystem(input = {}) {
out.hasFonts = out.allowedFonts.size > 0;
out.hasColors = out.allowedColorKeys.size > 0;
out.hasRadii = out.allowedRadii.length > 0;
out.hasFontSizes = out.allowedFontSizes.length > 0;
return out;
}
@@ -418,6 +438,17 @@ function isAllowedRadiusRaw(raw, designSystem) {
return designSystem.allowedRadii.some(entry => Math.abs(entry.px - px) <= RADIUS_TOLERANCE_PX);
}
function isAllowedFontSizeRaw(raw, designSystem) {
if (!designSystem?.hasFontSizes) return true;
const text = String(raw || '').trim().toLowerCase().replace(/\s*!important\s*$/, '');
if (!FONT_SIZE_LITERAL_RE.test(text)) return true;
const px = resolveLengthPx(text, 16);
if (px == null || !Number.isFinite(px) || px <= 0) return true;
return designSystem.allowedFontSizes.some(
entry => Math.abs(entry.px - px) <= FONT_SIZE_TOLERANCE_PX,
);
}
function lineLooksCommented(line) {
const trimmed = String(line || '').trim();
return trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*') || trimmed.startsWith('<!--');
@@ -509,6 +540,18 @@ function checkRadiusValue(value, filePath, line, designSystem, context) {
return findings;
}
function checkFontSizeValue(value, filePath, line, designSystem, context) {
const token = String(value || '').trim();
if (isAllowedFontSizeRaw(token, designSystem)) return [];
return [makeDesignFinding(
'design-system-font-size',
filePath,
`${context}: ${token} is off the DESIGN.md type ramp`,
line,
{ ignoreValue: token },
)];
}
function checkSourceDesignSystem(content, filePath, options = {}) {
const designSystem = options.designSystem;
if (!designSystem?.present) return [];
@@ -567,6 +610,18 @@ function checkSourceDesignSystem(content, filePath, options = {}) {
findings.push(...checkRadiusValue(match[1], filePath, lineNum, designSystem, 'borderRadius'));
}
}
if (designSystem.hasFontSizes) {
for (const match of line.matchAll(FONT_SIZE_DECL_RE)) {
findings.push(...checkFontSizeValue(match[1], filePath, lineNum, designSystem, 'font-size'));
}
for (const match of line.matchAll(FONT_SIZE_JS_RE)) {
findings.push(...checkFontSizeValue(match[1], filePath, lineNum, designSystem, 'fontSize'));
}
for (const match of line.matchAll(TAILWIND_FONT_SIZE_RE)) {
findings.push(...checkFontSizeValue(match[1], filePath, lineNum, designSystem, 'text-[…] class'));
}
}
}
return dedupeDesignFindings(findings);
@@ -581,6 +636,8 @@ function sampleText(el) {
return text ? ` "${text.slice(0, 40)}"` : '';
}
// Font-size design-system checks are source-scan-only (see checkSourceDesignSystem).
// Computed font-size cascades and clamp() ramps resolve to off-ramp px in the browser.
function collectStaticDesignSystemFindings(document, window, filePath, designSystem) {
if (!designSystem?.present) return [];
const findings = [];
@@ -698,6 +755,12 @@ function canonicalDesignFindingKey(item) {
const label = String(value || '').trim().toLowerCase();
return label ? `${item.antipattern}:radius:${label}` : null;
}
if (item.antipattern === 'design-system-font-size') {
const px = resolveLengthPx(String(value || '').trim(), 16);
if (px != null && Number.isFinite(px)) return `${item.antipattern}:font-size:${Math.round(px * 100) / 100}`;
const label = String(value || '').trim().toLowerCase();
return label ? `${item.antipattern}:font-size:${label}` : null;
}
return null;
}
@@ -744,6 +807,7 @@ export {
isAllowedFont,
isAllowedColorRaw,
isAllowedRadiusRaw,
isAllowedFontSizeRaw,
checkSourceDesignSystem,
collectStaticDesignSystemFindings,
mergeDesignSystemFindings,
@@ -123,6 +123,7 @@ const ANTIPATTERNS = [
{
id: 'overused-font',
category: 'slop',
scopes: ['type'],
name: 'Overused font',
description:
'Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.',
@@ -132,6 +133,7 @@ const ANTIPATTERNS = [
{
id: 'single-font',
category: 'slop',
scopes: ['type'],
name: 'Single font for everything',
description:
'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
@@ -141,6 +143,7 @@ const ANTIPATTERNS = [
{
id: 'flat-type-hierarchy',
category: 'slop',
scopes: ['type'],
name: 'Flat type hierarchy',
description:
'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
@@ -177,6 +180,7 @@ const ANTIPATTERNS = [
{
id: 'nested-cards',
category: 'slop',
scopes: ['layout'],
name: 'Nested cards',
description:
'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
@@ -186,6 +190,7 @@ const ANTIPATTERNS = [
{
id: 'monotonous-spacing',
category: 'slop',
scopes: ['layout'],
name: 'Monotonous spacing',
description:
'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
@@ -213,6 +218,7 @@ const ANTIPATTERNS = [
{
id: 'icon-tile-stack',
category: 'slop',
scopes: ['layout'],
name: 'Icon tile stacked above heading',
description:
'A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.',
@@ -222,6 +228,7 @@ const ANTIPATTERNS = [
{
id: 'italic-serif-display',
category: 'slop',
scopes: ['type'],
name: 'Italic serif display headline',
description:
'Oversized italic serif (Fraunces, Recoleta, Playfair, Newsreader-italic) as the primary hero headline reads as taste in isolation but has become the universal AI-startup landing page hero. Set roman, or move to a non-serif display face. Editorial / magazine register may legitimately want this — judge by context.',
@@ -231,6 +238,7 @@ const ANTIPATTERNS = [
{
id: 'hero-eyebrow-chip',
category: 'slop',
scopes: ['type'],
name: 'Hero eyebrow / pill chip',
description:
'A tiny uppercase letter-spaced label sitting immediately above an oversized hero headline — or the same shape rendered as a pill chip — is now the default AI SaaS hero. Drop the eyebrow, integrate the kicker into the headline, or run it as a navigation breadcrumb instead.',
@@ -240,6 +248,7 @@ const ANTIPATTERNS = [
{
id: 'repeated-section-kickers',
category: 'slop',
scopes: ['type'],
severity: 'advisory',
name: 'Repeated section kicker labels',
description:
@@ -250,6 +259,7 @@ const ANTIPATTERNS = [
{
id: 'numbered-section-markers',
category: 'slop',
scopes: ['layout'],
severity: 'advisory',
name: 'Numbered section markers (01 / 02 / 03)',
description:
@@ -287,6 +297,7 @@ const ANTIPATTERNS = [
{
id: 'oversized-h1',
category: 'slop',
scopes: ['type'],
name: 'Oversized hero headline',
description:
'A full-sentence headline set at display size ends up dominating the viewport, leaving no room for anything else above the fold. A punchy one- or two-word headline at that size is fine — the problem is a long headline blown up too large. Set long headlines smaller, or tighten the copy.',
@@ -296,6 +307,7 @@ const ANTIPATTERNS = [
{
id: 'extreme-negative-tracking',
category: 'slop',
scopes: ['type'],
name: 'Crushed letter spacing',
description:
'Letter-spacing pulled tighter than the point where characters keep their own shapes costs legibility. Tighten display type optically, not destructively.',
@@ -341,6 +353,7 @@ const ANTIPATTERNS = [
{
id: 'line-length',
category: 'quality',
scopes: ['type', 'layout'],
name: 'Line length too long',
description:
'Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.',
@@ -350,6 +363,7 @@ const ANTIPATTERNS = [
{
id: 'cramped-padding',
category: 'quality',
scopes: ['layout'],
name: 'Cramped padding',
description:
'Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 1216px) of padding inside bordered, outlined, or colored containers.',
@@ -359,6 +373,7 @@ const ANTIPATTERNS = [
{
id: 'body-text-viewport-edge',
category: 'quality',
scopes: ['layout'],
name: 'Body text touching viewport edge',
description:
'Body paragraphs render flush against the left or right viewport edge with no container providing horizontal padding. Wrap content in a container with at least 16px (ideally 24-32px) of horizontal padding, or apply max-width with mx-auto.',
@@ -366,6 +381,7 @@ const ANTIPATTERNS = [
{
id: 'tight-leading',
category: 'quality',
scopes: ['type'],
name: 'Tight line height',
description:
'Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.',
@@ -373,6 +389,7 @@ const ANTIPATTERNS = [
{
id: 'skipped-heading',
category: 'quality',
scopes: ['type'],
name: 'Skipped heading level',
description:
'Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.',
@@ -380,6 +397,7 @@ const ANTIPATTERNS = [
{
id: 'justified-text',
category: 'quality',
scopes: ['type'],
name: 'Justified text',
description:
'Justified text without hyphenation creates uneven word spacing ("rivers of white"). Use text-align: left for body text, or enable hyphens: auto if you must justify.',
@@ -387,6 +405,7 @@ const ANTIPATTERNS = [
{
id: 'tiny-text',
category: 'quality',
scopes: ['type'],
name: 'Tiny body text',
description:
'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
@@ -394,6 +413,7 @@ const ANTIPATTERNS = [
{
id: 'all-caps-body',
category: 'quality',
scopes: ['type'],
name: 'All-caps body text',
description:
'Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.',
@@ -403,6 +423,7 @@ const ANTIPATTERNS = [
{
id: 'wide-tracking',
category: 'quality',
scopes: ['type'],
name: 'Wide letter spacing on body text',
description:
'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.',
@@ -410,6 +431,7 @@ const ANTIPATTERNS = [
{
id: 'text-overflow',
category: 'quality',
scopes: ['layout'],
name: 'Content overflowing its container',
description:
'Content renders wider than its container, spilling out or forcing a horizontal scrollbar. Let text wrap, constrain widths, or give the region a deliberate scroll affordance.',
@@ -419,6 +441,7 @@ const ANTIPATTERNS = [
{
id: 'clipped-overflow-container',
category: 'quality',
scopes: ['layout'],
name: 'Positioned child clipped by overflow container',
description:
'A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.',
@@ -428,6 +451,7 @@ const ANTIPATTERNS = [
{
id: 'design-system-font',
category: 'quality',
scopes: ['type'],
name: 'Font outside DESIGN.md',
description:
'A font is used that is not declared in DESIGN.md typography. Use the documented type system or update DESIGN.md if this is an intentional brand addition.',
@@ -454,6 +478,17 @@ const ANTIPATTERNS = [
skillSection: 'Visual Details',
skillGuideline: 'border radius outside the project design system',
},
{
id: 'design-system-font-size',
category: 'quality',
severity: 'advisory',
scopes: ['type'],
name: 'Font size outside DESIGN.md',
description:
'A literal font-size is off the type ramp documented in DESIGN.md typography. Use a documented size step or update the design system if the new step is intentional.',
skillSection: 'Typography',
skillGuideline: 'font size outside the project design system',
},
// ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
{
@@ -478,6 +513,17 @@ const ANTIPATTERNS = [
skillSection: 'Visual Details',
skillGuideline: 'repeating-gradient decorative stripes',
},
{
id: 'codex-grid-background',
category: 'slop',
severity: 'advisory',
gated: 'gpt',
name: 'Decorative grid-line background',
description:
'A two-axis grid drawn with hairline linear-gradient layers ("1px, transparent 1px" on both axes) is a recurring generated-UI signature. Reserve grid overlays for actual canvas, map, blueprint, or measurement surfaces; elsewhere use product structure or a plain surface.',
skillSection: 'Visual Details',
skillGuideline: 'two-axis grid-line gradient background',
},
{
id: 'theater-slop-phrase',
category: 'slop',
@@ -617,6 +663,36 @@ function colorToHex(c) {
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
}
// --- cli/engine/shared/fonts.mjs ---
const GOOGLE_FONTS_URL_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi;
function normalizeGoogleFontFamilyParam(value) {
return String(value || '')
.split('|')
.map(part => part.split(':')[0].trim().toLowerCase())
.filter(Boolean);
}
function extractGoogleFontFamilies(text) {
const families = [];
if (!text) return families;
GOOGLE_FONTS_URL_RE.lastIndex = 0;
let urlMatch;
while ((urlMatch = GOOGLE_FONTS_URL_RE.exec(text)) !== null) {
const url = urlMatch[0];
const queryStart = url.indexOf('?');
if (queryStart === -1) continue;
const params = new URLSearchParams(url.slice(queryStart + 1).replace(/&amp;/g, '&'));
for (const value of params.getAll('family')) {
families.push(...normalizeGoogleFontFamilyParam(value));
}
}
return families;
}
// --- cli/engine/rules/checks.mjs ---
const DETECTOR_IS_BROWSER = typeof window !== 'undefined';
@@ -1172,6 +1248,42 @@ function checkHtmlPatterns(html) {
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });
}
// --- Provider tells (gated): two-axis grid-line background (Codex/GPT) ---
// The Codex grid tell is two hairline `linear-gradient(... <color> 1px,
// transparent 1px)` layers (one per axis) tiled by a repeating
// `background-size` cell. Both signals must co-occur in the SAME style block
// (a CSS rule body or one inline `style="..."`): two hairline stops WITHOUT a
// tiling background-size is a fixed crosshair, not a grid, and a single
// hairline is a legitimate ruled line. Scoping to one block also stops
// unrelated single-axis rules on separate elements from adding up across the
// page. Count hairlines only inside `background`/`background-image` values so
// a hairline in an unrelated property (mask-image, border-image) can't stand
// in for the second axis. Colors like `oklch(96% 0.012 82 / 0.055)` carry
// nested parens, so match the hairline stop directly rather than parsing
// whole gradient layers.
{
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
const gridSizeRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
let blk;
while ((blk = blockRe.exec(html)) !== null) {
const block = blk[1] || blk[2] || blk[3] || '';
if (!gridSizeRe.test(block)) continue;
let hairlineCount = 0;
let bm;
bgDeclRe.lastIndex = 0;
while ((bm = bgDeclRe.exec(block)) !== null) {
const stops = bm[1].match(hairlineRe);
if (stops) hairlineCount += stops.length;
}
if (hairlineCount >= 2) {
findings.push({ id: 'codex-grid-background', snippet: 'two-axis grid-line gradient background' });
break;
}
}
}
// --- Provider tells (gated): "X theater" framing copy (GPT) ---
// Lives here (regex-on-HTML) rather than in the text-content analyzers so it
// runs in the bundled browser path too, not just the CLI/static path.
@@ -2635,14 +2747,9 @@ function checkPageTypography(doc, win) {
// Check Google Fonts links in HTML
const html = doc.documentElement?.outerHTML || '';
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
let m;
while ((m = gfRe.exec(html)) !== null) {
const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase());
for (const f of families) {
fonts.add(f);
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
}
for (const f of extractGoogleFontFamilies(html)) {
fonts.add(f);
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
}
// Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM)
@@ -1,6 +1,9 @@
import { GENERIC_FONTS } from '../../shared/constants.mjs';
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
import { isNeutralColor } from '../../shared/color.mjs';
import { extractGoogleFontFamilies } from '../../shared/fonts.mjs';
import { checkSourceDesignSystem } from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
import { finding } from '../../findings.mjs';
import { filterByProviders } from '../../registry/antipatterns.mjs';
import { profileFindings, profileStep } from '../../profile/profiler.mjs';
@@ -36,11 +39,16 @@ function shouldRunPageAnalyzers(content, filePath) {
return !ext || PAGE_ANALYZER_EXTS.has(ext);
}
function firstOverusedGoogleFont(text) {
return extractGoogleFontFamilies(text).find(f => OVERUSED_FONTS.has(f)) || '';
}
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
const m = str.match(/solid\s+((?:rgba?|hsla?|oklch|oklab|lab|lch|hwb|color)\([^)]*\)|#[0-9a-f]{3,8}\b|[a-z]+)/i);
if (!m) return false;
const c = m[1].toLowerCase();
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
if (/^(?:rgba?|hsla?|oklch|oklab|lab|lch|hwb)\(/i.test(c)) return isNeutralColor(c);
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
if (hex) {
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
@@ -57,10 +65,10 @@ function isNeutralBorderColor(str) {
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 2 : n >= 4; },
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 2 : n >= 3; },
fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },
{ id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
@@ -85,9 +93,12 @@ const REGEX_MATCHERS = [
{ id: 'overused-font', regex: /font-family\s*:\s*['"]?(Inter|Roboto|Open Sans|Lato|Montserrat|Arial|Helvetica|Fraunces|Geist Sans|Geist Mono|Geist|Mona Sans|Plus Jakarta Sans|Space Grotesk|Recoleta|Instrument Sans|Instrument Serif)\b/gi,
test: () => true,
fmt: (m) => m[0] },
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat|Fraunces|Plus\+Jakarta\+Sans|Space\+Grotesk|Instrument\+Sans|Instrument\+Serif|Mona\+Sans|Geist)\b/gi,
test: () => true,
fmt: (m) => `Google Fonts: ${m[1].replace(/\+/g, ' ')}` },
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi,
test: (m) => {
m.overusedGoogleFont = firstOverusedGoogleFont(m[0]);
return Boolean(m.overusedGoogleFont);
},
fmt: (m) => `Google Fonts: ${m.overusedGoogleFont || firstOverusedGoogleFont(m[0])}` },
// --- Gradient text ---
{ id: 'gradient-text', regex: /background-clip\s*:\s*text|-webkit-background-clip\s*:\s*text/gi,
test: (m, line) => /gradient/i.test(line),
@@ -167,10 +178,7 @@ const REGEX_ANALYZERS = [
if (f && !GENERIC_FONTS.has(f)) fonts.add(f);
}
}
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
while ((m = gfRe.exec(content)) !== null) {
for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) fonts.add(f);
}
for (const f of extractGoogleFontFamilies(content)) fonts.add(f);
if (fonts.size !== 1 || content.split('\n').length < 20) return [];
const name = [...fonts][0];
const lines = content.split('\n');
@@ -547,7 +555,10 @@ function detectText(content, filePath, options = {}) {
}
}
return filterByProviders(deduped, options?.providers);
const byProvider = filterByProviders(deduped, options?.providers);
// Inline `impeccable-disable*` waivers travel with the file; honor them unless
// explicitly bypassed (`--no-config` / `--no-inline-ignores`).
return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, content);
}
export {
@@ -8,6 +8,7 @@ import {
mergeDesignSystemFindings,
} from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { applyInlineIgnores } from '../../shared/inline-ignores.mjs';
import { finding } from '../../findings.mjs';
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import {
@@ -223,7 +224,11 @@ async function detectHtml(filePath, options = {}) {
}
}
return filterByProviders(findings, options.providers);
const byProvider = filterByProviders(findings, options.providers);
// Static-HTML findings carry no line number, so only whole-file
// `impeccable-disable` directives apply here — exactly the standalone-document
// waiver this primitive targets. Bypassed by `--no-config` / `--no-inline-ignores`.
return options?.inlineIgnores === false ? byProvider : applyInlineIgnores(byProvider, html);
}
export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml };
@@ -21,6 +21,7 @@ const ANTIPATTERNS = [
{
id: 'overused-font',
category: 'slop',
scopes: ['type'],
name: 'Overused font',
description:
'Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.',
@@ -30,6 +31,7 @@ const ANTIPATTERNS = [
{
id: 'single-font',
category: 'slop',
scopes: ['type'],
name: 'Single font for everything',
description:
'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
@@ -39,6 +41,7 @@ const ANTIPATTERNS = [
{
id: 'flat-type-hierarchy',
category: 'slop',
scopes: ['type'],
name: 'Flat type hierarchy',
description:
'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
@@ -75,6 +78,7 @@ const ANTIPATTERNS = [
{
id: 'nested-cards',
category: 'slop',
scopes: ['layout'],
name: 'Nested cards',
description:
'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
@@ -84,6 +88,7 @@ const ANTIPATTERNS = [
{
id: 'monotonous-spacing',
category: 'slop',
scopes: ['layout'],
name: 'Monotonous spacing',
description:
'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
@@ -111,6 +116,7 @@ const ANTIPATTERNS = [
{
id: 'icon-tile-stack',
category: 'slop',
scopes: ['layout'],
name: 'Icon tile stacked above heading',
description:
'A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.',
@@ -120,6 +126,7 @@ const ANTIPATTERNS = [
{
id: 'italic-serif-display',
category: 'slop',
scopes: ['type'],
name: 'Italic serif display headline',
description:
'Oversized italic serif (Fraunces, Recoleta, Playfair, Newsreader-italic) as the primary hero headline reads as taste in isolation but has become the universal AI-startup landing page hero. Set roman, or move to a non-serif display face. Editorial / magazine register may legitimately want this — judge by context.',
@@ -129,6 +136,7 @@ const ANTIPATTERNS = [
{
id: 'hero-eyebrow-chip',
category: 'slop',
scopes: ['type'],
name: 'Hero eyebrow / pill chip',
description:
'A tiny uppercase letter-spaced label sitting immediately above an oversized hero headline — or the same shape rendered as a pill chip — is now the default AI SaaS hero. Drop the eyebrow, integrate the kicker into the headline, or run it as a navigation breadcrumb instead.',
@@ -138,6 +146,7 @@ const ANTIPATTERNS = [
{
id: 'repeated-section-kickers',
category: 'slop',
scopes: ['type'],
severity: 'advisory',
name: 'Repeated section kicker labels',
description:
@@ -148,6 +157,7 @@ const ANTIPATTERNS = [
{
id: 'numbered-section-markers',
category: 'slop',
scopes: ['layout'],
severity: 'advisory',
name: 'Numbered section markers (01 / 02 / 03)',
description:
@@ -185,6 +195,7 @@ const ANTIPATTERNS = [
{
id: 'oversized-h1',
category: 'slop',
scopes: ['type'],
name: 'Oversized hero headline',
description:
'A full-sentence headline set at display size ends up dominating the viewport, leaving no room for anything else above the fold. A punchy one- or two-word headline at that size is fine — the problem is a long headline blown up too large. Set long headlines smaller, or tighten the copy.',
@@ -194,6 +205,7 @@ const ANTIPATTERNS = [
{
id: 'extreme-negative-tracking',
category: 'slop',
scopes: ['type'],
name: 'Crushed letter spacing',
description:
'Letter-spacing pulled tighter than the point where characters keep their own shapes costs legibility. Tighten display type optically, not destructively.',
@@ -239,6 +251,7 @@ const ANTIPATTERNS = [
{
id: 'line-length',
category: 'quality',
scopes: ['type', 'layout'],
name: 'Line length too long',
description:
'Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.',
@@ -248,6 +261,7 @@ const ANTIPATTERNS = [
{
id: 'cramped-padding',
category: 'quality',
scopes: ['layout'],
name: 'Cramped padding',
description:
'Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 1216px) of padding inside bordered, outlined, or colored containers.',
@@ -257,6 +271,7 @@ const ANTIPATTERNS = [
{
id: 'body-text-viewport-edge',
category: 'quality',
scopes: ['layout'],
name: 'Body text touching viewport edge',
description:
'Body paragraphs render flush against the left or right viewport edge with no container providing horizontal padding. Wrap content in a container with at least 16px (ideally 24-32px) of horizontal padding, or apply max-width with mx-auto.',
@@ -264,6 +279,7 @@ const ANTIPATTERNS = [
{
id: 'tight-leading',
category: 'quality',
scopes: ['type'],
name: 'Tight line height',
description:
'Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.',
@@ -271,6 +287,7 @@ const ANTIPATTERNS = [
{
id: 'skipped-heading',
category: 'quality',
scopes: ['type'],
name: 'Skipped heading level',
description:
'Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.',
@@ -278,6 +295,7 @@ const ANTIPATTERNS = [
{
id: 'justified-text',
category: 'quality',
scopes: ['type'],
name: 'Justified text',
description:
'Justified text without hyphenation creates uneven word spacing ("rivers of white"). Use text-align: left for body text, or enable hyphens: auto if you must justify.',
@@ -285,6 +303,7 @@ const ANTIPATTERNS = [
{
id: 'tiny-text',
category: 'quality',
scopes: ['type'],
name: 'Tiny body text',
description:
'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
@@ -292,6 +311,7 @@ const ANTIPATTERNS = [
{
id: 'all-caps-body',
category: 'quality',
scopes: ['type'],
name: 'All-caps body text',
description:
'Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.',
@@ -301,6 +321,7 @@ const ANTIPATTERNS = [
{
id: 'wide-tracking',
category: 'quality',
scopes: ['type'],
name: 'Wide letter spacing on body text',
description:
'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.',
@@ -308,6 +329,7 @@ const ANTIPATTERNS = [
{
id: 'text-overflow',
category: 'quality',
scopes: ['layout'],
name: 'Content overflowing its container',
description:
'Content renders wider than its container, spilling out or forcing a horizontal scrollbar. Let text wrap, constrain widths, or give the region a deliberate scroll affordance.',
@@ -317,6 +339,7 @@ const ANTIPATTERNS = [
{
id: 'clipped-overflow-container',
category: 'quality',
scopes: ['layout'],
name: 'Positioned child clipped by overflow container',
description:
'A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.',
@@ -326,6 +349,7 @@ const ANTIPATTERNS = [
{
id: 'design-system-font',
category: 'quality',
scopes: ['type'],
name: 'Font outside DESIGN.md',
description:
'A font is used that is not declared in DESIGN.md typography. Use the documented type system or update DESIGN.md if this is an intentional brand addition.',
@@ -352,6 +376,17 @@ const ANTIPATTERNS = [
skillSection: 'Visual Details',
skillGuideline: 'border radius outside the project design system',
},
{
id: 'design-system-font-size',
category: 'quality',
severity: 'advisory',
scopes: ['type'],
name: 'Font size outside DESIGN.md',
description:
'A literal font-size is off the type ramp documented in DESIGN.md typography. Use a documented size step or update the design system if the new step is intentional.',
skillSection: 'Typography',
skillGuideline: 'font size outside the project design system',
},
// ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
{
@@ -376,6 +411,17 @@ const ANTIPATTERNS = [
skillSection: 'Visual Details',
skillGuideline: 'repeating-gradient decorative stripes',
},
{
id: 'codex-grid-background',
category: 'slop',
severity: 'advisory',
gated: 'gpt',
name: 'Decorative grid-line background',
description:
'A two-axis grid drawn with hairline linear-gradient layers ("1px, transparent 1px" on both axes) is a recurring generated-UI signature. Reserve grid overlays for actual canvas, map, blueprint, or measurement surfaces; elsewhere use product structure or a plain surface.',
skillSection: 'Visual Details',
skillGuideline: 'two-axis grid-line gradient background',
},
{
id: 'theater-slop-phrase',
category: 'slop',
@@ -437,12 +483,32 @@ function filterByProviders(findings, providers = []) {
});
}
// Set of scope tags rules can declare (e.g. 'type', 'layout'). Used by the
// CLI --scope flag to narrow output to one design domain.
const RULE_SCOPES = new Set(
ANTIPATTERNS.flatMap(rule => rule.scopes || []),
);
// Keep only findings whose rule declares at least one of the requested
// scopes. An empty scope list means no filtering (default CLI behavior).
function filterByScopes(findings, scopes = []) {
if (!scopes || scopes.length === 0) return findings;
const enabled = new Set(scopes);
return findings.filter(f => {
const rule = getAntipattern(f.antipattern);
return (rule?.scopes || []).some(scope => enabled.has(scope));
});
}
export {
ANTIPATTERNS,
RULE_SCOPES,
RULE_ENGINE_SUPPORT,
GATED_PROVIDERS,
getAntipattern,
getRulesForCategory,
getRuleEngineSupport,
filterByProviders,
filterByScopes,
};
@@ -18,6 +18,7 @@ import {
parseRgb,
relativeLuminance,
} from '../shared/color.mjs';
import { extractGoogleFontFamilies } from '../shared/fonts.mjs';
const DETECTOR_IS_BROWSER = typeof window !== 'undefined';
@@ -573,6 +574,42 @@ function checkHtmlPatterns(html) {
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });
}
// --- Provider tells (gated): two-axis grid-line background (Codex/GPT) ---
// The Codex grid tell is two hairline `linear-gradient(... <color> 1px,
// transparent 1px)` layers (one per axis) tiled by a repeating
// `background-size` cell. Both signals must co-occur in the SAME style block
// (a CSS rule body or one inline `style="..."`): two hairline stops WITHOUT a
// tiling background-size is a fixed crosshair, not a grid, and a single
// hairline is a legitimate ruled line. Scoping to one block also stops
// unrelated single-axis rules on separate elements from adding up across the
// page. Count hairlines only inside `background`/`background-image` values so
// a hairline in an unrelated property (mask-image, border-image) can't stand
// in for the second axis. Colors like `oklch(96% 0.012 82 / 0.055)` carry
// nested parens, so match the hairline stop directly rather than parsing
// whole gradient layers.
{
const hairlineRe = /\b\d{1,3}px\s*,\s*transparent\s+\d{1,3}px/gi;
const gridSizeRe = /background-size\s*:[^;{}"']*\b\d{1,3}px\b/i;
const bgDeclRe = /\bbackground(?:-image)?\s*:\s*([^;{}"']*)/gi;
const blockRe = /\{([^{}]*)\}|style\s*=\s*"([^"]*)"|style\s*=\s*'([^']*)'/gi;
let blk;
while ((blk = blockRe.exec(html)) !== null) {
const block = blk[1] || blk[2] || blk[3] || '';
if (!gridSizeRe.test(block)) continue;
let hairlineCount = 0;
let bm;
bgDeclRe.lastIndex = 0;
while ((bm = bgDeclRe.exec(block)) !== null) {
const stops = bm[1].match(hairlineRe);
if (stops) hairlineCount += stops.length;
}
if (hairlineCount >= 2) {
findings.push({ id: 'codex-grid-background', snippet: 'two-axis grid-line gradient background' });
break;
}
}
}
// --- Provider tells (gated): "X theater" framing copy (GPT) ---
// Lives here (regex-on-HTML) rather than in the text-content analyzers so it
// runs in the bundled browser path too, not just the CLI/static path.
@@ -2036,14 +2073,9 @@ function checkPageTypography(doc, win) {
// Check Google Fonts links in HTML
const html = doc.documentElement?.outerHTML || '';
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
let m;
while ((m = gfRe.exec(html)) !== null) {
const families = m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase());
for (const f of families) {
fonts.add(f);
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
}
for (const f of extractGoogleFontFamilies(html)) {
fonts.add(f);
if (OVERUSED_FONTS.has(f)) overusedFound.add(f);
}
// Also parse raw HTML/style content for font-family (jsdom may not expose all via CSSOM)
@@ -0,0 +1,30 @@
const GOOGLE_FONTS_URL_RE = /fonts\.googleapis\.com\/css2?\?[^"'\s)<>]*/gi;
function normalizeGoogleFontFamilyParam(value) {
return String(value || '')
.split('|')
.map(part => part.split(':')[0].trim().toLowerCase())
.filter(Boolean);
}
function extractGoogleFontFamilies(text) {
const families = [];
if (!text) return families;
GOOGLE_FONTS_URL_RE.lastIndex = 0;
let urlMatch;
while ((urlMatch = GOOGLE_FONTS_URL_RE.exec(text)) !== null) {
const url = urlMatch[0];
const queryStart = url.indexOf('?');
if (queryStart === -1) continue;
const params = new URLSearchParams(url.slice(queryStart + 1).replace(/&amp;/g, '&'));
for (const value of params.getAll('family')) {
families.push(...normalizeGoogleFontFamilyParam(value));
}
}
return families;
}
export { extractGoogleFontFamilies };
@@ -0,0 +1,148 @@
/**
* Inline, in-file ignore directives eslint-disable-style waivers that live at
* the point they apply and travel with the artifact instead of (or alongside)
* an ignore in `.impeccable/config.json`.
*
* A config ignore is the right default for repo-wide policy. This complements it
* for the one case config can't cover: a waiver that belongs to a single file and
* needs to follow that file when it leaves the repo a generated/exported
* standalone document, an emailed HTML file, a snippet scanned out of context.
*
* Comment-syntax-agnostic: the directive is a raw token matched anywhere on a
* line, so the same marker works across every comment style impeccable scans
* `//`, `/* *\/`, `<!-- -->`, `#`, `{/* *\/}`, `{# #}`. Trailing comment closers
* are stripped before the rule list is parsed.
*
* Syntax (reason optional; eslint `--` or biome `:` separator):
*
* impeccable-disable <rule>[, <rule>...] [-- reason] whole file
* impeccable-disable-line <rule>... [-- reason] the same line
* impeccable-disable-next-line <rule>... [-- reason] the following line
* impeccable-disable bare / `*` = every rule
*
* Examples:
*
* <!-- impeccable-disable overused-font -- exported brand doc, font is first-party -->
* .brand { font-family: Inter; } /* impeccable-disable-line overused-font *\/
* // impeccable-disable-next-line bounce-easing: intentional playful affordance
*
* Behavior is suppression, for parity with config ignores: a matched directive
* drops the finding. The inline reason is self-documenting in the diff; it is not
* required and is discarded at scan time (only used here to keep reason words out
* of the parsed rule list).
*/
const DIRECTIVE_RE = /impeccable-(disable-next-line|disable-line|disable)\b[ \t]*([^\n\r]*)/gi;
// Trailing comment closers, so `*/`, `*/}`, `-->`, `*}`, `#}`, `%>`, `}}` don't
// leak into the rule list. Anchored to end-of-line; the leading `\s*` mops up the
// space before the closer. `--+>` covers `-->` and any longer dash run.
const TRAILING_CLOSER_RE = /\s*(?:\*\/\}?|--+>|\*\}|#\}|%>|\}\})\s*$/;
function normalizeRule(token) {
return String(token || '').trim().toLowerCase();
}
// Split the directive remainder into rule tokens, dropping any human reason that
// follows an eslint-style `--` or biome-style `:` separator. Rule ids only ever
// contain single hyphens (`overused-font`, `bounce-easing`), so `--` and `:`
// are unambiguous separators.
function parseRuleList(remainder) {
let text = String(remainder || '').replace(TRAILING_CLOSER_RE, '').trim();
// Cut off a human reason at the first `--` (eslint) or `:` (biome) separator.
const reasonSep = text.match(/\s*(?:--+|:)\s*/);
if (reasonSep) text = text.slice(0, reasonSep.index);
const tokens = text.split(/[\s,]+/).map(normalizeRule).filter(Boolean);
if (tokens.length === 0 || tokens.includes('*')) return ['*'];
return tokens;
}
function addRules(set, rules) {
for (const rule of rules) set.add(rule);
}
function getSet(map, key) {
let set = map.get(key);
if (!set) {
set = new Set();
map.set(key, set);
}
return set;
}
/**
* Parse every inline ignore directive in a file's raw text.
*
* Returns sets keyed by the 1-based line the directive *targets* so matching is a
* direct lookup:
* - file: rules disabled for the whole file
* - line: line -> rules disabled on that exact line (disable-line)
* - nextLine: line -> rules disabled on that line (disable-next-line on line-1)
*
* `*` in any set means "every rule".
*/
function parseInlineIgnores(content) {
const result = { file: new Set(), line: new Map(), nextLine: new Map() };
const text = typeof content === 'string' ? content : '';
// Cheap bail-out: the substring must be present for any directive to exist.
// Case-insensitive to match DIRECTIVE_RE's `i` flag (e.g. `Impeccable-Disable`).
if (!/impeccable-disable/i.test(text)) return result;
// Split on `\n` only, exactly as detectText numbers lines, so directive line
// keys line up with finding `line` values (incl. on `\r`-only line endings).
// The directive regex excludes `\r`, so a trailing `\r` on `\r\n` files is
// never captured into the rule list.
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
DIRECTIVE_RE.lastIndex = 0;
let m;
while ((m = DIRECTIVE_RE.exec(lines[i])) !== null) {
const variant = m[1].toLowerCase();
const rules = parseRuleList(m[2]);
if (variant === 'disable') {
addRules(result.file, rules);
} else if (variant === 'disable-line') {
addRules(getSet(result.line, i + 1), rules);
} else {
// disable-next-line on line i+1 targets line i+2.
addRules(getSet(result.nextLine, i + 2), rules);
}
}
}
return result;
}
function setMatches(set, rule) {
return Boolean(set) && (set.has('*') || set.has(rule));
}
function isInlineIgnored(finding, directives) {
const rule = normalizeRule(finding && finding.antipattern);
if (!rule) return false;
if (setMatches(directives.file, rule)) return true;
const line = Number(finding && finding.line) || 0;
if (line > 0) {
if (setMatches(directives.line.get(line), rule)) return true;
if (setMatches(directives.nextLine.get(line), rule)) return true;
}
return false;
}
function hasDirectives(directives) {
return directives.file.size > 0 || directives.line.size > 0 || directives.nextLine.size > 0;
}
/**
* Drop findings waived by an inline directive in the same file's source text.
* Findings without a usable line number (e.g. static-HTML page-level findings)
* are only matched by whole-file directives which is the standalone-document
* case this primitive exists for.
*/
function applyInlineIgnores(findings, content) {
if (!Array.isArray(findings) || findings.length === 0) return findings;
const directives = parseInlineIgnores(content);
if (!hasDirectives(directives)) return findings;
return findings.filter((finding) => !isInlineIgnored(finding, directives));
}
export { parseInlineIgnores, applyInlineIgnores, isInlineIgnored };
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* `/impeccable hooks <on|off|status|reset>` manage the design hook runtime
* The Impeccable hooks command manages the design hook runtime
* via the `hook` key and shared detector ignores via the `detector` key in
* .impeccable/config.json / .impeccable/config.local.json.
*
@@ -21,6 +21,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { IMPECCABLE_COMMAND } from './lib/provider.mjs';
import {
getConfigPath,
@@ -75,7 +76,6 @@ const HOOK_MANIFEST_TARGETS = [
skillRel: '.agents/skills/impeccable',
destRel: '.codex/hooks.json',
manifest: () => ({
description: 'Impeccable design detector: runs after Edit/Write/apply_patch on UI files and surfaces findings as system reminders.',
hooks: {
PostToolUse: [
{
@@ -83,7 +83,7 @@ const HOOK_MANIFEST_TARGETS = [
hooks: [
{
type: 'command',
command: 'node "$(git rev-parse --show-toplevel)/.agents/skills/impeccable/scripts/hook.mjs"',
command: 'node ".agents/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
@@ -109,6 +109,28 @@ const HOOK_MANIFEST_TARGETS = [
},
}),
},
{
// GitHub Copilot reads repo-level hooks from `.github/hooks/*.json`. The same
// manifest is honored by the CLI (once committed to the default branch) and
// the cloud/app agent. Schema differs: lowercase `postToolUse`, flat entries,
// `bash`/`timeoutSec`, and a `matcher` regex against the `edit`/`create` tools.
provider: '.github',
skillRel: '.github/skills/impeccable',
destRel: '.github/hooks/impeccable.json',
manifest: () => ({
version: 1,
hooks: {
postToolUse: [
{
type: 'command',
matcher: 'edit|create|apply_patch',
bash: 'node "$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs"',
timeoutSec: TIMEOUT_SECONDS,
},
],
},
}),
},
];
function readRawConfigFile(filePath) {
@@ -163,7 +185,7 @@ function writeHookConfig(cwd, hookConfig, opts = {}) {
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const existingHook = stripDetectorKeys(hookSection(existing));
// Merge over the existing hook object so fields the merge helpers don't manage
// (consent, quiet, auditLog) survive a `/impeccable hooks` edit.
// (consent, quiet, auditLog) survive an Impeccable hooks edit.
const next = { ...existing, hook: { ...existingHook, ...hookConfig } };
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
@@ -400,7 +422,10 @@ function valueHasImpeccableHookMarker(value) {
function stripImpeccableHookEntry(entry) {
if (!entry || typeof entry !== 'object') return entry;
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
// `command`/`args`: Claude/Codex/Cursor. `bash`/`powershell`: GitHub Copilot's
// flat entry shape, where the marker lives under the shell-command keys.
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)
|| valueHasImpeccableHookMarker(entry.bash) || valueHasImpeccableHookMarker(entry.powershell)) {
return null;
}
if (!Array.isArray(entry.hooks)) return entry;
@@ -489,9 +514,9 @@ function parseIgnoreRuleArgs(args) {
function addIgnoreRule(cwd, args) {
const parsed = parseIgnoreRuleArgs(args);
const rule = parsed.rule;
if (!rule) throw new Error('Pass a rule id, e.g. /impeccable hooks ignore-rule side-tab');
if (!rule) throw new Error(`Pass a rule id, e.g. ${IMPECCABLE_COMMAND} hooks ignore-rule side-tab`);
if (rule === 'overused-font' && !parsed.allValues) {
throw new Error('overused-font is value-specific by default. Use /impeccable hooks ignore-value overused-font <font> for a confirmed font, or /impeccable hooks ignore-rule overused-font --all-values only when the user asked to ignore overused fonts generally.');
throw new Error(`overused-font is value-specific by default. Use ${IMPECCABLE_COMMAND} hooks ignore-value overused-font <font> for a confirmed font, or ${IMPECCABLE_COMMAND} hooks ignore-rule overused-font --all-values only when the user asked to ignore overused fonts generally.`);
}
const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
if (!config.ignoreRules.includes(rule)) config.ignoreRules.push(rule);
@@ -500,7 +525,7 @@ function addIgnoreRule(cwd, args) {
}
function addIgnoreFile(cwd, glob) {
if (!glob) throw new Error('Pass a glob, e.g. /impeccable hooks ignore-file "src/legacy/**"');
if (!glob) throw new Error(`Pass a glob, e.g. ${IMPECCABLE_COMMAND} hooks ignore-file "src/legacy/**"`);
const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob);
writeDetectorConfig(cwd, config);
@@ -545,7 +570,7 @@ function parseIgnoreValueArgs(args) {
function addIgnoreValue(cwd, args) {
const parsed = parseIgnoreValueArgs(args);
if (!parsed.rule || !parsed.value) {
throw new Error('Pass a rule id and value, e.g. /impeccable hooks ignore-value overused-font Inter');
throw new Error(`Pass a rule id and value, e.g. ${IMPECCABLE_COMMAND} hooks ignore-value overused-font Inter`);
}
if (parsed.shared && parsed.local) {
@@ -11,6 +11,7 @@
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
@@ -21,13 +22,17 @@ import {
appendDesignSystemNote,
designSystemOptions,
filterFindings,
isNativePlatform,
loadDetector,
matchConfiguredExtension,
matchesAnyGlob,
persistCache,
readCache,
readConfig,
renderTemplate,
resolveCacheCwd,
resolveProjectCwd,
resolveProjectPlatform,
truthy,
writeAuditLog,
} from './hook-lib.mjs';
@@ -332,6 +337,22 @@ function isInsideProject(filePath, cwd) {
}
}
// 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.
async function detectProposedHtml(detector, content, filePath, scanOptions) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-pre-'));
const tmpFile = path.join(dir, path.basename(filePath));
try {
fs.writeFileSync(tmpFile, content);
const findings = await detector.detectHtml(tmpFile, scanOptions);
// Findings carry the temp path; remap so file-scoped ignores still match.
return (findings || []).map((f) => (f && typeof f === 'object' ? { ...f, file: filePath } : f));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
function cursorBlockMessage(findings, filePath, config, cwd) {
const rendered = renderTemplate(findings, filePath, config, { cwd });
const blocked = rendered.replace(
@@ -379,9 +400,12 @@ async function main() {
return allow({ skipped: 'stdin-empty' });
}
const cwd = resolveProjectCwd(event);
const sessionCwd = resolveProjectCwd(event);
const started = Date.now();
const filePath = proposedFilePath(event, cwd);
const filePath = proposedFilePath(event, sessionCwd);
// Re-key config/cache to the edited file's project root when the session
// was launched from a non-project umbrella directory (issue #305).
const cwd = resolveCacheCwd(filePath, sessionCwd);
const audit = {
harness: 'cursor',
cwd,
@@ -394,9 +418,13 @@ async function main() {
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 });
// Config is read before the extension gate so `detector.extensions` entries
// (e.g. `.blade.php` template files, issue #316) can widen it.
const config = readConfig(cwd);
const ext = path.extname(filePath).toLowerCase();
audit.ext = ext;
if (!ALLOWED_EXTS.has(ext)) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
const configuredExt = matchConfiguredExtension(filePath, config.extensions);
audit.ext = configuredExt ? configuredExt.ext : ext;
if (!ALLOWED_EXTS.has(ext) && !configuredExt) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
const contentResult = proposedContent(event, cwd, filePath);
if (contentResult && typeof contentResult === 'object' && contentResult.skipped) {
@@ -405,9 +433,14 @@ async function main() {
const content = typeof contentResult === 'string' ? contentResult : '';
if (!content) return allow({ ...audit, skipped: 'no-proposed-content', durationMs: Date.now() - started });
const config = readConfig(cwd);
if (config.enabled === false) return allow({ ...audit, skipped: 'config-disabled', durationMs: Date.now() - started });
// Web rule engine, native project: stand aside (see resolveProjectPlatform).
const platform = resolveProjectPlatform(cwd);
if (isNativePlatform(platform)) {
return allow({ ...audit, skipped: 'native-platform', platform, durationMs: Date.now() - started });
}
const rel = relativePath(filePath, cwd);
if (matchesAnyGlob(rel, config.ignoreFiles) || matchesAnyGlob(filePath, config.ignoreFiles)) {
return allow({ ...audit, skipped: 'config-ignore-file', durationMs: Date.now() - started });
@@ -419,9 +452,16 @@ async function main() {
}
const scanOptions = designSystemOptions(config, detector, cwd);
// Mirror runHook's engine routing so template issues the HTML engine catches
// post-edit cannot slip past the pre-write gate.
const useHtmlEngine = configuredExt
? configuredExt.engine === 'html'
: (ext === '.html' || ext === '.htm');
let findings = [];
try {
findings = await detector.detectText(content, filePath, scanOptions);
findings = useHtmlEngine && typeof detector.detectHtml === 'function'
? await detectProposedHtml(detector, content, filePath, scanOptions)
: await detector.detectText(content, filePath, scanOptions);
} catch {
return allow({ ...audit, error: 'detector-threw', durationMs: Date.now() - started });
}
+267 -28
View File
@@ -9,15 +9,17 @@
* ENVELOPE_PREFIX, ALLOWED_EXTS, ACK_EXTS, SENSITIVE_PATH, GENERATED_PATH, TRUTHY
* truthy(value)
* readConfig(cwd) / DEFAULT_CONFIG / getConfigPath(cwd) / getLocalConfigPath(cwd)
* resolveProjectPlatform(cwd) / isNativePlatform(platform)
* normalizeIgnoreValue(value)
* readCache(cwd) / persistCache(cwd, cache)
* readCache(cwd) / persistCache(cwd, cache) / resolveCacheCwd(primaryFile, sessionCwd)
* bumpEditCount(cache, sessionId, filePath) -> number
* suppressionNotice(filePath)
* filterFindings(findings, content, ext, config)
* matchConfiguredExtension(filePath, extensions)
* dedupeAgainstCache(findings, cache, sessionId, filePath)
* renderTemplate(findings, filePath, config, opts)
* renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts)
* shouldEmitAckForFile(filePath)
* shouldEmitAckForFile(filePath, config?)
* writeAuditLog(env, entry)
* loadDetector() -> Promise<{ detectText, detectHtml }>
* matchesAnyGlob(filePath, globs)
@@ -35,8 +37,11 @@
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { pathToFileURL, fileURLToPath } from 'node:url';
import { extractPlatform, loadContext } from './context.mjs';
import { IMPECCABLE_COMMAND } from './lib/provider.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -77,6 +82,7 @@ export const DEFAULT_CONFIG = Object.freeze({
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
extensions: [],
limits: { maxFindings: 5, maxChars: 8000 },
});
@@ -134,6 +140,59 @@ export function resolveProjectCwd(event, fallback = process.cwd()) {
|| fallback;
}
function looksLikeProjectRoot(dir) {
return ['.git', 'package.json', '.impeccable'].some((marker) => {
try { return fs.existsSync(path.join(dir, marker)); } catch { return false; }
});
}
// Where `.impeccable/` (cache + config) lives for this event. Normally the
// session cwd, untouched. But when the agent was launched from an umbrella
// directory that is not itself a project (no .git, package.json, or
// .impeccable), key to the edited file's nearest project root instead, so a
// multi-project launch dir doesn't accumulate a shared cross-project cache
// (issue #305). Climbing stops at the home dir, falling back to the session
// cwd when no marker is found.
export function resolveCacheCwd(primaryFile, sessionCwd) {
const base = path.resolve(sessionCwd || process.cwd());
if (!primaryFile || typeof primaryFile !== 'string' || hasPathTraversal(primaryFile)) return base;
if (looksLikeProjectRoot(base)) return base;
let dir;
try {
dir = path.dirname(path.resolve(primaryFile));
} catch {
return base;
}
const home = path.resolve(os.homedir());
while (true) {
if (dir === home) return base;
if (looksLikeProjectRoot(dir)) return dir;
const parent = path.dirname(dir);
if (parent === dir) return base;
dir = parent;
}
}
// The detector's rules are web rules (HTML/CSS shapes), but a React Native or
// Flutter project is made of the exact extensions the hook watches (.tsx, .ts,
// .js), so without this gate every native screen edit would draw web-shaped
// findings that contradict the native platform references. PRODUCT.md's
// `## Platform` field decides: `ios` / `android` / `adaptive` projects skip
// the scan entirely. Resolution goes through loadContext so the hook reads the
// same PRODUCT.md the skill does (alternate context dirs, monorepo fallback).
export function resolveProjectPlatform(cwd) {
try {
const ctx = loadContext(cwd);
return extractPlatform(ctx && ctx.product);
} catch {
return null;
}
}
export function isNativePlatform(platform) {
return platform === 'ios' || platform === 'android' || platform === 'adaptive';
}
export function readConfig(cwd) {
const config = cloneDefaultConfig();
// Hook runtime settings live under `hook`; detector filters live under
@@ -168,6 +227,7 @@ function cloneDefaultConfig() {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
extensions: [],
designSystem: { ...DEFAULT_CONFIG.designSystem },
limits: { ...DEFAULT_CONFIG.limits },
};
@@ -190,9 +250,55 @@ function applyDetectorConfigSource(config, raw) {
if (Array.isArray(raw.ignoreValues)) {
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
}
if (Array.isArray(raw.extensions)) {
config.extensions = mergeExtensions(config.extensions, raw.extensions);
}
return config;
}
// Extra scanned extensions from `detector.extensions` config. Entries are
// `{ ext, engine }` (engine 'html' | 'text', default 'html' — the common case
// for server-side templates) or bare strings as shorthand. Extensions are
// matched against the end of the filename, not path.extname, so double
// extensions like `.blade.php` and `.html.erb` work (issue #316).
function normalizeExtensionEntries(entries) {
if (!Array.isArray(entries)) return [];
const out = [];
for (const entry of entries) {
const raw = typeof entry === 'string' ? entry : entry?.ext;
if (typeof raw !== 'string') continue;
let ext = raw.trim().toLowerCase();
if (!ext) continue;
if (!ext.startsWith('.')) ext = `.${ext}`;
const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html';
out.push({ ext, engine });
}
return out;
}
function mergeExtensions(existing, incoming) {
const map = new Map();
for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry);
for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry);
return Array.from(map.values());
}
export function matchConfiguredExtension(filePath, extensions) {
if (!Array.isArray(extensions) || extensions.length === 0) return null;
const name = path.basename(String(filePath || '')).toLowerCase();
if (!name) return null;
// The longest matching suffix wins, so `.blade.php` beats a broader `.php`
// entry regardless of config order.
let best = null;
for (const entry of normalizeExtensionEntries(extensions)) {
if (name.length > entry.ext.length && name.endsWith(entry.ext)
&& (!best || entry.ext.length > best.ext.length)) {
best = entry;
}
}
return best;
}
function applyConfigSource(config, raw) {
if (!raw || typeof raw !== 'object') return config;
if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) {
@@ -556,7 +662,7 @@ export function bumpEditCount(cache, sessionId, filePath) {
}
export function suppressionNotice(filePath) {
return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run /impeccable audit to revisit.`;
return `${ENVELOPE_PREFIX} Suppressing further design hints on ${filePath}. More than ${EDIT_COUNT_THRESHOLD} edits in this session reached. Run ${IMPECCABLE_COMMAND} audit to revisit.`;
}
// Glob → RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
@@ -628,11 +734,13 @@ export function filterFindings(findings, _content, _ext, config) {
function isIgnoredFindingValue(finding, ignoreValues) {
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
const rule = normalizeIgnoreRule(finding.antipattern);
if (!rule) return false;
// File-scoped wildcards suppress rules with no extractable value, such as side-tab.
const value = extractFindingIgnoreValue(finding);
if (!rule || !value) return false;
return ignoreValues.some((entry) => {
if (entry.rule !== rule) return false;
const wildcardValue = entry.value === '*';
if (entry.rule !== rule || (!wildcardValue && !ignoreValueMatches(rule, entry.value, value))) return false;
if (!wildcardValue && (!value || !ignoreValueMatches(rule, entry.value, value))) return false;
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
return findingMatchesScopedIgnoreFile(finding, entry.files);
});
@@ -770,7 +878,7 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
const lines = shown.map((f) => formatFindingLine(f));
const more = remaining > 0
? `... and ${remaining} more (see /impeccable audit).`
? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).`
: null;
const footer = directiveFooter(display);
@@ -814,7 +922,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
shownCount += shown.length;
const hidden = group.findings.length - shown.length;
if (hidden > 0) {
lines.push(`- ... ${hidden} more in ${display} (see /impeccable audit).`);
lines.push(`- ... ${hidden} more in ${display} (see ${IMPECCABLE_COMMAND} audit).`);
}
}
@@ -830,7 +938,7 @@ function clampGroupedToBudget(header, lines, footer, maxChars) {
const assemble = (linesArr, omitted) => [
header,
...linesArr,
...(omitted ? ['... and more (see /impeccable audit).'] : []),
...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []),
'',
footer,
].join('\n');
@@ -863,7 +971,7 @@ function clampToBudget(header, lines, more, footer, maxChars) {
let assembled = assemble(working, moreText);
while (assembled.length > maxChars && working.length > 1) {
working.pop();
moreText = '... and more (see /impeccable audit).';
moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`;
assembled = assemble(working, moreText);
}
if (assembled.length > maxChars) {
@@ -895,7 +1003,7 @@ function formatFindingIgnoreCommand(finding) {
const value = extractFindingIgnoreValueRaw(finding);
const valueArg = quoteCommandArg(value);
const reason = quoteCommandArg(`User confirmed ${value} is intentional`);
return `/impeccable hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`;
return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`;
}
function quoteCommandArg(value) {
@@ -959,13 +1067,114 @@ export function resolveTargetFiles(event, projectCwd) {
export function resolveHarness(env = {}, event = null) {
const explicit = env?.IMPECCABLE_HOOK_HARNESS;
if (explicit === 'cursor') return 'cursor';
if (explicit === 'github') return 'github';
if (explicit === 'claude' || explicit === 'codex') return 'claude';
// GitHub Copilot's postToolUse event uses camelCase `toolName`/`toolArgs` and
// has no `tool_name`/`tool_input`. That shape is the discriminator.
if (event && typeof event === 'object'
&& (typeof event.toolName === 'string' || event.toolArgs !== undefined)
&& event.tool_name === undefined && event.tool_input === undefined) {
return 'github';
}
if (typeof event?.conversation_id === 'string' && event.conversation_id) return 'cursor';
return 'claude';
}
// GitHub Copilot's postToolUse payload is
// { sessionId, timestamp, cwd, toolName, toolArgs, toolResult }
// mapped onto the internal `{ tool_name, tool_input, cwd, session_id }` shape.
// `toolArgs` shape depends on the tool: the `edit`/`create`/`view` tools send a
// JSON *string* (double-encoded) carrying the file under `path`, e.g.
// "{\"path\":\"/abs/app.tsx\",\"old_str\":\"...\",\"new_str\":\"...\"}",
// while `apply_patch` sends a raw OpenAI-format patch string (handled below in
// normalizeGitHubEvent). The detector reads the file from disk after the tool
// ran, so only the path (not the proposed content) is needed here.
export function parseGitHubToolArgs(toolArgs) {
if (toolArgs && typeof toolArgs === 'object' && !Array.isArray(toolArgs)) return toolArgs;
if (typeof toolArgs === 'string' && toolArgs.trim()) {
try {
const parsed = JSON.parse(toolArgs);
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch {
return {};
}
}
return {};
}
// Copilot's `apply_patch` tool (used by interactive sessions and the cloud
// agent) sends a raw OpenAI-format patch string in toolArgs, not JSON:
// *** Begin Patch
// *** Add File: /abs/app.css
// +body { ... }
// *** End Patch
// The `view`/`edit`/`create` tools (seen in `copilot -p` runs) instead send a
// JSON string with the path under `path`. Both must map onto the internal shape.
const APPLY_PATCH_MARKER = /\*\*\* (?:Begin Patch|Add File:|Update File:|Delete File:)/;
function looksLikeApplyPatch(rawArgs) {
if (typeof rawArgs !== 'string' || !APPLY_PATCH_MARKER.test(rawArgs)) return false;
// Guard against an edit/create payload whose edited *content* happens to
// contain patch markers: that payload is a JSON object string, whereas a real
// apply_patch payload is a raw patch string that does not parse as JSON. Only
// treat non-JSON-object strings as apply_patch so edit events still get their
// `path` extracted.
try {
const parsed = JSON.parse(rawArgs);
if (parsed && typeof parsed === 'object') return false;
} catch { /* not JSON → genuine raw patch */ }
return true;
}
function applyPatchText(rawArgs) {
if (typeof rawArgs === 'string') {
if (APPLY_PATCH_MARKER.test(rawArgs)) return rawArgs;
// Defensive: a future Copilot build might JSON-wrap the patch.
const parsed = parseGitHubToolArgs(rawArgs);
return parsed.patch || parsed.input || parsed.command || '';
}
if (rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)) {
return rawArgs.patch || rawArgs.input || rawArgs.command || '';
}
return '';
}
function normalizeGitHubEvent(event, projectCwd) {
const cwd = event.cwd || envProjectDir(projectCwd) || projectCwd;
const sessionId = event.sessionId || event.session_id || 'unknown';
const toolName = event.toolName || event.tool_name || null;
const toolInput = event.tool_input && typeof event.tool_input === 'object' ? { ...event.tool_input } : {};
const rawArgs = event.toolArgs;
let normalizedToolName = toolName;
if (toolName === 'apply_patch' || looksLikeApplyPatch(rawArgs)) {
// resolveTargetFiles() reads the touched paths from tool_input.command when
// tool_name is 'apply_patch', so normalize the name even if a future build
// sends the patch under a different tool label.
const patch = applyPatchText(rawArgs);
if (patch) {
toolInput.command = patch;
normalizedToolName = 'apply_patch';
}
} else {
const args = parseGitHubToolArgs(rawArgs);
const filePath = args.path || args.file_path || args.filePath || args.target_file;
if (typeof filePath === 'string' && filePath) toolInput.file_path = filePath;
}
return {
...event,
cwd,
session_id: sessionId,
tool_name: normalizedToolName,
tool_input: toolInput,
};
}
export function normalizeHookEvent(event, projectCwd, harness = 'claude') {
if (!event || typeof event !== 'object' || harness !== 'cursor') return event;
if (!event || typeof event !== 'object') return event;
if (harness === 'github') return normalizeGitHubEvent(event, projectCwd);
if (harness !== 'cursor') return event;
const cwd = event.cwd
|| (Array.isArray(event.workspace_roots) && event.workspace_roots[0])
@@ -1200,12 +1409,12 @@ export function setDetectorForTesting(impl) {
// session" so the model knows it's a re-mind, not a new finding.
// ────────────────────────────────────────────────────────────────────────
const STEER_LINE = 'Keep typography hierarchy, spacing rhythm, and color contrast intentional on the next change.';
const STEER_LINE = 'That does not mean the design is good: keep following the project design system and the impeccable skill guidance.';
export function renderCleanAck(filePath, opts = {}) {
const cwd = opts.cwd || process.cwd();
const display = relativize(filePath, cwd);
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No anti-patterns. ${STEER_LINE}`;
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. No deterministic design-quality issues found. ${STEER_LINE}`;
}
export function renderPendingAck(filePath, knownFindings, opts = {}) {
@@ -1218,8 +1427,12 @@ export function renderPendingAck(filePath, knownFindings, opts = {}) {
return `${ENVELOPE_PREFIX} Design hook scanned ${display}. Still has ${count} finding(s) flagged earlier this session (${sample}${more}). Handle them before finalizing — the previous reminder still applies.`;
}
export function shouldEmitAckForFile(filePath) {
return ACK_EXTS.has(path.extname(String(filePath || '')).toLowerCase());
export function shouldEmitAckForFile(filePath, config = null) {
if (ACK_EXTS.has(path.extname(String(filePath || '')).toLowerCase())) return true;
// Configured html-engine extensions are declared UI markup, so they get the
// clean/pending acks; text-engine ones stay quiet like plain .ts/.js.
const configured = matchConfiguredExtension(filePath, config?.extensions);
return Boolean(configured && configured.engine === 'html');
}
export function designSystemOptions(config, detector, projectCwd) {
@@ -1235,7 +1448,7 @@ export function designSystemOptions(config, detector, projectCwd) {
export function appendDesignSystemNote(text, scanOptions) {
if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text;
return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run /impeccable document to refresh the design-system sidecar.`;
return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`;
}
// The directive footer is the part of the hook output that steers model
@@ -1252,16 +1465,16 @@ export function appendDesignSystemNote(text, scanOptions) {
// raw envelope. Asking the model to surface the resolution in its
// reply is the cheapest way to make the feedback loop visible.
function directiveFooter(display, opts = {}) {
const ignoreFileCommand = `/impeccable hooks ignore-file ${quoteCommandArg(display)}`;
const ignoreFileCommand = `${IMPECCABLE_COMMAND} hooks ignore-file ${quoteCommandArg(display)}`;
const fileIgnoreGuidance = opts.grouped
? 'run `/impeccable hooks ignore-file <path>` for the specific file'
? `run \`${IMPECCABLE_COMMAND} hooks ignore-file <path>\` for the specific file`
: `run \`${ignoreFileCommand}\``;
return [
'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',
'',
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
'',
`Do not change intentional design just to satisfy the hook. Do not add source comments such as \`impeccable: ignore\`; those pollute the code and do not suppress hook findings. Persist hook ignores only after the user explicitly confirms the finding is intentional. Prefer the narrowest persisted exception: run the exact \`/impeccable hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`/impeccable hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`/impeccable hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run /impeccable audit for the full pass.`,
`Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable <rule>\` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`${IMPECCABLE_COMMAND} hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`${IMPECCABLE_COMMAND} hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`${IMPECCABLE_COMMAND} hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`,
].join('\n');
}
@@ -1301,9 +1514,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
event = normalizeHookEvent(event, cwd, harness);
audit.harness = harness;
const projectCwd = event.cwd || cwd;
const sessionCwd = event.cwd || cwd;
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, sessionCwd), sessionCwd);
const projectCwd = resolveCacheCwd(primaryFiles[0], sessionCwd);
audit.cwd = projectCwd;
const primaryFiles = normalizeScanTargets(resolveTargetFiles(event, projectCwd), projectCwd);
const primaryFileSet = new Set(primaryFiles);
const targetFiles = expandScanTargets(primaryFiles, projectCwd);
audit.session = event.session_id || null;
@@ -1318,11 +1532,16 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
return result({ skipped: 'config-disabled', durationMs: Date.now() - started });
}
const platform = resolveProjectPlatform(projectCwd);
if (isNativePlatform(platform)) {
return result({ skipped: 'native-platform', platform, durationMs: Date.now() - started });
}
const cache = readCache(projectCwd);
const sessionId = event.session_id || 'unknown';
const det = detector || await loadDetector();
if (!det || typeof det.detectText !== 'function') {
persistCache(projectCwd, cache);
// Cache is not mutated yet at this point; nothing to persist.
return result({ skipped: 'detector-missing', durationMs: Date.now() - started });
}
const scanOptions = designSystemOptions(config, det, projectCwd);
@@ -1334,6 +1553,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
let detectorThrewAny = false;
let lastSkip = 'no-scannable-file';
let suppressedHit = false;
let cacheDirty = false;
for (const filePath of targetFiles) {
audit.file = filePath;
@@ -1348,8 +1568,9 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
}
const ext = path.extname(filePath).toLowerCase();
audit.ext = ext;
if (!ALLOWED_EXTS.has(ext)) {
const configuredExt = matchConfiguredExtension(filePath, config.extensions);
audit.ext = configuredExt ? configuredExt.ext : ext;
if (!ALLOWED_EXTS.has(ext) && !configuredExt) {
lastSkip = 'extension';
continue;
}
@@ -1366,6 +1587,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
if (primaryFileSet.has(filePath)) {
const editCount = bumpEditCount(cache, sessionId, filePath);
cacheDirty = true;
audit.editCount = editCount;
if (editCount > EDIT_COUNT_THRESHOLD) {
@@ -1382,7 +1604,10 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
const content = fs.readFileSync(filePath, 'utf-8');
let findings;
let detectorThrew = false;
if ((ext === '.html' || ext === '.htm') && typeof det.detectHtml === 'function') {
const useHtmlEngine = configuredExt
? configuredExt.engine === 'html'
: (ext === '.html' || ext === '.htm');
if (useHtmlEngine && typeof det.detectHtml === 'function') {
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
} else {
try { findings = await det.detectText(content, filePath, scanOptions); } catch { findings = []; detectorThrew = true; }
@@ -1395,6 +1620,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
if (fresh.length > 0) {
rememberFindings(cache, sessionId, filePath, fresh);
cacheDirty = true;
freshGroups.push({ filePath, findings: fresh });
continue;
}
@@ -1412,7 +1638,15 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
}
}
persistCache(projectCwd, cache);
// Persist only when the write is earned: fresh findings justify creating
// `.impeccable/` (dedup and suppression need it), and an already-present
// `.impeccable/` dir marks a project that opted in. A non-UI edit, or a
// clean UI edit in a project with no Impeccable footprint, must be a
// no-op on disk (issues #344, #305).
if (freshGroups.length > 0
|| (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) {
persistCache(projectCwd, cache);
}
if (freshGroups.length > 0) {
const firstGroup = freshGroups[0];
@@ -1447,7 +1681,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
}
if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath)) {
if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) {
const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions);
return {
exitCode: 0,
@@ -1481,7 +1715,7 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
};
}
if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath)) {
if (cleanWinner && shouldEmitAckForFile(cleanWinner.filePath, config)) {
const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions);
return {
exitCode: 0,
@@ -1520,6 +1754,11 @@ export function payload(text, eventName = 'PostToolUse', harness = 'claude') {
if (harness === 'cursor') {
return JSON.stringify({ additional_context: text });
}
// GitHub Copilot's postToolUse hook injects context via a top-level
// `additionalContext` string (alongside an optional `modifiedResult`).
if (harness === 'github') {
return JSON.stringify({ additionalContext: text });
}
return JSON.stringify({
hookSpecificOutput: { hookEventName: eventName, additionalContext: text },
});
@@ -0,0 +1,640 @@
/**
* CLI-side reader/writer for the unified `.impeccable` config.
*
* The CLI (published to npm) and the skill scripts (bundled into the install)
* live in separate trees and cannot share runtime code, so this duplicates a
* small slice of skill/scripts/hook-lib.mjs the config-path layout, detector
* ignore semantics, and the `.git/info/exclude` handling. Keep the schema,
* ignore filtering, and exclude marker in sync if either side changes.
*
* Schema (config.json shared / config.local.json gitignored, per-developer):
* {
* "detector": { "ignoreRules": [], "ignoreFiles": [], "ignoreValues": [], "designSystem": { "enabled": true } },
* "hook": { "consent": "accepted" | "declined", ... },
* "updateCheck": bool
* }
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
import { join, dirname, isAbsolute, relative, resolve, sep } from 'node:path';
export function getConfigPath(root) {
return join(root, '.impeccable', 'config.json');
}
export function getLocalConfigPath(root) {
return join(root, '.impeccable', 'config.local.json');
}
function safeReadJson(filePath) {
try {
const raw = JSON.parse(readFileSync(filePath, 'utf-8'));
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : null;
} catch {
return null;
}
}
function hookSection(raw) {
return raw && raw.hook && typeof raw.hook === 'object' && !Array.isArray(raw.hook) ? raw.hook : null;
}
function detectorSection(raw) {
return raw && raw.detector && typeof raw.detector === 'object' && !Array.isArray(raw.detector) ? raw.detector : null;
}
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
const DEFAULT_DETECTION_CONFIG = Object.freeze({
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { enabled: true },
});
function cloneDetectionConfig() {
return {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
designSystem: { ...DEFAULT_DETECTION_CONFIG.designSystem },
};
}
function cloneRawDetectionConfig() {
return {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
};
}
function applyDetectionConfigSource(config, raw) {
if (!raw || typeof raw !== 'object') return config;
if (raw.designSystem && typeof raw.designSystem === 'object' && !Array.isArray(raw.designSystem)) {
config.designSystem = {
...config.designSystem,
enabled: raw.designSystem.enabled === false ? false : true,
};
}
if (Array.isArray(raw.ignoreRules)) {
config.ignoreRules = uniqueStrings([...config.ignoreRules, ...raw.ignoreRules]);
}
if (Array.isArray(raw.ignoreFiles)) {
config.ignoreFiles = uniqueStrings([...config.ignoreFiles, ...raw.ignoreFiles]);
}
if (Array.isArray(raw.ignoreValues)) {
config.ignoreValues = mergeIgnoreValues(config.ignoreValues, raw.ignoreValues);
}
return config;
}
function uniqueStrings(values) {
return Array.from(new Set(values.map(String)));
}
/**
* Detector filters shared by `npx impeccable detect` and the design hook.
* `hook.enabled` remains hook lifecycle state; manual CLI scans still run when
* the hook is disabled, but they honor the same ignore rules and design-system
* toggle.
*/
export function readDetectionConfig(root) {
const config = cloneDetectionConfig();
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const raw = safeReadJson(filePath);
// Back-compat: old builds stored detector filters under hook.*.
applyDetectionConfigSource(config, hookSection(raw));
applyDetectionConfigSource(config, detectorSection(raw));
}
return config;
}
export function readRawDetectionConfig(root, opts = {}) {
const raw = safeReadJson(opts.local ? getLocalConfigPath(root) : getConfigPath(root));
const config = cloneRawDetectionConfig();
applyDetectionConfigSource(config, hookSection(raw));
applyDetectionConfigSource(config, detectorSection(raw));
return config;
}
export function writeDetectionConfig(root, detectorConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(root) : getConfigPath(root);
if (opts.local) ensureConfigGitExclude(root);
const existing = safeReadJson(filePath) || {};
const existingHook = hookSection(existing);
const nextHook = stripDetectorKeys(existingHook);
const nextDetector = {
...(detectorSection(existing) || {}),
...normalizeDetectionConfigForWrite(detectorConfig),
};
const next = {
...existing,
detector: nextDetector,
};
if (nextHook && Object.keys(nextHook).length > 0) {
next.hook = nextHook;
} else {
delete next.hook;
}
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
return filePath;
}
function normalizeDetectionConfigForWrite(config) {
const out = {};
if (Array.isArray(config?.ignoreRules)) {
out.ignoreRules = uniqueStrings(config.ignoreRules.map((rule) => normalizeIgnoreRule(rule)).filter(Boolean));
}
if (Array.isArray(config?.ignoreFiles)) {
out.ignoreFiles = uniqueStrings(config.ignoreFiles.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()));
}
out.ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
if (config?.designSystem && typeof config.designSystem === 'object' && !Array.isArray(config.designSystem)) {
out.designSystem = {
enabled: config.designSystem.enabled === false ? false : true,
};
}
return out;
}
function stripDetectorKeys(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const out = {};
for (const [key, value] of Object.entries(raw)) {
if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
}
return out;
}
export function normalizeIgnoreValue(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
function normalizeIgnoreRule(rule) {
return String(rule || '').trim().toLowerCase();
}
function colorIgnoreKey(value) {
const color = parseIgnoreColor(value);
if (!color) return '';
return `${color.r},${color.g},${color.b},${Math.round(color.a * 255)}`;
}
function parseIgnoreColor(value) {
const text = String(value || '').trim().toLowerCase();
if (!text) return null;
const hex = text.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i);
if (hex) return parseHexIgnoreColor(hex[1]);
const rgb = text.match(/^rgba?\((.*)\)$/i);
if (rgb) {
const parts = splitColorArgs(rgb[1]);
if (parts.length < 3 || parts.length > 4) return null;
const r = parseRgbChannel(parts[0]);
const g = parseRgbChannel(parts[1]);
const b = parseRgbChannel(parts[2]);
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
if ([r, g, b, a].some((v) => v === null)) return null;
return { r, g, b, a };
}
const hsl = text.match(/^hsla?\((.*)\)$/i);
if (hsl) {
const parts = splitColorArgs(hsl[1]);
if (parts.length < 3 || parts.length > 4) return null;
const h = parseHueChannel(parts[0]);
const s = parsePercentChannel(parts[1]);
const l = parsePercentChannel(parts[2]);
const a = parts[3] === undefined ? 1 : parseAlphaChannel(parts[3]);
if ([h, s, l, a].some((v) => v === null)) return null;
return hslToRgb(h, s, l, a);
}
return null;
}
function parseHexIgnoreColor(hex) {
if (hex.length === 3 || hex.length === 4) {
const r = parseInt(hex[0] + hex[0], 16);
const g = parseInt(hex[1] + hex[1], 16);
const b = parseInt(hex[2] + hex[2], 16);
const a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
return { r, g, b, a };
}
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
return { r, g, b, a };
}
function splitColorArgs(body) {
const text = String(body || '').trim();
if (!text) return [];
if (text.includes(',')) {
const parts = text.split(',').map((part) => part.trim()).filter(Boolean);
const last = parts[parts.length - 1];
if (last && last.includes('/')) {
const split = last.split('/').map((part) => part.trim()).filter(Boolean);
return [...parts.slice(0, -1), ...split];
}
return parts;
}
return text.replace(/\s*\/\s*/g, ' / ').split(/\s+/).filter((part) => part && part !== '/');
}
function parseRgbChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const scaled = match[2] ? value * 2.55 : value;
if (scaled < 0 || scaled > 255) return null;
return Math.round(scaled);
}
function parseAlphaChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const alpha = match[2] ? value / 100 : value;
return alpha >= 0 && alpha <= 1 ? alpha : null;
}
function parseHueChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(deg|rad|turn|grad)?$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
const unit = match[2] || 'deg';
if (unit === 'turn') return value * 360;
if (unit === 'rad') return value * (180 / Math.PI);
if (unit === 'grad') return value * 0.9;
return value;
}
function parsePercentChannel(raw) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)%$/);
if (!match) return null;
const value = Number.parseFloat(match[1]);
if (!Number.isFinite(value)) return null;
return value >= 0 && value <= 100 ? value / 100 : null;
}
function hslToRgb(hue, saturation, lightness, alpha) {
const h = (((hue % 360) + 360) % 360) / 360;
if (saturation === 0) {
const gray = clampByte(Math.round(lightness * 255));
return { r: gray, g: gray, b: gray, a: alpha };
}
const q = lightness < 0.5
? lightness * (1 + saturation)
: lightness + saturation - lightness * saturation;
const p = 2 * lightness - q;
const toRgb = (t) => {
let channel = t;
if (channel < 0) channel += 1;
if (channel > 1) channel -= 1;
if (channel < 1 / 6) return p + (q - p) * 6 * channel;
if (channel < 1 / 2) return q;
if (channel < 2 / 3) return p + (q - p) * (2 / 3 - channel) * 6;
return p;
};
return {
r: clampByte(Math.round(toRgb(h + 1 / 3) * 255)),
g: clampByte(Math.round(toRgb(h) * 255)),
b: clampByte(Math.round(toRgb(h - 1 / 3) * 255)),
a: alpha,
};
}
function clampByte(value) {
return Math.min(255, Math.max(0, value));
}
function ignoreValueMatches(rule, entryValue, findingValue) {
if (entryValue === findingValue) return true;
if (rule !== 'design-system-color') return false;
const entryColor = colorIgnoreKey(entryValue);
return Boolean(entryColor && entryColor === colorIgnoreKey(findingValue));
}
export function normalizeIgnoreValueEntries(entries) {
if (!Array.isArray(entries)) return [];
const out = [];
for (const entry of entries) {
if (!entry || typeof entry !== 'object') continue;
const rule = normalizeIgnoreRule(entry.rule);
const value = normalizeIgnoreValue(entry.value);
if (!rule || !value) continue;
const normalized = { rule, value };
const files = uniqueStrings([
...(typeof entry.file === 'string' && entry.file.trim() ? [entry.file.trim()] : []),
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
]);
if (files.length > 0) normalized.files = files;
if (typeof entry.reason === 'string' && entry.reason.trim()) {
normalized.reason = entry.reason.trim();
}
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
normalized.createdAt = entry.createdAt.trim();
}
out.push(normalized);
}
return out;
}
function mergeIgnoreValues(existing, incoming) {
const map = new Map();
for (const entry of normalizeIgnoreValueEntries(existing)) {
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
}
for (const entry of normalizeIgnoreValueEntries(incoming)) {
map.set(`${entry.rule}\0${entry.value}\0${ignoreValueFilesKey(entry.files)}`, entry);
}
return Array.from(map.values());
}
function ignoreValueFilesKey(files) {
return Array.isArray(files) && files.length > 0 ? files.join('\x1f') : '';
}
// Glob -> RegExp. Supports `**`, `*`, `?`, and `{a,b}` alternation.
function globToRegex(glob) {
let re = '^';
let i = 0;
while (i < glob.length) {
const c = glob[i];
if (c === '*') {
if (glob[i + 1] === '*') {
re += '.*';
i += 2;
if (glob[i] === '/') i += 1;
} else {
re += '[^/]*';
i += 1;
}
} else if (c === '?') {
re += '[^/]';
i += 1;
} else if (c === '{') {
const end = glob.indexOf('}', i);
if (end === -1) { re += '\\{'; i += 1; continue; }
const parts = glob.slice(i + 1, end).split(',').map((p) => p.replace(/[.+^$()|[\]\\]/g, '\\$&'));
re += `(?:${parts.join('|')})`;
i = end + 1;
} else if (/[.+^$()|[\]\\]/.test(c)) {
re += `\\${c}`;
i += 1;
} else {
re += c;
i += 1;
}
}
re += '$';
return new RegExp(re);
}
export function matchesAnyGlob(filePath, globs) {
if (!Array.isArray(globs) || globs.length === 0) return false;
const normalized = String(filePath || '').split(sep).join('/');
for (const glob of globs) {
try {
const re = globToRegex(String(glob));
if (re.test(normalized)) return true;
const base = normalized.split('/').pop();
if (re.test(base)) return true;
} catch {
/* malformed glob, skip */
}
}
return false;
}
export function shouldIgnoreDetectionFile(filePath, root, config) {
const globs = config?.ignoreFiles || [];
if (!Array.isArray(globs) || globs.length === 0) return false;
const raw = String(filePath || '').trim();
if (!raw) return false;
if (matchesAnyGlob(raw, globs)) return true;
try {
const abs = isAbsolute(raw) ? raw : resolve(root, raw);
if (matchesAnyGlob(abs, globs)) return true;
const rel = relative(root, abs);
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
return matchesAnyGlob(rel, globs);
}
} catch {
/* ignore */
}
return false;
}
export function filterDetectionFindings(findings, config) {
if (!Array.isArray(findings) || findings.length === 0) return [];
const ignoreRules = new Set((config?.ignoreRules || []).map((rule) => normalizeIgnoreRule(rule)));
const ignoreValues = normalizeIgnoreValueEntries(config?.ignoreValues || []);
return findings.filter((finding) => {
if (!finding || typeof finding !== 'object') return false;
if (ignoreRules.has(normalizeIgnoreRule(finding.antipattern))) return false;
if (isIgnoredFindingValue(finding, ignoreValues)) return false;
return true;
});
}
function isIgnoredFindingValue(finding, ignoreValues) {
if (!Array.isArray(ignoreValues) || ignoreValues.length === 0) return false;
const rule = normalizeIgnoreRule(finding.antipattern);
if (!rule) return false;
// File-scoped wildcards suppress rules with no extractable value, such as side-tab.
const value = extractFindingIgnoreValue(finding);
return ignoreValues.some((entry) => {
if (entry.rule !== rule) return false;
const wildcardValue = entry.value === '*';
if (!wildcardValue && (!value || !ignoreValueMatches(rule, entry.value, value))) return false;
if (!Array.isArray(entry.files) || entry.files.length === 0) return !wildcardValue;
return findingMatchesScopedIgnoreFile(finding, entry.files);
});
}
function findingMatchesScopedIgnoreFile(finding, globs) {
const filePath = String(finding?.file || '').trim();
if (!filePath) return false;
if (matchesAnyGlob(filePath, globs)) return true;
const normalized = filePath.split(sep).join('/');
const parts = normalized.split('/').filter(Boolean);
for (let i = 0; i < parts.length; i++) {
const suffix = parts.slice(i).join('/');
if (matchesAnyGlob(suffix, globs)) return true;
}
return false;
}
export function extractFindingIgnoreValue(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
const directValueRules = new Set([
'overused-font',
'bounce-easing',
'design-system-font',
'design-system-color',
'design-system-radius',
]);
if (!directValueRules.has(rule)) return '';
return normalizeIgnoreValue(extractFindingIgnoreValueRaw(finding, rule));
}
function extractFindingIgnoreValueRaw(finding, rule = normalizeIgnoreRule(finding?.antipattern)) {
const direct = cleanIgnoreValueDisplay(finding.ignoreValue || finding.value || '');
if (direct) return direct;
const candidates = [finding.detail, finding.snippet].filter((v) => typeof v === 'string' && v);
for (const text of candidates) {
if (rule === 'bounce-easing') {
const motion = extractMotionIgnoreValue(text);
if (motion) return motion;
continue;
}
const primary = text.match(/Primary font:\s*([^()\n;]+)/i);
if (primary) return cleanIgnoreValueDisplay(primary[1]);
const family = text.match(/font-family\s*:\s*["']?([^'",;\n]+)/i);
if (family) return cleanIgnoreValueDisplay(family[1]);
const google = text.match(/[?&]family=([^&:;\n]+)/i);
if (google) {
try {
return cleanIgnoreValueDisplay(decodeURIComponent(google[1]));
} catch {
return cleanIgnoreValueDisplay(google[1]);
}
}
}
return '';
}
function extractMotionIgnoreValue(text) {
const tailwind = text.match(/\banimate-bounce\b/i);
if (tailwind) return cleanIgnoreValueDisplay(tailwind[0]);
const bezier = text.match(/cubic-bezier\([^)]+\)/i);
if (bezier) return cleanIgnoreValueDisplay(bezier[0]);
const animation = text.match(/animation(?:-name)?\s*:\s*([^;\n]+)/i);
if (animation) {
const token = animation[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
if (token) return cleanIgnoreValueDisplay(token);
}
return '';
}
function cleanIgnoreValueDisplay(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ');
}
/**
* The recorded design-hook decision: 'accepted' | 'declined' | undefined.
* config.local.json (per-developer) overrides config.json.
*/
export function getHookConsent(root) {
let consent;
for (const filePath of [getConfigPath(root), getLocalConfigPath(root)]) {
const hook = hookSection(safeReadJson(filePath));
if (hook && (hook.consent === 'accepted' || hook.consent === 'declined')) consent = hook.consent;
}
return consent;
}
/**
* Persist the per-developer decision to config.local.json, preserving any
* sibling keys, and ensure the file is gitignored.
*/
export function setHookConsent(root, value) {
const filePath = getLocalConfigPath(root);
const existing = safeReadJson(filePath) || {};
const hook = hookSection(existing) || {};
const next = { ...existing, hook: { ...hook, consent: value } };
mkdirSync(dirname(filePath), { recursive: true });
writeFileSync(filePath, `${JSON.stringify(next, null, 2)}\n`);
ensureConfigGitExclude(root);
return filePath;
}
const EXCLUDE_OPEN = '# impeccable-config-ignore-start';
const EXCLUDE_CLOSE = '# impeccable-config-ignore-end';
const EXCLUDE_PATTERNS = ['.impeccable/config.local.json'];
/**
* Add config.local.json to `.git/info/exclude` so a developer's decision is
* never committed. Idempotent via marker comments. Best-effort; returns false
* when there is no resolvable git dir.
*/
export function ensureConfigGitExclude(root) {
try {
const gitDir = resolveGitDir(root);
if (!gitDir) return false;
const target = join(gitDir, 'info', 'exclude');
const existing = existsSync(target) ? readFileSync(target, 'utf-8') : '';
const block = [EXCLUDE_OPEN, ...EXCLUDE_PATTERNS, EXCLUDE_CLOSE].join('\n');
const markerRe = new RegExp(`${escapeRegExp(EXCLUDE_OPEN)}[\\s\\S]*?${escapeRegExp(EXCLUDE_CLOSE)}`);
let updated;
if (markerRe.test(existing)) {
updated = existing.replace(markerRe, block);
} else {
const prefix = existing.length === 0 ? '' : existing.endsWith('\n') ? existing : `${existing}\n`;
updated = `${prefix}${block}\n`;
}
if (updated !== existing) {
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, updated);
}
return true;
} catch {
return false;
}
}
function resolveGitDir(root) {
const dotGit = join(root, '.git');
if (!existsSync(dotGit)) return null;
try {
if (statSync(dotGit).isDirectory()) return dotGit;
// A `.git` file (worktree/submodule) points elsewhere: "gitdir: <path>".
const match = readFileSync(dotGit, 'utf-8').match(/gitdir:\s*(.+)/);
if (match) {
const resolved = match[1].trim();
return isAbsolute(resolved) ? resolved : join(root, resolved);
}
} catch {
/* fall through */
}
return null;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -1,50 +1,53 @@
import fs from 'node:fs';
import path from 'node:path';
import { resolveProjectRoot } from '../context.mjs';
export { IMPECCABLE_COMMAND_PREFIX } from './provider.mjs';
export const IMPECCABLE_DIR = '.impeccable';
export const LIVE_DIR = 'live';
export const CRITIQUE_DIR = 'critique';
export function getImpeccableDir(cwd = process.cwd()) {
return path.join(cwd, IMPECCABLE_DIR);
export function getImpeccableDir(cwd = process.cwd(), options = {}) {
return path.join(resolveProjectRoot(cwd, options), IMPECCABLE_DIR);
}
export function getDesignSidecarPath(cwd = process.cwd()) {
return path.join(getImpeccableDir(cwd), 'design.json');
export function getDesignSidecarPath(cwd = process.cwd(), options = {}) {
return path.join(getImpeccableDir(cwd, options), 'design.json');
}
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) {
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) {
const projectRoot = resolveProjectRoot(cwd, options);
const candidates = [
getDesignSidecarPath(cwd),
path.join(cwd, 'DESIGN.json'),
getDesignSidecarPath(cwd, options),
path.join(projectRoot, 'DESIGN.json'),
];
const contextLegacy = path.join(contextDir, 'DESIGN.json');
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
return candidates;
}
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
return firstExisting(getDesignSidecarCandidates(cwd, contextDir));
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) {
return firstExisting(getDesignSidecarCandidates(cwd, contextDir, options));
}
export function getLiveDir(cwd = process.cwd()) {
return path.join(getImpeccableDir(cwd), LIVE_DIR);
export function getLiveDir(cwd = process.cwd(), options = {}) {
return path.join(getImpeccableDir(cwd, options), LIVE_DIR);
}
export function getLiveConfigPath(cwd = process.cwd()) {
return path.join(getLiveDir(cwd), 'config.json');
export function getLiveConfigPath(cwd = process.cwd(), options = {}) {
return path.join(getLiveDir(cwd, options), 'config.json');
}
export function getLegacyLiveConfigPath(scriptsDir) {
return path.join(scriptsDir, 'config.json');
}
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) {
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env, targetPath } = {}) {
if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) {
const configured = env.IMPECCABLE_LIVE_CONFIG.trim();
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
}
const primary = getLiveConfigPath(cwd);
const primary = getLiveConfigPath(cwd, { targetPath });
if (fs.existsSync(primary)) return primary;
if (scriptsDir) {
const legacy = getLegacyLiveConfigPath(scriptsDir);
@@ -53,16 +56,16 @@ export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = p
return primary;
}
export function getLiveServerPath(cwd = process.cwd()) {
return path.join(getLiveDir(cwd), 'server.json');
export function getLiveServerPath(cwd = process.cwd(), options = {}) {
return path.join(getLiveDir(cwd, options), 'server.json');
}
export function getLegacyLiveServerPath(cwd = process.cwd()) {
return path.join(cwd, '.impeccable-live.json');
export function getLegacyLiveServerPath(cwd = process.cwd(), options = {}) {
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live.json');
}
export function readLiveServerInfo(cwd = process.cwd()) {
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
export function readLiveServerInfo(cwd = process.cwd(), options = {}) {
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
try {
const info = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
if (info && typeof info.pid === 'number' && !isLiveServerPidReachable(info.pid)) {
@@ -88,37 +91,37 @@ export function isLiveServerPidReachable(pid) {
}
}
export function writeLiveServerInfo(cwd = process.cwd(), info) {
const filePath = getLiveServerPath(cwd);
export function writeLiveServerInfo(cwd = process.cwd(), info, options = {}) {
const filePath = getLiveServerPath(cwd, options);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(info));
return filePath;
}
export function removeLiveServerInfo(cwd = process.cwd()) {
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
export function removeLiveServerInfo(cwd = process.cwd(), options = {}) {
for (const filePath of [getLiveServerPath(cwd, options), getLegacyLiveServerPath(cwd, options)]) {
try { fs.unlinkSync(filePath); } catch {}
}
}
export function getLiveSessionsDir(cwd = process.cwd()) {
return path.join(getLiveDir(cwd), 'sessions');
export function getLiveSessionsDir(cwd = process.cwd(), options = {}) {
return path.join(getLiveDir(cwd, options), 'sessions');
}
export function getLegacyLiveSessionsDir(cwd = process.cwd()) {
return path.join(cwd, '.impeccable-live', 'sessions');
export function getLegacyLiveSessionsDir(cwd = process.cwd(), options = {}) {
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'sessions');
}
export function getLiveAnnotationsDir(cwd = process.cwd()) {
return path.join(getLiveDir(cwd), 'annotations');
export function getLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
return path.join(getLiveDir(cwd, options), 'annotations');
}
export function getCritiqueDir(cwd = process.cwd()) {
return path.join(getImpeccableDir(cwd), CRITIQUE_DIR);
export function getCritiqueDir(cwd = process.cwd(), options = {}) {
return path.join(getImpeccableDir(cwd, options), CRITIQUE_DIR);
}
export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) {
return path.join(cwd, '.impeccable-live', 'annotations');
export function getLegacyLiveAnnotationsDir(cwd = process.cwd(), options = {}) {
return path.join(resolveProjectRoot(cwd, options), '.impeccable-live', 'annotations');
}
function firstExisting(paths) {
@@ -0,0 +1,4 @@
// Source scripts default to slash commands. The provider build replaces only
// this exact declaration, avoiding heuristic rewrites across executable code.
export const IMPECCABLE_COMMAND_PREFIX = "/";
export const IMPECCABLE_COMMAND = `${IMPECCABLE_COMMAND_PREFIX}impeccable`;
@@ -0,0 +1,42 @@
class TargetArgError extends Error {
constructor(message, code) {
super(message);
this.name = 'TargetArgError';
this.code = code;
}
}
export function parseTargetPath(args = [], { strict = false } = {}) {
let targetPath = null;
for (let i = 0; i < args.length; i++) {
const arg = String(args[i]);
if (arg === '--target' || arg === '-t') {
const next = args[i + 1];
if (next && !String(next).startsWith('-')) {
targetPath = String(next);
i++;
continue;
}
if (strict) {
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
}
continue;
}
if (arg.startsWith('--target=')) {
const value = arg.slice('--target='.length);
if (value) {
targetPath = value;
continue;
}
if (strict) {
throw new TargetArgError('--target requires a path value.', 'TARGET_VALUE_MISSING');
}
}
}
return targetPath;
}
export function parseTargetOptions(args = [], options = {}) {
const targetPath = parseTargetPath(args, options);
return targetPath ? { targetPath } : {};
}
+248 -94
View File
@@ -57,7 +57,8 @@
const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 };
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint
const PREFIX = 'impeccable-live';
const PICK_CURSOR_CLASS = PREFIX + '-pick-cursor';
const IMPECCABLE_COMMAND = (window.__IMPECCABLE_COMMAND_PREFIX__ || '/') + 'impeccable';
const PICK_CURSOR_STYLE_ID = PREFIX + '-pick-cursor-style';
const MANUAL_APPLY_STATE_TTL_MS = 15 * 60 * 1000;
const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({
prefix: PREFIX,
@@ -152,6 +153,8 @@
let scrollLockTargetY = null;
let scrollLockRaf = null;
let scrollLockAbort = null;
const SCROLL_ANCHOR_LOCK_ID = 'impeccable-scroll-anchor-lock';
const VARIANT_STATE_STYLE_ID = 'impeccable-variant-state';
// Dedicated key for scroll position - SEPARATE from LS_KEY so that
// saveSession's state updates don't clobber a carefully-captured scrollY.
@@ -1915,45 +1918,45 @@
syncPageInteractionCursor();
}
let pageInteractionCursorActive = false;
function ensurePickCursorStyle() {
if (document.getElementById(PREFIX + '-pick-cursor-style')) return;
const style = document.createElement('style');
style.id = PREFIX + '-pick-cursor-style';
/**
* Drive the page-level pick / insert cursor through the textContent of one
* injected <style>, never by mutating <html> (className or inline style).
* Frameworks that server-render the <html>/<body> roots (Next.js App Router)
* report a React 19 hydration mismatch when the client adds an attribute the
* server HTML never emitted, so a `class`/inline `style` toggled on
* `document.documentElement` trips "a tree hydrated but some attributes ...
* didn't match" on the next Fast-Refresh re-render. Keying the cursor off a
* stable-id <style> keeps the effect off the hydrated host elements (same
* shape as the scroll-anchor lock). A falsy cursor clears the rule.
*/
function setPageInteractionCursor(cursor) {
let style = document.getElementById(PICK_CURSOR_STYLE_ID);
if (!cursor) {
if (style) style.textContent = '';
return;
}
if (!style) {
style = document.createElement('style');
style.id = PICK_CURSOR_STYLE_ID;
// Styles the host page, not the chrome - inside the adapter's shadow UI
// root (uiAppendStyle's target) these selectors would match nothing.
(document.head || document.documentElement).appendChild(style);
}
style.textContent =
'html.' + PICK_CURSOR_CLASS + ' * { cursor: crosshair !important; }\n'
+ 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"],\n'
+ 'html.' + PICK_CURSOR_CLASS + ' [id^="' + PREFIX + '"] * { cursor: revert !important; }';
// Styles the host page, not the chrome - inside the adapter's shadow UI
// root (uiAppendStyle's target) these selectors would match nothing.
document.head.appendChild(style);
'* { cursor: ' + cursor + ' !important; }\n'
+ '[id^="' + PREFIX + '"],\n'
+ '[id^="' + PREFIX + '"] * { cursor: revert !important; }';
}
/** Page-level cursor while pick or insert mode is targeting page elements. */
function syncPageInteractionCursor() {
const pickCursor = state === 'PICKING' && pickActive && !insertActive;
let axisCursor = '';
if (state === 'PICKING' && insertActive) {
axisCursor = insertHoverAnchor ? cursorForInsertAxis(insertHoverAxis || 'column') : '';
}
if (pickCursor) {
ensurePickCursorStyle();
document.documentElement.classList.add(PICK_CURSOR_CLASS);
document.documentElement.style.cursor = '';
pageInteractionCursorActive = true;
return;
}
document.documentElement.classList.remove(PICK_CURSOR_CLASS);
if (axisCursor) {
document.documentElement.style.cursor = axisCursor;
pageInteractionCursorActive = true;
} else if (pageInteractionCursorActive) {
document.documentElement.style.cursor = '';
pageInteractionCursorActive = false;
let cursor = '';
if (state === 'PICKING' && pickActive && !insertActive) {
cursor = 'crosshair';
} else if (state === 'PICKING' && insertActive && insertHoverAnchor) {
cursor = cursorForInsertAxis(insertHoverAxis || 'column');
}
setPageInteractionCursor(cursor);
}
/**
@@ -3034,16 +3037,26 @@
function applyParamValue(variantEl, param, value) {
if (!variantEl) return;
const attr = 'data-p-' + param.id;
if (param.kind === 'range') {
variantEl.style.setProperty('--p-' + param.id, String(value));
} else if (param.kind === 'toggle') {
if (param.kind === 'toggle') {
const on = !!value;
variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
if (on) variantEl.setAttribute(attr, 'on');
else variantEl.removeAttribute(attr);
} else if (param.kind === 'steps') {
variantEl.setAttribute(attr, String(value));
}
// Svelte component variants are client-mounted into
// [data-impeccable-component-mount] with no [data-impeccable-variant="N"]
// wrapper for the state stylesheet to target, and the element is not SSR'd,
// so there is no React hydration to mismatch. Drive range/toggle --p-* inline
// on the mounted element so scoped preview CSS resolves them.
if (svelteComponentSession?.sessionId === currentSessionId) {
if (param.kind === 'range') variantEl.style.setProperty('--p-' + param.id, String(value));
else if (param.kind === 'toggle') variantEl.style.setProperty('--p-' + param.id, value ? '1' : '0');
return;
}
// range/toggle --p-* custom properties are driven through the injected
// variant-state stylesheet so we never mutate inline style on SSR'd divs.
updateVariantStateStylesheet(currentSessionId, visibleVariant);
}
function applyParamDefaults(variantEl, params) {
@@ -4713,6 +4726,7 @@
paramsCurrentValues = {};
tuneOpen = false;
hideParamsPanel();
if (currentSessionId && visibleVariant) updateVariantStateStylesheet(currentSessionId, visibleVariant);
return;
}
applyParamDefaults(variantEl, params);
@@ -4770,20 +4784,7 @@
function isVariantShown(el) {
if (!el) return false;
if (el.hidden) return false;
if (el.style?.display === 'none') return false;
return true;
}
function setVariantShown(el, shown) {
if (!el) return;
if (shown) {
el.removeAttribute('hidden');
el.style.display = '';
} else {
el.setAttribute('hidden', '');
el.style.display = 'none';
}
return getComputedStyle(el).display !== 'none';
}
function scheduleCyclingBarSync(sessionId, variantNum) {
@@ -4822,11 +4823,7 @@
}
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
if (!wrapper) return false;
for (const child of wrapper.children) {
const v = child.dataset ? child.dataset.impeccableVariant : null;
if (!v) continue;
setVariantShown(child, v === String(num));
}
updateVariantStateStylesheet(sessionId, num);
// Unconditional refresh - covers first-reveal (no-op if state isn't
// CYCLING yet, the subsequent CYCLING transition triggers its own
// refresh) and every cycle step.
@@ -5491,6 +5488,7 @@
if (pendingSvelteComponentRetryObserver) { pendingSvelteComponentRetryObserver.disconnect(); pendingSvelteComponentRetryObserver = null; }
if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; }
stopScrollLock();
removeVariantStateStylesheet();
clearSession();
clearHandled();
resetSessionFileMeta();
@@ -5805,6 +5803,68 @@
return variantDiv;
}
// Variant visibility and range/toggle params are expressed through ONE
// injected stylesheet, never inline attributes on the variant divs. Those
// divs are scaffolded into page source, so SSR frameworks (Next.js App
// Router) server-render them; toggling their `hidden` / inline `style` /
// `--p-*` client-side trips a React 19 hydration mismatch on the next
// Fast-Refresh re-render — the same failure mode the scroll-anchor (#276)
// and pick-cursor (#286) fixes address. A stylesheet rule has the same
// computed effect without mutating any hydrated element's attributes.
// (steps params keep driving `data-p-*` attributes, matching scoped CSS.)
const VARIANT_HIDE_DECL = 'display: none !important;';
const VARIANT_SHOW_DECL = 'display: block !important;';
// Build a direct-child variant selector for a session. With `num`, targets a
// single variant (`… > [data-impeccable-variant="N"]`); without it, targets
// every variant via the bare `[data-impeccable-variant]` attribute.
function variantStateSelector(sessionId, num) {
const wrapper = '[data-impeccable-variants="' + sessionId + '"]';
const variant = num == null
? '[data-impeccable-variant]'
: '[data-impeccable-variant="' + num + '"]';
return wrapper + ' > ' + variant;
}
// Serialize the visible variant's knob values into `--p-<id>` custom-property
// declarations. Only range (number) and toggle (boolean) values become a
// custom property; steps params drive `data-p-*` attributes instead.
function variantParamDecls(values) {
return Object.entries(values || {})
.map(([id, val]) => {
if (typeof val === 'number') return ' --p-' + id + ': ' + val + ';';
if (typeof val === 'boolean') return ' --p-' + id + ': ' + (val ? '1' : '0') + ';';
return '';
})
.join('');
}
function updateVariantStateStylesheet(sessionId, num) {
if (!sessionId || num == null || num < 1) return;
let styleEl = document.getElementById(VARIANT_STATE_STYLE_ID);
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = VARIANT_STATE_STYLE_ID;
(document.head || document.documentElement).appendChild(styleEl);
}
// Hide every variant except the visible one (incl. the SSR'd "original").
const hideOthers = variantStateSelector(sessionId)
+ ':not([data-impeccable-variant="' + num + '"]) { ' + VARIANT_HIDE_DECL + ' }';
// Force-show the visible variant (beats the source inline display:none on
// v2/v3) and apply its knob values as custom properties.
const showVisible = variantStateSelector(sessionId, num)
+ ' { ' + VARIANT_SHOW_DECL + variantParamDecls(paramsCurrentValues) + ' }';
styleEl.textContent = hideOthers + '\n' + showVisible + '\n';
}
function removeVariantStateStylesheet() {
document.getElementById(VARIANT_STATE_STYLE_ID)?.remove();
}
// Hold window.scrollY at a fixed value across DOM mutations inside the
// session's wrapper (HMR patches, variant inserts, cycle swaps).
function startScrollLock(sessionId, initialTargetY) {
@@ -5815,10 +5875,22 @@
try { history.scrollRestoration = 'manual'; } catch {}
const prevHtmlAnchor = document.documentElement.style.overflowAnchor;
const prevBodyAnchor = document.body.style.overflowAnchor;
document.documentElement.style.overflowAnchor = 'none';
document.body.style.overflowAnchor = 'none';
// Suppress the browser's scroll-anchoring on the scroll root so it can't
// fight our manual scroll correction. Apply this as a stylesheet rule, not
// as inline `style` on <html>/<body>: those elements are server-rendered by
// frameworks like Next.js App Router, and mutating their inline style makes
// React 19 report a hydration mismatch on the next Fast-Refresh re-render.
// A <style> rule has the same computed effect without touching any hydrated
// element's attributes. Like the inline version, it is recreated on every
// startScrollLock call, so reload survival (driven by the persisted scroll
// key) is unaffected.
let anchorLockStyle = document.getElementById(SCROLL_ANCHOR_LOCK_ID);
if (!anchorLockStyle) {
anchorLockStyle = document.createElement('style');
anchorLockStyle.id = SCROLL_ANCHOR_LOCK_ID;
anchorLockStyle.textContent = 'html,body{overflow-anchor:none !important;}';
(document.head || document.documentElement).appendChild(anchorLockStyle);
}
const correct = (why) => {
scrollLockRaf = null;
@@ -5853,8 +5925,7 @@
scrollLockAbort = new AbortController();
scrollLockAbort.signal.addEventListener('abort', () => {
document.documentElement.style.overflowAnchor = prevHtmlAnchor;
document.body.style.overflowAnchor = prevBodyAnchor;
document.getElementById(SCROLL_ANCHOR_LOCK_ID)?.remove();
}, { once: true });
const sig = { signal: scrollLockAbort.signal };
// Track whether the most recent scroll came from a user gesture. We
@@ -6075,7 +6146,7 @@
switch (msg.type) {
case 'connected':
hasProjectContext = !!msg.hasProjectContext;
if (!hasProjectContext) showToast('No PRODUCT.md found. Variants will be brand-agnostic. Run /impeccable init to generate one.', 7000);
if (!hasProjectContext) showToast(`No PRODUCT.md found. Variants will be brand-agnostic. Run ${IMPECCABLE_COMMAND} init to generate one.`, 7000);
console.log('[impeccable] Live mode connected.');
syncAgentPollingUi(!!msg.agentPolling);
startAgentStatusPoll();
@@ -6489,10 +6560,13 @@
) {
return;
}
if (isPageEditableElement(deepActive) && !isInlineEditActive(deepActive)) {
return;
}
// While a contenteditable text-leaf is focused, let the browser handle
// all keys except Escape. Escape cancels the current edit (restores
// original text) and blurs without saving, staying in CONFIGURING.
if (e.target.isContentEditable && inlineEditRows.some((r) => r.el === e.target)) {
if (e.target.isContentEditable && isInlineEditActive(e.target)) {
if (e.key !== 'Escape') return;
e.preventDefault();
e.stopPropagation();
@@ -7621,6 +7695,7 @@ void main() {
stopScrollTracking();
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
stopScrollLock();
removeVariantStateStylesheet();
clearScrollY();
clearSession();
resetSessionFileMeta();
@@ -7882,6 +7957,7 @@ void main() {
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
if (pendingVariantAnchorRetryObserver) { pendingVariantAnchorRetryObserver.disconnect(); pendingVariantAnchorRetryObserver = null; }
stopScrollLock();
removeVariantStateStylesheet();
clearScrollY();
finalizeInsertSession();
clearSession();
@@ -7913,7 +7989,7 @@ void main() {
const barTopFromBottom = barRect && barRect.height > 0
? Math.max(16, window.innerHeight - barRect.top + 12)
: 16;
toastEl = el('div', {
const currentToast = el('div', {
position: 'fixed', bottom: barTopFromBottom + 'px', left: '50%',
transform: 'translateX(-50%) translateY(8px)',
background: C.ink, color: C.white,
@@ -7923,19 +7999,24 @@ void main() {
transition: 'opacity 0.25s ' + EASE + ', transform 0.25s ' + EASE,
pointerEvents: 'none', maxWidth: '420px', textAlign: 'center',
});
toastEl.id = PREFIX + '-toast';
toastEl.textContent = message;
uiAppend(toastEl);
toastEl = currentToast;
currentToast.id = PREFIX + '-toast';
currentToast.textContent = message;
uiAppend(currentToast);
requestAnimationFrame(() => {
toastEl.style.opacity = '1';
toastEl.style.transform = 'translateX(-50%) translateY(0)';
if (toastEl !== currentToast) return;
currentToast.style.opacity = '1';
currentToast.style.transform = 'translateX(-50%) translateY(0)';
});
setTimeout(() => {
if (toastEl) {
toastEl.style.opacity = '0';
toastEl.style.transform = 'translateX(-50%) translateY(8px)';
setTimeout(() => { if (toastEl) { toastEl.remove(); toastEl = null; } }, 250);
}
if (toastEl !== currentToast) return;
currentToast.style.opacity = '0';
currentToast.style.transform = 'translateX(-50%) translateY(8px)';
setTimeout(() => {
if (toastEl !== currentToast) return;
currentToast.remove();
toastEl = null;
}, 250);
}, duration);
}
@@ -8186,7 +8267,7 @@ void main() {
let voiceInterimBase = '';
/** @type {{ mode: 'steer'|'configure', input: HTMLInputElement, submit: () => void, beforeStart?: () => void } | null} */
let voiceCtx = null;
const PAGE_CHAT_COLLAPSED_W = '88px';
const PAGE_CHAT_COLLAPSED_W = '104px';
const PAGE_CHAT_PROCESSING_W = '76px';
const PAGE_CHAT_PLACEHOLDER_COLLAPSED = 'Steer…';
const PAGE_CHAT_PLACEHOLDER_EXPANDED = 'Steer the page…';
@@ -8197,7 +8278,7 @@ void main() {
const GLOBAL_BAR_SECTION_GAP = 8;
const GLOBAL_BAR_INNER_GAP = 2;
const GLOBAL_BAR_INNER_PAD_LEFT = 2;
const PAGE_CHAT_EXPANDED_W = 'min(280px, 38vw)';
const PAGE_CHAT_EXPANDED_MAX_W = 280;
const ICON_PAGE_CHAT =
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
const ICON_PAGE_VOICE =
@@ -8277,6 +8358,52 @@ void main() {
return barPaletteForTheme(globalBarEl?.dataset.theme || detectPageTheme());
}
function globalBarModeToggles() {
return [
uiGetById(PREFIX + '-pick-toggle'),
uiGetById(PREFIX + '-insert-toggle'),
uiGetById(PREFIX + '-detect-toggle'),
uiGetById(PREFIX + '-design-toggle'),
].filter(Boolean);
}
function applyGlobalBarLabelState(expandInactive, forceCollapse = false) {
globalBarModeToggles().forEach((toggle) => {
if (forceCollapse) toggle._collapseLabel?.(true);
else if (expandInactive || toggle.dataset.active === 'true') toggle._expandLabel?.();
else toggle._collapseLabel?.();
});
}
function syncGlobalBarExpandedLabels(expanded = globalBarEl?.matches(':hover')) {
const expandInactive = !!(expanded && !pageChatExpanded);
applyGlobalBarLabelState(expandInactive, pageChatExpanded);
if (expandInactive && globalBarEl && globalBarEl.scrollWidth > window.innerWidth - 16) {
applyGlobalBarLabelState(false);
}
}
function pageChatCollapsedWidthPx() {
const parsed = parseFloat(PAGE_CHAT_COLLAPSED_W);
return Number.isFinite(parsed) ? parsed : 104;
}
function pageChatExpandedWidth() {
if (!pageChatEl || !globalBarEl) return PAGE_CHAT_EXPANDED_MAX_W + 'px';
const currentChatWidth = pageChatEl.getBoundingClientRect().width || pageChatCollapsedWidthPx();
const barWidth = Math.max(globalBarEl.getBoundingClientRect().width || 0, globalBarEl.scrollWidth || 0);
const nonChatWidth = Math.max(0, barWidth - currentChatWidth);
const available = window.innerWidth - 16 - nonChatWidth;
const next = Math.max(pageChatCollapsedWidthPx(), Math.min(PAGE_CHAT_EXPANDED_MAX_W, available));
return Math.round(next) + 'px';
}
function syncPageChatExpandedWidth() {
if (!pageChatEl || !pageChatExpanded) return;
pageChatEl.style.width = pageChatExpandedWidth();
}
function syncPageChatChrome() {
if (!pageChatEl) return;
const P = pageChatPalette();
@@ -8312,6 +8439,21 @@ void main() {
&& !steerLocked;
}
function isPageEditableElement(el) {
if (!el || own(el)) return false;
if (/^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName || '')) return true;
return !!el.isContentEditable;
}
function isInlineEditActive(el) {
return !!el && inlineEditRows.some((r) => r.el === el);
}
function isPageEditableActive() {
const active = activeElementDeep();
return isPageEditableElement(active) && !isInlineEditActive(active);
}
function pageHasHostTextSelection() {
const sel = window.getSelection?.();
if (!sel || sel.isCollapsed) return false;
@@ -8325,6 +8467,7 @@ void main() {
function shouldSteerAutoFocus() {
return shouldFocusSteerChat()
&& !steerFocusSuspended
&& !isPageEditableActive()
&& performance.now() >= steerFocusPauseUntil;
}
@@ -8562,7 +8705,8 @@ void main() {
if (!pageChatEl || !pageChatInput) return false;
pageChatExpanded = true;
pageChatEl.dataset.expanded = 'true';
pageChatEl.style.width = PAGE_CHAT_EXPANDED_W;
syncGlobalBarExpandedLabels(false);
pageChatEl.style.width = pageChatExpandedWidth();
pageChatEl.style.cursor = steerLocked ? 'default' : 'text';
pageChatInput.placeholder = PAGE_CHAT_PLACEHOLDER_EXPANDED;
if (pageChatHint) {
@@ -8657,7 +8801,7 @@ void main() {
pageChatEl.setAttribute('aria-label', 'Steer the page');
pageChatExpanded = keepExpanded;
pageChatEl.dataset.expanded = keepExpanded ? 'true' : 'false';
pageChatEl.style.width = keepExpanded ? PAGE_CHAT_EXPANDED_W : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.width = keepExpanded ? pageChatExpandedWidth() : PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
if (pageChatInput) {
pageChatInput.disabled = false;
@@ -8971,6 +9115,7 @@ void main() {
pageChatEl.dataset.expanded = 'false';
pageChatEl.style.width = PAGE_CHAT_COLLAPSED_W;
pageChatEl.style.cursor = 'pointer';
syncGlobalBarExpandedLabels(globalBarEl?.matches(':hover'));
if (blur) {
pageChatInput.blur();
pageChatInput.style.pointerEvents = 'none';
@@ -9270,6 +9415,7 @@ void main() {
zIndex: Z.bar + 5,
display: 'flex', alignItems: 'stretch',
gap: '0',
width: 'max-content',
background: P.surface,
border: '1px solid ' + P.border,
borderRadius: '8px',
@@ -9277,6 +9423,8 @@ void main() {
fontFamily: FONT, fontSize: '12px', lineHeight: '1',
opacity: '0',
overflow: 'hidden', // clip the full-bleed brand mark to the bar radius
maxWidth: 'calc(100vw - 16px)',
boxSizing: 'border-box',
transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE,
});
globalBarEl.id = PREFIX + '-global-bar';
@@ -9325,6 +9473,7 @@ void main() {
const inner = el('div', {
display: 'flex', alignItems: 'center',
padding: '4px 5px 4px ' + GLOBAL_BAR_INNER_PAD_LEFT + 'px', gap: GLOBAL_BAR_INNER_GAP + 'px',
flex: '0 0 auto',
});
inner.id = PREFIX + '-global-bar-inner';
globalBarEl.appendChild(inner);
@@ -9333,7 +9482,10 @@ void main() {
function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) {
const b = el('button', {
position: 'relative',
display: 'inline-flex', alignItems: 'center',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
boxSizing: 'border-box',
flex: '0 0 auto',
minWidth: '30px',
padding: '6px 8px', borderRadius: '7px',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500',
@@ -9352,8 +9504,8 @@ void main() {
if (!labelEl) return;
labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; labelEl.style.transform = 'translateX(0)';
};
const collapse = () => {
if (!labelEl || b.dataset.active === 'true') return;
const collapse = (force = false) => {
if (!labelEl || (!force && b.dataset.active === 'true')) return;
labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; labelEl.style.transform = 'translateX(-4px)';
};
// Per-button hover only changes color (no layout). The label expand/
@@ -9604,6 +9756,7 @@ void main() {
width: '1px', height: '18px',
background: P.hairline,
margin: '0 4px 0 2px',
flexShrink: '0',
});
inner.appendChild(divider);
@@ -9620,6 +9773,7 @@ void main() {
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
padding: '0', boxSizing: 'border-box',
width: '24px', height: '24px', borderRadius: '6px',
flexShrink: '0',
border: 'none', background: 'transparent',
color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
@@ -9632,16 +9786,16 @@ void main() {
exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); });
inner.appendChild(exitBtn);
// Bar-level hover: expand every toggle's label at once; collapse on leave.
// Bar-level hover: expand mode labels unless Steer is using the space.
// Buttons with dataset.active="true" ignore collapse (their label stays).
const toggles = [pickBtn, insertBtn, detectBtn, designBtn];
globalBarEl.addEventListener('mouseenter', () => {
toggles.forEach((t) => t._expandLabel && t._expandLabel());
syncGlobalBarExpandedLabels(true);
syncPageChatExpandedWidth();
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
globalBarEl.addEventListener('mouseleave', () => {
toggles.forEach((t) => t._collapseLabel && t._collapseLabel());
syncGlobalBarExpandedLabels(false);
schedulePendingDockPosition();
setTimeout(schedulePendingDockPosition, 260);
});
@@ -9659,6 +9813,7 @@ void main() {
pendingDockResizeObserver.observe(globalBarEl);
}
window.addEventListener('resize', positionPendingDock);
window.addEventListener('resize', syncPageChatExpandedWidth);
requestAnimationFrame(() => {
globalBarEl.style.opacity = '1';
@@ -9705,9 +9860,7 @@ void main() {
// If the bar is currently under the cursor, keep all labels expanded -
// otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
// would collapse its label while the user's mouse is still on the bar.
if (globalBarEl && globalBarEl.matches(':hover')) {
[pickToggle, insertToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.());
}
syncGlobalBarExpandedLabels(globalBarEl && globalBarEl.matches(':hover'));
if (detectBadge) {
detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none';
@@ -9896,7 +10049,8 @@ void main() {
// Remove detection overlays
window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
setLiveState('IDLE');
document.getElementById(PREFIX + '-pick-cursor-style')?.remove();
document.getElementById(PICK_CURSOR_STYLE_ID)?.remove();
removeVariantStateStylesheet();
window.__IMPECCABLE_LIVE_INIT__ = false;
console.log('[impeccable] Live mode exited.');
}
@@ -10385,7 +10539,7 @@ void main() {
if (designState.present === false) {
const empty = document.createElement('div');
empty.className = 'empty';
empty.innerHTML = `<strong>No DESIGN.md yet</strong>Create one by running <code>/impeccable document</code> in your terminal, then re-open this panel.`;
empty.innerHTML = `<strong>No DESIGN.md yet</strong>Create one by running <code>${IMPECCABLE_COMMAND} document</code> in your terminal, then re-open this panel.`;
body.appendChild(empty);
return;
}
@@ -10415,7 +10569,7 @@ void main() {
box.className = 'stale';
box.innerHTML = `
<span class="stale-dot"></span>
<span class="stale-text"><strong>DESIGN.md is newer than .impeccable/design.json.</strong> Run <code>/impeccable document</code> to refresh the sidecar.</span>
<span class="stale-text"><strong>DESIGN.md is newer than .impeccable/design.json.</strong> Run <code>${IMPECCABLE_COMMAND} document</code> to refresh the sidecar.</span>
`;
return box;
}
@@ -10423,7 +10577,7 @@ void main() {
function renderParsedMdCta() {
const box = document.createElement('div');
box.className = 'parsed-md-cta';
box.innerHTML = `<strong>Basic view</strong>This panel reads the tokens in your <code>DESIGN.md</code> frontmatter. Running <code>/impeccable document</code> also generates a <code>.impeccable/design.json</code> sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`;
box.innerHTML = `<strong>Basic view</strong>This panel reads the tokens in your <code>DESIGN.md</code> frontmatter. Running <code>${IMPECCABLE_COMMAND} document</code> also generates a <code>.impeccable/design.json</code> sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`;
return box;
}
+16 -11
View File
@@ -2,11 +2,11 @@
* CLI client for the live variant mode poll/reply protocol.
*
* Usage:
* npx impeccable poll # Block until browser event, print JSON
* npx impeccable poll --stream # Experimental: keep polling; one JSON line per event
* npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly
* npx impeccable poll --reply <id> done # Reply "done" to event <id>
* npx impeccable poll --reply <id> error "msg" # Reply with error
* node <scripts_path>/live-poll.mjs # Block until browser event, print JSON
* node <scripts_path>/live-poll.mjs --stream # Experimental: keep polling; one JSON line per event
* node <scripts_path>/live-poll.mjs --timeout=600000 # Custom timeout (ms); default is long-poll friendly
* node <scripts_path>/live-poll.mjs --reply <id> done # Reply "done" to event <id>
* node <scripts_path>/live-poll.mjs --reply <id> error "msg" # Reply with error
*/
import { execFileSync } from 'node:child_process';
@@ -15,6 +15,11 @@ import { fileURLToPath } from 'node:url';
import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
// Absolute path to a sibling script in this skill's scripts dir, so runtime
// error hints print a directly-runnable command instead of a placeholder.
const SELF_DIR = path.dirname(fileURLToPath(import.meta.url));
const scriptCmd = (name) => `node "${path.join(SELF_DIR, name)}"`;
// Node's built-in fetch (undici under the hood) enforces a 300s headers
// timeout that can't be lowered per-request. We cap each request below
// that ceiling and loop in `pollOnce` to synthesize a long poll without
@@ -27,7 +32,7 @@ const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_ed
function readServerInfo() {
const record = readLiveServerInfo(process.cwd());
if (!record) {
console.error('No running live server found. Start one with: npx impeccable live');
console.error(`No running live server found. Start one with: ${scriptCmd('live.mjs')}`);
process.exit(1);
}
return record.info;
@@ -82,7 +87,7 @@ export function parseReplyArgs(args) {
}
function validateReplyArgs({ id, status }) {
const usage = "Usage: npx impeccable poll --reply <id> <status> [--file path] [--data '<json>'] [message]";
const usage = `Usage: ${scriptCmd('live-poll.mjs')} --reply <id> <status> [--file path] [--data '<json>'] [message]`;
if (!id || id.startsWith('--')) {
const err = new Error(`${usage}\nMissing event id after --reply.`);
err.code = 'INVALID_REPLY_ARGS';
@@ -283,11 +288,11 @@ export async function runPollStream(base, token, {
function handlePollError(err) {
if (err.code === 'AUTH_FAILED') {
console.error(err.message);
console.error('Try restarting: npx impeccable live stop && npx impeccable live');
console.error(`Try restarting: ${scriptCmd('live-server.mjs')} stop && ${scriptCmd('live.mjs')}`);
process.exit(1);
}
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
console.error(`Live server not running. Start one with: ${scriptCmd('live.mjs')}`);
process.exit(1);
}
if (err.code === 'ACK_TIMEOUT') {
@@ -331,7 +336,7 @@ Harness note:
const info = readServerInfo();
const base = `http://localhost:${info.port}`;
// Reply mode: npx impeccable poll --reply <id> <status> [--file path] [--data '<json>'] [message]
// Reply mode: node <scripts_path>/live-poll.mjs --reply <id> <status> [--file path] [--data '<json>'] [message]
if (args.includes('--reply')) {
let reply;
try {
@@ -345,7 +350,7 @@ Harness note:
await postReply(base, info.token, reply);
} catch (err) {
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
console.error(`Live server not running. Start one with: ${scriptCmd('live.mjs')}`);
} else {
console.error('Reply failed:', err.message);
}
@@ -21,7 +21,7 @@ import path from 'node:path';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
import { parseDesignMd } from './lib/design-parser.mjs';
import { resolveContextDir } from './context.mjs';
import { loadContext } from './context.mjs';
import {
assembleLiveBrowserScript,
assertLiveBrowserScriptParts,
@@ -36,6 +36,7 @@ import {
getDesignSidecarPath,
getLiveDir,
getLiveAnnotationsDir,
IMPECCABLE_COMMAND_PREFIX,
readLiveServerInfo,
removeLiveServerInfo,
resolveDesignSidecarPath,
@@ -55,7 +56,11 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
// DESIGN.json fallback for existing projects.
const CONTEXT_DIR = resolveContextDir(process.cwd());
const PROJECT_CONTEXT = loadContext(process.cwd());
const CONTEXT_DIR = PROJECT_CONTEXT.contextDir;
const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath)
: null;
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
@@ -371,10 +376,7 @@ function hasProjectContext() {
// PRODUCT.md carries brand voice / anti-references — that's what determines
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
// concern, surfaced by the design panel's own empty state.
try {
fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
return true;
} catch { return false; }
return !!PROJECT_CONTEXT.hasProduct;
}
function statOrNull(filePath) {
@@ -412,6 +414,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
token: state.token,
port: state.port,
vocabulary: LIVE_COMMANDS,
commandPrefix: IMPECCABLE_COMMAND_PREFIX,
parts,
});
res.writeHead(200, {
@@ -549,8 +552,8 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
const mdPath = DESIGN_MD_PATH;
const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
const mdStat = statOrNull(mdPath);
const jsonStat = statOrNull(jsonPath);
@@ -0,0 +1,30 @@
import path from 'node:path';
import { resolveProjectRoot } from './context.mjs';
import { parseTargetPath } from './lib/target-args.mjs';
export function resolveLiveTarget(cwd = process.cwd(), args = []) {
const originalCwd = path.resolve(cwd);
let targetPath = null;
try {
targetPath = parseTargetPath(args, { strict: true });
} catch (err) {
if (err?.name === 'TargetArgError') {
process.stderr.write(`${err.message}\n`);
process.exit(1);
}
throw err;
}
const absoluteTargetPath = targetPath
? path.isAbsolute(targetPath) ? targetPath : path.resolve(originalCwd, targetPath)
: null;
const projectRoot = targetPath
? resolveProjectRoot(originalCwd, { targetPath: absoluteTargetPath })
: originalCwd;
return {
originalCwd,
projectRoot,
targetPath,
absoluteTargetPath,
targetOptions: absoluteTargetPath ? { targetPath: absoluteTargetPath } : {},
};
}
@@ -2,7 +2,7 @@
* CLI helper: find an element in source and wrap it in a variant container.
*
* Usage:
* npx impeccable wrap --id SESSION_ID --count N --query "hero-combined-left" [--file path]
* node <scripts_path>/live-wrap.mjs --id SESSION_ID --count N --query "hero-combined-left" [--file path]
*
* Searches project files for the element matching the query (class name, ID, or
* text snippet), wraps it with the variant scaffolding, and prints the file path
+72 -21
View File
@@ -21,14 +21,16 @@ import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadContext } from './context.mjs';
import { loadContext, resolveTargetSelection } from './context.mjs';
import { resolveFiles } from './live-inject.mjs';
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
import { resolveLiveTarget } from './live-target.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
async function liveCli() {
const args = process.argv.slice(2);
const liveTarget = resolveLiveTarget(process.cwd(), args);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node live.mjs
@@ -38,37 +40,78 @@ Prepare everything for live variant mode in a single command:
- Starts (or reuses) the live server in the background
- Injects the browser script tag
- Reads PRODUCT.md / DESIGN.md for project context
- In monorepos, choose a child app first; --target <path> is the fallback/manual path
On success, prints a JSON blob with:
{ ok, serverPort, serverToken, pageFile, hasContext, context }
{ ok, serverPort, serverToken, pageFiles, projectRoot, repoRoot, targetPath, productPath, designPath }
On target_selection_required, prints:
{ ok: false, error: "target_selection_required", targetCandidates }
On config_missing, prints:
{ ok: false, error: "config_missing", configPath, hint }
The agent should then:
1. If config_missing, create the config and re-run this script
2. Optionally open the project's dev/preview URL in the browser (see reference/live.mdnot serverPort)
3. Enter the poll loop: node live-poll.mjs`);
1. If target_selection_required, ask which app to use and rerun from that child cwd
2. If config_missing, create the config and re-run this script
3. Optionally open the project's dev/preview URL in the browser (see reference/live.mdnot serverPort)
4. Enter the poll loop: node live-poll.mjs`);
process.exit(0);
}
const targetSelection = resolveTargetSelection(liveTarget.originalCwd, liveTarget.targetOptions);
if (targetSelection) {
console.log(JSON.stringify({
ok: false,
error: 'target_selection_required',
...targetSelection,
hint: 'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target <path> only as a fallback or explicit path diagnostic.',
}, null, 2));
process.exit(0);
}
const ctx = loadContext(liveTarget.originalCwd, liveTarget.targetOptions);
const activeCwd = ctx.projectRoot;
const outputTargetPath = liveTarget.targetPath || null;
const missingContext = missingLiveContext(ctx);
if (missingContext.length > 0) {
console.log(JSON.stringify({
ok: false,
error: 'context_missing',
missing: missingContext,
nextCommand: missingContext.includes('PRODUCT.md') ? 'init' : 'document',
targetPath: outputTargetPath,
projectRoot: ctx.projectRoot,
repoRoot: ctx.repoRoot,
productPath: ctx.productPath,
designPath: ctx.designPath,
}, null, 2));
process.exit(0);
}
// 1. Check config (fail fast if missing — no point starting anything else)
const checkOut = runScript('live-inject.mjs', ['--check']);
const checkOut = runScript('live-inject.mjs', ['--check'], { cwd: activeCwd });
const checkResult = safeParse(checkOut);
if (!checkResult || !checkResult.ok) {
console.log(JSON.stringify(checkResult || { ok: false, error: 'check_failed', raw: checkOut }));
console.log(JSON.stringify({
...(checkResult || { ok: false, error: 'check_failed', raw: checkOut }),
targetPath: outputTargetPath,
projectRoot: ctx.projectRoot,
repoRoot: ctx.repoRoot,
}));
process.exit(0);
}
// 2. Start server (or reuse existing)
const serverInfo = ensureServerRunning();
const serverInfo = ensureServerRunning(activeCwd);
if (!serverInfo) {
console.log(JSON.stringify({ ok: false, error: 'server_start_failed' }));
process.exit(1);
}
// 3. Inject the script tag at the current port
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)]);
const injectOut = runScript('live-inject.mjs', ['--port', String(serverInfo.port)], { cwd: activeCwd });
const injectResult = safeParse(injectOut);
if (!injectResult || !injectResult.ok) {
console.log(JSON.stringify({
@@ -80,22 +123,23 @@ The agent should then:
process.exit(1);
}
// 4. Load PRODUCT.md + DESIGN.md context.
const ctx = loadContext(process.cwd());
// 5. Compute drift-heal: compare resolved inject targets against the
// 4. Compute drift-heal: compare resolved inject targets against the
// project's HTML files. Orphans are HTML files not covered by config.
// Warning only — the agent decides whether to act.
const resolvedFiles = resolveFiles(process.cwd(), checkResult.config);
const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config);
const resolvedFiles = resolveFiles(activeCwd, checkResult.config);
const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config);
// 6. Emit everything the agent needs
// 5. Emit everything the agent needs
console.log(JSON.stringify({
ok: true,
serverPort: serverInfo.port,
serverToken: serverInfo.token,
pageFiles: resolvedFiles,
liveConfigPath: checkResult.path,
configDrift: drift,
targetPath: outputTargetPath,
projectRoot: ctx.projectRoot,
repoRoot: ctx.repoRoot,
hasProduct: ctx.hasProduct,
product: ctx.product,
productPath: ctx.productPath,
@@ -105,6 +149,13 @@ The agent should then:
}, null, 2));
}
function missingLiveContext(ctx) {
const missing = [];
if (!ctx.hasProduct) missing.push('PRODUCT.md');
if (!ctx.hasDesign) missing.push('DESIGN.md');
return missing;
}
/**
* Drift-heal scan. Walks the project for HTML files under common
* page-source directories (public/, src/, app/, pages/) and reports any
@@ -201,11 +252,11 @@ function globToRegex(pattern) {
// Helpers
// ---------------------------------------------------------------------------
function runScript(name, args) {
function runScript(name, args, options = {}) {
const scriptPath = path.join(__dirname, name);
const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
try {
return execSync(cmd, { encoding: 'utf-8', cwd: process.cwd(), timeout: 15_000 });
return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 });
} catch (err) {
// execSync throws on non-zero exit; return stdout if any
return err.stdout || err.message || '';
@@ -219,10 +270,10 @@ function safeParse(out) {
/**
* Return { pid, port, token } for the running live server, starting one if needed.
*/
function ensureServerRunning() {
function ensureServerRunning(cwd = process.cwd()) {
// Try to reuse an existing server
try {
const existing = readLiveServerInfo(process.cwd())?.info;
const existing = readLiveServerInfo(cwd)?.info;
if (existing && existing.pid) {
try {
process.kill(existing.pid, 0); // throws if dead
@@ -232,7 +283,7 @@ function ensureServerRunning() {
} catch { /* no PID file */ }
// Start a new server
const out = runScript('live-server.mjs', ['--background']);
const out = runScript('live-server.mjs', ['--background'], { cwd });
return safeParse(out);
}
@@ -32,10 +32,11 @@ export function readLiveBrowserScriptParts(parts, readFile = (filePath) => fs.re
}));
}
export function assembleLiveBrowserScript({ token, port, vocabulary, parts }) {
export function assembleLiveBrowserScript({ token, port, vocabulary, commandPrefix = '/', parts }) {
const prelude =
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
`window.__IMPECCABLE_PORT__ = ${port};\n` +
`window.__IMPECCABLE_COMMAND_PREFIX__ = ${JSON.stringify(commandPrefix)};\n` +
// Canonical command vocabulary (values + labels + icons). live-browser.js
// builds its action picker from this instead of an inline copy.
`window.__IMPECCABLE_VOCAB__ = ${JSON.stringify(vocabulary)};\n`;
+16 -9
View File
@@ -6,7 +6,7 @@
* node <scripts_path>/pin.mjs pin <command>
* node <scripts_path>/pin.mjs unpin <command>
*
* `pin audit` creates a lightweight /audit skill that redirects to /impeccable audit.
* `pin audit` creates a lightweight audit skill that redirects to Impeccable's audit workflow.
* `unpin audit` removes that shortcut.
*
* The script discovers harness directories (.claude/skills, .cursor/skills, etc.)
@@ -14,7 +14,7 @@
*/
import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, readdirSync } from 'node:fs';
import { join, resolve, dirname } from 'node:path';
import { basename, join, resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -25,6 +25,8 @@ const HARNESS_DIRS = [
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev',
];
const CODEX_HARNESSES = new Set(['.codex', '.agents']);
// Valid sub-command names
const VALID_COMMANDS = [
'craft', 'init', 'extract', 'document', 'shape',
@@ -87,8 +89,12 @@ function loadCommandMetadata() {
/**
* Generate a pinned skill's SKILL.md content.
*/
function generatePinnedSkill(command, metadata) {
const desc = metadata[command]?.description || `Shortcut for /impeccable ${command}.`;
function commandPrefixForSkillsDir(skillsDir) {
return CODEX_HARNESSES.has(basename(dirname(skillsDir))) ? '$' : '/';
}
function generatePinnedSkill(command, metadata, commandPrefix) {
const desc = metadata[command]?.description || `Shortcut for ${commandPrefix}impeccable ${command}.`;
const hint = metadata[command]?.argumentHint || '[target]';
return `---
@@ -100,9 +106,9 @@ user-invocable: true
${PIN_MARKER}
This is a pinned shortcut for \`{{command_prefix}}impeccable ${command}\`.
This is a pinned shortcut for \`${commandPrefix}impeccable ${command}\`.
Invoke {{command_prefix}}impeccable ${command}, passing along any arguments provided here, and follow its instructions.
Invoke ${commandPrefix}impeccable ${command}, passing along any arguments provided here, and follow its instructions.
`;
}
@@ -118,10 +124,11 @@ function pin(command, projectRoot) {
return false;
}
const content = generatePinnedSkill(command, metadata);
let created = 0;
for (const skillsDir of harnessDirs) {
const commandPrefix = commandPrefixForSkillsDir(skillsDir);
const content = generatePinnedSkill(command, metadata, commandPrefix);
// Check if skill already exists (and isn't a pin)
const skillDir = join(skillsDir, command);
if (existsSync(skillDir)) {
@@ -143,7 +150,7 @@ function pin(command, projectRoot) {
if (created > 0) {
console.log(`\nPinned '${command}' as a standalone shortcut in ${created} location(s).`);
console.log(`You can now use /${command} directly.`);
console.log('Use the pinned command directly in each harness.');
}
return created > 0;
@@ -177,7 +184,7 @@ function unpin(command, projectRoot) {
if (removed > 0) {
console.log(`\nUnpinned '${command}' from ${removed} location(s).`);
console.log(`Use /impeccable ${command} to access it.`);
console.log(`Use Impeccable's '${command}' workflow directly to access it.`);
} else {
console.log(`No pinned '${command}' shortcut found.`);
}
+1 -2
View File
@@ -1,5 +1,4 @@
{
"description": "Impeccable design detector: runs after Edit/Write/apply_patch on UI files and surfaces findings as system reminders.",
"hooks": {
"PostToolUse": [
{
@@ -7,7 +6,7 @@
"hooks": [
{
"type": "command",
"command": "node \"$(git rev-parse --show-toplevel)/.agents/skills/impeccable/scripts/hook.mjs\"",
"command": "node \".agents/skills/impeccable/scripts/hook.mjs\"",
"timeout": 5,
"statusMessage": "Checking UI changes"
}
+13 -12
View File
@@ -1,7 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 3.7.0
version: 3.9.1
license: Apache 2.0
---
@@ -11,11 +11,12 @@ Designs and iterates production-grade frontend interfaces. Real working code, co
You MUST do these steps before proceeding:
1. Run `node .cursor/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/<command>.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
1. Run `node .cursor/skills/impeccable/scripts/context.mjs` once per session; if the runtime shows this skill's loaded base directory, run `node <skill-base-dir>/scripts/context.mjs` instead. Keep cwd/workdir at the user's project, not the skill directory. If the request names or implies a file, route, or app inside a monorepo, infer the concrete path and append `--target <path>` to the same command. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`:** divert into `reference/init.md` first when the user invoked `init`, `teach`, `craft`, or `shape`, or when their wording clearly maps to one of those from-scratch build flows (for example: "build/create/make a landing page", "design a new app", or "shape a feature"). Captured product context is the point of those flows. For any other command, a scoped evaluate / refine / enhance / fix / iterate request against existing code, do **not** divert into init. The existing code is the context: proceed with the requested command, infer the register from the surface in focus (step 4), and offer `/impeccable init` once as a suggestion the user can take later. A missing PRODUCT.md must never block a scoped request. If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read the command's reference next: **`reference/<command>.md`, or the native variant from the Commands table** (e.g. `reference/audit.native.md`) **when the project platform is native** (`ios` / `android` / `adaptive`, per the `context.mjs` directive). One file, not both. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
5. **If the project is brand-new (no existing CSS tokens / theme / committed brand colors found in step 3)**, run `node .cursor/skills/impeccable/scripts/palette.mjs` to receive a brand seed color and composition guidance. This is the anchor for your primary brand color. Compose the rest of the palette (bg, surface, ink, accent, muted) around it per the script's instructions. Use OKLCH throughout. **Skip this step only if step 3 found committed brand colors in existing tokens; in that case identity-preservation wins.**
5. **If PRODUCT.md's `## Platform` is `ios` or `android`**, also read `reference/<platform>.md` (HIG / Material 3 conventions). `adaptive` (cross-platform, ships both) reads both files. `web`, absent, or unrecognized: nothing extra to read. `context.mjs` prints the directive when one applies.
6. **If the project is brand-new (no existing CSS tokens / theme / committed brand colors found in step 3)**, run `node .cursor/skills/impeccable/scripts/palette.mjs` to receive a brand seed color and composition guidance. This is the anchor for your primary brand color. Compose the rest of the palette (bg, surface, ink, accent, muted) around it per the script's instructions. Use OKLCH throughout. **Skip this step only if step 3 found committed brand colors in existing tokens; in that case identity-preservation wins.**
## Design guidance
@@ -104,7 +105,7 @@ If someone could look at this interface and say "AI made that" without doubt, it
| `document` | Build | Generate DESIGN.md from existing project code | [reference/document.md](reference/document.md) |
| `extract [target]` | Build | Pull reusable tokens and components into design system | [reference/extract.md](reference/extract.md) |
| `critique [target]` | Evaluate | UX design review with heuristic scoring | [reference/critique.md](reference/critique.md) |
| `audit [target]` | Evaluate | Technical quality checks (a11y, perf, responsive) | [reference/audit.md](reference/audit.md) |
| `audit [target]` | Evaluate | Technical quality checks (a11y, perf, responsive) | [reference/audit.md](reference/audit.md) · native: [reference/audit.native.md](reference/audit.native.md) |
| `polish [target]` | Refine | Final quality pass before shipping | [reference/polish.md](reference/polish.md) |
| `bolder [target]` | Refine | Amplify safe or bland designs | [reference/bolder.md](reference/bolder.md) |
| `quieter [target]` | Refine | Tone down aggressive or overstimulating designs | [reference/quieter.md](reference/quieter.md) |
@@ -118,7 +119,7 @@ If someone could look at this interface and say "AI made that" without doubt, it
| `delight [target]` | Enhance | Add personality and memorable touches | [reference/delight.md](reference/delight.md) |
| `overdrive [target]` | Enhance | Push past conventional limits | [reference/overdrive.md](reference/overdrive.md) |
| `clarify [target]` | Fix | Improve UX copy, labels, and error messages | [reference/clarify.md](reference/clarify.md) |
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) |
| `adapt [target]` | Fix | Adapt for different devices and screen sizes | [reference/adapt.md](reference/adapt.md) · native: [reference/adapt.native.md](reference/adapt.native.md) |
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
| `live` | Iterate | Visual variant mode: pick elements in the browser, generate alternatives | [reference/live.md](reference/live.md) |
@@ -126,26 +127,26 @@ Plus three management commands: `pin <command>`, `unpin <command>`, and `hooks <
### Routing rules
1. **No argument**: the user is asking "what should I do?" Make the menu context-aware instead of static. Setup has already run `context.mjs`; if that reported `NO_PRODUCT_MD` you are already in init (setup), so finish that and skip this. Otherwise run `node .cursor/skills/impeccable/scripts/context-signals.mjs` once and read its JSON, then lead with the **2-3 highest-value next commands**, each with a one-line reason pulled from the signals, followed by the full menu (the table above, grouped by category). **Never auto-run a command; the recommendation is a suggestion the user confirms.**
1. **No argument**: the user is asking "what should I do?" Make the menu context-aware instead of static. Setup has already run `context.mjs`; if that reported `NO_PRODUCT_MD` the project has no captured context yet, so lead the menu with `/impeccable init` as the top recommendation (one line on why) and still show the rest below; don't silently jump into init. Otherwise run `node .cursor/skills/impeccable/scripts/context-signals.mjs` once and read its JSON, then lead with the **2-3 highest-value next commands**, each with a one-line reason pulled from the signals, followed by the full menu (the table above, grouped by category). **Never auto-run a command; the recommendation is a suggestion the user confirms.**
Reason over the signals; there is no score to obey:
- `setup.hasDesign` false while `setup.hasCode` true → `document` (capture the visual system).
- `critique.latest` is `null` → the project has never been critiqued; for a set-up project with a real surface, offering `/impeccable critique <surface>` is a strong default.
- `critique.latest` with a low `score` or non-zero `p0` / `p1``polish` (it reads that snapshot as its backlog), or re-run `critique` if the snapshot looks stale.
- `git.changedFiles` pointing at one surface → scope `audit` or `polish` to those files specifically, naming them.
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`.
- `devServer.running` true → `live` is available for in-browser iteration; if false, don't lead with `live`. **`live` and the bundled `detect.mjs` are web-only.** If `setup.platform` is `ios`, `android`, or `adaptive`, don't lead with either; the browser overlay and the HTML rule engine don't apply to native app code.
- Otherwise group by intent exactly as init's "Recommend starting points" step does (build new / improve what's there / iterate visually), tailored to `setup.register`.
**If `scan.targets` is non-empty, run `node .cursor/skills/impeccable/scripts/detect.mjs --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `node .cursor/skills/impeccable/scripts/detect.mjs --json <scan.targets joined by spaces>` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects). `scan.via` tells you what they are: `git-changes` (the markup/style files in your dirty tree, the most relevant set), `source-dir` (e.g. `src`, `app`), `html`, or `root`. Fold the hits into your picks: many quality / contrast hits → `audit` or `polish`; a specific slop family → the matching command (gradient text or eyebrows → `quieter` / `typeset`, flat or gray palette → `colorize`, and so on). It's a real, current signal that beats guessing. If detect errors or the tree is large and slow, skip it and recommend the user run `audit` themselves; never block the suggestion on it.
Keep it to 2-3 pointed picks with the exact command to type. The menu stays the fallback; the recommendation is the lede.
2. **First word matches a command** (table above OR `pin` / `unpin` / `hooks`): load its reference file and follow its instructions. Everything after the command name is the target.
3. **First word doesn't match, but the intent clearly maps to one command** (e.g. "fix the spacing" → `layout`, "rewrite this error message" → `clarify`, "the colors feel flat" → `colorize`): load that command's reference and proceed as if invoked. If two commands could fit, ask once which.
2. **First word matches a command** (table above OR `pin` / `unpin` / `hooks`): load its reference file (on native platforms, the table's native variant; Setup step 2's one-file rule) and follow its instructions. Everything after the command name is the target.
3. **First word doesn't match, but the intent clearly maps to one command** (e.g. "fix the spacing" → `layout`, "rewrite this error message" → `clarify`, "the colors feel flat" → `colorize`): load that command's reference (same native-variant rule) and proceed as if invoked. If two commands could fit, ask once which.
4. **No clear command match**: general design invocation. Apply the setup steps, the General rules, and the loaded register reference, using the full argument as context.
Setup (context gathering, register) is already loaded by then; sub-commands don't re-invoke `/impeccable`.
If the first word is `craft`, setup still runs first, but [reference/craft.md](reference/craft.md) owns the rest of the flow. If setup invokes `init` as a blocker, finish init, refresh context, then resume the original command and target.
If the first word is `craft` or `shape`, or routing rule 3 clearly maps the user's intent to either command, setup still runs first, but the matching reference ([reference/craft.md](reference/craft.md) or [reference/shape.md](reference/shape.md)) owns the rest of the flow. Both are from-scratch build flows: if setup invokes `init` as a blocker, finish init, refresh context, then resume the original command and target.
`teach` is a deprecated alias for `init`: if the user types it, load [reference/init.md](reference/init.md) and proceed as if they ran `init`.
@@ -2,6 +2,7 @@
Adapt an existing design to a different context: another screen size, device, platform, or use case. The trap is treating adaptation as scaling. The job is rethinking the experience for the new context.
**Web only** (mobile web included). Native platforms (`ios` / `android` / `adaptive`) route to [adapt.native.md](adapt.native.md) instead; if the project is native, switch to it now.
---
@@ -0,0 +1,58 @@
> **Additional context needed**: target platforms/devices and usage contexts.
Adapt an existing **native** design (`ios` / `android` / `adaptive`) to a different context: another device class, orientation, platform, or origin. The trap is treating adaptation as scaling. The job is rethinking the experience for the new context, inside the platform conventions of [ios.md](ios.md) / [android.md](android.md); read the target platform's reference before planning if Setup hasn't already.
## Assess Adaptation Challenge
1. **Source context**: what was it designed for, and what assumptions did it make? (Phone-only? Portrait-only? One platform's idioms? A website?)
2. **Target context**: which device class (phone, tablet, foldable), orientation, platform, and usage posture (one-handed on the go vs two-handed at rest)?
3. **What breaks**: navigation that doesn't fit the target, layouts that stretch instead of restructure, gestures or controls that don't exist there?
## Adaptation Strategies
### Phone → Tablet (iPad / large screens)
- **Restructure, don't stretch.** A scaled-up phone UI on a tablet is the failure mode. Use size classes (iOS) / window size classes (Android) to switch structure.
- **Navigation changes shape**: tab bar stays or becomes a sidebar on iPad; Android navigation bar becomes a rail or drawer on expanded width.
- **Use the width**: split view / master-detail (list + detail side by side), multi-column grids, popovers where phones used sheets.
- **Multitasking is a size, not an edge case**: iPad Split View and Android multi-window can hand you a phone-width window on a tablet; size-class-driven layout handles both for free.
### Orientation & foldables
- Landscape restructures (side-by-side panes, repositioned controls); never clip or letterbox. Lock orientation only when the task truly demands it.
- Foldables (Android): react to posture and hinge via window size classes; test folded, unfolded, and tabletop.
### Platform → platform (iOS ↔ Android)
Translate idioms; never transplant them:
| iOS | Android |
|---|---|
| Tab bar | Navigation bar / rail / drawer |
| Edge-swipe back, back chevron | Predictive Back gesture / button |
| Switch, segmented control, system pickers | Material switch, chips, Material pickers |
| Action sheet | Bottom sheet / Material dialog |
| SF Symbols, SF Pro, Dynamic Type | Material Symbols, Roboto, sp scaling |
| Semantic system colors, materials | Material color roles, tonal elevation |
| System push/sheet transitions | Container transform, shared-axis, fade-through |
Rebuild navigation and controls in the target's vocabulary; carry over the brand's expressive layer (palette intent, type accent, motion personality) through the target's theming system.
### Web → native (porting a website or web app)
Reconform, don't reflow. Replace web navigation with the platform's model, HTML-shaped controls with platform controls, hover affordances with touch-first ones, and px-based type with Dynamic Type / sp. Then treat the result to the full platform reference; the slop test there is the acceptance bar.
## Implement & Verify
- Drive structure from **size classes / window size classes**, never from device-model checks.
- Respect safe areas and window insets in every new configuration (notch, hinge, status bar, keyboard).
- Test on simulators for breadth, then real hardware for truth: at least one phone and one tablet per shipped platform, both orientations, split-screen where supported.
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
**NEVER**:
- Ship a stretched phone layout on a tablet
- Port one platform's controls or navigation onto the other
- Hide core functionality on smaller devices (if it matters, make it work)
- Lock orientation to dodge a layout bug
- Trust simulators alone (posture, gestures, and performance need hardware)
@@ -0,0 +1,40 @@
# Android platform
For native Android apps: Jetpack Compose, Android Views, React Native, Expo, Flutter shipping to Android hardware.
On native, register narrows. Material Design 3 governs structure, navigation, and interaction whatever the register; brand expresses through Material's theming (color roles, type scale, shape, motion). A Material-everywhere cross-platform app that also ships to iPhone still owes iOS its OS guarantees on that hardware: safe-area insets, Reduce Motion, edge-swipe back.
## The Android slop test
Would a fluent Android user trust this app, or trip on off-spec components? The most common tell is an iOS app wearing Android's skin: a bottom-only navigation copied from iPhone, a back arrow that ignores the system Back gesture, Cupertino-shaped switches and dialogs. Material 3 is the rulebook; follow its components and theme the brand through it.
## Layout & structure
- **Material navigation, matched to size.** Navigation bar (bottom, 35 destinations) on compact width; navigation rail or drawer on expanded width. Never ship a phone bottom-bar untouched on a tablet.
- **System Back always works.** Honor the predictive Back gesture and Back button; never trap the user or hijack the gesture.
- **Edge-to-edge with window insets.** Apply the status bar, navigation bar, display cutout, and IME insets so content never hides behind system bars or the keyboard.
- **Top app bar for screen context**; pair with a FAB when the screen has a single primary action.
## Touch targets
- **48×48 dp minimum** for every touch target, with at least 8 dp between them.
## Typography
- **Material type scale.** Display, Headline, Title, Body, Label roles (large/medium/small each). Map text to roles; never hand-pick sizes per screen.
- **Roboto is the system face**; theme a brand face in through the type scale, keeping body, labels, and controls legible and consistent.
- **sp units, never fixed px**, so type follows the system font-size setting.
## Color & theming
- **Material color roles** (primary, on-primary, surface, surface-variant, secondary-container, outline, error). Role tokens resolve light/dark and contrast variants automatically; raw hex breaks there.
- **Dynamic Color (Material You)** where it fits: derive the scheme from the user's wallpaper on Android 12+, with a static fallback.
- **Dark theme is a first-class scheme.** Design and test it; never a quick invert.
- **Tonal elevation.** Convey elevation through the standard surface tonal levels (plus shadow where appropriate); no arbitrary drop shadows.
## Components & motion
- **Material components.** Buttons (filled / tonal / outlined / text), FAB, switches, chips, snackbars, bottom sheets, Material dialogs, navigation bar/rail/drawer. Never port iOS controls or invent equivalents.
- **One FAB, one primary action.** Never stack FABs or spend one on a secondary task.
- **Snackbars for transient feedback** (actionable when useful, never a toast for that); dialogs only for decisions that must interrupt.
- **Material motion patterns.** Container transform, shared-axis, fade-through, with standard easing and durations; honor the system Remove animations setting with a crossfade or instant cut.
@@ -10,6 +10,8 @@ Brand: motion is part of the voice; one well-rehearsed entrance beats scattered
Product: 150250 ms on most transitions. Motion conveys state: feedback, reveal, loading, transitions between views. No page-load choreography; users are in a task and won't wait for it.
Native (`ios` / `android` / `adaptive`): implementation follows the Motion section of [ios.md](ios.md) / [android.md](android.md) (read it first if Setup hasn't already): system transitions and OS Reduce Motion, never the web tooling below.
---
## Assess Animation Opportunities
@@ -2,6 +2,8 @@ Run systematic **technical** quality checks and generate a comprehensive report.
This is a code-level audit, not a design critique. Check what's measurable and verifiable in the implementation.
**Web only.** Native platforms (`ios` / `android` / `adaptive`) route to [audit.native.md](audit.native.md) instead; if the project is native, switch to it now.
## Diagnostic Scan
Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the criteria below.
@@ -0,0 +1,139 @@
Run systematic **technical** quality checks on a native app (`ios` / `android` / `adaptive`) and generate a comprehensive report. Don't fix issues; document them for other commands to address.
This is a code-level audit, not a design critique. Audit from source (SwiftUI / UIKit / Compose / React Native / Flutter); no browser tooling or `detect.mjs` applies. Score against the platform reference(s): [ios.md](ios.md) / [android.md](android.md), both for `adaptive`. Read them before scoring if Setup hasn't already. The report skeleton mirrors [audit.md](audit.md); keep the two in sync when changing it.
## Diagnostic Scan
Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the criteria below.
### 1. Accessibility (VoiceOver / TalkBack)
**Check for**:
- **Missing labels**: interactive elements without accessibility labels, traits/roles, or state announcements
- **Reading and focus order**: illogical traversal, unreachable controls, focus lost on navigation
- **Text scaling**: fixed point sizes defeating Dynamic Type (iOS) or px instead of sp (Android); layouts that clip or overlap at large sizes
- **Touch targets**: below 44 pt (iOS) / 48 dp (Android), or crammed without spacing
- **Reduce Motion ignored**: parallax and large slides with no crossfade alternative
- **Contrast**: text failing contrast in either appearance, light or dark
**Score 0-4**: 0=Screen reader unusable, 1=Major gaps (unlabeled controls, no scaling), 2=Partial (labels exist, order or scaling breaks), 3=Good (minor gaps), 4=Excellent (labeled, ordered, scales cleanly, Reduce Motion honored)
### 2. Performance
**Check for**:
- **Slow startup**: heavy work on launch before first frame
- **Unvirtualized lists**: long content without FlatList / LazyColumn / List recycling
- **Main-thread jank**: synchronous work in scroll or gesture paths, dropped frames on 60/120 Hz
- **Wasted rendering**: unnecessary re-renders (React Native) or recompositions (Compose); missing memoization/keys
- **Image handling**: full-size images decoded for thumbnails, no caching
- **App weight**: bloated JS bundle or binary, unused dependencies
**Score 0-4**: 0=Janky everywhere, 1=Major problems (unvirtualized lists, slow launch), 2=Partial, 3=Good (minor improvements possible), 4=Excellent (fast launch, smooth scroll, lean)
### 3. Appearance & Theming
**Check for**:
- **Hard-coded colors**: raw hex instead of semantic system colors (iOS) / Material color roles (Android) / design tokens
- **Broken dark appearance**: missing dark variants, poor contrast in dark, quick inverts
- **Dynamic Color** (Android 12+): no static fallback scheme, or ignored where it fits
- **Off-platform materials**: hand-rolled blur/glassmorphism instead of system materials or tonal elevation
**Score 0-4**: 0=Hard-coded everything, 1=Minimal tokens, 2=Partial (tokens exist, inconsistently used), 3=Good (minor hard-coded values), 4=Excellent (semantic throughout, both appearances first-class)
### 4. Platform Conformance (CRITICAL)
Score against the loaded platform reference(s), including their slop tests. **Check for**:
- **Broken system gestures**: edge-swipe back disabled (iOS), predictive Back hijacked (Android)
- **Inset violations**: content under the notch, Dynamic Island, home indicator, status bar, or keyboard
- **Off-platform navigation**: custom global nav, overloaded tab bars, iOS patterns on Android or vice versa
- **Web-shaped controls**: HTML-style buttons, custom toggles, hover-dependent affordances
- **Icon drift**: mixed icon sets instead of SF Symbols / Material Symbols
- **AI tells**: the shared absolute bans still apply (AI palette, gradient text, hero metrics)
**Score 0-4**: 0=Web port (nothing native), 1=Heavy violations (3-4 kinds), 2=Some (1-2 noticeable), 3=Mostly conformant (subtle issues), 4=Fully native (a fluent user trusts every screen)
### 5. Adaptivity
**Check for**:
- **Stretched phone layouts**: tablet/iPad rendering a scaled-up phone UI instead of using size classes / window size classes
- **Orientation breakage**: landscape clipping, ignored, or locked without reason
- **Keyboard/IME handling**: inputs hidden behind the keyboard, no inset adjustment
- **Multitasking**: iPad Split View / Android multi-window breaking layout
- **Foldables**: hinge-unaware layouts on posture change (Android)
**Score 0-4**: 0=One screen size only, 1=Major breakage (landscape or tablet broken), 2=Partial, 3=Good (minor edge cases), 4=Excellent (adapts across sizes, orientations, and windowing)
## Generate Report
### Audit Health Score
| # | Dimension | Score | Key Finding |
|---|-----------|-------|-------------|
| 1 | Accessibility | ? | [most critical issue or "--"] |
| 2 | Performance | ? | |
| 3 | Appearance & Theming | ? | |
| 4 | Platform Conformance | ? | |
| 5 | Adaptivity | ? | |
| **Total** | | **??/20** | **[Rating band]** |
**Rating bands**: 18-20 Excellent (minor polish), 14-17 Good (address weak dimensions), 10-13 Acceptable (significant work needed), 6-9 Poor (major overhaul), 0-5 Critical (fundamental issues)
### Platform Conformance Verdict
**Start here.** Pass/fail: does this read as a native app or a ported website? List specific violations. Be brutally honest.
### Executive Summary
- Audit Health Score: **??/20** ([rating band])
- Total issues found (count by severity: P0/P1/P2/P3)
- Top 3-5 critical issues
- Recommended next steps
### Detailed Findings by Severity
Tag every issue with **P0-P3 severity**:
- **P0 Blocking**: Prevents task completion. Fix immediately
- **P1 Major**: Significant difficulty or platform-guideline violation. Fix before release
- **P2 Minor**: Annoyance, workaround exists. Fix in next pass
- **P3 Polish**: Nice-to-fix, no real user impact. Fix if time permits
For each issue, document:
- **[P?] Issue name**
- **Location**: Screen, file, line
- **Category**: Accessibility / Performance / Theming / Conformance / Adaptivity
- **Impact**: How it affects users
- **Guideline**: The HIG / Material rule it violates (if applicable)
- **Recommendation**: How to fix it
- **Suggested command**: Which command to use (prefer: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable delight, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset)
### Patterns & Systemic Issues
Identify recurring problems that indicate systemic gaps rather than one-off mistakes:
- "Hard-coded colors appear in 15+ screens, should use semantic colors"
- "Touch targets consistently below 44 pt throughout the tab bar and list rows"
### Positive Findings
Note what's working well: good practices to maintain and replicate.
## Recommended Actions
List recommended commands in priority order (P0 first, then P1, then P2):
1. **[P?] `/command-name`**: Brief description (specific context from audit findings)
2. **[P?] `/command-name`**: Brief description (specific context)
**Rules**: Only recommend commands from: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable delight, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset. Map findings to the most appropriate command. End with `/impeccable polish` as the final step if any fixes were recommended.
After presenting the summary, tell the user:
> You can ask me to run these one at a time, all at once, or in any order you prefer.
>
> Re-run `/impeccable audit` after fixes to see your score improve.
**IMPORTANT**: Be thorough but actionable. Too many P3 issues creates noise. Focus on what actually matters.
**NEVER**:
- Report issues without explaining impact (why does this matter?)
- Provide generic recommendations (be specific and actionable)
- Skip positive findings (celebrate what works)
- Forget to prioritize (everything can't be P0)
- Report false positives without verification
+66 -59
View File
@@ -1,12 +1,12 @@
When asked for "bolder," AI defaults to the same tired tricks: cyan/purple gradients, glassmorphism, neon accents on dark backgrounds, gradient text on metrics. These are the opposite of bold. Reject them first, then increase visual impact and personality through stronger hierarchy, committed scale, and decisive type.
When asked for "bolder," AI defaults to the same tired tricks: cyan/purple gradients, glassmorphism, neon accents on dark backgrounds, gradient text on metrics. These are the opposite of bold. Reject them first, then increase visual impact by making the existing design language more decisive, specific, and committed.
---
## Register
Brand: "bolder" means distinctive. Extreme scale, unexpected color, typographic risk, committed POV.
Brand: "bolder" means distinctive. Express a stronger point of view through hierarchy, pacing, proportion, copy, evidence, and one committed visual idea.
Product: "bolder" rarely means theatrics; those undermine trust. It means stronger hierarchy, clearer weight contrast, one sharper accent, more committed density. The amplification is in clarity, not drama.
Product: "bolder" rarely means theatrics; those undermine trust. It means stronger hierarchy, clearer weight contrast, sharper information density, and more decisive prioritization. The amplification is in clarity, not drama.
---
@@ -15,98 +15,105 @@ Product: "bolder" rarely means theatrics; those undermine trust. It means strong
Analyze what makes the design feel too safe or boring:
1. **Identify weakness sources**:
- **Generic choices**: System fonts, basic colors, standard layouts
- **Timid scale**: Everything is medium-sized with no drama
- **Low contrast**: Everything has similar visual weight
- **Static**: No motion, no energy, no life
- **Predictable**: Standard patterns with no surprises
- **Flat hierarchy**: Nothing stands out or commands attention
- **Generic choices**: The page could belong to any product in the category.
- **Timid scale**: Everything is medium-sized with no clear lead.
- **Low contrast**: Important and supporting elements have similar visual weight.
- **Static**: The surface has no meaningful moment of emphasis.
- **Predictable**: The composition follows a default pattern without a point of view.
- **Flat hierarchy**: Nothing stands out or commands attention.
2. **Understand the context**:
- What's the brand personality? (How far can we push?)
- What's the purpose? (Marketing can be bolder than financial dashboards)
- Who's the audience? (What will resonate?)
- What are the constraints? (Brand guidelines, accessibility, performance)
- What is the brand personality?
- What is the purpose of this surface?
- Who is the audience?
- What design system, tokens, components, and visual conventions already exist?
If any of these are unclear from the codebase, ask the user directly to clarify what you cannot infer.
**CRITICAL**: "Bolder" doesn't mean chaotic or garish. It means distinctive, memorable, and confident. Think intentional drama, not random chaos.
**CRITICAL**: "Bolder" does not mean chaotic or garish. It means distinctive, memorable, and confident. Think intentional drama, not random noise.
**WARNING - AI SLOP TRAP**: Review ALL the DON'T guidelines from the parent impeccable skill (already loaded in this context) before proceeding. Bold means distinctive, not "more effects."
## Design-System Lock
If the project has `DESIGN.md`, tokens, theme variables, or established component styles, treat that system as the boundary. Make the existing language stronger before adding new language.
Do not invent new colors, gradients, radii, shadows, fonts, decorative backgrounds, or effects just because the request says "bolder." A bolder pass should usually change emphasis, proportion, rhythm, density, contrast, copy, artifact specificity, and layout relationships while staying inside the documented system.
If the existing system is genuinely too limited to express the bolder direction, stop and ask the user before expanding it. Name the exact additions, the role each would play, and why the current system cannot do the job. If the user approves expansion, update the design system or tokens alongside the implementation.
## Plan Amplification
Create a strategy to increase impact while maintaining coherence:
- **Focal point**: What should be the hero moment? (Pick ONE, make it amazing)
- **Personality direction**: Maximalist chaos? Elegant drama? Playful energy? Dark moody? Choose a lane.
- **Risk budget**: How experimental can we be? Push boundaries within constraints.
- **Hierarchy amplification**: Make big things BIGGER, small things smaller (increase contrast)
- **Focal point**: Pick one thing the viewer should remember, then make the rest support it.
- **System levers**: Identify which existing tokens, components, layout patterns, and copy structures can carry more weight.
- **Risk budget**: Decide how far the surface can push while still feeling like the same product or brand.
- **Hierarchy amplification**: Increase contrast between primary, secondary, and tertiary content instead of making every element louder.
**IMPORTANT**: Bold design must still be usable. Impact without function is just decoration.
## Amplify the Design
Systematically increase impact across these dimensions:
Systematically increase impact through intention, not a menu of effects:
### Typography Amplification
- **Replace generic fonts**: Swap system fonts for distinctive choices (see the parent skill's typography guidelines and the [Reference Material section of typeset.md](typeset.md#reference-material) for inspiration)
- **Extreme scale**: Create dramatic size jumps (3x-5x differences, not 1.5x)
- **Weight contrast**: Pair 900 weights with 200 weights, not 600 with 400
- **Unexpected choices**: Variable fonts, display fonts for headlines, condensed/extended widths, monospace as intentional accent (not as lazy "dev tool" default)
- Strengthen the existing type hierarchy before changing typefaces.
- Make important text meaningfully more dominant, and make supporting text quieter.
- Use weight, measure, spacing, and line breaks to sharpen the point of view.
- Add or replace fonts only after user-approved design-system expansion.
### Color Intensification
- **Increase saturation**: Shift to more vibrant, energetic colors (but not neon)
- **Bold palette**: Introduce unexpected color combinations. Avoid the purple-blue gradient AI slop
- **Dominant color strategy**: Let one bold color own 60% of the design
- **Sharp accents**: High-contrast accent colors that pop
- **Tinted neutrals**: Replace pure grays with tinted grays that harmonize with your palette
- **Rich gradients**: Intentional multi-stop gradients (not generic purple-to-blue)
### Color Amplification
- Use the existing palette more decisively before adding colors.
- Shift the proportion, placement, and contrast of documented colors to clarify meaning.
- Treat any new color, gradient, or tint ramp as a design-system expansion that requires user approval.
- Keep color tied to hierarchy, state, or brand meaning; do not use it as surface decoration.
### Spatial Drama
- **Extreme scale jumps**: Make important elements 3-5x larger than surroundings
- **Break the grid**: Let hero elements escape containers and cross boundaries
- **Asymmetric layouts**: Replace centered, balanced layouts with tension-filled asymmetry
- **Generous space**: Use white space dramatically (100-200px gaps, not 20-40px)
- **Overlap**: Layer elements intentionally for depth
### Spatial Amplification
- Change proportion, density, alignment, and sequencing so the composition has a stronger point of view.
- Create clearer contrast between dense evidence and open breathing room.
- Let layout express priority and narrative order before adding ornament.
- Preserve responsive behavior and avoid text overflow at every breakpoint.
### Visual Effects
- **Dramatic shadows**: Large, soft shadows for elevation (but not generic drop shadows on rounded rectangles)
- **Background treatments**: Mesh patterns, noise textures, geometric patterns, intentional gradients (not purple-to-blue)
- **Texture & depth**: Grain, halftone, duotone, layered elements. NOT glassmorphism (it's overused AI slop)
- **Borders & frames**: Thick borders, decorative frames, custom shapes (not rounded rectangles with colored border on one side)
- **Custom elements**: Illustrative elements, custom icons, decorative details that reinforce brand
### Surface Amplification
- Use existing surface, border, radius, and shadow rules more deliberately.
- Remove timid half-measures: either give an element a clear role or simplify it.
- Add texture, depth, illustration, or decorative treatments only when already established by the system or explicitly approved.
- Make real product artifacts, imagery, data, or copy carry attention before reaching for effects.
### Motion & Animation
- **Hero moment**: One signature entrance, once. Not on every visit and not on every section.
- **Micro-interactions**: Satisfying hover effects, click feedback, state changes.
- **Transitions**: Smooth, noticeable transitions using ease-out-quart/quint/expo (not bounce or elastic, which cheapen the effect).
- **Bolder scroll-fade-rise on every section.** That's the saturated AI default, the opposite of bold.
- Design one meaningful moment of emphasis when motion genuinely supports the point.
- Make interaction feedback feel more decisive without becoming distracting.
- Keep transitions smooth and intentional.
- **Bolder != scroll-fade-rise on every section.** That's the saturated AI default, the opposite of bold.
### Composition Boldness
- **Hero moments**: Create clear focal points with dramatic treatment
- **Diagonal flows**: Escape horizontal/vertical rigidity with diagonal arrangements
- **Full-bleed elements**: Use full viewport width/height for impact
- **Unexpected proportions**: Golden ratio? Throw it out. Try 70/30, 80/20 splits
- Make the dominant idea unmistakable.
- Use layout tension, sequencing, contrast, and restraint to create a stronger read.
- Let the page's structure communicate priority before adding decorative layers.
- If every element is louder, the composition is not bolder; it is flatter.
**NEVER**:
- Add effects randomly without purpose (chaos ≠ bold)
- Sacrifice readability for aesthetics (body text must be readable)
- Make everything bold (then nothing is bold; you need contrast)
- Ignore accessibility (bold design must still meet WCAG standards)
- Overwhelm with motion (animation fatigue is real)
- Copy trendy aesthetics blindly (bold means distinctive, not derivative)
- Add undocumented design-system primitives without user approval
- Add effects randomly without purpose
- Hide weak hierarchy behind decoration
- Sacrifice readability for aesthetics
- Make everything bold; contrast is the point
- Ignore accessibility
- Overwhelm with motion
- Copy trendy aesthetics blindly
## Verify Quality
Ensure amplification maintains usability and coherence:
- **System-faithful**: Did the pass make the existing design language stronger before adding anything new?
- **No undocumented drift**: Are new colors, gradients, shadows, radii, fonts, and effects either absent or explicitly approved and documented?
- **NOT AI slop**: Does this look like every other AI-generated "bold" design? If yes, start over.
- **Still functional**: Can users accomplish tasks without distraction?
- **Coherent**: Does everything feel intentional and unified?
- **Memorable**: Will users remember this experience?
- **Performant**: Do all these effects run smoothly?
- **Accessible**: Does it still meet accessibility standards?
- **Memorable**: Will users remember this experience for the intended reason?
- **Performant and accessible**: Does the result stay fast, readable, responsive, and WCAG-conscious?
**The test**: If you showed this to someone and said "AI made this bolder," would they believe you immediately? If yes, you've failed. Bold means distinctive, not "more AI effects."
@@ -5,8 +5,9 @@ Resolve one stable target, run two independent assessments, synthesize a design
### Hard Invariants
- Assessment A (design review) and Assessment B (detector/browser evidence) are both required.
- Assessment A and B MUST run as two isolated sub-agents whenever a sub-agent/Task tool is exposed. Running them inline in this context is "possible" but is NOT permitted; it is a degraded run. Inline is allowed ONLY when no sub-agent tool exists (or the user declined, on harnesses that ask).
- If you degrade for any reason, the report's first line MUST be a banner: `⚠️ DEGRADED: single-context (<reason>)`. A silent degraded critique is a failed critique.
- Assessment A must finish before detector findings enter the parent synthesis context. Detector output is deterministic, but it still anchors judgment.
- If sub-agents are unavailable, fall back sequentially: finish and record Assessment A first, then run Assessment B, then synthesize.
- A skipped detector is a failed critique run unless `detect.mjs` is missing or crashes after a real attempt.
- Viewable targets require browser inspection when available.
- Any local server started only for critique visualization must run in the background, have a recorded stop method, and be stopped before final reporting unless the user asks to keep it.
@@ -27,7 +28,13 @@ Resolve one stable target, run two independent assessments, synthesize a design
### Assessment Orchestration
Delegate Assessment A and Assessment B to separate sub-agents when possible. They must not see each other's output. Do not show findings to the user until synthesis.
Delegate Assessment A and Assessment B to separate sub-agents. They must not see each other's output. Do not show findings to the user until synthesis.
Sub-agent gate (all harnesses):
- Unless a harness-specific gate below overrides this, spawn A and B as two isolated, parallel sub-agents whenever a sub-agent/Task tool is exposed. This is the default and is mandatory; do not run them inline because it is faster.
- "Unavailable" means exactly one thing: no sub-agent/Task tool is exposed in this session (or, on harnesses that ask, the user declined). It does not mean inconvenient.
- If and only if sub-agents are unavailable, fall back sequentially: finish and record Assessment A, then run Assessment B, then synthesize, and emit the degraded banner.
- Whichever path you take, declare it in the report header (see Report header provenance). Skipping sub-agents without the banner is the most common failure of this command.
If browser automation is available, each assessment creates its own new tab. Never reuse an existing tab, even if it is already at the right URL.
@@ -61,7 +68,7 @@ node .cursor/skills/impeccable/scripts/detect.mjs --json [target]
Browser visualization is required for a viewable target when browser automation is available. Use a localhost dev/static URL for local files; avoid `file://` unless the available browser explicitly supports this workflow. Overlay flow:
1. Create a fresh tab and navigate.
1. Create a fresh tab and navigate. Prefer the harness's native/browser-canvas screenshot path before hand-rolling a Playwright/Puppeteer script; only fall back to a custom script when no native browser tool is exposed.
2. Preflight mutable injection by setting `document.title` and appending a `<script>` tag. Read-only evaluate APIs do not count.
3. If mutation is unavailable, skip live server, browser presentation, and injection; report fallback signal.
4. If mutation is available, start `node .cursor/skills/impeccable/scripts/live-server.mjs --background`, present the browser if supported, label `[Human]`, scroll top, inject `http://localhost:PORT/detect.js`, wait 2-3 seconds, read `impeccable` console messages, then stop the live server.
@@ -79,6 +86,12 @@ The chat response is the primary user-facing deliverable. Present the full struc
Structure your feedback as a design director would:
#### Report header provenance
The report's first line MUST declare how the assessments were run, so a degraded run is never silent:
- Dual-agent: `Method: dual-agent (A: <agent-id> · B: <agent-id>)`
- Degraded: `⚠️ DEGRADED: single-context (<reason, e.g. no sub-agent tool exposed>)`
#### Design Health Score
> *Consult the [Heuristics Scoring Guide](#heuristics-scoring-guide) section below.*
@@ -1,6 +1,6 @@
Generate a `DESIGN.md` file at the project root that captures the current visual design system, so AI agents generating new screens stay on-brand.
DESIGN.md follows the [official Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/): YAML frontmatter carrying machine-readable design tokens, followed by a markdown body with exactly six sections in a fixed order. **Tokens are normative; prose provides context for how to apply them.** Sections may be omitted when not relevant, but **do not reorder them and do not rename them**. Section headers must match the spec character-for-character so the file stays parseable by other DESIGN.md-aware tools (Stitch itself, awesome-design-md, skill-rest, etc.).
DESIGN.md follows the [official DESIGN.md format spec](https://raw.githubusercontent.com/google-labs-code/design.md/main/docs/spec.md): YAML frontmatter carrying machine-readable design tokens, followed by a markdown body with exactly six sections in a fixed order. **Tokens are normative; prose provides context for how to apply them.** Sections may be omitted when not relevant, but **do not reorder them and do not rename them**. Section headers must match the spec character-for-character so the file stays parseable by other DESIGN.md-aware tools (Stitch itself, awesome-design-md, skill-rest, etc.).
## The frontmatter: token schema
+8 -6
View File
@@ -2,13 +2,15 @@
Manage the **design detector hook** for the current project.
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code and Codex use `PostToolUse` and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
Declare server-side template extensions under **`detector.extensions`** when the project uses Blade, Twig, ERB, or Handlebars files; the hook skips them otherwise because they sit outside the built-in extension list. One entry per extension, `{ "ext": ".blade.php", "engine": "html" }`. `engine` picks the analyzer (`html` for markup templates, `text` for JS/TS/CSS-like files) and defaults to `html`. Match against the end of the filename, so double extensions like `.blade.php` and `.html.erb` work. Config only adds extensions; the built-in list always applies.
Manual `npx impeccable detect` scans use the same project filter config by default: `detector.ignoreRules`, `detector.ignoreFiles`, `detector.ignoreValues`, and `detector.designSystem.enabled`. `hook.enabled` only controls automatic hook execution, not manual CLI scans. Use `npx impeccable detect --no-config ...` for a raw detector run that ignores project config/context. Use `npx impeccable ignores ...` for direct CLI CRUD on the same detector ignores.
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), and Cursor (`.cursor/hooks.json` in the project).
Supported harnesses: Claude Code (`.claude/settings.local.json` in the project, which is gitignored so the hook stays machine-local; a hook you move into the shared `settings.json` is honored in place too), Codex (`.codex/hooks.json` in the project), Cursor (`.cursor/hooks.json` in the project), and GitHub Copilot (`.github/hooks/impeccable.json` in the project, a team-shared committed file that both the Copilot CLI and the cloud agent read). For the Copilot CLI, repo-level hooks fire once `.github/hooks/impeccable.json` is committed to the repository's default branch.
On **Cursor**, `preToolUse` checks proposed Write/Edit/Shell write content and denies only when the real detector finds an issue. The denial message is visible to the agent as the tool error, so the agent can reconsider before the bad write lands.
@@ -51,7 +53,7 @@ Prefer the narrowest exception:
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` when the user confirms the specific value. Do not use `ignore-rule overused-font` for a specific font.
- If the finding has no value-specific command, such as `side-tab`, prefer `ignore-file <path>` for the current file.
- Use `ignore-rule <id>` only when the user asks to suppress that whole rule across the project. For broad overused-font suppression, use `ignore-rule overused-font --all-values` only when the user asks to ignore overused fonts generally.
- Do not add source comments such as `impeccable: ignore`; inline comments pollute code and are not a supported suppression mechanism.
- Prefer config ignores (the commands above) by default; they keep suppressions in one reviewable place. Reach for an inline comment only when the waiver must travel with a single file that leaves the repo (a generated/exported standalone document, an emailed HTML file). The supported marker is `impeccable-disable <rule>` (whole file) or `impeccable-disable-line` / `impeccable-disable-next-line` (one line), in any comment syntax, with an optional reason after `:` or `--`. The detector honors it by default; `--no-inline-ignores` or `--no-config` bypasses it.
Example value-specific exception:
@@ -79,10 +81,10 @@ node .cursor/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Ca
## Constraints
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent.
- Never modify `.impeccable/config.json` or `.impeccable/config.local.json` by hand from this command. Always go through `hook-admin.mjs` so writes stay validated and the file shape stays consistent. One exception: `detector.extensions` has no admin action, so when the user asks to cover a template stack, edit that one field in `.impeccable/config.json` directly and leave the rest of the file untouched.
- Do not edit the hook scripts themselves (`hook.mjs`, `hook-lib.mjs`, `hook-before-edit.mjs`) from this flow. Those are skill plumbing.
- Cursor can block a proposed write when the detector finds a real issue. Claude Code and Codex do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, and `.cursor/hooks.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks.
- Cursor can block a proposed write when the detector finds a real issue. Claude Code, Codex, and GitHub Copilot do not block the edit; they emit a post-edit reminder instead. Disabling stops both blocking and reminders.
- The hook is bundled with the Impeccable skill and installed through project-local manifests: `.claude/settings.local.json`, `.codex/hooks.json`, `.cursor/hooks.json`, and `.github/hooks/impeccable.json`. On Codex, the user must approve the hook via `/hooks` the first time. On Cursor, confirm hooks are enabled under Settings -> Hooks. On GitHub Copilot, the CLI loads `.github/hooks/impeccable.json` once it is committed to the repository's default branch, and the cloud agent reads it from the repo directly.
## Failure modes

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