Compare commits

...
86 Commits
Author SHA1 Message Date
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
Paul Bakaus b928fe35fb Refine CLI release changelog 2026-06-16 13:10:16 +09:00
github-actions[bot] 08f1147b69 Sync generated provider output 2026-06-16 04:06:48 +00:00
Paul BakausandGitHub 51d01e3a5f [codex] Add design-aware detector rules (#252)
* Add design-aware detector rules

* Fix design-aware detector noise

* Unify CLI and hook detector ignores

* Fix remaining design-system review findings

* Add detector ignore CLI

* Fix design detector review findings

* Fix design color source false positives

* Fix core test suite registration

* Add design-aware detector docs

* Fix font priority design-system parsing

* Fix color ignore value matching
2026-06-15 21:06:17 -07:00
c27a75ad41 fix(cli): replace extract-zip with fflate to fix silent install on Node v24.16.0+ (#253)
On Node v24.16.0 / v26.1.0+, `impeccable install` printed "Downloading
impeccable skills...", exited 0, and installed nothing. A Node streams
regression (nodejs/node#63487) made pause()/resume() no-ops on destroyed
streams, stalling extract-zip's yauzl/fd-slicer read stack partway through;
its promise never settled and the process exited clean with no error.

Swap extract-zip for fflate across both extraction call sites
(downloadAndExtractBundle, copyOrExtractLocalBundle) via a new extractZip
helper. fflate decompresses from an in-memory buffer and never touches the
fs stream path, so it is immune on every Node version. It is pure JS with
zero dependencies, so the Windows fix from #198 (no `unzip` binary) holds.
Unlike extract-zip, fflate is actively maintained.

Because extractZip writes entries itself, it guards against zip-slip (`../`
entries escaping the target dir). Tests add a many-file regression guard
(fails on partial extraction) and a zip-slip rejection test.

Verified end-to-end: the real 1,194-file universal bundle extracts and
installs completely.

Fixes #250.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-15 20:33:43 -07:00
Paul Bakaus fff712ca98 Refine release changelog copy 2026-06-15 13:57:08 +09:00
Paul Bakaus 23eae3cc5b Fix homepage polish and update stars 2026-06-15 13:46:15 +09:00
Paul Bakaus 858b9bbea6 Improve hook false-positive handling 2026-06-15 13:30:28 +09:00
github-actions[bot] a9c15481a9 Sync generated provider output 2026-06-15 04:05:09 +00:00
Paul Bakaus 6443980117 Improve CLI install prompts 2026-06-15 13:04:25 +09:00
Paul Bakaus 636249cae0 Revert CLI patch release metadata 2026-06-15 09:32:15 +09:00
Paul Bakaus 32c01595e2 Prepare CLI 3.0.1 install targeting fix 2026-06-15 09:14:29 +09:00
Paul Bakaus 9b0b63c04f Prepare CLI 3.0.0, skill 3.6.0, extension 1.2.0 2026-06-14 21:51:14 +09:00
github-actions[bot] 50f68ffffc Sync generated provider output 2026-06-14 09:42:49 +00:00
8cf2be110d feat(cli): interactive hook consent + unified .impeccable/config.json (#245)
* feat(cli): interactive hook consent + unified .impeccable/config.json

Make the design-hook install a conscious choice and unify scattered config
into one file.

Interactive consent
- On an interactive `skills install`/`update`, the CLI explains what the hook
  does and offers to install it (default yes), then records the per-developer
  decision in the gitignored `.impeccable/config.local.json`, so it never
  re-asks. A recorded decision or an already-installed hook short-circuits;
  `-y`/non-TTY keeps the historical install-by-default behavior; `--no-hooks`
  is a one-off skip that records nothing. The trigger keys on "is the hook
  installed?" + "is there a recorded decision?", not a brittle version check.

Unified config
- `.impeccable/config.json` (shared) and `.impeccable/config.local.json`
  (gitignored) now hold all Impeccable settings: hook settings under a `hook`
  key, plus top-level `updateCheck`. `/impeccable hooks` writes the `hook`
  subtree, preserving siblings. The hook runtime reads `hook.quiet` and
  `hook.auditLog`; context boot reads `updateCheck`. The legacy
  `IMPECCABLE_HOOK_DISABLED|QUIET|LOG` and `IMPECCABLE_NO_UPDATE_CHECK` env vars
  still work and override config; docs now lead with config and treat env vars
  as a legacy note.
- No backward compat for the pre-unification `hook.json`/`hook.local.json`
  (the hook shipped an hour ago; nothing in the wild uses it). This repo's own
  hook config is migrated to `.impeccable/config.json`.

The CLI and skill scripts are separate trees, so a small CLI-side config module
(cli/lib/impeccable-config.mjs) duplicates the config-path and .git/info/exclude
handling; comments flag the duplication.

Tests: new cli config unit test; skills-cli consent tests (declined skips,
accepted installs, --no-hooks records nothing); hook.test.mjs back-compat
removed and quiet/auditLog-from-config + gitexclude coverage added. Full suite
green.

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

* fix(hooks): preserve sibling config fields + resolve audit log from event cwd (Bugbot)

Two Bugbot findings:

- High: `/impeccable hooks` edits replaced the whole `hook` object with the
  merge-helper output, dropping fields those helpers don't manage — so an
  `ignore-value --local` could wipe the recorded install consent and make the
  CLI re-prompt. writeConfig now merges over the existing hook object, keeping
  consent/quiet/auditLog.
- Medium: config-based audit logging resolved hook.auditLog from process.cwd(),
  which can differ from the hook event's project root (and Cursor's pre-edit
  hook passed no cwd). The hook now stamps the resolved project root on the
  audit entry, and writeAuditLog reads config from entry.cwd when present.

Tests: a /impeccable hooks edit preserves consent + quiet; writeAuditLog
resolves config auditLog from entry.cwd, not the fallback cwd.

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

* fix(hooks): resolve a relative auditLog path against the project root (Bugbot)

A relative hook.auditLog was read from the project root but written relative to
the hook process cwd, so when those differ the log went to the wrong place.
writeAuditLog now resolves a relative target (from env or config) against the
same project root it reads config from. Absolute and ~/ paths are unchanged.

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

* Fix hook consent recovery and smoke config

* Fix hook consent explainer for Cursor

* Fix empty hook target consent

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 02:42:19 -07:00
9c0012d4e1 feat(hooks): package design hook in plugin, install to settings.local.json (#243)
* feat(hooks): package design hook in plugin, install to settings.local.json

Three related changes to how the Impeccable design hook is distributed,
plus an unrelated build fix discovered along the way.

Package the hook in the Claude Code plugin
- The marketplace / `/plugin install` path previously shipped the skill and
  agents but no hook, so those users never got the design detector. The build
  now emits `plugin/hooks/hooks.json` (auto-discovered at the plugin root),
  resolving the script via `${CLAUDE_PLUGIN_ROOT}` so it works wherever Claude
  Code unpacks the plugin instead of assuming a `.claude/skills/` layout.

CLI installs the hook into settings.local.json, not shared settings.json
- `npx impeccable skills install/update` now writes the Claude hook to the
  gitignored `.claude/settings.local.json` (a machine-local install side
  effect) rather than the team-shared `settings.json`, which could otherwise
  be committed and break for teammates without the skill installed.
- Graceful handling (leave-it-never-duplicate): if our hook already lives in
  the shared `settings.json` (a legacy install or a deliberate user move), it
  is honored in place and never duplicated into the local override, which
  would otherwise run the detector twice per edit.
- The skill's `/impeccable hooks on|off` toggle is unaffected: it only writes
  `.impeccable/hook.json`, never the settings files.

Fix universal.zip build failure under archiver v8
- `archiver` was bumped to v8 (now ESM, factory function removed) but
  `scripts/lib/zip.js` still used the old `archiver('zip', ...)` API, so every
  build silently failed to produce `dist/universal.zip` (the skill-release
  artifact). Switched to `new ZipArchive({...})`.

Also folds in a pre-existing local rename of the hook status message
("Scanning design" -> "Checking UI changes") and its regenerated provider
output.

Tests: new coverage for the plugin-packaged hook manifest and the
shared-settings honor-in-place path; existing CLI assertions moved to
settings.local.json. Full suite green.

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

* fix(cli): detect hook by marker, not file existence (Bugbot)

hookInstalledForProvider treated any existing settings.local.json (or
hooks.json) as proof the hook was installed. Those files commonly hold
unrelated local settings, so the already-installed `skills install` path
would skip repairing a genuinely missing hook that `update` would add.

Detect the Impeccable marker in the file instead of mere existence. Adds a
test for the exact case: a settings.local.json with only permissions still
triggers hook repair and preserves the unrelated content.

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

* test(build): fail loud on a broken release zip + cover the zip writer

Close the gap that let the archiver v8 break ship a 0-byte universal.zip
with a green test suite:

- createProviderZip no longer swallows failures. It throws on a missing
  source, an archive with zero entries, or a 0-byte output, and build() now
  exits non-zero on any such rejection. A build that can't produce its release
  artifact fails instead of deploying an empty bundle.
- New tests/zip.test.mjs exercises the real zip writer and round-trips through
  extract-zip (the unpacker the CLI uses): a valid bundle unpacks to the skill
  tree, and the empty/missing-source cases throw. Wired into the core suite so
  it runs in `bun run test`.

Why this matters: the prior CLI e2e tests stub the bundle as a local
directory, so they never built, downloaded, or unzipped a real archive. The
zip writer had no coverage and failed soft, so Dependabot's archiver 7->8
major bump merged green and the deploy shipped an unusable bundle.

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

* fix(cli): scope hook marker scan to the hooks subtree + prune local dupes (Bugbot)

Two follow-ups from Bugbot:

- fileHasImpeccableHookMarker scanned the whole settings file as raw text, so
  an unrelated string (e.g. a permissions allow entry that mentions the hook
  path) could falsely read as an installed hook and block install/repair or
  the shared-settings skip. Now it parses the JSON and scans only the `hooks`
  subtree.
- When the hook is honored in the shared settings.json, copyProviderHooks
  skipped the local write but left a stale hook in settings.local.json from an
  earlier machine-local install, so Claude Code loaded both and ran the
  detector twice per edit. It now prunes the local copy (preserving unrelated
  local settings, dropping the file if only our scaffolding remained).

Adds tests for both: a permissions string mentioning the hook path still
triggers repair, and a shared hook prunes the stale local duplicate while
keeping unrelated permissions.

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-13 23:54:46 -07:00
github-actions[bot] 0ec64aad1b Sync generated provider output 2026-06-14 04:19:51 +00:00
672517f76e Add automatic design hook install and exceptions (#170)
* docs: add PRD for design detector hook integration

Plans a PostToolUse hook for Claude Code and Codex that runs the
existing design detector after every relevant file write and feeds
findings back to the agent as advisory system-reminder context. No
implementation in this commit; covers UX, technical design, build
pipeline changes, distribution, coverage tradeoffs, and rollout.

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

* docs: revise hook PRD with best-practices review

Folds in the P0/P1/P2 findings from an online best-practices critique
against the official Claude Code and Codex hook references plus 10+
2026 community guides and similar prior-art tools (claw-hooks,
claude-code-hooks-mastery).

Key changes:
- Exec form everywhere (Codex snippet was shell form), with Windows
  rationale.
- Default timeout dropped from 10s to 5s.
- Re-entrancy guard (CLAUDE_HOOK_DEPTH) and per-file edit counter.
- Session-scoped finding dedup promoted from open question to v1.
- Per-language inline-ignore syntax map (HTML/JSX/CSS/JS).
- Hard-skip rules for sensitive paths and generated/lock files.
- Honest framing about Claude Code lacking per-plugin hook disable.
- Honest framing about Bash-written files being invisible in v1.
- Codex Windows-not-supported call-out, feature flag note, trust ceremony detail.
- Optional NDJSON audit log via IMPECCABLE_HOOK_LOG.
- Findings cap lowered 8 → 5 with attention-budget rationale.
- Versioned envelope ([impeccable@1]) on rendered template.
- Expanded test plan, decision log, and stdin payload appendix.

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

* feat(hooks): ship the design detector hook for Claude Code and Codex

Implements docs/hooks-prd.md: a PostToolUse hook that runs the
impeccable design detector after every Edit/Write/MultiEdit on a UI
file and pushes findings into the agent's next-turn context as a
short system reminder. Silent on clean files. Never blocks an edit.

Why this matters: today, design slop (side-tab borders, gradient
text, purple/cyan palettes, bounce easing, etc.) only gets caught
when a human notices or someone explicitly runs /impeccable audit.
The hook closes the loop at the moment slop is written.

What ships in v1
- skill/scripts/hook.mjs: PostToolUse entry. Reads stdin, runs the
  detector in-process (no `npx impeccable` cold start), emits
  hookSpecificOutput.additionalContext when fresh findings exist.
- skill/scripts/hook-lib.mjs: extracted helpers (config, cache,
  filter, render, audit log, runHook orchestrator). 100% unit-testable.
- skill/scripts/hook-session-start.mjs: SessionStart greeting,
  gated by a project-scannable probe + 30-day throttle.
- skill/scripts/hook-admin.mjs: backs /impeccable hooks
  on/off/status/ignore-rule/ignore-file/reset.

Hardening built in
- Re-entrancy guard (IMPECCABLE_HOOK_DEPTH) so the hook can never
  recursively spawn itself.
- Hard-skip regexes for sensitive paths (.env, .pem, id_rsa,
  secrets, credentials, .git) and generated/lock/build output. These
  fire before the file is even read; cannot be turned off via config.
- Path-traversal check on the inbound file_path.
- Session-scoped dedup keyed by (session, file, rule, line) so the
  same finding never lands in context twice. Prevents the ~12.5K
  wasted tokens per chatty session called out in the PRD.
- Per-(session, file) edit counter with a one-shot suppression
  notice on the 7th edit, silent after.
- Fail-open contract: every error path returns exit 0 with no
  stdout. Optional NDJSON audit log via IMPECCABLE_HOOK_LOG.

Three kill switches (precedence high to low):
1. IMPECCABLE_HOOK_DISABLED env var (1/true/yes/on, case-insensitive)
2. .impeccable/hook.json `enabled: false`
3. /impeccable hooks off slash command (writes the JSON)

Inline ignores are language-aware. `// impeccable: ignore <rule>` for
JS/TS, `<!-- impeccable: ignore <rule> -->` for HTML/Vue/Svelte/Astro,
`{/* impeccable: ignore <rule> */}` for JSX/TSX, `/* impeccable:
ignore <rule> */` for CSS. `*` matches any rule. Directive applies
to the next non-blank line. Same shape as ESLint, Stylelint, Biome.

Build pipeline
- scripts/lib/transformers/hooks.js: per-provider hooks.json
  builders, plus the slim .codex-plugin/plugin.json manifest.
- providers.js: emitHooks: 'claude' for claude-code, emitHooks:
  'codex' for codex and agents. Codex also emits emitCodexPlugin.
- factory.js: emits hooks/hooks.json next to the skills tree.
- build.js: syncs hooks/ into harness roots and into the slim
  plugin/ subtree; writes .codex-plugin/plugin.json. Build is
  idempotent (verified: 98 staged files unchanged across two runs).

Claude Code wiring uses exec form (command + args) and the
${CLAUDE_PLUGIN_ROOT} placeholder. Matcher: Edit|Write|MultiEdit.
`if:` glob filters to UI extensions before spawning Node. PostToolUse
timeout 5s, SessionStart timeout 3s.

Codex wiring uses ${PLUGIN_ROOT} (Codex's native placeholder),
matcher Edit|Write|apply_patch, no `if:` analog (the script does the
extension filter). macOS and Linux only; hooks are disabled on
Windows in current Codex builds. The trust ceremony and feature flag
are documented in README.md.

Routing
- /impeccable hooks lives outside the 23-command router table on
  purpose: it is plumbing, not a design skill. The hidden
  routing slot is added to SKILL.md alongside pin/unpin so the LLM
  knows to dispatch it. The 23-command count and all stale-count
  validators remain happy.

Tests
- tests/hook.test.mjs: 38 unit tests covering env parsing, config
  load + defaults + malformed, cache round-trip + GC,
  ignoreRules/minSeverity/inline ignores (all four languages),
  globbing with **/*/{a,b}, render template with cap + clamp + 0-line
  prefix drop, audit log NDJSON, payload event-name parameterization,
  re-entrancy, kill switches, sensitive-path + generated-path +
  traversal skips, allowlist filter, config ignoreFiles, edit
  counter cycle including the 7th-edit notice, MultiEdit and
  apply_patch payload shapes, detector throw swallow, malformed
  stdin, missing file race.
- tests/hook-build.test.mjs: 18 integration tests covering hook
  manifest shape (matcher, timeouts, exec form, if: glob, placeholders),
  Codex differences (${PLUGIN_ROOT}, no if:, no SessionStart),
  Codex plugin manifest (no inline hooks field to avoid the
  duplicate-file error), routing across the hooksJsonFor table, and
  presence of all three committed artifacts plus the bundled detector
  the runtime relative-import path depends on.

Full suite: 175 bun tests + 186 node tests, all green.

Docs
- README.md: new "Design hook" section explaining default behavior,
  per-project / global / inline disable paths, the JSON schema knobs,
  the audit log debug flag, and the slop / a11y coverage split.
- HARNESSES.md: flips the `hooks` row for Codex from No -> Yes
  (Claude was already Yes), adds a per-harness hook-surface table
  with the manifest location and matcher each provider uses.

Open questions from the PRD intentionally deferred to v2: Bash-write
blind spot, effort-aware suppression, Stop-hook session summary,
per-rule severity, async hook mode. None block v1.

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

* Fix Codex hook scanning: apply_patch paths and co-located stylesheets

Parse file targets from Codex apply_patch command bodies, co-scan imported
and sibling CSS when UI components are edited, drop the git-sweep PostToolUse
group, and align Codex SessionStart manifest and trust docs with the official
hooks spec.

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

* Gitignore hook session cache and drop local test HTML

Hook dedup/throttle state in .impeccable/hook.cache.json is per-project
runtime data like other .impeccable/ sidecars. Remove an untracked
bad-nested-flexbox scratch page from site/public/.

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

* Fix Claude Code hook: drop Edit-only if filter so Write/MultiEdit fire

Claude's if permission rule binds to one tool name, so Edit(*.{…}) never
spawned the hook on Write or MultiEdit despite the matcher listing them.
Extension filtering now lives in hook-lib on both Claude and Codex.

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

* Surface Cursor design findings via stop-hook followup

Replace dropped postToolUse additional_context with afterFileEdit recording
and a one-shot stop followup_message so anti-pattern nudges reach the agent.

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

* Fix design hook packaging and scans

* Fix Cursor hook pending bucket fallback

* Fix Sass hook scan coverage

* Fix Cursor hook review findings

* Fix session start dead hook normalization

* Fix hook config and relative scan paths

* Remove SessionStart design hook

* Remove redundant afterFileEdit normalization

* Fix Cursor suppression and module style scans

* Fix sensitive path hook filter

* Fix disabled Cursor stop hook emission

* Refresh hook harness artifacts

* Fix Cursor hook manifest install

* Add hook ignore-value support

* Ignore hook runtime files locally

* Fix Codex plugin hook packaging

* fix: address PR review bot findings

Block numeric hook depth counters from re-entering.

Avoid following stylesheet imports from traversal-looking hook targets.

* fix: gate ignore-value suggestions by supported rules

Only render exact ignore-value commands when the same finding can be suppressed by ignoreValues.

* Package Codex plugin as hook-only

* Remove Codex plugin packaging

* Recover hook install probe plumbing

* Remove Codex hook packaging follow-up doc

* Remove extra hook docs and skill wording changes

* Install real design hooks via skills CLI

* Add provider hook smoke runner

* Fix Cursor hook delivery with preToolUse gate

* Simplify Cursor hook install to preToolUse

* Clarify confirmed hook exceptions

* Persist hook ignores in shared config

* Guard font hook exceptions

* Fix hook install after main rebase

* Fix hook scan target handling

* fix: address hook review findings

* Address hook review feedback

* Stabilize DeepSeek insert live fixture

* Fix Cursor hook Python shell write bypass

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-13 21:19:19 -07:00
github-actions[bot] 92d6141cdf Sync generated provider output 2026-06-11 05:19:15 +00:00
5b5e487a4f Improve live mode configure bar and pick UX (#242)
* Fix: tear down annotation overlay when Escape exits live pick mode.

The configure prompt auto-focuses and bypasses the global Escape handler, so its local path must hide the annot overlay; togglePick off now does the same as a safety net.

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

* Improve live mode steer pill typing affordance.

Show a visible caret and placeholder when focused, expand on pointerdown, and drop the muddy border so the graphite surface carries the affordance alone.

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

* Improve live mode configure bar layout and pill styling.

Align pills and input on a shared text track, refine muted pill chrome with a quiet action border, and center the row with symmetric inset so spacing reads evenly in the 36px bar.

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

* Add x1 to live mode variant count picker.

The configure bar count pill now cycles 1→2→3→4→1 so users can request a single variant.

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

* Polish live mode configure bar, edit badge, and action picker.

Refine selection pill layout and tooltips, shrink edit copy to an icon aligned with the outline, right-align the action picker, and sync demo styles and regression coverage.

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

* Fix live mode element nav when configure input is focused.

Passthrough empty arrow keys from the configure and steer prompts so handleKeyDown can move between pickable elements without breaking autofocus typing.

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

* Remove accidental live.js inject from Base.astro.

Strip the localhost helper script tag left over from local live mode iteration so the PR ships only intentional UI changes.

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

* Fix review findings: pick-cursor state sync, anchor recovery, e2e selectors.

Code review of this branch surfaced ten confirmed bugs plus three smaller
ones; this commit fixes all of them.

- Route every interaction-state transition through a new setLiveState()
  helper that re-syncs the pick-mode crosshair, fixing four confirmed
  cursor bugs: never appearing on pick toggle (sync ran before the state
  change), sticking through the configure phase, surviving teardown
  page-wide, and the style mounting inside the adapter's shadow root
  where it can't match the host document (now document.head).
- Anchor recovery: a matching id is decisive again (hashed class names
  and component tags broke recovery), empty-text elements can no longer
  match the fuzzy text passes (".includes('')" hole plus shortest-text
  preference), and the dead 2-class-subset fallback is removed.
- Selection pill: drop the hover-only "armed" guard so keyboard
  activation works; the pill arms on focus as well as hover.
- Configure chrome: remove the configure-bar tooltip on teardown, align
  restorePickerBarChrome padding with initBar (5px), share the
  configure-input stylesheet with the insert row, and sync the
  ui-core.mjs surface inventory with live-browser.js.
- Site demos: delete the stale duplicate .live-demo-ctx-selection rule
  that killed the teal pill on dark pages, and keep the configure-phase
  demo bar on the overlay's dark surface in light mode so the near-white
  prompt text stays readable.
- E2E/contract tests: match the icon-only submit button by aria-label
  ("Generate variants") instead of the removed "Go" text, and update
  source-contract pins for setLiveState and buildConfigureSubmitButton.

Verified: bun run test green, live-mode E2E 23/23 across all fixtures.

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

* Wire insert voice button into syncVoiceUi listening state.

Voice on the insert configure row runs through the same 'configure' mode,
but syncVoiceUi only stamped data-listening/aria state on the replace
row's #impeccable-live-configure-voice, so the insert button never pulsed
while listening. Target whichever of the two row buttons is mounted, the
same either-row pattern syncConfigureInputChrome uses.

Addresses Bugbot review comment on PR #242.

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

* Reinject from source when the session wrapper lands during anchor recovery.

The anchor-recovery observer stood down as soon as the session's variant
wrapper appeared in the DOM, without running injectVariantsFromSource.
A wrapper can land incomplete (wrap HMR landed, variant insert did not),
which is exactly the case injectVariantsFromSource's existing-wrapper
replace path handles - so recovery ended with the bar stuck and no
variants. Route both the anchor-found and wrapper-landed cases through
injectVariantsFromSource, which owns wrapper replacement, recovery-flag
clearing, and variant display.

Addresses Bugbot review comment on PR #242.

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

* Restore inline edit drafts before configure chrome teardown disables editing.

teardownConfigureChrome called disableInlineEdit() ahead of hideBar(),
wiping inlineEditRows and the impeccableOriginalText metadata that
hideBar()'s EDITING-state restoreInlineEditDrafts() needs - so turning
Pick off mid "Edit copy" left edited DOM text in place, neither saved
nor canceled. Let hideBar() own the sequence: it restores drafts first,
then disables inline edit.

Addresses Bugbot review comment on PR #242.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:18:45 -07:00
Paul Bakaus 1863a44b23 Clean up notes and tools 2026-06-09 17:07:59 -07:00
Paul Bakaus 324cec73a4 Reorganize contributor docs 2026-06-09 15:18:53 -07:00
Paul BakausandClaude Opus 4.7 5fbe37c97c skill: remove Copy section from main design skill
Copy guidance (em-dash bans, buzzword bans, button-label / link-text
phrasing, aphoristic-cadence) doesn't belong in the main design skill.
It's not design-specific — the skill is trying to do too much. The six
rules being dropped (every-word-earns, no-em-dashes, no-aphoristic-cadence,
no-buzzwords, button-verb-object, link-standalone) are now better served
by:

- The impeccable engine's antipattern detectors (em-dash-overuse,
  marketing-buzzword, aphoristic-cadence, copy-slop) for linting at scan
  time.
- The /clarify subcommand for surfacing the same checks when reviewing
  copy specifically.

The em-dash ban for the SKILL prose itself still lives in STYLE.md and the
build-time prose validator — that's separate from the skill's guidance to
agents.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-09 15:14:08 -07:00
Paul BakausandClaude Opus 4.7 d37be057ea skill: drop 4 redundant typography rules + add EMPIRICAL_VALIDATION
The v2.1 ablation sweep (n=10 × 4 brand niches × 3 providers, anchored to
commit 54c3a502, ~544 cells) confirmed these four rules carry no weight in
the skill:

- skill-typo-no-all-caps-body — duplicate of brand-ban-all-caps-body; brand
  version is more specific (reserves caps for labels + headings)
- skill-typo-codex-hero-ceiling-repeat — the codex-block restatement of
  skill-typo-hero-ceiling didn't add reinforcement on top of the universal
  rule
- skill-typo-scale-ratio — duplicate of brand-typo-modular-scale; same
  signal, brand version carries the clamp() / fluid implementation detail
- skill-typo-font-count — models don't reach for ≥4 font families in any
  niche we test, so the rule has no measurable effect

Each deletion is the Agent A / B / C / D Phase-2 audit recommendation;
none of the four ever validated under either prose state.

Adds EMPIRICAL_VALIDATION.md naming the seven cross-provider winners as the
trustworthy core, and documents the systemic findings (self-priming, detector
saturation, vocabulary anchoring) so future skill edits can avoid the same
traps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-09 15:14:08 -07:00
Paul BakausandGitHub 05f9797957 Remove deprecated cleanup migration (#240) 2026-06-09 15:12:58 -07:00
github-actions[bot] 983b75cabc Sync generated provider output 2026-06-09 21:10:51 +00:00
Paul BakausandGitHub 8735be3712 Extract live browser DOM helpers (#239) 2026-06-09 23:10:10 +02:00
Paul BakausandGitHub e3e22007a9 [codex] Improve detector false positive handling (#232)
* Improve detector false positive handling

* Register docs integrity test

* Fix clipped overflow decorative skip
2026-06-09 10:56:32 -07:00
github-actions[bot] c169b84f20 Sync generated provider output 2026-06-09 17:32:23 +00:00
Paul BakausandGitHub f24f9fca8b Refactor live browser script assembly (#235) 2026-06-09 19:31:50 +02:00
f636bd065a fix(live-inject): preserve the character after an insertAfter anchor (#227) (#230)
* fix(live-inject): preserve the character after an insertAfter anchor

insertTag()'s insertAfter branch sliced the post-anchor remainder by
prefix.length. When the anchor was not already followed by a newline,
prefix is one character longer than the anchor (the appended '\n'), so
content.slice(prefix.length) dropped the first real character after the
anchor — e.g. `<head>X...` lost the `X` during live-mode injection (#227).

Slice the remainder from the original anchor offset instead. The
insertBefore branch and the already-followed-by-newline case are
unchanged. Add a regression test for both the no-trailing-newline and
newline cases, and regenerate the tracked per-agent bundles so the fix
ships everywhere.

Fixes #227. Root-cause analysis from the issue reporter.

* Fix live inject CRLF insertAfter handling

---------

Co-authored-by: Paul Bakaus <paul.bakaus@gmail.com>
2026-06-08 18:45:12 -07:00
github-actions[bot] f81f63a485 Sync generated provider output 2026-06-09 01:28:38 +00:00
Paul BakausandGitHub c2ee19540b Refactor manual edit live routes (#234) 2026-06-09 03:28:11 +02:00
github-actions[bot] 018a16eb13 Sync generated provider output 2026-06-09 00:27:45 +00:00
Paul BakausandGitHub b41836ce0e Extract manual Apply live server module (#233)
* Extract manual apply live server module

* Fix core suite registry for docs integrity
2026-06-09 02:27:14 +02:00
github-actions[bot] b498b79afb Sync generated provider output 2026-06-09 00:04:29 +00:00
Paul Bakaus 325aeaf239 Organize skill script support modules 2026-06-08 16:58:55 -07:00
Paul Bakaus 972f5b18be Improve generated output sync workflow 2026-06-08 16:54:11 -07:00
Paul Bakaus 55a54c0fbd Add docs starter rail dividers 2026-06-08 15:44:27 -07:00
Paul Bakaus 3fbf64698c Improve docs starter rail alignment 2026-06-08 15:44:27 -07:00
Paul Bakaus b4e4fe1079 Improve docs starter experience 2026-06-08 15:44:27 -07:00
Paul BakausandGitHub 9381269a82 Fix Firefox DevTools extension paths (#231) 2026-06-08 15:23:37 -07:00
3d1be6238c feat(extension): build and ship a Firefox add-on (#188)
Derive a Gecko-compatible manifest at build time and package
extension-firefox.zip alongside the Chrome zip:

- background service worker is declared as an event-page `scripts`
  entry (top-level listeners + in-memory Map run unchanged on Gecko)
- browser_specific_settings.gecko with id, strict_min_version 140.0,
  and data_collection_permissions (required by AMO; honored on 140+)
- packZip helper parameterized over cwd/excludes; `*.DS_Store` strips
  junk at every depth and .DS_Store is excluded from the staging copy
- guard against a missing background.service_worker shape

CI now builds the extension and runs a pinned `web-ext@8 lint` over
the staged Firefox tree (innerHTML warnings are non-blocking); the
unpacked staging dir is excluded from the uploaded artifact. The
release script attaches both zips and points to AMO.

Bumps the extension to v1.2.0 with a changelog entry.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Paul Bakaus <paul.bakaus@gmail.com>
2026-06-08 15:09:09 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6f71b1d938 chore(deps-dev): bump @google/genai from 1.50.1 to 2.8.0 (#226)
Bumps [@google/genai](https://github.com/googleapis/js-genai) from 1.50.1 to 2.8.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/v1.50.1...v2.8.0)

---
updated-dependencies:
- dependency-name: "@google/genai"
  dependency-version: 2.8.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>
2026-06-08 13:53:07 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Paul Bakaus
642adb5e84 chore(deps-dev): bump archiver from 7.0.1 to 8.0.0 (#224)
* chore(deps-dev): bump archiver from 7.0.1 to 8.0.0

Bumps [archiver](https://github.com/archiverjs/node-archiver) from 7.0.1 to 8.0.0.
- [Release notes](https://github.com/archiverjs/node-archiver/releases)
- [Changelog](https://github.com/archiverjs/node-archiver/blob/master/CHANGELOG.md)
- [Commits](https://github.com/archiverjs/node-archiver/compare/7.0.1...8.0.0)

---
updated-dependencies:
- dependency-name: archiver
  dependency-version: 8.0.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* Fix archiver 8 ZIP creation

---------

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-08 13:47:51 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c719501cbd chore(deps): bump marked from 16.4.2 to 18.0.5 (#225)
Bumps [marked](https://github.com/markedjs/marked) from 16.4.2 to 18.0.5.
- [Release notes](https://github.com/markedjs/marked/releases)
- [Commits](https://github.com/markedjs/marked/compare/v16.4.2...v18.0.5)

---
updated-dependencies:
- dependency-name: marked
  dependency-version: 18.0.5
  dependency-type: direct:production
  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>
2026-06-08 13:32:26 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
13b142b677 chore(deps-dev): bump the bun-minor-and-patch group with 2 updates (#223)
Bumps the bun-minor-and-patch group with 2 updates: [@anthropic-ai/claude-agent-sdk](https://github.com/anthropics/claude-agent-sdk-typescript) and [@anthropic-ai/sdk](https://github.com/anthropics/anthropic-sdk-typescript).


Updates `@anthropic-ai/claude-agent-sdk` from 0.3.165 to 0.3.168
- [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.165...v0.3.168)

Updates `@anthropic-ai/sdk` from 0.101.0 to 0.102.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.101.0...sdk-v0.102.0)

---
updated-dependencies:
- dependency-name: "@anthropic-ai/claude-agent-sdk"
  dependency-version: 0.3.168
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: bun-minor-and-patch
- dependency-name: "@anthropic-ai/sdk"
  dependency-version: 0.102.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-08 13:31:49 -07:00
Paul BakausandClaude Opus 4.7 d0c934c03b chore(skill): rebuild harness SKILL.md outputs from source
Mirrors the 5 prose changes in skill/SKILL.src.md + skill/reference/brand.md
out to every harness directory (`.claude`, `.gemini`, `.cursor`, `.codex`,
`.agents`, etc.) so the staged skill that workers / agents read matches the
source. Auto-generated by `bun run build:skills`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 13:28:10 -07:00
Paul BakausandClaude Opus 4.7 b210dd71e7 skill: strip self-priming examples from 5 rules
Phase-2 ablation audit caught these rules causing the exact behavior they
ban via the literal examples in their own prose. Verified: OpenAI samples
under skill-on produced "fake theater", "vendor theater", "heatmap theater"
as verbatim copies of the 'X theater' example. Same pattern for the
restrained-on-cream example, the aphoristic-cadence template, and the
"reserve uppercase for…" enumeration.

- skill-ban-codex-x-theater: drop the 3 syntactic templates + 3 example
  phrases ("Productivity theater" etc.)
- brand-imagery-required: drop the niche enumeration that cued
  "imagery not required elsewhere"
- skill-typo-no-all-caps-body: drop the "Reserve uppercase for labels /
  eyebrows / badges" enumeration that primed uppercase usage
- brand-color-no-converge: drop the "restrained-on-cream" example that
  was priming cream-heavy palettes
- skill-copy-no-aphoristic-cadence: drop the literal cadence template
  ("serious statement, then punchy short negation") that named the
  rhythm it bans

Ablation re-run pending in impeccable-evals to measure impact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-08 13:28:10 -07:00
Paul BakausandGitHub afb8029a50 Fix live accept cleanup for mapped lists (#229) 2026-06-08 11:30:15 -07:00
Paul BakausandGitHub cbd6d80c26 Fix privacy wording for version check (#228) 2026-06-08 10:50:09 -07:00
Paul BakausandGitHub 82801a4894 [codex] Improve CI test coverage (#212)
* Improve CI test coverage

* Stabilize live E2E harness

* Shard live E2E CI

* Cache live E2E CI dependencies

* Stabilize live E2E smoke CI

* Update generated live browser bundles

* Tighten live E2E smoke runtime

* Prevent live E2E smoke hangs

* Stabilize live E2E CI coverage

* Fix stale accept DOM cleanup

* Regenerate live browser outputs
2026-06-08 10:39:12 -07:00
Paul Bakaus 1aedbcf538 Add Git submodule skill linking 2026-06-05 18:11:15 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fdcc1ba548 chore(deps): bump css-select from 5.2.2 to 7.0.0 (#211)
Bumps [css-select](https://github.com/fb55/css-select) from 5.2.2 to 7.0.0.
- [Release notes](https://github.com/fb55/css-select/releases)
- [Commits](https://github.com/fb55/css-select/compare/v5.2.2...v7.0.0)

---
updated-dependencies:
- dependency-name: css-select
  dependency-version: 7.0.0
  dependency-type: direct:production
  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>
2026-06-05 17:40:22 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
26da817aea chore(deps): bump domutils from 3.2.2 to 4.0.2 (#208)
Bumps [domutils](https://github.com/fb55/domutils) from 3.2.2 to 4.0.2.
- [Release notes](https://github.com/fb55/domutils/releases)
- [Commits](https://github.com/fb55/domutils/compare/v3.2.2...v4.0.2)

---
updated-dependencies:
- dependency-name: domutils
  dependency-version: 4.0.2
  dependency-type: direct:production
  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>
2026-06-05 17:37:13 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0df1e23a0e chore(deps): bump puppeteer from 24.42.0 to 25.1.0 (#210)
Bumps [puppeteer](https://github.com/puppeteer/puppeteer) from 24.42.0 to 25.1.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-v24.42.0...puppeteer-v25.1.0)

---
updated-dependencies:
- dependency-name: puppeteer
  dependency-version: 25.1.0
  dependency-type: direct:production
  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>
2026-06-05 17:34:37 -07:00
Paul Bakaus 81c5042561 Install Puppeteer browser in CI 2026-06-05 17:33:35 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
924d4b97f0 chore(deps): bump htmlparser2 from 10.1.0 to 12.0.0 (#209)
Bumps [htmlparser2](https://github.com/fb55/htmlparser2) from 10.1.0 to 12.0.0.
- [Release notes](https://github.com/fb55/htmlparser2/releases)
- [Commits](https://github.com/fb55/htmlparser2/compare/v10.1.0...v12.0.0)

---
updated-dependencies:
- dependency-name: htmlparser2
  dependency-version: 12.0.0
  dependency-type: direct:production
  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>
2026-06-05 17:31:14 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
270b64897a chore(deps-dev): bump the bun-minor-and-patch group with 11 updates (#207)
Bumps the bun-minor-and-patch group with 11 updates:

| Package | From | To |
| --- | --- | --- |
| [@ai-sdk/anthropic](https://github.com/vercel/ai/tree/HEAD/packages/anthropic) | `3.0.71` | `3.0.81` |
| [@ai-sdk/google](https://github.com/vercel/ai/tree/HEAD/packages/google) | `3.0.75` | `3.0.80` |
| [@ai-sdk/openai](https://github.com/vercel/ai/tree/HEAD/packages/openai) | `3.0.53` | `3.0.68` |
| [@anthropic-ai/claude-agent-sdk](https://github.com/anthropics/claude-agent-sdk-typescript) | `0.2.119` | `0.3.165` |
| [@anthropic-ai/sdk](https://github.com/anthropics/anthropic-sdk-typescript) | `0.91.1` | `0.101.0` |
| [ai](https://github.com/vercel/ai/tree/HEAD/packages/ai) | `6.0.168` | `6.0.197` |
| [astro](https://github.com/withastro/astro/tree/HEAD/packages/astro) | `6.2.1` | `6.4.4` |
| [motion](https://github.com/motiondivision/motion) | `12.38.0` | `12.40.0` |
| [playwright](https://github.com/microsoft/playwright) | `1.59.1` | `1.60.0` |
| [wrangler](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/wrangler) | `4.85.0` | `4.98.0` |
| [zod](https://github.com/colinhacks/zod) | `4.3.6` | `4.4.3` |


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

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

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

Updates `@anthropic-ai/claude-agent-sdk` from 0.2.119 to 0.3.165
- [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.2.119...v0.3.165)

Updates `@anthropic-ai/sdk` from 0.91.1 to 0.101.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.91.1...sdk-v0.101.0)

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

Updates `astro` from 6.2.1 to 6.4.4
- [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.4/packages/astro)

Updates `motion` from 12.38.0 to 12.40.0
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.38.0...v12.40.0)

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

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

Updates `zod` from 4.3.6 to 4.4.3
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.3.6...v4.4.3)

---
updated-dependencies:
- dependency-name: "@ai-sdk/anthropic"
  dependency-version: 3.0.81
  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.80
  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.68
  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.165
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: bun-minor-and-patch
- dependency-name: "@anthropic-ai/sdk"
  dependency-version: 0.101.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: bun-minor-and-patch
- dependency-name: ai
  dependency-version: 6.0.197
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: bun-minor-and-patch
- dependency-name: astro
  dependency-version: 6.4.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: bun-minor-and-patch
- dependency-name: motion
  dependency-version: 12.40.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: bun-minor-and-patch
- dependency-name: playwright
  dependency-version: 1.60.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: bun-minor-and-patch
- dependency-name: wrangler
  dependency-version: 4.98.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: bun-minor-and-patch
- dependency-name: zod
  dependency-version: 4.4.3
  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-05 17:29:56 -07:00
Paul Bakaus 75fc95947e Bump Node support to 24 2026-06-05 17:26:40 -07:00
397d3cb4b7 Add site copy feedback and command block fixes (#200)
* Add copy confirmation animation

* Add live mode copy confirmation

* Add Neo Mirai copy confirmation

* Fix Neo Mirai command overflow on narrow screens

* Link footer logo to homepage

* Fix copy feedback helper

---------

Co-authored-by: Paul Bakaus <43004+pbakaus@users.noreply.github.com>
2026-06-05 15:47:17 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
08d50f215b chore(deps): bump the github-actions group with 3 updates (#206)
Bumps the github-actions group with 3 updates: [actions/checkout](https://github.com/actions/checkout), [actions/setup-node](https://github.com/actions/setup-node) and [actions/upload-artifact](https://github.com/actions/upload-artifact).


Updates `actions/checkout` from 4 to 6
- [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/v4...v6)

Updates `actions/setup-node` from 4 to 6
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

Updates `actions/upload-artifact` from 4 to 7
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/setup-node
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/upload-artifact
  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-05 15:33:55 -07:00
Paul Bakaus 17eedd67bb Add Dependabot configuration 2026-06-05 15:28:54 -07:00
Abdul WahabandGitHub 4e251061b8 Fix sr-only text overflow false positive (#197) 2026-06-05 15:25:34 -07:00
Vyctor H. BrzezowskiandGitHub 6788085015 Add llms.txt (#199) 2026-06-05 15:10:08 -07:00
Paul BakausandClaude Opus 4.8 5fb30e03e6 Live picker: derive the command vocabulary from one canonical source
The verbs/labels/icons were copied three ways: live-browser.js (ICONS + ACTIONS),
VISUAL_ACTIONS in live-event-validation.mjs, and the marketing demo. Collapse
them to one source, skill/scripts/live-vocabulary.mjs (LIVE_COMMANDS + derived
VISUAL_ACTIONS).

- live-event-validation.mjs imports VISUAL_ACTIONS from it.
- live-server.mjs serializes LIVE_COMMANDS into window.__IMPECCABLE_VOCAB__ when
  it serves /live.js, next to the token/port. live-browser.js (served raw, can't
  import at runtime) builds its ICONS + ACTIONS from that injected vocab instead
  of an inline copy — byte-identical icons, zero behaviour change.
- site/components/LiveDemoPalette.astro imports the same module at build time, so
  the demo and the real picker can no longer drift.

Adds a /live.js test asserting the injected vocab deep-equals the canonical list.
Harness skill dirs refreshed via build. (Pre-existing, unrelated: `bun run
build:site` fails on an htmlparser2 import in the CLI detector.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:05:41 -07:00
Paul BakausandClaude Opus 4.8 1f975a69e4 Hero: vocabulary-first copy + command-palette switcher in the demo
Rewrite the hero around the why (the missing design vocabulary) instead of the
live-mode how: "The missing design vocabulary for agents." The live demo now
opens the picker's command palette and picks a verb before generating, which is
the move that makes the live approach unique and was previously skipped.

- Demo palette mirrors the real action picker (live-browser.js): same 12 verbs,
  the same SVG icons, a 4-col icon-over-label grid, selected chip on a kinpaku
  wash with its icon recolored. Light + dark covered.
- Shared <LiveDemoPalette> component renders the grid from one list, so the hero
  and /live-mode no longer hand-copy the markup. /live-mode lands on "delight",
  the hero on "colorize" (via data-demo-pick); pages without a palette filter the
  switcher beats out of the shared timeline.
- Trim the opening beats so the cursor clicks the element at ~1.3s (was ~2s), and
  slow the palette browse so the vocabulary is readable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 14:05:41 -07:00
Gabriel GrantandGitHub 0939528b95 Fix DESIGN.md links (#205)
* Fix DESIGN.md link

* Update link in documentation for DESIGN.md format
2026-06-05 13:53:40 -07:00
Abdul WahabandGitHub 347a0c06a2 Fix Windows skill bundle extraction (#198) 2026-06-05 11:16:23 -07:00
Paul BakausandClaude Opus 4.8 6c7c04866c Fix dark changelog/FAQ cards in light mode
The changelog + FAQ pages share changelog-faq-kinpaku.css, which paints its
card/panel/pill backgrounds with literal dark oklch(8% …) values that don't
flip with the theme. The only light override was dead twice over: scoped to a
.changelog-faq-kinpaku wrapper that's never on the body (these pages use
.kinpaku-surface), and naming the wrong elements (.faq-item/.changelog-entry
instead of the cf-prefixed classes). So the cards stayed near-black on the
light page.

Replace that dead block with correct html.light .cf-* rules: flip the card
surfaces (cf-entry, cf-stats, cf-faq-item, cf-entry--current), the before/after
frame + before tag (cf-ba-shot, cf-ba-tag--before) to the shared light card
treatment, and drop the bright-kinpaku accents (cf-version, cf-stat-num,
cf-current-badge, cf-ba-tag--after, cf-faq-question) to --ks-kinpaku-ink so
they stay legible on paper, matching the .cf-eyebrow. Answer-body tokens
already flip, so they're untouched. Dark mode unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:42:22 -07:00
Paul BakausandClaude Opus 4.8 198aa91719 Color the command palette by section accent; vibrant dividers
Each command section now reads in its own category accent (--spread-accent)
across the palette: the kicker, the skill name (big title + active list
item), and the divider all take it, while the slash before /impeccable
drops to the muted namespace ink. The per-section divider accents are also
made vibrant — refine/simplify/harden were muddy kinpaku-pale/-deep/oxide;
now create/refine/simplify = kinpaku gold, evaluate/harden = patina
verdigris, system = neutral, matching the established category color
grouping. Light mode re-applies the accents over the shared eyebrow :is()
rule; dark mode picks them up from the base rules.

Also includes light-mode readability fixes for the live-demo G-bar (brand
mark, active tool chip, control chips, pin-note text/caret) and a
specificity fix so the hotel-hero demo text stays light on its photo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:04:20 +02:00
Paul BakausandClaude Opus 4.8 1d5d745823 Make theme switcher three-way (auto/light/dark)
Default is "auto", which inherits from the OS via prefers-color-scheme
and follows it live. Clicking cycles auto → light → dark → auto; the
explicit choice persists in localStorage while auto stores nothing.

The toggle shows the active preference (half-circle / sun / moon) keyed
on a new data-theme-pref attribute, so "auto" is its own visible state
rather than collapsing into whatever the OS resolved to.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:03:21 +02:00
Paul BakausandClaude Opus 4.8 58e9fceede Update GitHub star counter to 34k
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:34:40 +02:00
Paul BakausandClaude Opus 4.8 05942485d6 Use pristine kinpaku in light-mode command palette
The palette's title, active command, and category kicker rendered in the
muddy kinpaku-ink in light mode (the shared eyebrow rule swaps pristine
kinpaku for kinpaku-ink for legibility on warm paper). But the palette
sits on a near-white card, where pristine kinpaku reads fine — and it now
matches the already-pristine slashes and the PALETTE toggle.

Scope the override under .magazine-container so it only affects the
palette and clears the eyebrow rule's :is() specificity (inflated to 4
classes by its .fisheye-item.is-active argument). Other eyebrows keep
kinpaku-ink for paper legibility; dark mode is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:30:29 +02:00
Paul BakausandClaude Opus 4.8 5083000125 Fix periodic-table view in light mode
The dark periodic table hardcodes a black stage and near-black tiles
with !important, and the only light override targeted .ptable-cell — a
class the component never emits (it renders .ptable-element). So in light
mode the tiles stayed black while the symbol/name text flipped to dark
ink: black on black, unreadable.

Add a proper light block (with !important to beat the dark rules): a soft
light stage, raised near-white tiles with neutral hairline borders and a
subtle shadow, and a darker hover border. Symbol/name colors and the
category labels already re-theme to dark inks, so they read cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:16:11 +02:00
Paul BakausandClaude Opus 4.8 4fda4a0ece Serve _data/api JSON in dev server
app.js fetches /_data/api/commands.json and patterns.json, but those are
build artifacts written into site/public/_data/ by scripts/build.js. The
plain `astro dev` server never runs that build, so the homepage 404'd on
both in dev.

Extract generateApiData into scripts/lib/api-data.js (shared by the build
and a new scripts/gen-dev-api.mjs prebuild), and run the prebuild before
astro dev so `bun run dev` serves the same payloads as production.
site/public/_data/ stays gitignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 07:33:53 +02:00
Paul BakausandClaude Opus 4.8 be83085dbd Remove unused --ks-rule-strong token
After the border audit reassigned every usage to --ks-kinpaku or
--ks-rule, the muddy gold token is dead. Delete its dark and light
definitions and update the comments that described it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:23:07 +02:00
Paul BakausandClaude Opus 4.8 2a605f63ca Replace muddy gold borders with pristine gold or neutral hairlines
Audit every --ks-rule-strong usage (the muddy desaturated gold). Where
the border/text sits beside pristine kinpaku or signals an active/hover/
brand state, lift it to --ks-kinpaku; where it's a large structural
frame, window chrome, or paired with neutral siblings, drop it to the
neutral --ks-rule hairline.

Lifted to --ks-kinpaku: detector focus ring + secondary action buttons,
design-system candidate caption + mini-ui "after" border, docs neon-case
secondary/hero-shot-hover, docs flow/chain arrows, homepage slop-teaser
hover + impeccable-card.
Dropped to --ks-rule: design-system comparison-stage frame, docs
neon-case-command frame, light-mode hero demo shell + split-label pill +
live-demo pin-note.

--ks-rule-strong is now unused outside its token definition.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:37:39 +02:00
Paul BakausandClaude Opus 4.8 23dcaa79e0 Theme-switch favicon; use pristine kinpaku gold
The favicon was hardcoded to a muddy #d8a83a, duller than the header
logo's pristine kinpaku. Switch fill by OS theme via an embedded
prefers-color-scheme media query: near-black in light, pristine kinpaku
(#ffb900, matching --ks-kinpaku) in dark.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:37:25 +02:00
Paul BakausandClaude Opus 4.8 8e25f9955f Fix light-mode header contrast over hero seam
On wide viewports the hero's bright kintsugi seam sits under the
top-right nav cluster, where dark light-mode text and the toggle/GitHub
chips fight the gold. Add a contextual, light-only radial scrim anchored
to the corner: it feathers out by 70% (no hard edge), is faded via --hp
the moment the header glasses in on scroll, and is off below the mobile
drawer breakpoint. Dark mode needs no treatment (cream text reads fine
over the darker seam).

With the scrim carrying the corner, the toggle and GitHub counter become
refined hairline chips in light mode — neutral --ks-rule border, no fill
(the GitHub pill previously used the gold --ks-rule-strong and a solid
background).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 11:00:21 +02:00
Abdul WahabandGitHub 6163ca0529 Add Svelte-native live mode adapter (#179)
* Fix live preview state for framework components

* Complete stateful live preview coverage

* Record Svelte manual validation

* Fix Svelte live mode adapter

* Fix live Steer apply flow

* Fix Svelte live variant refresh recovery

* Fix live exit bar teardown

* Consolidate Svelte live DeepSeek sweep

* Reconcile Svelte live browser after main rebase

* Fix live accept review regressions

* Fix carbonize column-zero indentation

* Fix live poll lease expiry flake

* Fix Svelte shader preview capture
2026-06-02 00:08:57 -07:00
Abdul WahabandGitHub 69b5f3af49 Fix live detector empty state (#190)
* Fix live detector empty state

* Fix live detector stale scan results
2026-06-01 14:44:45 -07:00
Abdul WahabandGitHub 3f8d002b76 Fix live edit badge button sizing (#191) 2026-06-01 14:43:50 -07:00
Abdul WahabandGitHub d3f0275356 Fix live accept DOM cleanup after carbonize (#185)
* Add live E2E regression report

* Fix live accept DOM cleanup

* Fix live browser review findings

* Fix stale accepted session cleanup

* Remove live regression hunt notes

* Fix accept error review findings
2026-06-01 12:39:59 -07:00
Abdul WahabandGitHub ab3a13245c Fix: ignore hook cache artifact (#189) 2026-06-01 12:32:53 -07:00
Abdul WahabandGitHub ea3e66b984 Fix GitHub Copilot logo and site mentions (#182)
* Fix GitHub Copilot site mentions

* Fix mobile command blocks

* Fix mobile nav drawer centering

* Align mobile nav controls

* Align mobile nav controls

* Pad mobile nav drawer

* Fix light mobile nav drawer

* Remove light drawer active border

* Restore light drawer active underline

* Fix designing mobile layout

* Tune designing mobile loop

* Fix designing mobile section gutters

* Keep polish commands on one mobile row

* Fix designing mobile bento gutters
2026-05-31 20:20:18 -07:00
1007 changed files with 203155 additions and 53150 deletions
+9 -18
View File
@@ -29,16 +29,12 @@ Produce ready-to-ship, production-grade code, not prototypes or starting points.
#### Typography
- Cap body line length at 6575ch.
- Hierarchy through scale + weight contrast (≥1.25 ratio between steps). Avoid flat scales.
- Cap font-family count at 3 (display + body + optional mono). More than 3 reads as indecision, not richness. One well-tuned family with weight contrast usually beats three competing typefaces.
- Don't pair fonts that are similar but not identical (two geometric sans-serifs, two humanist sans-serifs). Pair on a contrast axis (serif + sans, geometric + humanist) or use one family in multiple weights.
- No all-caps body copy. Reserve uppercase for short labels (≤4 words), section eyebrows (used sparingly per the Absolute bans), and badges. Sentences in ALL CAPS are unreadable at body sizes.
- Hero / display heading ceiling: clamp() max ≤ 6rem (~96px). Above that the page is shouting, not designing.
- Display heading letter-spacing floor: ≥ -0.04em. Anything tighter and letters touch; cramped, not "designed".
- Use `text-wrap: balance` on h1h3 for even line lengths; `text-wrap: pretty` on long prose to reduce orphans.
Two hard typographic ceilings you currently miss:
- Hero clamp() max ≤ 6rem. 811rem (128176px) reads as comically loud, not bold.
One hard typographic ceiling you currently miss:
- Display letter-spacing ≥ -0.04em. Your default of -0.05 to -0.085em on display H1s makes the letters touch and reads as cramped. -0.02 to -0.03em is plenty for tight grotesque display; -0.04em is the floor.
#### Layout
@@ -63,15 +59,6 @@ Two hard typographic ceilings you currently miss:
- Dropdowns rendered with `position: absolute` inside an `overflow: hidden` or `overflow: auto` container will be clipped. Use the native `<dialog>` / popover API, `position: fixed`, or a portal to escape the stacking context.
### Copy
- Every word earns its place. No restated headings, no intros that repeat the title.
- **No em dashes.** Use commas, colons, semicolons, periods, or parentheses. Also not `--`.
- **No aphoristic-cadence body copy as a default voice.** Don't fall into the rhythm of "serious statement, then punchy short negation" as the page's recurring voice. If three or more section copy blocks on the page land on a short rebuttal-shaped sentence, rewrite. Specific, not aphoristic.
- **No marketing buzzwords.** The streamline / empower / supercharge / leverage / unleash / transform / seamless / world-class / enterprise-grade / next-generation / cutting-edge / game-changer / mission-critical family of phrases. Pick a specific noun and a verb that describes what the product literally does.
- Button labels: verb + object. "Save changes" beats "OK"; "Delete project" beats "Yes". The label should say what will happen.
- Link text needs standalone meaning. "View pricing plans" beats "Click here"; screen readers announce links out of context.
### New projects only (when no prior work exists)
#### Color & Theme
@@ -105,7 +92,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.
- **"X theater" / "actually X" / "not just X, it's Y" copy.** "Productivity theater", "engagement theater", "growth theater": instant AI slop. Choose a specific noun, not a meta-criticism phrase.
- **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
@@ -144,7 +131,7 @@ If someone could look at this interface and say "AI made that" without doubt, it
| `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) |
Plus two management commands: `pin <command>` and `unpin <command>`, detailed below.
Plus three management commands: `pin <command>`, `unpin <command>`, and `hooks <on|off|status|...>`, detailed below.
### Routing rules
@@ -161,7 +148,7 @@ Plus two management commands: `pin <command>` and `unpin <command>`, detailed be
**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.
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**: load its reference file and follow its instructions. Everything after the command name is the target.
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.
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.
@@ -179,4 +166,8 @@ If the first word is `craft`, setup still runs first, but [reference/craft.md](r
node .agents/skills/impeccable/scripts/pin.mjs <pin|unpin> <command>
```
Valid `<command>` is any command from the table above. Report the script's result concisely. Confirm the new shortcut on success, relay stderr verbatim on error.
Valid `<command>` is any command from the table above. Report the script's result concisely. Confirm the new shortcut on success, relay stderr verbatim on error.
## Hooks
`$impeccable hooks <on|off|status|ignore-rule|ignore-file|ignore-value|reset>` manages the design detector hook for this project. The hook auto-runs the detector after direct UI file edits and surfaces findings as system reminders. Full flow is in [reference/hooks.md](reference/hooks.md); load it when the user invokes `$impeccable hooks` with any argument.
+2 -2
View File
@@ -60,7 +60,7 @@ Brand surfaces have permission for Committed, Full palette, and Drenched strateg
- Name a real reference before picking a strategy. "Klim Type Foundry #ff4500 orange drench", "Stripe purple-on-white restraint", "Liquid Death acid-green full palette", "Mailchimp yellow full palette", "Condé Nast Traveler muted navy restraint", "Vercel pure black monochrome". Unnamed ambition becomes beige.
- Palette IS voice. A calm brand and a restless brand should not share palette mechanics.
- When the strategy is Committed or Drenched, color carries the brand. Don't hedge with neutrals around the edges. Commit.
- Don't converge across projects. If the last brand surface was restrained-on-cream, this one is not.
- Don't converge across projects. Each brand surface differentiates from the last.
- When a cultural-symbol palette is the obvious pull, reach past it. Let the cultural reading come from typography, imagery, and copy, not the palette.
## Layout
@@ -74,7 +74,7 @@ Brand surfaces have permission for Committed, Full palette, and Drenched strateg
Brand surfaces lean on imagery. A restaurant, hotel, magazine, or product landing page without any imagery reads as incomplete, not as restrained. A solid-color rectangle where a hero image should go is worse than a representative stock photo.
**When the brief implies imagery (restaurants, hotels, magazines, photography, hobbyist communities, food, travel, fashion, product), you must ship imagery.** Zero images is a bug, not a design choice. "Restraint" is not an excuse. If the approved comp or brief is image-led, ship real project assets, generated raster assets, or a credible canvas/SVG/WebGL scene. Do not replace photographic, architectural, product, or place imagery with generic CSS panels, decorative diagrams, cards, bullets, or copy.
**When the brief implies imagery, you must ship imagery.** Zero images is a bug, not a design choice. "Restraint" is not an excuse. If the approved comp or brief is image-led, ship real project assets, generated raster assets, or a credible canvas/SVG/WebGL scene. Do not replace photographic, architectural, product, or place imagery with generic CSS panels, decorative diagrams, cards, bullets, or copy.
- **For greenfield work without local assets, use stock imagery.** Unsplash is the default. The URL shape is `https://images.unsplash.com/photo-{id}?auto=format&fit=crop&w=1600&q=80`. **Verify the URLs before referencing them.** If you have an image-search MCP, web-fetch tool, or browser access, use it to find real photo IDs and confirm they resolve. Guessed IDs (even ones that look real) often 404 and ship as broken-image placeholders. Without a verification path, pick fewer photos you're confident exist over more that you guessed; never substitute colored `<div>` placeholders.
- **Search for the brand's physical object**, not the generic category: "handmade pasta on a scratched wooden table" beats "Italian food"; "cypress trees above a limestone hotel facade at dusk" beats "luxury hotel".
@@ -0,0 +1,90 @@
# $impeccable hooks
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.
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.
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).
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.
## Routing
The first argument is the action. Defaults to `status`.
| Action | What it does |
|---|---|
| `status` | Print current state, shared/local config paths, ignored rules / files / values, env override. |
| `on` | Set `enabled: true` in `.impeccable/config.json`, record local hook consent as accepted, and install/repair provider hook manifests when the skill is installed. |
| `off` | Set `enabled: false` in `.impeccable/config.json`. |
| `ignore-rule <id>` | Append `<id>` to `detector.ignoreRules`; for `overused-font`, requires `--all-values`. |
| `ignore-file <glob>` | Append `<glob>` to `detector.ignoreFiles`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. |
| `reset` | Delete the project config, dedup cache, and Cursor pending queue. |
## Flow
1. Resolve the action from the user's argument. If no action was given, default to `status`.
2. Invoke the admin script and pass the user's output through verbatim:
```bash
node .agents/skills/impeccable/scripts/hook-admin.mjs <action> [args...]
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `$impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
## Intentional findings
The hook itself never writes ignore config. Persist an exception only after the user explicitly confirms the flagged issue is intentional, and always go through `hook-admin.mjs`.
Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- 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.
Example value-specific exception:
```bash
node .agents/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional"
```
Example intentional motion exception:
```bash
node .agents/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional"
```
Example whole-rule font exception:
```bash
node .agents/skills/impeccable/scripts/hook-admin.mjs ignore-rule overused-font --all-values --reason "User asked to ignore overused fonts generally"
```
Example file-scoped exception:
```bash
node .agents/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Card.tsx"
```
## 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.
- 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.
## Failure modes
- If `.impeccable/config.json` or `.impeccable/config.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
- If the user asks to "disable the hook" globally, lead with `$impeccable hooks off` (persistent for this project; writes `hook.enabled: false` to config). The legacy `IMPECCABLE_HOOK_DISABLED=1` env var also works as a one-shot override that follows the shell.
+24 -3
View File
@@ -113,7 +113,9 @@ node .agents/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVE
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Load `brand.md` or `product.md` (freeform only, no action sub-command). Write all variants in one edit, then `--reply done`.
On accept/discard, `live-accept.mjs` removes the wrapper block; the anchor element is untouched.
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
For non-Svelte targets, on accept/discard, `live-accept.mjs` removes the wrapper block; the anchor element is untouched.
### Replace mode (default)
@@ -151,6 +153,25 @@ If `--text` matches multiple candidates equally well, wrap exits with `{ error:
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
**Params on the Svelte component path go in a sidecar, never as an attribute.** Svelte parses `{` inside an attribute value as the start of an expression, so a `data-impeccable-params='[{…}]'` attribute on a component element fails to compile (`Expected token }`). Declare params for this path in `componentDir/params.json`, keyed by variant number, using the exact param schema from section 7:
```json
{
"1": [
{"id":"density","kind":"steps","default":"snug","label":"Density","options":[
{"value":"airy","label":"Airy"},{"value":"snug","label":"Snug"},{"value":"packed","label":"Packed"}
]}
],
"2": [
{"id":"accent","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Accent"}
]
}
```
Author the component `<style>` against `var(--p-<id>, default)` for `range`/`toggle` and `[data-p-<id>="…"]` for `steps`; wrap those selectors in `:global(...)` so the knob values the runtime sets on the mounted root reach your rules. The browser reads `params.json`, docks the panel, and drives `--p-*` / `data-p-*` on the mounted component exactly as it does for the HTML/JSX path.
`styleMode` controls how preview CSS must be authored. Treat it as a detected capability mode, not a framework guess:
- `scoped`: use `@scope ([data-impeccable-variant="N"])` rules.
@@ -342,7 +363,7 @@ Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement
**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it.
**How to declare.** Put a JSON manifest on the variant wrapper:
**How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the Svelte `svelte-component` path, do not use this attribute** (Svelte can't compile `{` inside an attribute value). Declare params in `componentDir/params.json` keyed by variant number instead (see the Svelte component paragraph in the wrap section). The param schema below is identical for both paths.
```html
<div data-impeccable-variant="1" data-impeccable-params='[
@@ -456,7 +477,7 @@ Do these five steps in the current thread, synchronously, before the next poll.
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values; read it first; it drives steps 3 and 4 below.
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `site/styles/workflow.css` for an Astro project, or the component's co-located CSS file for a Vite/Next project; pick whichever already owns styling for the surrounding element).
3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value.
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it; those are live-mode plumbing, not source.
4. **Unwrap the accepted content.** Delete the inner `<div data-impeccable-variant="N" style="display: contents">` that wraps it. On JSX/TSX, also delete the outer `<div data-impeccable-carbonize="SESSION_ID" style={{ display: 'contents' }}>` wrapper if present (accept adds it so ternary/`return` slots keep a single root). Drop `data-impeccable-params` and any `data-p-*` attributes; those are live-mode plumbing, not source.
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now.
After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again.
@@ -1,284 +0,0 @@
#!/usr/bin/env node
/**
* Cleans up deprecated Impeccable skill files, symlinks, and
* skills-lock.json entries left over from previous versions.
*
* Safe to run repeatedly -- it is a no-op when nothing needs cleaning.
*
* Usage (from the project root):
* node {{scripts_path}}/cleanup-deprecated.mjs
*
* What it does:
* 1. Finds every harness-specific skills directory (.claude/skills,
* .cursor/skills, .agents/skills, etc.).
* 2. For each deprecated skill name (with and without i- prefix),
* checks if the directory exists and its SKILL.md mentions
* "impeccable" (to avoid deleting unrelated user skills).
* 3. Deletes confirmed matches (files, directories, or symlinks).
* 4. Removes the corresponding entries from skills-lock.json.
*/
import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, unlinkSync } from 'node:fs';
import { join, resolve } from 'node:path';
// Skills that were renamed, merged, or folded in v2.0, v2.1, and v3.0.
const DEPRECATED_NAMES = [
// v2.0 renames
'frontend-design', // renamed to impeccable
'teach-impeccable', // folded into /impeccable init
// v2.1 merges
'arrange', // renamed to layout
'normalize', // merged into polish
'onboard', // merged into harden
'extract', // merged into /impeccable extract
// v3.0 consolidation: all standalone skills -> /impeccable sub-commands
'adapt',
'animate',
'audit',
'bolder',
'clarify',
'colorize',
'critique',
'delight',
'distill',
'harden',
'layout',
'optimize',
'overdrive',
'polish',
'quieter',
'shape',
'typeset',
];
// All known harness directories that may contain a skills/ subfolder.
const HARNESS_DIRS = [
'.claude', '.cursor', '.gemini', '.codex', '.agents',
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev',
];
// Per-skill fingerprints for SKILL.md bodies that never mentioned
// "impeccable" in their v2.x source. Used as a last-resort match
// when no skills-lock.json exists and the word heuristic fails.
// The strings are lifted verbatim from the v2.x frontmatter
// descriptions, so collisions with hand-written user skills are
// vanishingly unlikely.
const SKILL_FINGERPRINTS = {
harden: 'Make interfaces production-ready: error handling, empty states',
optimize: 'Diagnoses and fixes UI performance across loading speed',
};
/**
* Walk up from startDir until we find a directory that looks like a
* project root (has package.json, .git, or skills-lock.json).
*/
export function findProjectRoot(startDir = process.cwd()) {
let dir = resolve(startDir);
const { root } = { root: '/' };
while (dir !== root) {
if (
existsSync(join(dir, 'package.json')) ||
existsSync(join(dir, '.git')) ||
existsSync(join(dir, 'skills-lock.json'))
) {
return dir;
}
const parent = resolve(dir, '..');
if (parent === dir) break;
dir = parent;
}
return resolve(startDir);
}
/**
* Load skills-lock.json from the project root, or null if missing/unreadable.
*/
export function loadLock(projectRoot) {
const lockPath = join(projectRoot, 'skills-lock.json');
if (!existsSync(lockPath)) return null;
try {
return JSON.parse(readFileSync(lockPath, 'utf-8'));
} catch {
return null;
}
}
/**
* Check whether a skill directory belongs to Impeccable. Three layered
* signals, in order of reliability:
* 1. Lock source equals "pbakaus/impeccable" (authoritative).
* 2. SKILL.md body contains the word "impeccable".
* 3. SKILL.md body contains a per-skill fingerprint (for harden and
* optimize, whose v2.x SKILL.md never mentioned the pack name).
*/
export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
// 1. Authoritative: the lock file claims this skill is ours.
if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
return true;
}
const skillMd = join(skillDir, 'SKILL.md');
if (!existsSync(skillMd)) return false;
let content;
try {
content = readFileSync(skillMd, 'utf-8');
} catch {
return false;
}
// 2. Word-level content heuristic.
if (/impeccable/i.test(content)) return true;
// 3. Per-skill fingerprint for old skills that never mentioned the pack.
// Strip the i- prefix so both `harden` and `i-harden` resolve to the
// same fingerprint entry.
const unprefixed = skillName?.startsWith('i-') ? skillName.slice(2) : skillName;
const fingerprint = unprefixed && SKILL_FINGERPRINTS[unprefixed];
if (fingerprint && content.includes(fingerprint)) return true;
return false;
}
/**
* Build the full list of names to check: each deprecated name, plus
* its i-prefixed variant.
*/
export function buildTargetNames() {
const names = [];
for (const name of DEPRECATED_NAMES) {
names.push(name);
names.push(`i-${name}`);
}
return names;
}
/**
* Find every skills directory across all harness dirs in the project.
* Returns absolute paths that exist on disk.
*/
export function findSkillsDirs(projectRoot) {
const dirs = [];
for (const harness of HARNESS_DIRS) {
const candidate = join(projectRoot, harness, 'skills');
if (existsSync(candidate)) {
dirs.push(candidate);
}
}
return dirs;
}
/**
* Remove deprecated skill directories/symlinks from all harness dirs.
* Reads skills-lock.json so the authoritative "source" field can
* drive deletion even when SKILL.md never mentions impeccable.
* Returns an array of paths that were deleted.
*/
export function removeDeprecatedSkills(projectRoot, lock) {
if (lock === undefined) lock = loadLock(projectRoot);
const targets = buildTargetNames();
const skillsDirs = findSkillsDirs(projectRoot);
const deleted = [];
for (const skillsDir of skillsDirs) {
for (const name of targets) {
const skillPath = join(skillsDir, name);
// Use lstat to detect symlinks (existsSync follows symlinks and
// returns false for dangling ones).
let stat;
try {
stat = lstatSync(skillPath);
} catch {
continue; // does not exist at all
}
if (stat.isSymbolicLink()) {
// Symlink: check the target if it's alive, otherwise treat
// dangling symlinks to deprecated names as safe to remove.
const targetAlive = existsSync(skillPath);
const isMatch = targetAlive
? isImpeccableSkill(skillPath, { skillName: name, lock })
: true;
if (isMatch) {
unlinkSync(skillPath);
deleted.push(skillPath);
}
continue;
}
// Regular directory -- verify it belongs to impeccable
if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
rmSync(skillPath, { recursive: true, force: true });
deleted.push(skillPath);
}
}
}
return deleted;
}
/**
* Remove deprecated entries from skills-lock.json.
* Only removes entries whose source is "pbakaus/impeccable".
* Returns the list of removed skill names.
*/
export function cleanSkillsLock(projectRoot) {
const lockPath = join(projectRoot, 'skills-lock.json');
if (!existsSync(lockPath)) return [];
let lock;
try {
lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
} catch {
return [];
}
if (!lock.skills || typeof lock.skills !== 'object') return [];
const targets = buildTargetNames();
const removed = [];
for (const name of targets) {
const entry = lock.skills[name];
if (!entry) continue;
// Only remove if it belongs to impeccable
if (entry.source === 'pbakaus/impeccable') {
delete lock.skills[name];
removed.push(name);
}
}
if (removed.length > 0) {
writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf-8');
}
return removed;
}
/**
* Run the full cleanup. Returns a summary object.
*
* Order matters: read the lock and delete directories first, then
* strip lock entries. Otherwise the authoritative signal is gone by
* the time directory deletion runs.
*/
export function cleanup(projectRoot) {
const root = projectRoot || findProjectRoot();
const lock = loadLock(root);
const deletedPaths = removeDeprecatedSkills(root, lock);
const removedLockEntries = cleanSkillsLock(root);
return { deletedPaths, removedLockEntries, projectRoot: root };
}
// CLI entry point
if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname)) {
const result = cleanup();
if (result.deletedPaths.length === 0 && result.removedLockEntries.length === 0) {
console.log('No deprecated Impeccable skills found. Nothing to clean up.');
} else {
if (result.deletedPaths.length > 0) {
console.log(`Removed ${result.deletedPaths.length} deprecated skill(s):`);
for (const p of result.deletedPaths) console.log(` - ${p}`);
}
if (result.removedLockEntries.length > 0) {
console.log(`Cleaned ${result.removedLockEntries.length} entry/entries from skills-lock.json:`);
for (const name of result.removedLockEntries) console.log(` - ${name}`);
}
}
}
@@ -22,7 +22,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { loadContext, extractRegister } from './context.mjs';
import { getCritiqueDir } from './impeccable-paths.mjs';
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
/** Is there code here at all, or just context files / an empty repo? */
function hasCode(cwd) {
+17 -3
View File
@@ -27,7 +27,7 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
// ─── Update check ──────────────────────────────────────────────────────────
// Piggyback a lightweight skill-version check on the once-per-session boot.
// When a newer skill ships, append an UPDATE_AVAILABLE directive so the agent
// can offer `npx impeccable skills update`. Everything here is best-effort and
// can offer `npx impeccable update`. Everything here is best-effort and
// silent on failure: a network problem, sandbox, or missing cache must never
// block context output or print an error.
@@ -172,8 +172,8 @@ function buildUpdateDirective(localVersion, latestVersion) {
`UPDATE_AVAILABLE: A newer Impeccable skill is available ` +
`(installed v${localVersion}, latest v${latestVersion}). ` +
`Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` +
`Update now? It runs \`npx impeccable skills update\`." ` +
`If they agree, run \`npx impeccable skills update\` (the update applies to the next session, not this one). ` +
`Update now? It runs \`npx impeccable update\`." ` +
`If they agree, run \`npx impeccable update\` (the update applies to the next session, not this one). ` +
`Either way, continue the current task without waiting, and do not raise this again.`
);
}
@@ -184,9 +184,23 @@ function buildUpdateDirective(localVersion, latestVersion) {
* the user's home dir) and re-surfaces a given version at most once per week so
* the agent never nags. Opt out entirely with IMPECCABLE_NO_UPDATE_CHECK=1.
*/
// Read the unified config's top-level `updateCheck` (local overrides shared).
// Inlined rather than importing hook-lib so the boot path stays lightweight.
function updateCheckDisabledByConfig(cwd = process.cwd()) {
let value;
for (const name of ['config.json', 'config.local.json']) {
try {
const raw = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', name), 'utf-8'));
if (raw && typeof raw === 'object' && typeof raw.updateCheck === 'boolean') value = raw.updateCheck;
} catch { /* missing or malformed: ignore */ }
}
return value === false;
}
async function computeUpdateDirective(now = Date.now()) {
try {
if (process.env.IMPECCABLE_NO_UPDATE_CHECK) return null;
if (updateCheckDisabledByConfig()) return null;
const localVersion = readLocalSkillVersion();
if (!localVersion) return null;
@@ -28,7 +28,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { getCritiqueDir } from './impeccable-paths.mjs';
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
const SLUG_MAX = 50;
@@ -660,6 +660,7 @@ if (IS_BROWSER) {
if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
if (el.closest('[id^="impeccable-live-"]')) continue;
if (el === document.body || el === document.documentElement) continue;
if (!isRenderedForBrowserRule(el)) continue;
const tag = el.tagName.toLowerCase();
const style = getComputedStyle(el);
@@ -1091,6 +1092,7 @@ if (IS_BROWSER) {
return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' };
}
if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' };
if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' };
const blockingReason = (candidate.reasons || []).find(reason =>
reason === 'background-clip text' ||
@@ -1222,6 +1224,7 @@ if (IS_BROWSER) {
category: ap ? ap.category : 'quality',
severity: ap?.severity || 'warning',
detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id),
description: ap ? ap.description : '',
};
@@ -1258,10 +1261,203 @@ if (IS_BROWSER) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
}
const DESIGN_COLOR_TOLERANCE = 6;
const DESIGN_RADIUS_TOLERANCE_PX = 0.5;
const DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']);
function normalizeBrowserFontName(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
function browserPrimaryFont(stack) {
if (!stack || /var\(/i.test(stack)) return '';
return String(stack || '')
.split(',')
.map(normalizeBrowserFontName)
.find(font => font && !GENERIC_FONTS.has(font)) || '';
}
function browserDesignSystemConfig() {
const raw = window.__IMPECCABLE_CONFIG__?.designSystem;
if (!raw?.present) return null;
const allowedFonts = new Set((raw.allowedFonts || []).map(normalizeBrowserFontName).filter(Boolean));
const allowedColors = (raw.allowedColors || [])
.filter(color => color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b))
.map(color => ({ r: color.r, g: color.g, b: color.b }));
const allowedRadii = (raw.allowedRadii || [])
.map(Number)
.filter(px => Number.isFinite(px));
return {
present: true,
hasFonts: raw.hasFonts === true && allowedFonts.size > 0,
allowedFonts,
hasColors: raw.hasColors === true && allowedColors.length > 0,
allowedColors,
hasRadii: raw.hasRadii === true && allowedRadii.length > 0,
allowedRadii,
hasPillRadius: raw.hasPillRadius === true,
};
}
function browserColorsClose(a, b) {
if (!a || !b) return false;
return Math.max(
Math.abs(a.r - b.r),
Math.abs(a.g - b.g),
Math.abs(a.b - b.b),
) <= DESIGN_COLOR_TOLERANCE;
}
function isBrowserDesignColorAllowed(raw, designSystem) {
if (!designSystem?.hasColors) return true;
const text = String(raw || '').trim().toLowerCase();
if (!text || text === 'transparent' || text === 'currentcolor' || text === 'inherit' || text === 'initial') return true;
if (text.includes('var(')) return true;
const parsed = parseAnyColor(text);
if (!parsed) return true;
if ((parsed.a ?? 1) <= 0.05) return true;
return designSystem.allowedColors.some(color => browserColorsClose(parsed, color));
}
function isBrowserTransparentCss(value) {
const text = String(value || '').trim().toLowerCase();
if (!text || text === 'transparent') return true;
const parsed = parseAnyColor(text);
return parsed ? (parsed.a ?? 1) <= 0.05 : false;
}
function isBrowserDesignRadiusAllowed(raw, designSystem) {
if (!designSystem?.hasRadii) return true;
const text = String(raw || '').trim().toLowerCase();
if (!text || text === '0' || text === 'none' || text === 'initial' || text === 'inherit') return true;
if (text.includes('var(') || text.includes('%')) return true;
const px = resolveLengthPx(text, 16);
if (px == null || !Number.isFinite(px) || px <= DESIGN_RADIUS_TOLERANCE_PX) return true;
if (designSystem.hasPillRadius && px >= 99) return true;
return designSystem.allowedRadii.some(allowed => Math.abs(allowed - px) <= DESIGN_RADIUS_TOLERANCE_PX);
}
function browserRadiusTokens(value) {
return String(value || '')
.replace(/\s*\/\s*/g, ' ')
.split(/\s+/)
.map(token => token.trim())
.filter(Boolean);
}
function browserHasDirectText(el) {
return [...(el.childNodes || [])].some(node => node.nodeType === 3 && node.textContent.trim().length > 0);
}
function browserSampleText(el) {
const text = String(el.textContent || '').replace(/\s+/g, ' ').trim();
return text ? ` "${text.slice(0, 40)}"` : '';
}
function shouldSkipDesignElement(el) {
const tag = el.tagName?.toLowerCase?.() || '';
return DESIGN_SKIP_TAGS.has(tag) || isElementHidden(el);
}
function checkElementDesignSystemDOM(el, designSystem, seen) {
if (!designSystem?.present || shouldSkipDesignElement(el)) return [];
const findings = [];
const tag = el.tagName?.toLowerCase?.() || 'unknown';
const style = getComputedStyle(el);
if (designSystem.hasFonts && browserHasDirectText(el)) {
const font = browserPrimaryFont(style.fontFamily || '');
if (font && !designSystem.allowedFonts.has(font) && !seen.fonts.has(font)) {
seen.fonts.add(font);
findings.push({
type: 'design-system-font',
detail: `${tag}${browserSampleText(el)} uses ${font}; not declared in DESIGN.md typography`,
ignoreValue: font,
});
}
}
if (designSystem.hasColors) {
const colorChecks = [];
if (browserHasDirectText(el)) colorChecks.push(['text color', style.color]);
if (!isBrowserTransparentCss(style.backgroundColor)) colorChecks.push(['background', style.backgroundColor]);
for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
if ((parseFloat(style[`border${side}Width`]) || 0) > 0) {
colorChecks.push([`border-${side.toLowerCase()}`, style[`border${side}Color`]]);
}
}
if ((parseFloat(style.outlineWidth) || 0) > 0) colorChecks.push(['outline', style.outlineColor]);
for (const [kind, raw] of colorChecks) {
const label = String(raw || '').trim().replace(/\s+/g, ' ');
if (isBrowserDesignColorAllowed(label, designSystem)) continue;
const key = `${kind}:${label}`;
if (seen.colors.has(key)) continue;
seen.colors.add(key);
findings.push({
type: 'design-system-color',
detail: `${kind} ${label} on ${tag}${browserSampleText(el)} is outside DESIGN.md colors`,
ignoreValue: label,
});
}
}
if (designSystem.hasRadii) {
for (const token of browserRadiusTokens(style.borderRadius || '')) {
if (isBrowserDesignRadiusAllowed(token, designSystem)) continue;
if (seen.radii.has(token)) continue;
seen.radii.add(token);
findings.push({
type: 'design-system-radius',
detail: `border-radius ${token} on ${tag}${browserSampleText(el)} is outside the DESIGN.md rounded scale`,
ignoreValue: token,
});
}
}
return findings;
}
function decodeBrowserGoogleFamily(value) {
const family = String(value || '').split(':')[0].replace(/\+/g, ' ');
try {
return decodeURIComponent(family);
} catch {
return family;
}
}
function checkBrowserDesignSystemSources(designSystem, seen) {
if (!designSystem?.hasFonts) return [];
const findings = [];
for (const link of document.querySelectorAll('link[href*="fonts.googleapis.com/css"]')) {
const href = link.getAttribute('href') || '';
for (const match of href.matchAll(/[?&]family=([^&]+)/g)) {
const display = decodeBrowserGoogleFamily(match[1]);
const font = normalizeBrowserFontName(display);
if (!font || designSystem.allowedFonts.has(font) || seen.fonts.has(font)) continue;
seen.fonts.add(font);
findings.push({
type: 'design-system-font',
detail: `Google Fonts: ${display} is not declared in DESIGN.md typography`,
ignoreValue: display,
});
}
}
return findings;
}
function collectBrowserFindings() {
const groupMap = new Map();
const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : [];
const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id);
const designSystem = browserDesignSystemConfig();
const designSeen = { fonts: new Set(), colors: new Set(), radii: new Set() };
// Note: provider-gated rules (--gpt / --gemini) are NOT filtered here. In a
// real browser env (detector page, live overlay, extension) running every
// check is free, so we always surface them; the gating is purely a CLI
@@ -1292,6 +1488,7 @@ if (IS_BROWSER) {
...checkElementClippedOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementGptBorderShadowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementTextOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementDesignSystemDOM(el, designSystem, designSeen),
].filter(f => _ruleOk(f.type));
addBrowserFindings(groupMap, el, findings);
@@ -1308,6 +1505,13 @@ if (IS_BROWSER) {
const pageLevelFindings = [];
const designSourceFindings = checkBrowserDesignSystemSources(designSystem, designSeen)
.filter(f => _ruleOk(f.type));
if (designSourceFindings.length > 0) {
pageLevelFindings.push(...designSourceFindings);
addBrowserFindings(groupMap, document.body, designSourceFindings);
}
const typoFindings = checkTypography().filter(f => _ruleOk(f.type));
if (typoFindings.length > 0) {
pageLevelFindings.push(...typoFindings);
@@ -1437,13 +1641,20 @@ if (IS_BROWSER) {
return true;
}
function postSerializedFindings(groupMap) {
function scanResultMeta(options = {}) {
const scanId = options.scanId;
if (typeof scanId !== 'string' && typeof scanId !== 'number') return {};
return { scanId: String(scanId) };
}
function postSerializedFindings(groupMap, options = {}) {
if (!EXTENSION_MODE) return;
const allFindings = browserFindingsFromMap(groupMap);
window.postMessage({
source: 'impeccable-results',
findings: serializeFindings(allFindings),
count: allFindings.length,
...scanResultMeta(options),
}, '*');
}
@@ -1497,7 +1708,7 @@ if (IS_BROWSER) {
rememberVisualContrastAnalysis(result);
const added = addVisualContrastResult(groupMap, result, { decorate: true });
if (added) {
postSerializedFindings(groupMap);
postSerializedFindings(groupMap, options);
window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-resolved', {
detail: {
selector: result.selector,
@@ -1565,7 +1776,7 @@ if (IS_BROWSER) {
overlayIndex = 0;
}
function renderBrowserFindings(collected) {
function renderBrowserFindings(collected, options = {}) {
const { allFindings, pageLevelFindings } = collected;
for (const { el, findings } of allFindings) {
@@ -1585,6 +1796,7 @@ if (IS_BROWSER) {
source: 'impeccable-results',
findings: serializeFindings(allFindings),
count: allFindings.length,
...scanResultMeta(options),
}, '*');
}
@@ -1599,11 +1811,11 @@ if (IS_BROWSER) {
clearOverlays();
const generation = scanGeneration;
const collected = collectBrowserFindings();
const allFindings = renderBrowserFindings(collected);
const allFindings = renderBrowserFindings(collected, options);
if (shouldRunVisualContrast(options)) {
addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation })
.then(() => {
if (generation === scanGeneration) postSerializedFindings(collected.groupMap);
if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options);
})
.catch(err => {
reportVisualContrastError(err);
@@ -1618,10 +1830,10 @@ if (IS_BROWSER) {
if (shouldRunVisualContrast(options)) {
const collected = await collectBrowserFindingsAsync(options, { generation, scheduleLazy: true });
if (generation !== scanGeneration) return [];
return renderBrowserFindings(collected);
return renderBrowserFindings(collected, options);
}
lastVisualContrastAnalyses = [];
return renderBrowserFindings(collectBrowserFindings());
return renderBrowserFindings(collectBrowserFindings(), options);
};
const detect = function(options = {}) {
@@ -1,9 +1,15 @@
import fs from 'node:fs';
import path from 'node:path';
import { loadDesignSystemForCwd } from '../design-system.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';
import {
filterDetectionFindings,
readDetectionConfig,
shouldIgnoreDetectionFile,
} from '../../lib/impeccable-config.mjs';
import {
HTML_EXTENSIONS,
buildImportGraph,
@@ -79,10 +85,17 @@ function printUsage() {
Scan files or URLs for UI anti-patterns and design quality issues.
Options:
--json Output results as JSON
--gpt Also report GPT-specific provider tells (off by default)
--gemini Also report Gemini-specific provider tells (off by default)
--help Show this help message
--json Output results as JSON
--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
--no-design-system Do not load local DESIGN.md / .impeccable/design.json context
--help Show this help message
Project config:
Respects .impeccable/config.json and .impeccable/config.local.json detector
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
and detector.designSystem.enabled.
Detection modes:
HTML files Static HTML/CSS analysis (default, catches linked CSS)
@@ -93,7 +106,8 @@ Examples:
impeccable detect src/
impeccable detect index.html
impeccable detect https://example.com
impeccable detect --json .`);
impeccable detect --json .
impeccable detect --no-config src/`);
}
async function detectCli() {
@@ -114,10 +128,16 @@ async function detectCli() {
'Note: --fast is deprecated and ignored. The full scan is fast now and runs every rule.\n',
);
}
const configEnabled = !args.includes('--no-config');
const detectionConfig = configEnabled
? readDetectionConfig(process.cwd())
: { ignoreRules: [], ignoreFiles: [], ignoreValues: [] };
const providers = [];
if (args.includes('--gpt')) providers.push('gpt');
if (args.includes('--gemini')) providers.push('gemini');
const scanOptions = { providers };
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 };
const targets = args.filter(a => !a.startsWith('--'));
if (helpMode) { printUsage(); process.exit(0); }
@@ -175,7 +195,8 @@ async function detectCli() {
}
}
const files = walkDir(resolved);
const files = walkDir(resolved)
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
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)
@@ -219,6 +240,7 @@ async function detectCli() {
allFindings.push(...fileFindings);
}
} else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
const ext = path.extname(resolved).toLowerCase();
if (HTML_EXTENSIONS.has(ext)) {
allFindings.push(...await detectHtml(resolved, scanOptions));
@@ -232,6 +254,8 @@ async function detectCli() {
}
}
allFindings = filterDetectionFindings(allFindings, detectionConfig);
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
else process.stderr.write(formatFindings(allFindings, false) + '\n');
@@ -0,0 +1,750 @@
import fs from 'node:fs';
import path from 'node:path';
import { finding } from './findings.mjs';
import { GENERIC_FONTS } from './shared/constants.mjs';
import { parseAnyColor, resolveLengthPx } from './rules/checks.mjs';
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 CSS_COLOR_RE = /#[0-9a-f]{3,8}\b|rgba?\([^)]+\)|oklch\([^)]+\)|hsla?\([^)]+\)/gi;
const FONT_DECL_RE = /font-family\s*:\s*([^;}\n]+)/gi;
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 STATIC_DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']);
function firstExisting(dir, names) {
for (const name of names) {
const abs = path.join(dir, name);
if (fs.existsSync(abs)) return abs;
}
return null;
}
function resolveDesignMdPath(cwd = process.cwd()) {
const root = firstExisting(cwd, DESIGN_NAMES);
if (root) return { path: root, contextDir: cwd };
for (const rel of FALLBACK_DIRS) {
const dir = path.resolve(cwd, rel);
const found = firstExisting(dir, DESIGN_NAMES);
if (found) return { path: found, contextDir: dir };
}
return null;
}
function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
const candidates = [
path.join(cwd, '.impeccable', 'design.json'),
path.join(cwd, 'DESIGN.json'),
path.join(contextDir, 'DESIGN.json'),
];
return candidates.find((candidate, index) =>
candidates.indexOf(candidate) === index && fs.existsSync(candidate)
) || null;
}
function parseFrontmatter(md) {
const lines = String(md || '').split(/\r?\n/);
if (lines[0]?.trim() !== '---') return null;
let end = -1;
for (let i = 1; i < lines.length; i++) {
if (lines[i].trim() === '---') { end = i; break; }
}
if (end === -1) return null;
try {
return parseYamlSubset(lines.slice(1, end).join('\n'));
} catch {
return null;
}
}
function parseYamlSubset(yaml) {
const root = {};
const stack = [{ indent: -1, obj: root }];
for (const raw of String(yaml || '').split(/\r?\n/)) {
if (!raw.trim() || /^\s*#/.test(raw)) continue;
const indent = raw.match(/^\s*/)[0].length;
const content = raw.slice(indent);
const colonIdx = findTopLevelColon(content);
if (colonIdx === -1) continue;
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) stack.pop();
const key = unquoteYamlKey(content.slice(0, colonIdx).trim());
const rest = stripInlineYamlComment(content.slice(colonIdx + 1).trim());
const parent = stack[stack.length - 1].obj;
if (rest === '') {
const obj = {};
parent[key] = obj;
stack.push({ indent, obj });
} else {
parent[key] = parseScalar(rest);
}
}
return root;
}
function findTopLevelColon(s) {
let inQuote = null;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (inQuote) {
if (ch === inQuote && s[i - 1] !== '\\') inQuote = null;
} else if (ch === '"' || ch === "'") {
inQuote = ch;
} else if (ch === ':') {
return i;
}
}
return -1;
}
function unquoteYamlKey(key) {
if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) {
return key.slice(1, -1);
}
return key;
}
function stripInlineYamlComment(s) {
let inQuote = null;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (inQuote) {
if (ch === inQuote && s[i - 1] !== '\\') inQuote = null;
} else if (ch === '"' || ch === "'") {
inQuote = ch;
} else if (ch === '#' && i > 0 && /\s/.test(s[i - 1])) {
return s.slice(0, i).trimEnd();
}
}
return s;
}
function parseScalar(raw) {
const s = raw.trim();
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
return s.slice(1, -1);
}
if (s === 'true') return true;
if (s === 'false') return false;
if (s === 'null' || s === '~') return null;
if (/^-?\d+$/.test(s)) return Number(s);
if (/^-?\d*\.\d+$/.test(s)) return Number(s);
return s;
}
function safeReadJson(filePath) {
if (!filePath) return null;
try {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch {
return null;
}
}
function normalizeFontName(value) {
return String(value || '')
.trim()
.replace(/\s*!important\s*$/i, '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
function splitFontStack(stack) {
return String(stack || '')
.replace(/\s*!important\s*$/i, '')
.split(',')
.map(normalizeFontName)
.filter(Boolean);
}
function primaryFont(stack) {
if (!stack || /var\(/i.test(stack) || !isLiteralFontStack(stack)) return '';
return splitFontStack(stack).find(font => !GENERIC_FONTS.has(font)) || '';
}
function isLiteralFontStack(stack) {
const text = String(stack || '');
return !/[$`{}]|\s\+\s|\|\|/.test(text);
}
function cssColorLabel(raw) {
return String(raw || '').trim().replace(/\s+/g, ' ');
}
function colorKey(color) {
if (!color) return '';
return `${color.r},${color.g},${color.b}`;
}
function colorsClose(a, b) {
if (!a || !b) return false;
return Math.max(
Math.abs(a.r - b.r),
Math.abs(a.g - b.g),
Math.abs(a.b - b.b),
) <= COLOR_CHANNEL_TOLERANCE;
}
function hslToRgb(H, S, L, alpha = 1) {
const h = (((H % 360) + 360) % 360) / 360;
const s = Math.max(0, Math.min(1, S));
const l = Math.max(0, Math.min(1, L));
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
return {
r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
g: Math.round(hue2rgb(p, q, h) * 255),
b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
a: alpha,
};
}
function parseDesignColor(value) {
const text = String(value || '').trim();
const parsed = parseAnyColor(text);
if (parsed) return parsed;
const hsl = text.match(/hsla?\(\s*([-\d.]+)(?:deg)?\s*,?\s*([\d.]+)%\s*,?\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+))?\s*\)/i);
if (hsl) {
return hslToRgb(
parseFloat(hsl[1]),
parseFloat(hsl[2]) / 100,
parseFloat(hsl[3]) / 100,
hsl[4] !== undefined ? parseFloat(hsl[4]) : 1,
);
}
return null;
}
function addDesignColor(out, value, label) {
const parsed = parseDesignColor(value);
if (!parsed) return;
const key = colorKey(parsed);
if (!out.allowedColorKeys.has(key)) {
out.allowedColorKeys.set(key, { color: parsed, labels: [] });
}
out.allowedColorKeys.get(key).labels.push(label || cssColorLabel(value));
}
function addColorObject(out, colors, prefix = 'colors') {
if (!colors || typeof colors !== 'object') return;
for (const [name, value] of Object.entries(colors)) {
if (typeof value === 'string') {
addDesignColor(out, value, `${prefix}.${name}`);
}
}
}
function addSidecarColors(out, sidecar) {
const colorMeta = sidecar?.extensions?.colorMeta;
if (!colorMeta || typeof colorMeta !== 'object') return;
for (const [name, meta] of Object.entries(colorMeta)) {
if (!meta || typeof meta !== 'object') continue;
if (typeof meta.canonical === 'string') addDesignColor(out, meta.canonical, `sidecar.${name}`);
if (Array.isArray(meta.tonalRamp)) {
for (const [index, value] of meta.tonalRamp.entries()) {
if (typeof value === 'string') addDesignColor(out, value, `sidecar.${name}.tonalRamp[${index}]`);
}
}
}
}
function addTypographyFonts(out, typography) {
if (!typography || typeof typography !== 'object') return;
for (const role of Object.values(typography)) {
if (!role || typeof role !== 'object') continue;
if (typeof role.fontFamily !== 'string') continue;
for (const font of splitFontStack(role.fontFamily)) {
if (!GENERIC_FONTS.has(font)) out.allowedFonts.add(font);
}
}
}
function addRoundedScale(out, rounded) {
if (!rounded || typeof rounded !== 'object') return;
for (const [rawName, value] of Object.entries(rounded)) {
const name = unquoteYamlKey(rawName).toLowerCase();
addRoundedToken(out, name, value);
}
}
function addRoundedToken(out, name, value) {
if (typeof value !== 'string' && typeof value !== 'number') return;
const raw = String(value).trim();
if (!raw || /var\(/i.test(raw) || raw.includes('%')) return;
const px = resolveLengthPx(raw, 16);
if (px == null || !Number.isFinite(px)) return;
out.allowedRadii.push({ name, value: raw, px });
if (/(^|\.)(full|pill|round|rounded-full)$/.test(name)) out.hasPillRadius = true;
}
function addSidecarRadii(out, sidecar) {
const roundedMeta = sidecar?.extensions?.roundedMeta;
if (!roundedMeta || typeof roundedMeta !== 'object') return;
for (const [rawName, meta] of Object.entries(roundedMeta)) {
const name = unquoteYamlKey(rawName).toLowerCase();
if (typeof meta === 'string' || typeof meta === 'number') {
addRoundedToken(out, `sidecar.${name}`, meta);
continue;
}
if (!meta || typeof meta !== 'object') continue;
for (const key of ['canonical', 'value']) {
if (typeof meta[key] === 'string' || typeof meta[key] === 'number') {
addRoundedToken(out, `sidecar.${name}.${key}`, meta[key]);
}
}
for (const key of ['values', 'aliases']) {
if (!Array.isArray(meta[key])) continue;
for (const [index, value] of meta[key].entries()) {
addRoundedToken(out, `sidecar.${name}.${key}[${index}]`, value);
}
}
if (/^(full|pill|round|rounded-full)$/.test(name) || /^(full|pill|round)$/i.test(String(meta.role || ''))) {
out.hasPillRadius = true;
}
}
}
function normalizeDesignSystem(input = {}) {
const frontmatter = input.frontmatter || {};
const sidecar = input.sidecar || null;
const out = {
present: true,
sourcePath: input.sourcePath || null,
sidecarPath: input.sidecarPath || null,
mdNewerThanJson: input.mdNewerThanJson === true,
allowedFonts: new Set(),
allowedColorKeys: new Map(),
allowedRadii: [],
hasPillRadius: false,
};
addTypographyFonts(out, frontmatter.typography);
addColorObject(out, frontmatter.colors);
addSidecarColors(out, sidecar);
addRoundedScale(out, frontmatter.rounded);
addSidecarRadii(out, sidecar);
out.hasFonts = out.allowedFonts.size > 0;
out.hasColors = out.allowedColorKeys.size > 0;
out.hasRadii = out.allowedRadii.length > 0;
return out;
}
function loadDesignSystemForCwd(cwd = process.cwd()) {
const md = resolveDesignMdPath(cwd);
if (!md) return null;
let frontmatter = null;
let mdStat = null;
try {
mdStat = fs.statSync(md.path);
frontmatter = parseFrontmatter(fs.readFileSync(md.path, 'utf-8'));
} catch {
return null;
}
if (!frontmatter || typeof frontmatter !== 'object') return null;
const sidecarPath = resolveDesignSidecarPath(cwd, md.contextDir);
const sidecar = safeReadJson(sidecarPath);
let sidecarStat = null;
try {
if (sidecarPath) sidecarStat = fs.statSync(sidecarPath);
} catch {
sidecarStat = null;
}
return normalizeDesignSystem({
frontmatter,
sidecar,
sourcePath: md.path,
sidecarPath,
mdNewerThanJson: !!(mdStat && sidecarStat && mdStat.mtimeMs > sidecarStat.mtimeMs + 1000),
});
}
function isAllowedFont(font, designSystem) {
if (!font || GENERIC_FONTS.has(font)) return true;
if (!designSystem?.hasFonts) return true;
return designSystem.allowedFonts.has(font);
}
function isAllowedColorRaw(raw, designSystem) {
if (!designSystem?.hasColors) return true;
const text = String(raw || '').trim().toLowerCase();
if (!text || text === 'transparent' || text === 'currentcolor' || text === 'inherit' || text === 'initial') return true;
if (text.includes('var(')) return true;
const parsed = parseDesignColor(text);
if (!parsed) return true;
if ((parsed.a ?? 1) <= 0.05) return true;
for (const entry of designSystem.allowedColorKeys.values()) {
if (colorsClose(parsed, entry.color)) return true;
}
return false;
}
function isAllowedRadiusRaw(raw, designSystem) {
if (!designSystem?.hasRadii) return true;
const text = String(raw || '').trim().toLowerCase();
if (!text || text === '0' || text === 'none' || text === 'initial' || text === 'inherit') return true;
if (text.includes('var(') || text.includes('%')) return true;
const px = resolveLengthPx(text, 16);
if (px == null || !Number.isFinite(px) || px <= RADIUS_TOLERANCE_PX) return true;
if (designSystem.hasPillRadius && px >= 99) return true;
return designSystem.allowedRadii.some(entry => Math.abs(entry.px - px) <= RADIUS_TOLERANCE_PX);
}
function lineLooksCommented(line) {
const trimmed = String(line || '').trim();
return trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*') || trimmed.startsWith('<!--');
}
function isProbablyColorLiteral(line, match) {
const raw = match?.[0] || '';
const index = match.index ?? -1;
if (index < 0) return false;
if (isInsideCssAttributeSelector(line, index)) return false;
const before = line.slice(0, index);
const after = line.slice(index + raw.length);
if (raw.startsWith('#')) {
if (before.endsWith('&')) return false; // HTML numeric entity, e.g. &#8596;
const prevNonSpace = before.match(/\S(?=\s*$)/)?.[0] || '';
const nextNonSpace = after.match(/^\s*(\S)/)?.[1] || '';
if (prevNonSpace === '>' && nextNonSpace === '<') return false; // plain text, e.g. PR #155
}
const styleContext = /(?:^|[{\s;"'`(,])(?:color|background(?:-color|-image)?|border(?:-(?:top|right|bottom|left))?(?:-color)?|outline(?:-color)?|box-shadow|text-shadow|fill|stroke)\s*:\s*[^;{}"'`]*/i.test(before);
const cssFunctionContext = /(?:linear-gradient|radial-gradient|conic-gradient|color-mix)\([^)]*$/i.test(before);
const jsColorKeyContext = /(?:^|[,{]\s*)(?:color|background|backgroundColor|borderColor|outlineColor|fill|stroke|boxShadow|textShadow)\s*[:=]\s*["'`]?[^"'`,}]*/i.test(before);
return styleContext || cssFunctionContext || jsColorKeyContext;
}
function isInsideCssAttributeSelector(line, index) {
if (index < 0) return false;
const before = line.slice(0, index);
const lastOpen = before.lastIndexOf('[');
if (lastOpen === -1) return false;
const lastClose = before.lastIndexOf(']');
if (lastClose > lastOpen) return false;
const after = line.slice(index);
const close = after.indexOf(']');
const block = after.indexOf('{');
return close !== -1 && (block === -1 || close < block);
}
function makeDesignFinding(id, filePath, snippet, line = 0, extras = {}) {
return { ...finding(id, filePath, snippet, line), ...extras };
}
function decodeGoogleFamily(value) {
const family = String(value || '').split(':')[0].replace(/\+/g, ' ');
try {
return decodeURIComponent(family);
} catch {
return family;
}
}
function checkFontStack(stack, filePath, line, designSystem, context) {
const primary = primaryFont(stack);
if (!primary || isAllowedFont(primary, designSystem)) return [];
const display = primary.replace(/\b\w/g, ch => ch.toUpperCase());
return [makeDesignFinding(
'design-system-font',
filePath,
`${context}: ${display} is not declared in DESIGN.md typography`,
line,
{ ignoreValue: display },
)];
}
function extractRadiusTokens(value) {
return String(value || '')
.replace(/\s*\/\s*/g, ' ')
.split(/\s+/)
.map(token => token.trim())
.filter(Boolean);
}
function checkRadiusValue(value, filePath, line, designSystem, context) {
const findings = [];
for (const token of extractRadiusTokens(value)) {
if (isAllowedRadiusRaw(token, designSystem)) continue;
findings.push(makeDesignFinding(
'design-system-radius',
filePath,
`${context}: ${token} is outside the DESIGN.md rounded scale`,
line,
{ ignoreValue: token },
));
}
return findings;
}
function checkSourceDesignSystem(content, filePath, options = {}) {
const designSystem = options.designSystem;
if (!designSystem?.present) return [];
const findings = [];
const lines = String(content || '').split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineNum = i + 1;
if (lineLooksCommented(line)) continue;
if (designSystem.hasFonts) {
for (const match of line.matchAll(FONT_DECL_RE)) {
findings.push(...checkFontStack(match[1], filePath, lineNum, designSystem, 'font-family'));
}
for (const match of line.matchAll(FONT_JS_RE)) {
findings.push(...checkFontStack(match[1], filePath, lineNum, designSystem, 'fontFamily'));
}
for (const match of line.matchAll(GOOGLE_FONT_RE)) {
const url = match[0];
for (const familyMatch of url.matchAll(/[?&]family=([^&]+)/g)) {
const font = normalizeFontName(decodeGoogleFamily(familyMatch[1]));
if (!font || isAllowedFont(font, designSystem)) continue;
const display = decodeGoogleFamily(familyMatch[1]);
findings.push(makeDesignFinding(
'design-system-font',
filePath,
`Google Fonts: ${display} is not declared in DESIGN.md typography`,
lineNum,
{ ignoreValue: display },
));
}
}
}
if (designSystem.hasColors) {
for (const match of line.matchAll(CSS_COLOR_RE)) {
if (!isProbablyColorLiteral(line, match)) continue;
const raw = cssColorLabel(match[0]);
if (isAllowedColorRaw(raw, designSystem)) continue;
findings.push(makeDesignFinding(
'design-system-color',
filePath,
`Undocumented color ${raw} is outside DESIGN.md colors`,
lineNum,
{ ignoreValue: raw },
));
}
}
if (designSystem.hasRadii) {
for (const match of line.matchAll(BORDER_RADIUS_RE)) {
findings.push(...checkRadiusValue(match[1], filePath, lineNum, designSystem, 'border-radius'));
}
for (const match of line.matchAll(BORDER_RADIUS_JS_RE)) {
findings.push(...checkRadiusValue(match[1], filePath, lineNum, designSystem, 'borderRadius'));
}
}
}
return dedupeDesignFindings(findings);
}
function hasDirectText(el) {
return Array.from(el.childNodes || []).some(node => node.nodeType === 3 && node.textContent.trim().length > 0);
}
function sampleText(el) {
const text = String(el.textContent || '').replace(/\s+/g, ' ').trim();
return text ? ` "${text.slice(0, 40)}"` : '';
}
function collectStaticDesignSystemFindings(document, window, filePath, designSystem) {
if (!designSystem?.present) return [];
const findings = [];
const seenFonts = new Set();
const seenColors = new Set();
const seenRadii = new Set();
for (const el of document.querySelectorAll('*')) {
if (shouldSkipStaticDesignElement(el, window)) continue;
const tag = el.tagName?.toLowerCase?.() || 'unknown';
const style = window.getComputedStyle(el);
if (designSystem.hasFonts && hasDirectText(el)) {
const font = primaryFont(style.fontFamily || '');
if (font && !seenFonts.has(font) && !isAllowedFont(font, designSystem)) {
seenFonts.add(font);
findings.push(makeDesignFinding(
'design-system-font',
filePath,
`${tag}${sampleText(el)} uses ${font}; not declared in DESIGN.md typography`,
0,
{ ignoreValue: font },
));
}
}
if (designSystem.hasColors) {
const colorChecks = [];
if (hasDirectText(el)) colorChecks.push(['text color', style.color]);
if (!isTransparentCss(style.backgroundColor)) colorChecks.push(['background', style.backgroundColor]);
for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
if ((parseFloat(style[`border${side}Width`]) || 0) > 0) {
colorChecks.push([`border-${side.toLowerCase()}`, style[`border${side}Color`]]);
}
}
if ((parseFloat(style.outlineWidth) || 0) > 0) colorChecks.push(['outline', style.outlineColor]);
for (const [kind, raw] of colorChecks) {
const label = cssColorLabel(raw);
if (isAllowedColorRaw(label, designSystem)) continue;
const key = `${kind}:${label}`;
if (seenColors.has(key)) continue;
seenColors.add(key);
findings.push(makeDesignFinding(
'design-system-color',
filePath,
`${kind} ${label} on ${tag}${sampleText(el)} is outside DESIGN.md colors`,
0,
{ ignoreValue: label },
));
}
}
if (designSystem.hasRadii) {
const rawRadius = String(style.borderRadius || '').trim();
if (!rawRadius) continue;
for (const token of extractRadiusTokens(rawRadius)) {
if (isAllowedRadiusRaw(token, designSystem)) continue;
if (seenRadii.has(token)) continue;
seenRadii.add(token);
findings.push(makeDesignFinding(
'design-system-radius',
filePath,
`border-radius ${token} on ${tag}${sampleText(el)} is outside the DESIGN.md rounded scale`,
0,
{ ignoreValue: token },
));
}
}
}
return findings;
}
function shouldSkipStaticDesignElement(el, window) {
const tag = el.tagName?.toLowerCase?.() || '';
if (STATIC_DESIGN_SKIP_TAGS.has(tag)) return true;
let current = el;
while (current) {
if (current.getAttribute?.('hidden') !== null || current.getAttribute?.('aria-hidden') === 'true') return true;
const style = window.getComputedStyle(current);
const display = String(style.display || '').toLowerCase();
const visibility = String(style.visibility || '').toLowerCase();
if (display === 'none' || visibility === 'hidden' || visibility === 'collapse') return true;
current = current.parentElement;
}
return false;
}
function isTransparentCss(value) {
const text = String(value || '').trim().toLowerCase();
if (!text || text === 'transparent') return true;
const parsed = parseDesignColor(text);
return parsed ? (parsed.a ?? 1) <= 0.05 : false;
}
function canonicalDesignFindingKey(item) {
if (!item?.antipattern?.startsWith?.('design-system-')) return null;
const value = item.ignoreValue || item.value || '';
if (item.antipattern === 'design-system-font') {
const context = /google fonts/i.test(item.snippet || '') ? 'google-font' : 'font';
const font = normalizeFontName(value);
return font ? `${item.antipattern}:${context}:${font}` : null;
}
if (item.antipattern === 'design-system-color') {
const parsed = parseDesignColor(value);
if (parsed) return `${item.antipattern}:color:${colorKey(parsed)}`;
const label = cssColorLabel(value).toLowerCase();
return label ? `${item.antipattern}:color:${label}` : null;
}
if (item.antipattern === 'design-system-radius') {
const px = resolveLengthPx(String(value || '').trim(), 16);
if (px != null && Number.isFinite(px)) return `${item.antipattern}:radius:${Math.round(px * 100) / 100}`;
const label = String(value || '').trim().toLowerCase();
return label ? `${item.antipattern}:radius:${label}` : null;
}
return null;
}
function mergeDesignSystemFindings(...groups) {
const out = [];
const seen = new Map();
for (const group of groups) {
for (const item of group || []) {
const key = canonicalDesignFindingKey(item);
if (key) {
if (seen.has(key)) {
const existing = out[seen.get(key)];
if ((existing.line || 0) <= 0 && (item.line || 0) > 0) existing.line = item.line;
continue;
}
seen.set(key, out.length);
}
out.push(item);
}
}
return out;
}
function dedupeDesignFindings(findings) {
const out = [];
const seen = new Set();
for (const item of findings) {
const key = [
item.antipattern,
item.line || 0,
normalizeFontName(item.ignoreValue || item.snippet || ''),
].join('\0');
if (seen.has(key)) continue;
seen.add(key);
out.push(item);
}
return out;
}
export {
parseFrontmatter,
normalizeDesignSystem,
loadDesignSystemForCwd,
isAllowedFont,
isAllowedColorRaw,
isAllowedRadiusRaw,
checkSourceDesignSystem,
collectStaticDesignSystemFindings,
mergeDesignSystemFindings,
};
@@ -425,6 +425,35 @@ const ANTIPATTERNS = [
skillSection: 'Layout & Space',
skillGuideline: 'overflow container clipping positioned children',
},
{
id: 'design-system-font',
category: 'quality',
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.',
skillSection: 'Typography',
skillGuideline: 'font family outside the project design system',
},
{
id: 'design-system-color',
category: 'quality',
severity: 'advisory',
name: 'Color outside DESIGN.md',
description:
'A literal color is outside the DESIGN.md palette and sidecar tonal ramps. This may be legitimate, but it should be an intentional design-system addition rather than drift.',
skillSection: 'Color & Contrast',
skillGuideline: 'literal color outside the project design system',
},
{
id: 'design-system-radius',
category: 'quality',
severity: 'advisory',
name: 'Radius outside DESIGN.md',
description:
'A border-radius value is outside the DESIGN.md rounded scale. Use a documented radius token or update the design system if the new shape is intentional.',
skillSection: 'Visual Details',
skillGuideline: 'border radius outside the project design system',
},
// ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
{
@@ -1084,9 +1113,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
@@ -1544,11 +1577,16 @@ function parseAnyColor(s) {
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i);
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
return null;
}
@@ -1577,9 +1615,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[role="navigation"]',
'[aria-label*="breadcrumb" i]',
'[class*="breadcrumb" i]',
'[aria-hidden="true"]',
'[data-impeccable-allow-kickers]',
].join(',');
const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
'li',
'[role="listitem"]',
'[role="option"]',
].join(',');
function cleanInlineText(el) {
return [...el.childNodes]
.filter(n => n.nodeType === 3)
@@ -1589,6 +1637,11 @@ function cleanInlineText(el) {
.trim();
}
function isRepeatedKickerCardContext(heading, kicker) {
const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
function isRepeatedKickerCandidate(opts) {
const {
headingTag,
@@ -1602,6 +1655,7 @@ function isRepeatedKickerCandidate(opts) {
} = opts;
if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false;
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
@@ -1623,6 +1677,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
const kicker = heading.previousElementSibling;
if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
if (isRepeatedKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
@@ -1805,6 +1860,84 @@ function resolveLengthPx(value, fontSizePx) {
return num * fontSizePx;
}
function cssColorIsTransparent(value) {
if (!value) return true;
const str = String(value).trim().toLowerCase();
if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true;
const parsed = parseAnyColor(str);
if (parsed) return (parsed.a ?? 1) <= 0.05;
return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str);
}
function colorsNearlyMatch(a, b) {
const ca = parseAnyColor(a);
const cb = parseAnyColor(b);
if (!ca || !cb) return false;
const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1));
const channelDelta = Math.max(
Math.abs(ca.r - cb.r),
Math.abs(ca.g - cb.g),
Math.abs(ca.b - cb.b),
);
return alphaDelta <= 0.03 && channelDelta <= 3;
}
function getComputedStyleFor(win, el) {
if (win && typeof win.getComputedStyle === 'function') {
try { return win.getComputedStyle(el); } catch {}
}
if (typeof getComputedStyle === 'function') {
try { return getComputedStyle(el); } catch {}
}
return null;
}
function hasVisibleBackgroundBoundary(style, el, win) {
const bg = style?.backgroundColor || '';
if (cssColorIsTransparent(bg)) return false;
let parent = el?.parentElement || null;
while (parent) {
const parentStyle = getComputedStyleFor(win, parent);
const parentBg = parentStyle?.backgroundColor || '';
if (!cssColorIsTransparent(parentBg)) {
return !colorsNearlyMatch(bg, parentBg);
}
parent = parent.parentElement;
}
return true;
}
const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']);
function hasMeaningfulDirectText(node) {
if (!node?.childNodes) return false;
for (const child of node.childNodes) {
if (child.nodeType === 3 && child.textContent.trim().length > 4) return true;
}
return false;
}
function textDescendantsFlushSides(el, rect) {
const flush = { top: false, right: false, bottom: false, left: false };
if (!rect || !el?.querySelectorAll) return flush;
const TEXT_EDGE_THRESHOLD = 4;
const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th');
for (const node of candidates) {
if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue;
let nodeRect = null;
try { nodeRect = node.getBoundingClientRect(); } catch {}
if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue;
if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue;
if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true;
if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true;
if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true;
if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true;
}
return flush;
}
// Pure quality checks. Most run on computed CSS and DOM-only inputs (work in
// jsdom and the browser). Two checks (line-length, cramped-padding) gate on
// element rect dimensions, which jsdom can't compute — pass `rect: null` from
@@ -1834,7 +1967,8 @@ function checkQuality(opts) {
// font-size — bigger text demands proportionally more padding.
// vertical: max(4px, fontSize × 0.3)
// horizontal: max(8px, fontSize × 0.5)
if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre'));
if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
const borders = {
top: parseFloat(style.borderTopWidth) || 0,
right: parseFloat(style.borderRightWidth) || 0,
@@ -1842,7 +1976,7 @@ function checkQuality(opts) {
left: parseFloat(style.borderLeftWidth) || 0,
};
const borderCount = Object.values(borders).filter(w => w > 0).length;
const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)';
const hasBg = hasVisibleBackgroundBoundary(style, el, win);
if (borderCount >= 2 || hasBg) {
const vPads = [], hPads = [];
if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0);
@@ -1890,10 +2024,6 @@ function checkQuality(opts) {
!['fixed', 'absolute'].includes(elPosition) &&
el.children && el.children.length > 0
) {
const isTransparent = (c) =>
!c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' ||
/^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c);
const borderW = {
top: parseFloat(style.borderTopWidth) || 0,
right: parseFloat(style.borderRightWidth) || 0,
@@ -1901,10 +2031,10 @@ function checkQuality(opts) {
left: parseFloat(style.borderLeftWidth) || 0,
};
const borderVisible = {
top: borderW.top > 0 && !isTransparent(style.borderTopColor),
right: borderW.right > 0 && !isTransparent(style.borderRightColor),
bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor),
left: borderW.left > 0 && !isTransparent(style.borderLeftColor),
top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor),
right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor),
bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor),
left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor),
};
// Outline detection. jsdom decomposes `border` shorthand into
// border{Top,…}Width/Color but does NOT decompose `outline` —
@@ -1924,8 +2054,8 @@ function checkQuality(opts) {
if (cMatch) outlineColorVal = cMatch[1];
}
}
const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none';
const bgVisible = !isTransparent(style.backgroundColor);
const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none';
const bgVisible = hasVisibleBackgroundBoundary(style, el, win);
const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible;
if (anyVisible) {
@@ -1953,13 +2083,7 @@ function checkQuality(opts) {
const CHILD_INSULATE_THRESHOLD = 4;
const childrenInsulate = { top: false, right: false, bottom: false, left: false };
for (const child of el.children) {
let childStyle = null;
if (win && typeof win.getComputedStyle === 'function') {
try { childStyle = win.getComputedStyle(child); } catch {}
}
if (!childStyle && typeof getComputedStyle === 'function') {
try { childStyle = getComputedStyle(child); } catch {}
}
let childStyle = getComputedStyleFor(win, child);
if (!childStyle) continue;
const childPad = {
top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0,
@@ -1967,15 +2091,37 @@ function checkQuality(opts) {
bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0,
left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0,
};
const childMargin = {
top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0,
right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0,
bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0,
left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0,
};
if (rect && typeof child.getBoundingClientRect === 'function') {
try {
const childRect = child.getBoundingClientRect();
if (childRect && childRect.width > 0 && childRect.height > 0) {
if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true;
if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true;
if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true;
if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true;
}
} catch {}
}
for (const s of ['top', 'right', 'bottom', 'left']) {
if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true;
if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) {
childrenInsulate[s] = true;
}
}
}
const textFlush = rect ? textDescendantsFlushSides(el, rect) : null;
const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible;
const flushSides = [];
for (const side of ['top', 'right', 'bottom', 'left']) {
const sideBounded = borderVisible[side] || outlineVisible || bgVisible;
if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) {
const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right'));
const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide;
if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) {
flushSides.push(side);
}
}
@@ -2069,7 +2215,7 @@ function checkQuality(opts) {
// Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.)
if (hasDirectText && textLen > 20 && fontSize < 12) {
const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption'];
const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]');
const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]');
const isUppercase = style.textTransform === 'uppercase';
if (!skipTags.includes(tag) && !inUIContext && !isUppercase) {
findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
@@ -2677,17 +2823,28 @@ function checkCreamPalette(doc, win) {
}
// ─── Oversized hero headline ────────────────────────────────────────────────
// Fires when a *long* headline is set at display size, so a full sentence ends
// up dominating the viewport. A punchy one- or two-word headline at the same
// size is a legitimate stylistic choice and must pass — length, not size
// alone, is the tell.
// Fires when a *long* headline is set at display size and actually dominates
// the viewport. A punchy one- or two-word headline at the same size is a
// legitimate stylistic choice, and a large-but-contained two-line hero should
// pass too — length and viewport share together are the tell.
const OVERSIZED_H1_FONT_PX = 72;
const OVERSIZED_H1_MIN_CHARS = 40;
function checkOversizedH1({ tag, fontSize, headingText }) {
const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28;
const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25;
function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) {
if (tag !== 'h1') return [];
const textLen = headingText.length;
if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) {
return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }];
let viewportDetail = '';
if (rect && viewportWidth > 0 && viewportHeight > 0) {
const heightRatio = rect.height / viewportHeight;
const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight);
const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO
|| areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO;
if (!dominatesViewport) return [];
viewportDetail = `, ${Math.round(heightRatio * 100)}vh`;
}
return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }];
}
return [];
}
@@ -2705,31 +2862,54 @@ function checkElementOversizedH1DOM(el) {
const style = getComputedStyle(el);
const fontSize = parseFloat(style.fontSize) || 0;
const headingText = (el.textContent || '').trim().replace(/\s+/g, ' ');
return checkOversizedH1({ tag, fontSize, headingText });
const rect = el.getBoundingClientRect();
const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0;
const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0;
return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight });
}
// ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ────────────
function shadowMaxBlurPx(boxShadow) {
const CSS_COLOR_TOKEN_RE = /(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\([^)]*\)|#[0-9a-fA-F]{3,8}\b|\b(?:black|white|transparent|currentcolor)\b/gi;
function shadowLayerAlpha(layer) {
CSS_COLOR_TOKEN_RE.lastIndex = 0;
const match = CSS_COLOR_TOKEN_RE.exec(layer);
if (!match) return 1;
if (match[0].toLowerCase() === 'transparent') return 0;
const parsed = parseAnyColor(match[0]);
return parsed ? (parsed.a ?? 1) : 1;
}
function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) {
if (!boxShadow || boxShadow === 'none') return 0;
let maxBlur = 0;
// Split into layers on commas not inside parentheses (rgba(...) etc.).
for (const layer of boxShadow.split(/,(?![^()]*\))/)) {
if (shadowLayerAlpha(layer) < minAlpha) continue;
// Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the
// ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps
// unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") —
// both reduce to the same numbers here.
const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' ');
const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' ');
const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0]));
if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]);
}
return maxBlur;
}
function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) {
const maxBorder = Math.max(0, ...borderWidths);
const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5;
const blur = shadowMaxBlurPx(boxShadow);
if (hasThinBorder && blur >= 16) {
function cssColorAlpha(value) {
if (cssColorIsTransparent(value)) return 0;
const parsed = parseAnyColor(value);
return parsed ? (parsed.a ?? 1) : 1;
}
function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) {
const visibleThinBorders = borderWidths
.map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') }))
.filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28);
const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width));
const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 });
if (visibleThinBorders.length >= 2 && blur >= 16) {
return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }];
}
return [];
@@ -2744,13 +2924,22 @@ function borderWidthsFromStyle(style) {
];
}
function borderColorsFromStyle(style) {
return [
style.borderTopColor || '',
style.borderRightColor || '',
style.borderBottomColor || '',
style.borderLeftColor || '',
];
}
function checkElementGptBorderShadow(el, style) {
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' });
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' });
}
function checkElementGptBorderShadowDOM(el) {
const style = getComputedStyle(el);
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' });
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' });
}
// ─── Clipped overflow container ───────────────────────────────────────────────
@@ -2763,17 +2952,131 @@ function classSelector(el) {
return tokens.length ? `${tag}.${tokens.join('.')}` : tag;
}
function positionedChildIsDecorative(child) {
if (!child || typeof child.getAttribute !== 'function') return false;
if (child.closest?.('[aria-hidden="true"]')) return true;
const role = (child.getAttribute('role') || '').toLowerCase();
if (role === 'none' || role === 'presentation') return true;
const tag = child.tagName ? child.tagName.toLowerCase() : '';
if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true;
const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`;
if (
/\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) &&
!positionedChildHasSubstantiveContent(child)
) {
return true;
}
return false;
}
const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [
'a[href]',
'button',
'input',
'select',
'summary',
'textarea',
'[tabindex]:not([tabindex="-1"])',
'[role="button"]',
'[role="dialog"]',
'[role="link"]',
'[role="listbox"]',
'[role="menu"]',
'[role="menuitem"]',
'[role="option"]',
'[role="tooltip"]',
].join(',');
function positionedChildHasSubstantiveContent(child) {
const text = (child.textContent || '').replace(/\s+/g, ' ').trim();
if (text.length > 0) return true;
if (typeof child.matches === 'function') {
try {
if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true;
} catch {}
}
if (typeof child.querySelector === 'function') {
try {
if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true;
} catch {}
}
return false;
}
function clippingContainerIsIntentionalViewport(el) {
if (!el || typeof el.getAttribute !== 'function') return false;
const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase();
if (/\b(carousel|slider)\b/.test(roleDescription)) return true;
const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase();
return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) ||
/\b(demo-area|demo-stage|demo-viewport)\b/.test(ident);
}
function elementRect(el) {
if (!el || typeof el.getBoundingClientRect !== 'function') return null;
try {
const rect = el.getBoundingClientRect();
if (!rect) return null;
const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height];
if (!values.every(Number.isFinite)) return null;
if (rect.width <= 0 && rect.height <= 0) return null;
return rect;
} catch {
return null;
}
}
function positionedStyleImpliesEscape(style) {
const values = [
style.top,
style.right,
style.bottom,
style.left,
style.inset,
style.insetBlock,
style.insetInline,
style.insetBlockStart,
style.insetBlockEnd,
style.insetInlineStart,
style.insetInlineEnd,
].filter(Boolean).map(value => String(value).trim().toLowerCase());
for (const value of values) {
if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true;
if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true;
}
return false;
}
function positionedChildEscapesClip(el, child, clipX, clipY) {
const parentRect = elementRect(el);
const childRect = elementRect(child);
if (!parentRect || !childRect) return null;
const threshold = 2;
return Boolean(
(clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) ||
(clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold))
);
}
function checkClippedOverflow(el, style, getStyle) {
const clips = (v) => v === 'hidden' || v === 'clip';
const scrolls = (v) => v === 'auto' || v === 'scroll';
const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || '';
const anyClip = clips(ox) || clips(oy) || clips(ov);
const clipX = clips(ox) || clips(ov);
const clipY = clips(oy) || clips(ov);
const anyClip = clipX || clipY;
const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov);
if (!anyClip || anyScroll) return [];
if (clippingContainerIsIntentionalViewport(el)) return [];
if (!el.querySelectorAll) return [];
for (const child of el.querySelectorAll('*')) {
const pos = (getStyle(child).position) || '';
const childStyle = getStyle(child);
const pos = childStyle.position || '';
if (pos === 'absolute' || pos === 'fixed') {
if (positionedChildIsDecorative(child)) continue;
const escapes = positionedChildEscapesClip(el, child, clipX, clipY);
if (escapes === false) continue;
if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue;
return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }];
}
}
@@ -2792,14 +3095,94 @@ function checkElementClippedOverflowDOM(el) {
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
function metricLengthPx(value, fontSizePx = 16) {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value !== 'string') return null;
return resolveLengthPx(value, fontSizePx);
}
function firstMetricLengthPx(fontSizePx, ...values) {
for (const value of values) {
const parsed = metricLengthPx(value, fontSizePx);
if (parsed !== null) return parsed;
}
return null;
}
function expandBoxShorthand(parts) {
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
return [parts[0], parts[1], parts[2], parts[3]];
}
function clippedByInset(clipPath) {
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
if (!match) return false;
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
if (!beforeRound) return false;
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
if (percents.some(match => !match)) return false;
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
return top + bottom >= 100 || left + right >= 100;
}
function clippedByRect(clip) {
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
if (!match) return false;
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
if (values.length !== 4) return false;
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
if ([top, right, bottom, left].some(value => value === null)) return false;
return bottom <= top || right <= left;
}
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
if (!style) return false;
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
.map(value => String(value || '').toLowerCase());
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
const clip = String(style.clip || '').trim();
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
}
function isRenderedForBrowserRule(el) {
for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {
if (cur.getAttribute?.('aria-hidden') === 'true') return false;
const style = getComputedStyle(cur);
const visibility = String(style.visibility || '').toLowerCase();
if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false;
if ((parseFloat(style.opacity) || 0) <= 0.01) return false;
if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false;
}
return true;
}
function checkElementTextOverflowDOM(el) {
const tag = el.tagName.toLowerCase();
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
if (!isRenderedForBrowserRule(el)) return [];
// Only the element that actually owns overflowing text — not its ancestors,
// which inherit a wider scrollWidth from the spilling descendant.
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
if (!hasDirectText) return [];
const style = getComputedStyle(el);
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
if (isScreenReaderOnlyTextStyle(style, {
width: rect?.width,
height: rect?.height,
clientWidth: el.clientWidth,
clientHeight: el.clientHeight,
})) return [];
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
if (isScrollRegion(style)) return [];
// A scrollable ancestor means this overflow is intentional and scrollable.
@@ -3476,6 +3859,7 @@ if (IS_BROWSER) {
if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
if (el.closest('[id^="impeccable-live-"]')) continue;
if (el === document.body || el === document.documentElement) continue;
if (!isRenderedForBrowserRule(el)) continue;
const tag = el.tagName.toLowerCase();
const style = getComputedStyle(el);
@@ -3907,6 +4291,7 @@ if (IS_BROWSER) {
return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' };
}
if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' };
if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' };
const blockingReason = (candidate.reasons || []).find(reason =>
reason === 'background-clip text' ||
@@ -4038,6 +4423,7 @@ if (IS_BROWSER) {
category: ap ? ap.category : 'quality',
severity: ap?.severity || 'warning',
detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id),
description: ap ? ap.description : '',
};
@@ -4074,10 +4460,203 @@ if (IS_BROWSER) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
}
const DESIGN_COLOR_TOLERANCE = 6;
const DESIGN_RADIUS_TOLERANCE_PX = 0.5;
const DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']);
function normalizeBrowserFontName(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
function browserPrimaryFont(stack) {
if (!stack || /var\(/i.test(stack)) return '';
return String(stack || '')
.split(',')
.map(normalizeBrowserFontName)
.find(font => font && !GENERIC_FONTS.has(font)) || '';
}
function browserDesignSystemConfig() {
const raw = window.__IMPECCABLE_CONFIG__?.designSystem;
if (!raw?.present) return null;
const allowedFonts = new Set((raw.allowedFonts || []).map(normalizeBrowserFontName).filter(Boolean));
const allowedColors = (raw.allowedColors || [])
.filter(color => color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b))
.map(color => ({ r: color.r, g: color.g, b: color.b }));
const allowedRadii = (raw.allowedRadii || [])
.map(Number)
.filter(px => Number.isFinite(px));
return {
present: true,
hasFonts: raw.hasFonts === true && allowedFonts.size > 0,
allowedFonts,
hasColors: raw.hasColors === true && allowedColors.length > 0,
allowedColors,
hasRadii: raw.hasRadii === true && allowedRadii.length > 0,
allowedRadii,
hasPillRadius: raw.hasPillRadius === true,
};
}
function browserColorsClose(a, b) {
if (!a || !b) return false;
return Math.max(
Math.abs(a.r - b.r),
Math.abs(a.g - b.g),
Math.abs(a.b - b.b),
) <= DESIGN_COLOR_TOLERANCE;
}
function isBrowserDesignColorAllowed(raw, designSystem) {
if (!designSystem?.hasColors) return true;
const text = String(raw || '').trim().toLowerCase();
if (!text || text === 'transparent' || text === 'currentcolor' || text === 'inherit' || text === 'initial') return true;
if (text.includes('var(')) return true;
const parsed = parseAnyColor(text);
if (!parsed) return true;
if ((parsed.a ?? 1) <= 0.05) return true;
return designSystem.allowedColors.some(color => browserColorsClose(parsed, color));
}
function isBrowserTransparentCss(value) {
const text = String(value || '').trim().toLowerCase();
if (!text || text === 'transparent') return true;
const parsed = parseAnyColor(text);
return parsed ? (parsed.a ?? 1) <= 0.05 : false;
}
function isBrowserDesignRadiusAllowed(raw, designSystem) {
if (!designSystem?.hasRadii) return true;
const text = String(raw || '').trim().toLowerCase();
if (!text || text === '0' || text === 'none' || text === 'initial' || text === 'inherit') return true;
if (text.includes('var(') || text.includes('%')) return true;
const px = resolveLengthPx(text, 16);
if (px == null || !Number.isFinite(px) || px <= DESIGN_RADIUS_TOLERANCE_PX) return true;
if (designSystem.hasPillRadius && px >= 99) return true;
return designSystem.allowedRadii.some(allowed => Math.abs(allowed - px) <= DESIGN_RADIUS_TOLERANCE_PX);
}
function browserRadiusTokens(value) {
return String(value || '')
.replace(/\s*\/\s*/g, ' ')
.split(/\s+/)
.map(token => token.trim())
.filter(Boolean);
}
function browserHasDirectText(el) {
return [...(el.childNodes || [])].some(node => node.nodeType === 3 && node.textContent.trim().length > 0);
}
function browserSampleText(el) {
const text = String(el.textContent || '').replace(/\s+/g, ' ').trim();
return text ? ` "${text.slice(0, 40)}"` : '';
}
function shouldSkipDesignElement(el) {
const tag = el.tagName?.toLowerCase?.() || '';
return DESIGN_SKIP_TAGS.has(tag) || isElementHidden(el);
}
function checkElementDesignSystemDOM(el, designSystem, seen) {
if (!designSystem?.present || shouldSkipDesignElement(el)) return [];
const findings = [];
const tag = el.tagName?.toLowerCase?.() || 'unknown';
const style = getComputedStyle(el);
if (designSystem.hasFonts && browserHasDirectText(el)) {
const font = browserPrimaryFont(style.fontFamily || '');
if (font && !designSystem.allowedFonts.has(font) && !seen.fonts.has(font)) {
seen.fonts.add(font);
findings.push({
type: 'design-system-font',
detail: `${tag}${browserSampleText(el)} uses ${font}; not declared in DESIGN.md typography`,
ignoreValue: font,
});
}
}
if (designSystem.hasColors) {
const colorChecks = [];
if (browserHasDirectText(el)) colorChecks.push(['text color', style.color]);
if (!isBrowserTransparentCss(style.backgroundColor)) colorChecks.push(['background', style.backgroundColor]);
for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
if ((parseFloat(style[`border${side}Width`]) || 0) > 0) {
colorChecks.push([`border-${side.toLowerCase()}`, style[`border${side}Color`]]);
}
}
if ((parseFloat(style.outlineWidth) || 0) > 0) colorChecks.push(['outline', style.outlineColor]);
for (const [kind, raw] of colorChecks) {
const label = String(raw || '').trim().replace(/\s+/g, ' ');
if (isBrowserDesignColorAllowed(label, designSystem)) continue;
const key = `${kind}:${label}`;
if (seen.colors.has(key)) continue;
seen.colors.add(key);
findings.push({
type: 'design-system-color',
detail: `${kind} ${label} on ${tag}${browserSampleText(el)} is outside DESIGN.md colors`,
ignoreValue: label,
});
}
}
if (designSystem.hasRadii) {
for (const token of browserRadiusTokens(style.borderRadius || '')) {
if (isBrowserDesignRadiusAllowed(token, designSystem)) continue;
if (seen.radii.has(token)) continue;
seen.radii.add(token);
findings.push({
type: 'design-system-radius',
detail: `border-radius ${token} on ${tag}${browserSampleText(el)} is outside the DESIGN.md rounded scale`,
ignoreValue: token,
});
}
}
return findings;
}
function decodeBrowserGoogleFamily(value) {
const family = String(value || '').split(':')[0].replace(/\+/g, ' ');
try {
return decodeURIComponent(family);
} catch {
return family;
}
}
function checkBrowserDesignSystemSources(designSystem, seen) {
if (!designSystem?.hasFonts) return [];
const findings = [];
for (const link of document.querySelectorAll('link[href*="fonts.googleapis.com/css"]')) {
const href = link.getAttribute('href') || '';
for (const match of href.matchAll(/[?&]family=([^&]+)/g)) {
const display = decodeBrowserGoogleFamily(match[1]);
const font = normalizeBrowserFontName(display);
if (!font || designSystem.allowedFonts.has(font) || seen.fonts.has(font)) continue;
seen.fonts.add(font);
findings.push({
type: 'design-system-font',
detail: `Google Fonts: ${display} is not declared in DESIGN.md typography`,
ignoreValue: display,
});
}
}
return findings;
}
function collectBrowserFindings() {
const groupMap = new Map();
const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : [];
const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id);
const designSystem = browserDesignSystemConfig();
const designSeen = { fonts: new Set(), colors: new Set(), radii: new Set() };
// Note: provider-gated rules (--gpt / --gemini) are NOT filtered here. In a
// real browser env (detector page, live overlay, extension) running every
// check is free, so we always surface them; the gating is purely a CLI
@@ -4108,6 +4687,7 @@ if (IS_BROWSER) {
...checkElementClippedOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementGptBorderShadowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementTextOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementDesignSystemDOM(el, designSystem, designSeen),
].filter(f => _ruleOk(f.type));
addBrowserFindings(groupMap, el, findings);
@@ -4124,6 +4704,13 @@ if (IS_BROWSER) {
const pageLevelFindings = [];
const designSourceFindings = checkBrowserDesignSystemSources(designSystem, designSeen)
.filter(f => _ruleOk(f.type));
if (designSourceFindings.length > 0) {
pageLevelFindings.push(...designSourceFindings);
addBrowserFindings(groupMap, document.body, designSourceFindings);
}
const typoFindings = checkTypography().filter(f => _ruleOk(f.type));
if (typoFindings.length > 0) {
pageLevelFindings.push(...typoFindings);
@@ -4253,13 +4840,20 @@ if (IS_BROWSER) {
return true;
}
function postSerializedFindings(groupMap) {
function scanResultMeta(options = {}) {
const scanId = options.scanId;
if (typeof scanId !== 'string' && typeof scanId !== 'number') return {};
return { scanId: String(scanId) };
}
function postSerializedFindings(groupMap, options = {}) {
if (!EXTENSION_MODE) return;
const allFindings = browserFindingsFromMap(groupMap);
window.postMessage({
source: 'impeccable-results',
findings: serializeFindings(allFindings),
count: allFindings.length,
...scanResultMeta(options),
}, '*');
}
@@ -4313,7 +4907,7 @@ if (IS_BROWSER) {
rememberVisualContrastAnalysis(result);
const added = addVisualContrastResult(groupMap, result, { decorate: true });
if (added) {
postSerializedFindings(groupMap);
postSerializedFindings(groupMap, options);
window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-resolved', {
detail: {
selector: result.selector,
@@ -4381,7 +4975,7 @@ if (IS_BROWSER) {
overlayIndex = 0;
}
function renderBrowserFindings(collected) {
function renderBrowserFindings(collected, options = {}) {
const { allFindings, pageLevelFindings } = collected;
for (const { el, findings } of allFindings) {
@@ -4401,6 +4995,7 @@ if (IS_BROWSER) {
source: 'impeccable-results',
findings: serializeFindings(allFindings),
count: allFindings.length,
...scanResultMeta(options),
}, '*');
}
@@ -4415,11 +5010,11 @@ if (IS_BROWSER) {
clearOverlays();
const generation = scanGeneration;
const collected = collectBrowserFindings();
const allFindings = renderBrowserFindings(collected);
const allFindings = renderBrowserFindings(collected, options);
if (shouldRunVisualContrast(options)) {
addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation })
.then(() => {
if (generation === scanGeneration) postSerializedFindings(collected.groupMap);
if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options);
})
.catch(err => {
reportVisualContrastError(err);
@@ -4434,10 +5029,10 @@ if (IS_BROWSER) {
if (shouldRunVisualContrast(options)) {
const collected = await collectBrowserFindingsAsync(options, { generation, scheduleLazy: true });
if (generation !== scanGeneration) return [];
return renderBrowserFindings(collected);
return renderBrowserFindings(collected, options);
}
lastVisualContrastAnalyses = [];
return renderBrowserFindings(collectBrowserFindings());
return renderBrowserFindings(collectBrowserFindings(), options);
};
const detect = function(options = {}) {
@@ -23,6 +23,13 @@ export {
checkHtmlPatterns,
} from './rules/checks.mjs';
export { createDetectorProfile, summarizeDetectorProfile } from './profile/profiler.mjs';
export {
parseFrontmatter as parseDesignFrontmatter,
normalizeDesignSystem,
loadDesignSystemForCwd,
checkSourceDesignSystem,
collectStaticDesignSystemFindings,
} from './design-system.mjs';
export { detectHtml } from './engines/static-html/detect-html.mjs';
export { detectUrl, createBrowserDetector } from './engines/browser/detect-url.mjs';
export { detectText, extractStyleBlocks, extractCSSinJS } from './engines/regex/detect-text.mjs';
@@ -7,6 +7,25 @@ import { filterByProviders } from '../../registry/antipatterns.mjs';
import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
function serializeDesignSystemForBrowser(designSystem) {
if (!designSystem?.present) return null;
return {
present: true,
hasFonts: designSystem.hasFonts === true,
allowedFonts: Array.from(designSystem.allowedFonts || []),
hasColors: designSystem.hasColors === true,
allowedColors: Array.from(designSystem.allowedColorKeys?.values?.() || [])
.map(entry => entry?.color)
.filter(color => color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b))
.map(color => ({ r: color.r, g: color.g, b: color.b })),
hasRadii: designSystem.hasRadii === true,
allowedRadii: (designSystem.allowedRadii || [])
.map(entry => Number(entry?.px))
.filter(px => Number.isFinite(px)),
hasPillRadius: designSystem.hasPillRadius === true,
};
}
async function runVisualContrastFallback(page, serializedGroups, options, profile, target) {
if (options?.visualContrast === false) return [];
const maxCandidates = Number.isFinite(options?.visualContrastMaxCandidates)
@@ -163,17 +182,19 @@ async function detectUrl(url, options = {}) {
}
// Inject the browser detection script and collect results
const browserDesignSystem = serializeDesignSystemForBrowser(options?.designSystem);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'scan',
ruleId: 'configure-pure-detect',
target: url,
}, () => page.evaluate(() => {
}, () => page.evaluate((designSystem) => {
window.__IMPECCABLE_CONFIG__ = {
...(window.__IMPECCABLE_CONFIG__ || {}),
autoScan: false,
...(designSystem ? { designSystem } : {}),
};
}));
}, browserDesignSystem));
await profileStepAsync(profile, {
engine: 'browser',
phase: 'scan',
@@ -192,7 +213,7 @@ async function detectUrl(url, options = {}) {
return window.impeccableDetect({ decorate: false, serialize: true });
});
return serializedGroups.flatMap(({ findings }) =>
findings.map(f => ({ id: f.type, snippet: f.detail }))
findings.map(f => ({ id: f.type, snippet: f.detail, ignoreValue: f.ignoreValue || '' }))
);
});
const visualFindings = await runVisualContrastFallback(page, serializedGroups, options, profile, url);
@@ -213,7 +234,11 @@ async function detectUrl(url, options = {}) {
}, () => browser.close());
}
}
return filterByProviders(results.map(f => finding(f.id, url, f.snippet)), options.providers);
return filterByProviders(results.map(f => {
const item = finding(f.id, url, f.snippet);
if (f.ignoreValue) item.ignoreValue = f.ignoreValue;
return item;
}), options.providers);
}
async function createBrowserDetector(options = {}) {
@@ -1,4 +1,5 @@
import { GENERIC_FONTS } from '../../shared/constants.mjs';
import { checkSourceDesignSystem } from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { finding } from '../../findings.mjs';
import { filterByProviders } from '../../registry/antipatterns.mjs';
@@ -23,6 +24,18 @@ function stripHtmlToText(html) {
.replace(/\s+/g, ' ');
}
const PAGE_ANALYZER_EXTS = new Set(['.html', '.htm', '.astro', '.vue', '.svelte']);
function extFromFilePath(filePath) {
return filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : '';
}
function shouldRunPageAnalyzers(content, filePath) {
if (!isFullPage(content)) return false;
const ext = extFromFilePath(filePath);
return !ext || PAGE_ANALYZER_EXTS.has(ext);
}
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
if (!m) return false;
@@ -98,9 +111,14 @@ const REGEX_MATCHERS = [
{ id: 'bounce-easing', regex: /\banimate-bounce\b/g,
test: () => true,
fmt: () => 'animate-bounce (Tailwind)' },
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi,
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi,
test: () => true,
fmt: (m) => m[0] },
fmt: (m) => {
const token = m[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
return `animation: ${token || m[1].trim()}`;
} },
{ id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,
test: (m) => {
const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
@@ -422,7 +440,7 @@ const TEXT_CONTENT_ANALYZER_IDS = [
function runTextContentAnalyzers(content, filePath, options = {}) {
const profile = options?.profile;
if (!isFullPage(content)) return [];
if (!shouldRunPageAnalyzers(content, filePath)) return [];
// The 4 text-content analyzers are at indices 3-6 in REGEX_ANALYZERS.
const findings = [];
for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) {
@@ -442,11 +460,11 @@ function detectText(content, filePath, options = {}) {
const profile = options?.profile;
const findings = [];
const lines = content.split('\n');
const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : '';
const ext = extFromFilePath(filePath);
// Run regex matchers on the full file content (catches Tailwind classes, inline styles)
// Enable block context for CSS files where related properties span multiple lines
const cssLike = new Set(['.css', '.scss', '.less']);
const cssLike = new Set(['.css', '.scss', '.sass', '.less']);
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
profile,
phase: 'source',
@@ -486,6 +504,15 @@ function detectText(content, filePath, options = {}) {
}));
}
if (options?.designSystem) {
findings.push(...profileFindings(profile, {
engine: 'regex',
phase: 'source',
ruleId: 'design-system',
target: filePath,
}, () => checkSourceDesignSystem(content, filePath, { designSystem: options.designSystem })));
}
// Deduplicate findings (same antipattern + similar snippet, within 2 lines)
const deduped = [];
for (const f of findings) {
@@ -498,7 +525,7 @@ function detectText(content, filePath, options = {}) {
}
// Page-level analyzers only run on full pages
if (isFullPage(content)) {
if (shouldRunPageAnalyzers(content, filePath)) {
const analyzerIds = [
'single-font',
'flat-type-hierarchy',
@@ -267,7 +267,17 @@ const STATIC_DEFAULT_STYLE = {
paddingRight: '0px',
paddingBottom: '0px',
paddingLeft: '0px',
marginTop: '0px',
marginRight: '0px',
marginBottom: '0px',
marginLeft: '0px',
position: 'static',
visibility: 'visible',
top: 'auto',
right: 'auto',
bottom: 'auto',
left: 'auto',
inset: '',
display: '',
overflow: 'visible',
overflowX: 'visible',
@@ -312,7 +322,17 @@ const STATIC_PROP_MAP = {
'padding-right': 'paddingRight',
'padding-bottom': 'paddingBottom',
'padding-left': 'paddingLeft',
'margin-top': 'marginTop',
'margin-right': 'marginRight',
'margin-bottom': 'marginBottom',
'margin-left': 'marginLeft',
'position': 'position',
'visibility': 'visibility',
'top': 'top',
'right': 'right',
'bottom': 'bottom',
'left': 'left',
'inset': 'inset',
'display': 'display',
'overflow': 'overflow',
'overflow-x': 'overflowX',
@@ -579,6 +599,15 @@ function expandStaticDeclaration(prop, value) {
['paddingLeft', vals[3]],
];
}
if (p === 'margin') {
const vals = expandStaticBoxValues(splitCssTokens(v));
return [
['marginTop', vals[0]],
['marginRight', vals[1]],
['marginBottom', vals[2]],
['marginLeft', vals[3]],
];
}
if (p === 'font') return parseStaticFont(v);
if (p === 'transition') {
const parsed = parseStaticTransition(v);
@@ -2,6 +2,11 @@ import fs from 'node:fs';
import path from 'node:path';
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
import {
checkSourceDesignSystem,
collectStaticDesignSystemFindings,
mergeDesignSystemFindings,
} from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { finding } from '../../findings.mjs';
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
@@ -168,6 +173,22 @@ async function detectHtml(filePath, options = {}) {
}
}
if (options?.designSystem) {
const sourceDesignFindings = profileFindings(profile, {
engine: 'static-html',
phase: 'source',
ruleId: 'design-system',
target: filePath,
}, () => checkSourceDesignSystem(html, filePath, { designSystem: options.designSystem }));
const staticDesignFindings = profileFindings(profile, {
engine: 'static-html',
phase: 'page',
ruleId: 'design-system',
target: filePath,
}, () => collectStaticDesignSystemFindings(document, window, filePath, options.designSystem));
findings.push(...mergeDesignSystemFindings(staticDesignFindings, sourceDesignFindings));
}
if (isFullPage(html)) {
const runPageCheck = (ruleId, callback) => profile
? profileFindings(profile, { engine: 'static-html', phase: 'page', ruleId, target: filePath }, callback)
@@ -11,7 +11,7 @@ const SKIP_DIRS = new Set([
]);
const SCANNABLE_EXTENSIONS = new Set([
'.html', '.htm', '.css', '.scss', '.less',
'.html', '.htm', '.css', '.scss', '.sass', '.less',
'.jsx', '.tsx', '.js', '.ts',
'.vue', '.svelte', '.astro',
]);
@@ -323,6 +323,35 @@ const ANTIPATTERNS = [
skillSection: 'Layout & Space',
skillGuideline: 'overflow container clipping positioned children',
},
{
id: 'design-system-font',
category: 'quality',
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.',
skillSection: 'Typography',
skillGuideline: 'font family outside the project design system',
},
{
id: 'design-system-color',
category: 'quality',
severity: 'advisory',
name: 'Color outside DESIGN.md',
description:
'A literal color is outside the DESIGN.md palette and sidecar tonal ramps. This may be legitimate, but it should be an intentional design-system addition rather than drift.',
skillSection: 'Color & Contrast',
skillGuideline: 'literal color outside the project design system',
},
{
id: 'design-system-radius',
category: 'quality',
severity: 'advisory',
name: 'Radius outside DESIGN.md',
description:
'A border-radius value is outside the DESIGN.md rounded scale. Use a documented radius token or update the design system if the new shape is intentional.',
skillSection: 'Visual Details',
skillGuideline: 'border radius outside the project design system',
},
// ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
{
@@ -514,9 +514,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
@@ -974,11 +978,16 @@ function parseAnyColor(s) {
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i);
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
return null;
}
@@ -1007,9 +1016,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[role="navigation"]',
'[aria-label*="breadcrumb" i]',
'[class*="breadcrumb" i]',
'[aria-hidden="true"]',
'[data-impeccable-allow-kickers]',
].join(',');
const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
'li',
'[role="listitem"]',
'[role="option"]',
].join(',');
function cleanInlineText(el) {
return [...el.childNodes]
.filter(n => n.nodeType === 3)
@@ -1019,6 +1038,11 @@ function cleanInlineText(el) {
.trim();
}
function isRepeatedKickerCardContext(heading, kicker) {
const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
function isRepeatedKickerCandidate(opts) {
const {
headingTag,
@@ -1032,6 +1056,7 @@ function isRepeatedKickerCandidate(opts) {
} = opts;
if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false;
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
@@ -1053,6 +1078,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
const kicker = heading.previousElementSibling;
if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
if (isRepeatedKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
@@ -1235,6 +1261,84 @@ function resolveLengthPx(value, fontSizePx) {
return num * fontSizePx;
}
function cssColorIsTransparent(value) {
if (!value) return true;
const str = String(value).trim().toLowerCase();
if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true;
const parsed = parseAnyColor(str);
if (parsed) return (parsed.a ?? 1) <= 0.05;
return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str);
}
function colorsNearlyMatch(a, b) {
const ca = parseAnyColor(a);
const cb = parseAnyColor(b);
if (!ca || !cb) return false;
const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1));
const channelDelta = Math.max(
Math.abs(ca.r - cb.r),
Math.abs(ca.g - cb.g),
Math.abs(ca.b - cb.b),
);
return alphaDelta <= 0.03 && channelDelta <= 3;
}
function getComputedStyleFor(win, el) {
if (win && typeof win.getComputedStyle === 'function') {
try { return win.getComputedStyle(el); } catch {}
}
if (typeof getComputedStyle === 'function') {
try { return getComputedStyle(el); } catch {}
}
return null;
}
function hasVisibleBackgroundBoundary(style, el, win) {
const bg = style?.backgroundColor || '';
if (cssColorIsTransparent(bg)) return false;
let parent = el?.parentElement || null;
while (parent) {
const parentStyle = getComputedStyleFor(win, parent);
const parentBg = parentStyle?.backgroundColor || '';
if (!cssColorIsTransparent(parentBg)) {
return !colorsNearlyMatch(bg, parentBg);
}
parent = parent.parentElement;
}
return true;
}
const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']);
function hasMeaningfulDirectText(node) {
if (!node?.childNodes) return false;
for (const child of node.childNodes) {
if (child.nodeType === 3 && child.textContent.trim().length > 4) return true;
}
return false;
}
function textDescendantsFlushSides(el, rect) {
const flush = { top: false, right: false, bottom: false, left: false };
if (!rect || !el?.querySelectorAll) return flush;
const TEXT_EDGE_THRESHOLD = 4;
const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th');
for (const node of candidates) {
if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue;
let nodeRect = null;
try { nodeRect = node.getBoundingClientRect(); } catch {}
if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue;
if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue;
if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true;
if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true;
if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true;
if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true;
}
return flush;
}
// Pure quality checks. Most run on computed CSS and DOM-only inputs (work in
// jsdom and the browser). Two checks (line-length, cramped-padding) gate on
// element rect dimensions, which jsdom can't compute — pass `rect: null` from
@@ -1264,7 +1368,8 @@ function checkQuality(opts) {
// font-size — bigger text demands proportionally more padding.
// vertical: max(4px, fontSize × 0.3)
// horizontal: max(8px, fontSize × 0.5)
if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre'));
if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
const borders = {
top: parseFloat(style.borderTopWidth) || 0,
right: parseFloat(style.borderRightWidth) || 0,
@@ -1272,7 +1377,7 @@ function checkQuality(opts) {
left: parseFloat(style.borderLeftWidth) || 0,
};
const borderCount = Object.values(borders).filter(w => w > 0).length;
const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)';
const hasBg = hasVisibleBackgroundBoundary(style, el, win);
if (borderCount >= 2 || hasBg) {
const vPads = [], hPads = [];
if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0);
@@ -1320,10 +1425,6 @@ function checkQuality(opts) {
!['fixed', 'absolute'].includes(elPosition) &&
el.children && el.children.length > 0
) {
const isTransparent = (c) =>
!c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' ||
/^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c);
const borderW = {
top: parseFloat(style.borderTopWidth) || 0,
right: parseFloat(style.borderRightWidth) || 0,
@@ -1331,10 +1432,10 @@ function checkQuality(opts) {
left: parseFloat(style.borderLeftWidth) || 0,
};
const borderVisible = {
top: borderW.top > 0 && !isTransparent(style.borderTopColor),
right: borderW.right > 0 && !isTransparent(style.borderRightColor),
bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor),
left: borderW.left > 0 && !isTransparent(style.borderLeftColor),
top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor),
right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor),
bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor),
left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor),
};
// Outline detection. jsdom decomposes `border` shorthand into
// border{Top,…}Width/Color but does NOT decompose `outline` —
@@ -1354,8 +1455,8 @@ function checkQuality(opts) {
if (cMatch) outlineColorVal = cMatch[1];
}
}
const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none';
const bgVisible = !isTransparent(style.backgroundColor);
const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none';
const bgVisible = hasVisibleBackgroundBoundary(style, el, win);
const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible;
if (anyVisible) {
@@ -1383,13 +1484,7 @@ function checkQuality(opts) {
const CHILD_INSULATE_THRESHOLD = 4;
const childrenInsulate = { top: false, right: false, bottom: false, left: false };
for (const child of el.children) {
let childStyle = null;
if (win && typeof win.getComputedStyle === 'function') {
try { childStyle = win.getComputedStyle(child); } catch {}
}
if (!childStyle && typeof getComputedStyle === 'function') {
try { childStyle = getComputedStyle(child); } catch {}
}
let childStyle = getComputedStyleFor(win, child);
if (!childStyle) continue;
const childPad = {
top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0,
@@ -1397,15 +1492,37 @@ function checkQuality(opts) {
bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0,
left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0,
};
const childMargin = {
top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0,
right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0,
bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0,
left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0,
};
if (rect && typeof child.getBoundingClientRect === 'function') {
try {
const childRect = child.getBoundingClientRect();
if (childRect && childRect.width > 0 && childRect.height > 0) {
if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true;
if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true;
if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true;
if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true;
}
} catch {}
}
for (const s of ['top', 'right', 'bottom', 'left']) {
if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true;
if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) {
childrenInsulate[s] = true;
}
}
}
const textFlush = rect ? textDescendantsFlushSides(el, rect) : null;
const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible;
const flushSides = [];
for (const side of ['top', 'right', 'bottom', 'left']) {
const sideBounded = borderVisible[side] || outlineVisible || bgVisible;
if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) {
const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right'));
const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide;
if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) {
flushSides.push(side);
}
}
@@ -1499,7 +1616,7 @@ function checkQuality(opts) {
// Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.)
if (hasDirectText && textLen > 20 && fontSize < 12) {
const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption'];
const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]');
const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]');
const isUppercase = style.textTransform === 'uppercase';
if (!skipTags.includes(tag) && !inUIContext && !isUppercase) {
findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
@@ -2107,17 +2224,28 @@ function checkCreamPalette(doc, win) {
}
// ─── Oversized hero headline ────────────────────────────────────────────────
// Fires when a *long* headline is set at display size, so a full sentence ends
// up dominating the viewport. A punchy one- or two-word headline at the same
// size is a legitimate stylistic choice and must pass — length, not size
// alone, is the tell.
// Fires when a *long* headline is set at display size and actually dominates
// the viewport. A punchy one- or two-word headline at the same size is a
// legitimate stylistic choice, and a large-but-contained two-line hero should
// pass too — length and viewport share together are the tell.
const OVERSIZED_H1_FONT_PX = 72;
const OVERSIZED_H1_MIN_CHARS = 40;
function checkOversizedH1({ tag, fontSize, headingText }) {
const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28;
const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25;
function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) {
if (tag !== 'h1') return [];
const textLen = headingText.length;
if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) {
return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }];
let viewportDetail = '';
if (rect && viewportWidth > 0 && viewportHeight > 0) {
const heightRatio = rect.height / viewportHeight;
const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight);
const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO
|| areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO;
if (!dominatesViewport) return [];
viewportDetail = `, ${Math.round(heightRatio * 100)}vh`;
}
return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }];
}
return [];
}
@@ -2135,31 +2263,54 @@ function checkElementOversizedH1DOM(el) {
const style = getComputedStyle(el);
const fontSize = parseFloat(style.fontSize) || 0;
const headingText = (el.textContent || '').trim().replace(/\s+/g, ' ');
return checkOversizedH1({ tag, fontSize, headingText });
const rect = el.getBoundingClientRect();
const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0;
const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0;
return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight });
}
// ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ────────────
function shadowMaxBlurPx(boxShadow) {
const CSS_COLOR_TOKEN_RE = /(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\([^)]*\)|#[0-9a-fA-F]{3,8}\b|\b(?:black|white|transparent|currentcolor)\b/gi;
function shadowLayerAlpha(layer) {
CSS_COLOR_TOKEN_RE.lastIndex = 0;
const match = CSS_COLOR_TOKEN_RE.exec(layer);
if (!match) return 1;
if (match[0].toLowerCase() === 'transparent') return 0;
const parsed = parseAnyColor(match[0]);
return parsed ? (parsed.a ?? 1) : 1;
}
function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) {
if (!boxShadow || boxShadow === 'none') return 0;
let maxBlur = 0;
// Split into layers on commas not inside parentheses (rgba(...) etc.).
for (const layer of boxShadow.split(/,(?![^()]*\))/)) {
if (shadowLayerAlpha(layer) < minAlpha) continue;
// Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the
// ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps
// unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") —
// both reduce to the same numbers here.
const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' ');
const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' ');
const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0]));
if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]);
}
return maxBlur;
}
function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) {
const maxBorder = Math.max(0, ...borderWidths);
const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5;
const blur = shadowMaxBlurPx(boxShadow);
if (hasThinBorder && blur >= 16) {
function cssColorAlpha(value) {
if (cssColorIsTransparent(value)) return 0;
const parsed = parseAnyColor(value);
return parsed ? (parsed.a ?? 1) : 1;
}
function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) {
const visibleThinBorders = borderWidths
.map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') }))
.filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28);
const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width));
const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 });
if (visibleThinBorders.length >= 2 && blur >= 16) {
return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }];
}
return [];
@@ -2174,13 +2325,22 @@ function borderWidthsFromStyle(style) {
];
}
function borderColorsFromStyle(style) {
return [
style.borderTopColor || '',
style.borderRightColor || '',
style.borderBottomColor || '',
style.borderLeftColor || '',
];
}
function checkElementGptBorderShadow(el, style) {
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' });
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' });
}
function checkElementGptBorderShadowDOM(el) {
const style = getComputedStyle(el);
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' });
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' });
}
// ─── Clipped overflow container ───────────────────────────────────────────────
@@ -2193,17 +2353,131 @@ function classSelector(el) {
return tokens.length ? `${tag}.${tokens.join('.')}` : tag;
}
function positionedChildIsDecorative(child) {
if (!child || typeof child.getAttribute !== 'function') return false;
if (child.closest?.('[aria-hidden="true"]')) return true;
const role = (child.getAttribute('role') || '').toLowerCase();
if (role === 'none' || role === 'presentation') return true;
const tag = child.tagName ? child.tagName.toLowerCase() : '';
if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true;
const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`;
if (
/\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) &&
!positionedChildHasSubstantiveContent(child)
) {
return true;
}
return false;
}
const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [
'a[href]',
'button',
'input',
'select',
'summary',
'textarea',
'[tabindex]:not([tabindex="-1"])',
'[role="button"]',
'[role="dialog"]',
'[role="link"]',
'[role="listbox"]',
'[role="menu"]',
'[role="menuitem"]',
'[role="option"]',
'[role="tooltip"]',
].join(',');
function positionedChildHasSubstantiveContent(child) {
const text = (child.textContent || '').replace(/\s+/g, ' ').trim();
if (text.length > 0) return true;
if (typeof child.matches === 'function') {
try {
if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true;
} catch {}
}
if (typeof child.querySelector === 'function') {
try {
if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true;
} catch {}
}
return false;
}
function clippingContainerIsIntentionalViewport(el) {
if (!el || typeof el.getAttribute !== 'function') return false;
const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase();
if (/\b(carousel|slider)\b/.test(roleDescription)) return true;
const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase();
return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) ||
/\b(demo-area|demo-stage|demo-viewport)\b/.test(ident);
}
function elementRect(el) {
if (!el || typeof el.getBoundingClientRect !== 'function') return null;
try {
const rect = el.getBoundingClientRect();
if (!rect) return null;
const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height];
if (!values.every(Number.isFinite)) return null;
if (rect.width <= 0 && rect.height <= 0) return null;
return rect;
} catch {
return null;
}
}
function positionedStyleImpliesEscape(style) {
const values = [
style.top,
style.right,
style.bottom,
style.left,
style.inset,
style.insetBlock,
style.insetInline,
style.insetBlockStart,
style.insetBlockEnd,
style.insetInlineStart,
style.insetInlineEnd,
].filter(Boolean).map(value => String(value).trim().toLowerCase());
for (const value of values) {
if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true;
if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true;
}
return false;
}
function positionedChildEscapesClip(el, child, clipX, clipY) {
const parentRect = elementRect(el);
const childRect = elementRect(child);
if (!parentRect || !childRect) return null;
const threshold = 2;
return Boolean(
(clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) ||
(clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold))
);
}
function checkClippedOverflow(el, style, getStyle) {
const clips = (v) => v === 'hidden' || v === 'clip';
const scrolls = (v) => v === 'auto' || v === 'scroll';
const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || '';
const anyClip = clips(ox) || clips(oy) || clips(ov);
const clipX = clips(ox) || clips(ov);
const clipY = clips(oy) || clips(ov);
const anyClip = clipX || clipY;
const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov);
if (!anyClip || anyScroll) return [];
if (clippingContainerIsIntentionalViewport(el)) return [];
if (!el.querySelectorAll) return [];
for (const child of el.querySelectorAll('*')) {
const pos = (getStyle(child).position) || '';
const childStyle = getStyle(child);
const pos = childStyle.position || '';
if (pos === 'absolute' || pos === 'fixed') {
if (positionedChildIsDecorative(child)) continue;
const escapes = positionedChildEscapesClip(el, child, clipX, clipY);
if (escapes === false) continue;
if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue;
return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }];
}
}
@@ -2222,14 +2496,94 @@ function checkElementClippedOverflowDOM(el) {
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
function metricLengthPx(value, fontSizePx = 16) {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value !== 'string') return null;
return resolveLengthPx(value, fontSizePx);
}
function firstMetricLengthPx(fontSizePx, ...values) {
for (const value of values) {
const parsed = metricLengthPx(value, fontSizePx);
if (parsed !== null) return parsed;
}
return null;
}
function expandBoxShorthand(parts) {
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
return [parts[0], parts[1], parts[2], parts[3]];
}
function clippedByInset(clipPath) {
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
if (!match) return false;
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
if (!beforeRound) return false;
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
if (percents.some(match => !match)) return false;
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
return top + bottom >= 100 || left + right >= 100;
}
function clippedByRect(clip) {
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
if (!match) return false;
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
if (values.length !== 4) return false;
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
if ([top, right, bottom, left].some(value => value === null)) return false;
return bottom <= top || right <= left;
}
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
if (!style) return false;
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
.map(value => String(value || '').toLowerCase());
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
const clip = String(style.clip || '').trim();
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
}
function isRenderedForBrowserRule(el) {
for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {
if (cur.getAttribute?.('aria-hidden') === 'true') return false;
const style = getComputedStyle(cur);
const visibility = String(style.visibility || '').toLowerCase();
if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false;
if ((parseFloat(style.opacity) || 0) <= 0.01) return false;
if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false;
}
return true;
}
function checkElementTextOverflowDOM(el) {
const tag = el.tagName.toLowerCase();
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
if (!isRenderedForBrowserRule(el)) return [];
// Only the element that actually owns overflowing text — not its ancestors,
// which inherit a wider scrollWidth from the spilling descendant.
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
if (!hasDirectText) return [];
const style = getComputedStyle(el);
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
if (isScreenReaderOnlyTextStyle(style, {
width: rect?.width,
height: rect?.height,
clientWidth: el.clientWidth,
clientHeight: el.clientHeight,
})) return [];
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
if (isScrollRegion(style)) return [];
// A scrollable ancestor means this overflow is intentional and scrollable.
@@ -2312,5 +2666,6 @@ export {
checkClippedOverflow,
checkElementClippedOverflow,
checkElementClippedOverflowDOM,
isScreenReaderOnlyTextStyle,
checkElementTextOverflowDOM,
};
@@ -0,0 +1,636 @@
#!/usr/bin/env node
/**
* `/impeccable hooks <on|off|status|reset>` — manage the design hook runtime
* via the `hook` key and shared detector ignores via the `detector` key in
* .impeccable/config.json / .impeccable/config.local.json.
*
* Usage:
* node hook-admin.mjs status # print current state
* node hook-admin.mjs on # set enabled: true
* node hook-admin.mjs off # set enabled: false
* node hook-admin.mjs ignore-rule <rule-id> # append to ignoreRules
* node hook-admin.mjs ignore-rule overused-font --all-values
* node hook-admin.mjs ignore-file <glob> # append to ignoreFiles
* node hook-admin.mjs ignore-value <rule> <value> # append to shared ignoreValues
* node hook-admin.mjs ignore-value <rule> <value> --local
* node hook-admin.mjs reset # remove all config + cache
*
* Designed to be invoked by the LLM from the reference/hooks.md flow.
* Output is human-readable; the harness will pass it back to the user.
*/
import fs from 'node:fs';
import path from 'node:path';
import {
getConfigPath,
getLocalConfigPath,
getCachePath,
getPendingPath,
readConfig,
DEFAULT_CONFIG,
ensureHookGitExcludes,
normalizeIgnoreValue,
normalizeIgnoreValueEntries,
} from './hook-lib.mjs';
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
const IMPECCABLE_HOOK_COMMAND_MARKERS = [
'skills/impeccable/scripts/hook-probe.mjs',
'skills/impeccable/scripts/hook.mjs',
'skills/impeccable/scripts/hook-before-edit.mjs',
'skills/impeccable/scripts/hook-after-edit.mjs',
'skills/impeccable/scripts/hook-stop.mjs',
];
const TIMEOUT_SECONDS = 5;
const STATUS_MESSAGE = 'Checking UI changes';
const HOOK_MANIFEST_TARGETS = [
{
provider: '.claude',
skillRel: '.claude/skills/impeccable',
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: runs after Edit/Write/MultiEdit on UI files and surfaces findings as system reminders.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.agents',
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: [
{
matcher: 'Edit|Write|apply_patch',
hooks: [
{
type: 'command',
command: 'node "$(git rev-parse --show-toplevel)/.agents/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.cursor',
skillRel: '.cursor/skills/impeccable',
destRel: '.cursor/hooks.json',
manifest: () => ({
version: 1,
hooks: {
preToolUse: [
{
command: 'node ".cursor/skills/impeccable/scripts/hook-before-edit.mjs"',
timeout: TIMEOUT_SECONDS,
},
],
},
}),
},
];
function readRawConfigFile(filePath) {
if (!fs.existsSync(filePath)) return { exists: false, malformed: false, raw: null };
try {
return { exists: true, malformed: false, raw: JSON.parse(fs.readFileSync(filePath, 'utf-8')) };
} catch {
return { exists: true, malformed: true, raw: null };
}
}
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
function hookSection(unified) {
return unified && typeof unified === 'object' && !Array.isArray(unified) && unified.hook && typeof unified.hook === 'object' && !Array.isArray(unified.hook)
? unified.hook
: null;
}
function detectorSection(unified) {
return unified && typeof unified === 'object' && !Array.isArray(unified) && unified.detector && typeof unified.detector === 'object' && !Array.isArray(unified.detector)
? unified.detector
: null;
}
function readRawHookConfig(cwd, opts = {}) {
const unified = readRawConfigFile(opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd)).raw;
return hookSection(unified);
}
function readRawDetectorConfig(cwd, opts = {}) {
const unified = readRawConfigFile(opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd)).raw;
const merged = mergeDetectorConfig(hookSection(unified));
return mergeDetectorConfig(detectorSection(unified), merged);
}
function stripDetectorKeys(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
const out = {};
for (const [key, value] of Object.entries(raw)) {
if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
}
return out;
}
// Write hook runtime config under `hook`, leaving detector filters in
// `detector` and preserving sibling keys such as updateCheck.
function writeHookConfig(cwd, hookConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
if (opts.local) ensureHookGitExcludes(cwd);
const existingRaw = readRawConfigFile(filePath).raw;
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const existingHook = stripDetectorKeys(hookSection(existing));
// Merge over the existing hook object so fields the merge helpers don't manage
// (consent, quiet, auditLog) survive a `/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');
return filePath;
}
function writeDetectorConfig(cwd, detectorConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
if (opts.local) ensureHookGitExcludes(cwd);
const existingRaw = readRawConfigFile(filePath).raw;
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const nextHook = stripDetectorKeys(hookSection(existing));
const existingDetector = mergeDetectorConfig(detectorSection(existing));
const next = {
...existing,
detector: mergeDetectorConfig(detectorConfig, existingDetector),
};
if (Object.keys(nextHook).length > 0) next.hook = nextHook;
else delete next.hook;
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
return filePath;
}
function mergeHookConfig(existing) {
const base = existing && typeof existing === 'object' ? existing : {};
return {
enabled: base.enabled === false ? false : true,
limits: {
maxFindings: Number.isFinite(base?.limits?.maxFindings) ? base.limits.maxFindings : DEFAULT_CONFIG.limits.maxFindings,
maxChars: Number.isFinite(base?.limits?.maxChars) ? base.limits.maxChars : DEFAULT_CONFIG.limits.maxChars,
},
};
}
function mergeDetectorConfig(existing, seed = null) {
const base = existing && typeof existing === 'object' ? existing : {};
const out = seed ? {
ignoreRules: [...seed.ignoreRules],
ignoreFiles: [...seed.ignoreFiles],
ignoreValues: normalizeIgnoreValueEntries(seed.ignoreValues),
} : {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
};
if (seed?.designSystem && typeof seed.designSystem === 'object' && !Array.isArray(seed.designSystem)) {
out.designSystem = { ...seed.designSystem };
}
if (base.designSystem && typeof base.designSystem === 'object' && !Array.isArray(base.designSystem)) {
out.designSystem = {
...(out.designSystem || {}),
enabled: base.designSystem.enabled === false ? false : true,
};
}
if (Array.isArray(base.ignoreRules)) {
out.ignoreRules = Array.from(new Set([...out.ignoreRules, ...base.ignoreRules.map(String)]));
}
if (Array.isArray(base.ignoreFiles)) {
out.ignoreFiles = Array.from(new Set([...out.ignoreFiles, ...base.ignoreFiles.map(String)]));
}
if (Array.isArray(base.ignoreValues)) {
out.ignoreValues = mergeIgnoreValueEntries(out.ignoreValues, base.ignoreValues);
}
return out;
}
function mergeIgnoreValueEntries(existing, incoming) {
const map = new Map();
for (const entry of normalizeIgnoreValueEntries(existing)) {
map.set(ignoreValueEntryKey(entry), entry);
}
for (const entry of normalizeIgnoreValueEntries(incoming)) {
map.set(ignoreValueEntryKey(entry), entry);
}
return Array.from(map.values());
}
function ignoreValueEntryKey(entry) {
const files = Array.isArray(entry.files) && entry.files.length > 0 ? entry.files.join('\x1f') : '';
return `${entry.rule}\0${entry.value}\0${files}`;
}
function statusReport(cwd) {
const shared = readRawConfigFile(getConfigPath(cwd));
const local = readRawConfigFile(getLocalConfigPath(cwd));
const cfg = readConfig(cwd);
const envKill = process.env.IMPECCABLE_HOOK_DISABLED;
const envState = envKill ? `IMPECCABLE_HOOK_DISABLED=${envKill}` : 'unset';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/config.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/config.local.json';
const cachePath = path.relative(cwd, getCachePath(cwd)) || '.impeccable/hook.cache.json';
const fileState = (info, relPath, absent) => {
if (info.malformed) return `${relPath} (malformed; ignored)`;
if (info.exists) return relPath;
return `${relPath} (${absent})`;
};
const ignoreValues = cfg.ignoreValues.map((entry) => `${entry.rule}=${entry.value}`);
const lines = [
`Impeccable design hook`,
` state: ${cfg.enabled ? 'enabled' : 'disabled'}`,
` shared file: ${fileState(shared, cfgPath, 'using defaults; file not present')}`,
` local file: ${fileState(local, localPath, 'not present')}`,
` ignoreRules: ${cfg.ignoreRules.length ? cfg.ignoreRules.join(', ') : '(none)'}`,
` ignoreFiles: ${cfg.ignoreFiles.length ? cfg.ignoreFiles.join(', ') : '(none)'}`,
` ignoreValues: ${ignoreValues.length ? ignoreValues.join(', ') : '(none)'}`,
` maxFindings: ${cfg.limits.maxFindings}`,
` maxChars: ${cfg.limits.maxChars}`,
` env override: ${envState}`,
` cache file: ${fs.existsSync(getCachePath(cwd)) ? cachePath : `${cachePath} (not present)`}`,
];
return lines.join('\n');
}
function setEnabled(cwd, value) {
const config = mergeHookConfig(readRawHookConfig(cwd));
config.enabled = value;
const target = writeHookConfig(cwd, config);
if (!value) {
return `Design hook disabled for this project (wrote ${path.relative(cwd, target) || target}).`;
}
const localTarget = writeHookConfig(cwd, { consent: 'accepted' }, { local: true });
const repaired = repairHookManifests(cwd);
const parts = [
`Design hook enabled for this project (wrote ${path.relative(cwd, target) || target}).`,
`Recorded local hook consent in ${path.relative(cwd, localTarget) || localTarget}.`,
];
if (repaired.written.length > 0) {
parts.push(`Installed or repaired hook manifests for: ${repaired.written.join(', ')}.`);
} else if (repaired.already.length > 0) {
parts.push(`Hook manifests already installed for: ${repaired.already.join(', ')}.`);
} else {
parts.push('No installed provider skill folders found to repair.');
}
if (repaired.backups.length > 0) {
parts.push(`Backed up malformed manifest(s): ${repaired.backups.map((filePath) => path.relative(cwd, filePath) || filePath).join(', ')}.`);
}
return parts.join(' ');
}
function repairHookManifests(cwd) {
const result = { written: [], already: [], backups: [] };
for (const target of HOOK_MANIFEST_TARGETS) {
if (!fs.existsSync(path.join(cwd, target.skillRel))) continue;
const dest = path.join(cwd, target.destRel);
const sharedDest = target.sharedDestRel ? path.join(cwd, target.sharedDestRel) : null;
if (sharedDest && fileHasImpeccableHookMarker(sharedDest)) {
pruneImpeccableHookFromManifest(dest);
result.already.push(target.provider);
continue;
}
const fresh = target.manifest();
let next = fresh;
if (fs.existsSync(dest)) {
try {
next = mergeHookManifests(JSON.parse(fs.readFileSync(dest, 'utf-8')), fresh);
} catch {
const backup = `${dest}.bak`;
fs.copyFileSync(dest, backup);
result.backups.push(backup);
}
}
const serialized = `${JSON.stringify(next, null, 2)}\n`;
const current = fs.existsSync(dest) ? safeReadText(dest) : null;
if (current === serialized) {
result.already.push(target.provider);
continue;
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, serialized);
result.written.push(target.provider);
}
return result;
}
function safeReadText(filePath) {
try {
return fs.readFileSync(filePath, 'utf-8');
} catch {
return null;
}
}
function mergeHookManifests(existing, fresh) {
const existingObject = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
const freshObject = fresh && typeof fresh === 'object' && !Array.isArray(fresh) ? fresh : {};
const existingHooks = existingObject.hooks && typeof existingObject.hooks === 'object' && !Array.isArray(existingObject.hooks)
? existingObject.hooks
: {};
const freshHooks = freshObject.hooks && typeof freshObject.hooks === 'object' && !Array.isArray(freshObject.hooks)
? freshObject.hooks
: {};
const merged = { ...existingObject, hooks: {} };
if (freshObject.version !== undefined) merged.version = freshObject.version;
if (freshObject.description !== undefined) merged.description = freshObject.description;
const hookEvents = new Set([...Object.keys(existingHooks), ...Object.keys(freshHooks)]);
for (const event of hookEvents) {
const preserved = stripImpeccableHookEntries(existingHooks[event]);
const added = Array.isArray(freshHooks[event]) ? freshHooks[event] : [];
const mergedEntries = [...preserved, ...added];
if (mergedEntries.length > 0) merged.hooks[event] = mergedEntries;
}
return merged;
}
function fileHasImpeccableHookMarker(filePath) {
if (!fs.existsSync(filePath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch {
return false;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
if (!parsed.hooks || typeof parsed.hooks !== 'object') return false;
return valueHasImpeccableHookMarker(parsed.hooks);
}
function valueHasImpeccableHookMarker(value) {
if (typeof value === 'string') {
return IMPECCABLE_HOOK_COMMAND_MARKERS.some((marker) => value.includes(marker));
}
if (Array.isArray(value)) return value.some(valueHasImpeccableHookMarker);
if (value && typeof value === 'object') return Object.values(value).some(valueHasImpeccableHookMarker);
return false;
}
function stripImpeccableHookEntry(entry) {
if (!entry || typeof entry !== 'object') return entry;
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
return null;
}
if (!Array.isArray(entry.hooks)) return entry;
const strippedHooks = entry.hooks
.map(stripImpeccableHookEntry)
.filter(Boolean);
if (strippedHooks.length === 0 && entry.hooks.some(valueHasImpeccableHookMarker)) {
return null;
}
return { ...entry, hooks: strippedHooks };
}
function stripImpeccableHookEntries(entries) {
if (!Array.isArray(entries)) return [];
return entries
.map(stripImpeccableHookEntry)
.filter(Boolean);
}
function pruneImpeccableHookFromManifest(manifestPath) {
if (!fileHasImpeccableHookMarker(manifestPath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
} catch {
return false;
}
const existingHooks = parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)
? parsed.hooks
: {};
const cleanedHooks = {};
for (const [event, entries] of Object.entries(existingHooks)) {
const kept = stripImpeccableHookEntries(entries);
if (kept.length > 0) cleanedHooks[event] = kept;
}
const next = { ...parsed };
if (Object.keys(cleanedHooks).length > 0) {
next.hooks = cleanedHooks;
} else {
delete next.hooks;
delete next.description;
delete next.version;
}
if (Object.keys(next).length === 0) {
fs.rmSync(manifestPath, { force: true });
} else {
fs.writeFileSync(manifestPath, `${JSON.stringify(next, null, 2)}\n`);
}
return true;
}
function normalizeRuleId(rule) {
return String(rule || '').trim().toLowerCase();
}
function parseIgnoreRuleArgs(args) {
const positionals = [];
let allValues = false;
for (let i = 0; i < args.length; i++) {
const arg = String(args[i] || '');
if (arg === '--all-values') {
allValues = true;
} else if (arg === '--reason') {
while (i + 1 < args.length && !String(args[i + 1]).startsWith('--')) i++;
} else if (arg.startsWith('--reason=')) {
// Accepted for command symmetry; ignoreRules stores rule ids only.
} else if (arg.startsWith('--')) {
throw new Error(`Unknown ignore-rule flag: ${arg}`);
} else {
positionals.push(arg);
}
}
return {
rule: normalizeRuleId(positionals[0]),
allValues,
};
}
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 === '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.');
}
const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
if (!config.ignoreRules.includes(rule)) config.ignoreRules.push(rule);
writeDetectorConfig(cwd, config);
return `Added "${rule}" to detector.ignoreRules. Current: ${config.ignoreRules.join(', ')}`;
}
function addIgnoreFile(cwd, glob) {
if (!glob) throw new Error('Pass a glob, e.g. /impeccable hooks ignore-file "src/legacy/**"');
const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob);
writeDetectorConfig(cwd, config);
return `Added "${glob}" to detector.ignoreFiles. Current: ${config.ignoreFiles.join(', ')}`;
}
function parseIgnoreValueArgs(args) {
const positionals = [];
let shared = false;
let local = false;
let reason = '';
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--shared') {
shared = true;
} else if (arg === '--local') {
local = true;
} else if (arg === '--reason') {
const chunks = [];
while (i + 1 < args.length && !String(args[i + 1]).startsWith('--')) {
chunks.push(args[++i]);
}
reason = chunks.join(' ').trim();
} else if (String(arg).startsWith('--reason=')) {
reason = String(arg).slice('--reason='.length).trim();
} else {
positionals.push(arg);
}
}
const [rule, ...valueParts] = positionals;
return {
rule: String(rule || '').trim().toLowerCase(),
value: normalizeIgnoreValue(valueParts.join(' ')),
shared,
local,
reason,
};
}
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');
}
if (parsed.shared && parsed.local) {
throw new Error('Pass only one scope flag: --shared or --local');
}
const local = parsed.local;
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local }));
const key = `${parsed.rule}\0${parsed.value}`;
const existing = config.ignoreValues.find((entry) => `${entry.rule}\0${entry.value}` === key);
if (existing) {
if (parsed.reason) existing.reason = parsed.reason;
} else {
const entry = {
rule: parsed.rule,
value: parsed.value,
createdAt: new Date().toISOString(),
};
if (parsed.reason) entry.reason = parsed.reason;
config.ignoreValues.push(entry);
}
const target = writeDetectorConfig(cwd, config, { local });
const scope = local ? 'local detector.ignoreValues' : 'shared detector.ignoreValues';
return `Added ${parsed.rule}=${parsed.value} to ${scope} (${path.relative(cwd, target) || target}).`;
}
function reset(cwd) {
const removed = [];
// Unified files may hold non-hook keys (e.g. updateCheck); strip only the
// hook/detector subtrees and keep the rest, deleting the file only if nothing remains.
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd)]) {
try {
const raw = readRawConfigFile(filePath).raw;
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || (!('hook' in raw) && !('detector' in raw))) continue;
const { hook, detector, ...rest } = raw;
if (Object.keys(rest).length === 0) {
fs.unlinkSync(filePath);
} else {
fs.writeFileSync(filePath, JSON.stringify(rest, null, 2) + '\n');
}
removed.push(path.relative(cwd, filePath) || filePath);
} catch { /* ignore */ }
}
// State files are wholly ours; delete outright.
for (const filePath of [getCachePath(cwd), getPendingPath(cwd)]) {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
removed.push(path.relative(cwd, filePath) || filePath);
}
} catch { /* ignore */ }
}
return removed.length
? `Reset design hook config and cache (removed: ${removed.join(', ')}).`
: 'No hook config or cache to remove. Already at defaults.';
}
function main() {
const [, , actionArg, ...rest] = process.argv;
const action = (actionArg || 'status').toLowerCase();
const cwd = process.cwd();
if (!ACTIONS.has(action)) {
process.stderr.write(`Unknown action: ${action}\nValid: ${Array.from(ACTIONS).join(', ')}\n`);
process.exit(1);
}
try {
let out = '';
switch (action) {
case 'status': out = statusReport(cwd); break;
case 'on': out = setEnabled(cwd, true); break;
case 'off': out = setEnabled(cwd, false); break;
case 'ignore-rule': out = addIgnoreRule(cwd, rest); break;
case 'ignore-file': out = addIgnoreFile(cwd, rest[0]); break;
case 'ignore-value': out = addIgnoreValue(cwd, rest); break;
case 'reset': out = reset(cwd); break;
}
process.stdout.write(out + '\n');
} catch (err) {
process.stderr.write(`Error: ${err.message || err}\n`);
process.exit(1);
}
}
main();
@@ -0,0 +1,476 @@
#!/usr/bin/env node
/**
* Impeccable design hook — Cursor preToolUse write gate.
*
* Cursor's stop hook is not consistently dispatched by the headless agent, so
* this hook checks proposed Write/Edit content before it lands. It only denies
* writes when the real detector finds an issue in the proposed UI content.
*
* Contract: never break a turn accidentally. On malformed input or internal
* errors, allow the tool and exit 0.
*/
import fs from 'node:fs';
import path from 'node:path';
import {
ALLOWED_EXTS,
EDIT_COUNT_THRESHOLD,
GENERATED_PATH,
SENSITIVE_PATH,
appendDesignSystemNote,
designSystemOptions,
filterFindings,
loadDetector,
matchesAnyGlob,
persistCache,
readCache,
readConfig,
renderTemplate,
resolveProjectCwd,
truthy,
writeAuditLog,
} from './hook-lib.mjs';
async function readStdin() {
if (process.stdin.isTTY) return '';
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
return Buffer.concat(chunks).toString('utf-8');
}
function done(payload = null) {
if (payload) process.stdout.write(JSON.stringify(payload));
process.exit(0);
}
function allow(extra = {}, payload = {}) {
writeAuditLog(process.env, {
ts: new Date().toISOString(),
event: 'preToolUse',
...extra,
});
return done({ permission: 'allow', ...payload });
}
function deny(message, audit) {
writeAuditLog(process.env, {
ts: new Date().toISOString(),
event: 'preToolUse',
blocked: true,
...audit,
});
return done({
permission: 'deny',
user_message: message,
agent_message: message,
});
}
function toolInput(event) {
return event?.tool_input && typeof event.tool_input === 'object' ? event.tool_input : {};
}
function proposedFilePath(event, cwd) {
const input = toolInput(event);
const raw = input.file_path || input.path || input.target_file || event?.file_path;
const candidate = typeof raw === 'string' && raw.trim()
? raw
: shellWriteDestination(shellCommand(input));
if (typeof candidate !== 'string' || !candidate.trim()) return '';
return path.isAbsolute(candidate) ? candidate : path.resolve(cwd, candidate);
}
function proposedContent(event, cwd, filePath) {
const input = toolInput(event);
for (const key of ['content', 'streamContent', 'text']) {
if (typeof input[key] === 'string') return input[key];
}
const editProjection = projectedEditContent(input, filePath, cwd);
if (editProjection !== undefined) return editProjection;
if (hasFragmentEditContent(input)) {
return { skipped: 'fragment-only-edit' };
}
const command = shellCommand(input);
const pythonContent = shellPythonWriteContent(command);
if (pythonContent) return pythonContent;
const shellContent = shellHereDocContent(command);
if (shellContent) return shellContent;
const copiedContent = shellCopiedFileContent(command, cwd);
if (copiedContent) return copiedContent;
return '';
}
function hasFragmentEditContent(input) {
if (!input || typeof input !== 'object') return false;
if (typeof input.new_string === 'string' || typeof input.newString === 'string' || typeof input.new_str === 'string' || typeof input.replacement === 'string') {
return true;
}
return Array.isArray(input.edits) && input.edits.some((edit) => edit && typeof edit === 'object');
}
function projectedEditContent(input, filePath, cwd) {
if (!filePath) return undefined;
const singleOld = firstString(input, ['old_string', 'oldString', 'old_str', 'target']);
const singleNew = firstString(input, ['new_string', 'newString', 'new_str', 'replacement']);
if (singleOld !== undefined || singleNew !== undefined) {
if (singleOld === undefined || singleNew === undefined) return { skipped: 'fragment-only-edit' };
const original = readExistingProjectFile(filePath, cwd);
if (original === null) return { skipped: 'edit-original-unreadable' };
const projected = replaceOnce(original, singleOld, singleNew);
return projected === null ? { skipped: 'edit-old-string-missing' } : projected;
}
if (!Array.isArray(input.edits)) return undefined;
const original = readExistingProjectFile(filePath, cwd);
if (original === null) return { skipped: 'edit-original-unreadable' };
let projected = original;
for (const edit of input.edits) {
if (!edit || typeof edit !== 'object') return { skipped: 'fragment-only-edit' };
const oldString = firstString(edit, ['old_string', 'oldString', 'old_str', 'target']);
const newString = firstString(edit, ['new_string', 'newString', 'new_str', 'replacement']);
if (oldString === undefined || newString === undefined) return { skipped: 'fragment-only-edit' };
const next = replaceOnce(projected, oldString, newString);
if (next === null) return { skipped: 'edit-old-string-missing' };
projected = next;
}
return projected;
}
function firstString(obj, keys) {
for (const key of keys) {
if (typeof obj?.[key] === 'string') return obj[key];
}
return undefined;
}
function replaceOnce(original, oldString, newString) {
if (oldString === '') return null;
const index = original.indexOf(oldString);
if (index === -1) return null;
return `${original.slice(0, index)}${newString}${original.slice(index + oldString.length)}`;
}
function readExistingProjectFile(filePath, cwd) {
if (!isInsideProject(filePath, cwd)) return null;
if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null;
try {
const stat = fs.statSync(filePath);
if (!stat.isFile() || stat.size > 1024 * 1024) return null;
return fs.readFileSync(filePath, 'utf-8');
} catch {
return null;
}
}
function shellCommand(input) {
if (typeof input.command === 'string') return input.command;
if (input.args && typeof input.args.command === 'string') return input.args.command;
return '';
}
function shellRedirectPath(command) {
if (!command || typeof command !== 'string') return '';
const match = command.match(/(?:^|[\s;&|])(?:>>?|1>>?)\s*(?:"([^"]+)"|'([^']+)'|([^<>\s]+))/);
return (match?.[1] || match?.[2] || match?.[3] || '').trim();
}
function shellWriteDestination(command) {
return shellRedirectPath(command) || shellTeeDestination(command) || shellCopyPaths(command)?.dest || shellPythonWriteDestination(command) || '';
}
function shellPythonWriteDestination(command) {
if (!/\bpython(?:3)?\b/.test(command || '')) return '';
const directPath = firstMatch(command, /(?:^|[^\w.])(?:pathlib\.)?Path\(\s*(["'])(.*?)\1\s*\)\s*\.write_text\s*\(/);
if (directPath) return directPath;
const pathsByVar = new Map();
const assignmentRe = /\b([A-Za-z_]\w*)\s*=\s*(?:pathlib\.)?Path\(\s*(["'])(.*?)\2\s*\)/g;
let assignment;
while ((assignment = assignmentRe.exec(command))) {
pathsByVar.set(assignment[1], assignment[3]);
}
const writeVarRe = /\b([A-Za-z_]\w*)\.write_text\s*\(/g;
let writeVar;
while ((writeVar = writeVarRe.exec(command))) {
const candidate = pathsByVar.get(writeVar[1]);
if (candidate) return candidate;
}
return firstMatch(command, /\bopen\(\s*(["'])(.*?)\1\s*,\s*(["'])[wax](?:\+)?b?\3/);
}
function firstMatch(value, re) {
const match = String(value || '').match(re);
return (match?.[2] || '').trim();
}
function shellTeeDestination(command) {
const words = shellWords(command);
const teeIndex = words.findIndex((word) => path.basename(word) === 'tee');
if (teeIndex === -1) return '';
for (const word of words.slice(teeIndex + 1)) {
if (['&&', '||', ';', '|'].includes(word)) break;
if (word === '--') continue;
if (word.startsWith('-')) continue;
return word;
}
return '';
}
function shellCopiedFileContent(command, cwd) {
const source = shellCopyPaths(command)?.source;
if (!source) return '';
const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source);
if (!isInsideProject(sourcePath, cwd)) return '';
if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return '';
try {
const stat = fs.statSync(sourcePath);
if (!stat.isFile() || stat.size > 1024 * 1024) return '';
return fs.readFileSync(sourcePath, 'utf-8');
} catch {
return '';
}
}
function shellCopyPaths(command) {
const words = shellWords(command);
if (words.length < 3 || path.basename(words[0]) !== 'cp') return null;
const args = [];
for (const word of words.slice(1)) {
if (['&&', '||', ';', '|'].includes(word)) break;
if (word === '--') continue;
if (word.startsWith('-')) continue;
args.push(word);
}
if (args.length < 2) return null;
return { source: args[args.length - 2], dest: args[args.length - 1] };
}
function shellWords(command) {
if (!command || typeof command !== 'string') return [];
const words = [];
const re = /"((?:\\"|[^"])*)"|'((?:\\'|[^'])*)'|([^\s]+)/g;
let match;
while ((match = re.exec(command))) {
words.push((match[1] ?? match[2] ?? match[3] ?? '').replace(/\\(["'])/g, '$1'));
}
return words;
}
function shellHereDocContent(command) {
if (!command || typeof command !== 'string') return '';
const markerMatch = command.match(/<<-?\s*['"]?([A-Za-z0-9_.-]+)['"]?[^\r\n]*\r?\n/);
if (!markerMatch) return '';
const marker = markerMatch[1];
const start = (markerMatch.index || 0) + markerMatch[0].length;
const rest = command.slice(start);
const endRe = new RegExp(`\\r?\\n${escapeRegExp(marker)}(?:\\r?\\n|$)`);
const end = rest.search(endRe);
return end >= 0 ? rest.slice(0, end) : '';
}
function shellPythonWriteContent(command) {
if (!/\bpython(?:3)?\b/.test(command || '')) return '';
const script = shellHereDocContent(command) || command;
return pythonStringArg(script, /\.write_text\s*\(\s*/g) || pythonStringArg(script, /\.write\s*\(\s*/g);
}
function pythonStringArg(script, prefixRe) {
let prefix;
while ((prefix = prefixRe.exec(script))) {
const start = prefixRe.lastIndex;
const triple = script.slice(start, start + 3);
if (triple === "'''" || triple === '"""') {
const end = script.indexOf(triple, start + 3);
if (end !== -1) return script.slice(start + 3, end);
continue;
}
const quote = script[start];
if (quote !== '"' && quote !== "'") continue;
let out = '';
for (let i = start + 1; i < script.length; i++) {
const ch = script[i];
if (ch === '\\') {
out += script[i + 1] || '';
i += 1;
} else if (ch === quote) {
return out;
} else {
out += ch;
}
}
}
return '';
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function relativePath(filePath, cwd) {
try {
const rel = path.relative(cwd, filePath);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return filePath;
return rel.split(path.sep).join('/');
} catch {
return filePath;
}
}
function isInsideProject(filePath, cwd) {
try {
const rel = path.relative(cwd, filePath);
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
} catch {
return false;
}
}
function cursorBlockMessage(findings, filePath, config, cwd) {
const rendered = renderTemplate(findings, filePath, config, { cwd });
const blocked = rendered.replace(
'[impeccable@1] Design hook findings requiring review',
'[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review',
);
return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked;
}
function findingSignature(findings) {
return findings
.map((finding) => `${finding.antipattern || 'unknown'}:${finding.line || 0}`)
.sort()
.join('|');
}
function bumpCursorDenial(cache, sessionId, filePath, findings) {
const session = cache.sessions[sessionId] || { updatedAt: Date.now(), files: {} };
cache.sessions[sessionId] = session;
session.updatedAt = Date.now();
const fileEntry = session.files[filePath] || { editCount: 0, findings: [] };
session.files[filePath] = fileEntry;
const key = findingSignature(findings);
fileEntry.cursorDenials = fileEntry.cursorDenials && typeof fileEntry.cursorDenials === 'object'
? fileEntry.cursorDenials
: {};
fileEntry.cursorDenials[key] = (fileEntry.cursorDenials[key] || 0) + 1;
return { key, count: fileEntry.cursorDenials[key] };
}
async function main() {
if (truthy(process.env.IMPECCABLE_HOOK_DISABLED)) {
return allow({ skipped: 'env-disabled' });
}
let event = null;
try {
const raw = await readStdin();
if (raw) event = JSON.parse(raw);
} catch {
return allow({ skipped: 'stdin-malformed' });
}
if (!event || typeof event !== 'object') {
return allow({ skipped: 'stdin-empty' });
}
const cwd = resolveProjectCwd(event);
const started = Date.now();
const filePath = proposedFilePath(event, cwd);
const audit = {
harness: 'cursor',
cwd,
tool: event.tool_name || null,
file: filePath || null,
};
if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started });
if (!isInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started });
if (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 });
const ext = path.extname(filePath).toLowerCase();
audit.ext = ext;
if (!ALLOWED_EXTS.has(ext)) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
const contentResult = proposedContent(event, cwd, filePath);
if (contentResult && typeof contentResult === 'object' && contentResult.skipped) {
return allow({ ...audit, skipped: contentResult.skipped, durationMs: Date.now() - started });
}
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 });
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 });
}
const detector = await loadDetector();
if (!detector || typeof detector.detectText !== 'function') {
return allow({ ...audit, skipped: 'detector-missing', durationMs: Date.now() - started });
}
const scanOptions = designSystemOptions(config, detector, cwd);
let findings = [];
try {
findings = await detector.detectText(content, filePath, scanOptions);
} catch {
return allow({ ...audit, error: 'detector-threw', durationMs: Date.now() - started });
}
const filtered = filterFindings(findings || [], content, ext, config);
if (filtered.length === 0) {
return allow({
...audit,
findings: (findings || []).length,
blockedFindings: 0,
durationMs: Date.now() - started,
});
}
const message = appendDesignSystemNote(cursorBlockMessage(filtered, filePath, config, cwd), scanOptions);
const sessionId = event.session_id || event.conversation_id || 'unknown';
const cache = readCache(cwd);
const denial = bumpCursorDenial(cache, sessionId, filePath, filtered);
persistCache(cwd, cache);
if (denial.count > EDIT_COUNT_THRESHOLD) {
const warning = `${message}\n\nThis is the ${denial.count}th repeated denial for the same file and finding signature, so Impeccable is allowing this write to avoid a loop. Reconsider the issue immediately after the tool runs.`;
return allow({
...audit,
findings: (findings || []).length,
blockedFindings: filtered.length,
cursorDenialKey: denial.key,
cursorDenialCount: denial.count,
downgraded: true,
chars: warning.length,
durationMs: Date.now() - started,
}, {
user_message: warning,
agent_message: warning,
});
}
return deny(message, {
...audit,
findings: (findings || []).length,
blockedFindings: filtered.length,
cursorDenialKey: denial.key,
cursorDenialCount: denial.count,
chars: message.length,
durationMs: Date.now() - started,
});
}
main().catch((err) => {
if (process.env.IMPECCABLE_HOOK_DEBUG) {
process.stderr.write(`[impeccable-hook-before-edit] ${err}\n`);
}
done({ permission: 'allow' });
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
#!/usr/bin/env node
/**
* Impeccable design hook PostToolUse entry point.
*
* Reads the Claude Code / Codex / Cursor hook event from stdin, runs the design
* detector against the touched file, and emits a system reminder via
* `hookSpecificOutput.additionalContext` when findings exist.
*
* Contract: never break a turn. Always exit 0. Clean files emit a small ack
* unless quiet mode is enabled.
*
* Most logic lives in `hook-lib.mjs` so it is unit-testable without a
* subprocess. This file is the thin stdin/stdout adapter.
*/
import { runHook, writeAuditLog } from './hook-lib.mjs';
async function readStdin() {
if (process.stdin.isTTY) return '';
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
return Buffer.concat(chunks).toString('utf-8');
}
async function main() {
// Snapshot the inherited env FIRST so the re-entrancy guard checks the
// parent's value, not the value we are about to export for any child
// processes the hook might ever spawn.
const inheritedEnv = { ...process.env };
process.env.IMPECCABLE_HOOK_DEPTH = process.env.IMPECCABLE_HOOK_DEPTH || '1';
let stdinJson = '';
try { stdinJson = await readStdin(); } catch { /* fall through */ }
const result = await runHook({
stdinJson,
env: inheritedEnv,
cwd: process.cwd(),
});
writeAuditLog(process.env, result.audit, process.cwd());
if (result.stdout) process.stdout.write(result.stdout);
process.exit(result.exitCode || 0);
}
main().catch((err) => {
// Last-ditch: never break the agent's turn even if something we did not
// anticipate goes wrong. Audit-log the failure if logging is enabled.
try {
writeAuditLog(process.env, {
ts: new Date().toISOString(),
event: 'PostToolUse',
error: String(err && err.message ? err.message : err),
});
} catch { /* swallow */ }
if (process.env.IMPECCABLE_HOOK_DEBUG) {
process.stderr.write(`[impeccable-hook] ${err}\n`);
}
process.exit(0);
});
@@ -62,7 +62,7 @@ function parseYamlSubset(yaml) {
stack.pop();
}
const key = content.slice(0, colonIdx).trim();
const key = unquoteYamlKey(content.slice(0, colonIdx).trim());
const rest = stripInlineYamlComment(content.slice(colonIdx + 1).trim());
const parent = stack[stack.length - 1].obj;
@@ -93,6 +93,13 @@ function findTopLevelColon(s) {
return -1;
}
function unquoteYamlKey(key) {
if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) {
return key.slice(1, -1);
}
return key;
}
function stripInlineYamlComment(s) {
let inQuote = null;
for (let i = 0; i < s.length; i++) {
@@ -0,0 +1,638 @@
/**
* 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);
const value = extractFindingIgnoreValue(finding);
if (!rule || !value) return false;
return ignoreValues.some((entry) => {
const wildcardValue = entry.value === '*';
if (entry.rule !== rule || (!wildcardValue && !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, '\\$&');
}
+169 -46
View File
@@ -15,8 +15,14 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs';
import { isGeneratedFile } from './lib/is-generated.mjs';
import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import {
applyDeferredSvelteComponentAccepts,
findSvelteComponentManifest,
inlineSvelteComponentAccept,
removeSvelteComponentSession,
} from './live/svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -41,6 +47,9 @@ Required:
Options:
--page-url URL Current browser page URL; scopes staged copy-edit cleanup
--defer-source-write
Deprecated compatibility flag. Svelte component accepts
now write the real source immediately.
Output (JSON):
{ handled, file, carbonize }`);
@@ -64,18 +73,67 @@ Output (JSON):
// Find the file containing this session's markers
const found = findSessionFile(id, process.cwd());
if (!found) {
const svelteComponentManifest = found ? null : findSvelteComponentManifest(id, process.cwd());
if (!found && !svelteComponentManifest) {
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
process.exit(0);
}
if (svelteComponentManifest) {
if (isDiscard) {
removeSvelteComponentSession(id, process.cwd());
console.log(JSON.stringify({
handled: true,
file: svelteComponentManifest.sourceFile,
carbonize: false,
previewMode: 'svelte-component',
componentDir: svelteComponentManifest.componentDir,
}));
return;
}
let result;
try {
result = inlineSvelteComponentAccept(
svelteComponentManifest,
variantNum,
paramValues,
process.cwd(),
);
} catch (err) {
result = {
handled: false,
error: err.message,
file: svelteComponentManifest.sourceFile,
sourceFile: svelteComponentManifest.sourceFile,
previewMode: 'svelte-component',
componentDir: svelteComponentManifest.componentDir,
};
}
if (result.carbonize) {
result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + result.file + '. See reference/live.md "Required after accept".';
}
console.log(JSON.stringify({ handled: result.handled !== false, ...result }));
return;
}
const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile);
const previewBlock = findMarkerBlock(id, lines);
const sourceShadowPreview = previewBlock
? readSourceShadowPreviewMeta(content, id)
: null;
if (sourceShadowPreview) {
console.log(JSON.stringify({
handled: false,
error: 'source_shadow_preview_deprecated',
hint: 'Svelte live mode now uses svelte-component injection. Re-wrap the element and regenerate variants.',
}));
process.exit(0);
}
// Bail if the session lives in a generated file. The agent manually wrote
// the wrapper there for preview, and is responsible for writing the
// accepted variant to true source (or cleaning up on discard). See
// "Handle fallback" in live.md.
if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {
console.log(JSON.stringify({
handled: false,
@@ -207,6 +265,71 @@ function handleDiscard(id, lines, targetFile) {
// Accept
// ---------------------------------------------------------------------------
/**
* Build carbonize stitch-in lines. JSX targets occupy a single child slot
* (ternary branch, return value, etc.) the same constraint as live-wrap.
* When isJsx, tuck markers + <style> + variant wrapper inside one outer
* <div data-impeccable-carbonize> so the slot keeps a single root node.
*/
function buildCarbonizeReplacement({
indent,
commentSyntax,
isJsx,
id,
variantNum,
cssContent,
paramValues,
restored,
}) {
const lines = [];
if (!cssContent) {
lines.push(...restored);
return lines;
}
const variantStyleAttr = isJsx
? "style={{ display: 'contents' }}"
: 'style="display: contents"';
const pushCarbonizeBody = (bodyIndent) => {
const bodyRestored = reindentContent(restored, indent, bodyIndent + ' ');
lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
lines.push(bodyIndent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : ''));
for (const cssLine of cssContent) {
lines.push(bodyIndent + cssLine.trimStart());
}
lines.push(bodyIndent + (isJsx ? '`}</style>' : '</style>'));
if (paramValues && Object.keys(paramValues).length > 0) {
lines.push(
bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close,
);
}
lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
lines.push(bodyIndent + '<div data-impeccable-variant="' + variantNum + '" ' + variantStyleAttr + '>');
lines.push(...bodyRestored);
lines.push(bodyIndent + '</div>');
};
if (isJsx) {
const wrapperStyle = 'style={{ display: "contents" }}';
lines.push(indent + '<div data-impeccable-carbonize="' + id + '" ' + wrapperStyle + '>');
pushCarbonizeBody(indent + ' ');
lines.push(indent + '</div>');
} else {
pushCarbonizeBody(indent);
}
return lines;
}
function reindentContent(contentLines, fromIndent, toIndent) {
return contentLines.map((line) => {
if (line.trim() === '') return '';
if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length);
return toIndent + line.trimStart();
});
}
function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
@@ -235,45 +358,17 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const hasHelperAttrs = variantText.includes('data-impeccable-variant');
const needsCarbonize = !!(cssContent || hasHelperAttrs);
// Build the replacement
const restored = deindentContent(variantContent, indent);
const replacement = [];
if (cssContent) {
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
// JSX targets need the CSS body wrapped in a template literal so that the
// `{` and `}` in CSS rules don't get parsed as JSX expressions.
replacement.push(indent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : ''));
// Re-indent CSS content to match
for (const cssLine of cssContent) {
replacement.push(indent + cssLine.trimStart());
}
replacement.push(indent + (isJsx ? '`}</style>' : '</style>'));
if (paramValues && Object.keys(paramValues).length > 0) {
// Preserve the user's knob positions for the carbonize-cleanup agent
// to bake into the final CSS when it collapses scoped rules.
replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
}
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
// Keep the `@scope ([data-impeccable-variant="N"])` selectors in the
// carbonize CSS block working visually by re-wrapping the accepted content
// in a data-impeccable-variant="N" div with `display: contents` (so layout
// isn't affected). The carbonize agent strips this attribute + wrapper when
// it moves the CSS to a proper stylesheet.
//
// Style attribute syntax has to follow the host file's flavor — JSX files
// need the object form, otherwise React 19 throws "Failed to set indexed
// property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
if (cssContent) {
const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
replacement.push(...restored);
replacement.push(indent + '</div>');
} else {
replacement.push(...restored);
}
const replacement = buildCarbonizeReplacement({
indent,
commentSyntax,
isJsx,
id,
variantNum,
cssContent,
paramValues,
restored,
});
const newLines = [
...lines.slice(0, replaceRange.start),
@@ -285,6 +380,34 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') };
}
function readSourceShadowPreviewMeta(content, id) {
const escaped = escapeRegExp(id);
const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>');
const match = String(content || '').match(wrapperRe);
if (!match) return null;
const tag = match[0];
if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null;
const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file');
const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start'));
const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end'));
if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null;
return { sourceFile, sourceStartLine, sourceEndLine };
}
function readHtmlAttr(tag, name) {
const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1'));
if (!match) return null;
return decodeHtmlAttr(match[2]);
}
function decodeHtmlAttr(value) {
return String(value || '')
.replace(/&quot;/g, '"')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
}
// ---------------------------------------------------------------------------
// Parsing helpers
// ---------------------------------------------------------------------------
@@ -686,4 +809,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs
acceptCli();
}
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock };
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts };
@@ -0,0 +1,146 @@
/**
* Browser-side DOM helpers for Impeccable live mode.
*
* Kept separate from live-browser.js so future browser script parts can share
* chrome mounting, lookup, focus, and picker helpers without depending on the
* full overlay UI bundle.
*/
(function (root) {
'use strict';
if (!root) return;
function createLiveBrowserDomHelpers({
prefix,
skipTags,
document: doc = root.document,
css = root.CSS,
crypto = root.crypto,
} = {}) {
if (!prefix) throw new Error('prefix required');
if (!doc) throw new Error('document required');
const tagsToSkip = skipTags || new Set();
function own(el) {
return el && (el.id?.startsWith(prefix) || el.closest?.('[id^="' + prefix + '"]'));
}
function pickable(el) {
if (!el || el.nodeType !== 1) return false;
if (tagsToSkip.has(String(el.tagName || '').toLowerCase())) return false;
if (own(el)) return false;
const r = el.getBoundingClientRect();
return r.width >= 20 && r.height >= 20;
}
function desc(el) {
if (!el) return '';
let s = el.tagName.toLowerCase();
if (el.id) s += '#' + el.id;
else if (el.classList.length) s += '.' + [...el.classList].slice(0, 2).join('.');
return s;
}
function rectIsUsableAnchor(rect) {
return !!rect && rect.width > 0.5 && rect.height > 0.5;
}
function makeFrozenAnchor(el) {
if (!el || !el.getBoundingClientRect) return null;
const r = el.getBoundingClientRect();
if (!rectIsUsableAnchor(r)) return null;
const rect = {
x: r.x, y: r.y,
top: r.top, left: r.left,
right: r.right, bottom: r.bottom,
width: r.width, height: r.height,
};
return {
__impeccableFrozenAnchor: true,
tagName: el.tagName || 'DIV',
id: el.id || '',
classList: el.classList ? [...el.classList] : [],
hasAttribute: () => false,
getBoundingClientRect: () => rect,
};
}
function id8() {
if (crypto?.randomUUID) return crypto.randomUUID().replace(/-/g, '').slice(0, 8);
return (Math.random().toString(16).slice(2) + Date.now().toString(16)).slice(0, 8);
}
function cssId(id) {
if (css?.escape) return css.escape(id);
return String(id).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
}
function liveUiRoot() {
const uiRoot = root.__IMPECCABLE_LIVE_UI_ROOT__;
if (uiRoot && typeof uiRoot.appendChild === 'function') return uiRoot;
return doc.body;
}
function uiAppend(el) {
liveUiRoot().appendChild(el);
return el;
}
function uiAppendStyle(styleEl) {
const uiRoot = liveUiRoot();
if (uiRoot && uiRoot !== doc.body) uiRoot.appendChild(styleEl);
else doc.head.appendChild(styleEl);
return styleEl;
}
function uiGetById(id) {
const uiRoot = liveUiRoot();
if (uiRoot?.getElementById) {
const found = uiRoot.getElementById(id);
if (found) return found;
}
if (uiRoot?.querySelector) {
const found = uiRoot.querySelector('#' + cssId(id));
if (found) return found;
}
return doc.getElementById(id);
}
function activeElementDeep() {
let active = doc.activeElement;
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
return active;
}
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
if (!rootEl) return;
if (setPointerEvents) {
rootEl.style.setProperty('pointer-events', 'auto', 'important');
}
const stop = (e) => e.stopPropagation();
rootEl.addEventListener('pointerdown', stop);
rootEl.addEventListener('mousedown', stop);
rootEl.addEventListener('focusin', stop);
}
return {
own,
pickable,
desc,
rectIsUsableAnchor,
makeFrozenAnchor,
id8,
cssId,
liveUiRoot,
uiAppend,
uiAppendStyle,
uiGetById,
activeElementDeep,
defangOutsideHandlers,
};
}
root.__IMPECCABLE_LIVE_DOM__ = {
version: 1,
createLiveBrowserDomHelpers,
};
})(typeof window !== 'undefined' ? window : globalThis);
File diff suppressed because it is too large Load Diff
@@ -16,8 +16,8 @@
*/
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live-manual-edits-buffer.mjs';
import { isGeneratedFile } from './is-generated.mjs';
import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live/manual-edits-buffer.mjs';
import { isGeneratedFile } from './lib/is-generated.mjs';
import {
runCopyEditBatchAgent,
runCopyEditPostApplyChecks,
@@ -3,8 +3,8 @@
* Canonical durable completion acknowledgement for Impeccable live sessions.
*/
import { createLiveSessionStore } from './live-session-store.mjs';
import { readLiveServerInfo } from './impeccable-paths.mjs';
import { createLiveSessionStore } from './live/session-store.mjs';
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
function parseArgs(argv) {
const out = { status: 'complete' };
@@ -16,7 +16,7 @@
* Output JSON: { discarded: N, entries: [...discardedEntries], totalCount: N }
*/
import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs';
import { readBuffer, removeEntries, truncateBuffer } from './live/manual-edits-buffer.mjs';
function argVal(args, name) {
const prefix = name + '=';
@@ -16,12 +16,41 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolveLiveConfigPath } from './impeccable-paths.mjs';
import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs';
import {
applySvelteKitLiveAdapter,
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
} from './live/sveltekit-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
const MARKER_OPEN_TEXT = 'impeccable-live-start';
const MARKER_CLOSE_TEXT = 'impeccable-live-end';
const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start';
const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
export const LIVE_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/config.local.json',
'.impeccable/live/server.json',
'.impeccable/live/sessions/',
'.impeccable/live/previews/',
'.impeccable/live/annotations/',
'.impeccable/live/cache/',
'.impeccable/live/manual-edit-apply-transaction.json',
'.impeccable/live/manual-edit-events.jsonl',
'.impeccable/live/manual-edit-evidence/',
'.impeccable/live/pending-manual-edits.json',
'.impeccable/live/deferred-svelte-component-accepts.json',
'.impeccable-live.json',
'.impeccable-live/',
'node_modules/.impeccable-live/',
'src/lib/impeccable/ImpeccableLiveRoot.svelte',
'src/lib/impeccable/__runtime.js',
'src/lib/impeccable/[0-9a-f]*/',
]);
/**
* Hard-excluded directory patterns. These are NEVER user-facing pages and
@@ -83,8 +112,14 @@ Output (JSON):
validateConfig(config);
const resolvedFiles = resolveFiles(process.cwd(), config);
const svelteKit = detectSvelteKitProject(process.cwd(), config);
if (args.includes('--remove')) {
if (svelteKit) {
const adapterResult = removeSvelteKitLiveAdapter({ cwd: process.cwd(), config });
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
return;
}
const results = resolvedFiles.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
@@ -110,6 +145,13 @@ Output (JSON):
console.error(JSON.stringify({ ok: false, error: 'missing_port' }));
process.exit(1);
}
const gitIgnore = ensureLiveGitIgnores(process.cwd());
if (svelteKit) {
const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config });
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
return;
}
const results = resolvedFiles.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
@@ -129,10 +171,68 @@ Output (JSON):
};
});
const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results }));
console.log(JSON.stringify({ ok: anyInserted, port, gitIgnore, results }));
if (!anyInserted) process.exit(1);
}
export function ensureLiveGitIgnores(cwd = process.cwd()) {
const target = resolveIgnoreTarget(cwd);
const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : '';
const block = [
IGNORE_MARKER_OPEN,
...LIVE_IGNORE_PATTERNS,
IGNORE_MARKER_CLOSE,
].join('\n');
const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_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}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`;
}
if (updated !== existing) {
fs.mkdirSync(path.dirname(target.path), { recursive: true });
fs.writeFileSync(target.path, updated, 'utf-8');
}
return {
file: path.relative(cwd, target.path).split(path.sep).join('/'),
mode: target.mode,
changed: updated !== existing,
patterns: [...LIVE_IGNORE_PATTERNS],
};
}
function resolveIgnoreTarget(cwd) {
const gitExcludePath = resolveGitInfoExcludePath(cwd);
if (gitExcludePath) {
return { path: gitExcludePath, mode: 'git-info-exclude' };
}
return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' };
}
function resolveGitInfoExcludePath(cwd) {
const dotGit = path.join(cwd, '.git');
if (!fs.existsSync(dotGit)) return null;
const stat = fs.statSync(dotGit);
if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude');
if (!stat.isFile()) return null;
const body = fs.readFileSync(dotGit, 'utf-8').trim();
const match = body.match(/^gitdir:\s*(.+)$/i);
if (!match) return null;
const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]);
return path.join(gitDir, 'info', 'exclude');
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Expand config.files (which may contain glob patterns) into a literal list
* of existing file paths relative to rootDir. Literal entries pass through;
@@ -270,8 +370,26 @@ function buildTagBlock(syntax, port, filePath) {
);
}
function detectLineEnding(content) {
if (content.includes('\r\n')) return '\r\n';
if (content.includes('\r')) return '\r';
return '\n';
}
function normalizeLineEndings(content, lineEnding) {
return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding);
}
function readLineEndingAt(content, index) {
if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n';
if (content[index] === '\n') return '\n';
if (content[index] === '\r') return '\r';
return '';
}
function insertTag(content, config, port, filePath) {
const block = buildTagBlock(config.commentSyntax, port, filePath);
const lineEnding = detectLineEnding(content);
const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding);
// insertBefore: match the LAST occurrence. Anchors like `</body>` naturally
// belong at the end, and the same literal can appear earlier in code blocks
// within rendered documentation pages.
@@ -285,9 +403,15 @@ function insertTag(content, config, port, filePath) {
const idx = content.indexOf(config.insertAfter);
if (idx === -1) return content;
const after = idx + config.insertAfter.length;
// Preserve a single trailing newline if the anchor didn't end with one
const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n';
return prefix + block + content.slice(prefix.length);
// Preserve an existing trailing newline if the anchor already has one.
// Slice the remainder from the original anchor offset, not prefix.length:
// in the no-newline case prefix is one char longer than the anchor (the
// appended '\n'), so slicing by prefix.length would drop the first real
// character after the anchor (#227).
const existingNewline = readLineEndingAt(content, after);
const prefix = content.slice(0, after) + (existingNewline || lineEnding);
const rest = content.slice(after + existingNewline.length);
return prefix + block + rest;
}
/**
@@ -303,8 +427,8 @@ function insertTag(content, config, port, filePath) {
*/
function removeTag(content, _syntax) {
const patterns = [
/([ \t]*)<!--\s*impeccable-live-start\s*-->[\s\S]*?<!--\s*impeccable-live-end\s*-->([ \t]*(?:\n|$)?)/,
/([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\n|$)?)/,
/([ \t]*)<!--\s*impeccable-live-start\s*-->[\s\S]*?<!--\s*impeccable-live-end\s*-->([ \t]*(?:\r\n|\n|\r|$)?)/,
/([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/,
];
for (const pat of patterns) {
let changed = false;
@@ -312,7 +436,7 @@ function removeTag(content, _syntax) {
do {
content = next;
next = content.replace(pat, (_match, leadingIndent, trailing = '') => {
if (trailing.includes('\n')) return leadingIndent;
if (/[\r\n]/.test(trailing)) return leadingIndent;
return leadingIndent || trailing || '';
});
if (next !== content) changed = true;
@@ -9,7 +9,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
import { isGeneratedFile } from './lib/is-generated.mjs';
import {
buildSearchQueries,
findElement,
@@ -21,6 +21,11 @@ import {
buildCssAuthoring,
buildCssSelectorPrefixExamples,
} from './live-wrap.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentInsertSession,
shouldUseSvelteComponentInjection,
} from './live/svelte-component.mjs';
const INSERT_POSITIONS = new Set(['before', 'after']);
@@ -192,6 +197,41 @@ Output (JSON):
const styleMode = detectStyleMode(targetFile);
const isJsx = commentSyntax.open === '{/*';
const spliceIndex = computeInsertLine(startLine, endLine, position);
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
if (shouldUseSvelteComponentInjection(targetFile)) {
const session = scaffoldSvelteComponentInsertSession({
id,
count,
sourceFile: relTargetFile,
insertLine: spliceIndex + 1,
position,
anchorStartLine: startLine + 1,
anchorEndLine: endLine + 1,
anchorLines: lines.slice(startLine, endLine + 1),
cwd: process.cwd(),
});
console.log(JSON.stringify({
mode: 'insert',
position,
file: session.manifestFile,
sourceFile: relTargetFile,
previewMode: 'svelte-component',
componentDir: session.componentDir,
propContract: session.propContract,
insertLine: 1,
sourceInsertLine: spliceIndex + 1,
anchorStartLine: startLine + 1,
anchorEndLine: endLine + 1,
commentSyntax,
styleMode: 'svelte-component',
styleTag: null,
cssSelectorPrefixExamples: [],
cssAuthoring: buildSvelteComponentCssAuthoring(count),
}));
return;
}
const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1]
?? lines[startLine]?.match(/^(\s*)/)?.[1]
?? '';
@@ -216,7 +256,7 @@ Output (JSON):
console.log(JSON.stringify({
mode: 'insert',
position,
file: path.relative(process.cwd(), targetFile),
file: relTargetFile,
insertLine: insertLine + 1,
commentSyntax,
styleMode: styleMode.mode,
@@ -10,8 +10,8 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
import { readBuffer, getBufferPath } from './live-manual-edits-buffer.mjs';
import { isGeneratedFile } from './lib/is-generated.mjs';
import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs';
const EVIDENCE_VERSION = 1;
const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']);
@@ -12,14 +12,15 @@
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live-completion.mjs';
import { readLiveServerInfo } from './impeccable-paths.mjs';
import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
// 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
// depending on the standalone undici package.
export const PER_REQUEST_TIMEOUT_MS = 270_000;
export const DEFAULT_EVENT_LEASE_MS = 600_000;
const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']);
@@ -156,7 +157,7 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) {
? totalDeadline - Date.now()
: PER_REQUEST_TIMEOUT_MS;
const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS);
const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}`);
const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`);
if (res.status === 401) {
const err = new Error('Authentication failed. The server token may have changed.');
@@ -317,7 +318,7 @@ Modes:
Options:
--timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode
--ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000)
--file PATH Attach a source file path to the reply (generate flow)
--file PATH Attach a source file path to the reply (generate/steer flow)
--data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON
--help Show this help message
@@ -3,7 +3,7 @@
* Recover the next agent action from the durable live-session journal.
*/
import { createLiveSessionStore } from './live-session-store.mjs';
import { createLiveSessionStore } from './live/session-store.mjs';
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
File diff suppressed because it is too large Load Diff
@@ -3,8 +3,8 @@
* Print durable recovery status for Impeccable live sessions.
*/
import { createLiveSessionStore } from './live-session-store.mjs';
import { readLiveServerInfo } from './impeccable-paths.mjs';
import { createLiveSessionStore } from './live/session-store.mjs';
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
import { manualApplyResumeHint } from './live-resume.mjs';
function readServerInfo() {
+77 -25
View File
@@ -13,8 +13,13 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs';
import { isGeneratedFile } from './lib/is-generated.mjs';
import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentSession,
shouldUseSvelteComponentInjection,
} from './live/svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -262,6 +267,8 @@ The agent should insert variant HTML at insertLine.`);
.map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
.join('\n');
const originalIndented = reindentOriginal(' ');
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
const useSvelteComponent = shouldUseSvelteComponentInjection(targetFile);
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
// JSX requires object-literal style and parses string attrs as HTML (which
@@ -302,38 +309,75 @@ The agent should insert variant HTML at insertLine.`);
indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
];
// Replace the original element with the wrapper
const newLines = [
...lines.slice(0, startLine),
...wrapperLines,
...lines.slice(endLine + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
let outputFile = targetFile;
let outputLines;
let outputStartLine = startLine + 1;
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
let insertLine;
let svelteSession = null;
// Calculate insert line (the "insert below this line" comment).
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
// the insert marker (HTML: start-comment + outer-div + Original-comment +
// original-div + content + close-original-div; JSX: outer-div +
// start-comment + Original-comment + original-div + content +
// close-original-div). Multi-line originals push the marker by their
// extra line count.
const insertLine = startLine + 6 + (originalLines.length - 1);
if (useSvelteComponent) {
// Svelte/SvelteKit resets component-local state on markup HMR updates.
// Keep generation source-neutral: agents write real variant components
// under the generated componentDir, the browser mounts them into the live
// DOM, and live-accept.mjs inlines the accepted variant back into the route.
svelteSession = scaffoldSvelteComponentSession({
id,
count,
sourceFile: relTargetFile,
sourceStartLine: startLine + 1,
sourceEndLine: endLine + 1,
originalLines,
cwd: process.cwd(),
});
outputFile = path.resolve(process.cwd(), svelteSession.manifestFile);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
} else {
// Replace the original element with the wrapper
const newLines = [
...lines.slice(0, startLine),
...wrapperLines,
...lines.slice(endLine + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment).
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
// the insert marker (HTML: start-comment + outer-div + Original-comment +
// original-div + content + close-original-div; JSX: outer-div +
// start-comment + Original-comment + original-div + content +
// close-original-div). Multi-line originals push the marker by their
// extra line count.
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
}
const outputRelFile = path.relative(process.cwd(), outputFile).split(path.sep).join('/');
const svelteComponentAuthoring = useSvelteComponent ? buildSvelteComponentCssAuthoring(count) : null;
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
file: outputRelFile,
sourceFile: useSvelteComponent ? relTargetFile : undefined,
previewMode: useSvelteComponent ? 'svelte-component' : undefined,
componentDir: svelteSession?.componentDir,
propContract: svelteSession?.propContract,
sourceStartLine: useSvelteComponent ? startLine + 1 : undefined,
sourceEndLine: useSvelteComponent ? endLine + 1 : undefined,
startLine: outputStartLine, // 1-indexed for the agent
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that
// spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
endLine: outputEndLine, // 1-indexed
insertLine, // 1-indexed: where variants go
commentSyntax: commentSyntax,
styleMode: styleMode.mode,
styleTag: styleMode.styleTag,
cssSelectorPrefixExamples: buildCssSelectorPrefixExamples(styleMode.mode, count),
cssAuthoring: buildCssAuthoring(styleMode, count),
styleMode: useSvelteComponent ? 'svelte-component' : styleMode.mode,
styleTag: useSvelteComponent ? null : styleMode.styleTag,
cssSelectorPrefixExamples: useSvelteComponent ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
cssAuthoring: useSvelteComponent ? svelteComponentAuthoring : buildCssAuthoring(styleMode, count),
originalLineCount: originalLines.length,
}));
}
@@ -527,6 +571,14 @@ function splitClassList(classes) {
return String(classes).split(/[,\s]+/).map(c => c.trim()).filter(Boolean);
}
function attrEscapeDouble(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') {
+1 -1
View File
@@ -23,7 +23,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadContext } from './context.mjs';
import { resolveFiles } from './live-inject.mjs';
import { readLiveServerInfo } from './impeccable-paths.mjs';
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -0,0 +1,49 @@
import fs from 'node:fs';
import path from 'node:path';
export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([
Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }),
Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }),
Object.freeze({ name: 'browser-ui', file: 'live-browser.js' }),
]);
export function resolveLiveBrowserScriptParts(scriptsDir, parts = LIVE_BROWSER_SCRIPT_PARTS) {
if (!scriptsDir) throw new Error('scriptsDir is required');
return parts.map((part, index) => ({
...part,
index,
path: path.join(scriptsDir, part.file),
}));
}
export function assertLiveBrowserScriptParts(parts, exists = fs.existsSync) {
for (const part of parts) {
if (!exists(part.path)) {
throw new Error(`Live browser script part missing: ${part.name} (${part.path})`);
}
}
return parts;
}
export function readLiveBrowserScriptParts(parts, readFile = (filePath) => fs.readFileSync(filePath, 'utf-8')) {
return parts.map((part) => ({
...part,
source: readFile(part.path),
}));
}
export function assembleLiveBrowserScript({ token, port, vocabulary, parts }) {
const prelude =
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
`window.__IMPECCABLE_PORT__ = ${port};\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`;
const body = parts.map((part) => {
const file = part.file || path.basename(part.path || '');
return `// --- impeccable live script part: ${part.name} (${file}) ---\n${part.source}`;
}).join('\n');
return prelude + body;
}
@@ -3,6 +3,7 @@ export function completionTypeForAcceptResult(eventType, acceptResult) {
if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done';
if (acceptResult?.handled === true) return 'complete';
if (acceptResult?.mode === 'error') return 'error';
if (eventType === 'accept' && acceptResult?.previewMode === 'svelte-component') return 'error';
return 'agent_done';
}
@@ -3,12 +3,13 @@
* Extracted for unit testing (insert mode rules).
*/
import { canCreateInsert } from './live-insert-ui.mjs';
import { canCreateInsert } from './insert-ui.mjs';
export const VISUAL_ACTIONS = [
'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset',
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
// The accepted visual action values come from the canonical vocabulary so the
// validator, the picker UI, and the marketing demo never drift. Imported (not
// just re-exported) so it is also in scope for the validators below.
import { VISUAL_ACTIONS } from './vocabulary.mjs';
export { VISUAL_ACTIONS };
const ID_PATTERN = /^[0-9a-f]{8}$/;
const VARIANT_ID_PATTERN = /^[0-9]{1,3}$/;
@@ -0,0 +1,939 @@
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { getLiveDir } from '../lib/impeccable-paths.mjs';
import { readBuffer as readManualEditsBuffer } from './manual-edits-buffer.mjs';
const APPLY_EVENT_HARD_TIMEOUT_MS = Number(process.env.IMPECCABLE_LIVE_APPLY_EVENT_HARD_TIMEOUT_MS || 150_000);
const APPLY_EVENT_SOFT_DEADLINE_MS = Number(process.env.IMPECCABLE_LIVE_APPLY_EVENT_SOFT_DEADLINE_MS || 120_000);
const DEFAULT_MANUAL_EDIT_APPLY_CHUNK_SIZE = 3;
const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1;
const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20;
const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240;
const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4;
export function createManualApplyController({
pendingEvents,
pendingApplyDeferreds,
timedOutApplyIds,
enqueueEvent,
acknowledgePendingEvent,
flushPendingPolls,
recordManualEditActivity,
cwd = () => process.cwd(),
} = {}) {
const projectCwd = () => typeof cwd === 'function' ? cwd() : cwd || process.cwd();
function tombstoneTimedOutApplyId(eventId, details = {}) {
if (!eventId) return;
timedOutApplyIds.set(eventId, details);
if (timedOutApplyIds.size <= 200) return;
const oldest = timedOutApplyIds.keys().next().value;
timedOutApplyIds.delete(oldest);
}
function pushApplyEventAndWait(batch, pageUrl, chunk = null, repair = null) {
const cwdValue = projectCwd();
const eventId = randomUUID().replace(/-/g, '').slice(0, 8);
const evidencePath = writeManualApplyEvidence(eventId, batch, cwdValue);
const event = {
type: 'manual_edit_apply',
id: eventId,
pageUrl,
batch: compactManualApplyBatch(batch, cwdValue),
evidencePath,
agentAction: buildManualApplyAgentAction(eventId),
schemaVersion: 1,
deadlineMs: APPLY_EVENT_SOFT_DEADLINE_MS,
};
if (chunk) event.chunk = chunk;
if (repair) event.repair = repair;
const rollbackSnapshot = snapshotApplyEventFiles(batch, cwdValue);
recordManualEditActivity('manual_edit_apply_dispatched', {
id: eventId,
pageUrl,
chunk,
repair,
entryCount: Array.isArray(batch.entries) ? batch.entries.length : 0,
opCount: countManualApplyOps(batch),
fileCount: collectManualApplyFiles(batch, [], cwdValue).length,
});
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pendingApplyDeferreds.delete(eventId);
tombstoneTimedOutApplyId(eventId, { batch, rollbackSnapshot, cwd: cwdValue });
acknowledgePendingEvent(eventId);
removeManualApplyEvidence(evidencePath, cwdValue);
recordManualEditActivity('manual_edit_apply_timeout', {
id: eventId,
pageUrl,
chunk,
entryCount: Array.isArray(batch.entries) ? batch.entries.length : 0,
opCount: countManualApplyOps(batch),
});
reject(new Error('chat_agent_timeout'));
}, APPLY_EVENT_HARD_TIMEOUT_MS);
pendingApplyDeferreds.set(eventId, { resolve, reject, timer, event, batch, pageUrl, rollbackSnapshot, cwd: cwdValue });
enqueueEvent(event);
});
}
async function pushBatchInChunksAndWait(batch, pageUrl, context = {}) {
const repair = context?.repair || batch?.repair || null;
if (repair) return pushApplyEventAndWait(batch, pageUrl, null, repair);
const chunks = splitManualApplyBatch(batch, manualEditApplyChunkSize());
if (chunks.length <= 1) return pushApplyEventAndWait(batch, pageUrl);
const expectedOpsByEntry = new Map();
for (const entry of batch?.entries || []) {
expectedOpsByEntry.set(entry.id, Array.isArray(entry.ops) ? entry.ops.length : 0);
}
const appliedOpsByEntry = new Map();
const failedByEntry = new Map();
const files = new Set();
const notes = [];
let aborted = false;
for (const chunk of chunks) {
if (aborted) {
markChunkEntriesFailed(failedByEntry, chunk, 'manual_edit_chunk_aborted');
continue;
}
let result;
try {
result = normalizeApplyChunkResult(await pushApplyEventAndWait(chunk.batch, pageUrl, chunk.meta));
} catch (err) {
markChunkEntriesFailed(failedByEntry, chunk, err.message || 'chat_agent_error');
aborted = true;
continue;
}
for (const file of result.files) files.add(file);
notes.push(...result.notes);
const chunkFailedIds = new Set();
for (const item of result.failed) {
const entryId = item.entryId || item.id;
if (!entryId) continue;
chunkFailedIds.add(entryId);
if (!failedByEntry.has(entryId)) {
failedByEntry.set(entryId, {
entryId,
reason: item.reason || item.message || 'failed',
candidates: Array.isArray(item.candidates) ? item.candidates : [],
});
}
}
if (result.status === 'error') {
markChunkEntriesFailed(failedByEntry, chunk, result.message || firstFailureReason(result) || 'chat_agent_error');
aborted = true;
continue;
}
const reportedAppliedIds = new Set(result.appliedEntryIds);
for (const entryId of reportedAppliedIds) {
if (!chunk.entryIds.has(entryId) || chunkFailedIds.has(entryId)) continue;
appliedOpsByEntry.set(entryId, (appliedOpsByEntry.get(entryId) || 0) + (chunk.opCountsByEntry.get(entryId) || 0));
}
for (const entryId of chunk.entryIds) {
if (reportedAppliedIds.has(entryId) || chunkFailedIds.has(entryId)) continue;
if (!failedByEntry.has(entryId)) {
failedByEntry.set(entryId, { entryId, reason: 'not_reported_applied', candidates: [] });
}
}
}
const appliedEntryIds = [];
for (const [entryId, expectedOps] of expectedOpsByEntry.entries()) {
if (failedByEntry.has(entryId)) continue;
if ((appliedOpsByEntry.get(entryId) || 0) === expectedOps && expectedOps > 0) {
appliedEntryIds.push(entryId);
} else if (!failedByEntry.has(entryId)) {
failedByEntry.set(entryId, { entryId, reason: 'not_reported_applied', candidates: [] });
}
}
const failed = [...failedByEntry.values()];
return {
status: failed.length === 0 ? 'done' : appliedEntryIds.length > 0 ? 'partial' : 'error',
appliedEntryIds,
failed,
files: [...files],
notes,
};
}
function getDeferred(eventId) {
return pendingApplyDeferreds.get(eventId) || null;
}
function hasTimedOutId(eventId) {
return timedOutApplyIds.has(eventId);
}
function resolveDeferred(eventId, body) {
const deferred = pendingApplyDeferreds.get(eventId);
if (!deferred) return false;
pendingApplyDeferreds.delete(eventId);
clearTimeout(deferred.timer);
removeManualApplyEvidence(deferred.event?.evidencePath, deferred.cwd || projectCwd());
deferred.resolve(body);
return true;
}
function rejectDeferred(eventId, reason) {
const deferred = pendingApplyDeferreds.get(eventId);
if (!deferred) return false;
pendingApplyDeferreds.delete(eventId);
clearTimeout(deferred.timer);
removeManualApplyEvidence(deferred.event?.evidencePath, deferred.cwd || projectCwd());
deferred.reject(new Error(reason || 'chat_agent_error'));
return true;
}
function referencedManualApplyEvidencePaths(cwdValue = projectCwd()) {
const referenced = new Set();
const add = (event) => {
const fullPath = normalizeManualApplyEvidencePath(event?.evidencePath, cwdValue);
if (fullPath) referenced.add(fullPath);
};
for (const entry of pendingEvents) add(entry.event);
for (const deferred of pendingApplyDeferreds.values()) add(deferred.event);
return referenced;
}
function pruneStaleEvidence(cwdValue = projectCwd()) {
const dir = manualApplyEvidenceDir(cwdValue);
if (!fs.existsSync(dir)) return [];
const referenced = referencedManualApplyEvidencePaths(cwdValue);
const removed = [];
for (const name of fs.readdirSync(dir)) {
if (!name.endsWith('.json')) continue;
const fullPath = path.join(dir, name);
if (referenced.has(fullPath)) continue;
try {
fs.unlinkSync(fullPath);
removed.push(fullPath);
} catch {
// Stale evidence cleanup is best-effort; Apply verification never relies
// on deleting these files.
}
}
return removed;
}
function rollbackTimedOutReply(msg) {
const details = timedOutApplyIds.get(msg.id);
if (!details) return { rolledBackFiles: [], rollbackFailures: [] };
timedOutApplyIds.delete(msg.id);
return rollbackApplySnapshot(
details.batch,
details.rollbackSnapshot,
msg.data?.files || [],
'stale_manual_edit_apply_reply',
details.cwd || projectCwd(),
);
}
function cancelPendingEvents(pageUrl, reason = 'manual_edit_discarded') {
const canceledById = new Map();
const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl);
for (let i = pendingEvents.length - 1; i >= 0; i -= 1) {
const event = pendingEvents[i]?.event;
if (!shouldCancel(event)) continue;
pendingEvents.splice(i, 1);
removeManualApplyEvidence(event.evidencePath, projectCwd());
canceledById.set(event.id, {
id: event.id,
pageUrl: event.pageUrl,
entryCount: event.batch?.entries?.length || 0,
});
}
for (const [eventId, deferred] of [...pendingApplyDeferreds.entries()]) {
if (!shouldCancel(deferred.event)) continue;
pendingApplyDeferreds.delete(eventId);
clearTimeout(deferred.timer);
const cwdValue = deferred.cwd || projectCwd();
const rollback = rollbackApplySnapshot(deferred.batch, deferred.rollbackSnapshot, [], reason, cwdValue);
tombstoneTimedOutApplyId(eventId, {
batch: deferred.batch,
rollbackSnapshot: deferred.rollbackSnapshot,
reason,
cwd: cwdValue,
});
removeManualApplyEvidence(deferred.event?.evidencePath, cwdValue);
canceledById.set(eventId, {
id: eventId,
pageUrl: deferred.pageUrl,
entryCount: deferred.batch?.entries?.length || 0,
rolledBackFiles: rollback.rolledBackFiles,
rollbackFailures: rollback.rollbackFailures,
});
deferred.reject(new Error(reason));
}
if (canceledById.size > 0) flushPendingPolls();
return [...canceledById.values()];
}
return {
buildAgentAction: buildManualApplyAgentAction,
cancelPendingEvents,
clearTransaction: (transactionId = null) => clearManualApplyTransaction(projectCwd(), transactionId),
countOps: countManualApplyOps,
getDeferred,
hasTimedOutId,
pruneStaleEvidence,
pushBatchInChunksAndWait,
readTransaction: () => readManualApplyTransaction(projectCwd()),
rejectDeferred,
resolveDeferred,
rollbackTimedOutReply,
rollbackTransaction: (opts = {}) => rollbackManualApplyTransaction({
cwd: projectCwd(),
recordManualEditActivity,
...opts,
}),
summarizeEvent: (event = {}, batch = event.batch) => summarizeManualApplyEvent(event, batch, projectCwd()),
validateResultMessage: validateManualApplyResultMessage,
writeTransaction: (opts = {}) => writeManualApplyTransaction({ cwd: projectCwd(), ...opts }),
};
}
export function manualEditApplyChunkSize(env = process.env) {
const raw = Number(env.IMPECCABLE_LIVE_MANUAL_EDIT_CHUNK_SIZE);
if (!Number.isFinite(raw)) return DEFAULT_MANUAL_EDIT_APPLY_CHUNK_SIZE;
const size = Math.trunc(raw);
return Math.max(MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE, Math.min(MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE, size));
}
export function countManualApplyOps(entriesOrBatch) {
const entries = Array.isArray(entriesOrBatch)
? entriesOrBatch
: Array.isArray(entriesOrBatch?.entries) ? entriesOrBatch.entries : [];
let count = 0;
for (const entry of entries) count += Array.isArray(entry.ops) ? entry.ops.length : 0;
return count;
}
export function writeManualApplyEvidence(eventId, batch, cwd = process.cwd()) {
const dir = manualApplyEvidenceDir(cwd);
fs.mkdirSync(dir, { recursive: true });
const evidencePath = path.join(dir, `${eventId}.json`);
fs.writeFileSync(evidencePath, JSON.stringify(batch, null, 2) + '\n', 'utf-8');
return evidencePath;
}
export function manualApplyEvidenceDir(cwd = process.cwd()) {
return path.join(getLiveDir(cwd), 'manual-edit-evidence');
}
export function normalizeManualApplyEvidencePath(evidencePath, cwd = process.cwd()) {
if (!evidencePath || typeof evidencePath !== 'string') return null;
const fullPath = path.isAbsolute(evidencePath) ? evidencePath : path.resolve(cwd, evidencePath);
const evidenceDir = manualApplyEvidenceDir(cwd);
const relative = path.relative(evidenceDir, fullPath);
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
if (path.extname(relative) !== '.json') return null;
return fullPath;
}
export function removeManualApplyEvidence(evidencePath, cwd = process.cwd()) {
const fullPath = normalizeManualApplyEvidencePath(evidencePath, cwd);
if (!fullPath) return false;
try {
fs.unlinkSync(fullPath);
return true;
} catch {
return false;
}
}
export function compactManualApplyBatch(batch = {}, cwd = process.cwd()) {
const entries = (batch.entries || []).map(compactManualApplyEntry);
const candidates = compactManualApplyCandidates(batch.candidates || [], cwd);
return {
version: batch.version,
pageUrl: batch.pageUrl || null,
count: batch.count,
entries,
ops: entries.flatMap((entry) => entry.ops.map((op) => ({ ...op, entryId: entry.id }))),
candidates: candidates.length > 0 ? candidates : undefined,
context: batch.context ? {
bufferPath: batch.context.bufferPath,
totalEntries: batch.context.totalEntries,
totalOps: batch.context.totalOps,
chunkIndex: batch.context.chunkIndex,
chunkTotal: batch.context.chunkTotal,
totalApplyOps: batch.context.totalApplyOps,
} : undefined,
};
}
export function compactManualApplyCandidates(candidates, cwd = process.cwd()) {
return (Array.isArray(candidates) ? candidates : [])
.slice(0, 24)
.map((candidate) => ({
entryId: candidate.entryId,
ref: candidate.ref,
sourceHint: compactManualApplySourceMatch(candidate.sourceHint, cwd),
textMatches: compactManualApplySourceMatches(candidate.textMatches, 8, cwd),
objectKeyMatches: compactManualApplySourceMatches(candidate.objectKeyMatches, 8, cwd),
contextTextMatches: compactManualApplySourceMatches(candidate.contextTextMatches, 8, cwd),
locatorMatches: compactManualApplySourceMatches(candidate.locatorMatches, 6, cwd),
}));
}
function compactManualApplySourceMatches(matches, limit, cwd) {
return (Array.isArray(matches) ? matches : [])
.slice(0, limit)
.map((match) => compactManualApplySourceMatch(match, cwd))
.filter(Boolean);
}
function compactManualApplySourceMatch(match, cwd) {
if (!match || typeof match !== 'object') return null;
const file = match.relativeFile || match.file;
if (!file && !match.line) return null;
return {
file: summarizeManualLogFile(file, cwd),
line: match.line || null,
column: match.column || null,
reason: match.reason || match.kind || undefined,
status: match.status || undefined,
};
}
function compactManualApplyEntry(entry = {}) {
return {
id: entry.id,
pageUrl: entry.pageUrl,
stagedAt: entry.stagedAt || null,
element: compactManualApplyContext(entry.element),
ops: (entry.ops || []).map(compactManualApplyOp),
};
}
function compactManualApplyOp(op = {}) {
return {
entryId: op.entryId,
ref: op.ref,
contextRef: op.contextRef,
tag: op.tag,
elementId: op.elementId,
classes: Array.isArray(op.classes) ? op.classes : [],
originalText: op.originalText,
newText: op.newText,
deleted: op.deleted === true || undefined,
sourceHint: op.sourceHint || null,
leaf: compactManualApplyContext(op.leaf),
nearbyEditableTexts: compactNearbyManualEditTexts(op.nearbyEditableTexts),
container: compactManualApplyContext(op.container),
contextHints: Array.isArray(op.contextHints) ? op.contextHints.slice(0, 8) : undefined,
};
}
function compactManualApplyContext(value) {
if (!value || typeof value !== 'object') return null;
return {
ref: value.ref,
tagName: value.tagName || value.tag || null,
id: value.id || null,
classes: Array.isArray(value.classes) ? value.classes : [],
textContent: truncateManualApplyText(value.textContent, MANUAL_APPLY_COMPACT_TEXT_LIMIT),
};
}
function compactNearbyManualEditTexts(items) {
return (Array.isArray(items) ? items : [])
.slice(0, MANUAL_APPLY_COMPACT_NEARBY_LIMIT)
.map((item) => typeof item === 'string' ? { text: truncateManualApplyText(item, MANUAL_APPLY_COMPACT_TEXT_LIMIT) } : {
ref: item?.ref,
tag: item?.tag,
classes: Array.isArray(item?.classes) ? item.classes : [],
text: truncateManualApplyText(item?.text, MANUAL_APPLY_COMPACT_TEXT_LIMIT),
});
}
function truncateManualApplyText(value, max) {
if (typeof value !== 'string') return value || null;
return value.length > max ? value.slice(0, max) : value;
}
function normalizeApplyChunkResult(result) {
const status = result?.status === 'partial' ? 'partial' : result?.status === 'error' ? 'error' : 'done';
return {
status,
message: typeof result?.message === 'string' ? result.message : null,
appliedEntryIds: Array.isArray(result?.appliedEntryIds) ? result.appliedEntryIds.filter((id) => typeof id === 'string') : [],
failed: Array.isArray(result?.failed) ? result.failed.filter(Boolean) : [],
files: Array.isArray(result?.files) ? result.files.filter((file) => typeof file === 'string') : [],
notes: Array.isArray(result?.notes) ? result.notes.filter((note) => typeof note === 'string') : [],
};
}
function manualApplyResultShapeHint(eventId = 'EVENT_ID') {
return `Use live-poll.mjs --reply ${eventId} done --data '{"status":"done","appliedEntryIds":["ENTRY_ID"],"failed":[],"files":["src/page.html"],"notes":[]}'`;
}
function invalidManualApplyResult(reason, eventId, extra = {}) {
return {
ok: false,
body: {
error: 'invalid_manual_apply_result',
reason,
hint: manualApplyResultShapeHint(eventId),
...extra,
},
};
}
export function validateManualApplyResultMessage(msg, deferred) {
let data = msg?.data;
const eventId = msg?.id || deferred?.event?.id || 'EVENT_ID';
if (!data || typeof data !== 'object' || Array.isArray(data)) {
return invalidManualApplyResult('missing_result_data', eventId);
}
if ('entries' in data || 'ops' in data) {
return invalidManualApplyResult('summary_result_not_allowed', eventId);
}
if (!['done', 'partial', 'error'].includes(data.status)) {
return invalidManualApplyResult('invalid_status', eventId, { status: data.status ?? null });
}
for (const key of ['appliedEntryIds', 'failed', 'files', 'notes']) {
if (!Array.isArray(data[key])) {
return invalidManualApplyResult(`${key}_must_be_array`, eventId);
}
}
for (const [index, value] of data.appliedEntryIds.entries()) {
if (typeof value !== 'string' || !value) {
return invalidManualApplyResult('appliedEntryIds_must_contain_strings', eventId, { index });
}
}
for (const [index, value] of data.files.entries()) {
if (typeof value !== 'string' || !value) {
return invalidManualApplyResult('files_must_contain_strings', eventId, { index });
}
}
for (const [index, value] of data.notes.entries()) {
if (typeof value !== 'string') {
return invalidManualApplyResult('notes_must_contain_strings', eventId, { index });
}
}
for (const [index, item] of data.failed.entries()) {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return invalidManualApplyResult('failed_must_contain_objects', eventId, { index });
}
if (typeof item.entryId !== 'string' || !item.entryId) {
return invalidManualApplyResult('failed_entryId_required', eventId, { index });
}
if (typeof item.reason !== 'string' || !item.reason) {
return invalidManualApplyResult('failed_reason_required', eventId, { index });
}
}
const eventEntryIds = new Set((deferred?.batch?.entries || []).map((entry) => entry.id).filter(Boolean));
for (const entryId of data.appliedEntryIds) {
if (eventEntryIds.size > 0 && !eventEntryIds.has(entryId)) {
return invalidManualApplyResult('applied_entry_id_not_in_event', eventId, { entryId });
}
}
for (const item of data.failed) {
if (eventEntryIds.size > 0 && !eventEntryIds.has(item.entryId)) {
return invalidManualApplyResult('failed_entry_id_not_in_event', eventId, { entryId: item.entryId });
}
}
if (data.status === 'done') {
if (data.failed.length > 0) {
return invalidManualApplyResult('done_result_has_failed_entries', eventId);
}
if (countManualApplyOps(deferred?.batch) > 0 && data.appliedEntryIds.length === 0) {
return invalidManualApplyResult('done_result_missing_applied_entry_ids', eventId);
}
}
if (data.status === 'partial' && data.appliedEntryIds.length === 0 && data.failed.length === 0) {
return invalidManualApplyResult('partial_result_has_no_entries', eventId);
}
if (data.status === 'error' && data.appliedEntryIds.length > 0) {
return invalidManualApplyResult('error_result_has_applied_entries', eventId);
}
return {
ok: true,
result: {
status: data.status,
message: typeof data.message === 'string' ? data.message : undefined,
appliedEntryIds: data.appliedEntryIds,
failed: data.failed,
files: data.files,
notes: data.notes,
},
};
}
function firstFailureReason(result) {
const first = Array.isArray(result?.failed) ? result.failed.find(Boolean) : null;
return first?.reason || first?.message || null;
}
function markChunkEntriesFailed(failedByEntry, chunk, reason) {
for (const entryId of chunk.entryIds) {
if (failedByEntry.has(entryId)) continue;
failedByEntry.set(entryId, { entryId, reason, candidates: [] });
}
}
export function splitManualApplyBatch(batch, maxOps) {
const totalOpCount = countManualApplyOps(batch);
if (totalOpCount <= maxOps) {
return [{
batch,
meta: null,
entryIds: new Set((batch?.entries || []).map((entry) => entry.id).filter(Boolean)),
opCountsByEntry: new Map((batch?.entries || []).map((entry) => [entry.id, Array.isArray(entry.ops) ? entry.ops.length : 0])),
}];
}
const rawChunks = [];
let current = createManualApplyChunkBuilder();
for (const entry of batch?.entries || []) {
const ops = entry.ops || [];
if (ops.length <= maxOps) {
if (current.opCount > 0 && current.opCount + ops.length > maxOps) {
rawChunks.push(current);
current = createManualApplyChunkBuilder();
}
for (const op of ops) addOpToManualApplyChunk(current, entry, op);
continue;
}
if (current.opCount > 0) {
rawChunks.push(current);
current = createManualApplyChunkBuilder();
}
for (const op of ops) {
if (current.opCount >= maxOps) {
rawChunks.push(current);
current = createManualApplyChunkBuilder();
}
addOpToManualApplyChunk(current, entry, op);
}
}
if (current.opCount > 0) rawChunks.push(current);
return rawChunks.map((chunk, index) => ({
batch: {
...batch,
count: chunk.opCount,
entries: chunk.entries,
ops: chunk.ops,
candidates: filterManualApplyChunkCandidates(batch, chunk.refsByEntry),
context: {
...(batch?.context || {}),
totalEntries: chunk.entries.length,
totalOps: chunk.opCount,
chunkIndex: index + 1,
chunkTotal: rawChunks.length,
totalApplyOps: totalOpCount,
},
},
meta: {
index: index + 1,
total: rawChunks.length,
opCount: chunk.opCount,
totalOpCount,
},
entryIds: new Set(chunk.entries.map((entry) => entry.id).filter(Boolean)),
opCountsByEntry: chunk.opCountsByEntry,
}));
}
function createManualApplyChunkBuilder() {
return {
entries: [],
entryById: new Map(),
entryIds: new Set(),
ops: [],
refsByEntry: new Map(),
opCountsByEntry: new Map(),
opCount: 0,
};
}
function addOpToManualApplyChunk(chunk, entry, op) {
let chunkEntry = chunk.entryById.get(entry.id);
if (!chunkEntry) {
chunkEntry = { ...entry, ops: [] };
chunk.entryById.set(entry.id, chunkEntry);
chunk.entryIds.add(entry.id);
chunk.entries.push(chunkEntry);
}
chunkEntry.ops.push(op);
chunk.ops.push({ ...op, entryId: op.entryId || entry.id });
if (!chunk.refsByEntry.has(entry.id)) chunk.refsByEntry.set(entry.id, new Set());
if (op.ref) chunk.refsByEntry.get(entry.id).add(op.ref);
chunk.opCountsByEntry.set(entry.id, (chunk.opCountsByEntry.get(entry.id) || 0) + 1);
chunk.opCount += 1;
}
function filterManualApplyChunkCandidates(batch, refsByEntry) {
return (batch?.candidates || []).filter((candidate) => {
const refs = refsByEntry.get(candidate.entryId);
if (!refs) return false;
if (!candidate.ref) return true;
return refs.has(candidate.ref);
});
}
export function snapshotApplyEventFiles(batch, cwd = process.cwd()) {
const snapshot = new Map();
for (const relativeFile of collectManualApplyFiles(batch, [], cwd)) {
const absolute = path.resolve(cwd, relativeFile);
try {
snapshot.set(relativeFile, {
exists: fs.existsSync(absolute),
content: fs.existsSync(absolute) ? fs.readFileSync(absolute, 'utf-8') : '',
});
} catch {
// If a file cannot be read before dispatch, do not attempt late rollback.
}
}
return snapshot;
}
export function manualApplyTransactionPath(cwd = process.cwd()) {
return path.join(getLiveDir(cwd), 'manual-edit-apply-transaction.json');
}
export function readManualApplyTransaction(cwd = process.cwd()) {
const file = manualApplyTransactionPath(cwd);
if (!fs.existsSync(file)) return null;
try {
return JSON.parse(fs.readFileSync(file, 'utf-8'));
} catch {
return null;
}
}
export function writeManualApplyTransaction({ cwd = process.cwd(), pageUrl = null, batch }) {
const file = manualApplyTransactionPath(cwd);
const files = collectManualApplyFiles(batch, [], cwd);
const transaction = {
version: 1,
id: randomUUID().replace(/-/g, '').slice(0, 8),
createdAt: new Date().toISOString(),
pageUrl,
entryIds: (batch?.entries || []).map((entry) => entry.id).filter(Boolean),
files: files.map((relativeFile) => {
const absolute = path.resolve(cwd, relativeFile);
const exists = fs.existsSync(absolute);
return {
file: relativeFile,
exists,
content: exists ? fs.readFileSync(absolute, 'utf-8') : '',
};
}),
};
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(`${file}.tmp`, JSON.stringify(transaction, null, 2) + '\n', 'utf-8');
fs.renameSync(`${file}.tmp`, file);
return transaction;
}
export function clearManualApplyTransaction(cwd = process.cwd(), transactionId = null) {
const file = manualApplyTransactionPath(cwd);
if (!fs.existsSync(file)) return false;
if (transactionId) {
const existing = readManualApplyTransaction(cwd);
if (existing?.id && existing.id !== transactionId) return false;
}
try {
fs.unlinkSync(file);
return true;
} catch {
return false;
}
}
export function rollbackManualApplyTransaction({
cwd = process.cwd(),
pageUrl = null,
reason = 'manual_edit_transaction_rollback',
recordManualEditActivity = null,
} = {}) {
const transaction = readManualApplyTransaction(cwd);
if (!transaction) return null;
if (pageUrl && transaction.pageUrl && transaction.pageUrl !== pageUrl) return null;
let pendingIds = new Set();
try {
const buffer = readManualEditsBuffer(cwd);
pendingIds = new Set((buffer.entries || []).map((entry) => entry.id).filter(Boolean));
} catch {
pendingIds = new Set(transaction.entryIds || []);
}
const shouldRollback = (transaction.entryIds || []).some((id) => pendingIds.has(id));
if (!shouldRollback) {
clearManualApplyTransaction(cwd, transaction.id);
return { id: transaction.id, reason, rolledBackFiles: [], rollbackFailures: [], skipped: 'entries_not_pending' };
}
const rolledBackFiles = [];
const rollbackFailures = [];
for (const item of transaction.files || []) {
const relativeFile = normalizeProjectFile(item.file, cwd);
if (!relativeFile) continue;
const absolute = path.resolve(cwd, relativeFile);
try {
if (item.exists) {
fs.mkdirSync(path.dirname(absolute), { recursive: true });
fs.writeFileSync(absolute, item.content || '', 'utf-8');
} else if (fs.existsSync(absolute)) {
fs.rmSync(absolute);
}
rolledBackFiles.push(relativeFile);
} catch (err) {
rollbackFailures.push({ file: relativeFile, reason: 'restore_failed', message: err.message || String(err) });
}
}
clearManualApplyTransaction(cwd, transaction.id);
recordManualEditActivity?.('manual_edit_transaction_rolled_back', {
id: transaction.id,
pageUrl: transaction.pageUrl || null,
reason,
entryIds: transaction.entryIds || [],
rolledBackFiles: rolledBackFiles.map((file) => summarizeManualLogFile(file, cwd)).filter(Boolean),
rollbackFailures: summarizeManualDiagnostics(rollbackFailures, cwd),
});
return { id: transaction.id, reason, rolledBackFiles, rollbackFailures };
}
export function collectManualApplyFiles(batch, extraFiles = [], cwd = process.cwd()) {
const files = [];
for (const entry of batch?.entries || []) {
for (const op of entry.ops || []) files.push(op.sourceHint?.file);
}
for (const candidate of batch?.candidates || []) {
files.push(candidate.sourceHint?.relativeFile, candidate.sourceHint?.file);
for (const item of candidate.textMatches || []) files.push(item.file);
for (const item of candidate.objectKeyMatches || []) files.push(item.file);
for (const item of candidate.locatorMatches || []) files.push(item.file);
for (const item of candidate.contextTextMatches || []) files.push(item.file);
}
files.push(...(extraFiles || []));
return [...new Set(files)]
.map((file) => normalizeProjectFile(file, cwd))
.filter(Boolean);
}
function normalizeProjectFile(file, cwd = process.cwd()) {
if (!file || typeof file !== 'string') return null;
const absolute = path.isAbsolute(file) ? file : path.resolve(cwd, file);
const relative = path.relative(cwd, absolute);
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
return relative;
}
export function rollbackApplySnapshot(
batch,
rollbackSnapshot,
extraFiles = [],
_reason = 'manual_edit_apply_snapshot_rollback',
cwd = process.cwd(),
) {
const scope = collectManualApplyFiles(batch, extraFiles, cwd);
const rolledBackFiles = [];
const rollbackFailures = [];
for (const relativeFile of scope) {
const before = rollbackSnapshot?.get(relativeFile);
if (!before) continue;
const absolute = path.resolve(cwd, relativeFile);
try {
if (before.exists) {
fs.mkdirSync(path.dirname(absolute), { recursive: true });
fs.writeFileSync(absolute, before.content, 'utf-8');
} else if (fs.existsSync(absolute)) {
fs.rmSync(absolute);
}
rolledBackFiles.push(relativeFile);
} catch (err) {
rollbackFailures.push({ file: relativeFile, reason: 'restore_failed', message: err.message || String(err) });
}
}
return { rolledBackFiles, rollbackFailures };
}
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
return `live-poll.mjs --reply ${id} done --data '<json>'`;
}
export function buildManualApplyAgentAction(eventOrId = 'EVENT_ID') {
return {
kind: 'manual_edit_apply',
required: 'apply_source_edits_then_reply',
replyCommand: manualApplyReplyCommand(eventOrId),
warning: 'Polling only leases this work item; it does not commit source edits.',
};
}
export function summarizeManualApplyEvent(event = {}, batch = event.batch, cwd = process.cwd()) {
const entries = Array.isArray(batch?.entries) ? batch.entries : [];
const opCount = entries.reduce((sum, entry) => sum + (Array.isArray(entry.ops) ? entry.ops.length : 0), 0);
return {
pageUrl: event.pageUrl || null,
chunk: event.chunk || null,
entryCount: entries.length,
opCount,
files: collectManualApplyFiles(batch, [], cwd),
};
}
export function summarizeManualApplyFailures(failed, cwd = process.cwd()) {
if (!Array.isArray(failed)) return [];
return failed.slice(0, 20).map((item) => ({
id: item.id || item.entryId || null,
reason: item.reason || item.message || 'failed',
message: compactManualLogText(item.message, 300),
files: Array.isArray(item.files) ? item.files.slice(0, 12).map((file) => summarizeManualLogFile(file, cwd)).filter(Boolean) : undefined,
checks: summarizeManualDiagnostics(item.checks, cwd),
failures: summarizeManualDiagnostics(item.failures, cwd),
candidates: summarizeManualDiagnostics(item.candidates, cwd),
}));
}
export function summarizeManualDiagnostics(items, cwd = process.cwd()) {
if (!Array.isArray(items) || items.length === 0) return undefined;
return items.slice(0, 12).map((item) => ({
reason: item.reason || item.kind || undefined,
detail: compactManualLogText(item.detail, 220),
message: compactManualLogText(item.message, 300),
file: summarizeManualLogFile(item.file || item.relativeFile, cwd),
line: item.line || undefined,
ref: compactManualLogText(item.ref, 180),
marker: compactManualLogText(item.marker, 120),
files: Array.isArray(item.files) ? item.files.slice(0, 8).map((file) => summarizeManualLogFile(file, cwd)).filter(Boolean) : undefined,
}));
}
export function summarizeManualLogFile(file, cwd = process.cwd()) {
if (!file || typeof file !== 'string') return undefined;
if (!path.isAbsolute(file)) return file;
const relative = path.relative(cwd, file);
return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : file;
}
export function compactManualLogText(value, max = 200) {
if (typeof value !== 'string') return undefined;
const normalized = value.replace(/\s+/g, ' ').trim();
if (normalized.length <= max) return normalized;
return normalized.slice(0, max) + `... [truncated ${normalized.length - max} chars]`;
}
@@ -0,0 +1,357 @@
import { validateEvent } from './event-validation.mjs';
import {
countByPage as countPendingByPage,
readBuffer as readManualEditsBuffer,
removeEntries as removeManualEditEntries,
stageEntry as stageManualEditEntry,
truncateBuffer as truncateManualEditsBuffer,
} from './manual-edits-buffer.mjs';
import {
summarizeManualApplyFailures,
summarizeManualDiagnostics,
summarizeManualLogFile,
} from './manual-apply.mjs';
import { buildManualEditEvidence } from '../live-manual-edit-evidence.mjs';
import { commitManualEdits } from '../live-commit-manual-edits.mjs';
export function createManualEditRoutes({
getToken,
manualApply,
recordManualEditActivity,
getManualEditStatus,
chatAgentLikelyActive,
cwd = () => process.cwd(),
env = () => process.env,
} = {}) {
const projectCwd = () => typeof cwd === 'function' ? cwd() : cwd || process.cwd();
const currentEnv = () => typeof env === 'function' ? env() : env || process.env;
return function handleManualEditRoute(req, res, url) {
const p = url.pathname;
// Save stages entries; Apply commits the staged page batch through the
// local AI copy-edit runner.
if (p === '/manual-edit-stash' && req.method === 'POST') {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
let msg;
try { msg = JSON.parse(body); } catch {
sendJson(res, 400, { error: 'Invalid JSON' });
return;
}
if (msg.token !== getToken()) {
sendJson(res, 401, { error: 'Unauthorized' });
return;
}
const error = validateEvent({ ...msg, type: 'manual_edits' });
if (error) {
sendJson(res, 400, { error });
return;
}
try {
stageManualEditEntry(projectCwd(), {
id: msg.id,
pageUrl: msg.pageUrl,
element: msg.element,
ops: msg.ops,
});
} catch (err) {
sendJson(res, 500, { error: 'stash_write_failed', message: err.message });
return;
}
const { totalCount, perPage } = countPendingByPage(projectCwd());
const pendingCount = perPage[msg.pageUrl] || 0;
recordManualEditActivity('manual_edit_stashed', {
id: msg.id,
pageUrl: msg.pageUrl,
opCount: msg.ops.length,
pendingCount,
totalCount,
hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file, projectCwd())).filter(Boolean)).size,
});
sendJson(res, 200, { ok: true, pendingCount, totalCount, perPage });
});
return true;
}
if (p === '/manual-edit-stash' && req.method === 'GET') {
const token = url.searchParams.get('token');
if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; }
const pageUrl = url.searchParams.get('pageUrl') || '';
const { totalCount, perPage } = countPendingByPage(projectCwd());
const buffer = readManualEditsBuffer(projectCwd());
const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries;
sendJson(res, 200, {
count: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
totalCount,
perPage,
entries: entriesForPage,
});
return true;
}
if (p === '/manual-edit-commit' && req.method === 'POST') {
const token = url.searchParams.get('token');
if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; }
const pageUrl = url.searchParams.get('pageUrl');
const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || '');
const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || '');
const existingTransaction = manualApply.readTransaction();
if (repairOnly && !existingTransaction) {
sendJson(res, 409, { error: 'manual_edit_repair_transaction_missing' });
return true;
}
const recoveredTransaction = repairOnly ? null : manualApply.rollbackTransaction({
pageUrl,
reason: 'manual_edit_commit_recovered_abandoned_transaction',
});
const before = getManualEditStatus();
const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount;
recordManualEditActivity('manual_edit_commit_started', {
pageUrl,
repairOnly,
pendingCount,
totalCount: before.totalCount,
recoveredTransaction: recoveredTransaction ? {
id: recoveredTransaction.id,
reason: recoveredTransaction.reason,
skipped: recoveredTransaction.skipped,
rolledBackFiles: recoveredTransaction.rolledBackFiles,
rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures, projectCwd()),
} : null,
...summarizePendingManualEditBatch(projectCwd(), pageUrl),
});
if (asyncMode) {
sendJson(res, 202, {
status: 'started',
pendingCount,
totalCount: before.totalCount,
perPage: before.perPage,
});
}
(async () => {
let result;
let routedProvider = 'subprocess';
let transaction = null;
let commitBatch = null;
try {
if (pendingCount > 0) {
const transactionBatch = buildManualEditEvidence({ cwd: projectCwd(), pageUrl });
commitBatch = transactionBatch;
if (!repairOnly && manualApply.countOps(transactionBatch) > 0) {
transaction = manualApply.writeTransaction({
pageUrl,
batch: transactionBatch,
});
} else if (repairOnly && existingTransaction) {
transaction = existingTransaction;
}
}
const envValue = currentEnv();
const requestedMode = (envValue.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase();
const useChatRoute = requestedMode === 'chat'
|| (requestedMode === 'auto' && chatAgentLikelyActive());
if (useChatRoute) {
routedProvider = 'chat';
const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000);
result = await commitManualEdits({
cwd: projectCwd(),
pageUrl,
provider: 'chat',
env: envValue,
timeoutMs,
chatAvailable: chatAgentLikelyActive,
applyBatchToSource: (batch, context) => manualApply.pushBatchInChunksAndWait(batch, pageUrl, context),
repairOnly,
transactionId: transaction?.id || existingTransaction?.id || null,
batch: commitBatch,
});
} else {
const timeoutMs = Number(envValue.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000);
const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined;
result = await commitManualEdits({
cwd: projectCwd(),
pageUrl,
provider,
env: envValue,
timeoutMs,
chatAvailable: chatAgentLikelyActive,
repairOnly,
transactionId: transaction?.id || existingTransaction?.id || null,
batch: commitBatch,
});
}
} catch (err) {
if (transaction) {
manualApply.rollbackTransaction({
pageUrl,
reason: 'manual_edit_commit_exception',
});
}
const message = err.stderr?.toString?.() || err.message;
recordManualEditActivity('manual_edit_commit_failed', {
pageUrl,
provider: routedProvider,
error: 'manual_edit_commit_failed',
message,
transactionId: transaction?.id || null,
});
if (!asyncMode) {
sendJson(res, 500, {
error: 'manual_edit_commit_failed',
message,
});
}
return;
} finally {
if (transaction) {
const shouldKeepTransaction = result?.needsManualDecision === true;
if (!shouldKeepTransaction) manualApply.clearTransaction(transaction.id);
}
}
const { totalCount, perPage } = countPendingByPage(projectCwd());
if (result?.needsManualDecision) {
recordManualEditActivity('manual_edit_repair_needs_decision', {
pageUrl,
provider: routedProvider,
transactionId: transaction?.id || existingTransaction?.id || null,
repair: result.repair || null,
failed: summarizeManualApplyFailures(result.failed, projectCwd()),
files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [],
remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
totalCount,
});
} else {
recordManualEditActivity('manual_edit_commit_done', {
pageUrl,
provider: routedProvider,
reason: result.reason || null,
repair: result.repair || null,
appliedCount: Array.isArray(result.applied) ? result.applied.length : 0,
failedCount: Array.isArray(result.failed) ? result.failed.length : 0,
failed: summarizeManualApplyFailures(result.failed, projectCwd()),
files: Array.isArray(result.files) ? result.files.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [],
warnings: summarizeManualDiagnostics(result.warnings, projectCwd()),
rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : [],
rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures, projectCwd()),
unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) : undefined,
noteCount: Array.isArray(result.notes) ? result.notes.length : 0,
cleared: result.cleared || 0,
remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
totalCount,
});
}
if (!asyncMode) {
sendJson(res, 200, { ...result, totalCount, perPage });
}
})();
return true;
}
if (p === '/manual-edit-repair-decision' && req.method === 'POST') {
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
let payload = {};
try { payload = body ? JSON.parse(body) : {}; } catch {
sendJson(res, 400, { error: 'Invalid JSON' });
return;
}
const token = payload.token || url.searchParams.get('token');
if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return; }
const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null;
const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase();
if (action !== 'rollback') {
sendJson(res, 400, { error: 'unsupported_manual_edit_repair_decision', action });
return;
}
const rollback = manualApply.rollbackTransaction({
pageUrl,
reason: 'manual_edit_user_requested_rollback',
});
const { totalCount, perPage } = countPendingByPage(projectCwd());
const response = {
action,
pageUrl,
rollback,
remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
totalCount,
perPage,
};
recordManualEditActivity('manual_edit_repair_rollback_done', response);
sendJson(res, 200, response);
});
return true;
}
if (p === '/manual-edit-discard' && req.method === 'POST') {
const token = url.searchParams.get('token');
if (token !== getToken()) { res.writeHead(401); res.end('Unauthorized'); return true; }
const pageUrl = url.searchParams.get('pageUrl');
let discarded;
let discardedEntries = [];
let canceledApplyEvents = [];
let transactionRollback = null;
try {
const buffer = readManualEditsBuffer(projectCwd());
transactionRollback = manualApply.rollbackTransaction({
pageUrl,
reason: 'manual_edit_discarded',
});
if (pageUrl) {
discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl);
discarded = removeManualEditEntries(projectCwd(), (entry) => entry.pageUrl === pageUrl);
} else {
discardedEntries = buffer.entries;
discarded = truncateManualEditsBuffer(projectCwd());
}
canceledApplyEvents = manualApply.cancelPendingEvents(pageUrl);
} catch (err) {
sendJson(res, 500, { error: 'discard_failed', message: err.message });
return true;
}
const { totalCount, perPage } = countPendingByPage(projectCwd());
recordManualEditActivity('manual_edit_discarded', {
pageUrl,
discarded,
canceledApplyIds: canceledApplyEvents.map((event) => event.id),
transactionRollback: transactionRollback ? {
id: transactionRollback.id,
rolledBackFiles: transactionRollback.rolledBackFiles?.map((file) => summarizeManualLogFile(file, projectCwd())).filter(Boolean) || [],
rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures, projectCwd()),
skipped: transactionRollback.skipped,
} : undefined,
totalCount,
});
sendJson(res, 200, { discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage });
return true;
}
if (p === '/manual-edit' && req.method === 'POST') {
sendJson(res, 410, { error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' });
return true;
}
return false;
};
}
function sendJson(res, status, body) {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(body));
}
function summarizePendingManualEditBatch(cwd, pageUrl = null) {
try {
const buffer = readManualEditsBuffer(cwd);
const entries = (buffer.entries || [])
.filter((entry) => !pageUrl || entry.pageUrl === pageUrl);
return {
pendingEntryCount: entries.length,
pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0),
};
} catch (err) {
return { pendingSummaryError: err.message || String(err) };
}
}
@@ -12,7 +12,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { getLiveDir } from './impeccable-paths.mjs';
import { getLiveDir } from '../lib/impeccable-paths.mjs';
const BUFFER_VERSION = 1;
const BUFFER_FILENAME = 'pending-manual-edits.json';
@@ -1,6 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs';
import { getLegacyLiveSessionsDir, getLiveSessionsDir } from '../lib/impeccable-paths.mjs';
const COMPLETED_PHASES = new Set(['completed', 'discarded']);
@@ -106,6 +106,8 @@ function baseSnapshot(id) {
phase: 'new',
pageUrl: null,
sourceFile: null,
previewFile: null,
previewMode: null,
expectedVariants: 0,
arrivedVariants: 0,
visibleVariant: null,
@@ -177,8 +179,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
case 'variants_ready':
case 'agent_done':
next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
next.sourceFile = event.file ?? next.sourceFile;
next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants);
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
next.arrivedVariants = event.arrivedVariants ?? (next.expectedVariants || next.arrivedVariants || 0);
next.pendingEventSeq = null;
next.pendingEvent = null;
if (event.carbonize === true) {
@@ -190,12 +194,19 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
}
break;
case 'checkpoint':
if (COMPLETED_PHASES.has(next.phase)) {
next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
break;
}
if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
next.phase = event.phase ?? next.phase;
next.checkpointRevision = event.revision ?? next.checkpointRevision;
next.activeOwner = event.owner ?? next.activeOwner;
next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
next.sourceFile = event.sourceFile ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
if (event.paramValues) next.paramValues = { ...event.paramValues };
} else {
next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
@@ -223,6 +234,10 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
break;
case 'steer_done':
next.phase = 'steer_done';
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
next.message = event.message ?? next.message;
next.pendingEventSeq = null;
next.pendingEvent = null;
break;
@@ -238,6 +253,9 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
break;
case 'complete':
next.phase = 'completed';
next.sourceFile = event.sourceFile ?? event.file ?? next.sourceFile;
next.previewFile = event.previewFile ?? next.previewFile;
next.previewMode = event.previewMode ?? next.previewMode;
next.pendingEventSeq = null;
next.pendingEvent = null;
break;
@@ -0,0 +1,826 @@
/**
* Svelte live-mode component injection helpers.
*
* Variants are real .svelte components under node_modules/.impeccable-live/<session-id>/.
* The browser mounts them via Svelte 5 mount(); accept inlines the chosen
* variant back into the route source with props mapped to original bindings.
*/
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { createHash } from 'node:crypto';
export const SVELTE_COMPONENT_ROOT = 'node_modules/.impeccable-live';
export const SVELTE_RUNTIME_FILE = `${SVELTE_COMPONENT_ROOT}/__runtime.js`;
export const DEFERRED_ACCEPTS_FILE = '.impeccable/live/deferred-svelte-component-accepts.json';
const MUSTACHE_RE = /\{([^{}]+)\}/g;
export function shouldUseSvelteComponentInjection(filePath) {
if (/^(0|false|no)$/i.test(process.env.IMPECCABLE_LIVE_SVELTE_COMPONENT || '')) return false;
return path.extname(filePath).toLowerCase() === '.svelte';
}
export function componentSessionDir(id, cwd = process.cwd()) {
return path.join(cwd, SVELTE_COMPONENT_ROOT, id);
}
export function manifestPathForSession(id, cwd = process.cwd()) {
return path.join(componentSessionDir(id, cwd), 'manifest.json');
}
export function ensureRuntimeHelper(cwd = process.cwd()) {
const file = path.join(cwd, SVELTE_RUNTIME_FILE);
if (fs.existsSync(file)) return file;
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `export { mount, unmount } from 'svelte';\n`, 'utf-8');
return file;
}
/**
* Extract ordered unique mustache expressions from markup (not inside <!-- -->).
*/
export function extractMustacheExpressions(text) {
const expressions = [];
const seen = new Set();
const lines = String(text || '').split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('<!--')) continue;
let match;
MUSTACHE_RE.lastIndex = 0;
while ((match = MUSTACHE_RE.exec(line)) !== null) {
const expr = match[1].trim();
if (!expr || seen.has(expr)) continue;
seen.add(expr);
expressions.push(expr);
}
}
return expressions;
}
export function buildPropContract(expressions) {
return expressions.map((expr, index) => {
const derived = derivePropName(expr, index);
return {
prop: derived,
expr,
placeholder: `{${expr}}`,
};
});
}
function derivePropName(expr, index) {
const tail = expr.match(/(?:\.|\[)(\w+)\s*\]?$/);
if (tail && tail[1] && /^[A-Za-z_$][\w$]*$/.test(tail[1])) {
return tail[1];
}
return `prop${index}`;
}
export function substituteExprsWithProps(markup, contract) {
let out = String(markup || '');
for (const entry of contract) {
out = out.split(entry.placeholder).join(`{${entry.prop}}`);
}
return out;
}
export function substitutePropsWithExprs(markup, contract) {
let out = String(markup || '');
for (const entry of contract) {
out = out.split(`{${entry.prop}}`).join(`{${entry.expr}}`);
}
return out;
}
export function parseSvelteComponentFile(content) {
const text = String(content || '');
const scriptMatch = text.match(/^([\s\S]*?)<script\b[^>]*>[\s\S]*?<\/script>/i);
const withoutScript = scriptMatch ? text.slice(scriptMatch[0].length) : text;
const styleMatch = withoutScript.match(/<style\b[^>]*>[\s\S]*?<\/style\s*>/i);
const styleBlock = styleMatch ? styleMatch[0] : '';
const markup = styleMatch
? withoutScript.slice(0, styleMatch.index).trim()
: withoutScript.trim();
const cssLines = styleBlock
? styleBlock
.replace(/^<style\b[^>]*>/i, '')
.replace(/<\/style\s*>$/i, '')
.split('\n')
.map((line) => line.trimEnd())
: [];
while (cssLines.length > 0 && cssLines[0].trim() === '') cssLines.shift();
while (cssLines.length > 0 && cssLines[cssLines.length - 1].trim() === '') cssLines.pop();
return { markup, cssLines, styleBlock };
}
function buildPropsScript(contract) {
if (contract.length === 0) {
return '<script>\n /** @type {Record<string, never>} */\n let {} = $props();\n</script>\n';
}
const names = contract.map((c) => c.prop).join(', ');
const typeFields = contract.map((c) => ` ${c.prop}: string;`).join('\n');
return `<script>\n /** @type {{\n${typeFields}\n }} */\n let { ${names} } = $props();\n</script>\n`;
}
function buildVariantStub(variantNum, originalWithProps, contract) {
const propsComment = contract.length > 0
? `\n<!-- Props: ${contract.map((c) => `${c.prop} <- {${c.expr}}`).join(', ')} -->\n`
: '';
return `${buildPropsScript(contract)}${propsComment}${originalWithProps.trim()}\n\n<style>\n /* Variant ${variantNum}: add scoped CSS here */\n</style>\n`;
}
function buildInsertVariantStub(variantNum) {
return `${buildPropsScript([])}<div class="impeccable-insert-preview">Insert variant ${variantNum}</div>\n\n<style>\n .impeccable-insert-preview { display: block; }\n</style>\n`;
}
export function scaffoldSvelteComponentSession({
id,
count,
sourceFile,
sourceStartLine,
sourceEndLine,
originalLines,
cwd = process.cwd(),
}) {
ensureRuntimeHelper(cwd);
const dir = componentSessionDir(id, cwd);
fs.mkdirSync(dir, { recursive: true });
const originalMarkup = originalLines.join('\n');
const contract = buildPropContract(extractMustacheExpressions(originalMarkup));
const originalWithProps = substituteExprsWithProps(originalMarkup, contract);
const manifest = {
id,
previewMode: 'svelte-component',
sourceFile: sourceFile.split(path.sep).join('/'),
sourceStartLine,
sourceEndLine,
count,
propContract: contract,
originalMarkup,
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
};
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
for (let n = 1; n <= count; n++) {
const variantFile = path.join(dir, `v${n}.svelte`);
if (!fs.existsSync(variantFile)) {
fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8');
}
}
return {
manifest,
manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
componentDir: manifest.componentDir,
propContract: contract,
};
}
export function scaffoldSvelteComponentInsertSession({
id,
count,
sourceFile,
insertLine,
position,
anchorStartLine,
anchorEndLine,
anchorLines,
cwd = process.cwd(),
}) {
ensureRuntimeHelper(cwd);
const dir = componentSessionDir(id, cwd);
fs.mkdirSync(dir, { recursive: true });
const anchorMarkup = (anchorLines || []).join('\n');
const manifest = {
id,
mode: 'insert',
previewMode: 'svelte-component',
sourceFile: sourceFile.split(path.sep).join('/'),
insertLine,
position,
anchorStartLine,
anchorEndLine,
originalMarkup: anchorMarkup,
anchorMarkup,
count,
propContract: [],
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
};
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
for (let n = 1; n <= count; n++) {
const variantFile = path.join(dir, `v${n}.svelte`);
if (!fs.existsSync(variantFile)) {
fs.writeFileSync(variantFile, buildInsertVariantStub(n), 'utf-8');
}
}
return {
manifest,
manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
componentDir: manifest.componentDir,
propContract: [],
};
}
export function findSvelteComponentManifest(id, cwd = process.cwd()) {
const direct = manifestPathForSession(id, cwd);
if (fs.existsSync(direct)) {
return readManifest(direct);
}
const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
if (!fs.existsSync(root)) return null;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const candidate = path.join(root, entry.name, 'manifest.json');
if (!fs.existsSync(candidate)) continue;
try {
const manifest = readManifest(candidate);
if (manifest?.id === id) return { ...manifest, manifestPath: candidate };
} catch { /* skip */ }
}
return null;
}
export function readManifest(manifestPath) {
const data = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
return {
...data,
manifestPath,
};
}
export function resolveSourceFile(sourceFile, cwd = process.cwd()) {
if (!sourceFile || path.isAbsolute(sourceFile)) {
throw new Error('Invalid svelte-component source file');
}
const full = path.resolve(cwd, sourceFile);
const rel = path.relative(cwd, full);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) {
throw new Error('Svelte-component source file escapes project root');
}
if (!fs.existsSync(full)) {
throw new Error('Svelte-component source file not found: ' + sourceFile);
}
return full;
}
function appendCssToSvelteStyle(lines, cssLines) {
const closeIdx = findLastStyleCloseLine(lines);
const prepared = ['', ...cssLines.map((line) => (line.trim() === '' ? '' : ' ' + line.trimStart()))];
if (closeIdx === -1) {
return [...lines, '', '<style>', ...prepared.slice(1), '</style>'];
}
return [
...lines.slice(0, closeIdx),
...prepared,
...lines.slice(closeIdx),
];
}
function findLastStyleCloseLine(lines) {
for (let i = lines.length - 1; i >= 0; i--) {
if (/<\/style\s*>/.test(lines[i])) return i;
}
return -1;
}
function bakeParamValuesInCss(cssLines, paramValues) {
if (!paramValues || Object.keys(paramValues).length === 0) return cssLines;
return cssLines.map((line) => {
let out = line;
for (const [key, value] of Object.entries(paramValues)) {
const varName = `--p-${key}`;
out = out.replace(new RegExp(`var\\(${escapeRegExp(varName)}(?:,\\s*[^)]+)?\\)`, 'g'), String(value));
}
return out;
});
}
function sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues = null, rootTag = 'div') {
const css = String((cssLines || []).join('\n'));
if (!/data-impeccable-variant|impeccable-variant-ready/.test(css)) return cssLines;
const rules = parseCssRules(css);
const output = [];
for (const rule of rules) {
appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag);
}
return output.join('\n')
.split('\n')
.map((line) => line.trimEnd())
.filter((line) => line.trim() !== '');
}
function appendSanitizedCssRule(output, rule, variantNum, paramValues, rootTag) {
const prelude = rule.prelude.trim();
const body = rule.body.trim();
if (!prelude || !body || /--impeccable-variant-ready\s*:/.test(body)) return;
if (/^@scope\b/i.test(prelude)) {
if (/data-impeccable-variant/.test(prelude) && !selectorHasVariant(prelude, variantNum)) return;
const inner = parseCssRules(body);
for (const innerRule of inner) {
const rewrittenPrelude = rewriteAcceptedSvelteSelector(innerRule.prelude, variantNum, paramValues, rootTag, true);
if (!rewrittenPrelude || /--impeccable-variant-ready\s*:/.test(innerRule.body)) continue;
output.push(formatCssRule(rewrittenPrelude, innerRule.body.trim()));
}
return;
}
const rewrittenPrelude = rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, false);
if (!rewrittenPrelude) return;
output.push(formatCssRule(rewrittenPrelude, body));
}
function parseCssRules(css) {
const rules = [];
const text = String(css || '');
let i = 0;
while (i < text.length) {
while (i < text.length && /\s/.test(text[i])) i++;
const preludeStart = i;
while (i < text.length && text[i] !== '{') i++;
if (i >= text.length) break;
const prelude = text.slice(preludeStart, i).trim();
i++;
const bodyStart = i;
let depth = 1;
let quote = null;
let comment = false;
while (i < text.length && depth > 0) {
const ch = text[i];
const next = text[i + 1];
if (comment) {
if (ch === '*' && next === '/') {
comment = false;
i += 2;
continue;
}
i++;
continue;
}
if (quote) {
if (ch === '\\') {
i += 2;
continue;
}
if (ch === quote) quote = null;
i++;
continue;
}
if (ch === '/' && next === '*') {
comment = true;
i += 2;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
i++;
continue;
}
if (ch === '{') depth++;
else if (ch === '}') depth--;
i++;
}
const body = text.slice(bodyStart, Math.max(bodyStart, i - 1));
if (prelude) rules.push({ prelude, body });
}
return rules;
}
function rewriteAcceptedSvelteSelector(prelude, variantNum, paramValues, rootTag, fromScope) {
const selectors = splitSelectorList(prelude);
const rewritten = [];
for (const selector of selectors) {
const next = rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope);
if (next) rewritten.push(next);
}
return rewritten.join(', ');
}
function rewriteAcceptedSvelteSelectorPart(selector, variantNum, paramValues, rootTag, fromScope) {
let out = selector.trim();
const hasVariant = /data-impeccable-variant/.test(out);
if (hasVariant && !selectorHasVariant(out, variantNum)) return '';
if (hasVariant) {
out = out.replace(variantSelectorRegex(variantNum), '');
out = out.replace(/\[data-impeccable-variant=(["']).*?\1\]/g, '');
}
const paramResult = rewriteParamSelectors(out, paramValues);
if (!paramResult.keep) return '';
out = paramResult.selector;
out = out
.replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '')
.replace(/:scope(?:\[[^\]]+\])?/g, rootTag || '')
.replace(/\s+/g, ' ')
.trim();
out = out.replace(/^[>+~]\s*/, '').trim();
if (!out && (hasVariant || fromScope)) return rootTag || ':global(*)';
return out;
}
function rewriteParamSelectors(selector, paramValues) {
let keep = true;
const next = selector.replace(/\[data-p-([A-Za-z0-9_-]+)(?:=(["'])(.*?)\2)?\]/g, (_match, key, _quote, expected) => {
if (!paramValues || !Object.prototype.hasOwnProperty.call(paramValues, key)) return '';
const actual = paramValues[key];
if (expected != null && String(actual) !== String(expected)) {
keep = false;
return '';
}
if (expected == null && (actual === false || actual == null || actual === 'false' || actual === 'off' || actual === '0')) {
keep = false;
return '';
}
return '';
});
return { keep, selector: next };
}
function splitSelectorList(prelude) {
const selectors = [];
let start = 0;
let bracket = 0;
let paren = 0;
let quote = null;
for (let i = 0; i < prelude.length; i++) {
const ch = prelude[i];
if (quote) {
if (ch === '\\') i++;
else if (ch === quote) quote = null;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
continue;
}
if (ch === '[') bracket++;
else if (ch === ']') bracket = Math.max(0, bracket - 1);
else if (ch === '(') paren++;
else if (ch === ')') paren = Math.max(0, paren - 1);
else if (ch === ',' && bracket === 0 && paren === 0) {
selectors.push(prelude.slice(start, i));
start = i + 1;
}
}
selectors.push(prelude.slice(start));
return selectors;
}
function selectorHasVariant(selector, variantNum) {
return variantSelectorRegex(variantNum).test(selector);
}
function variantSelectorRegex(variantNum) {
return new RegExp(`\\[data-impeccable-variant=(["'])${escapeRegExp(String(variantNum))}\\1\\]`, 'g');
}
function formatCssRule(selector, body) {
return `${selector} { ${body.trim()} }`;
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function inlineSvelteComponentAccept(manifest, variantNum, paramValues = null, cwd = process.cwd()) {
const sourceFile = resolveSourceFile(manifest.sourceFile, cwd);
const variantPath = path.join(cwd, manifest.componentDir, `v${variantNum}.svelte`);
const resultBase = {
file: manifest.sourceFile,
sourceFile: manifest.sourceFile,
previewMode: 'svelte-component',
componentDir: manifest.componentDir,
carbonize: false,
};
if (!fs.existsSync(variantPath)) {
return { handled: false, error: `Variant ${variantNum} not found`, ...resultBase };
}
const { markup, cssLines } = parseSvelteComponentFile(fs.readFileSync(variantPath, 'utf-8'));
if (manifest.mode === 'insert') {
return inlineSvelteComponentInsertAccept({
manifest,
markup,
cssLines,
variantNum,
paramValues,
sourceFile,
resultBase,
cwd,
});
}
const rootTag = matchOpeningTag(markup)?.tag || 'div';
const contract = manifest.propContract || [];
const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || '');
const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract)
.split('\n')
.map((line) => line.trimEnd());
const sourceContent = fs.readFileSync(sourceFile, 'utf-8');
const sourceLines = sourceContent.split('\n');
const start = Number(manifest.sourceStartLine) - 1;
const end = Number(manifest.sourceEndLine) - 1;
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end >= sourceLines.length) {
return { handled: false, error: 'Invalid source line range for ' + manifest.sourceFile, ...resultBase };
}
const indent = sourceLines[start].match(/^(\s*)/)?.[1] || '';
const indentedMarkup = restoredMarkup.map((line) => {
if (line.trim() === '') return '';
return indent + line.trimStart();
});
let newLines = [
...sourceLines.slice(0, start),
...indentedMarkup,
...sourceLines.slice(end + 1),
];
const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
if (bakedCss.length > 0) {
newLines = appendCssToSvelteStyle(newLines, bakedCss);
}
try {
fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8');
} catch (err) {
return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase };
}
removeSvelteComponentSession(manifest.id, cwd);
return {
handled: true,
...resultBase,
};
}
function inlineSvelteComponentInsertAccept({
manifest,
markup,
cssLines,
variantNum,
paramValues,
sourceFile,
resultBase,
cwd,
}) {
if (!svelteMarkupHasVisibleContent(markup)) {
return { handled: false, error: 'Accepted Svelte insert variant is empty', ...resultBase };
}
if (/\bdata-impeccable-[\w-]*\s*=/.test(markup)) {
return { handled: false, error: 'Accepted Svelte insert variant contains preview-only data-impeccable attributes', ...resultBase };
}
const rootTag = matchOpeningTag(markup)?.tag || 'div';
const restoredMarkup = String(markup || '')
.split('\n')
.map((line) => line.trimEnd());
const sourceContent = fs.readFileSync(sourceFile, 'utf-8');
const sourceLines = sourceContent.split('\n');
const insertIndex = Number(manifest.insertLine) - 1;
if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > sourceLines.length) {
return { handled: false, error: 'Invalid insert line for ' + manifest.sourceFile, ...resultBase };
}
const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? '';
const indent = nearbyLine.match(/^(\s*)/)?.[1] || '';
const indentedMarkup = restoredMarkup.map((line) => {
if (line.trim() === '') return '';
return indent + line.trimStart();
});
let newLines = [
...sourceLines.slice(0, insertIndex),
...indentedMarkup,
...sourceLines.slice(insertIndex),
];
const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
if (bakedCss.length > 0) {
newLines = appendCssToSvelteStyle(newLines, bakedCss);
}
try {
fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8');
} catch (err) {
return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase };
}
removeSvelteComponentSession(manifest.id, cwd);
return {
handled: true,
...resultBase,
};
}
function svelteMarkupHasVisibleContent(markup) {
const text = String(markup || '')
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (text.length > 0) return true;
return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || '');
}
function mergeOriginalTopLevelAttrs(markup, originalMarkup) {
const variantOpen = matchOpeningTag(markup);
const originalOpen = matchOpeningTag(originalMarkup);
if (!variantOpen || !originalOpen) return markup;
if (variantOpen.tag.toLowerCase() !== originalOpen.tag.toLowerCase()) return markup;
const variantAttrs = parseAttrSegments(variantOpen.attrs);
const originalAttrs = parseAttrSegments(originalOpen.attrs);
const additions = [];
let attrs = variantOpen.attrs;
const originalClass = originalAttrs.get('class');
const variantClass = variantAttrs.get('class');
if (originalClass && variantClass) {
const merged = mergeStaticClassAttr(originalClass, variantClass);
if (merged) {
attrs = attrs.slice(0, variantClass.start) + merged + attrs.slice(variantClass.end);
variantAttrs.set('class', { ...variantClass, raw: merged });
}
} else if (originalClass && !variantClass) {
additions.push(originalClass.raw);
}
for (const [name, attr] of originalAttrs) {
if (name === 'class') continue;
if (!variantAttrs.has(name)) additions.push(attr.raw);
}
if (additions.length === 0 && attrs === variantOpen.attrs) return markup;
const nextOpen = variantOpen.prefix
+ variantOpen.tag
+ attrs
+ additions.map((attr) => ' ' + attr.trim()).join('')
+ variantOpen.close;
return markup.slice(0, variantOpen.index) + nextOpen + markup.slice(variantOpen.index + variantOpen.raw.length);
}
function matchOpeningTag(markup) {
const match = String(markup || '').match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*?)(\/?>)/);
if (!match) return null;
return {
raw: match[0],
prefix: match[1],
tag: match[2],
attrs: match[3] || '',
close: match[4],
index: match.index || 0,
};
}
function parseAttrSegments(attrs) {
const out = new Map();
const re = /([A-Za-z_:][\w:.-]*)(?:\s*=\s*(?:"[^"]*"|'[^']*'|\{[^}]*\}|[^\s"'>=]+))?/g;
let match;
while ((match = re.exec(attrs))) {
const raw = match[0];
const name = match[1];
out.set(name, {
name,
raw,
start: match.index,
end: match.index + raw.length,
});
}
return out;
}
function mergeStaticClassAttr(originalClass, variantClass) {
const originalValue = originalClass.raw.match(/class\s*=\s*(["'])(.*?)\1/);
const variantValue = variantClass.raw.match(/class\s*=\s*(["'])(.*?)\1/);
if (!originalValue || !variantValue) return null;
const quote = variantValue[1];
const classes = [
...variantValue[2].split(/\s+/),
...originalValue[2].split(/\s+/),
].filter(Boolean);
return `class=${quote}${[...new Set(classes)].join(' ')}${quote}`;
}
export function removeSvelteComponentSession(id, cwd = process.cwd()) {
const dir = componentSessionDir(id, cwd);
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch { /* non-fatal */ }
}
export function removeAllSvelteComponentSessions(cwd = process.cwd()) {
const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
if (!fs.existsSync(root)) return;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith('__')) continue;
try {
fs.rmSync(path.join(root, entry.name), { recursive: true, force: true });
} catch { /* non-fatal */ }
}
}
export function deferredAcceptsPath(cwd = process.cwd()) {
const key = createHash('sha1').update(path.resolve(cwd)).digest('hex').slice(0, 16);
return path.join(os.tmpdir(), 'impeccable-live', key, 'deferred-svelte-component-accepts.json');
}
export function readDeferredAccepts(cwd = process.cwd()) {
const file = deferredAcceptsPath(cwd);
try {
return JSON.parse(fs.readFileSync(file, 'utf-8'));
} catch {
return { accepts: [] };
}
}
export function writeDeferredAccept(entry, cwd = process.cwd()) {
const file = deferredAcceptsPath(cwd);
fs.mkdirSync(path.dirname(file), { recursive: true });
const data = readDeferredAccepts(cwd);
data.accepts = (data.accepts || []).filter((item) => item.id !== entry.id);
data.accepts.push({ ...entry, createdAt: new Date().toISOString() });
fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n', 'utf-8');
}
export function applyDeferredSvelteComponentAccepts(cwd = process.cwd()) {
const file = deferredAcceptsPath(cwd);
const data = readDeferredAccepts(cwd);
const pending = Array.isArray(data.accepts) ? data.accepts : [];
const results = [];
const remaining = [];
for (const entry of pending) {
try {
const manifest = findSvelteComponentManifest(entry.id, cwd);
if (!manifest) {
results.push({ id: entry.id, ok: false, error: 'manifest not found' });
remaining.push(entry);
continue;
}
const result = inlineSvelteComponentAccept(
manifest,
entry.variantNum,
entry.paramValues || null,
cwd,
);
results.push({ id: entry.id, ok: result.handled !== false, result });
if (result.handled === false) remaining.push(entry);
} catch (err) {
results.push({ id: entry.id, ok: false, error: err.message });
remaining.push(entry);
}
}
if (remaining.length > 0) {
fs.writeFileSync(file, JSON.stringify({ accepts: remaining }, null, 2) + '\n', 'utf-8');
} else {
try { fs.rmSync(file, { force: true }); } catch {}
}
return { applied: results.filter((r) => r.ok).length, failed: results.filter((r) => !r.ok).length, results };
}
export function buildSvelteComponentCssAuthoring(count) {
const variantNumbers = Array.from({ length: count }, (_, i) => i + 1);
return {
mode: 'svelte-component',
styleTag: null,
strategy: 'component-style-block',
rulePattern: '.semantic-class { ... }',
selectorExamples: variantNumbers.map(() => '.expense-row { padding: 22px; }'),
requirements: [
'Write each variant as a real Svelte component file (v1.svelte, v2.svelte, ...).',
'Keep the prop names from propContract; bind dynamic text with {propName}, not literal snapshot text.',
'Put variant CSS in the component <style> block using semantic class selectors.',
'Author param-driven CSS against var(--p-<id>, default) and [data-p-<id>] using :global(...) so the runtime knob values reach the mounted root.',
'Declare params in componentDir/params.json keyed by variant number (e.g. {"1": [...], "2": [...]}), NOT as a data-impeccable-params attribute.',
'Do not use @scope or data-impeccable-variant selectors in component files.',
'Do not edit the route source file during generation; only edit files under componentDir.',
],
forbidden: [
'Do not use @scope blocks in Svelte component variants.',
'Do not copy live DOM snapshot text into markup when propContract provides bindings.',
'Do not add data-impeccable-* attributes inside component files. Svelte parses { in attribute values as an expression, so data-impeccable-params with JSON breaks the build; use componentDir/params.json instead.',
],
paramsFile: 'params.json',
};
}
@@ -0,0 +1,274 @@
/**
* SvelteKit live-mode adapter.
*
* SvelteKit must not be patched through src/app.html. That file is a document
* template, not framework-owned component chrome. The adapter keeps SvelteKit
* work limited to mounting a dev-only shadow host from +layout.svelte; the
* actual live UI remains the shared plain-DOM browser chrome.
*/
import fs from 'node:fs';
import path from 'node:path';
export const SVELTE_LIVE_ROOT_COMPONENT = 'src/lib/impeccable/ImpeccableLiveRoot.svelte';
export const SVELTE_LAYOUT_MARKER_OPEN = '<!-- impeccable-live-svelte-start -->';
export const SVELTE_LAYOUT_MARKER_CLOSE = '<!-- impeccable-live-svelte-end -->';
export const SVELTE_ROOT_IMPORT = "import ImpeccableLiveRoot from '$lib/impeccable/ImpeccableLiveRoot.svelte';";
export function detectSvelteKitProject(cwd = process.cwd(), config = null) {
const appHtml = findSvelteKitAppHtml(cwd, config);
if (!appHtml) return null;
const hasTemplateMarkers = fileIncludes(path.join(cwd, appHtml), '%sveltekit.body%')
&& fileIncludes(path.join(cwd, appHtml), '%sveltekit.head%');
if (!hasTemplateMarkers) return null;
const hasSvelteConfig = fs.existsSync(path.join(cwd, 'svelte.config.js'))
|| fs.existsSync(path.join(cwd, 'svelte.config.mjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.cjs'))
|| fs.existsSync(path.join(cwd, 'svelte.config.ts'));
const hasKitPackage = packageHasSvelteKit(cwd);
if (!hasSvelteConfig && !hasKitPackage) return null;
return {
appHtml,
layoutFile: findSvelteKitLayout(cwd),
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
};
}
export function applySvelteKitLiveAdapter({ cwd = process.cwd(), port, config = null } = {}) {
if (!Number.isFinite(Number(port))) {
throw new Error('SvelteKit live adapter requires a numeric port');
}
const detected = detectSvelteKitProject(cwd, config);
if (!detected) return null;
ensureSvelteLiveRootComponent(cwd, Number(port));
const layoutRel = detected.layoutFile;
const layoutAbs = path.join(cwd, layoutRel);
fs.mkdirSync(path.dirname(layoutAbs), { recursive: true });
const layoutExisted = fs.existsSync(layoutAbs);
const before = layoutExisted ? fs.readFileSync(layoutAbs, 'utf-8') : defaultSvelteLayout();
const after = patchSvelteLayout(before);
fs.writeFileSync(layoutAbs, after, 'utf-8');
return {
file: layoutRel,
adapter: 'sveltekit',
inserted: after !== before || !layoutExisted,
appHtmlUntouched: true,
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
};
}
export function removeSvelteKitLiveAdapter({ cwd = process.cwd(), config = null } = {}) {
const detected = detectSvelteKitProject(cwd, config);
if (!detected) return null;
const layoutAbs = path.join(cwd, detected.layoutFile);
let removed = false;
if (fs.existsSync(layoutAbs)) {
const before = fs.readFileSync(layoutAbs, 'utf-8');
const after = unpatchSvelteLayout(before);
if (after !== before) {
fs.writeFileSync(layoutAbs, after, 'utf-8');
removed = true;
}
}
const rootAbs = path.join(cwd, SVELTE_LIVE_ROOT_COMPONENT);
if (fs.existsSync(rootAbs)) {
fs.rmSync(rootAbs, { force: true });
removed = true;
}
pruneEmptyDir(path.dirname(rootAbs), path.join(cwd, 'src'));
return {
file: detected.layoutFile,
adapter: 'sveltekit',
removed,
appHtmlUntouched: true,
rootComponent: SVELTE_LIVE_ROOT_COMPONENT,
};
}
export function patchSvelteLayout(content) {
let out = String(content || '');
if (!out.includes(SVELTE_ROOT_IMPORT)) {
const scriptMatch = out.match(/<script(?:\s[^>]*)?>/i);
if (scriptMatch) {
const insertAt = scriptMatch.index + scriptMatch[0].length;
out = out.slice(0, insertAt) + '\n ' + SVELTE_ROOT_IMPORT + out.slice(insertAt);
} else {
out = `<script>\n ${SVELTE_ROOT_IMPORT}\n</script>\n\n` + out;
}
}
if (!out.includes(SVELTE_LAYOUT_MARKER_OPEN)) {
const block = `${SVELTE_LAYOUT_MARKER_OPEN}\n<ImpeccableLiveRoot />\n${SVELTE_LAYOUT_MARKER_CLOSE}\n`;
const renderMatch = out.match(/\{@render\s+children(?:\?\.)?\(\)\s*\}/);
const slotMatch = out.match(/<slot\s*\/?>/);
const match = renderMatch || slotMatch;
if (match) {
out = out.slice(0, match.index) + block + out.slice(match.index);
} else {
out = out.replace(/\s*$/, '\n\n' + block);
}
}
return out;
}
export function unpatchSvelteLayout(content) {
let out = String(content || '');
const blockRe = new RegExp(
'([ \\t]*)' + escapeRegExp(SVELTE_LAYOUT_MARKER_OPEN)
+ '\\n<ImpeccableLiveRoot\\s*/>\\n'
+ escapeRegExp(SVELTE_LAYOUT_MARKER_CLOSE)
+ '\\n?',
'g',
);
out = out.replace(blockRe, '$1');
out = out.replace(new RegExp('^\\s*' + escapeRegExp(SVELTE_ROOT_IMPORT) + '\\s*\\n?', 'gm'), '');
out = out.replace(/<script>\s*<\/script>\s*\n?/g, '');
return out.replace(/\n{3,}/g, '\n\n');
}
export function ensureSvelteLiveRootComponent(cwd, port) {
const file = path.join(cwd, SVELTE_LIVE_ROOT_COMPONENT);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, buildSvelteLiveRootComponent(port), 'utf-8');
return file;
}
export function buildSvelteLiveRootComponent(port) {
return `<script>
import { onMount } from 'svelte';
const LIVE_URL = 'http://localhost:${Number(port)}/live.js';
const HOST_ID = 'impeccable-live-root';
onMount(() => {
let host = document.querySelector('impeccable-live-root#' + HOST_ID) || document.getElementById(HOST_ID);
if (!host) {
host = document.createElement('impeccable-live-root');
host.id = HOST_ID;
document.body.appendChild(host);
}
host.dataset.impeccableLiveAdapter = 'sveltekit';
host.style.setProperty('all', 'initial', 'important');
host.style.setProperty('display', 'block', 'important');
host.style.setProperty('position', 'fixed', 'important');
host.style.setProperty('top', '0', 'important');
host.style.setProperty('left', '0', 'important');
host.style.setProperty('width', '0', 'important');
host.style.setProperty('height', '0', 'important');
host.style.setProperty('overflow', 'visible', 'important');
host.style.setProperty('z-index', '2147483000', 'important');
host.style.setProperty('pointer-events', 'none', 'important');
const root = host.shadowRoot || host.attachShadow({ mode: 'open' });
if (!root.querySelector('style[data-impeccable-live-reset]')) {
const reset = document.createElement('style');
reset.dataset.impeccableLiveReset = 'true';
reset.textContent = ':host, :host *, * { box-sizing: border-box; }';
root.appendChild(reset);
}
window.__IMPECCABLE_LIVE_ADAPTER__ = 'sveltekit';
window.__IMPECCABLE_LIVE_UI_ROOT__ = root;
window.__IMPECCABLE_LIVE_CHROME_MOUNT__ = {
adapter: 'sveltekit',
version: 1,
host,
root,
};
const script = document.createElement('script');
script.src = LIVE_URL;
script.async = true;
script.dataset.impeccableLiveScript = 'true';
document.head.appendChild(script);
return () => {
script.remove();
if (window.__IMPECCABLE_LIVE_UI_ROOT__ === root) delete window.__IMPECCABLE_LIVE_UI_ROOT__;
if (window.__IMPECCABLE_LIVE_CHROME_MOUNT__?.root === root) delete window.__IMPECCABLE_LIVE_CHROME_MOUNT__;
if (window.__IMPECCABLE_LIVE_ADAPTER__ === 'sveltekit') delete window.__IMPECCABLE_LIVE_ADAPTER__;
};
});
</script>
`;
}
function findSvelteKitAppHtml(cwd, config) {
const files = Array.isArray(config?.files) ? config.files : ['src/app.html'];
for (const rel of files) {
if (rel.includes('*')) continue;
const normalized = rel.split(path.sep).join('/');
if (!normalized.endsWith('app.html')) continue;
const abs = path.join(cwd, normalized);
if (fs.existsSync(abs)) return normalized;
}
const fallback = 'src/app.html';
return fs.existsSync(path.join(cwd, fallback)) ? fallback : null;
}
function findSvelteKitLayout(cwd) {
const candidates = [
'src/routes/+layout.svelte',
'src/routes/(app)/+layout.svelte',
];
for (const rel of candidates) {
if (fs.existsSync(path.join(cwd, rel))) return rel;
}
return 'src/routes/+layout.svelte';
}
function defaultSvelteLayout() {
return `<script>\n let { children } = $props();\n</script>\n\n{@render children?.()}\n`;
}
function packageHasSvelteKit(cwd) {
const file = path.join(cwd, 'package.json');
if (!fs.existsSync(file)) return false;
try {
const pkg = JSON.parse(fs.readFileSync(file, 'utf-8'));
const deps = {
...(pkg.dependencies || {}),
...(pkg.devDependencies || {}),
...(pkg.peerDependencies || {}),
};
return Boolean(deps['@sveltejs/kit'] || deps['@sveltejs/vite-plugin-svelte'] || deps.svelte);
} catch {
return false;
}
}
function fileIncludes(file, text) {
try {
return fs.readFileSync(file, 'utf-8').includes(text);
} catch {
return false;
}
}
function pruneEmptyDir(dir, stopDir) {
let current = dir;
while (current.startsWith(stopDir) && current !== stopDir) {
try {
if (fs.readdirSync(current).length > 0) return;
fs.rmdirSync(current);
current = path.dirname(current);
} catch {
return;
}
}
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
@@ -0,0 +1,180 @@
/**
* Framework-neutral Impeccable live chrome contract.
*
* The production browser bundle is intentionally plain DOM so Svelte, React,
* Vue, and static adapters can all mount the same chrome. This module is the
* testable contract/inventory for that bundle; live-browser.js mirrors these
* values at runtime because it is served as a standalone script.
*/
export const LIVE_CHROME_MOUNT_CONTRACT = Object.freeze([
'root',
'transport',
'state',
'actions',
]);
export const LIVE_UI_SURFACES = Object.freeze([
{
key: 'global-bottom-bar',
ids: [
'impeccable-live-global-bar',
'impeccable-live-global-bar-brand',
'impeccable-live-pick-toggle',
'impeccable-live-insert-toggle',
'impeccable-live-detect-toggle',
'impeccable-live-detect-badge',
'impeccable-live-design-toggle',
'impeccable-live-page-chat',
'impeccable-live-page-chat-input',
'impeccable-live-page-chat-voice',
],
states: ['rest', 'hover', 'focus-visible', 'pressed', 'active', 'tooltip'],
},
{
key: 'pending-copy-edit-dock',
ids: ['impeccable-live-pending-dock'],
states: ['closed', 'open', 'hover', 'pressed', 'loading', 'rollback', 'keep-fixing'],
},
{
key: 'element-selection-chrome',
ids: [
'impeccable-live-highlight',
'impeccable-live-tooltip',
'impeccable-live-bar',
'impeccable-live-selection-pill',
'impeccable-live-input',
'impeccable-live-configure-voice',
'impeccable-live-configure-bar-tooltip',
],
states: ['rest', 'hover', 'focus-visible', 'pressed', 'disabled'],
},
{
key: 'action-picker',
ids: ['impeccable-live-picker'],
states: ['closed', 'open', 'option-hover', 'option-focus'],
},
{
key: 'edit-chrome',
ids: ['impeccable-live-edit-badge'],
states: ['enabled', 'disabled', 'editing', 'cancel', 'save', 'edited-content'],
},
{
key: 'generating-row',
ids: ['impeccable-live-bar', 'impeccable-live-shader'],
states: ['action-label', 'animated-dots', 'generating', 'done'],
},
{
key: 'variant-cycling-row',
ids: ['impeccable-live-bar', 'impeccable-live-params-panel'],
states: ['variant-1', 'variant-2', 'variant-3', 'left-disabled', 'right-disabled', 'dot-click', 'accept', 'discard'],
},
{
key: 'variant-params-panel',
ids: ['impeccable-live-params-panel'],
states: ['closed', 'open-above', 'open-below', 'range', 'steps', 'toggle'],
},
{
key: 'saving-confirmed-rows',
ids: ['impeccable-live-bar'],
states: ['saving', 'applying-variant', 'confirmed'],
},
{
key: 'insert-mode-chrome',
ids: [
'impeccable-live-insert-line',
'impeccable-live-insert-placeholder',
'impeccable-live-placeholder-resize',
'impeccable-live-insert-input',
'impeccable-live-insert-voice',
'impeccable-live-insert-create',
'impeccable-live-insert-create-tooltip',
],
states: ['toggle-active', 'line', 'placeholder', 'resize', 'enabled', 'disabled', 'tooltip'],
},
{
key: 'annotation-chrome',
ids: [
'impeccable-live-annot',
'impeccable-live-annot-svg',
'impeccable-live-annot-pins',
'impeccable-live-annot-clear',
],
states: ['overlay', 'drawing', 'pin', 'pin-edit', 'clear'],
},
{
key: 'design-system-panel',
ids: ['impeccable-live-design-host'],
states: ['closed', 'open', 'tabs', 'token-tiles', 'copy'],
},
{
key: 'toasts-and-errors',
ids: ['impeccable-live-toast'],
states: ['normal', 'error', 'no-variants-mounted'],
},
{
key: 'css-isolation-boundary',
ids: ['impeccable-live-root'],
states: ['shadow-root', 'style-tags', 'hostile-css'],
},
]);
export const LIVE_UI_COMPONENT_IDS = Object.freeze([
...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids)),
]);
export function resolveLiveUiRoot(env = globalThis) {
const doc = env?.document;
const explicit = env?.__IMPECCABLE_LIVE_UI_ROOT__
|| env?.window?.__IMPECCABLE_LIVE_UI_ROOT__;
if (explicit && typeof explicit.appendChild === 'function') return explicit;
return doc?.body || null;
}
export function getLiveUiElementById(id, env = globalThis) {
const doc = env?.document;
const root = resolveLiveUiRoot(env);
if (!id) return null;
if (root?.getElementById) {
const found = root.getElementById(id);
if (found) return found;
}
if (root?.querySelector) {
const found = root.querySelector('#' + escapeCssIdent(id));
if (found) return found;
}
return doc?.getElementById?.(id) || null;
}
export function appendToLiveUiRoot(el, env = globalThis) {
const root = resolveLiveUiRoot(env);
if (!root) throw new Error('Impeccable live UI root is not available');
root.appendChild(el);
return el;
}
export function appendStyleToLiveUiRoot(styleEl, env = globalThis) {
const doc = env?.document;
const root = resolveLiveUiRoot(env);
if (root && root !== doc?.body) {
root.appendChild(styleEl);
} else {
(doc?.head || doc?.body || root).appendChild(styleEl);
}
return styleEl;
}
export function activeElementDeep(doc = globalThis.document) {
let active = doc?.activeElement || null;
while (active?.shadowRoot?.activeElement) {
active = active.shadowRoot.activeElement;
}
return active;
}
function escapeCssIdent(value) {
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
return CSS.escape(String(value));
}
return String(value).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
}
@@ -0,0 +1,36 @@
/**
* Canonical design-command vocabulary for Live Mode: each command's value, human
* label, and SVG icon. Icons stack above the chip label; strokes use currentColor
* so the icon recolors when its chip is selected.
*
* Single source of truth, consumed by:
* - skill/scripts/live/event-validation.mjs re-exports VISUAL_ACTIONS.
* - skill/scripts/live-browser.js the real picker. It is served raw and
* injected as an IIFE, so it cannot import this at runtime; live-server.mjs
* serializes LIVE_COMMANDS into window.__IMPECCABLE_VOCAB__ alongside the
* token/port, and live-browser.js builds its ICONS + ACTIONS from that.
* - site/components/LiveDemoPalette.astro the marketing demo palette (imported
* at build time).
*
* Add, rename, or reorder a verb here and all three follow.
*/
const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';
export const LIVE_COMMANDS = [
{ value: 'impeccable', label: 'Freeform', icon: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>` },
{ value: 'bolder', label: 'Bolder', icon: `<svg ${ICON_ATTRS}><rect x="6" y="12" width="4" height="7" rx="0.5"/><rect x="14" y="5" width="4" height="14" rx="0.5"/></svg>` },
{ value: 'quieter', label: 'Quieter', icon: `<svg ${ICON_ATTRS}><rect x="6" y="5" width="4" height="14" rx="0.5"/><rect x="14" y="12" width="4" height="7" rx="0.5"/></svg>` },
{ value: 'distill', label: 'Distill', icon: `<svg ${ICON_ATTRS}><path d="M4 5h16l-6 8v7l-4-2v-5z"/></svg>` },
{ value: 'polish', label: 'Polish', icon: `<svg ${ICON_ATTRS}><path d="M15 3l1 3 3 1-3 1-1 3-1-3-3-1 3-1z"/><path d="M7 13l0.6 1.8 1.8 0.6-1.8 0.6-0.6 1.8-0.6-1.8-1.8-0.6 1.8-0.6z"/></svg>` },
{ value: 'typeset', label: 'Typeset', icon: `<svg ${ICON_ATTRS}><path d="M5 6h14" stroke-width="2.6"/><path d="M5 12h9" stroke-width="1.9"/><path d="M5 18h5" stroke-width="1.3"/></svg>` },
{ value: 'colorize', label: 'Colorize', icon: `<svg ${ICON_ATTRS}><circle cx="9" cy="10" r="5"/><circle cx="15" cy="10" r="5"/><circle cx="12" cy="15" r="5"/></svg>` },
{ value: 'layout', label: 'Layout', icon: `<svg ${ICON_ATTRS}><rect x="3" y="4" width="8" height="16" rx="0.5"/><rect x="13" y="4" width="8" height="7" rx="0.5"/><rect x="13" y="13" width="8" height="7" rx="0.5"/></svg>` },
{ value: 'adapt', label: 'Adapt', icon: `<svg ${ICON_ATTRS}><rect x="2.5" y="5" width="12" height="11" rx="1"/><line x1="2.5" y1="19" x2="14.5" y2="19"/><rect x="16.5" y="8" width="5" height="11" rx="1"/></svg>` },
{ value: 'animate', label: 'Animate', icon: `<svg ${ICON_ATTRS}><path d="M3 18c4-4 6-10 10-10"/><path d="M13 8c3 0 5 5 8 10"/><circle cx="13" cy="8" r="1.6" fill="currentColor" stroke="none"/></svg>` },
{ value: 'delight', label: 'Delight', icon: `<svg ${ICON_ATTRS}><path d="M12 3l2 6 6 2-6 2-2 6-2-6-6-2 6-2z"/></svg>` },
{ value: 'overdrive', label: 'Overdrive', icon: `<svg ${ICON_ATTRS}><path d="M13 3L5 13h5l-1 8 9-12h-6z"/></svg>` },
];
// Action values accepted by the live event protocol, in palette order.
export const VISUAL_ACTIONS = LIVE_COMMANDS.map((c) => c.value);
+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.5.0",
"version": "3.7.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.5.0",
"version": "3.7.1",
"author": {
"name": "Paul Bakaus",
"email": "paul@paulbakaus.com"
+18
View File
@@ -0,0 +1,18 @@
{
"description": "Impeccable design detector: runs after Edit/Write/MultiEdit on UI files and surfaces findings as system reminders.",
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs\"",
"timeout": 5,
"statusMessage": "Checking UI changes"
}
]
}
]
}
}
+8 -16
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.5.0
version: 3.7.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
@@ -35,10 +35,7 @@ Produce ready-to-ship, production-grade code, not prototypes or starting points.
#### Typography
- Cap body line length at 6575ch.
- Hierarchy through scale + weight contrast (≥1.25 ratio between steps). Avoid flat scales.
- Cap font-family count at 3 (display + body + optional mono). More than 3 reads as indecision, not richness. One well-tuned family with weight contrast usually beats three competing typefaces.
- Don't pair fonts that are similar but not identical (two geometric sans-serifs, two humanist sans-serifs). Pair on a contrast axis (serif + sans, geometric + humanist) or use one family in multiple weights.
- No all-caps body copy. Reserve uppercase for short labels (≤4 words), section eyebrows (used sparingly per the Absolute bans), and badges. Sentences in ALL CAPS are unreadable at body sizes.
- Hero / display heading ceiling: clamp() max ≤ 6rem (~96px). Above that the page is shouting, not designing.
- Display heading letter-spacing floor: ≥ -0.04em. Anything tighter and letters touch; cramped, not "designed".
- Use `text-wrap: balance` on h1h3 for even line lengths; `text-wrap: pretty` on long prose to reduce orphans.
@@ -65,15 +62,6 @@ Produce ready-to-ship, production-grade code, not prototypes or starting points.
- Dropdowns rendered with `position: absolute` inside an `overflow: hidden` or `overflow: auto` container will be clipped. Use the native `<dialog>` / popover API, `position: fixed`, or a portal to escape the stacking context.
### Copy
- Every word earns its place. No restated headings, no intros that repeat the title.
- **No em dashes.** Use commas, colons, semicolons, periods, or parentheses. Also not `--`.
- **No aphoristic-cadence body copy as a default voice.** Don't fall into the rhythm of "serious statement, then punchy short negation" as the page's recurring voice. If three or more section copy blocks on the page land on a short rebuttal-shaped sentence, rewrite. Specific, not aphoristic.
- **No marketing buzzwords.** The streamline / empower / supercharge / leverage / unleash / transform / seamless / world-class / enterprise-grade / next-generation / cutting-edge / game-changer / mission-critical family of phrases. Pick a specific noun and a verb that describes what the product literally does.
- Button labels: verb + object. "Save changes" beats "OK"; "Delete project" beats "Yes". The label should say what will happen.
- Link text needs standalone meaning. "View pricing plans" beats "Click here"; screen readers announce links out of context.
### New projects only (when no prior work exists)
#### Color & Theme
@@ -138,7 +126,7 @@ If someone could look at this interface and say "AI made that" without doubt, it
| `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) |
Plus two management commands: `pin <command>` and `unpin <command>`, detailed below.
Plus three management commands: `pin <command>`, `unpin <command>`, and `hooks <on|off|status|...>`, detailed below.
### Routing rules
@@ -155,7 +143,7 @@ Plus two management commands: `pin <command>` and `unpin <command>`, detailed be
**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.
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**: load its reference file and follow its instructions. Everything after the command name is the target.
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.
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.
@@ -173,4 +161,8 @@ If the first word is `craft`, setup still runs first, but [reference/craft.md](r
node .claude/skills/impeccable/scripts/pin.mjs <pin|unpin> <command>
```
Valid `<command>` is any command from the table above. Report the script's result concisely. Confirm the new shortcut on success, relay stderr verbatim on error.
Valid `<command>` is any command from the table above. Report the script's result concisely. Confirm the new shortcut on success, relay stderr verbatim on error.
## Hooks
`/impeccable hooks <on|off|status|ignore-rule|ignore-file|ignore-value|reset>` manages the design detector hook for this project. The hook auto-runs the detector after direct UI file edits and surfaces findings as system reminders. Full flow is in [reference/hooks.md](reference/hooks.md); load it when the user invokes `/impeccable hooks` with any argument.
+2 -2
View File
@@ -60,7 +60,7 @@ Brand surfaces have permission for Committed, Full palette, and Drenched strateg
- Name a real reference before picking a strategy. "Klim Type Foundry #ff4500 orange drench", "Stripe purple-on-white restraint", "Liquid Death acid-green full palette", "Mailchimp yellow full palette", "Condé Nast Traveler muted navy restraint", "Vercel pure black monochrome". Unnamed ambition becomes beige.
- Palette IS voice. A calm brand and a restless brand should not share palette mechanics.
- When the strategy is Committed or Drenched, color carries the brand. Don't hedge with neutrals around the edges. Commit.
- Don't converge across projects. If the last brand surface was restrained-on-cream, this one is not.
- Don't converge across projects. Each brand surface differentiates from the last.
- When a cultural-symbol palette is the obvious pull, reach past it. Let the cultural reading come from typography, imagery, and copy, not the palette.
## Layout
@@ -74,7 +74,7 @@ Brand surfaces have permission for Committed, Full palette, and Drenched strateg
Brand surfaces lean on imagery. A restaurant, hotel, magazine, or product landing page without any imagery reads as incomplete, not as restrained. A solid-color rectangle where a hero image should go is worse than a representative stock photo.
**When the brief implies imagery (restaurants, hotels, magazines, photography, hobbyist communities, food, travel, fashion, product), you must ship imagery.** Zero images is a bug, not a design choice. "Restraint" is not an excuse. If the approved comp or brief is image-led, ship real project assets, generated raster assets, or a credible canvas/SVG/WebGL scene. Do not replace photographic, architectural, product, or place imagery with generic CSS panels, decorative diagrams, cards, bullets, or copy.
**When the brief implies imagery, you must ship imagery.** Zero images is a bug, not a design choice. "Restraint" is not an excuse. If the approved comp or brief is image-led, ship real project assets, generated raster assets, or a credible canvas/SVG/WebGL scene. Do not replace photographic, architectural, product, or place imagery with generic CSS panels, decorative diagrams, cards, bullets, or copy.
- **For greenfield work without local assets, use stock imagery.** Unsplash is the default. The URL shape is `https://images.unsplash.com/photo-{id}?auto=format&fit=crop&w=1600&q=80`. **Verify the URLs before referencing them.** If you have an image-search MCP, web-fetch tool, or browser access, use it to find real photo IDs and confirm they resolve. Guessed IDs (even ones that look real) often 404 and ship as broken-image placeholders. Without a verification path, pick fewer photos you're confident exist over more that you guessed; never substitute colored `<div>` placeholders.
- **Search for the brand's physical object**, not the generic category: "handmade pasta on a scratched wooden table" beats "Italian food"; "cypress trees above a limestone hotel facade at dusk" beats "luxury hotel".
@@ -0,0 +1,90 @@
# /impeccable hooks
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.
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.
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).
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.
## Routing
The first argument is the action. Defaults to `status`.
| Action | What it does |
|---|---|
| `status` | Print current state, shared/local config paths, ignored rules / files / values, env override. |
| `on` | Set `enabled: true` in `.impeccable/config.json`, record local hook consent as accepted, and install/repair provider hook manifests when the skill is installed. |
| `off` | Set `enabled: false` in `.impeccable/config.json`. |
| `ignore-rule <id>` | Append `<id>` to `detector.ignoreRules`; for `overused-font`, requires `--all-values`. |
| `ignore-file <glob>` | Append `<glob>` to `detector.ignoreFiles`. |
| `ignore-value <id> <value> [--shared] [--reason "..."]` | Append a rule/value suppression to shared `.impeccable/config.json`. |
| `ignore-value <id> <value> --local [--reason "..."]` | Append a private rule/value suppression to `.impeccable/config.local.json`. |
| `reset` | Delete the project config, dedup cache, and Cursor pending queue. |
## Flow
1. Resolve the action from the user's argument. If no action was given, default to `status`.
2. Invoke the admin script and pass the user's output through verbatim:
```bash
node .claude/skills/impeccable/scripts/hook-admin.mjs <action> [args...]
```
3. If `<action>` is `off`, follow up with a one-line note: "Done. New edits will not trigger the design hook in this project until you run `/impeccable hooks on`."
4. If `<action>` is `on`, follow up with: "Done. The design hook will fire after the next Edit/Write/MultiEdit on a UI file."
5. If `<action>` is `ignore-value`, `ignore-file`, or `ignore-rule`, just print the script output. The default scope is shared `.impeccable/config.json`; add `--local` only when the user explicitly asks for a private exception.
6. If `<action>` is `status`, just print the script output. Do not add commentary unless the user asked a follow-up question.
## Intentional findings
The hook itself never writes ignore config. Persist an exception only after the user explicitly confirms the flagged issue is intentional, and always go through `hook-admin.mjs`.
Prefer the narrowest exception:
- If the finding line shows an exact `ignore-value` command, run that command. This writes shared `.impeccable/config.json` by default.
- 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.
Example value-specific exception:
```bash
node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional"
```
Example intentional motion exception:
```bash
node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional"
```
Example whole-rule font exception:
```bash
node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-rule overused-font --all-values --reason "User asked to ignore overused fonts generally"
```
Example file-scoped exception:
```bash
node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-file "src/legacy/Card.tsx"
```
## 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.
- 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.
## Failure modes
- If `.impeccable/config.json` or `.impeccable/config.local.json` is unreadable or malformed, the hook ignores that file and uses the remaining valid config/defaults. `hook-admin.mjs status` will show malformed files as ignored.
- If the user asks to "disable the hook" globally, lead with `/impeccable hooks off` (persistent for this project; writes `hook.enabled: false` to config). The legacy `IMPECCABLE_HOOK_DISABLED=1` env var also works as a one-shot override that follows the shell.
+24 -3
View File
@@ -111,7 +111,9 @@ node .claude/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVE
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Load `brand.md` or `product.md` (freeform only, no action sub-command). Write all variants in one edit, then `--reply done`.
On accept/discard, `live-accept.mjs` removes the wrapper block; the anchor element is untouched.
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
For non-Svelte targets, on accept/discard, `live-accept.mjs` removes the wrapper block; the anchor element is untouched.
### Replace mode (default)
@@ -149,6 +151,25 @@ If `--text` matches multiple candidates equally well, wrap exits with `{ error:
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`; use the `propContract` prop names for dynamic text (`{propName}`), not literal snapshot strings. Put variant CSS in each component's `<style>` block with semantic class selectors (no `@scope`, no `data-impeccable-*`). Reply with `--file` set to the manifest path; the browser dynamically imports and mounts the compiled components so Svelte HMR does not reset page state while the user cycles variants. On Accept, `live-accept.mjs` inlines the accepted component back into `sourceFile` immediately after source promotion succeeds.
**Params on the Svelte component path go in a sidecar, never as an attribute.** Svelte parses `{` inside an attribute value as the start of an expression, so a `data-impeccable-params='[{…}]'` attribute on a component element fails to compile (`Expected token }`). Declare params for this path in `componentDir/params.json`, keyed by variant number, using the exact param schema from section 7:
```json
{
"1": [
{"id":"density","kind":"steps","default":"snug","label":"Density","options":[
{"value":"airy","label":"Airy"},{"value":"snug","label":"Snug"},{"value":"packed","label":"Packed"}
]}
],
"2": [
{"id":"accent","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Accent"}
]
}
```
Author the component `<style>` against `var(--p-<id>, default)` for `range`/`toggle` and `[data-p-<id>="…"]` for `steps`; wrap those selectors in `:global(...)` so the knob values the runtime sets on the mounted root reach your rules. The browser reads `params.json`, docks the panel, and drives `--p-*` / `data-p-*` on the mounted component exactly as it does for the HTML/JSX path.
`styleMode` controls how preview CSS must be authored. Treat it as a detected capability mode, not a framework guess:
- `scoped`: use `@scope ([data-impeccable-variant="N"])` rules.
@@ -340,7 +361,7 @@ Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement
**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it.
**How to declare.** Put a JSON manifest on the variant wrapper:
**How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the Svelte `svelte-component` path, do not use this attribute** (Svelte can't compile `{` inside an attribute value). Declare params in `componentDir/params.json` keyed by variant number instead (see the Svelte component paragraph in the wrap section). The param schema below is identical for both paths.
```html
<div data-impeccable-variant="1" data-impeccable-params='[
@@ -454,7 +475,7 @@ Do these five steps in the current thread, synchronously, before the next poll.
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values; read it first; it drives steps 3 and 4 below.
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `site/styles/workflow.css` for an Astro project, or the component's co-located CSS file for a Vite/Next project; pick whichever already owns styling for the surrounding element).
3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value.
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it; those are live-mode plumbing, not source.
4. **Unwrap the accepted content.** Delete the inner `<div data-impeccable-variant="N" style="display: contents">` that wraps it. On JSX/TSX, also delete the outer `<div data-impeccable-carbonize="SESSION_ID" style={{ display: 'contents' }}>` wrapper if present (accept adds it so ternary/`return` slots keep a single root). Drop `data-impeccable-params` and any `data-p-*` attributes; those are live-mode plumbing, not source.
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now.
After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again.
@@ -1,284 +0,0 @@
#!/usr/bin/env node
/**
* Cleans up deprecated Impeccable skill files, symlinks, and
* skills-lock.json entries left over from previous versions.
*
* Safe to run repeatedly -- it is a no-op when nothing needs cleaning.
*
* Usage (from the project root):
* node {{scripts_path}}/cleanup-deprecated.mjs
*
* What it does:
* 1. Finds every harness-specific skills directory (.claude/skills,
* .cursor/skills, .agents/skills, etc.).
* 2. For each deprecated skill name (with and without i- prefix),
* checks if the directory exists and its SKILL.md mentions
* "impeccable" (to avoid deleting unrelated user skills).
* 3. Deletes confirmed matches (files, directories, or symlinks).
* 4. Removes the corresponding entries from skills-lock.json.
*/
import { existsSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, unlinkSync } from 'node:fs';
import { join, resolve } from 'node:path';
// Skills that were renamed, merged, or folded in v2.0, v2.1, and v3.0.
const DEPRECATED_NAMES = [
// v2.0 renames
'frontend-design', // renamed to impeccable
'teach-impeccable', // folded into /impeccable init
// v2.1 merges
'arrange', // renamed to layout
'normalize', // merged into polish
'onboard', // merged into harden
'extract', // merged into /impeccable extract
// v3.0 consolidation: all standalone skills -> /impeccable sub-commands
'adapt',
'animate',
'audit',
'bolder',
'clarify',
'colorize',
'critique',
'delight',
'distill',
'harden',
'layout',
'optimize',
'overdrive',
'polish',
'quieter',
'shape',
'typeset',
];
// All known harness directories that may contain a skills/ subfolder.
const HARNESS_DIRS = [
'.claude', '.cursor', '.gemini', '.codex', '.agents',
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev',
];
// Per-skill fingerprints for SKILL.md bodies that never mentioned
// "impeccable" in their v2.x source. Used as a last-resort match
// when no skills-lock.json exists and the word heuristic fails.
// The strings are lifted verbatim from the v2.x frontmatter
// descriptions, so collisions with hand-written user skills are
// vanishingly unlikely.
const SKILL_FINGERPRINTS = {
harden: 'Make interfaces production-ready: error handling, empty states',
optimize: 'Diagnoses and fixes UI performance across loading speed',
};
/**
* Walk up from startDir until we find a directory that looks like a
* project root (has package.json, .git, or skills-lock.json).
*/
export function findProjectRoot(startDir = process.cwd()) {
let dir = resolve(startDir);
const { root } = { root: '/' };
while (dir !== root) {
if (
existsSync(join(dir, 'package.json')) ||
existsSync(join(dir, '.git')) ||
existsSync(join(dir, 'skills-lock.json'))
) {
return dir;
}
const parent = resolve(dir, '..');
if (parent === dir) break;
dir = parent;
}
return resolve(startDir);
}
/**
* Load skills-lock.json from the project root, or null if missing/unreadable.
*/
export function loadLock(projectRoot) {
const lockPath = join(projectRoot, 'skills-lock.json');
if (!existsSync(lockPath)) return null;
try {
return JSON.parse(readFileSync(lockPath, 'utf-8'));
} catch {
return null;
}
}
/**
* Check whether a skill directory belongs to Impeccable. Three layered
* signals, in order of reliability:
* 1. Lock source equals "pbakaus/impeccable" (authoritative).
* 2. SKILL.md body contains the word "impeccable".
* 3. SKILL.md body contains a per-skill fingerprint (for harden and
* optimize, whose v2.x SKILL.md never mentioned the pack name).
*/
export function isImpeccableSkill(skillDir, { skillName, lock } = {}) {
// 1. Authoritative: the lock file claims this skill is ours.
if (skillName && lock?.skills?.[skillName]?.source === 'pbakaus/impeccable') {
return true;
}
const skillMd = join(skillDir, 'SKILL.md');
if (!existsSync(skillMd)) return false;
let content;
try {
content = readFileSync(skillMd, 'utf-8');
} catch {
return false;
}
// 2. Word-level content heuristic.
if (/impeccable/i.test(content)) return true;
// 3. Per-skill fingerprint for old skills that never mentioned the pack.
// Strip the i- prefix so both `harden` and `i-harden` resolve to the
// same fingerprint entry.
const unprefixed = skillName?.startsWith('i-') ? skillName.slice(2) : skillName;
const fingerprint = unprefixed && SKILL_FINGERPRINTS[unprefixed];
if (fingerprint && content.includes(fingerprint)) return true;
return false;
}
/**
* Build the full list of names to check: each deprecated name, plus
* its i-prefixed variant.
*/
export function buildTargetNames() {
const names = [];
for (const name of DEPRECATED_NAMES) {
names.push(name);
names.push(`i-${name}`);
}
return names;
}
/**
* Find every skills directory across all harness dirs in the project.
* Returns absolute paths that exist on disk.
*/
export function findSkillsDirs(projectRoot) {
const dirs = [];
for (const harness of HARNESS_DIRS) {
const candidate = join(projectRoot, harness, 'skills');
if (existsSync(candidate)) {
dirs.push(candidate);
}
}
return dirs;
}
/**
* Remove deprecated skill directories/symlinks from all harness dirs.
* Reads skills-lock.json so the authoritative "source" field can
* drive deletion even when SKILL.md never mentions impeccable.
* Returns an array of paths that were deleted.
*/
export function removeDeprecatedSkills(projectRoot, lock) {
if (lock === undefined) lock = loadLock(projectRoot);
const targets = buildTargetNames();
const skillsDirs = findSkillsDirs(projectRoot);
const deleted = [];
for (const skillsDir of skillsDirs) {
for (const name of targets) {
const skillPath = join(skillsDir, name);
// Use lstat to detect symlinks (existsSync follows symlinks and
// returns false for dangling ones).
let stat;
try {
stat = lstatSync(skillPath);
} catch {
continue; // does not exist at all
}
if (stat.isSymbolicLink()) {
// Symlink: check the target if it's alive, otherwise treat
// dangling symlinks to deprecated names as safe to remove.
const targetAlive = existsSync(skillPath);
const isMatch = targetAlive
? isImpeccableSkill(skillPath, { skillName: name, lock })
: true;
if (isMatch) {
unlinkSync(skillPath);
deleted.push(skillPath);
}
continue;
}
// Regular directory -- verify it belongs to impeccable
if (isImpeccableSkill(skillPath, { skillName: name, lock })) {
rmSync(skillPath, { recursive: true, force: true });
deleted.push(skillPath);
}
}
}
return deleted;
}
/**
* Remove deprecated entries from skills-lock.json.
* Only removes entries whose source is "pbakaus/impeccable".
* Returns the list of removed skill names.
*/
export function cleanSkillsLock(projectRoot) {
const lockPath = join(projectRoot, 'skills-lock.json');
if (!existsSync(lockPath)) return [];
let lock;
try {
lock = JSON.parse(readFileSync(lockPath, 'utf-8'));
} catch {
return [];
}
if (!lock.skills || typeof lock.skills !== 'object') return [];
const targets = buildTargetNames();
const removed = [];
for (const name of targets) {
const entry = lock.skills[name];
if (!entry) continue;
// Only remove if it belongs to impeccable
if (entry.source === 'pbakaus/impeccable') {
delete lock.skills[name];
removed.push(name);
}
}
if (removed.length > 0) {
writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n', 'utf-8');
}
return removed;
}
/**
* Run the full cleanup. Returns a summary object.
*
* Order matters: read the lock and delete directories first, then
* strip lock entries. Otherwise the authoritative signal is gone by
* the time directory deletion runs.
*/
export function cleanup(projectRoot) {
const root = projectRoot || findProjectRoot();
const lock = loadLock(root);
const deletedPaths = removeDeprecatedSkills(root, lock);
const removedLockEntries = cleanSkillsLock(root);
return { deletedPaths, removedLockEntries, projectRoot: root };
}
// CLI entry point
if (process.argv[1] && resolve(process.argv[1]) === resolve(new URL(import.meta.url).pathname)) {
const result = cleanup();
if (result.deletedPaths.length === 0 && result.removedLockEntries.length === 0) {
console.log('No deprecated Impeccable skills found. Nothing to clean up.');
} else {
if (result.deletedPaths.length > 0) {
console.log(`Removed ${result.deletedPaths.length} deprecated skill(s):`);
for (const p of result.deletedPaths) console.log(` - ${p}`);
}
if (result.removedLockEntries.length > 0) {
console.log(`Cleaned ${result.removedLockEntries.length} entry/entries from skills-lock.json:`);
for (const name of result.removedLockEntries) console.log(` - ${name}`);
}
}
}
@@ -22,7 +22,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { loadContext, extractRegister } from './context.mjs';
import { getCritiqueDir } from './impeccable-paths.mjs';
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
/** Is there code here at all, or just context files / an empty repo? */
function hasCode(cwd) {
+17 -3
View File
@@ -27,7 +27,7 @@ const FALLBACK_DIRS = ['.agents/context', 'docs'];
// ─── Update check ──────────────────────────────────────────────────────────
// Piggyback a lightweight skill-version check on the once-per-session boot.
// When a newer skill ships, append an UPDATE_AVAILABLE directive so the agent
// can offer `npx impeccable skills update`. Everything here is best-effort and
// can offer `npx impeccable update`. Everything here is best-effort and
// silent on failure: a network problem, sandbox, or missing cache must never
// block context output or print an error.
@@ -172,8 +172,8 @@ function buildUpdateDirective(localVersion, latestVersion) {
`UPDATE_AVAILABLE: A newer Impeccable skill is available ` +
`(installed v${localVersion}, latest v${latestVersion}). ` +
`Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` +
`Update now? It runs \`npx impeccable skills update\`." ` +
`If they agree, run \`npx impeccable skills update\` (the update applies to the next session, not this one). ` +
`Update now? It runs \`npx impeccable update\`." ` +
`If they agree, run \`npx impeccable update\` (the update applies to the next session, not this one). ` +
`Either way, continue the current task without waiting, and do not raise this again.`
);
}
@@ -184,9 +184,23 @@ function buildUpdateDirective(localVersion, latestVersion) {
* the user's home dir) and re-surfaces a given version at most once per week so
* the agent never nags. Opt out entirely with IMPECCABLE_NO_UPDATE_CHECK=1.
*/
// Read the unified config's top-level `updateCheck` (local overrides shared).
// Inlined rather than importing hook-lib so the boot path stays lightweight.
function updateCheckDisabledByConfig(cwd = process.cwd()) {
let value;
for (const name of ['config.json', 'config.local.json']) {
try {
const raw = JSON.parse(fs.readFileSync(path.join(cwd, '.impeccable', name), 'utf-8'));
if (raw && typeof raw === 'object' && typeof raw.updateCheck === 'boolean') value = raw.updateCheck;
} catch { /* missing or malformed: ignore */ }
}
return value === false;
}
async function computeUpdateDirective(now = Date.now()) {
try {
if (process.env.IMPECCABLE_NO_UPDATE_CHECK) return null;
if (updateCheckDisabledByConfig()) return null;
const localVersion = readLocalSkillVersion();
if (!localVersion) return null;
@@ -28,7 +28,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { getCritiqueDir } from './impeccable-paths.mjs';
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
const SLUG_MAX = 50;
@@ -660,6 +660,7 @@ if (IS_BROWSER) {
if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
if (el.closest('[id^="impeccable-live-"]')) continue;
if (el === document.body || el === document.documentElement) continue;
if (!isRenderedForBrowserRule(el)) continue;
const tag = el.tagName.toLowerCase();
const style = getComputedStyle(el);
@@ -1091,6 +1092,7 @@ if (IS_BROWSER) {
return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' };
}
if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' };
if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' };
const blockingReason = (candidate.reasons || []).find(reason =>
reason === 'background-clip text' ||
@@ -1222,6 +1224,7 @@ if (IS_BROWSER) {
category: ap ? ap.category : 'quality',
severity: ap?.severity || 'warning',
detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id),
description: ap ? ap.description : '',
};
@@ -1258,10 +1261,203 @@ if (IS_BROWSER) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
}
const DESIGN_COLOR_TOLERANCE = 6;
const DESIGN_RADIUS_TOLERANCE_PX = 0.5;
const DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']);
function normalizeBrowserFontName(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
function browserPrimaryFont(stack) {
if (!stack || /var\(/i.test(stack)) return '';
return String(stack || '')
.split(',')
.map(normalizeBrowserFontName)
.find(font => font && !GENERIC_FONTS.has(font)) || '';
}
function browserDesignSystemConfig() {
const raw = window.__IMPECCABLE_CONFIG__?.designSystem;
if (!raw?.present) return null;
const allowedFonts = new Set((raw.allowedFonts || []).map(normalizeBrowserFontName).filter(Boolean));
const allowedColors = (raw.allowedColors || [])
.filter(color => color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b))
.map(color => ({ r: color.r, g: color.g, b: color.b }));
const allowedRadii = (raw.allowedRadii || [])
.map(Number)
.filter(px => Number.isFinite(px));
return {
present: true,
hasFonts: raw.hasFonts === true && allowedFonts.size > 0,
allowedFonts,
hasColors: raw.hasColors === true && allowedColors.length > 0,
allowedColors,
hasRadii: raw.hasRadii === true && allowedRadii.length > 0,
allowedRadii,
hasPillRadius: raw.hasPillRadius === true,
};
}
function browserColorsClose(a, b) {
if (!a || !b) return false;
return Math.max(
Math.abs(a.r - b.r),
Math.abs(a.g - b.g),
Math.abs(a.b - b.b),
) <= DESIGN_COLOR_TOLERANCE;
}
function isBrowserDesignColorAllowed(raw, designSystem) {
if (!designSystem?.hasColors) return true;
const text = String(raw || '').trim().toLowerCase();
if (!text || text === 'transparent' || text === 'currentcolor' || text === 'inherit' || text === 'initial') return true;
if (text.includes('var(')) return true;
const parsed = parseAnyColor(text);
if (!parsed) return true;
if ((parsed.a ?? 1) <= 0.05) return true;
return designSystem.allowedColors.some(color => browserColorsClose(parsed, color));
}
function isBrowserTransparentCss(value) {
const text = String(value || '').trim().toLowerCase();
if (!text || text === 'transparent') return true;
const parsed = parseAnyColor(text);
return parsed ? (parsed.a ?? 1) <= 0.05 : false;
}
function isBrowserDesignRadiusAllowed(raw, designSystem) {
if (!designSystem?.hasRadii) return true;
const text = String(raw || '').trim().toLowerCase();
if (!text || text === '0' || text === 'none' || text === 'initial' || text === 'inherit') return true;
if (text.includes('var(') || text.includes('%')) return true;
const px = resolveLengthPx(text, 16);
if (px == null || !Number.isFinite(px) || px <= DESIGN_RADIUS_TOLERANCE_PX) return true;
if (designSystem.hasPillRadius && px >= 99) return true;
return designSystem.allowedRadii.some(allowed => Math.abs(allowed - px) <= DESIGN_RADIUS_TOLERANCE_PX);
}
function browserRadiusTokens(value) {
return String(value || '')
.replace(/\s*\/\s*/g, ' ')
.split(/\s+/)
.map(token => token.trim())
.filter(Boolean);
}
function browserHasDirectText(el) {
return [...(el.childNodes || [])].some(node => node.nodeType === 3 && node.textContent.trim().length > 0);
}
function browserSampleText(el) {
const text = String(el.textContent || '').replace(/\s+/g, ' ').trim();
return text ? ` "${text.slice(0, 40)}"` : '';
}
function shouldSkipDesignElement(el) {
const tag = el.tagName?.toLowerCase?.() || '';
return DESIGN_SKIP_TAGS.has(tag) || isElementHidden(el);
}
function checkElementDesignSystemDOM(el, designSystem, seen) {
if (!designSystem?.present || shouldSkipDesignElement(el)) return [];
const findings = [];
const tag = el.tagName?.toLowerCase?.() || 'unknown';
const style = getComputedStyle(el);
if (designSystem.hasFonts && browserHasDirectText(el)) {
const font = browserPrimaryFont(style.fontFamily || '');
if (font && !designSystem.allowedFonts.has(font) && !seen.fonts.has(font)) {
seen.fonts.add(font);
findings.push({
type: 'design-system-font',
detail: `${tag}${browserSampleText(el)} uses ${font}; not declared in DESIGN.md typography`,
ignoreValue: font,
});
}
}
if (designSystem.hasColors) {
const colorChecks = [];
if (browserHasDirectText(el)) colorChecks.push(['text color', style.color]);
if (!isBrowserTransparentCss(style.backgroundColor)) colorChecks.push(['background', style.backgroundColor]);
for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
if ((parseFloat(style[`border${side}Width`]) || 0) > 0) {
colorChecks.push([`border-${side.toLowerCase()}`, style[`border${side}Color`]]);
}
}
if ((parseFloat(style.outlineWidth) || 0) > 0) colorChecks.push(['outline', style.outlineColor]);
for (const [kind, raw] of colorChecks) {
const label = String(raw || '').trim().replace(/\s+/g, ' ');
if (isBrowserDesignColorAllowed(label, designSystem)) continue;
const key = `${kind}:${label}`;
if (seen.colors.has(key)) continue;
seen.colors.add(key);
findings.push({
type: 'design-system-color',
detail: `${kind} ${label} on ${tag}${browserSampleText(el)} is outside DESIGN.md colors`,
ignoreValue: label,
});
}
}
if (designSystem.hasRadii) {
for (const token of browserRadiusTokens(style.borderRadius || '')) {
if (isBrowserDesignRadiusAllowed(token, designSystem)) continue;
if (seen.radii.has(token)) continue;
seen.radii.add(token);
findings.push({
type: 'design-system-radius',
detail: `border-radius ${token} on ${tag}${browserSampleText(el)} is outside the DESIGN.md rounded scale`,
ignoreValue: token,
});
}
}
return findings;
}
function decodeBrowserGoogleFamily(value) {
const family = String(value || '').split(':')[0].replace(/\+/g, ' ');
try {
return decodeURIComponent(family);
} catch {
return family;
}
}
function checkBrowserDesignSystemSources(designSystem, seen) {
if (!designSystem?.hasFonts) return [];
const findings = [];
for (const link of document.querySelectorAll('link[href*="fonts.googleapis.com/css"]')) {
const href = link.getAttribute('href') || '';
for (const match of href.matchAll(/[?&]family=([^&]+)/g)) {
const display = decodeBrowserGoogleFamily(match[1]);
const font = normalizeBrowserFontName(display);
if (!font || designSystem.allowedFonts.has(font) || seen.fonts.has(font)) continue;
seen.fonts.add(font);
findings.push({
type: 'design-system-font',
detail: `Google Fonts: ${display} is not declared in DESIGN.md typography`,
ignoreValue: display,
});
}
}
return findings;
}
function collectBrowserFindings() {
const groupMap = new Map();
const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : [];
const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id);
const designSystem = browserDesignSystemConfig();
const designSeen = { fonts: new Set(), colors: new Set(), radii: new Set() };
// Note: provider-gated rules (--gpt / --gemini) are NOT filtered here. In a
// real browser env (detector page, live overlay, extension) running every
// check is free, so we always surface them; the gating is purely a CLI
@@ -1292,6 +1488,7 @@ if (IS_BROWSER) {
...checkElementClippedOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementGptBorderShadowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementTextOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementDesignSystemDOM(el, designSystem, designSeen),
].filter(f => _ruleOk(f.type));
addBrowserFindings(groupMap, el, findings);
@@ -1308,6 +1505,13 @@ if (IS_BROWSER) {
const pageLevelFindings = [];
const designSourceFindings = checkBrowserDesignSystemSources(designSystem, designSeen)
.filter(f => _ruleOk(f.type));
if (designSourceFindings.length > 0) {
pageLevelFindings.push(...designSourceFindings);
addBrowserFindings(groupMap, document.body, designSourceFindings);
}
const typoFindings = checkTypography().filter(f => _ruleOk(f.type));
if (typoFindings.length > 0) {
pageLevelFindings.push(...typoFindings);
@@ -1437,13 +1641,20 @@ if (IS_BROWSER) {
return true;
}
function postSerializedFindings(groupMap) {
function scanResultMeta(options = {}) {
const scanId = options.scanId;
if (typeof scanId !== 'string' && typeof scanId !== 'number') return {};
return { scanId: String(scanId) };
}
function postSerializedFindings(groupMap, options = {}) {
if (!EXTENSION_MODE) return;
const allFindings = browserFindingsFromMap(groupMap);
window.postMessage({
source: 'impeccable-results',
findings: serializeFindings(allFindings),
count: allFindings.length,
...scanResultMeta(options),
}, '*');
}
@@ -1497,7 +1708,7 @@ if (IS_BROWSER) {
rememberVisualContrastAnalysis(result);
const added = addVisualContrastResult(groupMap, result, { decorate: true });
if (added) {
postSerializedFindings(groupMap);
postSerializedFindings(groupMap, options);
window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-resolved', {
detail: {
selector: result.selector,
@@ -1565,7 +1776,7 @@ if (IS_BROWSER) {
overlayIndex = 0;
}
function renderBrowserFindings(collected) {
function renderBrowserFindings(collected, options = {}) {
const { allFindings, pageLevelFindings } = collected;
for (const { el, findings } of allFindings) {
@@ -1585,6 +1796,7 @@ if (IS_BROWSER) {
source: 'impeccable-results',
findings: serializeFindings(allFindings),
count: allFindings.length,
...scanResultMeta(options),
}, '*');
}
@@ -1599,11 +1811,11 @@ if (IS_BROWSER) {
clearOverlays();
const generation = scanGeneration;
const collected = collectBrowserFindings();
const allFindings = renderBrowserFindings(collected);
const allFindings = renderBrowserFindings(collected, options);
if (shouldRunVisualContrast(options)) {
addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation })
.then(() => {
if (generation === scanGeneration) postSerializedFindings(collected.groupMap);
if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options);
})
.catch(err => {
reportVisualContrastError(err);
@@ -1618,10 +1830,10 @@ if (IS_BROWSER) {
if (shouldRunVisualContrast(options)) {
const collected = await collectBrowserFindingsAsync(options, { generation, scheduleLazy: true });
if (generation !== scanGeneration) return [];
return renderBrowserFindings(collected);
return renderBrowserFindings(collected, options);
}
lastVisualContrastAnalyses = [];
return renderBrowserFindings(collectBrowserFindings());
return renderBrowserFindings(collectBrowserFindings(), options);
};
const detect = function(options = {}) {
@@ -1,9 +1,15 @@
import fs from 'node:fs';
import path from 'node:path';
import { loadDesignSystemForCwd } from '../design-system.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';
import {
filterDetectionFindings,
readDetectionConfig,
shouldIgnoreDetectionFile,
} from '../../lib/impeccable-config.mjs';
import {
HTML_EXTENSIONS,
buildImportGraph,
@@ -79,10 +85,17 @@ function printUsage() {
Scan files or URLs for UI anti-patterns and design quality issues.
Options:
--json Output results as JSON
--gpt Also report GPT-specific provider tells (off by default)
--gemini Also report Gemini-specific provider tells (off by default)
--help Show this help message
--json Output results as JSON
--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
--no-design-system Do not load local DESIGN.md / .impeccable/design.json context
--help Show this help message
Project config:
Respects .impeccable/config.json and .impeccable/config.local.json detector
settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues,
and detector.designSystem.enabled.
Detection modes:
HTML files Static HTML/CSS analysis (default, catches linked CSS)
@@ -93,7 +106,8 @@ Examples:
impeccable detect src/
impeccable detect index.html
impeccable detect https://example.com
impeccable detect --json .`);
impeccable detect --json .
impeccable detect --no-config src/`);
}
async function detectCli() {
@@ -114,10 +128,16 @@ async function detectCli() {
'Note: --fast is deprecated and ignored. The full scan is fast now and runs every rule.\n',
);
}
const configEnabled = !args.includes('--no-config');
const detectionConfig = configEnabled
? readDetectionConfig(process.cwd())
: { ignoreRules: [], ignoreFiles: [], ignoreValues: [] };
const providers = [];
if (args.includes('--gpt')) providers.push('gpt');
if (args.includes('--gemini')) providers.push('gemini');
const scanOptions = { providers };
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 };
const targets = args.filter(a => !a.startsWith('--'));
if (helpMode) { printUsage(); process.exit(0); }
@@ -175,7 +195,8 @@ async function detectCli() {
}
}
const files = walkDir(resolved);
const files = walkDir(resolved)
.filter(file => !shouldIgnoreDetectionFile(file, process.cwd(), detectionConfig));
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)
@@ -219,6 +240,7 @@ async function detectCli() {
allFindings.push(...fileFindings);
}
} else if (stat.isFile()) {
if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue;
const ext = path.extname(resolved).toLowerCase();
if (HTML_EXTENSIONS.has(ext)) {
allFindings.push(...await detectHtml(resolved, scanOptions));
@@ -232,6 +254,8 @@ async function detectCli() {
}
}
allFindings = filterDetectionFindings(allFindings, detectionConfig);
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
else process.stderr.write(formatFindings(allFindings, false) + '\n');
@@ -0,0 +1,750 @@
import fs from 'node:fs';
import path from 'node:path';
import { finding } from './findings.mjs';
import { GENERIC_FONTS } from './shared/constants.mjs';
import { parseAnyColor, resolveLengthPx } from './rules/checks.mjs';
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 CSS_COLOR_RE = /#[0-9a-f]{3,8}\b|rgba?\([^)]+\)|oklch\([^)]+\)|hsla?\([^)]+\)/gi;
const FONT_DECL_RE = /font-family\s*:\s*([^;}\n]+)/gi;
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 STATIC_DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']);
function firstExisting(dir, names) {
for (const name of names) {
const abs = path.join(dir, name);
if (fs.existsSync(abs)) return abs;
}
return null;
}
function resolveDesignMdPath(cwd = process.cwd()) {
const root = firstExisting(cwd, DESIGN_NAMES);
if (root) return { path: root, contextDir: cwd };
for (const rel of FALLBACK_DIRS) {
const dir = path.resolve(cwd, rel);
const found = firstExisting(dir, DESIGN_NAMES);
if (found) return { path: found, contextDir: dir };
}
return null;
}
function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
const candidates = [
path.join(cwd, '.impeccable', 'design.json'),
path.join(cwd, 'DESIGN.json'),
path.join(contextDir, 'DESIGN.json'),
];
return candidates.find((candidate, index) =>
candidates.indexOf(candidate) === index && fs.existsSync(candidate)
) || null;
}
function parseFrontmatter(md) {
const lines = String(md || '').split(/\r?\n/);
if (lines[0]?.trim() !== '---') return null;
let end = -1;
for (let i = 1; i < lines.length; i++) {
if (lines[i].trim() === '---') { end = i; break; }
}
if (end === -1) return null;
try {
return parseYamlSubset(lines.slice(1, end).join('\n'));
} catch {
return null;
}
}
function parseYamlSubset(yaml) {
const root = {};
const stack = [{ indent: -1, obj: root }];
for (const raw of String(yaml || '').split(/\r?\n/)) {
if (!raw.trim() || /^\s*#/.test(raw)) continue;
const indent = raw.match(/^\s*/)[0].length;
const content = raw.slice(indent);
const colonIdx = findTopLevelColon(content);
if (colonIdx === -1) continue;
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) stack.pop();
const key = unquoteYamlKey(content.slice(0, colonIdx).trim());
const rest = stripInlineYamlComment(content.slice(colonIdx + 1).trim());
const parent = stack[stack.length - 1].obj;
if (rest === '') {
const obj = {};
parent[key] = obj;
stack.push({ indent, obj });
} else {
parent[key] = parseScalar(rest);
}
}
return root;
}
function findTopLevelColon(s) {
let inQuote = null;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (inQuote) {
if (ch === inQuote && s[i - 1] !== '\\') inQuote = null;
} else if (ch === '"' || ch === "'") {
inQuote = ch;
} else if (ch === ':') {
return i;
}
}
return -1;
}
function unquoteYamlKey(key) {
if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) {
return key.slice(1, -1);
}
return key;
}
function stripInlineYamlComment(s) {
let inQuote = null;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (inQuote) {
if (ch === inQuote && s[i - 1] !== '\\') inQuote = null;
} else if (ch === '"' || ch === "'") {
inQuote = ch;
} else if (ch === '#' && i > 0 && /\s/.test(s[i - 1])) {
return s.slice(0, i).trimEnd();
}
}
return s;
}
function parseScalar(raw) {
const s = raw.trim();
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
return s.slice(1, -1);
}
if (s === 'true') return true;
if (s === 'false') return false;
if (s === 'null' || s === '~') return null;
if (/^-?\d+$/.test(s)) return Number(s);
if (/^-?\d*\.\d+$/.test(s)) return Number(s);
return s;
}
function safeReadJson(filePath) {
if (!filePath) return null;
try {
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch {
return null;
}
}
function normalizeFontName(value) {
return String(value || '')
.trim()
.replace(/\s*!important\s*$/i, '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
function splitFontStack(stack) {
return String(stack || '')
.replace(/\s*!important\s*$/i, '')
.split(',')
.map(normalizeFontName)
.filter(Boolean);
}
function primaryFont(stack) {
if (!stack || /var\(/i.test(stack) || !isLiteralFontStack(stack)) return '';
return splitFontStack(stack).find(font => !GENERIC_FONTS.has(font)) || '';
}
function isLiteralFontStack(stack) {
const text = String(stack || '');
return !/[$`{}]|\s\+\s|\|\|/.test(text);
}
function cssColorLabel(raw) {
return String(raw || '').trim().replace(/\s+/g, ' ');
}
function colorKey(color) {
if (!color) return '';
return `${color.r},${color.g},${color.b}`;
}
function colorsClose(a, b) {
if (!a || !b) return false;
return Math.max(
Math.abs(a.r - b.r),
Math.abs(a.g - b.g),
Math.abs(a.b - b.b),
) <= COLOR_CHANNEL_TOLERANCE;
}
function hslToRgb(H, S, L, alpha = 1) {
const h = (((H % 360) + 360) % 360) / 360;
const s = Math.max(0, Math.min(1, S));
const l = Math.max(0, Math.min(1, L));
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
return {
r: Math.round(hue2rgb(p, q, h + 1 / 3) * 255),
g: Math.round(hue2rgb(p, q, h) * 255),
b: Math.round(hue2rgb(p, q, h - 1 / 3) * 255),
a: alpha,
};
}
function parseDesignColor(value) {
const text = String(value || '').trim();
const parsed = parseAnyColor(text);
if (parsed) return parsed;
const hsl = text.match(/hsla?\(\s*([-\d.]+)(?:deg)?\s*,?\s*([\d.]+)%\s*,?\s*([\d.]+)%(?:\s*[,/]\s*([\d.]+))?\s*\)/i);
if (hsl) {
return hslToRgb(
parseFloat(hsl[1]),
parseFloat(hsl[2]) / 100,
parseFloat(hsl[3]) / 100,
hsl[4] !== undefined ? parseFloat(hsl[4]) : 1,
);
}
return null;
}
function addDesignColor(out, value, label) {
const parsed = parseDesignColor(value);
if (!parsed) return;
const key = colorKey(parsed);
if (!out.allowedColorKeys.has(key)) {
out.allowedColorKeys.set(key, { color: parsed, labels: [] });
}
out.allowedColorKeys.get(key).labels.push(label || cssColorLabel(value));
}
function addColorObject(out, colors, prefix = 'colors') {
if (!colors || typeof colors !== 'object') return;
for (const [name, value] of Object.entries(colors)) {
if (typeof value === 'string') {
addDesignColor(out, value, `${prefix}.${name}`);
}
}
}
function addSidecarColors(out, sidecar) {
const colorMeta = sidecar?.extensions?.colorMeta;
if (!colorMeta || typeof colorMeta !== 'object') return;
for (const [name, meta] of Object.entries(colorMeta)) {
if (!meta || typeof meta !== 'object') continue;
if (typeof meta.canonical === 'string') addDesignColor(out, meta.canonical, `sidecar.${name}`);
if (Array.isArray(meta.tonalRamp)) {
for (const [index, value] of meta.tonalRamp.entries()) {
if (typeof value === 'string') addDesignColor(out, value, `sidecar.${name}.tonalRamp[${index}]`);
}
}
}
}
function addTypographyFonts(out, typography) {
if (!typography || typeof typography !== 'object') return;
for (const role of Object.values(typography)) {
if (!role || typeof role !== 'object') continue;
if (typeof role.fontFamily !== 'string') continue;
for (const font of splitFontStack(role.fontFamily)) {
if (!GENERIC_FONTS.has(font)) out.allowedFonts.add(font);
}
}
}
function addRoundedScale(out, rounded) {
if (!rounded || typeof rounded !== 'object') return;
for (const [rawName, value] of Object.entries(rounded)) {
const name = unquoteYamlKey(rawName).toLowerCase();
addRoundedToken(out, name, value);
}
}
function addRoundedToken(out, name, value) {
if (typeof value !== 'string' && typeof value !== 'number') return;
const raw = String(value).trim();
if (!raw || /var\(/i.test(raw) || raw.includes('%')) return;
const px = resolveLengthPx(raw, 16);
if (px == null || !Number.isFinite(px)) return;
out.allowedRadii.push({ name, value: raw, px });
if (/(^|\.)(full|pill|round|rounded-full)$/.test(name)) out.hasPillRadius = true;
}
function addSidecarRadii(out, sidecar) {
const roundedMeta = sidecar?.extensions?.roundedMeta;
if (!roundedMeta || typeof roundedMeta !== 'object') return;
for (const [rawName, meta] of Object.entries(roundedMeta)) {
const name = unquoteYamlKey(rawName).toLowerCase();
if (typeof meta === 'string' || typeof meta === 'number') {
addRoundedToken(out, `sidecar.${name}`, meta);
continue;
}
if (!meta || typeof meta !== 'object') continue;
for (const key of ['canonical', 'value']) {
if (typeof meta[key] === 'string' || typeof meta[key] === 'number') {
addRoundedToken(out, `sidecar.${name}.${key}`, meta[key]);
}
}
for (const key of ['values', 'aliases']) {
if (!Array.isArray(meta[key])) continue;
for (const [index, value] of meta[key].entries()) {
addRoundedToken(out, `sidecar.${name}.${key}[${index}]`, value);
}
}
if (/^(full|pill|round|rounded-full)$/.test(name) || /^(full|pill|round)$/i.test(String(meta.role || ''))) {
out.hasPillRadius = true;
}
}
}
function normalizeDesignSystem(input = {}) {
const frontmatter = input.frontmatter || {};
const sidecar = input.sidecar || null;
const out = {
present: true,
sourcePath: input.sourcePath || null,
sidecarPath: input.sidecarPath || null,
mdNewerThanJson: input.mdNewerThanJson === true,
allowedFonts: new Set(),
allowedColorKeys: new Map(),
allowedRadii: [],
hasPillRadius: false,
};
addTypographyFonts(out, frontmatter.typography);
addColorObject(out, frontmatter.colors);
addSidecarColors(out, sidecar);
addRoundedScale(out, frontmatter.rounded);
addSidecarRadii(out, sidecar);
out.hasFonts = out.allowedFonts.size > 0;
out.hasColors = out.allowedColorKeys.size > 0;
out.hasRadii = out.allowedRadii.length > 0;
return out;
}
function loadDesignSystemForCwd(cwd = process.cwd()) {
const md = resolveDesignMdPath(cwd);
if (!md) return null;
let frontmatter = null;
let mdStat = null;
try {
mdStat = fs.statSync(md.path);
frontmatter = parseFrontmatter(fs.readFileSync(md.path, 'utf-8'));
} catch {
return null;
}
if (!frontmatter || typeof frontmatter !== 'object') return null;
const sidecarPath = resolveDesignSidecarPath(cwd, md.contextDir);
const sidecar = safeReadJson(sidecarPath);
let sidecarStat = null;
try {
if (sidecarPath) sidecarStat = fs.statSync(sidecarPath);
} catch {
sidecarStat = null;
}
return normalizeDesignSystem({
frontmatter,
sidecar,
sourcePath: md.path,
sidecarPath,
mdNewerThanJson: !!(mdStat && sidecarStat && mdStat.mtimeMs > sidecarStat.mtimeMs + 1000),
});
}
function isAllowedFont(font, designSystem) {
if (!font || GENERIC_FONTS.has(font)) return true;
if (!designSystem?.hasFonts) return true;
return designSystem.allowedFonts.has(font);
}
function isAllowedColorRaw(raw, designSystem) {
if (!designSystem?.hasColors) return true;
const text = String(raw || '').trim().toLowerCase();
if (!text || text === 'transparent' || text === 'currentcolor' || text === 'inherit' || text === 'initial') return true;
if (text.includes('var(')) return true;
const parsed = parseDesignColor(text);
if (!parsed) return true;
if ((parsed.a ?? 1) <= 0.05) return true;
for (const entry of designSystem.allowedColorKeys.values()) {
if (colorsClose(parsed, entry.color)) return true;
}
return false;
}
function isAllowedRadiusRaw(raw, designSystem) {
if (!designSystem?.hasRadii) return true;
const text = String(raw || '').trim().toLowerCase();
if (!text || text === '0' || text === 'none' || text === 'initial' || text === 'inherit') return true;
if (text.includes('var(') || text.includes('%')) return true;
const px = resolveLengthPx(text, 16);
if (px == null || !Number.isFinite(px) || px <= RADIUS_TOLERANCE_PX) return true;
if (designSystem.hasPillRadius && px >= 99) return true;
return designSystem.allowedRadii.some(entry => Math.abs(entry.px - px) <= RADIUS_TOLERANCE_PX);
}
function lineLooksCommented(line) {
const trimmed = String(line || '').trim();
return trimmed.startsWith('//') || trimmed.startsWith('/*') || trimmed.startsWith('*') || trimmed.startsWith('<!--');
}
function isProbablyColorLiteral(line, match) {
const raw = match?.[0] || '';
const index = match.index ?? -1;
if (index < 0) return false;
if (isInsideCssAttributeSelector(line, index)) return false;
const before = line.slice(0, index);
const after = line.slice(index + raw.length);
if (raw.startsWith('#')) {
if (before.endsWith('&')) return false; // HTML numeric entity, e.g. &#8596;
const prevNonSpace = before.match(/\S(?=\s*$)/)?.[0] || '';
const nextNonSpace = after.match(/^\s*(\S)/)?.[1] || '';
if (prevNonSpace === '>' && nextNonSpace === '<') return false; // plain text, e.g. PR #155
}
const styleContext = /(?:^|[{\s;"'`(,])(?:color|background(?:-color|-image)?|border(?:-(?:top|right|bottom|left))?(?:-color)?|outline(?:-color)?|box-shadow|text-shadow|fill|stroke)\s*:\s*[^;{}"'`]*/i.test(before);
const cssFunctionContext = /(?:linear-gradient|radial-gradient|conic-gradient|color-mix)\([^)]*$/i.test(before);
const jsColorKeyContext = /(?:^|[,{]\s*)(?:color|background|backgroundColor|borderColor|outlineColor|fill|stroke|boxShadow|textShadow)\s*[:=]\s*["'`]?[^"'`,}]*/i.test(before);
return styleContext || cssFunctionContext || jsColorKeyContext;
}
function isInsideCssAttributeSelector(line, index) {
if (index < 0) return false;
const before = line.slice(0, index);
const lastOpen = before.lastIndexOf('[');
if (lastOpen === -1) return false;
const lastClose = before.lastIndexOf(']');
if (lastClose > lastOpen) return false;
const after = line.slice(index);
const close = after.indexOf(']');
const block = after.indexOf('{');
return close !== -1 && (block === -1 || close < block);
}
function makeDesignFinding(id, filePath, snippet, line = 0, extras = {}) {
return { ...finding(id, filePath, snippet, line), ...extras };
}
function decodeGoogleFamily(value) {
const family = String(value || '').split(':')[0].replace(/\+/g, ' ');
try {
return decodeURIComponent(family);
} catch {
return family;
}
}
function checkFontStack(stack, filePath, line, designSystem, context) {
const primary = primaryFont(stack);
if (!primary || isAllowedFont(primary, designSystem)) return [];
const display = primary.replace(/\b\w/g, ch => ch.toUpperCase());
return [makeDesignFinding(
'design-system-font',
filePath,
`${context}: ${display} is not declared in DESIGN.md typography`,
line,
{ ignoreValue: display },
)];
}
function extractRadiusTokens(value) {
return String(value || '')
.replace(/\s*\/\s*/g, ' ')
.split(/\s+/)
.map(token => token.trim())
.filter(Boolean);
}
function checkRadiusValue(value, filePath, line, designSystem, context) {
const findings = [];
for (const token of extractRadiusTokens(value)) {
if (isAllowedRadiusRaw(token, designSystem)) continue;
findings.push(makeDesignFinding(
'design-system-radius',
filePath,
`${context}: ${token} is outside the DESIGN.md rounded scale`,
line,
{ ignoreValue: token },
));
}
return findings;
}
function checkSourceDesignSystem(content, filePath, options = {}) {
const designSystem = options.designSystem;
if (!designSystem?.present) return [];
const findings = [];
const lines = String(content || '').split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineNum = i + 1;
if (lineLooksCommented(line)) continue;
if (designSystem.hasFonts) {
for (const match of line.matchAll(FONT_DECL_RE)) {
findings.push(...checkFontStack(match[1], filePath, lineNum, designSystem, 'font-family'));
}
for (const match of line.matchAll(FONT_JS_RE)) {
findings.push(...checkFontStack(match[1], filePath, lineNum, designSystem, 'fontFamily'));
}
for (const match of line.matchAll(GOOGLE_FONT_RE)) {
const url = match[0];
for (const familyMatch of url.matchAll(/[?&]family=([^&]+)/g)) {
const font = normalizeFontName(decodeGoogleFamily(familyMatch[1]));
if (!font || isAllowedFont(font, designSystem)) continue;
const display = decodeGoogleFamily(familyMatch[1]);
findings.push(makeDesignFinding(
'design-system-font',
filePath,
`Google Fonts: ${display} is not declared in DESIGN.md typography`,
lineNum,
{ ignoreValue: display },
));
}
}
}
if (designSystem.hasColors) {
for (const match of line.matchAll(CSS_COLOR_RE)) {
if (!isProbablyColorLiteral(line, match)) continue;
const raw = cssColorLabel(match[0]);
if (isAllowedColorRaw(raw, designSystem)) continue;
findings.push(makeDesignFinding(
'design-system-color',
filePath,
`Undocumented color ${raw} is outside DESIGN.md colors`,
lineNum,
{ ignoreValue: raw },
));
}
}
if (designSystem.hasRadii) {
for (const match of line.matchAll(BORDER_RADIUS_RE)) {
findings.push(...checkRadiusValue(match[1], filePath, lineNum, designSystem, 'border-radius'));
}
for (const match of line.matchAll(BORDER_RADIUS_JS_RE)) {
findings.push(...checkRadiusValue(match[1], filePath, lineNum, designSystem, 'borderRadius'));
}
}
}
return dedupeDesignFindings(findings);
}
function hasDirectText(el) {
return Array.from(el.childNodes || []).some(node => node.nodeType === 3 && node.textContent.trim().length > 0);
}
function sampleText(el) {
const text = String(el.textContent || '').replace(/\s+/g, ' ').trim();
return text ? ` "${text.slice(0, 40)}"` : '';
}
function collectStaticDesignSystemFindings(document, window, filePath, designSystem) {
if (!designSystem?.present) return [];
const findings = [];
const seenFonts = new Set();
const seenColors = new Set();
const seenRadii = new Set();
for (const el of document.querySelectorAll('*')) {
if (shouldSkipStaticDesignElement(el, window)) continue;
const tag = el.tagName?.toLowerCase?.() || 'unknown';
const style = window.getComputedStyle(el);
if (designSystem.hasFonts && hasDirectText(el)) {
const font = primaryFont(style.fontFamily || '');
if (font && !seenFonts.has(font) && !isAllowedFont(font, designSystem)) {
seenFonts.add(font);
findings.push(makeDesignFinding(
'design-system-font',
filePath,
`${tag}${sampleText(el)} uses ${font}; not declared in DESIGN.md typography`,
0,
{ ignoreValue: font },
));
}
}
if (designSystem.hasColors) {
const colorChecks = [];
if (hasDirectText(el)) colorChecks.push(['text color', style.color]);
if (!isTransparentCss(style.backgroundColor)) colorChecks.push(['background', style.backgroundColor]);
for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
if ((parseFloat(style[`border${side}Width`]) || 0) > 0) {
colorChecks.push([`border-${side.toLowerCase()}`, style[`border${side}Color`]]);
}
}
if ((parseFloat(style.outlineWidth) || 0) > 0) colorChecks.push(['outline', style.outlineColor]);
for (const [kind, raw] of colorChecks) {
const label = cssColorLabel(raw);
if (isAllowedColorRaw(label, designSystem)) continue;
const key = `${kind}:${label}`;
if (seenColors.has(key)) continue;
seenColors.add(key);
findings.push(makeDesignFinding(
'design-system-color',
filePath,
`${kind} ${label} on ${tag}${sampleText(el)} is outside DESIGN.md colors`,
0,
{ ignoreValue: label },
));
}
}
if (designSystem.hasRadii) {
const rawRadius = String(style.borderRadius || '').trim();
if (!rawRadius) continue;
for (const token of extractRadiusTokens(rawRadius)) {
if (isAllowedRadiusRaw(token, designSystem)) continue;
if (seenRadii.has(token)) continue;
seenRadii.add(token);
findings.push(makeDesignFinding(
'design-system-radius',
filePath,
`border-radius ${token} on ${tag}${sampleText(el)} is outside the DESIGN.md rounded scale`,
0,
{ ignoreValue: token },
));
}
}
}
return findings;
}
function shouldSkipStaticDesignElement(el, window) {
const tag = el.tagName?.toLowerCase?.() || '';
if (STATIC_DESIGN_SKIP_TAGS.has(tag)) return true;
let current = el;
while (current) {
if (current.getAttribute?.('hidden') !== null || current.getAttribute?.('aria-hidden') === 'true') return true;
const style = window.getComputedStyle(current);
const display = String(style.display || '').toLowerCase();
const visibility = String(style.visibility || '').toLowerCase();
if (display === 'none' || visibility === 'hidden' || visibility === 'collapse') return true;
current = current.parentElement;
}
return false;
}
function isTransparentCss(value) {
const text = String(value || '').trim().toLowerCase();
if (!text || text === 'transparent') return true;
const parsed = parseDesignColor(text);
return parsed ? (parsed.a ?? 1) <= 0.05 : false;
}
function canonicalDesignFindingKey(item) {
if (!item?.antipattern?.startsWith?.('design-system-')) return null;
const value = item.ignoreValue || item.value || '';
if (item.antipattern === 'design-system-font') {
const context = /google fonts/i.test(item.snippet || '') ? 'google-font' : 'font';
const font = normalizeFontName(value);
return font ? `${item.antipattern}:${context}:${font}` : null;
}
if (item.antipattern === 'design-system-color') {
const parsed = parseDesignColor(value);
if (parsed) return `${item.antipattern}:color:${colorKey(parsed)}`;
const label = cssColorLabel(value).toLowerCase();
return label ? `${item.antipattern}:color:${label}` : null;
}
if (item.antipattern === 'design-system-radius') {
const px = resolveLengthPx(String(value || '').trim(), 16);
if (px != null && Number.isFinite(px)) return `${item.antipattern}:radius:${Math.round(px * 100) / 100}`;
const label = String(value || '').trim().toLowerCase();
return label ? `${item.antipattern}:radius:${label}` : null;
}
return null;
}
function mergeDesignSystemFindings(...groups) {
const out = [];
const seen = new Map();
for (const group of groups) {
for (const item of group || []) {
const key = canonicalDesignFindingKey(item);
if (key) {
if (seen.has(key)) {
const existing = out[seen.get(key)];
if ((existing.line || 0) <= 0 && (item.line || 0) > 0) existing.line = item.line;
continue;
}
seen.set(key, out.length);
}
out.push(item);
}
}
return out;
}
function dedupeDesignFindings(findings) {
const out = [];
const seen = new Set();
for (const item of findings) {
const key = [
item.antipattern,
item.line || 0,
normalizeFontName(item.ignoreValue || item.snippet || ''),
].join('\0');
if (seen.has(key)) continue;
seen.add(key);
out.push(item);
}
return out;
}
export {
parseFrontmatter,
normalizeDesignSystem,
loadDesignSystemForCwd,
isAllowedFont,
isAllowedColorRaw,
isAllowedRadiusRaw,
checkSourceDesignSystem,
collectStaticDesignSystemFindings,
mergeDesignSystemFindings,
};
@@ -425,6 +425,35 @@ const ANTIPATTERNS = [
skillSection: 'Layout & Space',
skillGuideline: 'overflow container clipping positioned children',
},
{
id: 'design-system-font',
category: 'quality',
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.',
skillSection: 'Typography',
skillGuideline: 'font family outside the project design system',
},
{
id: 'design-system-color',
category: 'quality',
severity: 'advisory',
name: 'Color outside DESIGN.md',
description:
'A literal color is outside the DESIGN.md palette and sidecar tonal ramps. This may be legitimate, but it should be an intentional design-system addition rather than drift.',
skillSection: 'Color & Contrast',
skillGuideline: 'literal color outside the project design system',
},
{
id: 'design-system-radius',
category: 'quality',
severity: 'advisory',
name: 'Radius outside DESIGN.md',
description:
'A border-radius value is outside the DESIGN.md rounded scale. Use a documented radius token or update the design system if the new shape is intentional.',
skillSection: 'Visual Details',
skillGuideline: 'border radius outside the project design system',
},
// ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
{
@@ -1084,9 +1113,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
@@ -1544,11 +1577,16 @@ function parseAnyColor(s) {
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i);
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
return null;
}
@@ -1577,9 +1615,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[role="navigation"]',
'[aria-label*="breadcrumb" i]',
'[class*="breadcrumb" i]',
'[aria-hidden="true"]',
'[data-impeccable-allow-kickers]',
].join(',');
const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
'li',
'[role="listitem"]',
'[role="option"]',
].join(',');
function cleanInlineText(el) {
return [...el.childNodes]
.filter(n => n.nodeType === 3)
@@ -1589,6 +1637,11 @@ function cleanInlineText(el) {
.trim();
}
function isRepeatedKickerCardContext(heading, kicker) {
const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
function isRepeatedKickerCandidate(opts) {
const {
headingTag,
@@ -1602,6 +1655,7 @@ function isRepeatedKickerCandidate(opts) {
} = opts;
if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false;
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
@@ -1623,6 +1677,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
const kicker = heading.previousElementSibling;
if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
if (isRepeatedKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
@@ -1805,6 +1860,84 @@ function resolveLengthPx(value, fontSizePx) {
return num * fontSizePx;
}
function cssColorIsTransparent(value) {
if (!value) return true;
const str = String(value).trim().toLowerCase();
if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true;
const parsed = parseAnyColor(str);
if (parsed) return (parsed.a ?? 1) <= 0.05;
return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str);
}
function colorsNearlyMatch(a, b) {
const ca = parseAnyColor(a);
const cb = parseAnyColor(b);
if (!ca || !cb) return false;
const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1));
const channelDelta = Math.max(
Math.abs(ca.r - cb.r),
Math.abs(ca.g - cb.g),
Math.abs(ca.b - cb.b),
);
return alphaDelta <= 0.03 && channelDelta <= 3;
}
function getComputedStyleFor(win, el) {
if (win && typeof win.getComputedStyle === 'function') {
try { return win.getComputedStyle(el); } catch {}
}
if (typeof getComputedStyle === 'function') {
try { return getComputedStyle(el); } catch {}
}
return null;
}
function hasVisibleBackgroundBoundary(style, el, win) {
const bg = style?.backgroundColor || '';
if (cssColorIsTransparent(bg)) return false;
let parent = el?.parentElement || null;
while (parent) {
const parentStyle = getComputedStyleFor(win, parent);
const parentBg = parentStyle?.backgroundColor || '';
if (!cssColorIsTransparent(parentBg)) {
return !colorsNearlyMatch(bg, parentBg);
}
parent = parent.parentElement;
}
return true;
}
const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']);
function hasMeaningfulDirectText(node) {
if (!node?.childNodes) return false;
for (const child of node.childNodes) {
if (child.nodeType === 3 && child.textContent.trim().length > 4) return true;
}
return false;
}
function textDescendantsFlushSides(el, rect) {
const flush = { top: false, right: false, bottom: false, left: false };
if (!rect || !el?.querySelectorAll) return flush;
const TEXT_EDGE_THRESHOLD = 4;
const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th');
for (const node of candidates) {
if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue;
let nodeRect = null;
try { nodeRect = node.getBoundingClientRect(); } catch {}
if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue;
if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue;
if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true;
if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true;
if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true;
if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true;
}
return flush;
}
// Pure quality checks. Most run on computed CSS and DOM-only inputs (work in
// jsdom and the browser). Two checks (line-length, cramped-padding) gate on
// element rect dimensions, which jsdom can't compute — pass `rect: null` from
@@ -1834,7 +1967,8 @@ function checkQuality(opts) {
// font-size — bigger text demands proportionally more padding.
// vertical: max(4px, fontSize × 0.3)
// horizontal: max(8px, fontSize × 0.5)
if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre'));
if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
const borders = {
top: parseFloat(style.borderTopWidth) || 0,
right: parseFloat(style.borderRightWidth) || 0,
@@ -1842,7 +1976,7 @@ function checkQuality(opts) {
left: parseFloat(style.borderLeftWidth) || 0,
};
const borderCount = Object.values(borders).filter(w => w > 0).length;
const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)';
const hasBg = hasVisibleBackgroundBoundary(style, el, win);
if (borderCount >= 2 || hasBg) {
const vPads = [], hPads = [];
if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0);
@@ -1890,10 +2024,6 @@ function checkQuality(opts) {
!['fixed', 'absolute'].includes(elPosition) &&
el.children && el.children.length > 0
) {
const isTransparent = (c) =>
!c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' ||
/^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c);
const borderW = {
top: parseFloat(style.borderTopWidth) || 0,
right: parseFloat(style.borderRightWidth) || 0,
@@ -1901,10 +2031,10 @@ function checkQuality(opts) {
left: parseFloat(style.borderLeftWidth) || 0,
};
const borderVisible = {
top: borderW.top > 0 && !isTransparent(style.borderTopColor),
right: borderW.right > 0 && !isTransparent(style.borderRightColor),
bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor),
left: borderW.left > 0 && !isTransparent(style.borderLeftColor),
top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor),
right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor),
bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor),
left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor),
};
// Outline detection. jsdom decomposes `border` shorthand into
// border{Top,…}Width/Color but does NOT decompose `outline` —
@@ -1924,8 +2054,8 @@ function checkQuality(opts) {
if (cMatch) outlineColorVal = cMatch[1];
}
}
const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none';
const bgVisible = !isTransparent(style.backgroundColor);
const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none';
const bgVisible = hasVisibleBackgroundBoundary(style, el, win);
const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible;
if (anyVisible) {
@@ -1953,13 +2083,7 @@ function checkQuality(opts) {
const CHILD_INSULATE_THRESHOLD = 4;
const childrenInsulate = { top: false, right: false, bottom: false, left: false };
for (const child of el.children) {
let childStyle = null;
if (win && typeof win.getComputedStyle === 'function') {
try { childStyle = win.getComputedStyle(child); } catch {}
}
if (!childStyle && typeof getComputedStyle === 'function') {
try { childStyle = getComputedStyle(child); } catch {}
}
let childStyle = getComputedStyleFor(win, child);
if (!childStyle) continue;
const childPad = {
top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0,
@@ -1967,15 +2091,37 @@ function checkQuality(opts) {
bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0,
left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0,
};
const childMargin = {
top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0,
right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0,
bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0,
left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0,
};
if (rect && typeof child.getBoundingClientRect === 'function') {
try {
const childRect = child.getBoundingClientRect();
if (childRect && childRect.width > 0 && childRect.height > 0) {
if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true;
if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true;
if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true;
if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true;
}
} catch {}
}
for (const s of ['top', 'right', 'bottom', 'left']) {
if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true;
if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) {
childrenInsulate[s] = true;
}
}
}
const textFlush = rect ? textDescendantsFlushSides(el, rect) : null;
const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible;
const flushSides = [];
for (const side of ['top', 'right', 'bottom', 'left']) {
const sideBounded = borderVisible[side] || outlineVisible || bgVisible;
if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) {
const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right'));
const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide;
if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) {
flushSides.push(side);
}
}
@@ -2069,7 +2215,7 @@ function checkQuality(opts) {
// Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.)
if (hasDirectText && textLen > 20 && fontSize < 12) {
const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption'];
const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]');
const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]');
const isUppercase = style.textTransform === 'uppercase';
if (!skipTags.includes(tag) && !inUIContext && !isUppercase) {
findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
@@ -2677,17 +2823,28 @@ function checkCreamPalette(doc, win) {
}
// ─── Oversized hero headline ────────────────────────────────────────────────
// Fires when a *long* headline is set at display size, so a full sentence ends
// up dominating the viewport. A punchy one- or two-word headline at the same
// size is a legitimate stylistic choice and must pass — length, not size
// alone, is the tell.
// Fires when a *long* headline is set at display size and actually dominates
// the viewport. A punchy one- or two-word headline at the same size is a
// legitimate stylistic choice, and a large-but-contained two-line hero should
// pass too — length and viewport share together are the tell.
const OVERSIZED_H1_FONT_PX = 72;
const OVERSIZED_H1_MIN_CHARS = 40;
function checkOversizedH1({ tag, fontSize, headingText }) {
const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28;
const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25;
function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) {
if (tag !== 'h1') return [];
const textLen = headingText.length;
if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) {
return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }];
let viewportDetail = '';
if (rect && viewportWidth > 0 && viewportHeight > 0) {
const heightRatio = rect.height / viewportHeight;
const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight);
const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO
|| areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO;
if (!dominatesViewport) return [];
viewportDetail = `, ${Math.round(heightRatio * 100)}vh`;
}
return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }];
}
return [];
}
@@ -2705,31 +2862,54 @@ function checkElementOversizedH1DOM(el) {
const style = getComputedStyle(el);
const fontSize = parseFloat(style.fontSize) || 0;
const headingText = (el.textContent || '').trim().replace(/\s+/g, ' ');
return checkOversizedH1({ tag, fontSize, headingText });
const rect = el.getBoundingClientRect();
const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0;
const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0;
return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight });
}
// ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ────────────
function shadowMaxBlurPx(boxShadow) {
const CSS_COLOR_TOKEN_RE = /(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\([^)]*\)|#[0-9a-fA-F]{3,8}\b|\b(?:black|white|transparent|currentcolor)\b/gi;
function shadowLayerAlpha(layer) {
CSS_COLOR_TOKEN_RE.lastIndex = 0;
const match = CSS_COLOR_TOKEN_RE.exec(layer);
if (!match) return 1;
if (match[0].toLowerCase() === 'transparent') return 0;
const parsed = parseAnyColor(match[0]);
return parsed ? (parsed.a ?? 1) : 1;
}
function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) {
if (!boxShadow || boxShadow === 'none') return 0;
let maxBlur = 0;
// Split into layers on commas not inside parentheses (rgba(...) etc.).
for (const layer of boxShadow.split(/,(?![^()]*\))/)) {
if (shadowLayerAlpha(layer) < minAlpha) continue;
// Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the
// ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps
// unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") —
// both reduce to the same numbers here.
const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' ');
const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' ');
const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0]));
if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]);
}
return maxBlur;
}
function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) {
const maxBorder = Math.max(0, ...borderWidths);
const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5;
const blur = shadowMaxBlurPx(boxShadow);
if (hasThinBorder && blur >= 16) {
function cssColorAlpha(value) {
if (cssColorIsTransparent(value)) return 0;
const parsed = parseAnyColor(value);
return parsed ? (parsed.a ?? 1) : 1;
}
function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) {
const visibleThinBorders = borderWidths
.map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') }))
.filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28);
const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width));
const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 });
if (visibleThinBorders.length >= 2 && blur >= 16) {
return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }];
}
return [];
@@ -2744,13 +2924,22 @@ function borderWidthsFromStyle(style) {
];
}
function borderColorsFromStyle(style) {
return [
style.borderTopColor || '',
style.borderRightColor || '',
style.borderBottomColor || '',
style.borderLeftColor || '',
];
}
function checkElementGptBorderShadow(el, style) {
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' });
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' });
}
function checkElementGptBorderShadowDOM(el) {
const style = getComputedStyle(el);
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' });
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' });
}
// ─── Clipped overflow container ───────────────────────────────────────────────
@@ -2763,17 +2952,131 @@ function classSelector(el) {
return tokens.length ? `${tag}.${tokens.join('.')}` : tag;
}
function positionedChildIsDecorative(child) {
if (!child || typeof child.getAttribute !== 'function') return false;
if (child.closest?.('[aria-hidden="true"]')) return true;
const role = (child.getAttribute('role') || '').toLowerCase();
if (role === 'none' || role === 'presentation') return true;
const tag = child.tagName ? child.tagName.toLowerCase() : '';
if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true;
const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`;
if (
/\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) &&
!positionedChildHasSubstantiveContent(child)
) {
return true;
}
return false;
}
const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [
'a[href]',
'button',
'input',
'select',
'summary',
'textarea',
'[tabindex]:not([tabindex="-1"])',
'[role="button"]',
'[role="dialog"]',
'[role="link"]',
'[role="listbox"]',
'[role="menu"]',
'[role="menuitem"]',
'[role="option"]',
'[role="tooltip"]',
].join(',');
function positionedChildHasSubstantiveContent(child) {
const text = (child.textContent || '').replace(/\s+/g, ' ').trim();
if (text.length > 0) return true;
if (typeof child.matches === 'function') {
try {
if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true;
} catch {}
}
if (typeof child.querySelector === 'function') {
try {
if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true;
} catch {}
}
return false;
}
function clippingContainerIsIntentionalViewport(el) {
if (!el || typeof el.getAttribute !== 'function') return false;
const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase();
if (/\b(carousel|slider)\b/.test(roleDescription)) return true;
const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase();
return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) ||
/\b(demo-area|demo-stage|demo-viewport)\b/.test(ident);
}
function elementRect(el) {
if (!el || typeof el.getBoundingClientRect !== 'function') return null;
try {
const rect = el.getBoundingClientRect();
if (!rect) return null;
const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height];
if (!values.every(Number.isFinite)) return null;
if (rect.width <= 0 && rect.height <= 0) return null;
return rect;
} catch {
return null;
}
}
function positionedStyleImpliesEscape(style) {
const values = [
style.top,
style.right,
style.bottom,
style.left,
style.inset,
style.insetBlock,
style.insetInline,
style.insetBlockStart,
style.insetBlockEnd,
style.insetInlineStart,
style.insetInlineEnd,
].filter(Boolean).map(value => String(value).trim().toLowerCase());
for (const value of values) {
if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true;
if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true;
}
return false;
}
function positionedChildEscapesClip(el, child, clipX, clipY) {
const parentRect = elementRect(el);
const childRect = elementRect(child);
if (!parentRect || !childRect) return null;
const threshold = 2;
return Boolean(
(clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) ||
(clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold))
);
}
function checkClippedOverflow(el, style, getStyle) {
const clips = (v) => v === 'hidden' || v === 'clip';
const scrolls = (v) => v === 'auto' || v === 'scroll';
const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || '';
const anyClip = clips(ox) || clips(oy) || clips(ov);
const clipX = clips(ox) || clips(ov);
const clipY = clips(oy) || clips(ov);
const anyClip = clipX || clipY;
const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov);
if (!anyClip || anyScroll) return [];
if (clippingContainerIsIntentionalViewport(el)) return [];
if (!el.querySelectorAll) return [];
for (const child of el.querySelectorAll('*')) {
const pos = (getStyle(child).position) || '';
const childStyle = getStyle(child);
const pos = childStyle.position || '';
if (pos === 'absolute' || pos === 'fixed') {
if (positionedChildIsDecorative(child)) continue;
const escapes = positionedChildEscapesClip(el, child, clipX, clipY);
if (escapes === false) continue;
if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue;
return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }];
}
}
@@ -2792,14 +3095,94 @@ function checkElementClippedOverflowDOM(el) {
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
function metricLengthPx(value, fontSizePx = 16) {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value !== 'string') return null;
return resolveLengthPx(value, fontSizePx);
}
function firstMetricLengthPx(fontSizePx, ...values) {
for (const value of values) {
const parsed = metricLengthPx(value, fontSizePx);
if (parsed !== null) return parsed;
}
return null;
}
function expandBoxShorthand(parts) {
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
return [parts[0], parts[1], parts[2], parts[3]];
}
function clippedByInset(clipPath) {
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
if (!match) return false;
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
if (!beforeRound) return false;
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
if (percents.some(match => !match)) return false;
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
return top + bottom >= 100 || left + right >= 100;
}
function clippedByRect(clip) {
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
if (!match) return false;
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
if (values.length !== 4) return false;
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
if ([top, right, bottom, left].some(value => value === null)) return false;
return bottom <= top || right <= left;
}
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
if (!style) return false;
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
.map(value => String(value || '').toLowerCase());
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
const clip = String(style.clip || '').trim();
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
}
function isRenderedForBrowserRule(el) {
for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {
if (cur.getAttribute?.('aria-hidden') === 'true') return false;
const style = getComputedStyle(cur);
const visibility = String(style.visibility || '').toLowerCase();
if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false;
if ((parseFloat(style.opacity) || 0) <= 0.01) return false;
if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false;
}
return true;
}
function checkElementTextOverflowDOM(el) {
const tag = el.tagName.toLowerCase();
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
if (!isRenderedForBrowserRule(el)) return [];
// Only the element that actually owns overflowing text — not its ancestors,
// which inherit a wider scrollWidth from the spilling descendant.
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
if (!hasDirectText) return [];
const style = getComputedStyle(el);
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
if (isScreenReaderOnlyTextStyle(style, {
width: rect?.width,
height: rect?.height,
clientWidth: el.clientWidth,
clientHeight: el.clientHeight,
})) return [];
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
if (isScrollRegion(style)) return [];
// A scrollable ancestor means this overflow is intentional and scrollable.
@@ -3476,6 +3859,7 @@ if (IS_BROWSER) {
if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue;
if (el.closest('[id^="impeccable-live-"]')) continue;
if (el === document.body || el === document.documentElement) continue;
if (!isRenderedForBrowserRule(el)) continue;
const tag = el.tagName.toLowerCase();
const style = getComputedStyle(el);
@@ -3907,6 +4291,7 @@ if (IS_BROWSER) {
return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'stale selector' };
}
if (!el) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'missing element' };
if (!isRenderedForBrowserRule(el)) return { ...candidate, status: 'unresolved', confidence: 'none', reason: 'hidden element' };
const blockingReason = (candidate.reasons || []).find(reason =>
reason === 'background-clip text' ||
@@ -4038,6 +4423,7 @@ if (IS_BROWSER) {
category: ap ? ap.category : 'quality',
severity: ap?.severity || 'warning',
detail: f.detail || f.snippet,
ignoreValue: f.ignoreValue || f.value || '',
name: ap ? ap.name : (f.type || f.id),
description: ap ? ap.description : '',
};
@@ -4074,10 +4460,203 @@ if (IS_BROWSER) {
return [...groupMap.entries()].map(([el, findings]) => ({ el, findings }));
}
const DESIGN_COLOR_TOLERANCE = 6;
const DESIGN_RADIUS_TOLERANCE_PX = 0.5;
const DESIGN_SKIP_TAGS = new Set(['head', 'title', 'meta', 'link', 'style', 'script', 'noscript', 'template', 'source']);
function normalizeBrowserFontName(value) {
return String(value || '')
.trim()
.replace(/^["']|["']$/g, '')
.replace(/\+/g, ' ')
.replace(/\s+/g, ' ')
.toLowerCase();
}
function browserPrimaryFont(stack) {
if (!stack || /var\(/i.test(stack)) return '';
return String(stack || '')
.split(',')
.map(normalizeBrowserFontName)
.find(font => font && !GENERIC_FONTS.has(font)) || '';
}
function browserDesignSystemConfig() {
const raw = window.__IMPECCABLE_CONFIG__?.designSystem;
if (!raw?.present) return null;
const allowedFonts = new Set((raw.allowedFonts || []).map(normalizeBrowserFontName).filter(Boolean));
const allowedColors = (raw.allowedColors || [])
.filter(color => color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b))
.map(color => ({ r: color.r, g: color.g, b: color.b }));
const allowedRadii = (raw.allowedRadii || [])
.map(Number)
.filter(px => Number.isFinite(px));
return {
present: true,
hasFonts: raw.hasFonts === true && allowedFonts.size > 0,
allowedFonts,
hasColors: raw.hasColors === true && allowedColors.length > 0,
allowedColors,
hasRadii: raw.hasRadii === true && allowedRadii.length > 0,
allowedRadii,
hasPillRadius: raw.hasPillRadius === true,
};
}
function browserColorsClose(a, b) {
if (!a || !b) return false;
return Math.max(
Math.abs(a.r - b.r),
Math.abs(a.g - b.g),
Math.abs(a.b - b.b),
) <= DESIGN_COLOR_TOLERANCE;
}
function isBrowserDesignColorAllowed(raw, designSystem) {
if (!designSystem?.hasColors) return true;
const text = String(raw || '').trim().toLowerCase();
if (!text || text === 'transparent' || text === 'currentcolor' || text === 'inherit' || text === 'initial') return true;
if (text.includes('var(')) return true;
const parsed = parseAnyColor(text);
if (!parsed) return true;
if ((parsed.a ?? 1) <= 0.05) return true;
return designSystem.allowedColors.some(color => browserColorsClose(parsed, color));
}
function isBrowserTransparentCss(value) {
const text = String(value || '').trim().toLowerCase();
if (!text || text === 'transparent') return true;
const parsed = parseAnyColor(text);
return parsed ? (parsed.a ?? 1) <= 0.05 : false;
}
function isBrowserDesignRadiusAllowed(raw, designSystem) {
if (!designSystem?.hasRadii) return true;
const text = String(raw || '').trim().toLowerCase();
if (!text || text === '0' || text === 'none' || text === 'initial' || text === 'inherit') return true;
if (text.includes('var(') || text.includes('%')) return true;
const px = resolveLengthPx(text, 16);
if (px == null || !Number.isFinite(px) || px <= DESIGN_RADIUS_TOLERANCE_PX) return true;
if (designSystem.hasPillRadius && px >= 99) return true;
return designSystem.allowedRadii.some(allowed => Math.abs(allowed - px) <= DESIGN_RADIUS_TOLERANCE_PX);
}
function browserRadiusTokens(value) {
return String(value || '')
.replace(/\s*\/\s*/g, ' ')
.split(/\s+/)
.map(token => token.trim())
.filter(Boolean);
}
function browserHasDirectText(el) {
return [...(el.childNodes || [])].some(node => node.nodeType === 3 && node.textContent.trim().length > 0);
}
function browserSampleText(el) {
const text = String(el.textContent || '').replace(/\s+/g, ' ').trim();
return text ? ` "${text.slice(0, 40)}"` : '';
}
function shouldSkipDesignElement(el) {
const tag = el.tagName?.toLowerCase?.() || '';
return DESIGN_SKIP_TAGS.has(tag) || isElementHidden(el);
}
function checkElementDesignSystemDOM(el, designSystem, seen) {
if (!designSystem?.present || shouldSkipDesignElement(el)) return [];
const findings = [];
const tag = el.tagName?.toLowerCase?.() || 'unknown';
const style = getComputedStyle(el);
if (designSystem.hasFonts && browserHasDirectText(el)) {
const font = browserPrimaryFont(style.fontFamily || '');
if (font && !designSystem.allowedFonts.has(font) && !seen.fonts.has(font)) {
seen.fonts.add(font);
findings.push({
type: 'design-system-font',
detail: `${tag}${browserSampleText(el)} uses ${font}; not declared in DESIGN.md typography`,
ignoreValue: font,
});
}
}
if (designSystem.hasColors) {
const colorChecks = [];
if (browserHasDirectText(el)) colorChecks.push(['text color', style.color]);
if (!isBrowserTransparentCss(style.backgroundColor)) colorChecks.push(['background', style.backgroundColor]);
for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
if ((parseFloat(style[`border${side}Width`]) || 0) > 0) {
colorChecks.push([`border-${side.toLowerCase()}`, style[`border${side}Color`]]);
}
}
if ((parseFloat(style.outlineWidth) || 0) > 0) colorChecks.push(['outline', style.outlineColor]);
for (const [kind, raw] of colorChecks) {
const label = String(raw || '').trim().replace(/\s+/g, ' ');
if (isBrowserDesignColorAllowed(label, designSystem)) continue;
const key = `${kind}:${label}`;
if (seen.colors.has(key)) continue;
seen.colors.add(key);
findings.push({
type: 'design-system-color',
detail: `${kind} ${label} on ${tag}${browserSampleText(el)} is outside DESIGN.md colors`,
ignoreValue: label,
});
}
}
if (designSystem.hasRadii) {
for (const token of browserRadiusTokens(style.borderRadius || '')) {
if (isBrowserDesignRadiusAllowed(token, designSystem)) continue;
if (seen.radii.has(token)) continue;
seen.radii.add(token);
findings.push({
type: 'design-system-radius',
detail: `border-radius ${token} on ${tag}${browserSampleText(el)} is outside the DESIGN.md rounded scale`,
ignoreValue: token,
});
}
}
return findings;
}
function decodeBrowserGoogleFamily(value) {
const family = String(value || '').split(':')[0].replace(/\+/g, ' ');
try {
return decodeURIComponent(family);
} catch {
return family;
}
}
function checkBrowserDesignSystemSources(designSystem, seen) {
if (!designSystem?.hasFonts) return [];
const findings = [];
for (const link of document.querySelectorAll('link[href*="fonts.googleapis.com/css"]')) {
const href = link.getAttribute('href') || '';
for (const match of href.matchAll(/[?&]family=([^&]+)/g)) {
const display = decodeBrowserGoogleFamily(match[1]);
const font = normalizeBrowserFontName(display);
if (!font || designSystem.allowedFonts.has(font) || seen.fonts.has(font)) continue;
seen.fonts.add(font);
findings.push({
type: 'design-system-font',
detail: `Google Fonts: ${display} is not declared in DESIGN.md typography`,
ignoreValue: display,
});
}
}
return findings;
}
function collectBrowserFindings() {
const groupMap = new Map();
const _disabled = EXTENSION_MODE ? (window.__IMPECCABLE_CONFIG__?.disabledRules || []) : [];
const _ruleOk = (id) => !_disabled.length || !_disabled.includes(id);
const designSystem = browserDesignSystemConfig();
const designSeen = { fonts: new Set(), colors: new Set(), radii: new Set() };
// Note: provider-gated rules (--gpt / --gemini) are NOT filtered here. In a
// real browser env (detector page, live overlay, extension) running every
// check is free, so we always surface them; the gating is purely a CLI
@@ -4108,6 +4687,7 @@ if (IS_BROWSER) {
...checkElementClippedOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementGptBorderShadowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementTextOverflowDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
...checkElementDesignSystemDOM(el, designSystem, designSeen),
].filter(f => _ruleOk(f.type));
addBrowserFindings(groupMap, el, findings);
@@ -4124,6 +4704,13 @@ if (IS_BROWSER) {
const pageLevelFindings = [];
const designSourceFindings = checkBrowserDesignSystemSources(designSystem, designSeen)
.filter(f => _ruleOk(f.type));
if (designSourceFindings.length > 0) {
pageLevelFindings.push(...designSourceFindings);
addBrowserFindings(groupMap, document.body, designSourceFindings);
}
const typoFindings = checkTypography().filter(f => _ruleOk(f.type));
if (typoFindings.length > 0) {
pageLevelFindings.push(...typoFindings);
@@ -4253,13 +4840,20 @@ if (IS_BROWSER) {
return true;
}
function postSerializedFindings(groupMap) {
function scanResultMeta(options = {}) {
const scanId = options.scanId;
if (typeof scanId !== 'string' && typeof scanId !== 'number') return {};
return { scanId: String(scanId) };
}
function postSerializedFindings(groupMap, options = {}) {
if (!EXTENSION_MODE) return;
const allFindings = browserFindingsFromMap(groupMap);
window.postMessage({
source: 'impeccable-results',
findings: serializeFindings(allFindings),
count: allFindings.length,
...scanResultMeta(options),
}, '*');
}
@@ -4313,7 +4907,7 @@ if (IS_BROWSER) {
rememberVisualContrastAnalysis(result);
const added = addVisualContrastResult(groupMap, result, { decorate: true });
if (added) {
postSerializedFindings(groupMap);
postSerializedFindings(groupMap, options);
window.dispatchEvent(new CustomEvent('impeccable-visual-contrast-resolved', {
detail: {
selector: result.selector,
@@ -4381,7 +4975,7 @@ if (IS_BROWSER) {
overlayIndex = 0;
}
function renderBrowserFindings(collected) {
function renderBrowserFindings(collected, options = {}) {
const { allFindings, pageLevelFindings } = collected;
for (const { el, findings } of allFindings) {
@@ -4401,6 +4995,7 @@ if (IS_BROWSER) {
source: 'impeccable-results',
findings: serializeFindings(allFindings),
count: allFindings.length,
...scanResultMeta(options),
}, '*');
}
@@ -4415,11 +5010,11 @@ if (IS_BROWSER) {
clearOverlays();
const generation = scanGeneration;
const collected = collectBrowserFindings();
const allFindings = renderBrowserFindings(collected);
const allFindings = renderBrowserFindings(collected, options);
if (shouldRunVisualContrast(options)) {
addVisualContrastFindings(collected.groupMap, options, { decorate: true, generation })
.then(() => {
if (generation === scanGeneration) postSerializedFindings(collected.groupMap);
if (generation === scanGeneration) postSerializedFindings(collected.groupMap, options);
})
.catch(err => {
reportVisualContrastError(err);
@@ -4434,10 +5029,10 @@ if (IS_BROWSER) {
if (shouldRunVisualContrast(options)) {
const collected = await collectBrowserFindingsAsync(options, { generation, scheduleLazy: true });
if (generation !== scanGeneration) return [];
return renderBrowserFindings(collected);
return renderBrowserFindings(collected, options);
}
lastVisualContrastAnalyses = [];
return renderBrowserFindings(collectBrowserFindings());
return renderBrowserFindings(collectBrowserFindings(), options);
};
const detect = function(options = {}) {
@@ -23,6 +23,13 @@ export {
checkHtmlPatterns,
} from './rules/checks.mjs';
export { createDetectorProfile, summarizeDetectorProfile } from './profile/profiler.mjs';
export {
parseFrontmatter as parseDesignFrontmatter,
normalizeDesignSystem,
loadDesignSystemForCwd,
checkSourceDesignSystem,
collectStaticDesignSystemFindings,
} from './design-system.mjs';
export { detectHtml } from './engines/static-html/detect-html.mjs';
export { detectUrl, createBrowserDetector } from './engines/browser/detect-url.mjs';
export { detectText, extractStyleBlocks, extractCSSinJS } from './engines/regex/detect-text.mjs';
@@ -7,6 +7,25 @@ import { filterByProviders } from '../../registry/antipatterns.mjs';
import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
function serializeDesignSystemForBrowser(designSystem) {
if (!designSystem?.present) return null;
return {
present: true,
hasFonts: designSystem.hasFonts === true,
allowedFonts: Array.from(designSystem.allowedFonts || []),
hasColors: designSystem.hasColors === true,
allowedColors: Array.from(designSystem.allowedColorKeys?.values?.() || [])
.map(entry => entry?.color)
.filter(color => color && Number.isFinite(color.r) && Number.isFinite(color.g) && Number.isFinite(color.b))
.map(color => ({ r: color.r, g: color.g, b: color.b })),
hasRadii: designSystem.hasRadii === true,
allowedRadii: (designSystem.allowedRadii || [])
.map(entry => Number(entry?.px))
.filter(px => Number.isFinite(px)),
hasPillRadius: designSystem.hasPillRadius === true,
};
}
async function runVisualContrastFallback(page, serializedGroups, options, profile, target) {
if (options?.visualContrast === false) return [];
const maxCandidates = Number.isFinite(options?.visualContrastMaxCandidates)
@@ -163,17 +182,19 @@ async function detectUrl(url, options = {}) {
}
// Inject the browser detection script and collect results
const browserDesignSystem = serializeDesignSystemForBrowser(options?.designSystem);
await profileStepAsync(profile, {
engine: 'browser',
phase: 'scan',
ruleId: 'configure-pure-detect',
target: url,
}, () => page.evaluate(() => {
}, () => page.evaluate((designSystem) => {
window.__IMPECCABLE_CONFIG__ = {
...(window.__IMPECCABLE_CONFIG__ || {}),
autoScan: false,
...(designSystem ? { designSystem } : {}),
};
}));
}, browserDesignSystem));
await profileStepAsync(profile, {
engine: 'browser',
phase: 'scan',
@@ -192,7 +213,7 @@ async function detectUrl(url, options = {}) {
return window.impeccableDetect({ decorate: false, serialize: true });
});
return serializedGroups.flatMap(({ findings }) =>
findings.map(f => ({ id: f.type, snippet: f.detail }))
findings.map(f => ({ id: f.type, snippet: f.detail, ignoreValue: f.ignoreValue || '' }))
);
});
const visualFindings = await runVisualContrastFallback(page, serializedGroups, options, profile, url);
@@ -213,7 +234,11 @@ async function detectUrl(url, options = {}) {
}, () => browser.close());
}
}
return filterByProviders(results.map(f => finding(f.id, url, f.snippet)), options.providers);
return filterByProviders(results.map(f => {
const item = finding(f.id, url, f.snippet);
if (f.ignoreValue) item.ignoreValue = f.ignoreValue;
return item;
}), options.providers);
}
async function createBrowserDetector(options = {}) {
@@ -1,4 +1,5 @@
import { GENERIC_FONTS } from '../../shared/constants.mjs';
import { checkSourceDesignSystem } from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { finding } from '../../findings.mjs';
import { filterByProviders } from '../../registry/antipatterns.mjs';
@@ -23,6 +24,18 @@ function stripHtmlToText(html) {
.replace(/\s+/g, ' ');
}
const PAGE_ANALYZER_EXTS = new Set(['.html', '.htm', '.astro', '.vue', '.svelte']);
function extFromFilePath(filePath) {
return filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : '';
}
function shouldRunPageAnalyzers(content, filePath) {
if (!isFullPage(content)) return false;
const ext = extFromFilePath(filePath);
return !ext || PAGE_ANALYZER_EXTS.has(ext);
}
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
if (!m) return false;
@@ -98,9 +111,14 @@ const REGEX_MATCHERS = [
{ id: 'bounce-easing', regex: /\banimate-bounce\b/g,
test: () => true,
fmt: () => 'animate-bounce (Tailwind)' },
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi,
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi,
test: () => true,
fmt: (m) => m[0] },
fmt: (m) => {
const token = m[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
return `animation: ${token || m[1].trim()}`;
} },
{ id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,
test: (m) => {
const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
@@ -422,7 +440,7 @@ const TEXT_CONTENT_ANALYZER_IDS = [
function runTextContentAnalyzers(content, filePath, options = {}) {
const profile = options?.profile;
if (!isFullPage(content)) return [];
if (!shouldRunPageAnalyzers(content, filePath)) return [];
// The 4 text-content analyzers are at indices 3-6 in REGEX_ANALYZERS.
const findings = [];
for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) {
@@ -442,11 +460,11 @@ function detectText(content, filePath, options = {}) {
const profile = options?.profile;
const findings = [];
const lines = content.split('\n');
const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : '';
const ext = extFromFilePath(filePath);
// Run regex matchers on the full file content (catches Tailwind classes, inline styles)
// Enable block context for CSS files where related properties span multiple lines
const cssLike = new Set(['.css', '.scss', '.less']);
const cssLike = new Set(['.css', '.scss', '.sass', '.less']);
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
profile,
phase: 'source',
@@ -486,6 +504,15 @@ function detectText(content, filePath, options = {}) {
}));
}
if (options?.designSystem) {
findings.push(...profileFindings(profile, {
engine: 'regex',
phase: 'source',
ruleId: 'design-system',
target: filePath,
}, () => checkSourceDesignSystem(content, filePath, { designSystem: options.designSystem })));
}
// Deduplicate findings (same antipattern + similar snippet, within 2 lines)
const deduped = [];
for (const f of findings) {
@@ -498,7 +525,7 @@ function detectText(content, filePath, options = {}) {
}
// Page-level analyzers only run on full pages
if (isFullPage(content)) {
if (shouldRunPageAnalyzers(content, filePath)) {
const analyzerIds = [
'single-font',
'flat-type-hierarchy',
@@ -267,7 +267,17 @@ const STATIC_DEFAULT_STYLE = {
paddingRight: '0px',
paddingBottom: '0px',
paddingLeft: '0px',
marginTop: '0px',
marginRight: '0px',
marginBottom: '0px',
marginLeft: '0px',
position: 'static',
visibility: 'visible',
top: 'auto',
right: 'auto',
bottom: 'auto',
left: 'auto',
inset: '',
display: '',
overflow: 'visible',
overflowX: 'visible',
@@ -312,7 +322,17 @@ const STATIC_PROP_MAP = {
'padding-right': 'paddingRight',
'padding-bottom': 'paddingBottom',
'padding-left': 'paddingLeft',
'margin-top': 'marginTop',
'margin-right': 'marginRight',
'margin-bottom': 'marginBottom',
'margin-left': 'marginLeft',
'position': 'position',
'visibility': 'visibility',
'top': 'top',
'right': 'right',
'bottom': 'bottom',
'left': 'left',
'inset': 'inset',
'display': 'display',
'overflow': 'overflow',
'overflow-x': 'overflowX',
@@ -579,6 +599,15 @@ function expandStaticDeclaration(prop, value) {
['paddingLeft', vals[3]],
];
}
if (p === 'margin') {
const vals = expandStaticBoxValues(splitCssTokens(v));
return [
['marginTop', vals[0]],
['marginRight', vals[1]],
['marginBottom', vals[2]],
['marginLeft', vals[3]],
];
}
if (p === 'font') return parseStaticFont(v);
if (p === 'transition') {
const parsed = parseStaticTransition(v);
@@ -2,6 +2,11 @@ import fs from 'node:fs';
import path from 'node:path';
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
import {
checkSourceDesignSystem,
collectStaticDesignSystemFindings,
mergeDesignSystemFindings,
} from '../../design-system.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { finding } from '../../findings.mjs';
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
@@ -168,6 +173,22 @@ async function detectHtml(filePath, options = {}) {
}
}
if (options?.designSystem) {
const sourceDesignFindings = profileFindings(profile, {
engine: 'static-html',
phase: 'source',
ruleId: 'design-system',
target: filePath,
}, () => checkSourceDesignSystem(html, filePath, { designSystem: options.designSystem }));
const staticDesignFindings = profileFindings(profile, {
engine: 'static-html',
phase: 'page',
ruleId: 'design-system',
target: filePath,
}, () => collectStaticDesignSystemFindings(document, window, filePath, options.designSystem));
findings.push(...mergeDesignSystemFindings(staticDesignFindings, sourceDesignFindings));
}
if (isFullPage(html)) {
const runPageCheck = (ruleId, callback) => profile
? profileFindings(profile, { engine: 'static-html', phase: 'page', ruleId, target: filePath }, callback)
@@ -11,7 +11,7 @@ const SKIP_DIRS = new Set([
]);
const SCANNABLE_EXTENSIONS = new Set([
'.html', '.htm', '.css', '.scss', '.less',
'.html', '.htm', '.css', '.scss', '.sass', '.less',
'.jsx', '.tsx', '.js', '.ts',
'.vue', '.svelte', '.astro',
]);
@@ -323,6 +323,35 @@ const ANTIPATTERNS = [
skillSection: 'Layout & Space',
skillGuideline: 'overflow container clipping positioned children',
},
{
id: 'design-system-font',
category: 'quality',
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.',
skillSection: 'Typography',
skillGuideline: 'font family outside the project design system',
},
{
id: 'design-system-color',
category: 'quality',
severity: 'advisory',
name: 'Color outside DESIGN.md',
description:
'A literal color is outside the DESIGN.md palette and sidecar tonal ramps. This may be legitimate, but it should be an intentional design-system addition rather than drift.',
skillSection: 'Color & Contrast',
skillGuideline: 'literal color outside the project design system',
},
{
id: 'design-system-radius',
category: 'quality',
severity: 'advisory',
name: 'Radius outside DESIGN.md',
description:
'A border-radius value is outside the DESIGN.md rounded scale. Use a documented radius token or update the design system if the new shape is intentional.',
skillSection: 'Visual Details',
skillGuideline: 'border radius outside the project design system',
},
// ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
{
@@ -514,9 +514,13 @@ function checkHtmlPatterns(html) {
// --- Motion ---
// Bounce/elastic animation names
const bounceRe = /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi;
if (bounceRe.test(html)) {
findings.push({ id: 'bounce-easing', snippet: 'Bounce/elastic animation in CSS' });
const bounceRe = /animation(?:-name)?\s*:\s*([^;{}]*(?:bounce|elastic|wobble|jiggle|spring)[^;{}]*)/gi;
const bounceMatch = bounceRe.exec(html);
if (bounceMatch) {
const animationToken = bounceMatch[1]
.split(/[,\s]+/)
.find((part) => /bounce|elastic|wobble|jiggle|spring/i.test(part));
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}` });
}
// Overshoot cubic-bezier
@@ -974,11 +978,16 @@ function parseAnyColor(s) {
// OKLCH parser. Tailwind v4's CSS minifier squishes the space after
// `%` ("21.5%.02 50"), so the separator between L and C may be absent.
// Match L (with optional %), then C and H separated permissively.
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?\s*\)/i);
m = str.match(/oklch\(\s*([\d.]+)(%?)\s*[\s,]*\s*([\d.]+)\s*[\s,]+\s*([-\d.]+)(?:deg)?(?:\s*\/\s*([\d.]+)(%)?)?\s*\)/i);
if (m) {
const Lnum = parseFloat(m[1]);
const L = m[2] === '%' ? Lnum / 100 : Lnum;
return oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
const rgb = oklchToRgb(L, parseFloat(m[3]), parseFloat(m[4]));
if (m[5] !== undefined) {
const alpha = parseFloat(m[5]);
rgb.a = m[6] === '%' ? alpha / 100 : alpha;
}
return rgb;
}
return null;
}
@@ -1007,9 +1016,19 @@ const REPEATED_KICKER_SKIP_SELECTOR = [
'[role="navigation"]',
'[aria-label*="breadcrumb" i]',
'[class*="breadcrumb" i]',
'[aria-hidden="true"]',
'[data-impeccable-allow-kickers]',
].join(',');
const REPEATED_KICKER_CARD_CONTEXT_SELECTOR = [
'article',
'button',
'a',
'li',
'[role="listitem"]',
'[role="option"]',
].join(',');
function cleanInlineText(el) {
return [...el.childNodes]
.filter(n => n.nodeType === 3)
@@ -1019,6 +1038,11 @@ function cleanInlineText(el) {
.trim();
}
function isRepeatedKickerCardContext(heading, kicker) {
const item = heading.closest?.(REPEATED_KICKER_CARD_CONTEXT_SELECTOR);
return Boolean(item && (!item.contains || item.contains(kicker)));
}
function isRepeatedKickerCandidate(opts) {
const {
headingTag,
@@ -1032,6 +1056,7 @@ function isRepeatedKickerCandidate(opts) {
} = opts;
if (!['h2', 'h3', 'h4'].includes(headingTag)) return false;
if (!headingText || headingText.length < 3) return false;
if (/^\/[\w-]+/i.test(headingText.replace(/^"|"$/g, '').trim())) return false;
if (!(headingFontSize >= 20)) return false;
if (!kickerTag || HEADING_TAGS.has(kickerTag)) return false;
if (!['p', 'span', 'div', 'small'].includes(kickerTag)) return false;
@@ -1053,6 +1078,7 @@ function collectRepeatedSectionKickerCandidates(doc, getStyle, resolveLetterSpac
if (heading.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
const kicker = heading.previousElementSibling;
if (!kicker || kicker.closest?.(REPEATED_KICKER_SKIP_SELECTOR)) continue;
if (isRepeatedKickerCardContext(heading, kicker)) continue;
const headingStyle = getStyle(heading);
const kickerStyle = getStyle(kicker);
@@ -1235,6 +1261,84 @@ function resolveLengthPx(value, fontSizePx) {
return num * fontSizePx;
}
function cssColorIsTransparent(value) {
if (!value) return true;
const str = String(value).trim().toLowerCase();
if (!str || str === 'transparent' || str === 'rgba(0, 0, 0, 0)') return true;
const parsed = parseAnyColor(str);
if (parsed) return (parsed.a ?? 1) <= 0.05;
return /^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(str);
}
function colorsNearlyMatch(a, b) {
const ca = parseAnyColor(a);
const cb = parseAnyColor(b);
if (!ca || !cb) return false;
const alphaDelta = Math.abs((ca.a ?? 1) - (cb.a ?? 1));
const channelDelta = Math.max(
Math.abs(ca.r - cb.r),
Math.abs(ca.g - cb.g),
Math.abs(ca.b - cb.b),
);
return alphaDelta <= 0.03 && channelDelta <= 3;
}
function getComputedStyleFor(win, el) {
if (win && typeof win.getComputedStyle === 'function') {
try { return win.getComputedStyle(el); } catch {}
}
if (typeof getComputedStyle === 'function') {
try { return getComputedStyle(el); } catch {}
}
return null;
}
function hasVisibleBackgroundBoundary(style, el, win) {
const bg = style?.backgroundColor || '';
if (cssColorIsTransparent(bg)) return false;
let parent = el?.parentElement || null;
while (parent) {
const parentStyle = getComputedStyleFor(win, parent);
const parentBg = parentStyle?.backgroundColor || '';
if (!cssColorIsTransparent(parentBg)) {
return !colorsNearlyMatch(bg, parentBg);
}
parent = parent.parentElement;
}
return true;
}
const TEXT_EDGE_TAGS = new Set(['A', 'BUTTON', 'CODE', 'DD', 'DT', 'FIGCAPTION', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI', 'P', 'PRE', 'SPAN', 'TD', 'TH']);
function hasMeaningfulDirectText(node) {
if (!node?.childNodes) return false;
for (const child of node.childNodes) {
if (child.nodeType === 3 && child.textContent.trim().length > 4) return true;
}
return false;
}
function textDescendantsFlushSides(el, rect) {
const flush = { top: false, right: false, bottom: false, left: false };
if (!rect || !el?.querySelectorAll) return flush;
const TEXT_EDGE_THRESHOLD = 4;
const candidates = el.querySelectorAll('a, button, code, dd, dt, figcaption, h1, h2, h3, h4, h5, h6, li, p, pre, span, td, th');
for (const node of candidates) {
if (!TEXT_EDGE_TAGS.has(node.tagName) || !hasMeaningfulDirectText(node)) continue;
let nodeRect = null;
try { nodeRect = node.getBoundingClientRect(); } catch {}
if (!nodeRect || nodeRect.width <= 0 || nodeRect.height <= 0) continue;
if (nodeRect.bottom < rect.top || nodeRect.top > rect.bottom || nodeRect.right < rect.left || nodeRect.left > rect.right) continue;
if (nodeRect.top - rect.top <= TEXT_EDGE_THRESHOLD) flush.top = true;
if (rect.right - nodeRect.right <= TEXT_EDGE_THRESHOLD) flush.right = true;
if (rect.bottom - nodeRect.bottom <= TEXT_EDGE_THRESHOLD) flush.bottom = true;
if (nodeRect.left - rect.left <= TEXT_EDGE_THRESHOLD) flush.left = true;
}
return flush;
}
// Pure quality checks. Most run on computed CSS and DOM-only inputs (work in
// jsdom and the browser). Two checks (line-length, cramped-padding) gate on
// element rect dimensions, which jsdom can't compute — pass `rect: null` from
@@ -1264,7 +1368,8 @@ function checkQuality(opts) {
// font-size — bigger text demands proportionally more padding.
// vertical: max(4px, fontSize × 0.3)
// horizontal: max(8px, fontSize × 0.5)
if (rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
const isInlineCode = tag === 'code' && !(el.closest && el.closest('pre'));
if (!isInlineCode && rect && hasDirectText && textLen > 20 && rect.width > 100 && rect.height > 30) {
const borders = {
top: parseFloat(style.borderTopWidth) || 0,
right: parseFloat(style.borderRightWidth) || 0,
@@ -1272,7 +1377,7 @@ function checkQuality(opts) {
left: parseFloat(style.borderLeftWidth) || 0,
};
const borderCount = Object.values(borders).filter(w => w > 0).length;
const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)';
const hasBg = hasVisibleBackgroundBoundary(style, el, win);
if (borderCount >= 2 || hasBg) {
const vPads = [], hPads = [];
if (hasBg || borders.top > 0) vPads.push(parseFloat(style.paddingTop) || 0);
@@ -1320,10 +1425,6 @@ function checkQuality(opts) {
!['fixed', 'absolute'].includes(elPosition) &&
el.children && el.children.length > 0
) {
const isTransparent = (c) =>
!c || c === 'transparent' || c === 'rgba(0, 0, 0, 0)' ||
/^rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*0(?:\.0+)?\s*\)$/.test(c);
const borderW = {
top: parseFloat(style.borderTopWidth) || 0,
right: parseFloat(style.borderRightWidth) || 0,
@@ -1331,10 +1432,10 @@ function checkQuality(opts) {
left: parseFloat(style.borderLeftWidth) || 0,
};
const borderVisible = {
top: borderW.top > 0 && !isTransparent(style.borderTopColor),
right: borderW.right > 0 && !isTransparent(style.borderRightColor),
bottom: borderW.bottom > 0 && !isTransparent(style.borderBottomColor),
left: borderW.left > 0 && !isTransparent(style.borderLeftColor),
top: borderW.top > 0 && !cssColorIsTransparent(style.borderTopColor),
right: borderW.right > 0 && !cssColorIsTransparent(style.borderRightColor),
bottom: borderW.bottom > 0 && !cssColorIsTransparent(style.borderBottomColor),
left: borderW.left > 0 && !cssColorIsTransparent(style.borderLeftColor),
};
// Outline detection. jsdom decomposes `border` shorthand into
// border{Top,…}Width/Color but does NOT decompose `outline` —
@@ -1354,8 +1455,8 @@ function checkQuality(opts) {
if (cMatch) outlineColorVal = cMatch[1];
}
}
const outlineVisible = outlineW > 0 && !isTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none';
const bgVisible = !isTransparent(style.backgroundColor);
const outlineVisible = outlineW > 0 && !cssColorIsTransparent(outlineColorVal) && outlineStyleVal && outlineStyleVal !== 'none';
const bgVisible = hasVisibleBackgroundBoundary(style, el, win);
const anyVisible = borderVisible.top || borderVisible.right || borderVisible.bottom || borderVisible.left || outlineVisible || bgVisible;
if (anyVisible) {
@@ -1383,13 +1484,7 @@ function checkQuality(opts) {
const CHILD_INSULATE_THRESHOLD = 4;
const childrenInsulate = { top: false, right: false, bottom: false, left: false };
for (const child of el.children) {
let childStyle = null;
if (win && typeof win.getComputedStyle === 'function') {
try { childStyle = win.getComputedStyle(child); } catch {}
}
if (!childStyle && typeof getComputedStyle === 'function') {
try { childStyle = getComputedStyle(child); } catch {}
}
let childStyle = getComputedStyleFor(win, child);
if (!childStyle) continue;
const childPad = {
top: resolveLengthPx(childStyle.paddingTop, fontSize) ?? 0,
@@ -1397,15 +1492,37 @@ function checkQuality(opts) {
bottom: resolveLengthPx(childStyle.paddingBottom, fontSize) ?? 0,
left: resolveLengthPx(childStyle.paddingLeft, fontSize) ?? 0,
};
const childMargin = {
top: resolveLengthPx(childStyle.marginTop, fontSize) ?? 0,
right: resolveLengthPx(childStyle.marginRight, fontSize) ?? 0,
bottom: resolveLengthPx(childStyle.marginBottom, fontSize) ?? 0,
left: resolveLengthPx(childStyle.marginLeft, fontSize) ?? 0,
};
if (rect && typeof child.getBoundingClientRect === 'function') {
try {
const childRect = child.getBoundingClientRect();
if (childRect && childRect.width > 0 && childRect.height > 0) {
if (childRect.top - rect.top >= CHILD_INSULATE_THRESHOLD) childrenInsulate.top = true;
if (rect.right - childRect.right >= CHILD_INSULATE_THRESHOLD) childrenInsulate.right = true;
if (rect.bottom - childRect.bottom >= CHILD_INSULATE_THRESHOLD) childrenInsulate.bottom = true;
if (childRect.left - rect.left >= CHILD_INSULATE_THRESHOLD) childrenInsulate.left = true;
}
} catch {}
}
for (const s of ['top', 'right', 'bottom', 'left']) {
if (childPad[s] >= CHILD_INSULATE_THRESHOLD) childrenInsulate[s] = true;
if (childPad[s] >= CHILD_INSULATE_THRESHOLD || childMargin[s] >= CHILD_INSULATE_THRESHOLD) {
childrenInsulate[s] = true;
}
}
}
const textFlush = rect ? textDescendantsFlushSides(el, rect) : null;
const fullBleedBgBand = rect && viewportWidth > 0 && rect.width >= viewportWidth * 0.94 && bgVisible && !outlineVisible;
const flushSides = [];
for (const side of ['top', 'right', 'bottom', 'left']) {
const sideBounded = borderVisible[side] || outlineVisible || bgVisible;
if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side]) {
const bgBoundsSide = bgVisible && !(fullBleedBgBand && (side === 'left' || side === 'right'));
const sideBounded = borderVisible[side] || outlineVisible || bgBoundsSide;
if (sideBounded && pad[side] <= PAD_THRESHOLD && !childrenInsulate[side] && (!textFlush || textFlush[side])) {
flushSides.push(side);
}
}
@@ -1499,7 +1616,7 @@ function checkQuality(opts) {
// Only flag actual body content, not UI labels (buttons, tabs, badges, captions, footer text, etc.)
if (hasDirectText && textLen > 20 && fontSize < 12) {
const skipTags = ['sub', 'sup', 'code', 'kbd', 'samp', 'var', 'caption', 'figcaption'];
const inUIContext = el.closest && el.closest('button, a, label, summary, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [class*="badge" i], [class*="chip" i], [class*="pill" i], [class*="tag" i], [class*="label" i], [class*="caption" i]');
const inUIContext = el.closest && el.closest('button, a, label, summary, pre, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="option"], nav, footer, [aria-hidden="true"], [class*="badge" i], [class*="caption" i], [class*="chip" i], [class*="code" i], [class*="console" i], [class*="diff" i], [class*="label" i], [class*="meta" i], [class*="mock" i], [class*="pill" i], [class*="preview" i], [class*="tag" i], [class*="terminal" i], [class*="writes" i]');
const isUppercase = style.textTransform === 'uppercase';
if (!skipTags.includes(tag) && !inUIContext && !isUppercase) {
findings.push({ id: 'tiny-text', snippet: `${fontSize}px body text` });
@@ -2107,17 +2224,28 @@ function checkCreamPalette(doc, win) {
}
// ─── Oversized hero headline ────────────────────────────────────────────────
// Fires when a *long* headline is set at display size, so a full sentence ends
// up dominating the viewport. A punchy one- or two-word headline at the same
// size is a legitimate stylistic choice and must pass — length, not size
// alone, is the tell.
// Fires when a *long* headline is set at display size and actually dominates
// the viewport. A punchy one- or two-word headline at the same size is a
// legitimate stylistic choice, and a large-but-contained two-line hero should
// pass too — length and viewport share together are the tell.
const OVERSIZED_H1_FONT_PX = 72;
const OVERSIZED_H1_MIN_CHARS = 40;
function checkOversizedH1({ tag, fontSize, headingText }) {
const OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO = 0.28;
const OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO = 0.25;
function checkOversizedH1({ tag, fontSize, headingText, rect = null, viewportWidth = 0, viewportHeight = 0 }) {
if (tag !== 'h1') return [];
const textLen = headingText.length;
if (fontSize >= OVERSIZED_H1_FONT_PX && textLen >= OVERSIZED_H1_MIN_CHARS) {
return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars "${headingText.slice(0, 60)}"` }];
let viewportDetail = '';
if (rect && viewportWidth > 0 && viewportHeight > 0) {
const heightRatio = rect.height / viewportHeight;
const areaRatio = (rect.width * rect.height) / (viewportWidth * viewportHeight);
const dominatesViewport = heightRatio >= OVERSIZED_H1_MIN_VIEWPORT_HEIGHT_RATIO
|| areaRatio >= OVERSIZED_H1_MIN_VIEWPORT_AREA_RATIO;
if (!dominatesViewport) return [];
viewportDetail = `, ${Math.round(heightRatio * 100)}vh`;
}
return [{ id: 'oversized-h1', snippet: `${Math.round(fontSize)}px h1, ${textLen} chars${viewportDetail} "${headingText.slice(0, 60)}"` }];
}
return [];
}
@@ -2135,31 +2263,54 @@ function checkElementOversizedH1DOM(el) {
const style = getComputedStyle(el);
const fontSize = parseFloat(style.fontSize) || 0;
const headingText = (el.textContent || '').trim().replace(/\s+/g, ' ');
return checkOversizedH1({ tag, fontSize, headingText });
const rect = el.getBoundingClientRect();
const viewportWidth = (typeof window !== 'undefined' ? window.innerWidth : 0) || 0;
const viewportHeight = (typeof window !== 'undefined' ? window.innerHeight : 0) || 0;
return checkOversizedH1({ tag, fontSize, headingText, rect, viewportWidth, viewportHeight });
}
// ─── GPT tell: hairline border + wide diffuse shadow (gated --gpt) ────────────
function shadowMaxBlurPx(boxShadow) {
const CSS_COLOR_TOKEN_RE = /(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\([^)]*\)|#[0-9a-fA-F]{3,8}\b|\b(?:black|white|transparent|currentcolor)\b/gi;
function shadowLayerAlpha(layer) {
CSS_COLOR_TOKEN_RE.lastIndex = 0;
const match = CSS_COLOR_TOKEN_RE.exec(layer);
if (!match) return 1;
if (match[0].toLowerCase() === 'transparent') return 0;
const parsed = parseAnyColor(match[0]);
return parsed ? (parsed.a ?? 1) : 1;
}
function shadowMaxBlurPx(boxShadow, { minAlpha = 0 } = {}) {
if (!boxShadow || boxShadow === 'none') return 0;
let maxBlur = 0;
// Split into layers on commas not inside parentheses (rgba(...) etc.).
for (const layer of boxShadow.split(/,(?![^()]*\))/)) {
if (shadowLayerAlpha(layer) < minAlpha) continue;
// Strip colors and keywords (rgba()/hsl()/hex/named/inset/px), leaving the
// ordered length tokens: offsetX offsetY blur [spread]. Static jsdom keeps
// unitless zeros ("0 0 24px"); browsers normalize to px ("0px 0px 24px") —
// both reduce to the same numbers here.
const cleaned = layer.replace(/rgba?\([^)]*\)|hsla?\([^)]*\)|#[0-9a-f]+|\b[a-z]+\b/gi, ' ');
const cleaned = layer.replace(CSS_COLOR_TOKEN_RE, ' ').replace(/\b[a-z]+\b/gi, ' ');
const nums = [...cleaned.matchAll(/-?\d*\.?\d+/g)].map(m => parseFloat(m[0]));
if (nums.length >= 3) maxBlur = Math.max(maxBlur, nums[2]);
}
return maxBlur;
}
function checkGptThinBorderWideShadow({ borderWidths, boxShadow }) {
const maxBorder = Math.max(0, ...borderWidths);
const hasThinBorder = maxBorder > 0 && maxBorder <= 1.5;
const blur = shadowMaxBlurPx(boxShadow);
if (hasThinBorder && blur >= 16) {
function cssColorAlpha(value) {
if (cssColorIsTransparent(value)) return 0;
const parsed = parseAnyColor(value);
return parsed ? (parsed.a ?? 1) : 1;
}
function checkGptThinBorderWideShadow({ borderWidths, borderColors, boxShadow }) {
const visibleThinBorders = borderWidths
.map((width, index) => ({ width, alpha: cssColorAlpha(borderColors?.[index] || '') }))
.filter(({ width, alpha }) => width > 0 && width <= 1.5 && alpha >= 0.28);
const maxBorder = Math.max(0, ...visibleThinBorders.map(({ width }) => width));
const blur = shadowMaxBlurPx(boxShadow, { minAlpha: 0.12 });
if (visibleThinBorders.length >= 2 && blur >= 16) {
return [{ id: 'gpt-thin-border-wide-shadow', snippet: `${maxBorder}px border + ${Math.round(blur)}px shadow blur` }];
}
return [];
@@ -2174,13 +2325,22 @@ function borderWidthsFromStyle(style) {
];
}
function borderColorsFromStyle(style) {
return [
style.borderTopColor || '',
style.borderRightColor || '',
style.borderBottomColor || '',
style.borderLeftColor || '',
];
}
function checkElementGptBorderShadow(el, style) {
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' });
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' });
}
function checkElementGptBorderShadowDOM(el) {
const style = getComputedStyle(el);
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), boxShadow: style.boxShadow || '' });
return checkGptThinBorderWideShadow({ borderWidths: borderWidthsFromStyle(style), borderColors: borderColorsFromStyle(style), boxShadow: style.boxShadow || '' });
}
// ─── Clipped overflow container ───────────────────────────────────────────────
@@ -2193,17 +2353,131 @@ function classSelector(el) {
return tokens.length ? `${tag}.${tokens.join('.')}` : tag;
}
function positionedChildIsDecorative(child) {
if (!child || typeof child.getAttribute !== 'function') return false;
if (child.closest?.('[aria-hidden="true"]')) return true;
const role = (child.getAttribute('role') || '').toLowerCase();
if (role === 'none' || role === 'presentation') return true;
const tag = child.tagName ? child.tagName.toLowerCase() : '';
if (['img', 'svg', 'canvas', 'video'].includes(tag)) return true;
const ident = `${child.getAttribute('class') || ''} ${child.getAttribute('id') || ''}`;
if (
/\b(art|bg|background|badge|blob|crop|decor|dot|glow|grain|image|mask|ornament|overlay|photo|scrim|shadow|shine|texture)\b/i.test(ident) &&
!positionedChildHasSubstantiveContent(child)
) {
return true;
}
return false;
}
const POSITIONED_CHILD_INTERACTIVE_SELECTOR = [
'a[href]',
'button',
'input',
'select',
'summary',
'textarea',
'[tabindex]:not([tabindex="-1"])',
'[role="button"]',
'[role="dialog"]',
'[role="link"]',
'[role="listbox"]',
'[role="menu"]',
'[role="menuitem"]',
'[role="option"]',
'[role="tooltip"]',
].join(',');
function positionedChildHasSubstantiveContent(child) {
const text = (child.textContent || '').replace(/\s+/g, ' ').trim();
if (text.length > 0) return true;
if (typeof child.matches === 'function') {
try {
if (child.matches(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true;
} catch {}
}
if (typeof child.querySelector === 'function') {
try {
if (child.querySelector(POSITIONED_CHILD_INTERACTIVE_SELECTOR)) return true;
} catch {}
}
return false;
}
function clippingContainerIsIntentionalViewport(el) {
if (!el || typeof el.getAttribute !== 'function') return false;
const roleDescription = (el.getAttribute('aria-roledescription') || '').toLowerCase();
if (/\b(carousel|slider)\b/.test(roleDescription)) return true;
const ident = `${el.getAttribute('class') || ''} ${el.getAttribute('id') || ''}`.toLowerCase();
return /\b(carousel|comparison|compare|fisheye|marquee|preview|scroller|slider|slideshow|split|viewport)\b/.test(ident) ||
/\b(demo-area|demo-stage|demo-viewport)\b/.test(ident);
}
function elementRect(el) {
if (!el || typeof el.getBoundingClientRect !== 'function') return null;
try {
const rect = el.getBoundingClientRect();
if (!rect) return null;
const values = [rect.top, rect.right, rect.bottom, rect.left, rect.width, rect.height];
if (!values.every(Number.isFinite)) return null;
if (rect.width <= 0 && rect.height <= 0) return null;
return rect;
} catch {
return null;
}
}
function positionedStyleImpliesEscape(style) {
const values = [
style.top,
style.right,
style.bottom,
style.left,
style.inset,
style.insetBlock,
style.insetInline,
style.insetBlockStart,
style.insetBlockEnd,
style.insetInlineStart,
style.insetInlineEnd,
].filter(Boolean).map(value => String(value).trim().toLowerCase());
for (const value of values) {
if (/(^|[\s(])-+(?:\d|\.)/.test(value)) return true;
if (/(^|[\s(])100(?:\.0+)?%/.test(value)) return true;
}
return false;
}
function positionedChildEscapesClip(el, child, clipX, clipY) {
const parentRect = elementRect(el);
const childRect = elementRect(child);
if (!parentRect || !childRect) return null;
const threshold = 2;
return Boolean(
(clipX && (childRect.left < parentRect.left - threshold || childRect.right > parentRect.right + threshold)) ||
(clipY && (childRect.top < parentRect.top - threshold || childRect.bottom > parentRect.bottom + threshold))
);
}
function checkClippedOverflow(el, style, getStyle) {
const clips = (v) => v === 'hidden' || v === 'clip';
const scrolls = (v) => v === 'auto' || v === 'scroll';
const ox = style.overflowX || '', oy = style.overflowY || '', ov = style.overflow || '';
const anyClip = clips(ox) || clips(oy) || clips(ov);
const clipX = clips(ox) || clips(ov);
const clipY = clips(oy) || clips(ov);
const anyClip = clipX || clipY;
const anyScroll = scrolls(ox) || scrolls(oy) || scrolls(ov);
if (!anyClip || anyScroll) return [];
if (clippingContainerIsIntentionalViewport(el)) return [];
if (!el.querySelectorAll) return [];
for (const child of el.querySelectorAll('*')) {
const pos = (getStyle(child).position) || '';
const childStyle = getStyle(child);
const pos = childStyle.position || '';
if (pos === 'absolute' || pos === 'fixed') {
if (positionedChildIsDecorative(child)) continue;
const escapes = positionedChildEscapesClip(el, child, clipX, clipY);
if (escapes === false) continue;
if (escapes === null && !positionedStyleImpliesEscape(childStyle)) continue;
return [{ id: 'clipped-overflow-container', snippet: `${classSelector(el)} clips a positioned child` }];
}
}
@@ -2222,14 +2496,94 @@ function checkElementClippedOverflowDOM(el) {
// ─── Text overflow (browser-only: needs scrollWidth/clientWidth) ──────────────
const TEXT_OVERFLOW_SKIP_TAGS = new Set(['pre', 'code', 'textarea', 'svg', 'canvas', 'select', 'option', 'marquee']);
function metricLengthPx(value, fontSizePx = 16) {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value !== 'string') return null;
return resolveLengthPx(value, fontSizePx);
}
function firstMetricLengthPx(fontSizePx, ...values) {
for (const value of values) {
const parsed = metricLengthPx(value, fontSizePx);
if (parsed !== null) return parsed;
}
return null;
}
function expandBoxShorthand(parts) {
if (parts.length === 1) return [parts[0], parts[0], parts[0], parts[0]];
if (parts.length === 2) return [parts[0], parts[1], parts[0], parts[1]];
if (parts.length === 3) return [parts[0], parts[1], parts[2], parts[1]];
return [parts[0], parts[1], parts[2], parts[3]];
}
function clippedByInset(clipPath) {
const match = String(clipPath || '').trim().toLowerCase().match(/^inset\s*\(([^)]*)\)$/);
if (!match) return false;
const beforeRound = match[1].split(/\s+round\s+/)[0].trim();
if (!beforeRound) return false;
const values = expandBoxShorthand(beforeRound.split(/\s+/).slice(0, 4));
const percents = values.map(value => String(value).trim().match(/^(-?\d+(?:\.\d+)?)%$/));
if (percents.some(match => !match)) return false;
const [top, right, bottom, left] = percents.map(match => parseFloat(match[1]));
return top + bottom >= 100 || left + right >= 100;
}
function clippedByRect(clip) {
const match = String(clip || '').trim().toLowerCase().match(/^rect\s*\(([^)]*)\)$/);
if (!match) return false;
const values = match[1].split(/[,\s]+/).map(value => value.trim()).filter(Boolean);
if (values.length !== 4) return false;
const [top, right, bottom, left] = values.map(value => metricLengthPx(value, 16));
if ([top, right, bottom, left].some(value => value === null)) return false;
return bottom <= top || right <= left;
}
function isScreenReaderOnlyTextStyle(style, metrics = {}) {
if (!style) return false;
const overflowValues = [style.overflow, style.overflowX, style.overflowY]
.map(value => String(value || '').toLowerCase());
const clipsOverflow = overflowValues.some(value => value === 'hidden' || value === 'clip');
const fontSize = metricLengthPx(style.fontSize, 16) || 16;
const width = firstMetricLengthPx(fontSize, metrics.width, metrics.clientWidth, style.width, style.inlineSize);
const height = firstMetricLengthPx(fontSize, metrics.height, metrics.clientHeight, style.height, style.blockSize);
const isTiny = width !== null && height !== null && width <= 2 && height <= 2;
const isAbsolutelyHidden = String(style.position || '').toLowerCase() === 'absolute' && isTiny && clipsOverflow;
const clipPath = String(style.clipPath || style.webkitClipPath || '').trim();
const clip = String(style.clip || '').trim();
return isAbsolutelyHidden || clippedByInset(clipPath) || clippedByRect(clip);
}
function isRenderedForBrowserRule(el) {
for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {
if (cur.getAttribute?.('aria-hidden') === 'true') return false;
const style = getComputedStyle(cur);
const visibility = String(style.visibility || '').toLowerCase();
if (style.display === 'none' || visibility === 'hidden' || visibility === 'collapse') return false;
if ((parseFloat(style.opacity) || 0) <= 0.01) return false;
if (String(style.contentVisibility || '').toLowerCase() === 'hidden') return false;
}
return true;
}
function checkElementTextOverflowDOM(el) {
const tag = el.tagName.toLowerCase();
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
if (!isRenderedForBrowserRule(el)) return [];
// Only the element that actually owns overflowing text — not its ancestors,
// which inherit a wider scrollWidth from the spilling descendant.
const hasDirectText = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
if (!hasDirectText) return [];
const style = getComputedStyle(el);
const rect = el.getBoundingClientRect ? el.getBoundingClientRect() : null;
if (isScreenReaderOnlyTextStyle(style, {
width: rect?.width,
height: rect?.height,
clientWidth: el.clientWidth,
clientHeight: el.clientHeight,
})) return [];
const isScrollRegion = (s) => /(auto|scroll)/.test(s.overflowX || '') || /(auto|scroll)/.test(s.overflow || '');
if (isScrollRegion(style)) return [];
// A scrollable ancestor means this overflow is intentional and scrollable.
@@ -2312,5 +2666,6 @@ export {
checkClippedOverflow,
checkElementClippedOverflow,
checkElementClippedOverflowDOM,
isScreenReaderOnlyTextStyle,
checkElementTextOverflowDOM,
};
@@ -0,0 +1,636 @@
#!/usr/bin/env node
/**
* `/impeccable hooks <on|off|status|reset>` manage the design hook runtime
* via the `hook` key and shared detector ignores via the `detector` key in
* .impeccable/config.json / .impeccable/config.local.json.
*
* Usage:
* node hook-admin.mjs status # print current state
* node hook-admin.mjs on # set enabled: true
* node hook-admin.mjs off # set enabled: false
* node hook-admin.mjs ignore-rule <rule-id> # append to ignoreRules
* node hook-admin.mjs ignore-rule overused-font --all-values
* node hook-admin.mjs ignore-file <glob> # append to ignoreFiles
* node hook-admin.mjs ignore-value <rule> <value> # append to shared ignoreValues
* node hook-admin.mjs ignore-value <rule> <value> --local
* node hook-admin.mjs reset # remove all config + cache
*
* Designed to be invoked by the LLM from the reference/hooks.md flow.
* Output is human-readable; the harness will pass it back to the user.
*/
import fs from 'node:fs';
import path from 'node:path';
import {
getConfigPath,
getLocalConfigPath,
getCachePath,
getPendingPath,
readConfig,
DEFAULT_CONFIG,
ensureHookGitExcludes,
normalizeIgnoreValue,
normalizeIgnoreValueEntries,
} from './hook-lib.mjs';
const ACTIONS = new Set(['status', 'on', 'off', 'ignore-rule', 'ignore-file', 'ignore-value', 'reset']);
const IMPECCABLE_HOOK_COMMAND_MARKERS = [
'skills/impeccable/scripts/hook-probe.mjs',
'skills/impeccable/scripts/hook.mjs',
'skills/impeccable/scripts/hook-before-edit.mjs',
'skills/impeccable/scripts/hook-after-edit.mjs',
'skills/impeccable/scripts/hook-stop.mjs',
];
const TIMEOUT_SECONDS = 5;
const STATUS_MESSAGE = 'Checking UI changes';
const HOOK_MANIFEST_TARGETS = [
{
provider: '.claude',
skillRel: '.claude/skills/impeccable',
destRel: '.claude/settings.local.json',
sharedDestRel: '.claude/settings.json',
manifest: () => ({
description: 'Impeccable design detector: runs after Edit/Write/MultiEdit on UI files and surfaces findings as system reminders.',
hooks: {
PostToolUse: [
{
matcher: 'Edit|Write|MultiEdit',
hooks: [
{
type: 'command',
command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.agents',
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: [
{
matcher: 'Edit|Write|apply_patch',
hooks: [
{
type: 'command',
command: 'node "$(git rev-parse --show-toplevel)/.agents/skills/impeccable/scripts/hook.mjs"',
timeout: TIMEOUT_SECONDS,
statusMessage: STATUS_MESSAGE,
},
],
},
],
},
}),
},
{
provider: '.cursor',
skillRel: '.cursor/skills/impeccable',
destRel: '.cursor/hooks.json',
manifest: () => ({
version: 1,
hooks: {
preToolUse: [
{
command: 'node ".cursor/skills/impeccable/scripts/hook-before-edit.mjs"',
timeout: TIMEOUT_SECONDS,
},
],
},
}),
},
];
function readRawConfigFile(filePath) {
if (!fs.existsSync(filePath)) return { exists: false, malformed: false, raw: null };
try {
return { exists: true, malformed: false, raw: JSON.parse(fs.readFileSync(filePath, 'utf-8')) };
} catch {
return { exists: true, malformed: true, raw: null };
}
}
const DETECTOR_CONFIG_KEYS = new Set(['ignoreRules', 'ignoreFiles', 'ignoreValues', 'designSystem']);
function hookSection(unified) {
return unified && typeof unified === 'object' && !Array.isArray(unified) && unified.hook && typeof unified.hook === 'object' && !Array.isArray(unified.hook)
? unified.hook
: null;
}
function detectorSection(unified) {
return unified && typeof unified === 'object' && !Array.isArray(unified) && unified.detector && typeof unified.detector === 'object' && !Array.isArray(unified.detector)
? unified.detector
: null;
}
function readRawHookConfig(cwd, opts = {}) {
const unified = readRawConfigFile(opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd)).raw;
return hookSection(unified);
}
function readRawDetectorConfig(cwd, opts = {}) {
const unified = readRawConfigFile(opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd)).raw;
const merged = mergeDetectorConfig(hookSection(unified));
return mergeDetectorConfig(detectorSection(unified), merged);
}
function stripDetectorKeys(raw) {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return {};
const out = {};
for (const [key, value] of Object.entries(raw)) {
if (!DETECTOR_CONFIG_KEYS.has(key)) out[key] = value;
}
return out;
}
// Write hook runtime config under `hook`, leaving detector filters in
// `detector` and preserving sibling keys such as updateCheck.
function writeHookConfig(cwd, hookConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
if (opts.local) ensureHookGitExcludes(cwd);
const existingRaw = readRawConfigFile(filePath).raw;
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const existingHook = stripDetectorKeys(hookSection(existing));
// Merge over the existing hook object so fields the merge helpers don't manage
// (consent, quiet, auditLog) survive a `/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');
return filePath;
}
function writeDetectorConfig(cwd, detectorConfig, opts = {}) {
const filePath = opts.local ? getLocalConfigPath(cwd) : getConfigPath(cwd);
if (opts.local) ensureHookGitExcludes(cwd);
const existingRaw = readRawConfigFile(filePath).raw;
const existing = existingRaw && typeof existingRaw === 'object' && !Array.isArray(existingRaw) ? existingRaw : {};
const nextHook = stripDetectorKeys(hookSection(existing));
const existingDetector = mergeDetectorConfig(detectorSection(existing));
const next = {
...existing,
detector: mergeDetectorConfig(detectorConfig, existingDetector),
};
if (Object.keys(nextHook).length > 0) next.hook = nextHook;
else delete next.hook;
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(next, null, 2) + '\n');
return filePath;
}
function mergeHookConfig(existing) {
const base = existing && typeof existing === 'object' ? existing : {};
return {
enabled: base.enabled === false ? false : true,
limits: {
maxFindings: Number.isFinite(base?.limits?.maxFindings) ? base.limits.maxFindings : DEFAULT_CONFIG.limits.maxFindings,
maxChars: Number.isFinite(base?.limits?.maxChars) ? base.limits.maxChars : DEFAULT_CONFIG.limits.maxChars,
},
};
}
function mergeDetectorConfig(existing, seed = null) {
const base = existing && typeof existing === 'object' ? existing : {};
const out = seed ? {
ignoreRules: [...seed.ignoreRules],
ignoreFiles: [...seed.ignoreFiles],
ignoreValues: normalizeIgnoreValueEntries(seed.ignoreValues),
} : {
ignoreRules: [],
ignoreFiles: [],
ignoreValues: [],
};
if (seed?.designSystem && typeof seed.designSystem === 'object' && !Array.isArray(seed.designSystem)) {
out.designSystem = { ...seed.designSystem };
}
if (base.designSystem && typeof base.designSystem === 'object' && !Array.isArray(base.designSystem)) {
out.designSystem = {
...(out.designSystem || {}),
enabled: base.designSystem.enabled === false ? false : true,
};
}
if (Array.isArray(base.ignoreRules)) {
out.ignoreRules = Array.from(new Set([...out.ignoreRules, ...base.ignoreRules.map(String)]));
}
if (Array.isArray(base.ignoreFiles)) {
out.ignoreFiles = Array.from(new Set([...out.ignoreFiles, ...base.ignoreFiles.map(String)]));
}
if (Array.isArray(base.ignoreValues)) {
out.ignoreValues = mergeIgnoreValueEntries(out.ignoreValues, base.ignoreValues);
}
return out;
}
function mergeIgnoreValueEntries(existing, incoming) {
const map = new Map();
for (const entry of normalizeIgnoreValueEntries(existing)) {
map.set(ignoreValueEntryKey(entry), entry);
}
for (const entry of normalizeIgnoreValueEntries(incoming)) {
map.set(ignoreValueEntryKey(entry), entry);
}
return Array.from(map.values());
}
function ignoreValueEntryKey(entry) {
const files = Array.isArray(entry.files) && entry.files.length > 0 ? entry.files.join('\x1f') : '';
return `${entry.rule}\0${entry.value}\0${files}`;
}
function statusReport(cwd) {
const shared = readRawConfigFile(getConfigPath(cwd));
const local = readRawConfigFile(getLocalConfigPath(cwd));
const cfg = readConfig(cwd);
const envKill = process.env.IMPECCABLE_HOOK_DISABLED;
const envState = envKill ? `IMPECCABLE_HOOK_DISABLED=${envKill}` : 'unset';
const cfgPath = path.relative(cwd, getConfigPath(cwd)) || '.impeccable/config.json';
const localPath = path.relative(cwd, getLocalConfigPath(cwd)) || '.impeccable/config.local.json';
const cachePath = path.relative(cwd, getCachePath(cwd)) || '.impeccable/hook.cache.json';
const fileState = (info, relPath, absent) => {
if (info.malformed) return `${relPath} (malformed; ignored)`;
if (info.exists) return relPath;
return `${relPath} (${absent})`;
};
const ignoreValues = cfg.ignoreValues.map((entry) => `${entry.rule}=${entry.value}`);
const lines = [
`Impeccable design hook`,
` state: ${cfg.enabled ? 'enabled' : 'disabled'}`,
` shared file: ${fileState(shared, cfgPath, 'using defaults; file not present')}`,
` local file: ${fileState(local, localPath, 'not present')}`,
` ignoreRules: ${cfg.ignoreRules.length ? cfg.ignoreRules.join(', ') : '(none)'}`,
` ignoreFiles: ${cfg.ignoreFiles.length ? cfg.ignoreFiles.join(', ') : '(none)'}`,
` ignoreValues: ${ignoreValues.length ? ignoreValues.join(', ') : '(none)'}`,
` maxFindings: ${cfg.limits.maxFindings}`,
` maxChars: ${cfg.limits.maxChars}`,
` env override: ${envState}`,
` cache file: ${fs.existsSync(getCachePath(cwd)) ? cachePath : `${cachePath} (not present)`}`,
];
return lines.join('\n');
}
function setEnabled(cwd, value) {
const config = mergeHookConfig(readRawHookConfig(cwd));
config.enabled = value;
const target = writeHookConfig(cwd, config);
if (!value) {
return `Design hook disabled for this project (wrote ${path.relative(cwd, target) || target}).`;
}
const localTarget = writeHookConfig(cwd, { consent: 'accepted' }, { local: true });
const repaired = repairHookManifests(cwd);
const parts = [
`Design hook enabled for this project (wrote ${path.relative(cwd, target) || target}).`,
`Recorded local hook consent in ${path.relative(cwd, localTarget) || localTarget}.`,
];
if (repaired.written.length > 0) {
parts.push(`Installed or repaired hook manifests for: ${repaired.written.join(', ')}.`);
} else if (repaired.already.length > 0) {
parts.push(`Hook manifests already installed for: ${repaired.already.join(', ')}.`);
} else {
parts.push('No installed provider skill folders found to repair.');
}
if (repaired.backups.length > 0) {
parts.push(`Backed up malformed manifest(s): ${repaired.backups.map((filePath) => path.relative(cwd, filePath) || filePath).join(', ')}.`);
}
return parts.join(' ');
}
function repairHookManifests(cwd) {
const result = { written: [], already: [], backups: [] };
for (const target of HOOK_MANIFEST_TARGETS) {
if (!fs.existsSync(path.join(cwd, target.skillRel))) continue;
const dest = path.join(cwd, target.destRel);
const sharedDest = target.sharedDestRel ? path.join(cwd, target.sharedDestRel) : null;
if (sharedDest && fileHasImpeccableHookMarker(sharedDest)) {
pruneImpeccableHookFromManifest(dest);
result.already.push(target.provider);
continue;
}
const fresh = target.manifest();
let next = fresh;
if (fs.existsSync(dest)) {
try {
next = mergeHookManifests(JSON.parse(fs.readFileSync(dest, 'utf-8')), fresh);
} catch {
const backup = `${dest}.bak`;
fs.copyFileSync(dest, backup);
result.backups.push(backup);
}
}
const serialized = `${JSON.stringify(next, null, 2)}\n`;
const current = fs.existsSync(dest) ? safeReadText(dest) : null;
if (current === serialized) {
result.already.push(target.provider);
continue;
}
fs.mkdirSync(path.dirname(dest), { recursive: true });
fs.writeFileSync(dest, serialized);
result.written.push(target.provider);
}
return result;
}
function safeReadText(filePath) {
try {
return fs.readFileSync(filePath, 'utf-8');
} catch {
return null;
}
}
function mergeHookManifests(existing, fresh) {
const existingObject = existing && typeof existing === 'object' && !Array.isArray(existing) ? existing : {};
const freshObject = fresh && typeof fresh === 'object' && !Array.isArray(fresh) ? fresh : {};
const existingHooks = existingObject.hooks && typeof existingObject.hooks === 'object' && !Array.isArray(existingObject.hooks)
? existingObject.hooks
: {};
const freshHooks = freshObject.hooks && typeof freshObject.hooks === 'object' && !Array.isArray(freshObject.hooks)
? freshObject.hooks
: {};
const merged = { ...existingObject, hooks: {} };
if (freshObject.version !== undefined) merged.version = freshObject.version;
if (freshObject.description !== undefined) merged.description = freshObject.description;
const hookEvents = new Set([...Object.keys(existingHooks), ...Object.keys(freshHooks)]);
for (const event of hookEvents) {
const preserved = stripImpeccableHookEntries(existingHooks[event]);
const added = Array.isArray(freshHooks[event]) ? freshHooks[event] : [];
const mergedEntries = [...preserved, ...added];
if (mergedEntries.length > 0) merged.hooks[event] = mergedEntries;
}
return merged;
}
function fileHasImpeccableHookMarker(filePath) {
if (!fs.existsSync(filePath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
} catch {
return false;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
if (!parsed.hooks || typeof parsed.hooks !== 'object') return false;
return valueHasImpeccableHookMarker(parsed.hooks);
}
function valueHasImpeccableHookMarker(value) {
if (typeof value === 'string') {
return IMPECCABLE_HOOK_COMMAND_MARKERS.some((marker) => value.includes(marker));
}
if (Array.isArray(value)) return value.some(valueHasImpeccableHookMarker);
if (value && typeof value === 'object') return Object.values(value).some(valueHasImpeccableHookMarker);
return false;
}
function stripImpeccableHookEntry(entry) {
if (!entry || typeof entry !== 'object') return entry;
if (valueHasImpeccableHookMarker(entry.command) || valueHasImpeccableHookMarker(entry.args)) {
return null;
}
if (!Array.isArray(entry.hooks)) return entry;
const strippedHooks = entry.hooks
.map(stripImpeccableHookEntry)
.filter(Boolean);
if (strippedHooks.length === 0 && entry.hooks.some(valueHasImpeccableHookMarker)) {
return null;
}
return { ...entry, hooks: strippedHooks };
}
function stripImpeccableHookEntries(entries) {
if (!Array.isArray(entries)) return [];
return entries
.map(stripImpeccableHookEntry)
.filter(Boolean);
}
function pruneImpeccableHookFromManifest(manifestPath) {
if (!fileHasImpeccableHookMarker(manifestPath)) return false;
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
} catch {
return false;
}
const existingHooks = parsed.hooks && typeof parsed.hooks === 'object' && !Array.isArray(parsed.hooks)
? parsed.hooks
: {};
const cleanedHooks = {};
for (const [event, entries] of Object.entries(existingHooks)) {
const kept = stripImpeccableHookEntries(entries);
if (kept.length > 0) cleanedHooks[event] = kept;
}
const next = { ...parsed };
if (Object.keys(cleanedHooks).length > 0) {
next.hooks = cleanedHooks;
} else {
delete next.hooks;
delete next.description;
delete next.version;
}
if (Object.keys(next).length === 0) {
fs.rmSync(manifestPath, { force: true });
} else {
fs.writeFileSync(manifestPath, `${JSON.stringify(next, null, 2)}\n`);
}
return true;
}
function normalizeRuleId(rule) {
return String(rule || '').trim().toLowerCase();
}
function parseIgnoreRuleArgs(args) {
const positionals = [];
let allValues = false;
for (let i = 0; i < args.length; i++) {
const arg = String(args[i] || '');
if (arg === '--all-values') {
allValues = true;
} else if (arg === '--reason') {
while (i + 1 < args.length && !String(args[i + 1]).startsWith('--')) i++;
} else if (arg.startsWith('--reason=')) {
// Accepted for command symmetry; ignoreRules stores rule ids only.
} else if (arg.startsWith('--')) {
throw new Error(`Unknown ignore-rule flag: ${arg}`);
} else {
positionals.push(arg);
}
}
return {
rule: normalizeRuleId(positionals[0]),
allValues,
};
}
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 === '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.');
}
const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
if (!config.ignoreRules.includes(rule)) config.ignoreRules.push(rule);
writeDetectorConfig(cwd, config);
return `Added "${rule}" to detector.ignoreRules. Current: ${config.ignoreRules.join(', ')}`;
}
function addIgnoreFile(cwd, glob) {
if (!glob) throw new Error('Pass a glob, e.g. /impeccable hooks ignore-file "src/legacy/**"');
const config = mergeDetectorConfig(readRawDetectorConfig(cwd));
if (!config.ignoreFiles.includes(glob)) config.ignoreFiles.push(glob);
writeDetectorConfig(cwd, config);
return `Added "${glob}" to detector.ignoreFiles. Current: ${config.ignoreFiles.join(', ')}`;
}
function parseIgnoreValueArgs(args) {
const positionals = [];
let shared = false;
let local = false;
let reason = '';
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--shared') {
shared = true;
} else if (arg === '--local') {
local = true;
} else if (arg === '--reason') {
const chunks = [];
while (i + 1 < args.length && !String(args[i + 1]).startsWith('--')) {
chunks.push(args[++i]);
}
reason = chunks.join(' ').trim();
} else if (String(arg).startsWith('--reason=')) {
reason = String(arg).slice('--reason='.length).trim();
} else {
positionals.push(arg);
}
}
const [rule, ...valueParts] = positionals;
return {
rule: String(rule || '').trim().toLowerCase(),
value: normalizeIgnoreValue(valueParts.join(' ')),
shared,
local,
reason,
};
}
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');
}
if (parsed.shared && parsed.local) {
throw new Error('Pass only one scope flag: --shared or --local');
}
const local = parsed.local;
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local }));
const key = `${parsed.rule}\0${parsed.value}`;
const existing = config.ignoreValues.find((entry) => `${entry.rule}\0${entry.value}` === key);
if (existing) {
if (parsed.reason) existing.reason = parsed.reason;
} else {
const entry = {
rule: parsed.rule,
value: parsed.value,
createdAt: new Date().toISOString(),
};
if (parsed.reason) entry.reason = parsed.reason;
config.ignoreValues.push(entry);
}
const target = writeDetectorConfig(cwd, config, { local });
const scope = local ? 'local detector.ignoreValues' : 'shared detector.ignoreValues';
return `Added ${parsed.rule}=${parsed.value} to ${scope} (${path.relative(cwd, target) || target}).`;
}
function reset(cwd) {
const removed = [];
// Unified files may hold non-hook keys (e.g. updateCheck); strip only the
// hook/detector subtrees and keep the rest, deleting the file only if nothing remains.
for (const filePath of [getConfigPath(cwd), getLocalConfigPath(cwd)]) {
try {
const raw = readRawConfigFile(filePath).raw;
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || (!('hook' in raw) && !('detector' in raw))) continue;
const { hook, detector, ...rest } = raw;
if (Object.keys(rest).length === 0) {
fs.unlinkSync(filePath);
} else {
fs.writeFileSync(filePath, JSON.stringify(rest, null, 2) + '\n');
}
removed.push(path.relative(cwd, filePath) || filePath);
} catch { /* ignore */ }
}
// State files are wholly ours; delete outright.
for (const filePath of [getCachePath(cwd), getPendingPath(cwd)]) {
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
removed.push(path.relative(cwd, filePath) || filePath);
}
} catch { /* ignore */ }
}
return removed.length
? `Reset design hook config and cache (removed: ${removed.join(', ')}).`
: 'No hook config or cache to remove. Already at defaults.';
}
function main() {
const [, , actionArg, ...rest] = process.argv;
const action = (actionArg || 'status').toLowerCase();
const cwd = process.cwd();
if (!ACTIONS.has(action)) {
process.stderr.write(`Unknown action: ${action}\nValid: ${Array.from(ACTIONS).join(', ')}\n`);
process.exit(1);
}
try {
let out = '';
switch (action) {
case 'status': out = statusReport(cwd); break;
case 'on': out = setEnabled(cwd, true); break;
case 'off': out = setEnabled(cwd, false); break;
case 'ignore-rule': out = addIgnoreRule(cwd, rest); break;
case 'ignore-file': out = addIgnoreFile(cwd, rest[0]); break;
case 'ignore-value': out = addIgnoreValue(cwd, rest); break;
case 'reset': out = reset(cwd); break;
}
process.stdout.write(out + '\n');
} catch (err) {
process.stderr.write(`Error: ${err.message || err}\n`);
process.exit(1);
}
}
main();
@@ -0,0 +1,476 @@
#!/usr/bin/env node
/**
* Impeccable design hook Cursor preToolUse write gate.
*
* Cursor's stop hook is not consistently dispatched by the headless agent, so
* this hook checks proposed Write/Edit content before it lands. It only denies
* writes when the real detector finds an issue in the proposed UI content.
*
* Contract: never break a turn accidentally. On malformed input or internal
* errors, allow the tool and exit 0.
*/
import fs from 'node:fs';
import path from 'node:path';
import {
ALLOWED_EXTS,
EDIT_COUNT_THRESHOLD,
GENERATED_PATH,
SENSITIVE_PATH,
appendDesignSystemNote,
designSystemOptions,
filterFindings,
loadDetector,
matchesAnyGlob,
persistCache,
readCache,
readConfig,
renderTemplate,
resolveProjectCwd,
truthy,
writeAuditLog,
} from './hook-lib.mjs';
async function readStdin() {
if (process.stdin.isTTY) return '';
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
return Buffer.concat(chunks).toString('utf-8');
}
function done(payload = null) {
if (payload) process.stdout.write(JSON.stringify(payload));
process.exit(0);
}
function allow(extra = {}, payload = {}) {
writeAuditLog(process.env, {
ts: new Date().toISOString(),
event: 'preToolUse',
...extra,
});
return done({ permission: 'allow', ...payload });
}
function deny(message, audit) {
writeAuditLog(process.env, {
ts: new Date().toISOString(),
event: 'preToolUse',
blocked: true,
...audit,
});
return done({
permission: 'deny',
user_message: message,
agent_message: message,
});
}
function toolInput(event) {
return event?.tool_input && typeof event.tool_input === 'object' ? event.tool_input : {};
}
function proposedFilePath(event, cwd) {
const input = toolInput(event);
const raw = input.file_path || input.path || input.target_file || event?.file_path;
const candidate = typeof raw === 'string' && raw.trim()
? raw
: shellWriteDestination(shellCommand(input));
if (typeof candidate !== 'string' || !candidate.trim()) return '';
return path.isAbsolute(candidate) ? candidate : path.resolve(cwd, candidate);
}
function proposedContent(event, cwd, filePath) {
const input = toolInput(event);
for (const key of ['content', 'streamContent', 'text']) {
if (typeof input[key] === 'string') return input[key];
}
const editProjection = projectedEditContent(input, filePath, cwd);
if (editProjection !== undefined) return editProjection;
if (hasFragmentEditContent(input)) {
return { skipped: 'fragment-only-edit' };
}
const command = shellCommand(input);
const pythonContent = shellPythonWriteContent(command);
if (pythonContent) return pythonContent;
const shellContent = shellHereDocContent(command);
if (shellContent) return shellContent;
const copiedContent = shellCopiedFileContent(command, cwd);
if (copiedContent) return copiedContent;
return '';
}
function hasFragmentEditContent(input) {
if (!input || typeof input !== 'object') return false;
if (typeof input.new_string === 'string' || typeof input.newString === 'string' || typeof input.new_str === 'string' || typeof input.replacement === 'string') {
return true;
}
return Array.isArray(input.edits) && input.edits.some((edit) => edit && typeof edit === 'object');
}
function projectedEditContent(input, filePath, cwd) {
if (!filePath) return undefined;
const singleOld = firstString(input, ['old_string', 'oldString', 'old_str', 'target']);
const singleNew = firstString(input, ['new_string', 'newString', 'new_str', 'replacement']);
if (singleOld !== undefined || singleNew !== undefined) {
if (singleOld === undefined || singleNew === undefined) return { skipped: 'fragment-only-edit' };
const original = readExistingProjectFile(filePath, cwd);
if (original === null) return { skipped: 'edit-original-unreadable' };
const projected = replaceOnce(original, singleOld, singleNew);
return projected === null ? { skipped: 'edit-old-string-missing' } : projected;
}
if (!Array.isArray(input.edits)) return undefined;
const original = readExistingProjectFile(filePath, cwd);
if (original === null) return { skipped: 'edit-original-unreadable' };
let projected = original;
for (const edit of input.edits) {
if (!edit || typeof edit !== 'object') return { skipped: 'fragment-only-edit' };
const oldString = firstString(edit, ['old_string', 'oldString', 'old_str', 'target']);
const newString = firstString(edit, ['new_string', 'newString', 'new_str', 'replacement']);
if (oldString === undefined || newString === undefined) return { skipped: 'fragment-only-edit' };
const next = replaceOnce(projected, oldString, newString);
if (next === null) return { skipped: 'edit-old-string-missing' };
projected = next;
}
return projected;
}
function firstString(obj, keys) {
for (const key of keys) {
if (typeof obj?.[key] === 'string') return obj[key];
}
return undefined;
}
function replaceOnce(original, oldString, newString) {
if (oldString === '') return null;
const index = original.indexOf(oldString);
if (index === -1) return null;
return `${original.slice(0, index)}${newString}${original.slice(index + oldString.length)}`;
}
function readExistingProjectFile(filePath, cwd) {
if (!isInsideProject(filePath, cwd)) return null;
if (SENSITIVE_PATH.test(filePath) || GENERATED_PATH.test(filePath)) return null;
try {
const stat = fs.statSync(filePath);
if (!stat.isFile() || stat.size > 1024 * 1024) return null;
return fs.readFileSync(filePath, 'utf-8');
} catch {
return null;
}
}
function shellCommand(input) {
if (typeof input.command === 'string') return input.command;
if (input.args && typeof input.args.command === 'string') return input.args.command;
return '';
}
function shellRedirectPath(command) {
if (!command || typeof command !== 'string') return '';
const match = command.match(/(?:^|[\s;&|])(?:>>?|1>>?)\s*(?:"([^"]+)"|'([^']+)'|([^<>\s]+))/);
return (match?.[1] || match?.[2] || match?.[3] || '').trim();
}
function shellWriteDestination(command) {
return shellRedirectPath(command) || shellTeeDestination(command) || shellCopyPaths(command)?.dest || shellPythonWriteDestination(command) || '';
}
function shellPythonWriteDestination(command) {
if (!/\bpython(?:3)?\b/.test(command || '')) return '';
const directPath = firstMatch(command, /(?:^|[^\w.])(?:pathlib\.)?Path\(\s*(["'])(.*?)\1\s*\)\s*\.write_text\s*\(/);
if (directPath) return directPath;
const pathsByVar = new Map();
const assignmentRe = /\b([A-Za-z_]\w*)\s*=\s*(?:pathlib\.)?Path\(\s*(["'])(.*?)\2\s*\)/g;
let assignment;
while ((assignment = assignmentRe.exec(command))) {
pathsByVar.set(assignment[1], assignment[3]);
}
const writeVarRe = /\b([A-Za-z_]\w*)\.write_text\s*\(/g;
let writeVar;
while ((writeVar = writeVarRe.exec(command))) {
const candidate = pathsByVar.get(writeVar[1]);
if (candidate) return candidate;
}
return firstMatch(command, /\bopen\(\s*(["'])(.*?)\1\s*,\s*(["'])[wax](?:\+)?b?\3/);
}
function firstMatch(value, re) {
const match = String(value || '').match(re);
return (match?.[2] || '').trim();
}
function shellTeeDestination(command) {
const words = shellWords(command);
const teeIndex = words.findIndex((word) => path.basename(word) === 'tee');
if (teeIndex === -1) return '';
for (const word of words.slice(teeIndex + 1)) {
if (['&&', '||', ';', '|'].includes(word)) break;
if (word === '--') continue;
if (word.startsWith('-')) continue;
return word;
}
return '';
}
function shellCopiedFileContent(command, cwd) {
const source = shellCopyPaths(command)?.source;
if (!source) return '';
const sourcePath = path.isAbsolute(source) ? source : path.resolve(cwd, source);
if (!isInsideProject(sourcePath, cwd)) return '';
if (SENSITIVE_PATH.test(sourcePath) || GENERATED_PATH.test(sourcePath)) return '';
try {
const stat = fs.statSync(sourcePath);
if (!stat.isFile() || stat.size > 1024 * 1024) return '';
return fs.readFileSync(sourcePath, 'utf-8');
} catch {
return '';
}
}
function shellCopyPaths(command) {
const words = shellWords(command);
if (words.length < 3 || path.basename(words[0]) !== 'cp') return null;
const args = [];
for (const word of words.slice(1)) {
if (['&&', '||', ';', '|'].includes(word)) break;
if (word === '--') continue;
if (word.startsWith('-')) continue;
args.push(word);
}
if (args.length < 2) return null;
return { source: args[args.length - 2], dest: args[args.length - 1] };
}
function shellWords(command) {
if (!command || typeof command !== 'string') return [];
const words = [];
const re = /"((?:\\"|[^"])*)"|'((?:\\'|[^'])*)'|([^\s]+)/g;
let match;
while ((match = re.exec(command))) {
words.push((match[1] ?? match[2] ?? match[3] ?? '').replace(/\\(["'])/g, '$1'));
}
return words;
}
function shellHereDocContent(command) {
if (!command || typeof command !== 'string') return '';
const markerMatch = command.match(/<<-?\s*['"]?([A-Za-z0-9_.-]+)['"]?[^\r\n]*\r?\n/);
if (!markerMatch) return '';
const marker = markerMatch[1];
const start = (markerMatch.index || 0) + markerMatch[0].length;
const rest = command.slice(start);
const endRe = new RegExp(`\\r?\\n${escapeRegExp(marker)}(?:\\r?\\n|$)`);
const end = rest.search(endRe);
return end >= 0 ? rest.slice(0, end) : '';
}
function shellPythonWriteContent(command) {
if (!/\bpython(?:3)?\b/.test(command || '')) return '';
const script = shellHereDocContent(command) || command;
return pythonStringArg(script, /\.write_text\s*\(\s*/g) || pythonStringArg(script, /\.write\s*\(\s*/g);
}
function pythonStringArg(script, prefixRe) {
let prefix;
while ((prefix = prefixRe.exec(script))) {
const start = prefixRe.lastIndex;
const triple = script.slice(start, start + 3);
if (triple === "'''" || triple === '"""') {
const end = script.indexOf(triple, start + 3);
if (end !== -1) return script.slice(start + 3, end);
continue;
}
const quote = script[start];
if (quote !== '"' && quote !== "'") continue;
let out = '';
for (let i = start + 1; i < script.length; i++) {
const ch = script[i];
if (ch === '\\') {
out += script[i + 1] || '';
i += 1;
} else if (ch === quote) {
return out;
} else {
out += ch;
}
}
}
return '';
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function relativePath(filePath, cwd) {
try {
const rel = path.relative(cwd, filePath);
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return filePath;
return rel.split(path.sep).join('/');
} catch {
return filePath;
}
}
function isInsideProject(filePath, cwd) {
try {
const rel = path.relative(cwd, filePath);
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
} catch {
return false;
}
}
function cursorBlockMessage(findings, filePath, config, cwd) {
const rendered = renderTemplate(findings, filePath, config, { cwd });
const blocked = rendered.replace(
'[impeccable@1] Design hook findings requiring review',
'[impeccable@1] Impeccable design hook blocked this write before it landed. Design hook findings requiring review',
);
return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked;
}
function findingSignature(findings) {
return findings
.map((finding) => `${finding.antipattern || 'unknown'}:${finding.line || 0}`)
.sort()
.join('|');
}
function bumpCursorDenial(cache, sessionId, filePath, findings) {
const session = cache.sessions[sessionId] || { updatedAt: Date.now(), files: {} };
cache.sessions[sessionId] = session;
session.updatedAt = Date.now();
const fileEntry = session.files[filePath] || { editCount: 0, findings: [] };
session.files[filePath] = fileEntry;
const key = findingSignature(findings);
fileEntry.cursorDenials = fileEntry.cursorDenials && typeof fileEntry.cursorDenials === 'object'
? fileEntry.cursorDenials
: {};
fileEntry.cursorDenials[key] = (fileEntry.cursorDenials[key] || 0) + 1;
return { key, count: fileEntry.cursorDenials[key] };
}
async function main() {
if (truthy(process.env.IMPECCABLE_HOOK_DISABLED)) {
return allow({ skipped: 'env-disabled' });
}
let event = null;
try {
const raw = await readStdin();
if (raw) event = JSON.parse(raw);
} catch {
return allow({ skipped: 'stdin-malformed' });
}
if (!event || typeof event !== 'object') {
return allow({ skipped: 'stdin-empty' });
}
const cwd = resolveProjectCwd(event);
const started = Date.now();
const filePath = proposedFilePath(event, cwd);
const audit = {
harness: 'cursor',
cwd,
tool: event.tool_name || null,
file: filePath || null,
};
if (!filePath) return allow({ ...audit, skipped: 'no-file-path', durationMs: Date.now() - started });
if (!isInsideProject(filePath, cwd)) return allow({ ...audit, skipped: 'outside-project', durationMs: Date.now() - started });
if (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 });
const ext = path.extname(filePath).toLowerCase();
audit.ext = ext;
if (!ALLOWED_EXTS.has(ext)) return allow({ ...audit, skipped: 'extension', durationMs: Date.now() - started });
const contentResult = proposedContent(event, cwd, filePath);
if (contentResult && typeof contentResult === 'object' && contentResult.skipped) {
return allow({ ...audit, skipped: contentResult.skipped, durationMs: Date.now() - started });
}
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 });
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 });
}
const detector = await loadDetector();
if (!detector || typeof detector.detectText !== 'function') {
return allow({ ...audit, skipped: 'detector-missing', durationMs: Date.now() - started });
}
const scanOptions = designSystemOptions(config, detector, cwd);
let findings = [];
try {
findings = await detector.detectText(content, filePath, scanOptions);
} catch {
return allow({ ...audit, error: 'detector-threw', durationMs: Date.now() - started });
}
const filtered = filterFindings(findings || [], content, ext, config);
if (filtered.length === 0) {
return allow({
...audit,
findings: (findings || []).length,
blockedFindings: 0,
durationMs: Date.now() - started,
});
}
const message = appendDesignSystemNote(cursorBlockMessage(filtered, filePath, config, cwd), scanOptions);
const sessionId = event.session_id || event.conversation_id || 'unknown';
const cache = readCache(cwd);
const denial = bumpCursorDenial(cache, sessionId, filePath, filtered);
persistCache(cwd, cache);
if (denial.count > EDIT_COUNT_THRESHOLD) {
const warning = `${message}\n\nThis is the ${denial.count}th repeated denial for the same file and finding signature, so Impeccable is allowing this write to avoid a loop. Reconsider the issue immediately after the tool runs.`;
return allow({
...audit,
findings: (findings || []).length,
blockedFindings: filtered.length,
cursorDenialKey: denial.key,
cursorDenialCount: denial.count,
downgraded: true,
chars: warning.length,
durationMs: Date.now() - started,
}, {
user_message: warning,
agent_message: warning,
});
}
return deny(message, {
...audit,
findings: (findings || []).length,
blockedFindings: filtered.length,
cursorDenialKey: denial.key,
cursorDenialCount: denial.count,
chars: message.length,
durationMs: Date.now() - started,
});
}
main().catch((err) => {
if (process.env.IMPECCABLE_HOOK_DEBUG) {
process.stderr.write(`[impeccable-hook-before-edit] ${err}\n`);
}
done({ permission: 'allow' });
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
#!/usr/bin/env node
/**
* Impeccable design hook PostToolUse entry point.
*
* Reads the Claude Code / Codex / Cursor hook event from stdin, runs the design
* detector against the touched file, and emits a system reminder via
* `hookSpecificOutput.additionalContext` when findings exist.
*
* Contract: never break a turn. Always exit 0. Clean files emit a small ack
* unless quiet mode is enabled.
*
* Most logic lives in `hook-lib.mjs` so it is unit-testable without a
* subprocess. This file is the thin stdin/stdout adapter.
*/
import { runHook, writeAuditLog } from './hook-lib.mjs';
async function readStdin() {
if (process.stdin.isTTY) return '';
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
return Buffer.concat(chunks).toString('utf-8');
}
async function main() {
// Snapshot the inherited env FIRST so the re-entrancy guard checks the
// parent's value, not the value we are about to export for any child
// processes the hook might ever spawn.
const inheritedEnv = { ...process.env };
process.env.IMPECCABLE_HOOK_DEPTH = process.env.IMPECCABLE_HOOK_DEPTH || '1';
let stdinJson = '';
try { stdinJson = await readStdin(); } catch { /* fall through */ }
const result = await runHook({
stdinJson,
env: inheritedEnv,
cwd: process.cwd(),
});
writeAuditLog(process.env, result.audit, process.cwd());
if (result.stdout) process.stdout.write(result.stdout);
process.exit(result.exitCode || 0);
}
main().catch((err) => {
// Last-ditch: never break the agent's turn even if something we did not
// anticipate goes wrong. Audit-log the failure if logging is enabled.
try {
writeAuditLog(process.env, {
ts: new Date().toISOString(),
event: 'PostToolUse',
error: String(err && err.message ? err.message : err),
});
} catch { /* swallow */ }
if (process.env.IMPECCABLE_HOOK_DEBUG) {
process.stderr.write(`[impeccable-hook] ${err}\n`);
}
process.exit(0);
});
@@ -62,7 +62,7 @@ function parseYamlSubset(yaml) {
stack.pop();
}
const key = content.slice(0, colonIdx).trim();
const key = unquoteYamlKey(content.slice(0, colonIdx).trim());
const rest = stripInlineYamlComment(content.slice(colonIdx + 1).trim());
const parent = stack[stack.length - 1].obj;
@@ -93,6 +93,13 @@ function findTopLevelColon(s) {
return -1;
}
function unquoteYamlKey(key) {
if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) {
return key.slice(1, -1);
}
return key;
}
function stripInlineYamlComment(s) {
let inQuote = null;
for (let i = 0; i < s.length; i++) {
@@ -0,0 +1,638 @@
/**
* 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);
const value = extractFindingIgnoreValue(finding);
if (!rule || !value) return false;
return ignoreValues.some((entry) => {
const wildcardValue = entry.value === '*';
if (entry.rule !== rule || (!wildcardValue && !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, '\\$&');
}
+169 -46
View File
@@ -15,8 +15,14 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs';
import { isGeneratedFile } from './lib/is-generated.mjs';
import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import {
applyDeferredSvelteComponentAccepts,
findSvelteComponentManifest,
inlineSvelteComponentAccept,
removeSvelteComponentSession,
} from './live/svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -41,6 +47,9 @@ Required:
Options:
--page-url URL Current browser page URL; scopes staged copy-edit cleanup
--defer-source-write
Deprecated compatibility flag. Svelte component accepts
now write the real source immediately.
Output (JSON):
{ handled, file, carbonize }`);
@@ -64,18 +73,67 @@ Output (JSON):
// Find the file containing this session's markers
const found = findSessionFile(id, process.cwd());
if (!found) {
const svelteComponentManifest = found ? null : findSvelteComponentManifest(id, process.cwd());
if (!found && !svelteComponentManifest) {
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
process.exit(0);
}
if (svelteComponentManifest) {
if (isDiscard) {
removeSvelteComponentSession(id, process.cwd());
console.log(JSON.stringify({
handled: true,
file: svelteComponentManifest.sourceFile,
carbonize: false,
previewMode: 'svelte-component',
componentDir: svelteComponentManifest.componentDir,
}));
return;
}
let result;
try {
result = inlineSvelteComponentAccept(
svelteComponentManifest,
variantNum,
paramValues,
process.cwd(),
);
} catch (err) {
result = {
handled: false,
error: err.message,
file: svelteComponentManifest.sourceFile,
sourceFile: svelteComponentManifest.sourceFile,
previewMode: 'svelte-component',
componentDir: svelteComponentManifest.componentDir,
};
}
if (result.carbonize) {
result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + result.file + '. See reference/live.md "Required after accept".';
}
console.log(JSON.stringify({ handled: result.handled !== false, ...result }));
return;
}
const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile);
const previewBlock = findMarkerBlock(id, lines);
const sourceShadowPreview = previewBlock
? readSourceShadowPreviewMeta(content, id)
: null;
if (sourceShadowPreview) {
console.log(JSON.stringify({
handled: false,
error: 'source_shadow_preview_deprecated',
hint: 'Svelte live mode now uses svelte-component injection. Re-wrap the element and regenerate variants.',
}));
process.exit(0);
}
// Bail if the session lives in a generated file. The agent manually wrote
// the wrapper there for preview, and is responsible for writing the
// accepted variant to true source (or cleaning up on discard). See
// "Handle fallback" in live.md.
if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {
console.log(JSON.stringify({
handled: false,
@@ -207,6 +265,71 @@ function handleDiscard(id, lines, targetFile) {
// Accept
// ---------------------------------------------------------------------------
/**
* Build carbonize stitch-in lines. JSX targets occupy a single child slot
* (ternary branch, return value, etc.) the same constraint as live-wrap.
* When isJsx, tuck markers + <style> + variant wrapper inside one outer
* <div data-impeccable-carbonize> so the slot keeps a single root node.
*/
function buildCarbonizeReplacement({
indent,
commentSyntax,
isJsx,
id,
variantNum,
cssContent,
paramValues,
restored,
}) {
const lines = [];
if (!cssContent) {
lines.push(...restored);
return lines;
}
const variantStyleAttr = isJsx
? "style={{ display: 'contents' }}"
: 'style="display: contents"';
const pushCarbonizeBody = (bodyIndent) => {
const bodyRestored = reindentContent(restored, indent, bodyIndent + ' ');
lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
lines.push(bodyIndent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : ''));
for (const cssLine of cssContent) {
lines.push(bodyIndent + cssLine.trimStart());
}
lines.push(bodyIndent + (isJsx ? '`}</style>' : '</style>'));
if (paramValues && Object.keys(paramValues).length > 0) {
lines.push(
bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close,
);
}
lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
lines.push(bodyIndent + '<div data-impeccable-variant="' + variantNum + '" ' + variantStyleAttr + '>');
lines.push(...bodyRestored);
lines.push(bodyIndent + '</div>');
};
if (isJsx) {
const wrapperStyle = 'style={{ display: "contents" }}';
lines.push(indent + '<div data-impeccable-carbonize="' + id + '" ' + wrapperStyle + '>');
pushCarbonizeBody(indent + ' ');
lines.push(indent + '</div>');
} else {
pushCarbonizeBody(indent);
}
return lines;
}
function reindentContent(contentLines, fromIndent, toIndent) {
return contentLines.map((line) => {
if (line.trim() === '') return '';
if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length);
return toIndent + line.trimStart();
});
}
function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
@@ -235,45 +358,17 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const hasHelperAttrs = variantText.includes('data-impeccable-variant');
const needsCarbonize = !!(cssContent || hasHelperAttrs);
// Build the replacement
const restored = deindentContent(variantContent, indent);
const replacement = [];
if (cssContent) {
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
// JSX targets need the CSS body wrapped in a template literal so that the
// `{` and `}` in CSS rules don't get parsed as JSX expressions.
replacement.push(indent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : ''));
// Re-indent CSS content to match
for (const cssLine of cssContent) {
replacement.push(indent + cssLine.trimStart());
}
replacement.push(indent + (isJsx ? '`}</style>' : '</style>'));
if (paramValues && Object.keys(paramValues).length > 0) {
// Preserve the user's knob positions for the carbonize-cleanup agent
// to bake into the final CSS when it collapses scoped rules.
replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close);
}
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
// Keep the `@scope ([data-impeccable-variant="N"])` selectors in the
// carbonize CSS block working visually by re-wrapping the accepted content
// in a data-impeccable-variant="N" div with `display: contents` (so layout
// isn't affected). The carbonize agent strips this attribute + wrapper when
// it moves the CSS to a proper stylesheet.
//
// Style attribute syntax has to follow the host file's flavor — JSX files
// need the object form, otherwise React 19 throws "Failed to set indexed
// property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
if (cssContent) {
const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
replacement.push(...restored);
replacement.push(indent + '</div>');
} else {
replacement.push(...restored);
}
const replacement = buildCarbonizeReplacement({
indent,
commentSyntax,
isJsx,
id,
variantNum,
cssContent,
paramValues,
restored,
});
const newLines = [
...lines.slice(0, replaceRange.start),
@@ -285,6 +380,34 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
return { carbonize: needsCarbonize, acceptedOriginalText: originalContent.join('\n') };
}
function readSourceShadowPreviewMeta(content, id) {
const escaped = escapeRegExp(id);
const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>');
const match = String(content || '').match(wrapperRe);
if (!match) return null;
const tag = match[0];
if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null;
const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file');
const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start'));
const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end'));
if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null;
return { sourceFile, sourceStartLine, sourceEndLine };
}
function readHtmlAttr(tag, name) {
const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1'));
if (!match) return null;
return decodeHtmlAttr(match[2]);
}
function decodeHtmlAttr(value) {
return String(value || '')
.replace(/&quot;/g, '"')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
}
// ---------------------------------------------------------------------------
// Parsing helpers
// ---------------------------------------------------------------------------
@@ -686,4 +809,4 @@ if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs
acceptCli();
}
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock };
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts };
@@ -0,0 +1,146 @@
/**
* Browser-side DOM helpers for Impeccable live mode.
*
* Kept separate from live-browser.js so future browser script parts can share
* chrome mounting, lookup, focus, and picker helpers without depending on the
* full overlay UI bundle.
*/
(function (root) {
'use strict';
if (!root) return;
function createLiveBrowserDomHelpers({
prefix,
skipTags,
document: doc = root.document,
css = root.CSS,
crypto = root.crypto,
} = {}) {
if (!prefix) throw new Error('prefix required');
if (!doc) throw new Error('document required');
const tagsToSkip = skipTags || new Set();
function own(el) {
return el && (el.id?.startsWith(prefix) || el.closest?.('[id^="' + prefix + '"]'));
}
function pickable(el) {
if (!el || el.nodeType !== 1) return false;
if (tagsToSkip.has(String(el.tagName || '').toLowerCase())) return false;
if (own(el)) return false;
const r = el.getBoundingClientRect();
return r.width >= 20 && r.height >= 20;
}
function desc(el) {
if (!el) return '';
let s = el.tagName.toLowerCase();
if (el.id) s += '#' + el.id;
else if (el.classList.length) s += '.' + [...el.classList].slice(0, 2).join('.');
return s;
}
function rectIsUsableAnchor(rect) {
return !!rect && rect.width > 0.5 && rect.height > 0.5;
}
function makeFrozenAnchor(el) {
if (!el || !el.getBoundingClientRect) return null;
const r = el.getBoundingClientRect();
if (!rectIsUsableAnchor(r)) return null;
const rect = {
x: r.x, y: r.y,
top: r.top, left: r.left,
right: r.right, bottom: r.bottom,
width: r.width, height: r.height,
};
return {
__impeccableFrozenAnchor: true,
tagName: el.tagName || 'DIV',
id: el.id || '',
classList: el.classList ? [...el.classList] : [],
hasAttribute: () => false,
getBoundingClientRect: () => rect,
};
}
function id8() {
if (crypto?.randomUUID) return crypto.randomUUID().replace(/-/g, '').slice(0, 8);
return (Math.random().toString(16).slice(2) + Date.now().toString(16)).slice(0, 8);
}
function cssId(id) {
if (css?.escape) return css.escape(id);
return String(id).replace(/([ !"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '\\$1');
}
function liveUiRoot() {
const uiRoot = root.__IMPECCABLE_LIVE_UI_ROOT__;
if (uiRoot && typeof uiRoot.appendChild === 'function') return uiRoot;
return doc.body;
}
function uiAppend(el) {
liveUiRoot().appendChild(el);
return el;
}
function uiAppendStyle(styleEl) {
const uiRoot = liveUiRoot();
if (uiRoot && uiRoot !== doc.body) uiRoot.appendChild(styleEl);
else doc.head.appendChild(styleEl);
return styleEl;
}
function uiGetById(id) {
const uiRoot = liveUiRoot();
if (uiRoot?.getElementById) {
const found = uiRoot.getElementById(id);
if (found) return found;
}
if (uiRoot?.querySelector) {
const found = uiRoot.querySelector('#' + cssId(id));
if (found) return found;
}
return doc.getElementById(id);
}
function activeElementDeep() {
let active = doc.activeElement;
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
return active;
}
function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
if (!rootEl) return;
if (setPointerEvents) {
rootEl.style.setProperty('pointer-events', 'auto', 'important');
}
const stop = (e) => e.stopPropagation();
rootEl.addEventListener('pointerdown', stop);
rootEl.addEventListener('mousedown', stop);
rootEl.addEventListener('focusin', stop);
}
return {
own,
pickable,
desc,
rectIsUsableAnchor,
makeFrozenAnchor,
id8,
cssId,
liveUiRoot,
uiAppend,
uiAppendStyle,
uiGetById,
activeElementDeep,
defangOutsideHandlers,
};
}
root.__IMPECCABLE_LIVE_DOM__ = {
version: 1,
createLiveBrowserDomHelpers,
};
})(typeof window !== 'undefined' ? window : globalThis);
File diff suppressed because it is too large Load Diff
@@ -16,8 +16,8 @@
*/
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live-manual-edits-buffer.mjs';
import { isGeneratedFile } from './is-generated.mjs';
import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live/manual-edits-buffer.mjs';
import { isGeneratedFile } from './lib/is-generated.mjs';
import {
runCopyEditBatchAgent,
runCopyEditPostApplyChecks,
@@ -3,8 +3,8 @@
* Canonical durable completion acknowledgement for Impeccable live sessions.
*/
import { createLiveSessionStore } from './live-session-store.mjs';
import { readLiveServerInfo } from './impeccable-paths.mjs';
import { createLiveSessionStore } from './live/session-store.mjs';
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
function parseArgs(argv) {
const out = { status: 'complete' };
@@ -16,7 +16,7 @@
* Output JSON: { discarded: N, entries: [...discardedEntries], totalCount: N }
*/
import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs';
import { readBuffer, removeEntries, truncateBuffer } from './live/manual-edits-buffer.mjs';
function argVal(args, name) {
const prefix = name + '=';
@@ -16,12 +16,41 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolveLiveConfigPath } from './impeccable-paths.mjs';
import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs';
import {
applySvelteKitLiveAdapter,
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
} from './live/sveltekit-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
const MARKER_OPEN_TEXT = 'impeccable-live-start';
const MARKER_CLOSE_TEXT = 'impeccable-live-end';
const IGNORE_MARKER_OPEN = '# impeccable-live-ignore-start';
const IGNORE_MARKER_CLOSE = '# impeccable-live-ignore-end';
export const LIVE_IGNORE_PATTERNS = Object.freeze([
'.impeccable/hook.cache.json',
'.impeccable/hook.pending.json',
'.impeccable/config.local.json',
'.impeccable/live/server.json',
'.impeccable/live/sessions/',
'.impeccable/live/previews/',
'.impeccable/live/annotations/',
'.impeccable/live/cache/',
'.impeccable/live/manual-edit-apply-transaction.json',
'.impeccable/live/manual-edit-events.jsonl',
'.impeccable/live/manual-edit-evidence/',
'.impeccable/live/pending-manual-edits.json',
'.impeccable/live/deferred-svelte-component-accepts.json',
'.impeccable-live.json',
'.impeccable-live/',
'node_modules/.impeccable-live/',
'src/lib/impeccable/ImpeccableLiveRoot.svelte',
'src/lib/impeccable/__runtime.js',
'src/lib/impeccable/[0-9a-f]*/',
]);
/**
* Hard-excluded directory patterns. These are NEVER user-facing pages and
@@ -83,8 +112,14 @@ Output (JSON):
validateConfig(config);
const resolvedFiles = resolveFiles(process.cwd(), config);
const svelteKit = detectSvelteKitProject(process.cwd(), config);
if (args.includes('--remove')) {
if (svelteKit) {
const adapterResult = removeSvelteKitLiveAdapter({ cwd: process.cwd(), config });
console.log(JSON.stringify({ ok: true, adapter: 'sveltekit', results: [adapterResult] }));
return;
}
const results = resolvedFiles.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
@@ -110,6 +145,13 @@ Output (JSON):
console.error(JSON.stringify({ ok: false, error: 'missing_port' }));
process.exit(1);
}
const gitIgnore = ensureLiveGitIgnores(process.cwd());
if (svelteKit) {
const adapterResult = applySvelteKitLiveAdapter({ cwd: process.cwd(), port, config });
console.log(JSON.stringify({ ok: true, port, adapter: 'sveltekit', gitIgnore, results: [adapterResult] }));
return;
}
const results = resolvedFiles.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
@@ -129,10 +171,68 @@ Output (JSON):
};
});
const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results }));
console.log(JSON.stringify({ ok: anyInserted, port, gitIgnore, results }));
if (!anyInserted) process.exit(1);
}
export function ensureLiveGitIgnores(cwd = process.cwd()) {
const target = resolveIgnoreTarget(cwd);
const existing = fs.existsSync(target.path) ? fs.readFileSync(target.path, 'utf-8') : '';
const block = [
IGNORE_MARKER_OPEN,
...LIVE_IGNORE_PATTERNS,
IGNORE_MARKER_CLOSE,
].join('\n');
const markerRe = new RegExp(`${escapeRegExp(IGNORE_MARKER_OPEN)}[\\s\\S]*?${escapeRegExp(IGNORE_MARKER_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}${prefix.endsWith('\n\n') || prefix === '' ? '' : '\n'}${block}\n`;
}
if (updated !== existing) {
fs.mkdirSync(path.dirname(target.path), { recursive: true });
fs.writeFileSync(target.path, updated, 'utf-8');
}
return {
file: path.relative(cwd, target.path).split(path.sep).join('/'),
mode: target.mode,
changed: updated !== existing,
patterns: [...LIVE_IGNORE_PATTERNS],
};
}
function resolveIgnoreTarget(cwd) {
const gitExcludePath = resolveGitInfoExcludePath(cwd);
if (gitExcludePath) {
return { path: gitExcludePath, mode: 'git-info-exclude' };
}
return { path: path.join(cwd, '.gitignore'), mode: 'gitignore' };
}
function resolveGitInfoExcludePath(cwd) {
const dotGit = path.join(cwd, '.git');
if (!fs.existsSync(dotGit)) return null;
const stat = fs.statSync(dotGit);
if (stat.isDirectory()) return path.join(dotGit, 'info', 'exclude');
if (!stat.isFile()) return null;
const body = fs.readFileSync(dotGit, 'utf-8').trim();
const match = body.match(/^gitdir:\s*(.+)$/i);
if (!match) return null;
const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(cwd, match[1]);
return path.join(gitDir, 'info', 'exclude');
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Expand config.files (which may contain glob patterns) into a literal list
* of existing file paths relative to rootDir. Literal entries pass through;
@@ -270,8 +370,26 @@ function buildTagBlock(syntax, port, filePath) {
);
}
function detectLineEnding(content) {
if (content.includes('\r\n')) return '\r\n';
if (content.includes('\r')) return '\r';
return '\n';
}
function normalizeLineEndings(content, lineEnding) {
return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding);
}
function readLineEndingAt(content, index) {
if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n';
if (content[index] === '\n') return '\n';
if (content[index] === '\r') return '\r';
return '';
}
function insertTag(content, config, port, filePath) {
const block = buildTagBlock(config.commentSyntax, port, filePath);
const lineEnding = detectLineEnding(content);
const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding);
// insertBefore: match the LAST occurrence. Anchors like `</body>` naturally
// belong at the end, and the same literal can appear earlier in code blocks
// within rendered documentation pages.
@@ -285,9 +403,15 @@ function insertTag(content, config, port, filePath) {
const idx = content.indexOf(config.insertAfter);
if (idx === -1) return content;
const after = idx + config.insertAfter.length;
// Preserve a single trailing newline if the anchor didn't end with one
const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n';
return prefix + block + content.slice(prefix.length);
// Preserve an existing trailing newline if the anchor already has one.
// Slice the remainder from the original anchor offset, not prefix.length:
// in the no-newline case prefix is one char longer than the anchor (the
// appended '\n'), so slicing by prefix.length would drop the first real
// character after the anchor (#227).
const existingNewline = readLineEndingAt(content, after);
const prefix = content.slice(0, after) + (existingNewline || lineEnding);
const rest = content.slice(after + existingNewline.length);
return prefix + block + rest;
}
/**
@@ -303,8 +427,8 @@ function insertTag(content, config, port, filePath) {
*/
function removeTag(content, _syntax) {
const patterns = [
/([ \t]*)<!--\s*impeccable-live-start\s*-->[\s\S]*?<!--\s*impeccable-live-end\s*-->([ \t]*(?:\n|$)?)/,
/([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\n|$)?)/,
/([ \t]*)<!--\s*impeccable-live-start\s*-->[\s\S]*?<!--\s*impeccable-live-end\s*-->([ \t]*(?:\r\n|\n|\r|$)?)/,
/([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/,
];
for (const pat of patterns) {
let changed = false;
@@ -312,7 +436,7 @@ function removeTag(content, _syntax) {
do {
content = next;
next = content.replace(pat, (_match, leadingIndent, trailing = '') => {
if (trailing.includes('\n')) return leadingIndent;
if (/[\r\n]/.test(trailing)) return leadingIndent;
return leadingIndent || trailing || '';
});
if (next !== content) changed = true;
@@ -9,7 +9,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
import { isGeneratedFile } from './lib/is-generated.mjs';
import {
buildSearchQueries,
findElement,
@@ -21,6 +21,11 @@ import {
buildCssAuthoring,
buildCssSelectorPrefixExamples,
} from './live-wrap.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentInsertSession,
shouldUseSvelteComponentInjection,
} from './live/svelte-component.mjs';
const INSERT_POSITIONS = new Set(['before', 'after']);
@@ -192,6 +197,41 @@ Output (JSON):
const styleMode = detectStyleMode(targetFile);
const isJsx = commentSyntax.open === '{/*';
const spliceIndex = computeInsertLine(startLine, endLine, position);
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
if (shouldUseSvelteComponentInjection(targetFile)) {
const session = scaffoldSvelteComponentInsertSession({
id,
count,
sourceFile: relTargetFile,
insertLine: spliceIndex + 1,
position,
anchorStartLine: startLine + 1,
anchorEndLine: endLine + 1,
anchorLines: lines.slice(startLine, endLine + 1),
cwd: process.cwd(),
});
console.log(JSON.stringify({
mode: 'insert',
position,
file: session.manifestFile,
sourceFile: relTargetFile,
previewMode: 'svelte-component',
componentDir: session.componentDir,
propContract: session.propContract,
insertLine: 1,
sourceInsertLine: spliceIndex + 1,
anchorStartLine: startLine + 1,
anchorEndLine: endLine + 1,
commentSyntax,
styleMode: 'svelte-component',
styleTag: null,
cssSelectorPrefixExamples: [],
cssAuthoring: buildSvelteComponentCssAuthoring(count),
}));
return;
}
const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1]
?? lines[startLine]?.match(/^(\s*)/)?.[1]
?? '';
@@ -216,7 +256,7 @@ Output (JSON):
console.log(JSON.stringify({
mode: 'insert',
position,
file: path.relative(process.cwd(), targetFile),
file: relTargetFile,
insertLine: insertLine + 1,
commentSyntax,
styleMode: styleMode.mode,
@@ -10,8 +10,8 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
import { readBuffer, getBufferPath } from './live-manual-edits-buffer.mjs';
import { isGeneratedFile } from './lib/is-generated.mjs';
import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs';
const EVIDENCE_VERSION = 1;
const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']);
@@ -12,14 +12,15 @@
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live-completion.mjs';
import { readLiveServerInfo } from './impeccable-paths.mjs';
import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
// 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
// depending on the standalone undici package.
export const PER_REQUEST_TIMEOUT_MS = 270_000;
export const DEFAULT_EVENT_LEASE_MS = 600_000;
const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']);
@@ -156,7 +157,7 @@ export async function fetchNextEvent(base, token, { totalDeadline } = {}) {
? totalDeadline - Date.now()
: PER_REQUEST_TIMEOUT_MS;
const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS);
const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}`);
const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}&leaseMs=${DEFAULT_EVENT_LEASE_MS}`);
if (res.status === 401) {
const err = new Error('Authentication failed. The server token may have changed.');
@@ -317,7 +318,7 @@ Modes:
Options:
--timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode
--ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000)
--file PATH Attach a source file path to the reply (generate flow)
--file PATH Attach a source file path to the reply (generate/steer flow)
--data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON
--help Show this help message
@@ -3,7 +3,7 @@
* Recover the next agent action from the durable live-session journal.
*/
import { createLiveSessionStore } from './live-session-store.mjs';
import { createLiveSessionStore } from './live/session-store.mjs';
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
File diff suppressed because it is too large Load Diff
@@ -3,8 +3,8 @@
* Print durable recovery status for Impeccable live sessions.
*/
import { createLiveSessionStore } from './live-session-store.mjs';
import { readLiveServerInfo } from './impeccable-paths.mjs';
import { createLiveSessionStore } from './live/session-store.mjs';
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
import { manualApplyResumeHint } from './live-resume.mjs';
function readServerInfo() {
+77 -25
View File
@@ -13,8 +13,13 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs';
import { isGeneratedFile } from './lib/is-generated.mjs';
import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentSession,
shouldUseSvelteComponentInjection,
} from './live/svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -262,6 +267,8 @@ The agent should insert variant HTML at insertLine.`);
.map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
.join('\n');
const originalIndented = reindentOriginal(' ');
const relTargetFile = path.relative(process.cwd(), targetFile).split(path.sep).join('/');
const useSvelteComponent = shouldUseSvelteComponentInjection(targetFile);
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
// JSX requires object-literal style and parses string attrs as HTML (which
@@ -302,38 +309,75 @@ The agent should insert variant HTML at insertLine.`);
indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
];
// Replace the original element with the wrapper
const newLines = [
...lines.slice(0, startLine),
...wrapperLines,
...lines.slice(endLine + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
let outputFile = targetFile;
let outputLines;
let outputStartLine = startLine + 1;
let outputEndLine = startLine + wrapperLines.length + (originalLines.length - 1);
let insertLine;
let svelteSession = null;
// Calculate insert line (the "insert below this line" comment).
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
// the insert marker (HTML: start-comment + outer-div + Original-comment +
// original-div + content + close-original-div; JSX: outer-div +
// start-comment + Original-comment + original-div + content +
// close-original-div). Multi-line originals push the marker by their
// extra line count.
const insertLine = startLine + 6 + (originalLines.length - 1);
if (useSvelteComponent) {
// Svelte/SvelteKit resets component-local state on markup HMR updates.
// Keep generation source-neutral: agents write real variant components
// under the generated componentDir, the browser mounts them into the live
// DOM, and live-accept.mjs inlines the accepted variant back into the route.
svelteSession = scaffoldSvelteComponentSession({
id,
count,
sourceFile: relTargetFile,
sourceStartLine: startLine + 1,
sourceEndLine: endLine + 1,
originalLines,
cwd: process.cwd(),
});
outputFile = path.resolve(process.cwd(), svelteSession.manifestFile);
outputStartLine = 1;
outputEndLine = 1;
insertLine = 1;
} else {
// Replace the original element with the wrapper
const newLines = [
...lines.slice(0, startLine),
...wrapperLines,
...lines.slice(endLine + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment).
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
// the insert marker (HTML: start-comment + outer-div + Original-comment +
// original-div + content + close-original-div; JSX: outer-div +
// start-comment + Original-comment + original-div + content +
// close-original-div). Multi-line originals push the marker by their
// extra line count.
insertLine = startLine + 6 + (originalLines.length - 1) + 1;
}
const outputRelFile = path.relative(process.cwd(), outputFile).split(path.sep).join('/');
const svelteComponentAuthoring = useSvelteComponent ? buildSvelteComponentCssAuthoring(count) : null;
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
file: outputRelFile,
sourceFile: useSvelteComponent ? relTargetFile : undefined,
previewMode: useSvelteComponent ? 'svelte-component' : undefined,
componentDir: svelteSession?.componentDir,
propContract: svelteSession?.propContract,
sourceStartLine: useSvelteComponent ? startLine + 1 : undefined,
sourceEndLine: useSvelteComponent ? endLine + 1 : undefined,
startLine: outputStartLine, // 1-indexed for the agent
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that
// spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
endLine: outputEndLine, // 1-indexed
insertLine, // 1-indexed: where variants go
commentSyntax: commentSyntax,
styleMode: styleMode.mode,
styleTag: styleMode.styleTag,
cssSelectorPrefixExamples: buildCssSelectorPrefixExamples(styleMode.mode, count),
cssAuthoring: buildCssAuthoring(styleMode, count),
styleMode: useSvelteComponent ? 'svelte-component' : styleMode.mode,
styleTag: useSvelteComponent ? null : styleMode.styleTag,
cssSelectorPrefixExamples: useSvelteComponent ? [] : buildCssSelectorPrefixExamples(styleMode.mode, count),
cssAuthoring: useSvelteComponent ? svelteComponentAuthoring : buildCssAuthoring(styleMode, count),
originalLineCount: originalLines.length,
}));
}
@@ -527,6 +571,14 @@ function splitClassList(classes) {
return String(classes).split(/[,\s]+/).map(c => c.trim()).filter(Boolean);
}
function attrEscapeDouble(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') {

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