Compare commits

...
Author SHA1 Message Date
Paul BakausandClaude 294199d542 fix: abstain on translucent gradients over images, drop phantom color-mix stops
Two follow-up review findings on the merge with main.

A gradient leading a url() layer was treated as a resolvable surface even
when its stops are translucent, so the glow and AI-palette hunts averaged
wash stops (a 20% black wash reads as pure black) while the real surface
blends with image pixels the engine cannot read. resolveBackgroundInfo now
marks gradient-over-image unresolved unless every readable stop of the
leading gradient is opaque, in which case the gradient provably covers the
image and remains the scorable surface.

parseGradientColorsModern predated this branch's parseGradientColors
rewrite: its second regex pass re-extracted color tokens nested inside
color-mix() stops that the shared parser already captures whole via
balanced-paren tokens, appending ingredient colors that are never painted.
The worst-case stop ratio then invented low-contrast findings against a
color nobody sees. The helper is removed; all callers use the shared
parser, which covers the modern syntaxes it existed for.

Fixture coverage pins both: the translucent-wash-over-image glow abstains
in both engines, an opaque gradient over an image still flags in the
browser, and the color-mix wash case stays clean in the static engine.
Each new assertion was verified to fail against the previous engine.

Addresses Greptile and Cursor Bugbot review findings on PR #541.

AI-assisted-by: Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 14:51:01 -04:00
Paul BakausandClaude da286a938d Merge origin/main into fix/browser-bg-resolution-dark-themes
Reconciles this branch's resolveBackgroundInfo contract (unresolved-surface
abstention, url-on-top handling, currentcolor substitution, glow adapter fix)
with main's #557 gradient-ground work (readCascadeBackgroundColor,
parseGradientColorsModern, resolveGradientStops rewrite) and #559's scoped
ignores and invisible-at-rest skips. Generated browser bundle rebuilt with
bun run build:browser.

Merge conflict resolution performed with AI assistance (Claude Code).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 13:56:55 -04:00
Paul BakausandClaude 1fec78d3d2 fix: keep zero-offset glow findings when the surface is unreadable
The browser glow adapter abstained from the whole element when
resolveBackgroundInfo reported an unreadable surface, which also dropped
zero-offset chromatic halo findings that do not depend on the background
at all. It now skips only the gradient hunt past the unreadable layer and
scores the halo tell against a null surface, matching what the static
loop already did. Fixture cases pin both sides: the halo over a url()
image ancestor flags in both engines, and an offset chromatic shadow on
the same unknown surface stays abstained.

Also hardens the currentcolor background substitution with the
parseColorResolved fallback used by the text-color path, and adds fixture
coverage proving tokenized currentcolor surfaces already resolve through
the static cascade (flag when knowable, abstain when the token is
undefined).

Addresses Cursor Bugbot review findings on PR #541.

AI-assisted-by: Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 13:50:49 -04:00
github-actions[bot] c0b9b6f95a Sync generated provider output 2026-08-11 17:43:10 +00:00
dc4e4a4bd6 Denoise the design hook and let agents self-serve confident ignores (#508)
* Denoise the design hook and let agents self-serve confident ignores (#497)

The directive footer now emits in full once per session (a one-line
reminder after), the DESIGN.md staleness note is mentioned once per
session, rule descriptions dedupe within an emission, and the per-line
ignore suggestion shrinks to the bare rule/value pair. The footer and
hooks.md replace the confirmation-gated ignore policy with a three-way
triage: fix real problems, self-serve the narrowest ignore for confident
false positives or sanctioned exceptions and disclose it (with an honest
--reason), ask when unsure. Self-serve stops at ignore-value, and the
footer now gives a runnable hook-admin.mjs command instead of a slash
command agents cannot execute.

Measured on a seeded lab session replaying 11 hook events: 33,658 to
14,063 chars of agent-visible output (-58%).

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

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

* Preserve the policy footer and honor maxChars under constrained budgets

Greptile's runtime check found two pre-existing clamp gaps that matter
more now that the full policy emits once per session: the last-resort
tail slice cut the footer off an over-budget emission, and the DESIGN.md
staleness note was appended after clamping, pushing past maxChars.

The clamp now gives the footer the budget first, clipping the finding
line and downgrading full to short policy when needed. The staleness
note defers, without consuming its session flag, to a later emission
with room.

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

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

* Harden the constrained-budget clamp: keep findings, honest flags, guaranteed note

Review follow-ups from Bugbot and Greptile on the clamp fix:

- The clamp retries with the short policy before dropping finding lines
  that fit beside it, and a grouped result that kept only a file header
  no longer counts as a fit.
- The full-footer session flag commits only when the full policy
  actually survived the clamp, so a downgraded emission does not mark
  the session as having seen a policy it never received.
- Render paths reserve room for a pending DESIGN.md staleness note, so
  it is delivered inside the budget on the first emission instead of
  deferring behind full emissions indefinitely.

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

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

* Route the Cursor deny cap through the clamp and match the whole footer

Bugbot follow-up: cursorBlockMessage tail-sliced at 4000 chars after
render, which the default 8000-char budget made reachable, and a cut
that spared the footer's opening words still committed the session
flag. The 4000 cap now feeds through the renderer's footer-preserving
clamp, and commitFooterShown matches the complete footer text instead
of a sentinel.

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

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

* Reconcile hook.test.mjs expectations with main's per-platform quoting

Three tests fell out of sync when main's quoteCommandArg change (#533,
building on #476) met this branch's footer/hint rework. Test-only
changes; production logic untouched:

- The full-footer test now accepts either close quote after the
  hook-admin.mjs path, since quoteCommandArg single-quotes absolute
  paths on POSIX and double-quotes them on Windows. The short-footer
  guard rejects `node '` and `node "` alike.
- The #476 hostile-value test asserts the new bare
  `ignore-value <rule> '<value>'` hint format. The security property is
  unchanged: the value still passes through quoteCommandArg, so
  $(touch pwned) stays single-quoted and inert.
- The #533 test previously asserted a concrete quoted `--file` path in
  the footer; directiveFooter() now carries only literal placeholders,
  so that surface is gone. The per-platform assertion moves to the
  per-finding ignore hint, the remaining user-visible surface where
  scanned file content flows through quoteCommandArg.

Prepared with AI assistance (Claude Code).

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

* Fix two Bugbot findings: Cursor prefix budget and footer-cutting tail slice

Both flagged by Cursor Bugbot on PR #508 after the main merge; both real.

1. cursorBlockMessage computed min(maxChars, 4000 - prefix), so a
   configured maxChars at or below the Cursor ceiling never charged the
   BLOCK_PREFIX against the budget: the final deny text could exceed
   maxChars by the prefix length, and appendDesignSystemNoteOnce's size
   check lost exactly the room designNoteReserve had held back. The
   prefix now comes off whichever limit binds. Default-config behavior
   is unchanged (min(8000, 4000) - 60 equals the old 4000 - 60).

2. The note reservation is subtracted after renderTemplate's 500-char
   floor, so the clamp can run below the budget clampLastLine assumed
   safe, and its last-resort path tail-sliced the rendered text, cutting
   the policy footer (the failure mode this PR exists to eliminate) when
   a deep file path met a pending DESIGN.md note. The reservation order
   stays (the staleness-note delivery guarantee at floor budgets depends
   on it); the last resort now drops the finding line and clips the head
   instead, so the footer survives every path. New regression test pins
   it: 6 findings, 100-char path, maxChars 500, reserveChars 134.

Prepared with AI assistance (Claude Code).

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

* Charge the Cursor deny prefix after the renderer's floor, not before

Greptile's runtime check caught the residual from ead0346d: subtracting
BLOCK_PREFIX from the maxChars passed to renderTemplate does nothing at
floor-tier configs, because the renderer re-raises any budget below its
500-char floor. At maxChars 500 with a stale design sidecar, the
prefixed denial landed at 432 chars and appendDesignSystemNoteOnce
could not fit the staleness note inside 500, deferring it (flag
unconsumed) for every equivalent denial in the session.

The prefix now rides in reserveChars, which comes off after the floor,
so it is charged at every config tier and the final prefixed message
plus a pending note closes exactly at the binding limit (499 chars in
the regression scenario). Default-config output is byte-identical:
max(500, min(8000, 4000)) - prefix equals the old min(8000, 4000) -
prefix. New end-to-end Cursor preToolUse test pins the path with a real
stale sidecar at maxChars 500.

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

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Paul Bakaus <paul.bakaus@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-08-11 13:42:39 -04:00
github-actions[bot] 251135e190 Sync generated provider output 2026-08-10 23:32:16 +00:00
aee5ddd10c data-impeccable-ignore scoped waivers + occlusion and image-backed contrast FP fixes (#559)
* Add data-impeccable-ignore scoped waivers; fix occlusion and image-backed contrast FPs

Three changes that let a page hosting deliberate anti-pattern exhibits
scan clean without losing coverage, prepared with AI assistance (Claude
Code) on maintainer instruction:

- data-impeccable-ignore="rule-a rule-b" (or "*" / bare) on any element
  suppresses matching findings for its whole subtree, in the browser
  overlay, the extension, and the static engine. The DOM twin of the
  line-based impeccable-disable comments (which a live DOM cannot
  apply) and the generalization of data-impeccable-allow-kickers.
  Applied at the addBrowserFindings choke point, at the static element
  walk, and for regex findings that carry a live selector.

- text-occlusion: an occluder whose effective opacity multiplies out to
  ~0 paints nothing. An opacity-0 range scrubber stretched over a
  before/after comparison produced 16 "100% covered by an opaque
  element" findings on one page because elementFromPoint returns it and
  its UA background-color read as opaque paint. Invisible-at-rest
  elements are also no longer probed as victims.

- Analytic contrast now skips what it cannot measure: a url() image
  layer anywhere in the background stack ends the gradient-stops walk
  (dark ink on a bright gold-leaf image measured 2.6:1 against the wash
  composited over the wrong base), and elements that are invisible at
  rest (visibility hidden, effective opacity ~0 — hidden scene decks)
  are skipped by the color checks in both engines. The static cascade
  now tracks opacity to support this.

Covered by a new scoped-ignore fixture (exact rule, star, comma list,
nested depth, wrong-rule control) tested in both engines, a scrubber
pass case in the occlusion fixture, and image-backed / photo-panel /
hidden-scene pass cases in the gradient-ground fixture. Full suite
passes; browser and extension bundles regenerated.

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

* CSS-scan findings carry their enclosing selector; browser pass resolves them

Page-level CSS-text findings (marquee, dark-glow, radial-halo,
repeating-stripes, codex-grid, ai-color-palette, image-hover-transform,
pseudo/inset side-tab stripes) now attach the selector of the rule that
matched, via a best-effort enclosingCssSelector() helper or the
selector already in scope. The browser pass resolves that selector
against the live DOM: pseudo segments are stripped, a selector that
renders nowhere on the page drops the finding (the CSS ships there but
the pattern never paints — the live DOM is ground truth in a browser
scan), and matches under a data-impeccable-ignore ancestor are waived.
Static scans are unchanged: partial documents keep the text-level
findings. Applied with AI assistance (Claude Code).

Covered in the scoped-ignore fixture: a live marquee under a marquee
waiver is suppressed, and dead two-axis grid CSS matching no element is
dropped.

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

* Attribute selectors on gradient-text and bounce-easing page emitters too

Same mechanism as the previous commit, extended to the three page-level
motion/text emitters that were still selector-less. Applied with AI
assistance (Claude Code).

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

* text-overflow: skip SVG content; scrollWidth lies there

Chrome reports arbitrary non-zero scrollWidth/clientWidth on SVG
elements (a <text> gave 78/48 while its rendered length sat inside its
box), so the box-metric delta is noise. SVG clips to its own viewport
anyway. Pass case added to the quality fixture. Applied with AI
assistance (Claude Code).

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

* Overlay samples image-backed text at the pixel level by default

Visual contrast gains a third mode. Explicit true still runs the full
sampled pass, explicit false still disables everything (the mode the
test suites use), and unset — the default overlay run — now samples
ONLY image-backed text: the one class the analytic walk deliberately
skips, because a url() layer's pixels are unknowable without looking.

The cost is bounded and the method is precise: at most a 3x3 grid of
sample points per candidate (degrading to 3 or 1 for small rects), the
source image drawn once to a canvas with only those pixels read, and
glyph ink never pollutes the samples because the image is drawn alone.
A cross-origin image without CORS headers reports unresolved rather
than guessing. Applied with AI assistance (Claude Code).

Covered by a new fixture: white text on a near-white same-origin
data-URI image background flags via sampled pixels under default
options; dark ink on the same image passes.

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

* Review fixes: root opacity, keyframe steps, static parity, attributed fixtures

Applied with AI assistance (Claude Code), addressing all seven findings
from the automated reviews:

- effectiveOpacityDOM walks through body and html: a page-fade wrapper
  with body/html opacity 0 hides every descendant (Greptile executed a
  Chromium repro of the false positive).
- enclosingCssSelector refuses `from`/`to` keyframe steps, which read
  as never-matching type selectors and got valid findings wrongly
  dropped by the zero-match rule (Bugbot, high). Regression case: an
  overshoot bezier inside a `to` step must survive as page-level.
- The static cascade now inherits visibility, so descendants of a
  hidden container compute as hidden like the browser path; a declared
  visibility:visible still overrides.
- The static engine applies scoped waivers to selector-backed
  html-pattern findings, mirroring the browser — but keeps findings
  whose selector matches nothing, since static scans see partial
  documents.
- The scoped-ignore fixture grows to the mandated matrix: 4 flag cases
  (control, other-rule waiver, sibling waiver, misspelled rule id) and
  5 waived shapes (exact rule, nested depth, star, comma list, self),
  each with a unique border width so every finding attributes to
  exactly one case in both engines' tests.
- The image-backed contrast test pins its cases via the sampled
  finding's candidate text: the white-on-light specimen must flag and
  the dark-ink control must stay clean.

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

* Review fixes: image-only starvation, selector rejection class, inset stripes

Second review round, applied with AI assistance (Claude Code):

- The image-only filter moves inside the candidate collector, before the
  cap: gradient/opacity/filter candidates earlier in DOM order no longer
  consume the 12-candidate budget and starve the url()-backed texts the
  mode exists to sample (Bugbot, high). The regression fixture packs 14
  gradient decoys ahead of the photo panels, and the test now drives the
  overlay entry (impeccableDetectAsync, default options) rather than
  detectUrl's Node-side full fallback, which is where the image-only
  mode actually lives.
- enclosingCssSelector no longer rejects the child combinator or quoted
  attribute selectors; only braces and angle brackets disqualify.
- The inset box-shadow side-tab scanner attaches its selector like the
  pseudo-element scanner does, so those findings waive and dead-drop
  the same way.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 16:31:46 -07:00
github-actions[bot] 8e62ab47ba Sync generated provider output 2026-08-10 19:36:27 +00:00
5f7b001cbe Fix: measure gradient body grounds instead of assuming white (low-contrast false positives) (#557)
* Fix: measure gradient body grounds instead of assuming white (browser mode)

A page whose ground is set via background: linear-gradient(...) on body
leaves backgroundColor transparent, and resolveBackground assumed white
for any body/html-level gradient. In a real browser that assumption is
wrong: the shorthand is always decomposed there, so reaching that branch
means the ground truly is the gradient. On a dark oklch gradient ground
(impeccable.style's lacquer) this turned every light-on-dark text into a
~1.3:1 "on #ffffff" low-contrast finding, ~120 false positives on one
site. Browser mode now returns null so the caller measures against the
actual gradient stops; the white assumption stays for jsdom, where the
undecomposed-shorthand rationale still holds.

Gradient stops also now parse modern color syntax: computed
backgroundImage keeps oklch()/oklab()/hsl()/hwb() stops as authored, and
parseGradientColors only read rgb()/hex, so a token-driven gradient
ground was invisible even once the walk deferred to it. New
parseGradientColorsModern routes those stops through parseAnyColor.

Covered by a Puppeteer fixture (dark oklch body gradient): light text on
the ground must not flag, muted dark-gray ink must, proving the stops
are measured rather than the checks silently skipping.

Prepared with AI assistance (Claude Code), on maintainer instruction.

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

* Composite translucent layers over gradient stops; parse modern glow stops

Review fixes from PR #557's automated reviews, applied with AI
assistance (Claude Code):

- Cursor Bugbot found the new browser-mode early return discarded the
  translucent ancestors resolveBackground had collected: text on a
  frosted wash over a body gradient was measured against raw stops.
  resolveGradientStops now collects translucent layers during its own
  walk (through readCascadeBackgroundColor, extracted so both walks
  read surfaces identically) and composites every stop under them.
- Copilot flagged the other legacy parseGradientColors call sites. The
  glow-context fallback now uses parseGradientColorsModern, since body
  gradients reach it more often after this change. The AI-palette rule
  and the injected analytic sampler stay on the legacy parser
  deliberately: the former is a rule-behavior expansion deserving its
  own fixtures, the latter degrades to pixel sampling or a skip.
- Greptile asked for standard fixture structure: the fixture now has
  labeled flag/pass cases (3 flag, 5 pass) including the frosted-wash
  pair that locks the overlay compositing in both directions and a
  legacy hex-stop gradient guarding the original parser path.

The test scopes itself to the DOM path via visualContrast: false, the
suite's established pattern; the screenshot sampler is a separate
subsystem with its own coverage.

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

* Pin gradient-ground flag cases to their snippet signatures

Bugbot follow-up: a count-only assertion let an offsetting miss and
false positive cancel, especially the frosted pair. Each flag case now
asserts its full text-on-background signature, so the frosted case must
measure against the composited wash and the count guard excludes any
pass case flagging in its place. Applied with AI assistance (Claude
Code).

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

* Comments: the static path is the custom engine now, not jsdom

jsdom left the dependency tree when the static-html engine (StaticElement
+ css-cascade.mjs) replaced it, and that engine does decompose the
background shorthand, so the comments this PR added were dated in both
name and rationale. Only comments touched by this PR are renamed; the
~40 legacy jsdom mentions elsewhere in checks.mjs are a separate sweep.
Applied with AI assistance (Claude Code).

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

* Static engine: measure body gradients too, dropping the white assumption

Follow-up to the browser-mode fix: the white assumption for body/html
gradients was a jsdom guard, and jsdom is gone. The static cascade
decomposes the background shorthand (expandStaticDeclaration) and
preserves var() colors for later resolution, so a missing solid under a
body gradient is now as real in static mode as in a browser — and the
static engine had the identical false-positive class (light text on a
dark gradient ground flagged "on #ffffff") while missing the muted-ink
true positives on the same page.

The old catastrophic case cannot recur: opaque stops fully cover any
hidden solid (they are the ground), alpha stops composite over the
resolved base or the white canvas default, and unresolvable stops drop
rather than guess.

Static twin of the browser test added over the same fixture; the full
suite, the url()-ancestor guard, and a source scan of impeccable.style
(0 low-contrast findings) all stay clean. Applied with AI assistance
(Claude Code).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 12:35:55 -07:00
github-actions[bot] 2ab054d1f4 Sync generated provider output 2026-08-09 23:57:37 +00:00
Paul BakausandGitHub 7fa695093e Bound copy-edit prompt context (#528)
* Bound copy-edit prompt context

Whitelist and truncate staged operation context before it reaches the local agent prompt.

AI assistance: Implemented and validated with OpenAI Codex under maintainer authorization.

* Harden copy-edit prompt bounds

Bound repair, candidate, and element context consistently and preserve absent source positions as null.\n\nAI assistance: Implemented and validated with OpenAI Codex under maintainer authorization.

* Preserve bounded repair context

Keep repair attempt metadata and nested diagnostics while retaining prompt limits.\n\nAI assistance: Implemented and validated with OpenAI Codex under maintainer authorization.
2026-08-09 16:56:49 -07:00
Paul BakausandGitHub 63fb8a56f9 Fix Claude copy-edit prompt transport (#529)
Pass staged copy-edit prompts over stdin so large batches do not exceed platform argv limits.

AI assistance: Implemented and validated with OpenAI Codex under maintainer authorization.
2026-08-09 16:56:46 -07:00
Paul BakausandClaude 6df96fbced fix: address PR review bot findings on background resolution
- Treat a url() image layer stacked above a gradient as an occluding,
  unreadable surface: resolveBackgroundInfo now returns unresolved so the
  gradient-stop fallback never measures stops the image hides
  (greptile-apps finding, reproduced in Chrome).
- Route the glow and AI-palette DOM adapters through resolveBackgroundInfo
  so an unresolved surface makes them abstain instead of hunting gradient
  ancestors past an unreadable layer (Cursor Bugbot finding).
- Resolve background-color keywords jsdom hands through verbatim:
  inherit now reads as no-paint (the ancestor walk IS its resolution) and
  currentcolor substitutes the element's own computed text color instead
  of forcing an abstention (Copilot finding).
- Regression coverage in the dark-theme fixture for all three, asserted in
  both the jsdom and real-Chrome suites; browser detector regenerated.

AI-assisted: prepared with Claude Code at the maintainer's direction.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-09 16:00:51 -07:00
github-actions[bot] 0dd4f90af6 Sync generated provider output 2026-08-09 22:48:39 +00:00
Abarnaa Sree NandGitHub ab9a29728b Warn when static HTML parser dependencies are unavailable (#465) 2026-08-09 15:48:07 -07:00
github-actions[bot] 29e5b1494c Sync generated provider output 2026-08-09 22:35:43 +00:00
Paul BakausandGitHub 181212cb2d Map polish's evidence and verify steps per platform (#550)
* Map polish's evidence and verify steps per platform

polish.md was the last command reference verifying through web-only
vocabulary after #546 gave the pipeline its native leg. Three targeted
mappings, following the in-file precedent new-work.md set (the
classify-triage-polish-verify flow itself is platform-neutral, so no
polish.native.md):

- Evidence gathering: desktop and mobile sizes on the web; the shipped
  device classes on simulator, emulator, or hardware on native, per the
  platform reference's Verifying the build section.
- Verify checklist layouts: phone and tablet size classes, both
  orientations where supported, on native.
- Verify checklist "supported browsers": native has none, so the
  analogues are named (runtime warnings, dropped frames, supported OS
  versions).

Assisted-by: Claude Code

* fix: branch the verify checklist web-vs-native explicitly

Copilot follow-up: the parenthetical style could read as both term
sets applying on native. The two bullets now branch explicitly, and
the shared items (console errors, layout shift, latency, image
loading) stay unbranched since they apply everywhere.

Assisted-by: Claude Code

* fix: restore runtime warnings to the native verify branch

greptile follow-up: the explicit-branch restyle dropped the runtime
warnings requirement the parenthetical carried; folding it into
"console errors everywhere" hid it behind web vocabulary. It is back
as its own item in the native branch.

Assisted-by: Claude Code
2026-08-09 15:35:07 -07:00
github-actions[bot] c38ad8fb8b Sync generated provider output 2026-08-09 22:10:23 +00:00
Paul BakausandGitHub 19786e7a22 Native leg for the verify-and-review pipeline (#546)
* Give the verify-and-review pipeline a native leg

The build-verify-review loop assumed a browser end to end while the
comp side of the system was already platform-aware: new-work.md,
visualize.md, and the asset producer all comp a native app portrait at
its device viewport, and then the verification steps asked for desktop
and mobile browser screenshots of it. Concretely:

- new-work.md step 7 ordered detect.mjs on every hookless build with no
  platform guard. routing.md declares the detector web-only and the
  design hook skips native projects, so a native build was always
  hookless and always ordered to run an HTML rule engine over
  Swift/Kotlin/RN code. The playbook now guards it: web-only, and on
  native the reviewer's floor check is the named slop gate.
- The inspection round and the SKILL.src.md batched-round principle
  named desktop and mobile as the only viewports. Both now map per
  platform: web keeps desktop and mobile; native inspects the shipped
  device classes per OS, captured from the simulator or emulator.
- ios.md and android.md carried no verification guidance at all, so
  nothing told a native run how to produce the screenshots the evidence
  chain depends on. Each gains a Verifying the build section: simctl /
  adb capture commands, dark-appearance and type-scale checks, and the
  simulator-vs-hardware honesty line.
- The finish reviewer judged native builds blind: it never runs
  context.mjs and its packet carried no platform guidance. On native
  the packet now includes the platform reference path(s) and a
  no-detector-ran line, and the reviewer's Input Contract says to judge
  in the platform's conventions.

Assisted-by: Claude Code

* fix: address PR review bot findings

- greptile: carry the capture's device selector through the
  state-changing verification commands (simctl appearance, adb uimode
  and font_scale); unqualified forms fail with several targets attached
- Copilot: align new-work.md's cross-reference with the actual heading
  (Verifying the build)
- Copilot: give the finish reviewer's Input Contract the native
  filename example new-work.md establishes (phone.png / tablet.png,
  suffixed per OS on adaptive)

Assisted-by: Claude Code

* fix: identify simulators by UDID, not display name

greptile follow-up: display names can collide across booted simulators,
so the capture and appearance commands now both key on the UDID from
simctl list devices booted.

Assisted-by: Claude Code
2026-08-09 15:09:55 -07:00
github-actions[bot] 1cbee026c3 Sync generated provider output 2026-08-09 02:27:13 +00:00
045865918a Held for review: agent placeholder substitution, reviewer recapture contract, base-directory script form (#544)
* Resolve {{scripts_path}} in the agent bodies Codex ships

Three code paths emit an agent body: the degraded fallback reference, the
.toml nested inside the skill for Codex, and the native agent file. Only the
nested .toml skipped placeholder substitution and rule-marker stripping, so
the codex and .agents dists shipped `node {{scripts_path}}/embed-prompt.mjs`
verbatim in the asset producer, and every caller had to substitute the token
itself at load time.

All three now render through renderAgentBody(), and the new regression test
asserts a runnable embed-prompt command on each emitted surface plus a
synthetic agent proving markers and placeholders resolve in the nested .toml.

Prepared by an AI agent (Claude Code) under pbakaus's instruction.

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

* Give the finish reviewer's screenshots one fixed address

The Input Contract asked for "desktop and mobile screenshot paths captured by
the parent" and named none, so each session invented a filename and the
verdict pass went looking for a recapture that was never written there. Two
reviewer passes burned on that in the eval runs.

The parent now captures and recaptures to .impeccable/review/desktop.png and
.impeccable/review/mobile.png, and the reviewer reads those two first,
treating a brief-named path as the fallback for a parent that wrote elsewhere.

Prepared by an AI agent (Claude Code) under pbakaus's instruction.

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

* Lead Setup with the base directory the runtime reports

The rendered claude and codex skills opened with
`node .claude/skills/impeccable/scripts/context.mjs`, a project-relative path
that resolves in this repo and in nothing a user installs: a personal or
plugin install puts the scripts outside the project entirely. The working form
was already in the text, parenthesized, after the one that fails.

Setup now leads with `node <skill-base-dir>/scripts/context.mjs` and says once
that the base directory resolves every scripts-path command in the skill and
its references, leaving the project-relative path as the fallback for runtimes
that report no base directory.

Prepared by an AI agent (Claude Code) under pbakaus's instruction.

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

* Answer the Copilot review: brittle model assertion, missing review dir

Assert that {{model}} resolved rather than that it resolved to "GPT", which
belongs to PROVIDER_PLACEHOLDERS and can change without touching what the test
guards. And have the parent create .impeccable/review/ when the harness does
not, so a fresh project's first capture has somewhere to land.

Prepared by an AI agent (Claude Code) under pbakaus's instruction.

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

* Make the review-screenshot contract directory-based, not web-viewport-named

Two amendments to the recapture contract from review feedback:

1. The canonical location is the directory .impeccable/review/, one file
   per captured viewport; desktop.png and mobile.png are the web case,
   not the contract. Baking web-viewport names into the reviewer's spec
   would have hardened a web assumption into paths that a native
   (ios/android/adaptive) build cannot honestly write.

2. Precedence restored to explicit-beats-convention: paths the calling
   brief names are authoritative when the files exist; the canonical
   directory is where the reviewer looks when the brief names none or a
   named path is missing. This avoids stale canonical files from an
   earlier run silently winning over fresh explicit paths. The observed
   failure (the verdict round inventing a round-stamped filename) stays
   fixed: recapture happens over the same files, and invented filenames
   are still called out as pointing at nothing.

Assisted-by: Claude Code

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 19:26:43 -07:00
github-actions[bot] 5c8652b019 Sync generated provider output 2026-08-09 01:43:56 +00:00
490dcfd678 Fix #476: stop using JSON.stringify/double quotes as shell quoting in four exec sites (#533)
* Fix: use argv exec and single-quote escaping for the four #476 shell-injection sites

JSON.stringify and raw double-quote interpolation were used as shell quoting,
but /bin/sh still expands $(...), backticks, and ${} inside double quotes.

- is-generated.mjs / live.mjs runScript: switch execSync string commands to
  execFileSync argv form, which never invokes a shell. Closes the remote path
  where a source file named `$(...)` executes during the live-mode walk.
- skills.mjs hook command + hook-lib.mjs ignore-value suggestion: values that
  must stay shell strings now use POSIX single-quote escaping instead of
  JSON/double quotes. The doctor's hook-token parser learns the single-quoted
  absolute form so it keeps verifying user-level installs.

Adds regression tests for the single-quoted absolute hook form and the
single-quoted ignore-value suggestion. Verified end to end in a browser through
a real live-mode wrap walk against a hostile-named source file.

Prepared with AI assistance (Cursor) under maintainer instruction.

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

* Test: lock in POSIX single-quoting for a $(...) absolute install path (#476)

Follow-up from security review: prove an install path embedding $(...) is
single-quoted in the written hook manifest, not double-quoted.

Prepared with AI assistance (Cursor) under maintainer instruction.

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

* Fix: quote ignore-command args per platform so Windows cmd.exe keeps spaces (#533)

Greptile flagged that switching quoteCommandArg to POSIX single quotes fixed
$(...) injection on /bin/sh but regressed Windows cmd.exe, where single quotes
are literal, so a --file path containing spaces was split and the ignore scope
was stored malformed.

The suggested command runs on the same machine the hook fired on, so branch on
process.platform (the pattern skills.mjs already uses): single-quote on POSIX
for the #476 fix, and keep the original double-quote escaping on Windows so
that path's behavior is unchanged. Adds a regression test asserting both forms.

Prepared with AI assistance (Cursor) under maintainer instruction.

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

* Test: prove the POSIX hook guard is inert under /bin/sh and Windows keeps double quotes (#533)

Greptile's probe could not reach the generated manifest, leaving the hook
command contract unverified. Convert that into committed proof:

- POSIX: install with a $(touch pwned) absolute path, then actually execute the
  generated guard under /bin/sh from a clean cwd and assert no marker file
  appears and the guard exits 0 (single-quoted substitution stays inert).
- Windows: drive copyProviderHooks as win32 in-process and assert the command
  keeps the double-quoted absolute path (usable when the install path has
  spaces; $(...) is inert on cmd.exe anyway).

Test-only; source quoting is unchanged.

Prepared with AI assistance (Cursor) under maintainer instruction.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 18:43:17 -07:00
github-actions[bot] 4596f3183c Sync generated provider output 2026-08-09 01:41:51 +00:00
ddf4526fb5 Fix Windows libuv abort in concept-seed after a successful roll (#526)
* Fix Windows libuv abort in concept-seed after a successful roll

process.exit() with a live fetch keep-alive socket trips libuv's
UV_HANDLE_CLOSING assertion on Windows (nodejs/node#56645), aborting
the CLI with 0xC0000409 after complete output on the successful-roll
path. Destroy the global undici dispatcher before the explicit exit
so no socket is left to race; the hard exit stays, keeping the
no-linger guarantee on blackholed networks.

Fixes #504

Prepared with AI assistance (Cursor agent) under maintainer direction.

* Add regression test for the successful-API dispatcher teardown

The suite covered local rolls and the unreachable-API fallback but
never a successful roll, the one path where a pooled keep-alive
socket exists at exit (issue #504). Serve a real /api/roll from a
local server and assert the CLI destroys fetch's global dispatcher
before its explicit exit. Verified to fail without the fix.

Prepared with AI assistance (Cursor agent) under maintainer direction.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 18:41:19 -07:00
628aac5a40 Fix: point install's next step at the agent chat, not the terminal (#472) (#532)
The install completion message said to run /impeccable init "in your AI
harness", and users pasted it into their shell instead (bash: /impeccable:
No such file or directory). Say the command is typed in the AI coding
agent's chat, and give `npx impeccable init` a pointed redirect instead of
the generic unknown-command error. A real path named `init` still routes
to detect as before.

Prepared with AI assistance (Cursor agent), directed by @abdulwahabone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 18:40:26 -07:00
477484aaee Fix: install missing explicitly selected providers without --force (#536)
* Fix: install missing explicitly selected providers without --force (#500)

An explicit --providers list now treats "already installed" per selected
target: providers with an existing install take the update path, providers
with none get a fresh install (skills + hooks) in the same run. Previously
any existing install (e.g. .claude) made `install --providers=grok` exit 0
without writing .grok, leaving Grok Build on the Claude-variant fallback.

Written with AI assistance (Cursor agent), reviewed and tested by maintainer.

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

* Fix: copy provider agents for freshly installed mixed-install targets

Bugbot caught that the mixed explicit-providers path installed skills and
hooks for missing targets but skipped copyProviderAgents, which both the
update and fresh-install paths run. Written with AI assistance (Cursor agent).

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 18:33:23 -07:00
github-actions[bot] 5d10bc842c Sync generated provider output 2026-08-08 22:50:13 +00:00
Paul BakausandGitHub fc05472a20 Restore reduced-motion animation guidance (#540)
* Restore reduced-motion build guidance

Restores the accessibility requirement and verification step to the animation playbook, with a regression test that keeps it on the build path.

Implemented and validated with OpenAI Codex assistance under standing maintainer authorization.

* Harden reduced-motion guidance regression

Normalizes CRLF input and accepts either reduced-motion spelling so the contract stays portable and intent-focused.

Implemented and validated with OpenAI Codex assistance under standing maintainer authorization.

* Anchor skill reference test to its module

Resolve the repository fixture path from the test module so the regression test is independent of the caller's working directory.

This change was prepared with AI assistance under maintainer authorization.

* Clarify reduced-motion guidance

Replace the double negative in the canonical animation guidance and keep the source contract aligned with the clearer wording.

This change was prepared with AI assistance under maintainer authorization.
2026-08-08 15:49:43 -07:00
github-actions[bot] f254e7685d Sync generated provider output 2026-08-08 22:47:41 +00:00
dbff0880e6 Decision page: full-fidelity comps, raise cycler, declined sizing, canon order, full card anatomy (#545)
* Polish the decision page: raise cycler, declined height, canon order, full card anatomy

Field feedback from the first real rolls of the verdict-routed hand:

- Several raises stacked on the assigned card blew it out of proportion.
  More than one raise now renders as a compact cycler: one visible, a
  counter, click or Enter advances. A single raise stays inline.
- Declined cards inherited the row's stretch alignment, so a narrow card
  stood at the tallest contender's height, a strange stilt beside the
  hand. They now size to their content.
- Deck order becomes a gradient of standing: contenders, then the canon,
  then declined dead last. The canon between full alternates and the
  demoted row reads as the familiar door rather than the last resort
  after the rejects.
- Root cause of bare-bones challenger and canon cards in the field: the
  --schema example only gave the assigned card palette, materials, and
  risk, and models author payloads by imitating the example, so the
  "same anatomy on every card" instruction lost to it every time. The
  example now carries full anatomy on every card and the schema note says
  a card with no palette chips is an authoring gap, not a data gap.

AI-assisted change.

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

* Decision cards carry full-fidelity comps instead of sketches

Field verdict on the sketch contract: the sketches came back too simple
to inform the choice, and generation takes the same time at any
fidelity, so the deliberately-unfinished frame paid comp cost for sketch
quality. The decision card's image is now that direction's north-star
comp, produced under visualize.md's comp discipline (structure-led
prompt, real name and content, no invented commercial claims), saved
under .impeccable/mocks/ with its prompt sidecar. Fairness between cards
comes from equal fidelity in each card's own grammar rather than shared
unfinishedness.

The chosen card's comp is never spent by the choice: on a comp-led build
it enters the comp round as compositional option one (visualize.md now
generates two variations beside it; a round arriving with no decision
comp still renders all three), and on a code-led build it returns at the
finish review as the critique reference. Produce order still front-loads
a re-roll's spend onto the cards read first.

serve-question keeps the sketch field's wire name for payload
compatibility; docs, schema paths, shimmer labels, and the answer
directive (CHOSEN COMP) speak comp.

AI-assisted change.

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

* fix: address PR review bot findings on the comp round

- Producer still forced sketches (cursor, high): the asset producer's
  Decision Sketches contract still mandated deliberately unfinished matte
  sketches, so the parallel path would keep shipping sketch-era images.
  The section is now Decision Comps: full-fidelity north-star comp,
  structure-led prompt, equal commitment across siblings, no invented
  claims, sidecar written.
- Mocks collided with the approval check (cursor, high): decision comps
  now live under .impeccable/mocks/decision/, visualize.md scopes the
  no-approval finding to comp-round output, new-work.md states the
  unchosen hand implies no approval, and the code-led finish packet names
  the chosen decision comp as the critique reference in the approved-comp
  slot.
- Raise cycler announces (greptile, both P1s): a visually hidden
  aria-live region reads out the newly active raise and its position on
  advance; initial render stays quiet.
- Declined width in the vertical deck (cursor, medium): align-self:
  flex-start shrank declined cards to content width in the portrait
  column layout, where the cross axis is horizontal; they stretch there
  and keep content height in the row layout.

AI-assisted change.

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

* fix: raise cycler tooltip and label name both input modes

Copilot: the tooltip said Click while the control also answers Enter and
Space; the title and a new aria-label now say activate/press Enter.

AI-assisted change.

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

* fix: finish reviewer exempts decision comps from the approval check

cursor[bot] follow-through: the reviewer's Persistence check still
treated any comps under .impeccable/mocks/ as approval-gated, and the
reviewer never reads visualize.md by design, so code-led and spent-hand
rounds could draw a false skipped-approval finding. The check now scopes
to comp-round comps, exempts .impeccable/mocks/decision/ as the direction
round's dealt hand, and defines how a code-led build's decision comp is
judged in the approved-comp slot: the critique reference, under the
no-approved-comp fidelity rules plus what the image dared that the build
did not.

AI-assisted change.

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

* fix: the critique reference is its own reviewer input, not the approved-comp slot

cursor[bot]: passing the code-led decision comp through the approved-comp
slot dragged in that slot's obligations (inventory-first reading, the
fidelity matrix, Truth's shipped-asset demand for every image-native
region), which contradicts code-led's premise. The input contract now
names it a separate labeled critique-reference input that nothing binding
"the approved comp" touches, and Fidelity defines its treatment where the
no-approved-comp rules live: provocation, not spec; no matrix, citations,
or asset obligations; its dares enter material_fixes as ordinary fixes.

AI-assisted change.

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

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-08-08 15:47:11 -07:00
github-actions[bot] d65a08b064 Sync generated provider output 2026-08-08 21:17:56 +00:00
c70bcbf6b4 Direction round: verdict-routed hand, MY PICK card, salience parity, Safer/Bolder registers (#531)
* Route the direction hand by verdict, add the pick card, enforce salience parity

The decision round previously rendered every dealt challenger as an equal
full card whatever the weighing said, so a world that fused poorly (an
underwater world dealt to a flower shop) sat at the same visual weight as
the assigned direction, and concept-level fusion had no surviving output.
Three changes, all presentation-layer; the dice, the assignment, and the
two-axis weighing are untouched:

- Verdict routing: the weighing closes with wins / competitive / declined
  per challenger, decided before any borrowing. Declined challengers render
  demoted (narrow, quiet, catalog art as a labeled thumb, "Adopt anyway"),
  reordered to the end of the deck by the page itself, still adoptable,
  never silently dropped. Donations return as named "raised by" lines on
  the assigned card: a declined challenger donates ambition and system
  discipline, never its clothes.

- The pick card: one card for the model's top-ranked grounded candidate
  when the dice assigned another, kicker MY PICK, honest familiarity risk
  on its face. One card, never a ranked list, never the lead position; the
  anti-menu rule survives with exactly this carve-out.

- Salience parity: a card's imagery weight is capped by the assigned
  card's. With a text-only assigned card (no image generation in the
  harness), full-bleed catalog heroes demote to labeled thumbs, so what
  looks important is the verdict's call, never rendering luck.

serve-question payload gains additive fields (verdict, kept, raised); old
payloads render unchanged. concept-seed's rendered instructions carry the
verdict/donation contract and the pick-card carve-out. Covered by two
Playwright tests in the new-work e2e suite (verdict routing + parity).

Design exploration and rationale were worked through with the maintainer;
research grounding is impeccable.style/research lessons 3-5.

AI-assisted change.

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

* Add Safer/Bolder re-roll registers to the direction round

The re-roll gains the user's steering wheel on the familiar-to-bold axis.
The decision page renders two register buttons beside the plain re-roll
(payload: reroll: { registers: ["safer", "bolder"] }; booleans still work),
the answer carries the chosen register, and concept-seed gains --register.

The design constraint that shaped the implementation: a register changes
only what a round INSTRUCTS, never what it DEALT. The same key and reroll
count reproduce the same deal whatever the register, so the exclusion chain
never forks and the reproduction contract holds with no API change.

- bolder: the dealt foreign forms become the whole hand, every challenger a
  full card; the first-dealt challenger leads (assignment by deal order, so
  the dice still choose). The pick card sits out; the canon stays.
- safer: the round's dealt hand is spent unseen and stays excluded; the
  model presents its remaining conventional grounded candidates (at most
  three) plus the canon executed against named competitors. This is the one
  sanctioned lineup of the model's own ranked list, existing only by
  explicit user request. Works degraded (needs no catalog); bolder degrades
  to a plain grounded round, disclosed.

Registers are user steering, never the model's to pre-select. Covered by a
concept-seed unit test (same-deal invariant, validation) and a Playwright
test (button, answer field, REGISTER directive).

AI-assisted change.

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

* Add the execution-contract round: comp-led or code-led, chosen after the direction

The build previously went comp-led for everyone, silently: a generated comp
led and the build chased it, which produces the boldest compositions and
also the measured worst-of-both-worlds failure (ambitious design landed
poorly, no motion, fix rounds after). Models already defect from it by
quietly skipping comp generation, which is unsanctioned code-led with no
contract to catch it. This makes the fork explicit and both paths
defection-proof:

- Comp-led: the comp is law and non-optional once chosen; visualize.md and
  the comp-is-king build phases run as today.
- Code-led: no comp of this page, skipped by contract rather than drift.
  The QUALITY BAR boards still calibrate finish, and the ambition moves
  into the written direction contract (FIRST VIEWPORT plus a named
  signature interaction and motion grammar), audited by the finish
  reviewer in behavior. Not a discount on commitment.

Placement: a second round on the same open table, right after the
direction lands. Sketches stay in the direction round (they pick the
world); comps are what code-led skips (they bind the composition). The
chosen world sets the default lead; the user flips freely; a standing
preference recorded in PRODUCT.md skips the round on later surfaces; with
no image generation there is no fork, code-led is the only path.

Mechanism: serve-question gains payload-level followup: true, which keeps
the detached server alive after a pick (exactly like re-roll), swaps the
page to the loading hand instead of goodbye, marks the answer with
followup: true so --wait keeps the table, and prints a FOLLOWUP OPEN
directive telling the agent to deliver the next round via --update.
Covered by a Playwright test driving the full two-round flow.

AI-assisted change.

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

* fix: address PR review bot findings

- Degraded safer register no longer contradicts itself (greptile,
  Copilot, cursor): the degraded template previously said "the assigned
  index is suspended; the user picks" and then emitted ASSIGNED INDEX,
  the mandatory build instruction, and the restated footer anyway. The
  degraded safer path now suppresses the assignment machinery entirely,
  matching the non-degraded safer round, and restates the user-picks
  behavior for truncated readers instead.
- A declined card's declared sketch no longer renders a full media face
  (Copilot): the renderer ignores sketch slots on declined cards
  outright, so a stray sketch cannot buy back the salience the verdict
  took away.
- Bolder rounds no longer carry the generic weighing instruction
  (cursor): it measures against the assigned grounded direction, which
  the bolder register suspends; a leader-relative variant weighs the
  fused challengers against the first-dealt leader instead.

All three pinned by new assertions in tests/concept-seed.test.mjs and
tests/new-work-e2e.test.mjs.

AI-assisted change.

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

* fix: followup never arms the loading hand in blocking serve mode

cursor[bot] caught a client/server disagreement: the page interpolated its
FOLLOWUP constant from the payload alone, so a followup: true payload served
in blocking mode (no --start) would leave the browser on a loading hand that
nothing resolves, since a blocking server exits on any pick and has no
update channel. The page constant is now armed only when the server is
detached, blocking rounds get the goodbye screen as before, and new-work.md
states that followup belongs only on a detached round; blocking and
structured-tool channels run the build-path round as its own second
question. Pinned in tests/serve-question.test.mjs.

AI-assisted change.

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

* Add card-kind choice telemetry and the bolder routing disambiguation

The choice ping previously fired only when a dealt catalog challenger won,
so pick-share and canon-share had no denominator and the decision page's
new spectrum could not be measured. The ping now fires once per resolved
attended round on API-dealt rolls: --kind names which card class won
(assigned / pick / challenger / canon), --chosen carries the catalog id
only when a dealt challenger won, and --register rides along when the
round came from a steered hand. Grounded candidates' names never leave the
machine (the ping carries the kind alone), the legacy id-only shape stays
valid, and DO_NOT_TRACK / IMPECCABLE_NO_TELEMETRY still disable the ping
entirely. The seed's TELEMETRY block teaches the new invocation.

Also the naming-collision guard: "bolder" said while a direction round is
open routes to the Bolder hand register, never the bolder refinement
command; one line each in bolder.md and new-work.md.

The /api/chosen field additions land in a sister impeccable-site PR; the
API ignores unknown fields meanwhile, so this is safe to ship first.

AI-assisted change.

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

* fix: ping test survives a DO_NOT_TRACK shell

cursor[bot]: the pingChosen unit test cleared only IMPECCABLE_NO_TELEMETRY,
so a developer shell with DO_NOT_TRACK set failed the success-path
assertions. The test now clears both, restores prior values in finally,
and passes under DO_NOT_TRACK=1.

AI-assisted change.

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

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-08-08 14:17:21 -07:00
Paul Bakaus 331af89eae Stop assuming white when a background cannot be read
Dark themes came back from a scan buried in low-contrast findings that
all claimed the light text sat on #ffffff. Two live runs against
impeccable.style produced 102 and 95 of them.

Two causes, both fixed here.

Parsing. Browsers keep the authored color space in getComputedStyle
output: oklch() stayed oklch, but color-mix results come back as
color(srgb 1.04 0.72 -0.21), wide-gamut authors get color(display-p3
...), and lch()/lab() survive verbatim. The parser read none of those, so
those surfaces registered as unset. parseGradientColors was worse: it
matched only rgba() and #hex, so a ground painted as
linear-gradient(oklch(...), oklch(...)) counted as a gradient with no
stops at all.

Guessing. When the ancestor walk ran out of readable color it returned
white, and on a body-level gradient it returned white without even
looking. Light copy on a lacquer-black page then measured 1.3:1 against a
canvas the visitor never sees.

resolveBackgroundInfo now separates three outcomes: a resolved surface, a
gradient the caller should fall back to stops for, and an unreadable
layer. The last one makes both color adapters skip their contrast checks
entirely. White survives in exactly one case, the one that earns it:
every layer up to the document root was genuinely transparent.

Color conversions moved to cli/engine/shared/color.mjs and gained lab,
lch, and color() for srgb, srgb-linear, and display-p3. Spaces outside
that set return null, which now routes to abstention rather than to a
color nobody painted. Every conversion is pinned against what Chrome
itself paints for the same string.

Rescanning impeccable.style: 102 low-contrast findings down to 30, none
of them on an invented white ground.

Assisted-by: Claude Code
2026-08-07 11:18:14 -07:00
Paul BakausandClaude Opus 5 aee6ce9352 Give the Live UI surface inventory one definition again
The list of Live chrome surfaces was inlined into live-browser.js as a
function-scope const when live/ui-core.mjs was deleted for having zero
in-repo references. It had one out-of-repo reference. The private
impeccable-site repo imports it at build time: its Live UI lab must hold
a snapshot for every surface Live defines, and the site build fails with
the surface name when one is missing. Inlining put the list out of reach
of every Node importer, so the site had to regex it back out of the
browser script, and the guard only kept passing because the site's
materialized copy of skill/ was stale.

A guard that reads a list the site itself maintains guards nothing, so
the fix is a real export rather than a better parser.

skill/scripts/live/ui-surfaces.mjs is now the single definition. The
browser-runtime constraint is unchanged and satisfied the same way the
command palette already solves it: live-browser.js is served raw and
injected as a classic <script>, so it cannot import an ES module. The
/live.js assembler serializes the module into
window.__IMPECCABLE_LIVE_UI_SURFACES__ in the prelude it already writes
for the token, port and vocabulary, and live-browser.js reads the global.
assembleLiveBrowserScript defaults the value from the module rather than
taking it from live-server.mjs, so the bundle carries the canonical
inventory by construction instead of by a caller remembering to pass it.

The emitted inventory is byte-identical to the inlined one.

tests/live-ui-surfaces.test.mjs pins both halves of the seam: the module
is the definition (live-browser.js must not redeclare it), the prefix the
module builds ids from matches the PREFIX live-browser.js hardcodes, and
the assembled bundle still carries the list. live-server.test.mjs gets
the matching integration check against a served /live.js.

One existing assertion changed. live-browser-regression.test.mjs checked
that the steer Send control is registered as live chrome by matching the
text of the inline literal's last line. That encoded where the list was
written, not what it contains; it now asserts membership in the imported
LIVE_UI_COMPONENT_IDS, which is the behaviour it was after.

Verified with the full default suite plus a live-e2e fixture run
(vite8-react-modal), so the overlay is exercised end to end in a browser.

AI-assisted via Claude Code under maintainer direction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 14:53:20 -07:00
Paul BakausandGitHub a075d89bdb Simplify CSS color channel parsing (#520)
Centralize CSS numeric token parsing and characterize every supported color unit while preserving config and filtering behavior.

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

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

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

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

* Fix compound scan suffix matching

AI assistance: Codex addressed review findings and ran the validation described in the pull request.
2026-08-04 14:08:49 -07:00
github-actions[bot] 2f609915eb Sync generated provider output 2026-08-04 20:34:25 +00:00
616 changed files with 31089 additions and 7567 deletions
+2 -2
View File
@@ -9,11 +9,11 @@ This skill gives you the tools and permission to create design that earns to be
Core principles:
- Go all out. No hedging, no shortcuts. The deliverable must be complete (except assets the user must provide).
- Dream big and bold. Distinct, beautiful, outstanding and highly inspiring work.
- Verify in bounded passes, not a loop, and the ceiling covers the whole cycle: screenshots, defect scans, micro-edits, and rebuilds alike. Build fully, inspect once with a batched round (desktop and mobile together), fix everything it shows in one batch, confirm with at most one more round, and stop polishing. Open-ended self-QA burns the user's money doing worse what the finish handoffs do better.
- Verify in bounded passes, not a loop, and the ceiling covers the whole cycle: screenshots, defect scans, micro-edits, and rebuilds alike. Build fully, inspect once with a batched round (desktop and mobile together on the web; the shipped device classes on a native platform), fix everything it shows in one batch, confirm with at most one more round, and stop polishing. Open-ended self-QA burns the user's money doing worse what the finish handoffs do better.
## Setup
1. Run `node .agents/skills/impeccable/scripts/context.mjs` once per session (if the runtime shows this skill's loaded base directory, run `node <skill-base-dir>/scripts/context.mjs`; keep cwd at the user's project). Pass a named source file or route as `--target <path>`. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it.
1. Run `node <skill-base-dir>/scripts/context.mjs` once per session, where `<skill-base-dir>` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .agents/skills/impeccable/scripts/...` command in this skill and its references, and `.agents/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target <path>`. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it.
2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing.
3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work.
@@ -13,9 +13,9 @@ Your job is production cleanup, not new art direction. Work only from the approv
Do not redesign. Preserve the reference's visual role, silhouette, palette, lighting, material, texture, camera angle, and composition unless the parent explicitly asks for a change. Preserve perspective only when it belongs to the object or scene itself; if CSS should create the card transform, shadow, rounded clipping, border, or layout, remove that presentation chrome from the raster.
## Decision Sketches
## Decision Comps
When the parent hands you a decision card packet instead of an approved mock, the job is one sketch: one card, one file, written to the card's declared `sketch` path the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a sketch is reported back, not padded from imagination. Render through the parent's shared frame, including its aspect: the requested surface's first viewport as a flat, matte design sketch in the card's own palette and type character, deliberately unfinished, no photorealism, no gloss; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. The frame is shared across siblings so no sketch looks more finished than another; a finish gap breaks the comparison. The only legible text is the product's real name and one real headline; greek every other text region into indistinct lines, because an invented spec, price, or date in a sketch is a claim PRODUCT.md never made. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a sketch run.
When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `sketch` path (the field keeps its wire name) the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a comp is reported back, not padded from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (its regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment is what keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
## Input Contract
@@ -56,7 +56,7 @@ Ask blockers once, globally. Missing source path/crops or output directory block
Codex: the imagegen skill's built-in `image_gen` path is the native tool here; prefer it for generation, editing, and the chroma-key workflow.
7. Remove baked-in UI text, navigation, buttons, body copy, and mock chrome unless the text is part of the asset.
8. Think through the final DOM/CSS representation before generating. If CSS will own radius, clipping, shadows, borders, perspective, responsive cropping, captions, or card frames, do not bake those into the bitmap.
9. Save outputs non-destructively in the requested project directory, and leave the intent with the file: after every generation, run `node {{scripts_path}}/embed-prompt.mjs <asset> --prompt "<the prompt used>"` so the prompt is embedded in the image itself, because the build thread composes what you made and needs to know what it is looking at, and the embedding survives copies where sidecars get lost.
9. Save outputs non-destructively in the requested project directory, and leave the intent with the file: after every generation, run `node .agents/skills/impeccable/scripts/embed-prompt.mjs <asset> --prompt "<the prompt used>"` so the prompt is embedded in the image itself, because the build thread composes what you made and needs to know what it is looking at, and the embedding survives copies where sidecars get lost.
10. Compare each output against its source crop, opening every image by its workspace-relative path; sandboxed viewers reject absolute paths. If a review/QA tool is available, run it before the final manifest, then retry each major/fatal finding once before finalizing.
Use `texture/pattern extraction` only when the source region is already clean enough to sample as texture. If UI, cards, labels, headings, body copy, or footer chrome must be removed to make a reusable texture or background, classify it as crop-derived cleanup or clean-plate work.
@@ -13,12 +13,12 @@ A hard turn ceiling ends the run without warning; a run that ends before the fiv
## Input Contract
Expect: the original request; the confirmed user answers; the artifact path(s); desktop and mobile screenshot paths captured by the parent; the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths and the approved comp path; and the skill's `reference/craft-floor.md` path. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped.
Expect: the original request; the confirmed user answers; the artifact path(s); the screenshots the parent captured, which live in `.impeccable/review/` (on the web, `desktop.png` and `mobile.png`; on native, device-class names such as `phone.png` and `tablet.png`, suffixed per OS on adaptive); a screenshot path the calling brief names is authoritative when the file exists, and `.impeccable/review/` is where to look when the brief names none or a named path is missing, never a filename you invent; the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths and, on a comp-led build, the approved comp path (a code-led build has no approved comp; it passes the chosen decision comp as a separate critique-reference input, labeled as such, and nothing in this file that binds the approved comp binds it); and the skill's `reference/craft-floor.md` path. On a native (`ios` / `android` / `adaptive`) build the packet also carries the platform reference path(s) (`reference/ios.md` / `reference/android.md`) and a line saying no detector ran: read the platform reference alongside the craft floor and judge every check in the platform's own conventions, the screenshots are device captures rather than browser viewports, and your floor check is the build's only slop gate. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped.
## Checks, in order
1. **Persistence.** PRODUCT.md exists. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comps with no recorded pick mean the approval point was skipped, and that is a material finding.
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element, and its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement, because medium is part of the promise. When no approved comp was supplied, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality, CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. In every material_fixes list, a fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
1. **Persistence.** PRODUCT.md exists. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comp-round comps with no recorded pick mean the approval point was skipped, and that is a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and they imply no approval whatever the build path; a code-led build has no comp round at all.
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element, and its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement, because medium is part of the promise. When no approved comp was supplied, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality, CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. A critique-reference comp, when one arrived on such a build, is provocation rather than spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is the question of what the image dared that the build did not, and the dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. In every material_fixes list, a fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition.
4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped and that is a material fix ahead of any craft point. Then, for each of the five blocks, does the render keep the promise? Apply the memory test to the first viewport.
5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every image-native region of the approved comp shipped as a real asset, not a gradient standing in for one, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind other paint is a compliance token, not a shipped material.
@@ -36,5 +36,5 @@ Return the disposition line first, then exactly five sections: `persistence` (pa
## Verdict Pass
When the parent returns with post-fix recaptures, you are scoring, not re-hunting. The parent's narration of what was fixed is not evidence; a claimed fix you cannot see in the recaptures is unresolved. For each material fix from your review, one line: resolved, partial, or unresolved, tied to what the new screenshots visibly show; a fix answered mechanically, positions moved but the quality the finding named still absent, is partial at best. Then name at most three regressions the fix batch itself introduced, judged by the same matrix rules, and nothing else; no new hunt, no new checks. Return exactly two sections: `verdict` (the scored list) and `remaining` (what stays open, or "clear"), and end with the disposition line recomputed against what remains open; unresolved or partial material findings can never recompute to ship.
When the parent returns with post-fix recaptures, you are scoring, not re-hunting. The parent recaptures over the same screenshot files you read in the review round, so re-read those exact paths for this round; a round-stamped filename you invent points at nothing. The parent's narration of what was fixed is not evidence; a claimed fix you cannot see in the recaptures is unresolved. For each material fix from your review, one line: resolved, partial, or unresolved, tied to what the new screenshots visibly show; a fix answered mechanically, positions moved but the quality the finding named still absent, is partial at best. Then name at most three regressions the fix batch itself introduced, judged by the same matrix rules, and nothing else; no new hunt, no new checks. Return exactly two sections: `verdict` (the scored list) and `remaining` (what stays open, or "clear"), and end with the disposition line recomputed against what remains open; unresolved or partial material findings can never recompute to ship.
'''
@@ -38,3 +38,9 @@ Would a fluent Android user trust this app, or trip on off-spec components? The
- **One FAB, one primary action.** Never stack FABs or spend one on a secondary task.
- **Snackbars for transient feedback** (actionable when useful, never a toast for that); dialogs only for decisions that must interrupt.
- **Material motion patterns.** Container transform, shared-axis, fade-through, with standard easing and durations; honor the system Remove animations setting with a crossfade or instant cut.
## Verifying the build
- **Screenshots come from the emulator or a connected device, never a browser.** Build and install, then capture with `adb exec-out screencap -p > <path>` (pick a device with `adb -s <serial>` when several are attached). Capture every device class the app ships to, at least one phone and, when tablets are a target, one tablet, and write the files where the review flow expects them.
- **Dark theme and font scale belong in the pass.** `adb shell cmd uimode night yes` flips the theme; `adb shell settings put system font_scale 1.3` (restore `1.0` after) catches the clipped labels a fixed layout hides; with several targets attached, the capture's `-s <serial>` goes on these commands too.
- **Emulators give breadth; gestures, refresh rates, and performance need hardware.** Say which one produced the evidence.
@@ -74,12 +74,15 @@ Keep content visible in the default state so failed scripts do not hide the page
Respect autoplay and sound preferences. Any nonessential loop must stop when offscreen or hidden.
Every web animation needs a `prefers-reduced-motion` path with an intentional alternative. Remove or reduce spatial movement while preserving opacity, color, and state transitions that carry meaning. Reduced motion means fewer and gentler animations, not disabling all motion; feedback that confirms an action should remain legible.
## Verify
- The focal motion is specific to the selected world and surface.
- Every supporting animation explains feedback, state, or relationship.
- Interruption and repeated use behave correctly.
- Desktop, mobile, and keyboard paths remain usable.
- The `prefers-reduced-motion` path reduces movement without erasing meaningful feedback or state changes.
- Expensive effects stay smooth on the target device.
- Removing an animation would lose meaning or authored character, not merely decoration.
@@ -1,5 +1,7 @@
> **Additional context needed**: which section is the target, and what must stay untouched.
An open direction round owns the word first: "bolder" said while a direction decision is on the table is the Bolder hand register steer, a fresh deal of foreign forms (see new-work.md), not this command. This command refines a surface whose world already shipped.
"Bolder" is an amplification request, and almost always it is scoped to something that already exists. The surrounding page, its system, and its conventions are the given. Your job is to raise one part to the conviction the rest already implies, without rebuilding anything the brief did not name. The reflex answer, reaching for more effects, is the opposite of bold; reject it first.
## Scope is sovereign
@@ -12,6 +12,7 @@ Each of these is a check on the built result, not an intention. Run them togethe
- **Type:** body measure 6575ch, display max 6rem, tracking floor -0.04em, balanced headings, obvious scale and weight steps. Run the real copy at every breakpoint and fix what overflows.
- **Motion:** one authored moment, not scattered effects and not one identical entrance on every section. Exponential ease-out from an already-visible default. Reach past transform and opacity: blur, backdrop-filter, clip-path, mask, and shadow belong to the palette when they stay smooth.
- **States:** hover, disabled, loading, error, empty. Plus real content, working controls, responsive composition, keyboard focus.
- **Browser surfaces:** the parts you did not draw still carry the design. Text selection, the caret, custom scrollbars, focus rings, underline offset, and the numerals in tabular data all ship with browser defaults that belong to no design system. Theme them from the palette. This is the cheapest signal that a page was built rather than assembled, and the one models skip most reliably.
- **Copy:** the product's own language. Controls name their action; errors name the problem and the recovery.
- **Coverage:** every brief requirement present and findable within seconds.
@@ -11,9 +11,9 @@ Your job is production cleanup, not new art direction. Work only from the approv
Do not redesign. Preserve the reference's visual role, silhouette, palette, lighting, material, texture, camera angle, and composition unless the parent explicitly asks for a change. Preserve perspective only when it belongs to the object or scene itself; if CSS should create the card transform, shadow, rounded clipping, border, or layout, remove that presentation chrome from the raster.
## Decision Sketches
## Decision Comps
When the parent hands you a decision card packet instead of an approved mock, the job is one sketch: one card, one file, written to the card's declared `sketch` path the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a sketch is reported back, not padded from imagination. Render through the parent's shared frame, including its aspect: the requested surface's first viewport as a flat, matte design sketch in the card's own palette and type character, deliberately unfinished, no photorealism, no gloss; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. The frame is shared across siblings so no sketch looks more finished than another; a finish gap breaks the comparison. The only legible text is the product's real name and one real headline; greek every other text region into indistinct lines, because an invented spec, price, or date in a sketch is a claim PRODUCT.md never made. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a sketch run.
When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `sketch` path (the field keeps its wire name) the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a comp is reported back, not padded from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (its regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment is what keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
## Input Contract
@@ -11,12 +11,12 @@ A hard turn ceiling ends the run without warning; a run that ends before the fiv
## Input Contract
Expect: the original request; the confirmed user answers; the artifact path(s); desktop and mobile screenshot paths captured by the parent; the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths and the approved comp path; and the skill's `reference/craft-floor.md` path. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped.
Expect: the original request; the confirmed user answers; the artifact path(s); the screenshots the parent captured, which live in `.impeccable/review/` (on the web, `desktop.png` and `mobile.png`; on native, device-class names such as `phone.png` and `tablet.png`, suffixed per OS on adaptive); a screenshot path the calling brief names is authoritative when the file exists, and `.impeccable/review/` is where to look when the brief names none or a named path is missing, never a filename you invent; the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths and, on a comp-led build, the approved comp path (a code-led build has no approved comp; it passes the chosen decision comp as a separate critique-reference input, labeled as such, and nothing in this file that binds “the approved comp” binds it); and the skill's `reference/craft-floor.md` path. On a native (`ios` / `android` / `adaptive`) build the packet also carries the platform reference path(s) (`reference/ios.md` / `reference/android.md`) and a line saying no detector ran: read the platform reference alongside the craft floor and judge every check in the platform's own conventions, the screenshots are device captures rather than browser viewports, and your floor check is the build's only slop gate. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped.
## Checks, in order
1. **Persistence.** PRODUCT.md exists. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comps with no recorded pick mean the approval point was skipped, and that is a material finding.
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element, and its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement, because medium is part of the promise. When no approved comp was supplied, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality, CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. In every material_fixes list, a fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
1. **Persistence.** PRODUCT.md exists. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comp-round comps with no recorded pick mean the approval point was skipped, and that is a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and they imply no approval whatever the build path; a code-led build has no comp round at all.
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element, and its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement, because medium is part of the promise. When no approved comp was supplied, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality, CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. A critique-reference comp, when one arrived on such a build, is provocation rather than spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is the question of what the image dared that the build did not, and the dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. In every material_fixes list, a fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition.
4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped and that is a material fix ahead of any craft point. Then, for each of the five blocks, does the render keep the promise? Apply the memory test to the first viewport.
5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every image-native region of the approved comp shipped as a real asset, not a gradient standing in for one, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind other paint is a compliance token, not a shipped material.
@@ -34,4 +34,4 @@ Return the disposition line first, then exactly five sections: `persistence` (pa
## Verdict Pass
When the parent returns with post-fix recaptures, you are scoring, not re-hunting. The parent's narration of what was fixed is not evidence; a claimed fix you cannot see in the recaptures is unresolved. For each material fix from your review, one line: resolved, partial, or unresolved, tied to what the new screenshots visibly show; a fix answered mechanically, positions moved but the quality the finding named still absent, is partial at best. Then name at most three regressions the fix batch itself introduced, judged by the same matrix rules, and nothing else; no new hunt, no new checks. Return exactly two sections: `verdict` (the scored list) and `remaining` (what stays open, or "clear"), and end with the disposition line recomputed against what remains open; unresolved or partial material findings can never recompute to ship.
When the parent returns with post-fix recaptures, you are scoring, not re-hunting. The parent recaptures over the same screenshot files you read in the review round, so re-read those exact paths for this round; a round-stamped filename you invent points at nothing. The parent's narration of what was fixed is not evidence; a claimed fix you cannot see in the recaptures is unresolved. For each material fix from your review, one line: resolved, partial, or unresolved, tied to what the new screenshots visibly show; a fix answered mechanically, positions moved but the quality the finding named still absent, is partial at best. Then name at most three regressions the fix batch itself introduced, judged by the same matrix rules, and nothing else; no new hunt, no new checks. Return exactly two sections: `verdict` (the scored list) and `remaining` (what stays open, or "clear"), and end with the disposition line recomputed against what remains open; unresolved or partial material findings can never recompute to ship.
+12 -6
View File
@@ -48,14 +48,20 @@ The first argument is the action. Defaults to `status`.
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
## Triage 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`.
The hook itself never writes ignore config; every exception goes through `hook-admin.mjs`. Triage each finding into one of three outcomes:
- **Real design problem**: fix it. Never add an ignore to skip a fix or to push a blocked write through.
- **Confident false positive or sanctioned exception**: persist the narrowest ignore yourself and disclose it in your reply. The bar is evidence you can name: an intentional demo or fixture, documentation of bad design, literal or domain-appropriate motion (a ball that bounces), or a choice the user already confirmed. Put that evidence in `--reason` as `"<who decided: evidence>"`; write "user confirmed" only when the user actually did.
- **Unsure**: leave the finding standing and ask the user in one line. Ask once; a one-line question costs less than the hook re-firing on every later edit.
Self-serve stops at `ignore-value`. `ignore-file` and `ignore-rule` silence too much to add on your own judgment; ask the user first.
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 line shows an `ignore-value <rule> <value>` pair, pass it to `hook-admin.mjs ignore-value` with your `--reason`. This writes shared `.impeccable/config.json` by default.
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` for 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`, scope that one rule to the file: `ignore-value <id> "*" --file <path>`. Run `npx impeccable detect <path>` first to see what actually fires there.
- Reach for `ignore-file <path>` only when the whole file is out of scope for design review: a fixture, a generated artifact, a deliberate slop demo. It silences every rule for that file permanently, including rules that have not been written yet. A real UI surface with one noisy rule wants the file-scoped value ignore above.
- 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.
@@ -67,10 +73,10 @@ Example value-specific exception:
node .agents/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional"
```
Example intentional motion exception:
Example self-served exception, with the evidence named:
```bash
node .agents/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional"
node .agents/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "Agent: literal ball-bounce animation, bounce easing is the subject"
```
Example whole-rule font exception:
@@ -43,3 +43,9 @@ Would a fluent iPhone user trust this app, or pause at off-spec controls? The te
- **System transitions.** Push slides, sheets rise, dismiss reverses the entrance. Custom transitions that fight the navigation model disorient.
- **Honor Reduce Motion.** Crossfade instead of parallax and large slides.
## Verifying the build
- **Screenshots come from the Simulator, never a browser.** Build and run, then capture with `xcrun simctl io booted screenshot <path>` (with several running, replace `booted` with the target's UDID from `xcrun simctl list devices booted`; display names can collide, the UDID never does). Capture every device class the app ships to, at least one iPhone and, when iPad is a target, one iPad, and write the files where the review flow expects them.
- **Dark Mode and Dynamic Type belong in the pass.** `xcrun simctl ui booted appearance dark` flips appearance, reusing the capture's UDID when several are booted; a check at a large Dynamic Type size catches the truncation a fixed layout hides.
- **Simulators give breadth; posture, gestures, and performance need hardware.** Say which one produced the evidence.
@@ -43,12 +43,14 @@ The script assigns which structure gets built; your top-ranked structure is what
1. Name the product's unique mechanism in one sentence, the audience's real scene, its cultural home, and what this first surface must prove. Note the page this category always ships and its predictable opposite; name both as the rut and keep them out of the seven-candidate list. A brief that paints its own picture, a product name, a titled artifact, a governing metaphor, adds its literal reading to the rut: spend at most one candidate on it and derive the rest from elsewhere in the audience's world.
2. From that cultural world, list seven concrete visual systems, artifacts, places, or rituals the audience knows by heart, each with one line on why it resonates and can carry the mechanism, ordered by resonance. The audience's world includes its graphic and screen traditions, not only its physical objects: the notation, publications, identity programs, data graphics, and interfaces it reads daily; a nameable abstract system (a school of poster, a documentation standard) is as concrete a candidate as any artifact. What would this thing look like as a physical object; what did its world look like before the web? Near-duplicates count once. When more than three of the seven share one material family, the derivation stopped at the subject's most obvious artifact; dig until the list spans at least three families.
3. Turn that material into complete directions: each joins a reusable visual world to a concrete first-surface experience.
4. Run `node .agents/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. This step has no substitute and no skip condition: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure, because the roll is the mechanism that keeps every run from converging on the category default. The script assigns which direction gets built and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, and clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity; losing to strong grounded material is a valid outcome, and beating a thin or tool-monoculture list is the point.
5. Present one direction, fully committed: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Alongside it, offer the hand's challengers as named alternates, the weighing's verdict written on each as its one-line case, an honest "fuses poorly because X" included; the weighing informs the user's choice, it never pre-empts it. A hand holds at most three challengers: when the roll deals more, the three strongest join the hand and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add re-roll with an optional one-line steer. Never present a ranked menu of your own grounded candidates; a lineup of those invites the safest card. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool; the structured tool's option list also carries the standing exit as its last option.
4. Run `node .agents/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. This step has no substitute and no skip condition: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure, because the roll is the mechanism that keeps every run from converging on the category default. The script assigns which direction gets built and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, and clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity; losing to strong grounded material is a valid outcome, and beating a thin or tool-monoculture list is the point. The weighing closes with a verdict per challenger, decided before any borrowing is considered: wins (beats the assigned direction on both axes; it becomes the build candidate), competitive (holds one axis; it stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a motif lifted from a declined world is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen.
5. Present one direction, fully committed and already raised by the hand it beat, its raises visible as named lines: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Alongside it, route each dealt challenger by its verdict: winning and competitive challengers are full alternates carrying their QUALITY BAR cards and one-line case, while declined challengers render demoted, compact and quiet, each carrying its verdict plus what the direction kept from it, never full-size and never silently dropped, each still adoptable on request. The verdict informs the user's choice, it never pre-empts it; the demoted row is the hand's proof of judgment, showing why the dealt worlds made the presented direction better. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join the hand and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker MY PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often the one most runs in this category land on, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: the rest of your grounded candidates stay yours, because a lineup of them hands selection back to a taste function and invites the safest card. The pick never takes the lead position, and when the dice assign your top candidate there is no pick card; the assigned card notes it also topped your list. Add re-roll with an optional one-line steer, offered in three registers: plain (a fresh hand, same spread), safer (the familiar register: your remaining conventional grounded candidates plus the canon against named competitors), and bolder (foreign forms only, at full commitment). A register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool; the structured tool's option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit as its last option, while declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel too.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it, in the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path, convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. A standing preference gets recorded as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, the dealt challengers as alternates carrying their QUALITY BAR cards, and re-roll, steer, plus canon enabled; a degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy, thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (run the script with `--schema` for the exact shape); the page renders identity from these fields, and a challenger's catalog image rides as labeled inspiration, never as the promise of the build. Author `canonCard` too: the category standard as one honest card with the same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .agents/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. Only a session where no browser can open at all, headless, CI, an eval worker, a remote shell with no display, puts the same decision through the structured question tool instead; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it, in the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path, convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. A standing preference gets recorded as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading and its raised lines included, the pick card when one exists, the dealt challengers as alternates carrying their QUALITY BAR cards plus each challenger's verdict and kept line, re-roll with its safer and bolder registers, steer, plus canon enabled, and `followup: true` when the execution-contract round will follow (it does whenever image generation exists and no standing build-path preference is recorded); a degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy, thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (run the script with `--schema` for the exact shape); the page renders identity from these fields, routes declined challengers to a demoted row on its own, and a challenger's catalog image rides as labeled inspiration, never as the promise of the build. Author `canonCard` too: the category standard as one honest card with the same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .agents/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. Only a session where no browser can open at all, headless, CI, an eval worker, a remote shell with no display, puts the same decision through the structured question tool instead; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry.
When image generation exists, every card also declares a `sketch` path under `.impeccable/sketches/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the sketches; the page shimmer-waits per slot and the user may answer before they land. Render every sketch through one shared frame so the comparison stays about direction, never rendering luck: the requested surface's first viewport as a flat, matte design sketch in that card's own palette and type character, deliberately unfinished, no photorealism, no gloss, identical framing across cards; a candidate whose sketch looks more finished than the others has broken the comparison, not won it. The frame's aspect is the surface's own: a native app or mobile-first surface sketches portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen sketched landscape is a broken frame, not a neutral default. The only legible text in a sketch is the product's real name and one real headline; every other text region is greeked, indistinct lines standing where copy will go, because a sketch that renders invented specs, prices, or dates puts claims in front of the user that PRODUCT.md never made. Produce in the order the user reads: the assigned card, then the hand, then canon, each file written the moment it is done. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-sketch packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. A sketch answers which world, never which composition: the comp round still renders its full set, and the chosen card's sketch seeds at most one probe. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version.
When image generation exists, every card also declares a `sketch` path under `.impeccable/mocks/decision/` (the field keeps its wire name for compatibility; what it carries is the card's comp), the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity, produced under the comp discipline in [visualize.md](visualize.md): the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished sketch pays sketch quality for comp cost; fairness between cards comes from equal fidelity in each card's own grammar, one surface, one aspect, never from shared unfinishedness. The frame's aspect is the surface's own: a native app or mobile-first surface comps portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen comped landscape is a broken frame, not a neutral default. Produce in the order the user reads, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: on a comp-led build it enters the comp round as compositional option one, and on a code-led build it returns at the finish review as the critique reference, what the image dared that the build did not. The unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
The moment the direction lands, one more round on the same open table decides the execution contract. The direction payload declares `followup: true`, so the table stays open after the pick; deliver the build-path payload through `--update` immediately. Two text-only cards. **Comp-led**: a first-viewport comp is generated and it is law, the finish review audits the build against it; boldest composition on the table, fix rounds expected, motion at risk; choosing it makes the comp non-optional, no silent skipping. **Code-led**: no comp of this page and no apology for it; the QUALITY BAR boards still calibrate finish, and the ambition moves into the written contract, the FIRST VIEWPORT block plus a named signature interaction and motion grammar, which the finish reviewer audits in behavior; code-led is not a discount on commitment, the direction still lands fully committed in code. Lead with the chosen world's fit: a costume-heavy catalog world leads comp-led, a quiet or conventional direction leads code-led; the lead is a default, never a decision, and the user flips it freely. A standing preference, voiced once, is recorded as a brand commitment in PRODUCT.md and skips this round on later surfaces. Without image generation there is no fork and no round: code-led is the only path, stated in one line rather than asked. Only a detached table (`--start`) stays open for `--update`: a blocking serve or the structured-tool channel runs the build-path round as its own second question instead, and `followup: true` belongs only on a detached round.
Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. Open the QUALITY BAR board and hero for the world you build the moment the choice lands, even if you viewed another card earlier; the ANSWER line names the chosen card's images (when the harness only reads files or runs sandboxed, download them into the workspace and open the relative path; sandboxed viewers reject absolute paths outside it). They set the craft level the build must reach, a rendered reference's finish, commitment, and art direction, never the composition; your surface serves this product.
@@ -78,7 +80,7 @@ If the work establishes durable strategy for a route or artifact, read its exist
Keep the brief small: scope and visitor mode; audience, job, action/task, proof/content, and constraints; chosen direction and memorable moment; unresolved decisions. Do not copy global product truth or DESIGN.md tokens into it.
Whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports, the locked direction is visualized before it is built, never skipped: load [visualize.md](visualize.md) and follow it, three compositional options rendered and put before the user for approval. This step is proven to produce the most compositional and ambitious work.
On a comp-led build, whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports, the locked direction is visualized before it is built, never skipped: load [visualize.md](visualize.md) and follow it, three compositional options put before the user for approval, the chosen card's decision comp plus two variations. This step is proven to produce the most compositional and ambitious work. On a code-led build the comp round is skipped by contract, never by drift: the ambition it would have carried lives in the direction contract's FIRST VIEWPORT block and named signature interaction, and the finish reviewer audits those promises in behavior.
For `shape`, return the selected direction to [shape.md](shape.md) and stop before persistence or implementation.
@@ -101,8 +103,8 @@ Preserve semantics, accessibility, performance, responsiveness, project conventi
## 7. Inspect and finish
Inspect desktop and mobile in one batched screenshot round, critique the render against the user's request and the direction contract, fix material gaps, and confirm with one final round; two rounds is the ceiling, and fixes batch between them rather than earning per-tweak screenshots. When an approved comp exists, the critique is a side-by-side: view the comp region and the build region together, the hero and each section as its own crop at legible scale, never one full-page thumbnail, which hides exactly the failures that matter, crude controls, wrong lettering character, flattened material, behind a superficially similar section order. On a Persuade surface, verify the mode did its job: a first-time visitor should know what this is, why it matters, and what to do within seconds, in the form's own vocabulary.
Inspect the surface's target sizes in one batched screenshot round: desktop and mobile on the web; on a native platform (`ios` / `android` / `adaptive`), the shipped device classes per OS, captured from the simulator or emulator the way the platform reference's Verifying the build section describes. Critique the render against the user's request and the direction contract, fix material gaps, and confirm with one final round; two rounds is the ceiling, and fixes batch between them rather than earning per-tweak screenshots. When an approved comp exists, the critique is a side-by-side: view the comp region and the build region together, the hero and each section as its own crop at legible scale, never one full-page thumbnail, which hides exactly the failures that matter, crude controls, wrong lettering character, flattened material, behind a superficially similar section order. On a Persuade surface, verify the mode did its job: a first-time visitor should know what this is, why it matters, and what to do within seconds, in the form's own vocabulary.
After the second inspection round the build thread's polishing is over: no further defect hunts, micro-edit scripts, or rebuilds here; whatever remains ships through the handoffs, where a fresh context does the finding better and cheaper. Where this harness runs no design hook, run `node .agents/skills/impeccable/scripts/detect.mjs --json` on the changed targets once here, fix what is mechanical, and pass the remaining findings to the reviewer; a hookless build that skips this ships every tell the hook exists to catch. Capture desktop and mobile screenshots to files, then spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccable_finish_reviewer` in codex; `/impeccable-finish-reviewer` in Cursor; on GitHub Copilot say "Use the impeccable-finish-reviewer agent"), with the original request, confirmed answers, the artifact path, the screenshot paths, its direction contract, existing hook findings, the QUALITY BAR card and approved comp paths, and the craft-floor reference path. The reviewer has no browser; screenshots you fail to pass are checks it cannot run. Never read the shipped agents' definition files before spawning; the harness loads them at spawn, and you owe only the input packet. Wait on any agent with one long timeout rather than a loop of short polls, and spend the wait on the next independent step. Verify its return carries the five contract sections; on an empty or thrashed return, respawn once with the same inputs before doing anything else. This review never runs inside the build thread and never inherits it: spawn the reviewer fresh, with no forked conversation history (`fork_turns: 0` in codex); a reviewer that inherits your transcript inherits your framing, your optimism, and your abstractions, and everything it needs travels in the inputs above. Only a harness whose tool surface has no subagent capability at all substitutes a fresh in-thread pass after stepping fully out of the build context, run from [degraded/finish-reviewer.md](degraded/finish-reviewer.md), and a substituted or failed-and-replaced review is disclosed in one line at finish, never silently. When the reviewer's first material fix is a rebuild directive, fidelity failed wholesale rather than in patches, so skip the fix batch and execute the rebuild immediately: re-derive the named regions, produce the named assets, and send the result back for a verdict, telling the user what is happening rather than asking permission to fix a failure. The user is consulted only when a second rebuild directive arrives, both verdicts on the table, or when rebuilding would discard content the user approved. Otherwise apply the material fixes in one batch, rebuild once, and recapture the same viewports. A recapture measures positions, loading, and overflow; it cannot measure whether a fix reached the quality the finding named, so send the recaptured screenshots back to the same reviewer for a verdict scoring every material fix resolved, partial, or unresolved (through the harness's agent continuation; without one, run the scoring fresh from [degraded/finish-reviewer.md](degraded/finish-reviewer.md)'s Verdict Pass). Fixes scored partial or unresolved get another batch, recapture, and verdict. Two rounds is the budget an unattended run ends at; an attended session's ceiling belongs to the user, so when the second verdict still lists open items, put the table in front of them and let them choose between shipping as it stands and funding another round. Whoever is deciding, stop the moment a round resolves nothing, and the reviewer's findings are the only list you work from, never your own re-opened hunt. Report the final verdict table to the user as it stands, open items included, under the reviewer's own disposition word: a table with open material findings is never announced as a pass, and never under a softer label than the reviewer wrote. Do not run a second detector.
After the second inspection round the build thread's polishing is over: no further defect hunts, micro-edit scripts, or rebuilds here; whatever remains ships through the handoffs, where a fresh context does the finding better and cheaper. On the web, where this harness runs no design hook, run `node .agents/skills/impeccable/scripts/detect.mjs --json` on the changed targets once here, fix what is mechanical, and pass the remaining findings to the reviewer; a hookless web build that skips this ships every tell the hook exists to catch. A native platform skips the detector entirely: it reads HTML and CSS and has no verdict on native code, so the reviewer's floor check is the only slop gate and the input packet says so. Capture the screenshots into `.impeccable/review/`, one file per captured viewport (on the web, `desktop.png` and `mobile.png`; on native, one per device class, such as `phone.png` and `tablet.png`, suffixed per OS on adaptive), creating that directory when the harness does not; the paths you pass the reviewer are its spec, and that directory is where it looks when a passed path is missing. Then spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccable_finish_reviewer` in codex; `/impeccable-finish-reviewer` in Cursor; on GitHub Copilot say "Use the impeccable-finish-reviewer agent"), with the original request, confirmed answers, the artifact path, the screenshot paths, its direction contract, existing hook findings, the QUALITY BAR card and approved comp paths (on a code-led build there is no approved comp; the chosen decision comp rides in that slot as the critique reference, named as such), the craft-floor reference path, and on a native platform the platform reference path(s), [ios.md](ios.md) / [android.md](android.md), both on adaptive, plus one line saying no detector ran, so the reviewer judges in the platform's conventions rather than the web's. The reviewer has no browser; screenshots you fail to pass are checks it cannot run. Never read the shipped agents' definition files before spawning; the harness loads them at spawn, and you owe only the input packet. Wait on any agent with one long timeout rather than a loop of short polls, and spend the wait on the next independent step. Verify its return carries the five contract sections; on an empty or thrashed return, respawn once with the same inputs before doing anything else. This review never runs inside the build thread and never inherits it: spawn the reviewer fresh, with no forked conversation history (`fork_turns: 0` in codex); a reviewer that inherits your transcript inherits your framing, your optimism, and your abstractions, and everything it needs travels in the inputs above. Only a harness whose tool surface has no subagent capability at all substitutes a fresh in-thread pass after stepping fully out of the build context, run from [degraded/finish-reviewer.md](degraded/finish-reviewer.md), and a substituted or failed-and-replaced review is disclosed in one line at finish, never silently. When the reviewer's first material fix is a rebuild directive, fidelity failed wholesale rather than in patches, so skip the fix batch and execute the rebuild immediately: re-derive the named regions, produce the named assets, and send the result back for a verdict, telling the user what is happening rather than asking permission to fix a failure. The user is consulted only when a second rebuild directive arrives, both verdicts on the table, or when rebuilding would discard content the user approved. Otherwise apply the material fixes in one batch, rebuild once, and recapture the same viewports over the same files. A recapture measures positions, loading, and overflow; it cannot measure whether a fix reached the quality the finding named, so send the recaptured screenshots back to the same reviewer for a verdict scoring every material fix resolved, partial, or unresolved (through the harness's agent continuation; without one, run the scoring fresh from [degraded/finish-reviewer.md](degraded/finish-reviewer.md)'s Verdict Pass). Fixes scored partial or unresolved get another batch, recapture, and verdict. Two rounds is the budget an unattended run ends at; an attended session's ceiling belongs to the user, so when the second verdict still lists open items, put the table in front of them and let them choose between shipping as it stands and funding another round. Whoever is deciding, stop the moment a round resolves nothing, and the reviewer's findings are the only list you work from, never your own re-opened hunt. Report the final verdict table to the user as it stands, open items included, under the reviewer's own disposition word: a table with open material findings is never announced as a pass, and never under a softer label than the reviewer wrote. Do not run a second detector.
Then spawn the shipped documenter, `impeccable-documenter` (`impeccable_documenter` in codex), with the project root, the artifact path, the direction contract, PRODUCT.md, the [document.md](document.md) reference path, and the boundary to write at; it records DESIGN.md and the sidecar from the built world, ground truth over intention; without subagents the pass runs from [degraded/documenter.md](degraded/documenter.md). A clean detector pass is not finished; finished is the contract kept, the comp honored, the review closed, and the system recorded.
@@ -19,7 +19,7 @@ Fix the cause at the narrowest correct level. Ask when a binding system principl
## 2. Gather the evidence
Use the feature yourself at representative desktop and mobile sizes. Determine:
Use the feature yourself at the surface's representative sizes: desktop and mobile on the web; on a native platform (`ios` / `android` / `adaptive`), the shipped device classes on the simulator, emulator, or hardware, captured per the platform reference's Verifying the build section. Determine:
- whether the path is functionally complete;
- the intended quality bar and time available;
@@ -86,10 +86,10 @@ Do not perfect one corner while leaving the rest below the same quality bar.
Walk the complete path again with mouse, keyboard, and touch where applicable. Check:
- mobile, intermediate, and wide layouts;
- mobile, intermediate, and wide layouts on the web; phone and tablet size classes in both supported orientations on native;
- loading, empty, error, success, disabled, long-content, and missing-content states;
- zoom, contrast, focus, semantics, and screen-reader names;
- console errors, layout shift, interaction latency, image loading, and supported browsers;
- console errors, layout shift, interaction latency, and image loading everywhere; supported browsers on the web; supported OS versions, runtime warnings, and dropped frames on native;
- agreement with DESIGN.md, neighboring features, and the user's scope.
Follow the quality guidance supplied by `context.mjs` and hooks, then run any other relevant QA commands. Context requests a manual scan only when no automatic detector is active; never add another detector pass. Fix real defects and document only narrow intentional exceptions. A clean scan does not replace visual judgment.
@@ -1,12 +1,12 @@
# Visualize: Direction Comps & Asset Production
Load this from [new-work.md](new-work.md) whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it.
Load this from [new-work.md](new-work.md) on a comp-led build, when image generation is available (a harness-native tool or the API fallback context.mjs reports). A code-led execution contract skips this file by design, not by drift: its ambition lives in the written direction contract and is audited in behavior, so do not load it for a code-led round. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it.
The purpose of a probe is to test composition, narrative, hierarchy, density, focal moment, signature use, and image requirements. It is not a second identity workshop. Keep DESIGN.md's palette, typography direction, material language, component character, imagery stance, and motion grammar fixed.
## Generate three compositional options
Render three distinct high-fidelity north-star comps of the requested surface, with whatever generation capability exists, saved under `.impeccable/mocks/` so they survive the session. Comp at the surface's own viewport: portrait at device size for a native app or mobile-first surface, desktop landscape otherwise; a phone screen comped landscape misstates the composition before anything gets built against it. Comps are the build thread's own work, never delegated: the thread that writes the comp prompts holds the direction's full context, and it has already seen every comp when the build starts. Open every image you produce or reference by its workspace-relative path, never an absolute one: sandboxed viewers reject absolute paths, and everything under the project root has a relative path. Base them on the real content and the surface concepts already developed with the user. Three is the number: one comp invites rubber-stamping, and the spread between three is what surfaces the composition worth building. A decision-page sketch is not a probe: it chose the direction at deliberately unfinished fidelity, so the three comps render regardless, and the chosen card's sketch seeds at most one of them.
Render three distinct high-fidelity north-star comps of the requested surface, with whatever generation capability exists, saved under `.impeccable/mocks/` so they survive the session. Comp at the surface's own viewport: portrait at device size for a native app or mobile-first surface, desktop landscape otherwise; a phone screen comped landscape misstates the composition before anything gets built against it. Comps are the build thread's own work, never delegated: the thread that writes the comp prompts holds the direction's full context, and it has already seen every comp when the build starts. Open every image you produce or reference by its workspace-relative path, never an absolute one: sandboxed viewers reject absolute paths, and everything under the project root has a relative path. Base them on the real content and the surface concepts already developed with the user. Three is the number: one comp invites rubber-stamping, and the spread between three is what surfaces the composition worth building. The chosen card's decision comp is the first of the three: it already renders this direction at full fidelity under this file's discipline, so this round generates two more that vary what the first held fixed, and all three go to the approval point together. Only a round that arrives with no decision comp, a degraded roll, an identity-mode page, a direction pinned without the decision round, renders all three here.
- A comp is a designed surface, not a picture of the subject. Lead the generation prompt with the surface's own structure, whatever regions this design actually has, named in order with their scale relationships; a page with no navigation states that instead of inventing one, and an unconventional surface states its unconventional skeleton. A prompt that leads with the world's atmosphere gets a vignette back: the model paints the fish market instead of the fish market's website. Self-check every render: if it could hang as a poster, or reads as a photograph or scene with some text on it, it is not a comp; regenerate with the layout scaffold stated more literally.
- When the user shortlisted multiple concepts, spread the three across them.
@@ -22,7 +22,7 @@ Show the three together: in the harness when it can display images, otherwise on
Do not begin code until the user approves a direction or explicitly delegates the choice. If they delegate, choose using the task brief, PRODUCT.md, and DESIGN.md, and state the evidence. Approval refines the task concept; it does not modify DESIGN.md.
This approval point has no substitute and no skip condition. When the structured question tool errors, fall back to the decision page; only after both fail may you treat the choice as delegated, and a delegated pick is still recorded exactly as an approval is and disclosed in your first reply, not your last. The finish reviewer treats a build with generated comps and no recorded approval as carrying a material finding.
This approval point has no substitute and no skip condition. When the structured question tool errors, fall back to the decision page; only after both fail may you treat the choice as delegated, and a delegated pick is still recorded exactly as an approval is and disclosed in your first reply, not your last. The finish reviewer treats a build whose comp round produced comps with no recorded approval as carrying a material finding; decision comps under `.impeccable/mocks/decision/` are the direction round's hand, not comp-round output, and imply no approval on their own.
After approval, record the choice where tools can find it: the approved comp's path goes in the surface brief, and the approved comp's `.json` prompt sidecar gains `"approved": true` (every comp generated through `generate-image.mjs` has one; create it if a native tool didn't). The sidecar travels with the mocks folder, so the approval survives sessions and machines that never see the brief. Then summarize the composition and the parts of the comp that must not be literalized, return to new-work.md, record the direction contract from the approved surface concept, and build.
@@ -31,6 +31,16 @@
* recomputes what rounds 0..n-1 drew, excludes all of it, and rolls a
* fresh assigned index, challengers, and compositions. One base key therefore
* reproduces the entire chain of rounds.
* - REGISTER (--register safer|bolder): the user's steering on the
* familiar-to-bold axis, applied to a re-roll round. A register changes
* only what this round instructs, never what it dealt: the same key and
* reroll count reproduce the same deal whatever the register, so the
* exclusion chain never forks. bolder presents the dealt foreign forms
* as the whole hand (first-dealt leads, dice-assigned by deal order);
* safer spends the dealt hand unseen and presents the familiar register,
* the model's conventional grounded candidates plus the canon against
* named competitors, the one sanctioned lineup of the model's own list.
* Registers are user-requested, never pre-selected by the model.
* - RATINGS: the reviewer's approval ratings weight the challenger draw
* (3-star doubles the odds, 1-star sits out); the approved pool itself
* is unchanged.
@@ -41,7 +51,9 @@
* node scripts/concept-seed.mjs --scope surface --mode operate --grain flow
* node scripts/concept-seed.mjs --scope direction --candidate-count 6
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1
* node scripts/concept-seed.mjs --chosen <challenger-id> --from <key> --scope direction
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1 --register bolder
* node scripts/concept-seed.mjs --chosen <challenger-id> --kind challenger --from <key> --scope direction
* node scripts/concept-seed.mjs --kind assigned --from <key> --scope direction
*
* --grain names how much of the product is in play: product, flow, view, or
* region. A docs site, an onboarding flow, a landing page and a data table are
@@ -62,8 +74,13 @@
* Challenger data resolves in order: a local catalog directory (the private
* service repo, evals, and tests set IMPECCABLE_CATALOG_DIR), then the roll
* API at impeccable.style, then a degraded assignment-only seed when both are
* unavailable. --chosen sends the anonymous choice ping for API-dealt rolls;
* DO_NOT_TRACK or IMPECCABLE_NO_TELEMETRY disables it.
* unavailable. The anonymous choice ping fires once per resolved attended
* round on API-dealt rolls: --kind names which card class won (assigned,
* pick, challenger, canon) so share metrics have a denominator, --chosen
* carries the catalog id when a dealt challenger won, and --register rides
* along when the round came from a steered hand. Grounded candidates' names
* never leave the machine. DO_NOT_TRACK or IMPECCABLE_NO_TELEMETRY disables
* the ping entirely.
*
* Env vars:
* IMPECCABLE_CONCEPT_SEED same as --from; for reproducible eval runs.
@@ -172,17 +189,35 @@ function telemetryDisabled() {
return Boolean(process.env.IMPECCABLE_NO_TELEMETRY || process.env.DO_NOT_TRACK);
}
// Anonymous choice ping: records only that a dealt world was selected.
// Anonymous choice ping: one per resolved attended direction round. kind
// says which card class won (assigned / pick / challenger / canon), so
// pick-share and canon-share have a denominator; chosenId rides along only
// when a dealt catalog world won, and register only when the round came from
// a steered hand. Grounded candidates' names never leave the machine: they
// are derived from the user's project, so the ping carries the kind alone.
// Fire-and-forget; never fails the caller.
export async function pingChosen({ chosenId, key, scope, mode }) {
if (telemetryDisabled() || !chosenId) return false;
const PING_KINDS = new Set(['assigned', 'pick', 'challenger', 'canon']);
export async function pingChosen({ chosenId, key, scope, mode, kind, register }) {
if (telemetryDisabled()) return false;
if (kind && !PING_KINDS.has(kind)) return false;
if (register && register !== 'safer' && register !== 'bolder') return false;
// Legacy shape: a bare challenger id with no kind stays a valid ping.
if (!chosenId && !kind) return false;
if ((kind === 'challenger' || !kind) && !chosenId) return false;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
try {
await fetch(`${API_BASE}/chosen`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chosenId, key, scope, mode }),
body: JSON.stringify({
...(chosenId ? { chosenId } : {}),
key,
scope,
mode,
...(kind ? { kind } : {}),
...(register ? { register } : {}),
}),
signal: controller.signal,
});
return true;
@@ -260,6 +295,7 @@ export function renderConceptSeed({
scope = 'surface',
key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'),
reroll = 0,
register = null,
mode = null,
grain = null,
platform = null,
@@ -273,6 +309,15 @@ export function renderConceptSeed({
if (!Number.isInteger(reroll) || reroll < 0) {
throw new Error('concept-seed: --reroll must be a non-negative integer');
}
if (register !== null && register !== 'safer' && register !== 'bolder') {
throw new Error('concept-seed: --register must be safer or bolder');
}
if (register !== null && reroll < 1) {
throw new Error('concept-seed: --register steers a re-roll round; pass --reroll <n> with it');
}
if (register !== null && scope !== 'direction') {
throw new Error('concept-seed: --register applies to direction rounds only');
}
if (mode !== null && !SEED_MODES.has(mode)) {
throw new Error('concept-seed: --mode must be persuade, operate, read, or experience');
}
@@ -326,6 +371,7 @@ export function renderConceptSeed({
scope,
key,
reroll,
register,
mode,
grain,
platform,
@@ -357,7 +403,11 @@ export function renderConceptSeed({
survive the current task plus navigation, quiet and dense content,
interaction and state, and a substantially different future surface. In an
attended run, present the assigned direction fully committed and offer
re-roll; never present a ranked lineup to choose from. Re-roll yourself only
re-roll. You may add ONE card for your top-ranked grounded candidate when
it is not the assigned direction, kicker MY PICK, with an honest risk line
naming its familiarity; one pick card, never a ranked lineup, and the pick
never takes the lead position. When the assignment IS your top candidate,
there is no pick card. Re-roll yourself only
on named factual grounds, when the assignment cannot carry the product's
truth or task; taste is never grounds.`
: `After ordering the task's grounded structural candidates by resonance,
@@ -374,7 +424,16 @@ export function renderConceptSeed({
conflicts. Weigh the fused result against the assigned direction on exactly
two axes, audience identification and product clarity. Losing to strong
grounded material is a valid outcome; beating a thin or tool-monoculture
list is the point. A fused challenger that wins both axes becomes the build.`
list is the point. A fused challenger that wins both axes becomes the build.
Close the weighing with a verdict per challenger, decided before any
borrowing is considered: wins (beats the assigned direction on both axes),
competitive (holds one axis), or declined (loses both). A declined
challenger is not spent: name the one discipline of its system the assigned
direction lacks, and raise the assigned direction to match before
presenting it. A donation transfers ambition and system discipline, never
the challenger's clothes; one world owns the page. Write each raise as its
own named line on the presented direction, and carry every verdict, kept
line, and raise into the decision page payload.`
: `A challenger wins only when its fused result beats the grounded list on
audience identification and product clarity. It may change task topology or
interaction, but never the committed visual identity.`;
@@ -399,8 +458,39 @@ Ambitious motion, spatial media, or interaction is welcome when it strengthens
the product without weakening semantics, performance, or fallback behavior.`;
if (!data) {
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: degraded; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''} --candidate-count ${candidateCount})
ASSIGNED INDEX: ${buildIndex}
// A degraded roll can still serve the safer register, which needs no
// catalog at all: the assignment machinery is suppressed entirely, the
// same as the non-degraded safer round, because emitting both "the user
// picks" and a mandatory numbered build order hands the model two
// contradicting instructions and the mandatory one tends to win. The
// bolder register is exactly the thing degradation took away, so it
// falls back to a plain grounded round, disclosed.
const degradedHeader = `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: degraded; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''}${register ? ` --register ${register}` : ''} --candidate-count ${candidateCount})`;
if (register === 'safer') {
return `${degradedHeader}
SAFER REGISTER (user-requested): the assigned index is suspended this
round; the user picks, and no candidate is mandated. Present the familiar
register: your remaining grounded candidates from the conventional end, at
most three, as full cards with an honest risk line each, plus the canon
executed against two or three named competitors. This is the one sanctioned
lineup of your own ranked candidates; it exists only by this explicit
request. When the user voices a standing preference for it, record a brand
commitment in PRODUCT.md.
${authorityInstruction}
A user- or brief-pinned decision beats the roll, always.
REGISTER (restated for truncated readers): safer, user-requested; the
assigned index is suspended this round and the user picks; seed key ${key}.
`;
}
const degradedRegister = register === 'bolder'
? `BOLDER REGISTER UNAVAILABLE: bolder deals foreign forms, and this roll ran
degraded with no catalog and no roll service, so there is nothing bold to
deal. Tell the user, then run this round as a plain grounded re-roll; the
assignment below applies.
`
: '';
return `${degradedHeader}
${degradedRegister}ASSIGNED INDEX: ${buildIndex}
${promotedInstruction}
The assignment exists to refuse the model's ranking rut, never to outrank
the user or the brief. Never expose assignment metadata in user-facing labels.
@@ -471,34 +561,76 @@ structure only, never a palette, typeface, or material. Treat them as serious
rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n`
: '';
const rerollBlock = reroll > 0
? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded
and challenger alike, is eliminated and may not return reworded. Derive
? `RE-ROLL ROUND ${reroll}${register ? ` (${register.toUpperCase()} REGISTER, user-requested)` : ''}: every candidate presented in earlier rounds, grounded
and challenger alike, is eliminated and may not return reworded.${register ? '' : ` Derive
genuinely new grounded candidates from unexplored angles before judging
these fresh challengers.\n`
these fresh challengers.`}\n`
: '';
// A register swaps the round's presentation, never its deal: the assigned
// index and challenger fetch stay identical so the chain reproduces, and
// only the instructions change.
const saferBlock = `SAFER REGISTER: the user asked for the familiar end of the spectrum, so this
round's dealt hand is spent unseen, stays excluded from future rounds, and
is not printed. The assigned index is suspended this round; the user picks. Present the familiar register: your remaining grounded
candidates from the conventional end, at most three, as full cards with an
honest risk line each, plus the canon executed against two or three named
competitors. This is the one sanctioned lineup of your own ranked
candidates; it exists only by this explicit request. When the user voices a
standing preference for it, record a brand commitment in PRODUCT.md.`;
const bolderBlock = `BOLDER REGISTER: the user asked for foreign forms at full commitment, so no
grounded direction is presented this round and the assigned index is
suspended. The hand is every dealt challenger below, each fused with the
product and presented as a full card; the FIRST dealt challenger leads, an
assignment by deal order, so the dice still choose. Verdicts and donations
apply between the challengers, weighed against the leader. The pick card
sits out; the canon stays, as always.`;
const telemetryBlock = data.source === 'api'
? `TELEMETRY: if the resolved direction uses one of these challengers, rerun
this script once with --chosen <challenger-id> --from ${key} --scope ${scope}${mode ? ` --mode ${mode}` : ''}
after resolution. The ping is anonymous (chosen id only) and is skipped
automatically when DO_NOT_TRACK or IMPECCABLE_NO_TELEMETRY is set.\n`
? `TELEMETRY: after the user's choice resolves, rerun this script once with
--kind <assigned|pick|challenger|canon> --from ${key} --scope ${scope}${mode ? ` --mode ${mode}` : ''},
adding --chosen <challenger-id> when a dealt challenger won and keeping
--register <safer|bolder> when the resolved round came from a steered hand.
One ping per resolved attended round. The ping is anonymous, the card kind
plus the catalog id when one won; your grounded candidates' names never
leave the machine, and the ping is skipped automatically when DO_NOT_TRACK
or IMPECCABLE_NO_TELEMETRY is set.\n`
: '';
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: ${data.source}; approved pool: ${data.poolRevision}; ${data.approvedCount}/${data.catalogCount} human-approved; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''} --candidate-count ${candidateCount} to reproduce this roll against this catalog revision)
${rerollBlock}ASSIGNED INDEX: ${buildIndex}
const assignedBlock = register === null
? `ASSIGNED INDEX: ${buildIndex}
${promotedInstruction}
The assignment exists to refuse the model's ranking rut, never to outrank
the user or the brief. Never expose assignment metadata in user-facing labels.
CHALLENGERS:
the user or the brief. Never expose assignment metadata in user-facing labels.`
: register === 'safer' ? saferBlock : bolderBlock;
// A bolder round has no assigned grounded direction, so the generic
// weighing instruction (which measures against the assignment) would
// contradict the register; the bolder variant weighs against the leader.
const bolderChallengerInstruction = `Fuse each challenger before judging it: the challenger supplies the form
and its system grammar, the product supplies every fact, and clarity wins
conflicts. Weigh every fused challenger against the fused LEADER, the first
dealt, on exactly two axes, audience identification and product clarity;
verdicts and donations apply between the challengers, and one that beats
the leader on both axes presents as the hand's strongest alternate.`;
const roundChallengerInstruction = register === 'bolder' ? bolderChallengerInstruction : challengerInstruction;
const challengerSection = register === 'safer'
? ''
: `CHALLENGERS:
${data.challengers.map(renderChallenger).join('\n')}
${compositionBlock}${challengerInstruction}
${compositionBlock}${roundChallengerInstruction}
When you can view images, open the QUALITY BAR board and hero for any
challenger you weigh seriously and for the world you build. They exist as a
craft bar, the finish level and commitment the build is expected to reach,
never as a mockup to copy; your surface serves this product, not that render.
${authorityInstruction}
`;
const restated = register === null
? `ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
${buildIndex} of your own grounded list; seed key ${key}.`
: `REGISTER (restated for truncated readers): ${register}, user-requested; the
assigned index is suspended this round; seed key ${key}.`;
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: ${data.source}; approved pool: ${data.poolRevision}; ${data.approvedCount}/${data.catalogCount} human-approved; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''}${register ? ` --register ${register}` : ''} --candidate-count ${candidateCount} to reproduce this roll against this catalog revision)
${rerollBlock}${assignedBlock}
${challengerSection}${authorityInstruction}
${richnessInstruction}
${telemetryBlock}A user- or brief-pinned decision beats the roll, always.
ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
${buildIndex} of your own grounded list; seed key ${key}.
${restated}
`;
}
@@ -507,19 +639,25 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
const fromIdx = args.indexOf('--from');
const scopeIdx = args.indexOf('--scope');
const rerollIdx = args.indexOf('--reroll');
const registerIdx = args.indexOf('--register');
const modeIdx = args.indexOf('--mode');
const grainIdx = args.indexOf('--grain');
const platformIdx = args.indexOf('--platform');
const candidateCountIdx = args.indexOf('--candidate-count');
const chosenIdx = args.indexOf('--chosen');
const kindIdx = args.indexOf('--kind');
try {
if (chosenIdx !== -1) {
if (chosenIdx !== -1 || kindIdx !== -1) {
// Choice ping: always exits 0, telemetry must never fail a design flow.
// --kind alone pings a non-challenger outcome (assigned/pick/canon);
// --chosen alone stays the legacy challenger-win ping.
const sent = await pingChosen({
chosenId: args[chosenIdx + 1],
chosenId: chosenIdx !== -1 ? args[chosenIdx + 1] : undefined,
key: fromIdx !== -1 ? args[fromIdx + 1] : undefined,
scope: scopeIdx !== -1 ? args[scopeIdx + 1] : undefined,
mode: modeIdx !== -1 ? args[modeIdx + 1] : undefined,
kind: kindIdx !== -1 ? args[kindIdx + 1] : undefined,
register: registerIdx !== -1 ? args[registerIdx + 1] : undefined,
});
process.stdout.write(sent ? 'choice recorded\n' : 'choice ping skipped\n');
} else {
@@ -542,6 +680,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
? args[fromIdx + 1]
: (process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex')),
reroll: rerollIdx !== -1 ? Number(args[rerollIdx + 1]) : 0,
register: registerIdx !== -1 ? args[registerIdx + 1] : null,
mode: modeIdx !== -1 ? args[modeIdx + 1] : null,
grain: grainIdx !== -1 ? args[grainIdx + 1] : null,
platform: platformIdx !== -1 ? args[platformIdx + 1] : null,
@@ -553,6 +692,13 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
process.exitCode = 1;
}
// A raced-out fetch may still hold a socket; exit explicitly so the CLI
// never lingers on a dead network path after output is written.
// never lingers on a dead network path after output is written. Destroy
// fetch's global undici dispatcher first: process.exit() with a live
// keep-alive socket trips a libuv assertion on Windows and aborts the
// process after a successful roll (nodejs/node#56645).
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
if (dispatcher && typeof dispatcher.destroy === 'function') {
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
}
process.exit(process.exitCode ?? 0);
}
@@ -22,7 +22,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { loadContext, extractPlatform } from './context.mjs';
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
import { readLatestSnapshotAcrossTargets } from './critique-storage.mjs';
/** Is there code here at all, or just context files / an empty repo? */
function hasCode(cwd) {
@@ -34,23 +34,13 @@ function hasCode(cwd) {
}
/**
* The most recent critique snapshot across all targets. Filenames are
* timestamp-prefixed (`<iso>__<slug>.md`), so a lexical sort is chronological.
* Parses the small frontmatter for score + P0/P1 counts.
* Summarize the most recent critique snapshot across all targets.
*/
function latestCritique(cwd) {
try {
const dir = getCritiqueDir(cwd);
if (!fs.existsSync(dir)) return null;
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')).sort();
if (!files.length) return null;
const newest = files[files.length - 1];
const text = fs.readFileSync(path.join(dir, newest), 'utf-8');
const front = text.split('---')[1] || '';
const get = (k) => {
const m = front.match(new RegExp(`^${k}:\\s*(.+)$`, 'm'));
return m ? m[1].trim() : null;
};
const latest = readLatestSnapshotAcrossTargets({ cwd });
if (!latest) return null;
const get = (key) => latest.meta[key] ?? null;
const num = (v) => {
const n = Number(v);
return Number.isFinite(n) ? n : null;
@@ -61,7 +51,7 @@ function latestCritique(cwd) {
p0: num(get('p0')),
p1: num(get('p1')),
timestamp: get('timestamp'),
file: path.relative(cwd, path.join(dir, newest)),
file: path.relative(cwd, latest.path),
};
} catch {
return null;
@@ -105,28 +105,37 @@ function parseFrontmatter(text) {
}
/**
* Return all snapshot files for `slug`, sorted oldest newest.
* Return snapshot files matching `suffix`, sorted oldest newest.
*/
function listSnapshotsForSlug(slug, cwd) {
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/;
function listSnapshots(suffix, cwd) {
const dir = getCritiqueDir(cwd);
if (!fs.existsSync(dir)) return [];
const suffix = `__${slug}.md`;
return fs.readdirSync(dir)
.filter((f) => f.endsWith(suffix))
.filter((f) => SNAPSHOT_FILENAME.test(f) && f.endsWith(suffix))
.sort()
.map((f) => path.join(dir, f));
}
function readLatestSnapshotMatching(suffix, cwd) {
const filePath = listSnapshots(suffix, cwd).at(-1);
if (!filePath) return null;
const body = fs.readFileSync(filePath, 'utf-8');
return { path: filePath, body, meta: parseFrontmatter(body) };
}
/**
* Return the most recent snapshot for `slug`, or null. Polish reads this
* to find its fix backlog when the slug matches.
*/
export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
const all = listSnapshotsForSlug(slug, cwd);
if (!all.length) return null;
const latest = all[all.length - 1];
const body = fs.readFileSync(latest, 'utf-8');
return { path: latest, body, meta: parseFrontmatter(body) };
return readLatestSnapshotMatching(`__${slug}.md`, cwd);
}
/** Return the most recent snapshot across all targets, or null. */
export function readLatestSnapshotAcrossTargets({ cwd = process.cwd() } = {}) {
return readLatestSnapshotMatching('.md', cwd);
}
/**
@@ -134,7 +143,7 @@ export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
* Critique appends a one-line trend to its output using this.
*/
export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
const all = listSnapshotsForSlug(slug, cwd);
const all = listSnapshots(`__${slug}.md`, cwd);
const slice = all.slice(-limit);
return slice.map((file) => parseFrontmatter(fs.readFileSync(file, 'utf-8')));
}
@@ -683,6 +683,10 @@ if (IS_BROWSER) {
const reasons = collectVisualContrastReasons(el, style);
if (reasons.length === 0) continue;
// Image-only mode filters here, inside the cap: gradient/opacity/filter
// candidates earlier in DOM order must not consume the budget and
// starve the url()-backed texts this mode exists to sample.
if (options.imageOnly && !reasons.includes('image background')) continue;
const textColor = parseRgb(style.color);
const fontSize = parseFloat(style.fontSize) || 16;
@@ -1175,6 +1179,7 @@ if (IS_BROWSER) {
}
async function analyzeVisualContrast(options = {}) {
// imageOnly is enforced inside the collector, before the candidate cap.
const candidates = collectVisualContrastCandidates(options);
const results = [];
const shouldScrollOffscreen = options.scrollOffscreen === true;
@@ -1260,9 +1265,16 @@ if (IS_BROWSER) {
function addBrowserFindings(groupMap, el, findings) {
if (!findings || findings.length === 0) return;
// Element-scoped waivers: a data-impeccable-ignore ancestor suppresses
// matching findings for its whole subtree. Applied at this choke point so
// every per-element attribution (checks, layout, occlusion, rhythm)
// honors it; page-level findings attributed to <body> pass through
// untouched, since body has no ignoring ancestor.
const kept = findings.filter(f => !scopedIgnoreActive(el, f.type));
if (kept.length === 0) return;
const existing = groupMap.get(el);
if (existing) existing.push(...findings);
else groupMap.set(el, [...findings]);
if (existing) existing.push(...kept);
else groupMap.set(el, [...kept]);
}
function browserFindingsFromMap(groupMap) {
@@ -1620,9 +1632,27 @@ if (IS_BROWSER) {
for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) {
node.remove();
}
const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML);
if (htmlPatternFindings.length > 0) {
const mapped = htmlPatternFindings.map(f => {
// Regex findings that name a live selector resolve against the real DOM:
// pseudo-element/class segments are stripped (the host element is the
// anchor), a selector that matches nothing on this page drops the finding
// (the CSS ships here, but the pattern never renders — the live DOM is
// ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level.
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
if (!f.selector) return true;
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false;
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
});
if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => {
const item = { type: f.id, detail: f.snippet };
if (f.severity) {
item.severity = f.severity;
@@ -1652,8 +1682,27 @@ if (IS_BROWSER) {
};
}
// Visual contrast has three modes. Explicit true runs the full sampled
// pass; explicit false disables it entirely (the deterministic-only mode
// the test suites use). Unset — the default overlay run — samples ONLY
// image-backed text: the one class the analytic walk deliberately skips,
// because a url() layer's pixels are unknowable without looking. In-page
// sampling draws the source image alone to a canvas (glyph ink never
// pollutes it), and a cross-origin image without CORS reports unresolved
// instead of guessing.
function visualContrastMode(options = {}) {
const explicit = typeof options.visualContrast === 'boolean'
? options.visualContrast
: typeof window.__IMPECCABLE_CONFIG__?.visualContrast === 'boolean'
? window.__IMPECCABLE_CONFIG__.visualContrast
: null;
if (explicit === true) return 'full';
if (explicit === false) return false;
return 'image-only';
}
function shouldRunVisualContrast(options = {}) {
return options.visualContrast === true || window.__IMPECCABLE_CONFIG__?.visualContrast === true;
return visualContrastMode(options) !== false;
}
function visualContrastOptions(options = {}) {
@@ -1830,6 +1879,7 @@ if (IS_BROWSER) {
return [];
}
const resolvedOptions = visualContrastOptions(options);
if (visualContrastMode(options) === 'image-only') resolvedOptions.imageOnly = true;
const analyses = await analyzeVisualContrast(resolvedOptions);
if (runtime.generation && runtime.generation !== scanGeneration) return analyses;
lastVisualContrastAnalyses = analyses;
@@ -861,6 +861,34 @@ function checkBorders(tag, widths, colors, radius, opts = {}) {
return findings;
}
// ─── Scoped ignores: data-impeccable-ignore ─────────────────────────────────
//
// An element-scoped waiver that travels with the markup: any element carrying
// `data-impeccable-ignore="rule-a rule-b"` (or `*`, or an empty value, for
// every rule) suppresses matching findings from itself and its entire subtree,
// in every engine that walks elements — the browser overlay, the extension,
// and the static scan. This is the DOM twin of the line-based
// `impeccable-disable` comment directives, which the browser cannot apply (a
// live DOM has no line numbers), and the generalization of the one-off
// `data-impeccable-allow-kickers` opt-out.
//
// The intended use is curated exhibits: a page that documents anti-patterns by
// example, or renders a deliberate "before" specimen, marks the container once
// and every engine skips it while still scanning the page around it.
function scopedIgnoreActive(el, ruleId) {
const rule = String(ruleId || '').toLowerCase();
let cur = el;
while (cur && cur.nodeType === 1) {
const attr = typeof cur.getAttribute === 'function' ? cur.getAttribute('data-impeccable-ignore') : null;
if (attr != null) {
const rules = String(attr).trim().toLowerCase().split(/[\s,]+/).filter(Boolean);
if (rules.length === 0 || rules.includes('*') || rules.includes(rule)) return true;
}
cur = cur.parentElement;
}
return false;
}
// Returns true if the given text is composed entirely of emoji characters
// (plus whitespace / variation selectors). Emojis render as multicolor glyphs
// regardless of CSS `color`, so contrast checks against the element's text
@@ -1428,6 +1456,26 @@ function cssTextHasDarkRootBg(content, customProps) {
return false;
}
// Best-effort extraction of the CSS selector whose declaration block contains
// the given index in raw CSS text. Lets CSS-text findings carry a live-DOM
// anchor, so the browser pass can resolve scoped ignores against the actual
// element and drop patterns that render nowhere on the page. Returns null for
// @-rule preludes, keyframe steps, nested blocks, and anything that does not
// read as a selector; those findings stay page-level.
function enclosingCssSelector(cssText, index) {
if (!cssText || !Number.isFinite(index)) return null;
const open = cssText.lastIndexOf('{', index);
if (open === -1) return null;
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
const raw = cssText.slice(prevClose + 1, open).trim().replace(/\s+/g, ' ');
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from`
// and `to` would read as (never-matching) type selectors and get a valid
// finding wrongly dropped by the zero-match rule downstream.
if (/^(?:from|to)(?:\s*,\s*(?:from|to))*$/i.test(raw)) return null;
return raw;
}
function scanCssTextForGlow(content) {
const customProps = collectCssCustomProps(content);
const hasDarkBg = cssTextHasDarkRootBg(content, customProps);
@@ -1739,6 +1787,7 @@ function scanCssTextForPseudoStripe(rawContent) {
id: 'side-tab',
snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`,
index: selectorStart,
selector,
});
}
return findings;
@@ -1801,6 +1850,7 @@ function scanCssTextForInsetStripe(content) {
findings.push({
id: 'side-tab',
snippet: `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`,
selector,
});
break;
}
@@ -1858,7 +1908,7 @@ function collectMarqueeKeyframes(content) {
function scanCssTextForMarquee(content, markup = content) {
const findings = [];
if (/<marquee\b/i.test(markup)) {
findings.push({ id: 'marquee', snippet: '<marquee> element' });
findings.push({ id: 'marquee', snippet: '<marquee> element', selector: 'marquee' });
}
const marqueeKeyframes = collectMarqueeKeyframes(content);
if (marqueeKeyframes.size === 0) return findings;
@@ -1873,7 +1923,7 @@ function scanCssTextForMarquee(content, markup = content) {
const key = `${selector} ${name}`;
if (seen.has(key)) continue;
seen.add(key);
findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"` });
findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"`, selector });
}
}
return findings;
@@ -2244,8 +2294,10 @@ function checkHtmlPatterns(html, corpora) {
const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
if (purpleHexRe.test(styleText)) {
const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
if (purpleTextRe.test(styleText)) {
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
purpleTextRe.lastIndex = 0;
const purpleMatch = purpleTextRe.exec(styleText);
if (purpleMatch) {
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected', selector: enclosingCssSelector(styleText, purpleMatch.index + 1) || undefined });
}
}
@@ -2256,7 +2308,7 @@ function checkHtmlPatterns(html, corpora) {
const start = Math.max(0, gm.index - 200);
const context = styleText.substring(start, gm.index + gm[0].length + 200);
if (/gradient/i.test(context)) {
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient', selector: enclosingCssSelector(styleText, gm.index) || undefined });
break;
}
}
@@ -2322,7 +2374,7 @@ function checkHtmlPatterns(html, corpora) {
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()}` });
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}`, selector: enclosingCssSelector(styleText, bounceMatch.index) || undefined });
}
// Overshoot cubic-bezier
@@ -2331,7 +2383,7 @@ function checkHtmlPatterns(html, corpora) {
while ((bm = bezierRe.exec(styleText)) !== null) {
const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})`, selector: enclosingCssSelector(styleText, bm.index) || undefined });
break;
}
}
@@ -2364,18 +2416,21 @@ function checkHtmlPatterns(html, corpora) {
const glowHits = scanCssTextForGlow(styleText);
if (glowHits.length > 0) {
findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet });
findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet, selector: enclosingCssSelector(styleText, glowHits[0].index) || undefined });
}
// Radial-gradient background halo (gradient-drawn sibling of dark-glow)
const haloHits = scanCssTextForRadialHalo(styleText);
if (haloHits.length > 0) {
findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet });
findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet, selector: enclosingCssSelector(styleText, haloHits[0].index) || undefined });
}
// --- Generated-UI tells: repeating-gradient stripes ---
if (/repeating-(?:linear|radial|conic)-gradient\s*\(/i.test(styleText)) {
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });
{
const stripesMatch = /repeating-(?:linear|radial|conic)-gradient\s*\(/i.exec(styleText);
if (stripesMatch) {
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes', selector: enclosingCssSelector(styleText, stripesMatch.index) || undefined });
}
}
// --- Generated-UI tells: two-axis grid-line background ---
@@ -2393,7 +2448,7 @@ function checkHtmlPatterns(html, corpora) {
// whole gradient layers.
const gridHits = scanCssTextForGridBackground(styleText);
if (gridHits.length > 0) {
findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet });
findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet, selector: enclosingCssSelector(styleText, gridHits[0].index) || undefined });
}
// --- Generated-copy tells: "X theater" framing copy ---
@@ -2413,8 +2468,11 @@ function checkHtmlPatterns(html, corpora) {
// hover:rotate / hover:translate utility on an <img>. Each distinct
// mechanism is its own finding.
const imgHoverCss = /\bimg\b[^,{}]*:hover\b[^{}]*\{[^}]*\btransform\s*:\s*(?:scale|rotate|translate|matrix|skew)/i;
if (imgHoverCss.test(styleText)) {
findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule' });
{
const imgHoverMatch = imgHoverCss.exec(styleText);
if (imgHoverMatch) {
findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule', selector: enclosingCssSelector(styleText, imgHoverMatch.index + imgHoverMatch[0].indexOf('{') + 1) || undefined });
}
}
const imgTagRe = /<img\b[^>]*\bclass\s*=\s*"([^"]*)"/gi;
let im;
@@ -2461,6 +2519,33 @@ function readOwnBackgroundColor(el, computedStyle) {
return bg;
}
// One element's background-color as the cascade walk sees it: computed style
// first (with the modern-color fallback), then, in static mode only,
// custom-prop resolution and the inline-shorthand peek. Shared by
// resolveBackground and resolveGradientStops so both walks read the same
// surfaces.
function readCascadeBackgroundColor(current, style, customPropMap) {
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
// The static engine can return literal "var(--X)" / "oklch(...)" strings.
// Resolve through customPropMap so Tailwind v4 color tokens become RGB.
if (customPropMap) {
bg = parseColorResolved(style.backgroundColor, customPropMap);
}
if (!bg || bg.a < 0.1) {
// Inline-style fallback for colors the static cascade did not surface
// on backgroundColor.
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
}
}
}
return bg;
}
function resolveBackground(el, win, customPropMap) {
let current = el;
// Translucent layers (0.1 < a < 1) found on the way down to an opaque
@@ -2489,24 +2574,7 @@ function resolveBackground(el, win, customPropMap) {
// body backgrounds.
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
// (e.g. any color-mix() result), which plain parseRgb misses.
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
// jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve
// through customPropMap so Tailwind v4 color tokens become RGB.
if (customPropMap) {
bg = parseColorResolved(style.backgroundColor, customPropMap);
}
if (!bg || bg.a < 0.1) {
// Inline-style fallback. jsdom doesn't decompose background
// shorthand, so colors set via inline style are otherwise invisible.
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
}
}
}
const bg = readCascadeBackgroundColor(current, style, customPropMap);
if (bg && bg.a > 0.1) {
if (bg.a >= 0.99) return flatten(bg);
@@ -2524,40 +2592,95 @@ function resolveBackground(el, win, customPropMap) {
// • on other elements: bail to null and let the caller fall back
// to gradient stops (gradient buttons / hero sections are real
// bgs worth checking against).
if (hasGradientOrUrl) {
if (current.tagName === 'BODY' || current.tagName === 'HTML') {
return flatten({ r: 255, g: 255, b: 255, a: 1 });
}
return null;
}
// A gradient or image with no solid color under it, at any level
// including body/html, means the visible ground is that layer itself.
// Return null so the caller measures against the actual gradient stops,
// or skips when nothing is parseable — skipping beats a wrong ratio.
//
// Body/html used to assume white here, a guard written for jsdom, which
// never decomposed the `background:` shorthand and so could not see the
// solid paper color a texture gradient usually sits on. It turned every
// light-on-dark page into a wall of low-contrast false positives (a dark
// oklch body gradient produced ~120 "on #ffffff" findings on one site).
// Both engines can see shorthand solids now — the browser natively, the
// static cascade via expandStaticDeclaration + var() resolution — so a
// missing solid is real, and the old failure case cannot recur: opaque
// stops fully cover any hidden solid (they ARE the ground), alpha stops
// composite over the resolved base or the white canvas default, and
// unresolvable stops drop rather than guess.
if (hasGradientOrUrl) return null;
current = current.parentElement;
}
return flatten({ r: 255, g: 255, b: 255, a: 1 });
}
// parseGradientColors (shared) reads only the legacy serializations: rgb()
// and hex stops. Browsers keep modern-space stops in computed backgroundImage
// exactly as authored — `linear-gradient(oklch(7% 0.006 95), …)` stays oklch —
// which is what every token-driven page produces. Route those through
// parseAnyColor so a gradient ground is measurable rather than invisible.
function parseGradientColorsModern(bgImage) {
if (!bgImage || !/gradient/i.test(bgImage)) return [];
const colors = parseGradientColors(bgImage);
for (const m of bgImage.matchAll(/(?:oklch|oklab|hsla?|hwb)\(\s*[^()]*\)/gi)) {
const c = parseAnyColor(m[0]);
if (c) colors.push(c);
}
return colors;
}
// Walk parents looking for a gradient background and return its color stops.
// Used as a fallback when resolveBackground() returns null because the
// effective background is a gradient (no single solid color to compare against).
// Translucent solid layers found between the element and the gradient (frosted
// panels, glass washes) are composited over every stop, the same way
// resolveBackground flattens them over a solid base — raw stops alone would
// false-flag dark text on a light frosted wash over a dark gradient, and miss
// the inverse.
function resolveGradientStops(el, win, customPropMap) {
let current = el;
const overlays = [];
while (current && current.nodeType === 1) {
const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
const bgImage = style.backgroundImage || '';
// A url() layer anywhere in the stack — alone, or alongside a gradient in
// the same declaration (a translucent wash over a texture photo) — paints
// pixels the analytic walk cannot know. Measuring the gradient stops over
// the wrong base flagged dark ink sitting on a bright gold-leaf image at
// 2.6:1; skipping beats a wrong ratio, and the screenshot subsystem owns
// image-backed text.
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
let stops = null;
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
const parsed = parseGradientColors(bgImage);
const parsed = parseGradientColorsModern(bgImage);
if (parsed.length > 0) stops = parsed;
}
if (!stops && !DETECTOR_IS_BROWSER) {
// jsdom doesn't decompose `background:` shorthand — peek at the raw inline style
// Static mode: peek at the raw inline style for gradients the cascade did not surface
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
if (bgMatch && /gradient/i.test(bgMatch[1])) {
const parsed = parseGradientColors(bgMatch[1]);
const parsed = parseGradientColorsModern(bgMatch[1]);
if (parsed.length > 0) stops = parsed;
}
}
if (stops) return compositeGradientStops(stops, current, win, customPropMap);
if (stops) {
const composited = compositeGradientStops(stops, current, win, customPropMap);
if (!composited || overlays.length === 0) return composited;
return composited.map(stop => {
let acc = stop;
for (let i = overlays.length - 1; i >= 0; i--) acc = compositeColorOver(overlays[i], acc);
return acc;
});
}
const bg = readCascadeBackgroundColor(current, style, customPropMap);
if (bg && bg.a > 0.1) {
// An opaque surface above the gradient means the gradient never shows
// through here; resolveBackground would have returned it, so reaching
// this is defensive — bail rather than measure the wrong layer.
if (bg.a >= 0.99) return null;
overlays.push(bg);
}
current = current.parentElement;
}
return null;
@@ -2777,6 +2900,10 @@ function checkElementColorsDOM(el) {
const rect = el.getBoundingClientRect();
if (rect.width < 10 || rect.height < 10) return [];
const style = getComputedStyle(el);
// Invisible at rest: hidden scene variants (opacity-0 carousels, swap
// decks) are not user-visible, and measuring their inherited colors against
// whatever surface happens to sit behind the stack is noise, not audit.
if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
let effectiveBg = resolveBackground(el);
@@ -3589,11 +3716,13 @@ function checkElementGlowDOM(el) {
// If resolveBackground returns null (gradient), try to infer from the gradient colors
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
if (!parentBg) {
// Gradient background — sample its colors to determine if it's dark
// Gradient background — sample its colors to determine if it's dark.
// Modern-syntax parsing matters here: body-level gradients now reach this
// fallback in browser mode, and their stops usually serialize as oklch.
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const bgImage = getComputedStyle(cur).backgroundImage || '';
const gradColors = parseGradientColors(bgImage);
const gradColors = parseGradientColorsModern(bgImage);
if (gradColors.length > 0) {
// Average the gradient colors
const avg = { r: 0, g: 0, b: 0 };
@@ -4435,6 +4564,14 @@ function checkElementBorders(tag, style, overrides, resolvedRadius, el = null) {
}
function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) {
// Invisible at rest, static twin of the browser walk's skip: opacity does
// not inherit, so walk ancestors multiplying declared opacity down.
if (style.visibility === 'hidden') return [];
let effOpacity = 1;
for (let cur = el; cur && cur.nodeType === 1 && effOpacity > 0.02; cur = cur.parentElement) {
effOpacity *= parseFloat(window.getComputedStyle(cur).opacity || '1');
}
if (effOpacity <= 0.02) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
@@ -5593,6 +5730,11 @@ function isRenderedForBrowserRule(el) {
function checkElementTextOverflowDOM(el) {
const tag = el.tagName.toLowerCase();
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
// scrollWidth/clientWidth are CSS box-model metrics; on SVG content Chrome
// returns arbitrary non-zero values for both (a <text> reported 78/48 while
// its rendered length sat comfortably inside its box), so the delta is
// noise, not overflow. SVG clips to its own viewport anyway.
if (el.namespaceURI === 'http://www.w3.org/2000/svg') 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.
@@ -5977,6 +6119,22 @@ function isPaintedForOcclusion(el) {
// path is pure geometry and runs anywhere on the page.
const OCCLUSION_TEXT_SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'title']);
// An element whose effective opacity multiplies out to ~0 paints nothing at
// rest: it is not user-visible, so visual findings on it (contrast, occlusion)
// measure a state nobody sees. Browser-only — the walk needs live computed
// styles. Cycling scenes that fade such elements in later are the screenshot
// subsystem's territory, not the analytic walk's.
function effectiveOpacityDOM(el) {
let o = 1;
// Walk all the way through body and html: `body { opacity: 0 }` page-fade
// wrappers hide every descendant just as thoroughly as a local wrapper.
for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {
o *= parseFloat(getComputedStyle(cur).opacity || '1');
if (o <= 0.02) return 0;
}
return o;
}
function checkTextOcclusionDOM() {
const findings = [];
const seenVictims = new Set();
@@ -6004,6 +6162,11 @@ function checkTextOcclusionDOM() {
}
return false;
};
// The classic occluder shape this rules out is an opacity-0 interaction
// layer — a range scrubber stretched over a before/after comparison — which
// elementFromPoint still returns and whose UA background-color otherwise
// reads as an opaque box.
const effectiveOpacity = effectiveOpacityDOM;
// Collect renderable text owners in / near the first viewport for the
// elementFromPoint probe. SVG <text> counts too.
@@ -6016,6 +6179,7 @@ function checkTextOcclusionDOM() {
const text = inSvg ? (el.textContent || '').trim() : elementDirectText(el);
if (text.length < 2) continue;
if (!isPaintedForOcclusion(el)) continue;
if (effectiveOpacity(el) <= 0.02) continue;
let rect; try { rect = el.getBoundingClientRect(); } catch { continue; }
if (rect.width < 6 || rect.height < 6) continue;
// Viewport-bound probe: keep text whose box overlaps the live viewport.
@@ -6049,6 +6213,7 @@ function checkTextOcclusionDOM() {
if (top === el || el.contains(top) || top.contains(el)) continue;
const topCs = getComputedStyle(top);
if (isFloated(topCs) || isMarqueeish(top, topCs) || isPinnedOverlay(top)) continue;
if (effectiveOpacity(top) <= 0.02) continue;
const topTag = top.tagName.toLowerCase();
// Text sitting under a raw image/video is contrast territory (deduped
// against the pixel low-contrast rule); leave those alone here.
@@ -6941,6 +7106,10 @@ if (IS_BROWSER) {
const reasons = collectVisualContrastReasons(el, style);
if (reasons.length === 0) continue;
// Image-only mode filters here, inside the cap: gradient/opacity/filter
// candidates earlier in DOM order must not consume the budget and
// starve the url()-backed texts this mode exists to sample.
if (options.imageOnly && !reasons.includes('image background')) continue;
const textColor = parseRgb(style.color);
const fontSize = parseFloat(style.fontSize) || 16;
@@ -7433,6 +7602,7 @@ if (IS_BROWSER) {
}
async function analyzeVisualContrast(options = {}) {
// imageOnly is enforced inside the collector, before the candidate cap.
const candidates = collectVisualContrastCandidates(options);
const results = [];
const shouldScrollOffscreen = options.scrollOffscreen === true;
@@ -7518,9 +7688,16 @@ if (IS_BROWSER) {
function addBrowserFindings(groupMap, el, findings) {
if (!findings || findings.length === 0) return;
// Element-scoped waivers: a data-impeccable-ignore ancestor suppresses
// matching findings for its whole subtree. Applied at this choke point so
// every per-element attribution (checks, layout, occlusion, rhythm)
// honors it; page-level findings attributed to <body> pass through
// untouched, since body has no ignoring ancestor.
const kept = findings.filter(f => !scopedIgnoreActive(el, f.type));
if (kept.length === 0) return;
const existing = groupMap.get(el);
if (existing) existing.push(...findings);
else groupMap.set(el, [...findings]);
if (existing) existing.push(...kept);
else groupMap.set(el, [...kept]);
}
function browserFindingsFromMap(groupMap) {
@@ -7878,9 +8055,27 @@ if (IS_BROWSER) {
for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) {
node.remove();
}
const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML);
if (htmlPatternFindings.length > 0) {
const mapped = htmlPatternFindings.map(f => {
// Regex findings that name a live selector resolve against the real DOM:
// pseudo-element/class segments are stripped (the host element is the
// anchor), a selector that matches nothing on this page drops the finding
// (the CSS ships here, but the pattern never renders — the live DOM is
// ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level.
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
if (!f.selector) return true;
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false;
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
});
if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => {
const item = { type: f.id, detail: f.snippet };
if (f.severity) {
item.severity = f.severity;
@@ -7910,8 +8105,27 @@ if (IS_BROWSER) {
};
}
// Visual contrast has three modes. Explicit true runs the full sampled
// pass; explicit false disables it entirely (the deterministic-only mode
// the test suites use). Unset — the default overlay run — samples ONLY
// image-backed text: the one class the analytic walk deliberately skips,
// because a url() layer's pixels are unknowable without looking. In-page
// sampling draws the source image alone to a canvas (glyph ink never
// pollutes it), and a cross-origin image without CORS reports unresolved
// instead of guessing.
function visualContrastMode(options = {}) {
const explicit = typeof options.visualContrast === 'boolean'
? options.visualContrast
: typeof window.__IMPECCABLE_CONFIG__?.visualContrast === 'boolean'
? window.__IMPECCABLE_CONFIG__.visualContrast
: null;
if (explicit === true) return 'full';
if (explicit === false) return false;
return 'image-only';
}
function shouldRunVisualContrast(options = {}) {
return options.visualContrast === true || window.__IMPECCABLE_CONFIG__?.visualContrast === true;
return visualContrastMode(options) !== false;
}
function visualContrastOptions(options = {}) {
@@ -8088,6 +8302,7 @@ if (IS_BROWSER) {
return [];
}
const resolvedOptions = visualContrastOptions(options);
if (visualContrastMode(options) === 'image-only') resolvedOptions.imageOnly = true;
const analyses = await analyzeVisualContrast(resolvedOptions);
if (runtime.generation && runtime.generation !== scanGeneration) return analyses;
lastVisualContrastAnalyses = analyses;
@@ -35,6 +35,7 @@ export { detectUrl, createBrowserDetector } from './engines/browser/detect-url.m
export { detectText, extractStyleBlocks, extractCSSinJS } from './engines/regex/detect-text.mjs';
export {
walkDir,
hasScannableExtension,
SCANNABLE_EXTENSIONS,
SKIP_DIRS,
buildImportGraph,
@@ -226,6 +226,10 @@ const STATIC_INHERITED_PROPS = new Set([
'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
'webkitHyphens',
// visibility inherits in real CSS, and the invisible-at-rest contrast skip
// relies on descendants of a hidden container computing as hidden. A child
// that declares `visibility: visible` still overrides the inherited value.
'visibility',
]);
const STATIC_DEFAULT_STYLE = {
@@ -278,6 +282,7 @@ const STATIC_DEFAULT_STYLE = {
marginLeft: '0px',
position: 'static',
visibility: 'visible',
opacity: '1',
top: 'auto',
right: 'auto',
bottom: 'auto',
@@ -334,6 +339,7 @@ const STATIC_PROP_MAP = {
'margin-left': 'marginLeft',
'position': 'position',
'visibility': 'visibility',
'opacity': 'opacity',
'top': 'top',
'right': 'right',
'bottom': 'bottom',
@@ -28,6 +28,7 @@ import {
checkCreamPalette,
checkHtmlPatterns,
checkKickerAboveHeadingFromDoc,
scopedIgnoreActive,
checkNumberedSectionLabelsFromDoc,
checkPageLayout,
checkPageQualityFromDoc,
@@ -138,10 +139,21 @@ async function detectHtml(filePath, options = {}) {
domutils,
};
});
} catch {
return detectText(html, filePath, options);
} catch (err) {
if (!globalThis.__impeccableStaticHtmlWarned) {
globalThis.__impeccableStaticHtmlWarned = true;
process.stderr.write(
'impeccable detect: DEGRADED - HTML parser modules unavailable ' +
'(htmlparser2, css-select, css-tree, domutils).\n' +
'Falling back to regex matching. Custom properties, selector matching and computed ' +
'contrast are NOT evaluated; findings are an undercount, not a clean bill of health.\n'
);
}
return detectText(html, filePath, options);
}
const resolvedPath = path.resolve(filePath);
const fileDir = path.dirname(resolvedPath);
const root = profileStep(profile, {
@@ -171,6 +183,9 @@ async function detectHtml(filePath, options = {}) {
const tag = el.tagName.toLowerCase();
const style = window.getComputedStyle(el);
for (const f of runElementCheck(rule.id, () => rule.run(el, tag, style, window, customPropMap))) {
// Element-scoped waivers: a data-impeccable-ignore ancestor suppresses
// matching findings for its subtree, same as the browser walk.
if (scopedIgnoreActive(el, f.id)) continue;
findings.push(finding(f.id, filePath, f.snippet));
}
}
@@ -238,6 +253,17 @@ async function detectHtml(filePath, options = {}) {
for (const f of runPageCheck('html-patterns', () => checkHtmlPatterns(html, patternCorpora).filter(item =>
item.id !== 'bounce-easing' && item.id !== 'layout-transition'
))) {
// Selector-backed page findings honor scoped waivers here too, matching
// the browser pass: resolve the selector and drop the finding when an
// ignoring ancestor covers a match. Unlike the browser, an unmatched
// selector keeps the finding — static scans see partial documents.
if (f.selector) {
let matches = null;
try {
matches = document.querySelectorAll(String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim());
} catch { matches = null; }
if (matches && matches.length > 0 && [...matches].every(el => scopedIgnoreActive(el, f.id))) continue;
}
const item = finding(f.id, filePath, f.snippet);
// Position-aware severity promotion: checks may attach a per-finding
// severity (e.g. a pulsing dot inside a header/nav landmark) that
@@ -26,11 +26,20 @@ const HIDDEN_SOURCE_DIRS = new Set(['.vitepress', '.vuepress', '.storybook']);
const SCANNABLE_EXTENSIONS = new Set([
'.html', '.htm', '.css', '.scss', '.sass', '.less',
'.jsx', '.tsx', '.js', '.ts',
'.vue', '.svelte', '.astro',
'.vue', '.svelte', '.astro', '.blade.php',
]);
const HTML_EXTENSIONS = new Set(['.html', '.htm']);
function hasScannableExtension(filename) {
const lower = filename.toLowerCase();
if (SCANNABLE_EXTENSIONS.has(path.extname(lower))) return true;
for (const ext of SCANNABLE_EXTENSIONS) {
if (ext.indexOf('.', 1) !== -1 && lower.endsWith(ext)) return true;
}
return false;
}
const IMPORT_SPECIFIER_PATTERNS = [
/import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g,
/@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g,
@@ -46,7 +55,7 @@ function walkDir(dir) {
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full));
else if (SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(full);
else if (hasScannableExtension(entry.name)) files.push(full);
}
return files;
}
@@ -194,6 +203,7 @@ export {
SKIP_DIRS,
SCANNABLE_EXTENSIONS,
HTML_EXTENSIONS,
hasScannableExtension,
walkDir,
resolveImport,
buildImportGraph,
@@ -70,6 +70,34 @@ function checkBorders(tag, widths, colors, radius, opts = {}) {
return findings;
}
// ─── Scoped ignores: data-impeccable-ignore ─────────────────────────────────
//
// An element-scoped waiver that travels with the markup: any element carrying
// `data-impeccable-ignore="rule-a rule-b"` (or `*`, or an empty value, for
// every rule) suppresses matching findings from itself and its entire subtree,
// in every engine that walks elements — the browser overlay, the extension,
// and the static scan. This is the DOM twin of the line-based
// `impeccable-disable` comment directives, which the browser cannot apply (a
// live DOM has no line numbers), and the generalization of the one-off
// `data-impeccable-allow-kickers` opt-out.
//
// The intended use is curated exhibits: a page that documents anti-patterns by
// example, or renders a deliberate "before" specimen, marks the container once
// and every engine skips it while still scanning the page around it.
function scopedIgnoreActive(el, ruleId) {
const rule = String(ruleId || '').toLowerCase();
let cur = el;
while (cur && cur.nodeType === 1) {
const attr = typeof cur.getAttribute === 'function' ? cur.getAttribute('data-impeccable-ignore') : null;
if (attr != null) {
const rules = String(attr).trim().toLowerCase().split(/[\s,]+/).filter(Boolean);
if (rules.length === 0 || rules.includes('*') || rules.includes(rule)) return true;
}
cur = cur.parentElement;
}
return false;
}
// Returns true if the given text is composed entirely of emoji characters
// (plus whitespace / variation selectors). Emojis render as multicolor glyphs
// regardless of CSS `color`, so contrast checks against the element's text
@@ -637,6 +665,26 @@ function cssTextHasDarkRootBg(content, customProps) {
return false;
}
// Best-effort extraction of the CSS selector whose declaration block contains
// the given index in raw CSS text. Lets CSS-text findings carry a live-DOM
// anchor, so the browser pass can resolve scoped ignores against the actual
// element and drop patterns that render nowhere on the page. Returns null for
// @-rule preludes, keyframe steps, nested blocks, and anything that does not
// read as a selector; those findings stay page-level.
function enclosingCssSelector(cssText, index) {
if (!cssText || !Number.isFinite(index)) return null;
const open = cssText.lastIndexOf('{', index);
if (open === -1) return null;
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
const raw = cssText.slice(prevClose + 1, open).trim().replace(/\s+/g, ' ');
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from`
// and `to` would read as (never-matching) type selectors and get a valid
// finding wrongly dropped by the zero-match rule downstream.
if (/^(?:from|to)(?:\s*,\s*(?:from|to))*$/i.test(raw)) return null;
return raw;
}
function scanCssTextForGlow(content) {
const customProps = collectCssCustomProps(content);
const hasDarkBg = cssTextHasDarkRootBg(content, customProps);
@@ -948,6 +996,7 @@ function scanCssTextForPseudoStripe(rawContent) {
id: 'side-tab',
snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`,
index: selectorStart,
selector,
});
}
return findings;
@@ -1010,6 +1059,7 @@ function scanCssTextForInsetStripe(content) {
findings.push({
id: 'side-tab',
snippet: `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`,
selector,
});
break;
}
@@ -1067,7 +1117,7 @@ function collectMarqueeKeyframes(content) {
function scanCssTextForMarquee(content, markup = content) {
const findings = [];
if (/<marquee\b/i.test(markup)) {
findings.push({ id: 'marquee', snippet: '<marquee> element' });
findings.push({ id: 'marquee', snippet: '<marquee> element', selector: 'marquee' });
}
const marqueeKeyframes = collectMarqueeKeyframes(content);
if (marqueeKeyframes.size === 0) return findings;
@@ -1082,7 +1132,7 @@ function scanCssTextForMarquee(content, markup = content) {
const key = `${selector} ${name}`;
if (seen.has(key)) continue;
seen.add(key);
findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"` });
findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"`, selector });
}
}
return findings;
@@ -1453,8 +1503,10 @@ function checkHtmlPatterns(html, corpora) {
const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
if (purpleHexRe.test(styleText)) {
const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
if (purpleTextRe.test(styleText)) {
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
purpleTextRe.lastIndex = 0;
const purpleMatch = purpleTextRe.exec(styleText);
if (purpleMatch) {
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected', selector: enclosingCssSelector(styleText, purpleMatch.index + 1) || undefined });
}
}
@@ -1465,7 +1517,7 @@ function checkHtmlPatterns(html, corpora) {
const start = Math.max(0, gm.index - 200);
const context = styleText.substring(start, gm.index + gm[0].length + 200);
if (/gradient/i.test(context)) {
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient', selector: enclosingCssSelector(styleText, gm.index) || undefined });
break;
}
}
@@ -1531,7 +1583,7 @@ function checkHtmlPatterns(html, corpora) {
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()}` });
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}`, selector: enclosingCssSelector(styleText, bounceMatch.index) || undefined });
}
// Overshoot cubic-bezier
@@ -1540,7 +1592,7 @@ function checkHtmlPatterns(html, corpora) {
while ((bm = bezierRe.exec(styleText)) !== null) {
const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})`, selector: enclosingCssSelector(styleText, bm.index) || undefined });
break;
}
}
@@ -1573,18 +1625,21 @@ function checkHtmlPatterns(html, corpora) {
const glowHits = scanCssTextForGlow(styleText);
if (glowHits.length > 0) {
findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet });
findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet, selector: enclosingCssSelector(styleText, glowHits[0].index) || undefined });
}
// Radial-gradient background halo (gradient-drawn sibling of dark-glow)
const haloHits = scanCssTextForRadialHalo(styleText);
if (haloHits.length > 0) {
findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet });
findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet, selector: enclosingCssSelector(styleText, haloHits[0].index) || undefined });
}
// --- Generated-UI tells: repeating-gradient stripes ---
if (/repeating-(?:linear|radial|conic)-gradient\s*\(/i.test(styleText)) {
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });
{
const stripesMatch = /repeating-(?:linear|radial|conic)-gradient\s*\(/i.exec(styleText);
if (stripesMatch) {
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes', selector: enclosingCssSelector(styleText, stripesMatch.index) || undefined });
}
}
// --- Generated-UI tells: two-axis grid-line background ---
@@ -1602,7 +1657,7 @@ function checkHtmlPatterns(html, corpora) {
// whole gradient layers.
const gridHits = scanCssTextForGridBackground(styleText);
if (gridHits.length > 0) {
findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet });
findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet, selector: enclosingCssSelector(styleText, gridHits[0].index) || undefined });
}
// --- Generated-copy tells: "X theater" framing copy ---
@@ -1622,8 +1677,11 @@ function checkHtmlPatterns(html, corpora) {
// hover:rotate / hover:translate utility on an <img>. Each distinct
// mechanism is its own finding.
const imgHoverCss = /\bimg\b[^,{}]*:hover\b[^{}]*\{[^}]*\btransform\s*:\s*(?:scale|rotate|translate|matrix|skew)/i;
if (imgHoverCss.test(styleText)) {
findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule' });
{
const imgHoverMatch = imgHoverCss.exec(styleText);
if (imgHoverMatch) {
findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule', selector: enclosingCssSelector(styleText, imgHoverMatch.index + imgHoverMatch[0].indexOf('{') + 1) || undefined });
}
}
const imgTagRe = /<img\b[^>]*\bclass\s*=\s*"([^"]*)"/gi;
let im;
@@ -1670,6 +1728,33 @@ function readOwnBackgroundColor(el, computedStyle) {
return bg;
}
// One element's background-color as the cascade walk sees it: computed style
// first (with the modern-color fallback), then, in static mode only,
// custom-prop resolution and the inline-shorthand peek. Shared by
// resolveBackground and resolveGradientStops so both walks read the same
// surfaces.
function readCascadeBackgroundColor(current, style, customPropMap) {
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
// The static engine can return literal "var(--X)" / "oklch(...)" strings.
// Resolve through customPropMap so Tailwind v4 color tokens become RGB.
if (customPropMap) {
bg = parseColorResolved(style.backgroundColor, customPropMap);
}
if (!bg || bg.a < 0.1) {
// Inline-style fallback for colors the static cascade did not surface
// on backgroundColor.
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
}
}
}
return bg;
}
function resolveBackground(el, win, customPropMap) {
let current = el;
// Translucent layers (0.1 < a < 1) found on the way down to an opaque
@@ -1698,24 +1783,7 @@ function resolveBackground(el, win, customPropMap) {
// body backgrounds.
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
// (e.g. any color-mix() result), which plain parseRgb misses.
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
// jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve
// through customPropMap so Tailwind v4 color tokens become RGB.
if (customPropMap) {
bg = parseColorResolved(style.backgroundColor, customPropMap);
}
if (!bg || bg.a < 0.1) {
// Inline-style fallback. jsdom doesn't decompose background
// shorthand, so colors set via inline style are otherwise invisible.
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
}
}
}
const bg = readCascadeBackgroundColor(current, style, customPropMap);
if (bg && bg.a > 0.1) {
if (bg.a >= 0.99) return flatten(bg);
@@ -1733,40 +1801,95 @@ function resolveBackground(el, win, customPropMap) {
// • on other elements: bail to null and let the caller fall back
// to gradient stops (gradient buttons / hero sections are real
// bgs worth checking against).
if (hasGradientOrUrl) {
if (current.tagName === 'BODY' || current.tagName === 'HTML') {
return flatten({ r: 255, g: 255, b: 255, a: 1 });
}
return null;
}
// A gradient or image with no solid color under it, at any level
// including body/html, means the visible ground is that layer itself.
// Return null so the caller measures against the actual gradient stops,
// or skips when nothing is parseable — skipping beats a wrong ratio.
//
// Body/html used to assume white here, a guard written for jsdom, which
// never decomposed the `background:` shorthand and so could not see the
// solid paper color a texture gradient usually sits on. It turned every
// light-on-dark page into a wall of low-contrast false positives (a dark
// oklch body gradient produced ~120 "on #ffffff" findings on one site).
// Both engines can see shorthand solids now — the browser natively, the
// static cascade via expandStaticDeclaration + var() resolution — so a
// missing solid is real, and the old failure case cannot recur: opaque
// stops fully cover any hidden solid (they ARE the ground), alpha stops
// composite over the resolved base or the white canvas default, and
// unresolvable stops drop rather than guess.
if (hasGradientOrUrl) return null;
current = current.parentElement;
}
return flatten({ r: 255, g: 255, b: 255, a: 1 });
}
// parseGradientColors (shared) reads only the legacy serializations: rgb()
// and hex stops. Browsers keep modern-space stops in computed backgroundImage
// exactly as authored — `linear-gradient(oklch(7% 0.006 95), …)` stays oklch —
// which is what every token-driven page produces. Route those through
// parseAnyColor so a gradient ground is measurable rather than invisible.
function parseGradientColorsModern(bgImage) {
if (!bgImage || !/gradient/i.test(bgImage)) return [];
const colors = parseGradientColors(bgImage);
for (const m of bgImage.matchAll(/(?:oklch|oklab|hsla?|hwb)\(\s*[^()]*\)/gi)) {
const c = parseAnyColor(m[0]);
if (c) colors.push(c);
}
return colors;
}
// Walk parents looking for a gradient background and return its color stops.
// Used as a fallback when resolveBackground() returns null because the
// effective background is a gradient (no single solid color to compare against).
// Translucent solid layers found between the element and the gradient (frosted
// panels, glass washes) are composited over every stop, the same way
// resolveBackground flattens them over a solid base — raw stops alone would
// false-flag dark text on a light frosted wash over a dark gradient, and miss
// the inverse.
function resolveGradientStops(el, win, customPropMap) {
let current = el;
const overlays = [];
while (current && current.nodeType === 1) {
const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
const bgImage = style.backgroundImage || '';
// A url() layer anywhere in the stack — alone, or alongside a gradient in
// the same declaration (a translucent wash over a texture photo) — paints
// pixels the analytic walk cannot know. Measuring the gradient stops over
// the wrong base flagged dark ink sitting on a bright gold-leaf image at
// 2.6:1; skipping beats a wrong ratio, and the screenshot subsystem owns
// image-backed text.
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
let stops = null;
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
const parsed = parseGradientColors(bgImage);
const parsed = parseGradientColorsModern(bgImage);
if (parsed.length > 0) stops = parsed;
}
if (!stops && !DETECTOR_IS_BROWSER) {
// jsdom doesn't decompose `background:` shorthand — peek at the raw inline style
// Static mode: peek at the raw inline style for gradients the cascade did not surface
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
if (bgMatch && /gradient/i.test(bgMatch[1])) {
const parsed = parseGradientColors(bgMatch[1]);
const parsed = parseGradientColorsModern(bgMatch[1]);
if (parsed.length > 0) stops = parsed;
}
}
if (stops) return compositeGradientStops(stops, current, win, customPropMap);
if (stops) {
const composited = compositeGradientStops(stops, current, win, customPropMap);
if (!composited || overlays.length === 0) return composited;
return composited.map(stop => {
let acc = stop;
for (let i = overlays.length - 1; i >= 0; i--) acc = compositeColorOver(overlays[i], acc);
return acc;
});
}
const bg = readCascadeBackgroundColor(current, style, customPropMap);
if (bg && bg.a > 0.1) {
// An opaque surface above the gradient means the gradient never shows
// through here; resolveBackground would have returned it, so reaching
// this is defensive — bail rather than measure the wrong layer.
if (bg.a >= 0.99) return null;
overlays.push(bg);
}
current = current.parentElement;
}
return null;
@@ -1986,6 +2109,10 @@ function checkElementColorsDOM(el) {
const rect = el.getBoundingClientRect();
if (rect.width < 10 || rect.height < 10) return [];
const style = getComputedStyle(el);
// Invisible at rest: hidden scene variants (opacity-0 carousels, swap
// decks) are not user-visible, and measuring their inherited colors against
// whatever surface happens to sit behind the stack is noise, not audit.
if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
let effectiveBg = resolveBackground(el);
@@ -2798,11 +2925,13 @@ function checkElementGlowDOM(el) {
// If resolveBackground returns null (gradient), try to infer from the gradient colors
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
if (!parentBg) {
// Gradient background — sample its colors to determine if it's dark
// Gradient background — sample its colors to determine if it's dark.
// Modern-syntax parsing matters here: body-level gradients now reach this
// fallback in browser mode, and their stops usually serialize as oklch.
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const bgImage = getComputedStyle(cur).backgroundImage || '';
const gradColors = parseGradientColors(bgImage);
const gradColors = parseGradientColorsModern(bgImage);
if (gradColors.length > 0) {
// Average the gradient colors
const avg = { r: 0, g: 0, b: 0 };
@@ -3644,6 +3773,14 @@ function checkElementBorders(tag, style, overrides, resolvedRadius, el = null) {
}
function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) {
// Invisible at rest, static twin of the browser walk's skip: opacity does
// not inherit, so walk ancestors multiplying declared opacity down.
if (style.visibility === 'hidden') return [];
let effOpacity = 1;
for (let cur = el; cur && cur.nodeType === 1 && effOpacity > 0.02; cur = cur.parentElement) {
effOpacity *= parseFloat(window.getComputedStyle(cur).opacity || '1');
}
if (effOpacity <= 0.02) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
@@ -4802,6 +4939,11 @@ function isRenderedForBrowserRule(el) {
function checkElementTextOverflowDOM(el) {
const tag = el.tagName.toLowerCase();
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
// scrollWidth/clientWidth are CSS box-model metrics; on SVG content Chrome
// returns arbitrary non-zero values for both (a <text> reported 78/48 while
// its rendered length sat comfortably inside its box), so the delta is
// noise, not overflow. SVG clips to its own viewport anyway.
if (el.namespaceURI === 'http://www.w3.org/2000/svg') 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.
@@ -5186,6 +5328,22 @@ function isPaintedForOcclusion(el) {
// path is pure geometry and runs anywhere on the page.
const OCCLUSION_TEXT_SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'title']);
// An element whose effective opacity multiplies out to ~0 paints nothing at
// rest: it is not user-visible, so visual findings on it (contrast, occlusion)
// measure a state nobody sees. Browser-only — the walk needs live computed
// styles. Cycling scenes that fade such elements in later are the screenshot
// subsystem's territory, not the analytic walk's.
function effectiveOpacityDOM(el) {
let o = 1;
// Walk all the way through body and html: `body { opacity: 0 }` page-fade
// wrappers hide every descendant just as thoroughly as a local wrapper.
for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {
o *= parseFloat(getComputedStyle(cur).opacity || '1');
if (o <= 0.02) return 0;
}
return o;
}
function checkTextOcclusionDOM() {
const findings = [];
const seenVictims = new Set();
@@ -5213,6 +5371,11 @@ function checkTextOcclusionDOM() {
}
return false;
};
// The classic occluder shape this rules out is an opacity-0 interaction
// layer — a range scrubber stretched over a before/after comparison — which
// elementFromPoint still returns and whose UA background-color otherwise
// reads as an opaque box.
const effectiveOpacity = effectiveOpacityDOM;
// Collect renderable text owners in / near the first viewport for the
// elementFromPoint probe. SVG <text> counts too.
@@ -5225,6 +5388,7 @@ function checkTextOcclusionDOM() {
const text = inSvg ? (el.textContent || '').trim() : elementDirectText(el);
if (text.length < 2) continue;
if (!isPaintedForOcclusion(el)) continue;
if (effectiveOpacity(el) <= 0.02) continue;
let rect; try { rect = el.getBoundingClientRect(); } catch { continue; }
if (rect.width < 6 || rect.height < 6) continue;
// Viewport-bound probe: keep text whose box overlaps the live viewport.
@@ -5258,6 +5422,7 @@ function checkTextOcclusionDOM() {
if (top === el || el.contains(top) || top.contains(el)) continue;
const topCs = getComputedStyle(top);
if (isFloated(topCs) || isMarqueeish(top, topCs) || isPinnedOverlay(top)) continue;
if (effectiveOpacity(top) <= 0.02) continue;
const topTag = top.tagName.toLowerCase();
// Text sitting under a raw image/video is contrast territory (deduped
// against the pixel low-contrast rule); leave those alone here.
@@ -5468,6 +5633,7 @@ export {
CSS_NAMED_COLORS,
checkBorders,
isEmojiOnlyText,
scopedIgnoreActive,
checkColors,
checkHoverContrast,
checkElementHoverContrast,
@@ -16,11 +16,15 @@ import path from 'node:path';
import {
ALLOWED_EXTS,
DEFAULT_CONFIG,
EDIT_COUNT_THRESHOLD,
GENERATED_PATH,
SENSITIVE_PATH,
appendDesignSystemNote,
appendDesignSystemNoteOnce,
commitFooterShown,
designNoteReserve,
designSystemOptions,
footerModeForSession,
filterFindings,
isNativePlatform,
isScanTargetInsideProject,
@@ -345,13 +349,32 @@ async function detectProposedHtml(detector, content, filePath, scanOptions) {
}
}
function cursorBlockMessage(findings, filePath, config, cwd) {
const rendered = renderTemplate(findings, filePath, config, { cwd });
const blocked = rendered.replace(
// Cursor caps deny messages around 4000 chars. The cap feeds through the
// renderer's clamp, which preserves the policy footer, rather than tail-
// slicing the rendered text, which cut the footer off any message the
// default 8000-char budget let past 4000.
const CURSOR_DENY_LIMIT = 4000;
const BLOCK_PREFIX = 'Impeccable design hook blocked this write before it landed. ';
function cursorBlockMessage(findings, filePath, config, cwd, footerMode, reserveChars) {
const limits = config?.limits || DEFAULT_CONFIG.limits;
// Charge the prefix via reserveChars, not by subtracting from maxChars:
// renderTemplate's 500-char floor re-raises any maxChars pushed below it,
// un-charging a prefix subtracted from maxChars (Greptile P1 on PR #508).
// reserveChars comes off after the floor, so the prefix is charged at every
// config tier and the final prefixed message plus a pending staleness note
// fits the binding limit. Default-config output is byte-identical.
const budget = Math.min(
limits.maxChars || DEFAULT_CONFIG.limits.maxChars,
CURSOR_DENY_LIMIT,
);
const rendered = renderTemplate(findings, filePath,
{ ...config, limits: { ...limits, maxChars: budget } },
{ cwd, footer: footerMode, reserveChars: (reserveChars || 0) + BLOCK_PREFIX.length });
return 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',
`[impeccable@1] ${BLOCK_PREFIX}Design hook findings requiring review`,
);
return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked;
}
function findingSignature(findings) {
@@ -468,9 +491,16 @@ async function main() {
});
}
const message = appendDesignSystemNote(cursorBlockMessage(filtered, filePath, config, cwd), scanOptions);
const sessionId = event.session_id || event.conversation_id || 'unknown';
const cache = readCache(cwd);
// Repeated denials for the same session repeat the findings, not the
// policy: the full footer emits once per session, the short form after.
const footerMode = footerModeForSession(cache, sessionId);
const message = appendDesignSystemNoteOnce(
cursorBlockMessage(filtered, filePath, config, cwd, footerMode, designNoteReserve(scanOptions, cache, sessionId)),
scanOptions, cache, sessionId, config,
);
commitFooterShown(cache, sessionId, message);
const denial = bumpCursorDenial(cache, sessionId, filePath, filtered);
persistCache(cwd, cache);
if (denial.count > EDIT_COUNT_THRESHOLD) {
+278 -88
View File
@@ -22,6 +22,9 @@
* dedupeAgainstCache(findings, cache, sessionId, filePath)
* renderTemplate(findings, filePath, config, opts)
* renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts)
* appendDesignSystemNote(text, scanOptions) / appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config)
* designNoteReserve(scanOptions, cache, sessionId)
* footerModeForSession(cache, sessionId) / commitFooterShown(cache, sessionId, text)
* shouldEmitAckForFile(filePath, config?)
* writeAuditLog(env, entry)
* loadDetector() -> Promise<{ detectText, detectHtml }>
@@ -970,7 +973,13 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
if (!Array.isArray(findings) || findings.length === 0) return '';
const limits = config?.limits || DEFAULT_CONFIG.limits;
const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings);
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
// reserveChars holds back room for a note the caller appends after render
// (the DESIGN.md staleness note), so the final payload stays inside the
// configured budget. It comes off after the 500-char floor, so at floor
// configs the note keeps guaranteed delivery room; the clamp budget can
// therefore sit below 500, which clampLastLine's footer-preserving
// fallback handles (Bugbot on PR #508).
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0);
const cwd = opts.cwd || process.cwd();
const display = relativize(filePath, cwd);
@@ -979,11 +988,12 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
const remaining = total - shown.length;
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
const lines = shown.map((f) => formatFindingLine(f));
const seenRules = new Set();
const lines = shown.map((f) => formatDedupedFindingLine(f, seenRules));
const more = remaining > 0
? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).`
: null;
const footer = directiveFooter(display);
const footer = directiveFooter({ mode: opts.footer });
const blocks = [header, ...lines];
if (more) blocks.push(more);
@@ -1007,12 +1017,15 @@ function renderGroupedTemplate(groups, config, opts = {}) {
const limits = config?.limits || DEFAULT_CONFIG.limits;
const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings);
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0);
const cwd = opts.cwd || process.cwd();
const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0);
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`;
const lines = [];
let shownCount = 0;
// One seen-set across all groups: a rule already described under one file
// is not re-described under the next.
const seenRules = new Set();
for (const group of realGroups) {
const display = relativize(group.filePath, cwd);
@@ -1020,7 +1033,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
const remainingCap = Math.max(0, cap - shownCount);
const shown = group.findings.slice(0, remainingCap);
for (const finding of shown) {
lines.push(formatFindingLine(finding));
lines.push(formatDedupedFindingLine(finding, seenRules));
}
shownCount += shown.length;
const hidden = group.findings.length - shown.length;
@@ -1029,7 +1042,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
}
}
const footer = directiveFooter('the affected files', { grouped: true });
const footer = directiveFooter({ mode: opts.footer });
let text = [header, ...lines, '', footer].join('\n');
if (text.length > maxChars) {
text = clampGroupedToBudget(header, lines, footer, maxChars);
@@ -1037,82 +1050,149 @@ function renderGroupedTemplate(groups, config, opts = {}) {
return text;
}
// The clamp contract, shared by both budget functions: the footer is policy,
// not detail, so it survives every clamp. Try the requested footer first;
// when it cannot fit even after dropping finding lines, retry with the short
// policy rather than sacrifice findings that fit beside it. A result that
// dropped every finding line (a grouped render can fit a bare file header)
// does not count as a fit: findings are why the emission exists.
const isFindingLine = (line) => line.startsWith('- ');
function footerFallbacks(footer) {
const short = directiveFooter({ mode: 'short' });
return footer === short ? [footer] : [footer, short];
}
function clampGroupedToBudget(header, lines, footer, maxChars) {
const assemble = (linesArr, omitted) => [
const assemble = (linesArr, omitted, footerText) => [
header,
...linesArr,
...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []),
'',
footer,
footerText,
].join('\n');
let working = lines.slice();
let omitted = false;
let assembled = assemble(working, omitted);
while (assembled.length > maxChars && working.length > 1) {
working.pop();
omitted = true;
assembled = assemble(working, omitted);
for (const footerText of footerFallbacks(footer)) {
let working = lines.slice();
let omitted = false;
let assembled = assemble(working, omitted, footerText);
while (assembled.length > maxChars && working.length > 1) {
working.pop();
omitted = true;
assembled = assemble(working, omitted, footerText);
}
if (assembled.length <= maxChars && working.some(isFindingLine)) return assembled;
}
if (assembled.length > maxChars) {
assembled = `${assembled.slice(0, maxChars - 1)}`;
}
return assembled;
return clampLastLine((linesArr, footerText) => assemble(linesArr, true, footerText),
lines.find(isFindingLine) || lines[0], maxChars);
}
function clampToBudget(header, lines, more, footer, maxChars) {
const assemble = (linesArr, moreText) => {
const assemble = (linesArr, moreText, footerText) => {
const blocks = [header, ...linesArr];
if (moreText) blocks.push(moreText);
blocks.push('');
blocks.push(footer);
blocks.push(footerText);
return blocks.join('\n');
};
let working = lines.slice();
let moreText = more;
let assembled = assemble(working, moreText);
while (assembled.length > maxChars && working.length > 1) {
working.pop();
moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`;
assembled = assemble(working, moreText);
let lastMore = more;
for (const footerText of footerFallbacks(footer)) {
let working = lines.slice();
let moreText = more;
let assembled = assemble(working, moreText, footerText);
while (assembled.length > maxChars && working.length > 1) {
working.pop();
moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`;
assembled = assemble(working, moreText, footerText);
}
lastMore = moreText;
if (assembled.length <= maxChars) return assembled;
}
if (assembled.length > maxChars) {
assembled = `${assembled.slice(0, maxChars - 1)}`;
}
return assembled;
return clampLastLine((linesArr, footerText) => assemble(linesArr, lastMore, footerText),
lines.find(isFindingLine) || lines[0], maxChars);
}
function formatFindingLine(f) {
// Last resort with one finding line left: the short policy gets the budget
// first, the line is clipped to what remains. The pre-fix tail-slice cut
// whatever happened to be last, which was always the footer.
function clampLastLine(build, line, maxChars) {
const footerText = directiveFooter({ mode: 'short' });
const bare = build([], footerText);
// +1 for the newline the line itself brings when it joins the blocks.
const room = maxChars - bare.length - 1;
if (room >= 24) {
const clipped = line.length > room ? `${line.slice(0, room - 1)}` : line;
return build([clipped], footerText);
}
// No room for even a clipped finding line: the note reservation can pull
// the budget below the 500-char floor, and a deep file path can push the
// header past what remains beside the short policy (Bugbot on PR #508).
// Drop the line, and if the bare header + policy still overflow, clip the
// head. Never tail-slice: the footer sits at the end, so a tail slice is
// exactly the footer cut this renderer exists to prevent.
if (bare.length <= maxChars) return bare;
const head = bare.slice(0, Math.max(0, maxChars - footerText.length - 4));
return `${head}\n\n${footerText}`;
}
// `compact` drops the registry description: within one emission the first
// occurrence of a rule carries the full description and repeats keep only the
// rule id, name, and their own ignore hint (values differ per line, so the
// hint must survive the dedupe).
function formatFindingLine(f, opts = {}) {
const prefix = f.line && f.line > 0 ? `- L${f.line}` : '-';
const desc = (f.description || '').trim();
const desc = opts.compact ? '' : (f.description || '').trim();
const name = (f.name || '').trim();
// Description from the registry already ends in punctuation; join with a
// single space. `name` may have a trailing period already, keep it clean.
const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : '';
const ignoreCommand = formatFindingIgnoreCommand(f);
const ignoreSegment = ignoreCommand
? ` If the user explicitly confirms this value is intentional: \`${ignoreCommand}\`.`
: '';
const ignoreHint = formatFindingIgnoreHint(f);
const ignoreSegment = ignoreHint ? ` If intentional: \`${ignoreHint}\`.` : '';
return `${prefix} [${f.antipattern}] ${nameSegment} ${desc}${ignoreSegment}`.replace(/\s+/g, ' ').trim();
}
function formatFindingIgnoreCommand(finding) {
// Dedupe applied in shown-line order, so the first rendered occurrence of a
// rule always carries the description. The budget clamps pop lines from the
// end, which can never orphan a compact repeat before its described first
// occurrence.
function formatDedupedFindingLine(finding, seenRules) {
const rule = normalizeIgnoreRule(finding?.antipattern);
const compact = rule ? seenRules.has(rule) : false;
if (rule) seenRules.add(rule);
return formatFindingLine(finding, { compact });
}
// The rule/value pair the footer's `hook-admin.mjs ignore-value` command
// takes. Deliberately just the args: the executable prefix, the --reason
// contract, and the disclosure rule live in the directive footer, stated once
// instead of per line.
function formatFindingIgnoreHint(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
if (!rule) return '';
const normalizedValue = extractFindingIgnoreValue(finding);
if (!normalizedValue) return '';
const value = extractFindingIgnoreValueRaw(finding);
const valueArg = quoteCommandArg(value);
const reason = quoteCommandArg(`User confirmed ${value} is intentional`);
return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`;
const valueArg = quoteCommandArg(extractFindingIgnoreValueRaw(finding));
return `ignore-value ${rule} ${valueArg}`;
}
function quoteCommandArg(value) {
const text = String(value || '').trim();
if (/^[A-Za-z0-9._:-]+$/.test(text)) return text;
return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
// The suggestion is meant to be run on this same machine, so quote for its
// shell. POSIX /bin/sh still expands $(...), backticks, and ${} inside
// double quotes, and these values come from scanned file content (a
// font-family name) or a file path, so untrusted input must be
// single-quoted (issue #476). Windows cmd.exe performs no such command
// substitution, but it treats a single quote as a literal character rather
// than a grouping delimiter, so a value or path containing spaces has to
// stay double-quoted there (Greptile #533). Keep the pre-existing
// double-quote escaping on Windows so that path's behavior is unchanged.
if (process.platform === 'win32') {
return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
return `'${text.replace(/'/g, `'\\''`)}'`;
}
function relativize(filePath, cwd) {
@@ -1594,36 +1674,105 @@ export function designSystemOptions(config, detector, projectCwd) {
}
}
const DESIGN_STALE_NOTE = `${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`;
export function appendDesignSystemNote(text, scanOptions) {
if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text;
return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`;
return `${text}\n\n${DESIGN_STALE_NOTE}`;
}
// Session-scoped once-only gate for repeat-prone message parts. Returns true
// the first time a flag is consumed in a session and false after, mirroring
// the `cleanAcked` mechanic: the mtime skew (and the policy footer) do not
// change between edits, so re-stating them on every emission spends context
// to say nothing new. Callers must persist the cache for the flag to stick.
function consumeSessionNoticeFlag(cache, sessionId, flag) {
const session = ensureSession(cache, sessionId);
if (session[flag]) return false;
session[flag] = true;
session.updatedAt = Date.now();
return true;
}
// Once-per-session variant of appendDesignSystemNote for the emission paths
// that have cache access. The staleness note names standing project state,
// not new information, so one mention per session is enough. The note is
// appended after the renderer has clamped to the configured budget: render
// paths reserve room for it via designNoteReserve, and the size check here
// is the safety net for the ack paths, deferring (without consuming the
// flag) to a later emission rather than busting maxChars.
export function appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) {
if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text;
const maxChars = Math.max(500, config?.limits?.maxChars || DEFAULT_CONFIG.limits.maxChars);
if (text.length + DESIGN_STALE_NOTE.length + 2 > maxChars) return text;
if (!consumeSessionNoticeFlag(cache, sessionId, 'designNoteShown')) return text;
return appendDesignSystemNote(text, scanOptions);
}
// Render-time reservation for the note above: how many characters the
// renderer must hold back so a pending staleness note still fits inside the
// configured budget. Zero once the session has seen the note. Without the
// reservation, a session whose every emission fills the budget would defer
// the note forever.
export function designNoteReserve(scanOptions, cache, sessionId) {
if (!scanOptions?.designSystem?.mdNewerThanJson) return 0;
if (ensureSession(cache, sessionId).designNoteShown) return 0;
return DESIGN_STALE_NOTE.length + 2;
}
// Full directive footer once per session, the short reminder after. Fresh
// emissions and Cursor denials share the session flag (`footerShown`), so a
// session pays the full policy exactly once however it first fires. The mode
// is a peek: the clamp can downgrade a requested full footer under a tight
// budget, so the flag commits only when the complete full policy actually
// reached the output. Matching the whole footer text (not a sentinel) keeps
// the flag honest against any truncation that spares the opening words.
export function footerModeForSession(cache, sessionId) {
return ensureSession(cache, sessionId).footerShown ? 'short' : 'full';
}
export function commitFooterShown(cache, sessionId, text) {
if (!text || !text.includes(directiveFooter())) return;
const session = ensureSession(cache, sessionId);
if (session.footerShown) return;
session.footerShown = true;
session.updatedAt = Date.now();
}
const HOOK_ADMIN_COMMAND = `node ${quoteCommandArg(path.join(__dirname, 'hook-admin.mjs'))}`;
// The directive footer is the part of the hook output that steers model
// behavior. Three intentional moves:
// 1. **Imperative, not advisory.** "Handle these..." beats "Consider
// revising..." which the model treats as a soft suggestion it can
// override when the user asked for any kind of throwaway / demo UI.
// 2. **Explicit judgment clause.** Without it, the model will try to
// "fix" intentional motion, bad fixtures, anti-pattern examples in
// docs, or test cases. Naming the judgment inline beats hoping the
// model infers it from context.
// 3. **Acknowledgement instruction.** Hook output is injected as
// developer-role context, not a chat turn, so the user never sees the
// raw envelope. Asking the model to surface the resolution in its
// reply is the cheapest way to make the feedback loop visible.
function directiveFooter(display, opts = {}) {
// Offer the rule-scoped-to-file form first. `ignore-file` silences every rule
// for the path forever, which is far more than one noisy rule on a real UI
// surface justifies, and it was previously the only option named here.
const target = opts.grouped ? '<path>' : quoteCommandArg(display);
const fileIgnoreGuidance = `run \`${IMPECCABLE_COMMAND} hooks ignore-value <id> "*" --file ${target}\` to scope just that rule to the file, or \`${IMPECCABLE_COMMAND} hooks ignore-file ${target}\` only when the whole file is out of scope for design review (a fixture, a generated artifact, a deliberate demo)`;
// behavior. Intentional moves, in order:
// 1. **Imperative, not advisory.** "Triage each finding..." beats
// "Consider revising...", which the model treats as a soft suggestion.
// 2. **Positive triage branches.** Fix / suppress-and-disclose / ask. The
// suppress branch names the calibration examples (demo, fixture,
// documented bad design, user-confirmed choice) because the agent now
// acts on its own confidence and needs the bar stated.
// 3. **Executable ignore path.** The old footer named only the slash
// command, which an agent reacting to hook output cannot run; the
// hook-admin.mjs invocation is runnable as-is and keeps agents out of
// hand-editing config.json.
// 4. **Honest provenance.** The --reason is the audit trail; "user
// confirmed" appears only when the user actually did.
// 5. **Acknowledgement instruction.** Hook output is injected as
// developer-role context, so the reply is where the user sees the
// resolution, including any ignore the agent persisted.
// 6. **Once per session.** The full policy emits on the session's first
// fire; later emissions carry the one-line short form (mode 'short').
function directiveFooter(opts = {}) {
if (opts.mode === 'short') {
// No command path here: the session's first emission already gave the
// runnable hook-admin.mjs invocation, and restating ~70 chars of absolute
// path on every repeat is the duplication this mode exists to cut.
return 'Triage per the session policy: fix real problems; persist confident false-positive or sanctioned-exception ignores via `hook-admin.mjs ignore-value` and disclose them in your reply; unsure, ask in one line.';
}
return [
'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',
'',
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
'',
`Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable <rule>\` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`${IMPECCABLE_COMMAND} hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`${IMPECCABLE_COMMAND} hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For a finding whose line shows no exact ignore-value command, such as \`side-tab\`, ${fileIgnoreGuidance}; use \`${IMPECCABLE_COMMAND} hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`,
'Triage each finding, then state in your reply what you fixed, what you suppressed, and what you left standing:',
'- Real design problem: fix it. Keep intentional design as designed.',
`- Confident false positive or sanctioned exception (an intentional demo or fixture, documentation of bad design, literal or domain-appropriate motion, a choice the user confirmed): persist the narrowest ignore yourself and disclose it. Run \`${HOOK_ADMIN_COMMAND} ignore-value <rule> "<value>" --reason "<who decided: evidence>"\` with the pair shown on the finding line, or value "*" plus \`--file <path>\` when the line shows none. Write "user confirmed" in a reason only when the user did.`,
'- Unsure: leave it as is and ask the user in one line.',
`Self-serve ends at ignore-value: \`ignore-file\` and \`ignore-rule\` need the user's explicit approval, and never add an ignore to push a blocked write through. Full suppression ladder: ${IMPECCABLE_COMMAND} hooks.`,
].join('\n');
}
@@ -1845,20 +1994,23 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
}
}
// Persist only when the write is earned: fresh findings justify creating
// `.impeccable/` (dedup and suppression need it), deferred findings do
// too (the Stop deep pass needs the touched-file list to surface them),
// and an already-present `.impeccable/` dir marks a project that opted
// in. A non-UI edit, or a clean UI edit in a project with no Impeccable
// footprint, must be a no-op on disk (issues #344, #305).
if (freshGroups.length > 0 || deferredTotal > 0
|| (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) {
persistCache(projectCwd, cache);
}
// The session notice flags mutate the cache, so they must settle before
// the persist that makes them stick across events.
if (freshGroups.length > 0) {
const firstGroup = freshGroups[0];
const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions);
const footerMode = footerModeForSession(cache, sessionId);
const text = appendDesignSystemNoteOnce(
renderGroupedTemplate(freshGroups, config, {
cwd: projectCwd,
footer: footerMode,
reserveChars: designNoteReserve(scanOptions, cache, sessionId),
}),
scanOptions, cache, sessionId, config,
);
commitFooterShown(cache, sessionId, text);
// Fresh findings always earn the cache write, including creating
// `.impeccable/`: dedup, suppression, and the notice flags need it.
persistCache(projectCwd, cache);
const allFindings = freshGroups.flatMap((group) => group.findings);
return {
exitCode: 0,
@@ -1881,6 +2033,33 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
};
}
// Resolve the ack emission before the persist below: appendDesignSystem-
// NoteOnce consumes a session flag, and the flag only sticks when the
// write happens after it. Quiet mode emits nothing, so it consumes
// nothing. The clean arm mirrors the branch order further down: pending
// outranks suppression, suppression outranks clean.
let ack = null;
if (!quietMode && pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) {
ack = {
kind: 'pending',
text: appendDesignSystemNoteOnce(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions, cache, sessionId, config),
};
} else if (!quietMode && !suppressionWinner && cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) {
ack = {
kind: 'clean',
text: appendDesignSystemNoteOnce(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions, cache, sessionId, config),
};
}
// Persist only when the write is earned: deferred findings need the
// touched-file list for the Stop deep pass, and an already-present
// `.impeccable/` dir marks a project that opted in. A non-UI edit, or a
// clean UI edit in a project with no Impeccable footprint, must be a
// no-op on disk (issues #344, #305).
if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) {
persistCache(projectCwd, cache);
}
if (detectorThrewAny && !pendingWinner && !cleanWinner) {
return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
}
@@ -1889,8 +2068,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
}
if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) {
const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions);
if (ack?.kind === 'pending') {
const text = ack.text;
return {
exitCode: 0,
stdout: payload(text, 'PostToolUse', harness),
@@ -1923,8 +2102,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
};
}
if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) {
const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions);
if (ack?.kind === 'clean') {
const text = ack.text;
return {
exitCode: 0,
stdout: payload(text, 'PostToolUse', harness),
@@ -2108,11 +2287,22 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
}
// Fresh findings earn the cache write so the next Stop fire is silent
// unless new issues appear.
persistCache(projectCwd, cache);
// A per-edit fire earlier in this session already consumed the footer
// flag, so the Stop wall of text carries the one-line short footer.
const footerMode = footerModeForSession(cache, sessionId);
const text = appendDesignSystemNoteOnce(
renderGroupedTemplate(freshGroups, config, {
cwd: projectCwd,
footer: footerMode,
reserveChars: designNoteReserve(scanOptions, cache, sessionId),
}),
scanOptions, cache, sessionId, config,
);
commitFooterShown(cache, sessionId, text);
const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions);
// Fresh findings earn the cache write so the next Stop fire is silent
// unless new issues appear; the notice flags ride along.
persistCache(projectCwd, cache);
return {
exitCode: 0,
stdout: payload(text, 'Stop', harness),
@@ -109,6 +109,18 @@ export function validateConceptEntry(concept, { existingForms = new Map(), axes
|| concept.tags.some(tag => typeof tag !== 'string' || !tag.trim())) {
errors.push(`concept ${id} must have exactly three structural tags`);
}
// The slop this world in particular is at risk of. Optional, because 541
// entries predate it and none of them are wrong for lacking it. A world built
// from posters is at risk of shouting and one built from instruments is at
// risk of dead greys; a global detector cannot know which, and the author can.
if (concept?.avoid !== undefined) {
if (!Array.isArray(concept.avoid)
|| concept.avoid.length < 2
|| concept.avoid.length > 3
|| concept.avoid.some(item => typeof item !== 'string' || item.trim().length < 12 || item.trim().length > 160)) {
errors.push(`concept ${id} avoid must be two or three negations of 12160 characters`);
}
}
if (!Array.isArray(concept?.system)
|| concept.system.length !== SYSTEM_PREFIXES.length
|| concept.system.some(rule => typeof rule !== 'string' || rule.trim().length < 12 || rule.trim().length > 180)) {
@@ -206,10 +206,10 @@ function parseIgnoreColor(value) {
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]);
const r = parseColorChannel(parts[0], COLOR_CHANNEL_FORMATS.rgb);
const g = parseColorChannel(parts[1], COLOR_CHANNEL_FORMATS.rgb);
const b = parseColorChannel(parts[2], COLOR_CHANNEL_FORMATS.rgb);
const a = parts[3] === undefined ? 1 : parseColorChannel(parts[3], COLOR_CHANNEL_FORMATS.alpha);
if ([r, g, b, a].some((v) => v === null)) return null;
return { r, g, b, a };
}
@@ -218,10 +218,10 @@ function parseIgnoreColor(value) {
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]);
const h = parseColorChannel(parts[0], COLOR_CHANNEL_FORMATS.hue);
const s = parseColorChannel(parts[1], COLOR_CHANNEL_FORMATS.percent);
const l = parseColorChannel(parts[2], COLOR_CHANNEL_FORMATS.percent);
const a = parts[3] === undefined ? 1 : parseColorChannel(parts[3], COLOR_CHANNEL_FORMATS.alpha);
if ([h, s, l, a].some((v) => v === null)) return null;
return hslToRgb(h, s, l, a);
}
@@ -230,18 +230,13 @@ function parseIgnoreColor(value) {
}
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 };
const expanded = hex.length <= 4
? [...hex].map((digit) => digit.repeat(2)).join('')
: hex;
const [r, g, b, alpha = 255] = expanded
.match(/../g)
.map((channel) => Number.parseInt(channel, 16));
return { r, g, b, a: alpha / 255 };
}
function splitColorArgs(body) {
@@ -259,47 +254,34 @@ function splitColorArgs(body) {
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);
}
const CSS_NUMBER_RE = /^(-?\d*\.?\d+)(%|deg|rad|turn|grad)?$/;
const identity = (value) => value;
const COLOR_CHANNEL_FORMATS = {
rgb: { units: { '': identity, '%': (value) => value * 2.55 }, min: 0, max: 255, round: true },
alpha: { units: { '': identity, '%': (value) => value / 100 }, min: 0, max: 1 },
hue: {
units: {
'': identity,
deg: identity,
rad: (value) => value * (180 / Math.PI),
turn: (value) => value * 360,
grad: (value) => value * 0.9,
},
},
percent: { units: { '%': (value) => value / 100 }, min: 0, max: 1 },
};
function parseAlphaChannel(raw) {
function parseColorChannel(raw, { units, min = -Infinity, max = Infinity, round = false }) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
const match = text.match(CSS_NUMBER_RE);
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;
const convert = units[match[2] || ''];
if (!convert) return null;
const number = Number.parseFloat(match[1]);
if (!Number.isFinite(number)) return null;
const value = convert(number);
if (value < min || value > max) return null;
return round ? Math.round(value) : value;
}
function hslToRgb(hue, saturation, lightness, alpha) {
@@ -13,7 +13,7 @@
* within the first ~300 characters catches non-git projects.
*/
import { execSync } from 'node:child_process';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
@@ -41,7 +41,10 @@ export function isGeneratedFile(filePath, options = {}) {
function isGitIgnored(absPath, cwd) {
try {
execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, {
// argv form, never a shell: this runs on every file the live-mode source
// walk reaches, so a hostile filename embedding $(...) or backticks must
// not be interpretable (issue #476). JSON.stringify is not shell quoting.
execFileSync('git', ['check-ignore', '--quiet', absPath], {
cwd,
stdio: 'ignore',
});
@@ -0,0 +1,26 @@
import { spawn } from 'node:child_process';
export function browserOpenCommand(url, {
platform = process.platform,
comspec = process.env.ComSpec || process.env.COMSPEC || 'cmd.exe',
} = {}) {
if (platform === 'darwin') return { command: 'open', args: [url] };
if (platform === 'win32') return { command: comspec, args: ['/c', 'start', '', url] };
return { command: 'xdg-open', args: [url] };
}
export function openSystemBrowser(url, {
platform = process.platform,
comspec = process.env.ComSpec || process.env.COMSPEC || 'cmd.exe',
spawnImpl = spawn,
} = {}) {
const { command, args } = browserOpenCommand(url, { platform, comspec });
try {
const child = spawnImpl(command, args, { stdio: 'ignore', detached: true });
child.on('error', () => {});
child.unref();
return true;
} catch {
return false;
}
}
@@ -244,7 +244,8 @@ const HOOK_MARKER = /skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs/;
// * bundle-relative: node ".agents/.../hook.mjs"
// * legacy unquoted: node .claude/.../hook.mjs
// * guarded (#399): [ ! -f "PATH" ] || node "PATH" (PATH twice, identical)
// * absolute: node "/Users/.../hook.mjs" (user-level installs)
// * absolute (#476): [ ! -f 'PATH' ] || node 'PATH' (single-quoted since
// the shell-injection fix; older installs double-quote)
// * github portable: node "$(git rev-parse --show-toplevel)/.../hook.mjs"
// A quoted path wins; the guard's two occurrences are identical, so the first
// quoted match is the path. Otherwise fall back to the whitespace/metachar-
@@ -255,6 +256,12 @@ function hookScriptTokenFrom(command) {
if (!HOOK_MARKER.test(str)) return null;
const quoted = str.match(/"([^"]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)"/);
if (quoted) return quoted[1];
// A path containing an apostrophe serializes as '\'' inside single quotes;
// no regex reassembles that, and the bare fallback would misread a fragment
// of it, so return null: the caller never asserts on a path it can't parse.
if (str.includes("'\\''")) return null;
const singleQuoted = str.match(/'([^']*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)'/);
if (singleQuoted) return singleQuoted[1];
const bare = str.match(/([^\s"'|&;()]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)/);
return bare ? bare[1] : null;
}
@@ -97,23 +97,20 @@
return { value: c.value, label: c.label };
});
const LIVE_CHROME_MOUNT_CONTRACT = ['root', 'transport', 'state', 'actions'];
const LIVE_UI_SURFACES = [
{ key: 'global-bottom-bar', ids: [PREFIX + '-global-bar', PREFIX + '-global-bar-brand', PREFIX + '-pick-toggle', PREFIX + '-insert-toggle', PREFIX + '-detect-toggle', PREFIX + '-detect-badge', PREFIX + '-design-toggle', PREFIX + '-page-chat', PREFIX + '-page-chat-input', PREFIX + '-page-chat-voice', PREFIX + '-page-chat-send'] },
{ key: 'pending-copy-edit-dock', ids: [PREFIX + '-pending-dock'] },
{ key: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-selection-pill', PREFIX + '-input', PREFIX + '-configure-voice', PREFIX + '-configure-bar-tooltip'] },
{ key: 'action-picker', ids: [PREFIX + '-picker'] },
{ key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] },
{ key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] },
{ key: 'variant-cycling-row', ids: [PREFIX + '-bar', PREFIX + '-params-panel'] },
{ key: 'variant-params-panel', ids: [PREFIX + '-params-panel'] },
{ key: 'saving-confirmed-rows', ids: [PREFIX + '-bar'] },
{ key: 'insert-mode-chrome', ids: [PREFIX + '-insert-line', PREFIX + '-insert-placeholder', PREFIX + '-placeholder-resize', PREFIX + '-insert-input', PREFIX + '-insert-voice', PREFIX + '-insert-create', PREFIX + '-insert-create-tooltip'] },
{ key: 'annotation-chrome', ids: [PREFIX + '-annot', PREFIX + '-annot-svg', PREFIX + '-annot-pins', PREFIX + '-annot-clear'] },
{ key: 'design-system-panel', ids: [PREFIX + '-design-host'] },
{ key: 'toasts-and-errors', ids: [PREFIX + '-toast', PREFIX + '-mount-error'] },
{ key: 'css-isolation-boundary', ids: [PREFIX + '-root'] },
];
// The Live chrome inventory (which surfaces exist, and the element ids each
// one owns) comes from the canonical source, skill/scripts/live/ui-surfaces.mjs,
// which the /live.js assembler serializes into these globals alongside the
// token/port/vocabulary. This file is served raw and injected as a classic
// script, so it cannot import that module; the private impeccable-site repo
// imports it directly to check its Live UI lab holds a snapshot for every
// surface, which only works while the list has exactly one definition.
// Add a surface in ui-surfaces.mjs, not here.
const LIVE_CHROME_MOUNT_CONTRACT = Array.isArray(window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__)
? window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__
: ['root', 'transport', 'state', 'actions'];
const LIVE_UI_SURFACES = Array.isArray(window.__IMPECCABLE_LIVE_UI_SURFACES__)
? window.__IMPECCABLE_LIVE_UI_SURFACES__
: [];
const LIVE_UI_COMPONENT_IDS = [...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids))];
//
@@ -14,10 +14,12 @@ import path from 'node:path';
import { createRequire } from 'node:module';
const DEFAULT_TIMEOUT_MS = 60_000;
const BATCH_OP_TEXT_LIMIT = 240;
const require = createRequire(import.meta.url);
export function buildCopyEditBatchPrompt(batch, { cwd = process.cwd() } = {}) {
const repairLines = batch?.repair ? [
const compactBatch = compactBatchForPrompt(batch);
const repairLines = compactBatch.repair ? [
'',
'Repair mode:',
'- The previous Apply attempt changed source, but validation failed.',
@@ -28,7 +30,7 @@ export function buildCopyEditBatchPrompt(batch, { cwd = process.cwd() } = {}) {
'- If failures or candidates show edited text is also a lookup key, update coupled count, animation, icon, image, asset, style, or metadata keys in the current source, or fail that entry without partial edits.',
'- Keep failed and notes as arrays.',
'- Return the same canonical JSON shape after repair.',
JSON.stringify(batch.repair, null, 2),
JSON.stringify(compactBatch.repair, null, 2),
] : [];
return [
'You are the Impeccable staged copy-edit batch applier.',
@@ -80,7 +82,7 @@ export function buildCopyEditBatchPrompt(batch, { cwd = process.cwd() } = {}) {
...repairLines,
'',
'Staged copy-edit batch:',
JSON.stringify(compactBatchForPrompt(batch), null, 2),
JSON.stringify(compactBatch, null, 2),
].join('\n');
}
@@ -292,7 +294,7 @@ function readManualEditValidationScript(cwd) {
function compactBatchForPrompt(batch) {
return {
pageUrl: batch?.pageUrl || null,
repair: batch?.repair || undefined,
repair: compactBatchRepair(batch?.repair),
entries: (batch?.entries || []).map((entry) => ({
id: entry.id,
pageUrl: entry.pageUrl,
@@ -300,7 +302,71 @@ function compactBatchForPrompt(batch) {
element: compactContextForBatch(entry.element),
ops: (entry.ops || []).map(compactBatchOp),
})),
candidates: batch?.candidates || [],
candidates: compactBatchCandidates(batch?.candidates),
};
}
function compactBatchRepair(repair) {
if (!repair || typeof repair !== 'object') return undefined;
return {
status: compactBatchString(repair.status),
attempt: normalizeOptionalBatchNumber(repair.attempt),
attempts: normalizeOptionalBatchNumber(repair.attempts),
maxAttempts: normalizeOptionalBatchNumber(repair.maxAttempts),
reason: compactBatchString(repair.reason),
transactionId: compactBatchString(repair.transactionId),
pageUrl: compactBatchString(repair.pageUrl),
failures: compactBatchDiagnostics(repair.failures),
files: compactBatchStringList(repair.files, 20),
};
}
function compactBatchDiagnostics(items, depth = 0) {
if (!Array.isArray(items)) return undefined;
return items.slice(0, 12).map((item) => ({
entryId: compactBatchString(item?.entryId || item?.id),
reason: compactBatchString(item?.reason || item?.kind),
detail: compactBatchString(item?.detail),
message: compactBatchString(item?.message),
file: compactBatchString(item?.file || item?.relativeFile),
line: normalizeOptionalBatchNumber(item?.line),
ref: compactBatchString(item?.ref),
marker: compactBatchString(item?.marker),
files: compactBatchStringList(item?.files, 8),
candidates: depth < 2 ? compactBatchSourceMatches(item?.candidates, 8) : undefined,
failures: depth < 2 ? compactBatchDiagnostics(item?.failures, depth + 1) : undefined,
checks: depth < 2 ? compactBatchDiagnostics(item?.checks, depth + 1) : undefined,
}));
}
function compactBatchCandidates(candidates) {
return (Array.isArray(candidates) ? candidates : [])
.slice(0, 24)
.map((candidate) => ({
entryId: compactBatchString(candidate?.entryId),
ref: compactBatchString(candidate?.ref),
sourceHint: compactBatchSourceMatch(candidate?.sourceHint),
textMatches: compactBatchSourceMatches(candidate?.textMatches, 8),
objectKeyMatches: compactBatchSourceMatches(candidate?.objectKeyMatches, 8),
contextTextMatches: compactBatchSourceMatches(candidate?.contextTextMatches, 8),
locatorMatches: compactBatchSourceMatches(candidate?.locatorMatches, 6),
}));
}
function compactBatchSourceMatches(matches, limit) {
if (!Array.isArray(matches)) return undefined;
return matches.slice(0, limit).map(compactBatchSourceMatch).filter(Boolean);
}
function compactBatchSourceMatch(match) {
if (!match || typeof match !== 'object') return null;
return {
file: compactBatchString(match.relativeFile || match.file),
line: normalizeBatchNumber(match.line),
column: normalizeBatchNumber(match.column),
kind: compactBatchString(match.kind),
reason: compactBatchString(match.reason || match.kind),
status: compactBatchString(match.status),
};
}
@@ -311,25 +377,77 @@ function compactBatchOp(op) {
contextRef: op.contextRef,
tag: op.tag,
elementId: op.elementId,
classes: op.classes,
classes: compactBatchStringList(op.classes, 24),
originalText: op.originalText,
newText: op.newText,
deleted: op.deleted === true || undefined,
sourceHint: op.sourceHint,
sourceHint: normalizeBatchSourceHint(op.sourceHint),
leaf: compactContextForBatch(op.leaf),
nearbyEditableTexts: Array.isArray(op.nearbyEditableTexts) ? op.nearbyEditableTexts.slice(0, 8) : [],
nearbyEditableTexts: compactNearbyBatchTexts(op.nearbyEditableTexts),
container: compactContextForBatch(op.container),
contextHints: Array.isArray(op.contextHints) ? op.contextHints.slice(0, 12) : [],
contextHints: compactBatchStringList(op.contextHints, 12),
};
}
function normalizeBatchSourceHint(hint) {
if (!hint || typeof hint !== 'object') return null;
let line = normalizeBatchNumber(hint.line);
let column = normalizeBatchNumber(hint.column);
if ((line === null || column === null) && typeof hint.loc === 'string') {
const match = hint.loc.match(/^(\d+)(?::(\d+))?/);
if (match) {
line = Number(match[1]);
if (match[2]) column = Number(match[2]);
}
}
return {
file: compactBatchString(hint.file) || '',
loc: compactBatchString(hint.loc) || '',
line,
column,
};
}
function normalizeBatchNumber(value) {
if (value === null || value === undefined || value === '') return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function normalizeOptionalBatchNumber(value) {
const number = normalizeBatchNumber(value);
return number === null ? undefined : number;
}
function compactNearbyBatchTexts(items) {
return (Array.isArray(items) ? items : [])
.slice(0, 8)
.map((item) => typeof item === 'string' ? { text: truncate(item, BATCH_OP_TEXT_LIMIT) } : {
ref: compactBatchString(item?.ref),
tag: compactBatchString(item?.tag),
classes: compactBatchStringList(item?.classes, 24),
text: compactBatchString(item?.text),
});
}
function compactBatchStringList(items, limit) {
return (Array.isArray(items) ? items : [])
.slice(0, limit)
.filter((item) => typeof item === 'string')
.map((item) => truncate(item, BATCH_OP_TEXT_LIMIT));
}
function compactBatchString(value) {
return typeof value === 'string' ? truncate(value, BATCH_OP_TEXT_LIMIT) : undefined;
}
function compactContextForBatch(value) {
if (!value || typeof value !== 'object') return value || null;
return {
ref: value.ref,
tagName: value.tagName,
id: value.id,
classes: value.classes,
ref: compactBatchString(value.ref),
tagName: compactBatchString(value.tagName),
id: compactBatchString(value.id),
classes: compactBatchStringList(value.classes, 24),
textContent: truncate(value.textContent, 900),
outerHTML: truncate(stripLiveRuntimeHtml(value.outerHTML), 1800),
};
@@ -470,12 +588,11 @@ function runClaude(prompt, { cwd, env, resultPath, logPath, timeoutMs = DEFAULT_
if (env.IMPECCABLE_LIVE_COPY_AGENT_MODEL) {
args.push('--model', env.IMPECCABLE_LIVE_COPY_AGENT_MODEL);
}
args.push(prompt);
// Forward env as-is so CLAUDE_CODE_OAUTH_TOKEN and ANTHROPIC_API_KEY flow
// through. On macOS, `claude /login` stores creds in the Keychain, which a
// non-TTY subprocess cannot read; setting CLAUDE_CODE_OAUTH_TOKEN (via
// `claude setup-token`) is the supported headless auth path.
return runAgentProcess('claude', args, '', { cwd, env, logPath, timeoutMs, mirrorOutputPath: resultPath });
return runAgentProcess('claude', args, prompt, { cwd, env, logPath, timeoutMs, mirrorOutputPath: resultPath });
}
function runAgentProcess(command, args, stdin, { cwd, env, logPath, timeoutMs, mirrorOutputPath }) {
+10 -4
View File
@@ -17,7 +17,7 @@
* node live.mjs --help
*/
import { execSync } from 'node:child_process';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -316,11 +316,17 @@ function globToRegex(pattern) {
function runScript(name, args, options = {}) {
const scriptPath = path.join(__dirname, name);
const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
try {
return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 });
// argv form, never a shell: string interpolation into double quotes would
// let a `"` or `$(...)` in any future caller's arg escape into the shell
// (issue #476).
return execFileSync(process.execPath, [scriptPath, ...args], {
encoding: 'utf-8',
cwd: options.cwd || process.cwd(),
timeout: 15_000,
});
} catch (err) {
// execSync throws on non-zero exit; return stdout if any
// execFileSync throws on non-zero exit; return stdout if any
return err.stdout || err.message || '';
}
}
@@ -1,6 +1,8 @@
import fs from 'node:fs';
import path from 'node:path';
import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs';
export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([
Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }),
Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }),
@@ -32,7 +34,20 @@ export function readLiveBrowserScriptParts(parts, readFile = (filePath) => fs.re
}));
}
export function assembleLiveBrowserScript({ token, port, vocabulary, commandPrefix = '/', appRoot = null, parts }) {
export function assembleLiveBrowserScript({
token,
port,
vocabulary,
commandPrefix = '/',
appRoot = null,
parts,
// Defaulted rather than threaded through live-server.mjs: the browser bundle
// must always carry the canonical inventory, and a default makes that true by
// construction instead of by every caller remembering to pass it. Overridable
// so tests can assemble with a stand-in.
uiSurfaces = LIVE_UI_SURFACES,
mountContract = LIVE_CHROME_MOUNT_CONTRACT,
}) {
const prelude =
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
`window.__IMPECCABLE_PORT__ = ${port};\n` +
@@ -44,7 +59,14 @@ export function assembleLiveBrowserScript({ token, port, vocabulary, commandPref
`window.__IMPECCABLE_COMMAND_PREFIX__ = ${JSON.stringify(commandPrefix)};\n` +
// Canonical command vocabulary (values + labels + icons). live-browser.js
// builds its action picker from this instead of an inline copy.
`window.__IMPECCABLE_VOCAB__ = ${JSON.stringify(vocabulary)};\n`;
`window.__IMPECCABLE_VOCAB__ = ${JSON.stringify(vocabulary)};\n` +
// Canonical Live chrome inventory from live/ui-surfaces.mjs. live-browser.js
// is a classic script and cannot import an ES module at runtime, so the list
// is serialized here and read off the global there. Node consumers (this
// repo's tests, the impeccable-site Live UI lab) import the module directly,
// which is what keeps the two from drifting.
`window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` +
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`;
const body = parts.map((part) => {
const file = part.file || path.basename(part.path || '');
@@ -0,0 +1,75 @@
/**
* Canonical inventory of the Live overlay's UI surfaces: one entry per piece of
* chrome Live mounts on the user's page, with the element ids that make it up.
*
* Single source of truth, consumed by:
* - skill/scripts/live/browser-script-parts.mjs serializes this into
* window.__IMPECCABLE_LIVE_UI_SURFACES__ in the /live.js prelude.
* - skill/scripts/live-browser.js publishes it on
* window.__IMPECCABLE_LIVE_CHROME_CORE__ for adapters and E2E probes. That
* file is served raw and injected as a classic <script>, so it cannot
* import this module at runtime; it reads the injected global instead, the
* same path live/vocabulary.mjs already takes for the command palette.
* - the private impeccable-site repo site/components/LiveUiGallery.astro
* and tests/live-ui-lab.test.mjs import LIVE_UI_SURFACES at build time and
* fail the site build when the Live UI lab has no snapshot for a surface
* defined here. That guard only guards if it reads this list rather than a
* copy the site keeps, so this module must stay importable from Node.
* Renaming a key or the module is a breaking change for that build; the
* list was briefly inlined into live-browser.js and the site had to parse
* it back out with a regex.
*
* Add a surface here and both the browser bundle and the site lab follow.
*/
/** Id prefix every Live chrome element carries. Mirrored by PREFIX in live-browser.js. */
export const LIVE_UI_PREFIX = 'impeccable-live';
const id = (suffix) => `${LIVE_UI_PREFIX}-${suffix}`;
/**
* The mount contract every Live chrome adapter (DOM, Svelte, ...) satisfies.
* Published alongside the surfaces on __IMPECCABLE_LIVE_CHROME_CORE__.
*/
export const LIVE_CHROME_MOUNT_CONTRACT = Object.freeze(['root', 'transport', 'state', 'actions']);
export const LIVE_UI_SURFACES = Object.freeze([
{
key: 'global-bottom-bar',
ids: [
id('global-bar'), id('global-bar-brand'), id('pick-toggle'), id('insert-toggle'),
id('detect-toggle'), id('detect-badge'), id('design-toggle'), id('page-chat'),
id('page-chat-input'), id('page-chat-voice'), id('page-chat-send'),
],
},
{ key: 'pending-copy-edit-dock', ids: [id('pending-dock')] },
{
key: 'element-selection-chrome',
ids: [
id('highlight'), id('tooltip'), id('bar'), id('selection-pill'), id('input'),
id('configure-voice'), id('configure-bar-tooltip'),
],
},
{ key: 'action-picker', ids: [id('picker')] },
{ key: 'edit-chrome', ids: [id('edit-badge')] },
{ key: 'generating-row', ids: [id('bar'), id('shader')] },
{ key: 'variant-cycling-row', ids: [id('bar'), id('params-panel')] },
{ key: 'variant-params-panel', ids: [id('params-panel')] },
{ key: 'saving-confirmed-rows', ids: [id('bar')] },
{
key: 'insert-mode-chrome',
ids: [
id('insert-line'), id('insert-placeholder'), id('placeholder-resize'), id('insert-input'),
id('insert-voice'), id('insert-create'), id('insert-create-tooltip'),
],
},
{ key: 'annotation-chrome', ids: [id('annot'), id('annot-svg'), id('annot-pins'), id('annot-clear')] },
{ key: 'design-system-panel', ids: [id('design-host')] },
{ key: 'toasts-and-errors', ids: [id('toast'), id('mount-error')] },
{ key: 'css-isolation-boundary', ids: [id('root')] },
].map((surface) => Object.freeze({ ...surface, ids: Object.freeze(surface.ids) })));
/** Every id any surface owns, de-duplicated, in surface order. */
export const LIVE_UI_COMPONENT_IDS = Object.freeze([
...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids)),
]);
+6 -4
View File
@@ -93,15 +93,17 @@ function commandPrefixForSkillsDir(skillsDir) {
return CODEX_HARNESSES.has(basename(dirname(skillsDir))) ? '$' : '/';
}
function generatePinnedSkill(command, metadata, commandPrefix) {
function generatePinnedSkill(command, metadata, commandPrefix, isCodex) {
const desc = metadata[command]?.description || `Shortcut for ${commandPrefix}impeccable ${command}.`;
const hint = metadata[command]?.argumentHint || '[target]';
const providerFrontmatter = isCodex
? `metadata:\n argument-hint: "${hint}"`
: `argument-hint: "${hint}"\nuser-invocable: true`;
return `---
name: ${command}
description: "${desc}"
argument-hint: "${hint}"
user-invocable: true
${providerFrontmatter}
---
${PIN_MARKER}
@@ -128,7 +130,7 @@ function pin(command, projectRoot) {
for (const skillsDir of harnessDirs) {
const commandPrefix = commandPrefixForSkillsDir(skillsDir);
const content = generatePinnedSkill(command, metadata, commandPrefix);
const content = generatePinnedSkill(command, metadata, commandPrefix, commandPrefix === '$');
// Check if skill already exists (and isn't a pin)
const skillDir = join(skillsDir, command);
if (existsSync(skillDir)) {
@@ -29,25 +29,50 @@
* "materials": ["letterpress", "newsprint"], // optional, rendered as tags
* "viewport": "one line: the first-viewport composition", // optional
* "case": "one line: the fusion verdict, honest", // optional
* "verdict": "competitive", // optional routing tier: "wins" |
* // "competitive" | "declined". Declined cards
* // render demoted after the full cards:
* // narrow, quiet, catalog art as a labeled
* // thumb, "Adopt anyway" instead of "Build
* // this". Still choosable; never deleted.
* "kept": "one line: what the direction kept from this declined world",
* "raised": [ { "from": "challenger-x", "raise": "one line" } ],
* // assigned card only: donations taken from
* // declined challengers, rendered as named
* // raise lines under the identity row
* "risk": "one line: the honest risk", // optional
* "body": "fallback prose when the structured fields are absent",
* "sketch": ".impeccable/sketches/assigned.webp", // optional; may not exist
* // yet: the page shimmer-waits and polls the
* // slot until the file lands, so serve first
* // and generate after
* "sketch": ".impeccable/mocks/decision/assigned.webp", // optional; the card's
* // full-fidelity direction comp (the field
* // keeps the sketch era's wire name). May not
* // exist yet: the page shimmer-waits and
* // polls the slot until the file lands, so
* // serve first and generate after
* "hero": "https://... or /abs/path.webp", // optional inspiration image;
* // rides picture-in-picture when a sketch exists
* "board": "https://... or /abs/path.webp" // optional secondary image
* }, ...
* ],
* "reroll": true, // adds a re-roll action (returns {"optionId":"reroll"})
* // or { "registers": ["safer", "bolder"] } to add
* // the register steers beside it: the answer then
* // carries "register" and the agent re-runs
* // concept-seed with --register <value>
* "canon": true, // adds the "Play it straight" standing exit;
* // direction rounds only (returns {"optionId":"canon"})
* "canonCard": { ... }, // optional: the standing exit as a full card with the
* // same anatomy (label, thesis, palette, sketch, ...);
* // rendered last and visually subordinate. Without it,
* // canon stays a quiet footer action.
* "steer": true // adds a free-text steer field returned with any answer
* "steer": true, // adds a free-text steer field returned with any answer
* "followup": true // this round's pick is not terminal: the server
* // stays open awaiting --update with the next
* // round (detached mode only), the page shows a
* // loading hand instead of goodbye, and the
* // answer carries followup:true so --wait knows
* // to keep the table. Use it when a decision has
* // a known second half, e.g. direction first,
* // then the execution contract.
* }
*
* Options render as large cards: the sketch leads when present, with the
@@ -79,6 +104,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { openSystemBrowser } from './lib/open-system-browser.mjs';
function arg(name, fallback = null) {
const i = process.argv.indexOf(`--${name}`);
@@ -123,11 +149,17 @@ function printAnswer(raw) {
console.log("CHOSEN CARD: open the chosen world's board and hero images now, before any code. When your harness only reads files, or runs sandboxed, download them INTO the workspace and open the relative path; a sandboxed viewer rejects absolute paths outside it. They set the craft bar the build must reach.");
}
if (a.sketch) {
console.log('CHOSEN SKETCH: the decision sketch at that path may seed one comp probe; the comp round still renders its full set, because a sketch chose the direction, not the composition.');
console.log('CHOSEN COMP: the decision comp at that path is compositional option one. On a comp-led build the comp round adds two variations beside it; on a code-led build it returns at the finish review as the critique reference. Never regenerate it from scratch.');
}
if (a.optionId === 'canon') {
console.log('CANON CHOSEN: the user picked the category standard on purpose. Ask once for two or three products this should sit alongside; their craft level becomes the quality bar. Execute the canon at full commitment, conventions embraced without irony or smuggled quirk.');
}
if (a.optionId === 'reroll' && a.register) {
console.log(`REGISTER: the user steered the next hand to the ${a.register} register. Re-run concept-seed with the same key, the next --reroll round, and --register ${a.register}, then follow what it prints; the register is the user's steering, never yours to pre-select.`);
}
if (a.followup && a.optionId !== 'reroll') {
console.log('FOLLOWUP OPEN: the table stays open and the page is showing a loading hand. Deliver the next round now with --update --key <key> --payload <file>, then collect it with --wait; never leave the page waiting on a round you have not sent.');
}
} catch { /* raw answer */ }
}
@@ -143,15 +175,17 @@ if (hasFlag('schema')) {
title: 'Choose the visual world',
question: 'The roll assigned Fillmore Handbill. Keep it, take an alternate, or re-roll.',
options: [
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', thesis: 'The gig poster that treats every release like a one-night stand.', palette: ['#e8452c', '#f5d64c', '#1b2a52', '#f3ead8'], materials: ['letterpress', 'split-fountain ink'], viewport: 'A full-bleed dated bill with the product name in warped display type.', risk: 'Reads nostalgic when the type is set timidly.', sketch: '.impeccable/sketches/assigned.webp', hero: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill.webp' },
{ id: 'challenger-teletext', label: 'Teletext Service', lineage: 'broadcast teletext magazines', thesis: 'The catalog as a broadcast index: pages, not sections.', case: 'Fuses cleanly: releases map to numbered pages.', sketch: '.impeccable/sketches/challenger-teletext.webp', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', thesis: 'The gig poster that treats every release like a one-night stand.', palette: ['#e8452c', '#f5d64c', '#1b2a52', '#f3ead8'], materials: ['letterpress', 'split-fountain ink'], viewport: 'A full-bleed dated bill with the product name in warped display type.', risk: 'Reads nostalgic when the type is set timidly.', raised: [{ from: 'challenger-microfiche', raise: 'The bill now owns its whole viewport as one continuous printed sheet.' }], sketch: '.impeccable/mocks/decision/assigned.webp', hero: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill.webp' },
{ id: 'model-pick', label: 'The Broadside Ballad', kicker: 'MY PICK', lineage: 'street-sold ballad sheets', thesis: 'Every release printed as the days ballad sheet.', palette: ['#1f1c18', '#efe5d0', '#a33327'], materials: ['woodcut', 'rag paper'], viewport: 'One tall sheet, the newest release as todays ballad.', risk: 'Also the direction most runs in this category land on.', sketch: '.impeccable/mocks/decision/model-pick.webp' },
{ id: 'challenger-teletext', label: 'Teletext Service', verdict: 'competitive', lineage: 'broadcast teletext magazines', thesis: 'The catalog as a broadcast index: pages, not sections.', palette: ['#0000c0', '#ffff00', '#00c000', '#ffffff'], materials: ['block mosaic', 'phosphor glow'], viewport: 'P100 index page, releases as numbered rows.', case: 'Fuses cleanly: releases map to numbered pages; loses narrowly on clarity.', risk: 'Reads retro-novelty when the grid is not strict.', sketch: '.impeccable/mocks/decision/challenger-teletext.webp', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
{ id: 'challenger-microfiche', label: 'Microfiche Reader', verdict: 'declined', lineage: 'library microfiche stations', palette: ['#101418', '#9fb4c0'], materials: ['film grain', 'backlit glass'], case: 'Fuses poorly: listeners do not identify with archival retrieval.', kept: 'Total environmental commitment.', hero: 'https://impeccable.style/worlds/cards/archives-microfiche-reader-hero.webp' },
],
reroll: true,
reroll: { registers: ['safer', 'bolder'] },
canon: true,
canonCard: { label: 'The category standard', thesis: 'What this category ships, executed impeccably.', viewport: 'The arrangement a visitor expects, at full craft.', sketch: '.impeccable/sketches/canon.webp' },
canonCard: { label: 'The category standard', thesis: 'What this category ships, executed impeccably.', palette: ['#ffffff', '#111827', '#2563eb'], materials: ['clean grid', 'product photography'], viewport: 'The arrangement a visitor expects, at full craft.', risk: 'Indistinguishable from the competition by design.', sketch: '.impeccable/mocks/decision/canon.webp' },
steer: true,
}, null, 2));
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/sketch accept URLs or local paths; sketch slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the sketch, never as the promise of the build. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. A card with no imagery at all has no back; its full read renders on the front, so a text-only round loses nothing. Sketch aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either.');
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/sketch accept URLs or local paths; sketch slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the sketch, never as the promise of the build. verdict routes rendering: "wins" and "competitive" challengers keep full cards, "declined" ones render demoted after them (narrow, quiet, art as a labeled thumb, "Adopt anyway"), with their kept line on the front; the page reorders declined cards to the end on its own. raised on the assigned card renders each donation as a named raise line. Salience parity: when the assigned card declares no sketch (no image generation this round), catalog art on every card demotes to a labeled thumb, so what looks important is the verdicts call, never rendering luck. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. The pick card is a kicker convention, not a field: kicker "MY PICK" on your top-ranked grounded candidate, one at most, never in the lead slot. Every card gets the full anatomy, challengers, canon, and declined included: thesis, palette, materials, viewport, risk; the seed already hands you each challengers system rules, so a card with no palette chips is an authoring gap, not a data gap. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. A card with no imagery at all has no back; its full read renders on the front, so a text-only round loses nothing. The sketch slot carries the cards full-fidelity direction comp (the field keeps its wire name for compatibility). Comp aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either. reroll accepts true or { "registers": ["safer", "bolder"] }: the register buttons steer the next hand along the familiar-to-bold axis, the answer carries "register", and you re-run concept-seed with --register <value> for the next round; offer the registers on direction rounds, and never pre-select one. followup: true keeps the table open after a pick for a second round via --update (direction first, then the execution contract); send the next payload immediately, the page is waiting on it.');
process.exit(0);
}
@@ -195,12 +229,16 @@ if (hasFlag('wait')) {
if (!answered()) { console.log(`WAITING: no answer yet after ${pollSec}s; run --wait --key ${key} again`); process.exit(3); }
const collected = fs.readFileSync(answerFile(key), 'utf8').trim();
printAnswer(collected);
// A re-roll keeps the table open: the server stays alive awaiting --update,
// so only the answer file is consumed. Terminal choices clean up fully.
let isRerollAnswer = false;
try { isRerollAnswer = JSON.parse(collected).optionId === 'reroll'; } catch { /* treat as terminal */ }
// A re-roll or a followup-round pick keeps the table open: the server stays
// alive awaiting --update, so only the answer file is consumed. Terminal
// choices clean up fully.
let keepsTableOpen = false;
try {
const parsedAnswer = JSON.parse(collected);
keepsTableOpen = parsedAnswer.optionId === 'reroll' || parsedAnswer.followup === true;
} catch { /* treat as terminal */ }
try { fs.rmSync(answerFile(key)); } catch { /* already gone */ }
if (!isRerollAnswer) { try { fs.rmSync(stateFile(key)); } catch { /* already gone */ } }
if (!keepsTableOpen) { try { fs.rmSync(stateFile(key)); } catch { /* already gone */ } }
process.exit(0);
}
@@ -301,11 +339,19 @@ function loadRound(json) {
sketchSrc: sketchSrc(option.sketch),
});
options = parsed.options.map(decorate);
// The verdict routes rendering: full cards first, then the canon, then the
// declined cards dead last in their own payload order. The reorder happens
// here so a payload that interleaves them still renders the weighing's
// shape, and the deck reads as a gradient of standing: contenders, the
// familiar door, then the demoted row.
const declined = options.filter((o) => o.verdict === 'declined');
options = options.filter((o) => o.verdict !== 'declined');
// The standing exit as a full card: same anatomy, reserved id, rendered
// subordinate by the page. Without it, canon stays the quiet footer action.
if (parsed.canonCard && typeof parsed.canonCard === 'object') {
options = [...options, { ...decorate(parsed.canonCard), id: 'canon', isCanon: true }];
}
options = [...options, ...declined];
}
try { loadRound(raw); } catch (error) { console.error(`serve-question: ${error.message}`); process.exit(1); }
const detachedKey = hasFlag('detached-serve') ? arg('key') : null;
@@ -321,7 +367,19 @@ function page() {
// and material tags give a text-only direction an immediate identity that
// no generation luck can distort.
const fact = (label, value, cls = '') => value ? `<p class="fact${cls ? ` ${cls}` : ''}"><span class="fact-label">${label}</span>${esc(value)}</p>` : '';
const hasMedia = (option) => Boolean(option.sketchSrc || option.heroSrc || option.boardSrc);
const demoted = (option) => option.verdict === 'declined';
// Salience parity: a card's imagery weight is capped by the assigned card's.
// When the lead card has no media at all (no image generation this round,
// and no catalog art of its own), full-bleed catalog art beside a text-only
// assigned card would let rendering luck outvote the weighing: users click
// the colorful thing. Declined cards are thumb-only regardless; the verdict
// demoted them, and a full-bleed hero would promote them right back.
const identityRound = !(options[0] && (options[0].sketchSrc || options[0].heroSrc || options[0].boardSrc));
// A declined card never renders a full media face, sketch included: even a
// declared sketch would buy back the salience the verdict took away.
const faceSketch = (option) => demoted(option) ? null : option.sketchSrc;
const thumbOnly = (option) => !faceSketch(option) && Boolean(option.heroSrc || option.boardSrc) && (demoted(option) || identityRound);
const hasMedia = (option) => Boolean(faceSketch(option) || ((option.heroSrc || option.boardSrc) && !thumbOnly(option)));
// The back exists to keep long facts off a card whose front is an image;
// a card with no art has no flip chip to reach it, so it gets no back and
// the full read lives on the front instead.
@@ -337,6 +395,30 @@ function page() {
idBits.push(option.materials.slice(0, 4).map((m) => `<span class="tag">${esc(m)}</span>`).join(''));
}
if (idBits.length) rows.push(`<div class="identity">${idBits.join('')}</div>`);
// Donations from declined challengers render as named raise lines: the
// assigned card arrives already raised by the hand it beat, and the raise
// is readable, because a raise nobody can read did not happen. One raise
// renders inline; several become a compact cycler (click advances), so a
// generous hand cannot blow the card out of proportion.
if (Array.isArray(option.raised) && option.raised.length) {
const nameOf = (id) => options.find((o) => o.id === id)?.label || String(id ?? '');
const raiseLines = option.raised.slice(0, 6).map((r) => `<p class="raise"><span class="fact-label">Raised by ${esc(nameOf(r.from))}</span>${esc(r.raise || r.kept || '')}</p>`);
if (raiseLines.length > 1) {
rows.push(`<div class="raises raises-cycle" role="button" tabindex="0" title="Click or press Enter to see the next raise" aria-label="Raised by the hand; activate to see the next raise">
<div class="raises-head"><span class="fact-label">Raised by the hand</span><span class="raises-count" data-raises-count>1/${raiseLines.length}</span></div>
${raiseLines.join('')}
<span class="sr-live" aria-live="polite"></span>
</div>`);
} else {
rows.push(`<div class="raises">${raiseLines[0]}</div>`);
}
}
// Demoted art stays reachable as a labeled thumb: the catalog world
// explains where the direction comes from without buying it back the
// salience the verdict took away.
if (thumbOnly(option)) {
rows.push(`<figure class="inspo" title="Inspiration: the world this direction draws from. Your page will not look like this image."><img src="${esc(option.heroSrc || option.boardSrc)}" alt=""><figcaption>inspired by</figcaption></figure>`);
}
// The front carries only what the choice needs: thesis, identity, and the
// honest risk clamped to two lines. First viewport and the case read on
// the card's back; once the sketch lands, the first viewport is a picture.
@@ -347,6 +429,7 @@ function page() {
} else {
rows.push(fact('First viewport', option.viewport));
rows.push(fact('The case', option.case));
rows.push(fact('Kept', option.kept));
rows.push(fact('Risk', option.risk));
}
if (!option.thesis && option.body) rows.push(`<p class="detail">${esc(option.body)}</p>`);
@@ -356,6 +439,7 @@ function page() {
const backFacts = (option) => [
fact('First viewport', option.viewport),
fact('The case', option.case),
fact('Kept', option.kept),
fact('Risk', option.risk),
option.body && option.thesis ? `<p class="detail more">${esc(option.body)}</p>` : '',
].filter(Boolean).join('\n ');
@@ -365,9 +449,12 @@ function page() {
<figcaption>inspiration</figcaption>
</figure>` : '';
const details = hasBack(option) ? flipChip('Details') : '';
if (option.sketchSrc) {
// Thumb-only art renders inside the body via anatomy(), never as a face,
// and a declined card's sketch slot is ignored outright.
if (thumbOnly(option)) return '';
if (faceSketch(option)) {
return `<div class="media sketching" data-sketch="${esc(option.sketchSrc)}">
<div class="shimmer"><span class="sketch-note">sketching&hellip;</span></div>
<div class="shimmer"><span class="sketch-note">rendering&hellip;</span></div>
<img class="sketch" alt="" hidden>
${inspiration}
<div class="chips">${expandChip}${details}</div>
@@ -384,17 +471,18 @@ function page() {
}
return '';
};
const chooseLabel = (option) => option.isCanon ? 'Play it straight' : demoted(option) ? 'Adopt anyway' : 'Build this';
const cards = options.map((option, index) => `
<article class="card${option.isCanon ? ' canon' : ''}" style="--fan:${index === 0 ? '0deg' : (index % 2 ? '1.4deg' : '-1.2deg')};--deal:${index * 90}ms" data-id="${esc(option.id)}">
<article class="card${option.isCanon ? ' canon' : ''}${demoted(option) ? ' declined' : ''}" style="--fan:${index === 0 ? '0deg' : (index % 2 ? '1.4deg' : '-1.2deg')};--deal:${index * 90}ms" data-id="${esc(option.id)}">
<div class="card-inner">
<div class="face front${index === 0 ? ' lead' : ''}${media(option) ? '' : ' text-only'}">
${option.kicker ? `<span class="kicker">${esc(option.kicker)}</span>` : option.isCanon ? '<span class="kicker standing">The standing door</span>' : ''}
${option.kicker ? `<span class="kicker">${esc(option.kicker)}</span>` : demoted(option) ? '<span class="kicker declined-k">Declined</span>' : option.isCanon ? '<span class="kicker standing">The standing door</span>' : ''}
${media(option)}
<div class="body">
${option.lineage ? `<p class="tier">${esc(option.lineage)}</p>` : ''}
<h2>${esc(option.label)}</h2>
${anatomy(option)}
<button class="choose" data-id="${esc(option.id)}">${option.isCanon ? 'Play it straight' : 'Build this'}</button>
<button class="choose" data-id="${esc(option.id)}">${chooseLabel(option)}</button>
</div>
</div>
${hasBack(option) ? `<div class="face back${index === 0 ? ' lead' : ''}">
@@ -405,7 +493,7 @@ function page() {
<div class="body back-body">
${option.boardSrc ? `<p class="tier">The full read &middot; ${esc(option.label)}</p>` : ''}
${backFacts(option)}
<button class="choose" data-id="${esc(option.id)}">${option.isCanon ? 'Play it straight' : 'Build this'}</button>
<button class="choose" data-id="${esc(option.id)}">${chooseLabel(option)}</button>
</div>
</div>` : ''}
</div>
@@ -496,6 +584,10 @@ function page() {
.nav.next { right: auto; left: 50%; top: auto; bottom: 6px; transform: translate(-50%, 0); }
.fade-prev { top: 0; left: 0; right: 0; bottom: auto; width: auto; height: 72px; background: linear-gradient(180deg, var(--ks-lacquer), transparent); }
.fade-next { top: auto; left: 0; right: 0; bottom: 0; width: auto; height: 72px; background: linear-gradient(0deg, var(--ks-lacquer), transparent); }
/* In the vertical deck the cross axis is horizontal: flex-start would
shrink a declined card to content WIDTH, not height, so it stretches
like every other card and its height is already its own. */
.grid > .card.declined { align-self: stretch; }
}
.card { position: relative; perspective: 1400px; transform: rotate(var(--fan, 0deg)); transition: transform .25s cubic-bezier(.16, 1, .3, 1); }
.card:hover { transform: rotate(0deg) translateY(-4px); }
@@ -584,6 +676,42 @@ function page() {
/* The generic .media img display:block would defeat [hidden] and float an
empty block over the shimmer; an unloaded sketch must truly not render. */
.media img[hidden] { display: none; }
/* Declined challengers: the weighing demoted them, so the card is narrower
and quieter, its catalog art rides as a labeled thumb in the body, and
the action reads "Adopt anyway". Adoptable, never deleted: the demoted
row is the hand's proof of judgment. */
/* Narrow AND short: without align-self the stretch default drags a thin
declined card to the tallest contender's height, a strange stilt of a
card beside the full hand. */
.grid > .card.declined { flex: 0 0 clamp(15rem, 21vw, 21rem); align-self: flex-start; }
.card.declined .face { background: var(--ks-graphite); }
.card.declined:hover .face { border-color: var(--ks-text-faint); }
.card.declined h2 { font-size: 1rem; color: var(--ks-text); }
.kicker.declined-k { background: transparent; border: 1px solid var(--ks-rule); color: var(--ks-text-faint); }
.card.declined button.choose { background: transparent; color: var(--ks-text-muted); border: 1px solid var(--ks-rule); font-size: .85rem; padding: 8px 22px; }
.card.declined button.choose:hover { background: var(--ks-graphite-2); border-color: var(--ks-text-muted); }
/* Thumb-scale inspiration: present, labeled, zoomable, and incapable of
outshouting a text-only assigned card. */
.inspo { position: relative; flex: none; margin: 2px 0; width: 104px; height: 64px; border: 1px solid var(--ks-rule); border-radius: 6px; overflow: hidden; cursor: zoom-in; background: var(--ks-lacquer); }
.inspo img { display: block; width: 100%; height: 100%; object-fit: cover; }
.inspo figcaption { position: absolute; left: 0; right: 0; bottom: 0; font-family: var(--ks-mono); font-size: .48rem; letter-spacing: .16em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 2px 0 3px; background: oklch(7% 0.006 95 / 0.72); }
/* Raises: the donations the assigned direction took from the hand it beat,
each named for its donor. Patina, not kinpaku: a raise is provenance. */
.raises { display: flex; flex-direction: column; gap: 4px; margin: 2px 0; }
.raise { font-size: .78rem; color: var(--ks-text-muted); line-height: 1.45; border-left: 2px solid var(--ks-patina); padding-left: 8px; }
.raise .fact-label { color: var(--ks-patina); }
/* Several raises cycle instead of stacking: one visible at a time, a
counter for the rest, the whole block advances on click. */
.raises-cycle { cursor: pointer; border-radius: 6px; }
.raises-cycle .raise { display: none; border-left: none; padding-left: 0; }
.raises-cycle .raise.active { display: block; }
.raises-cycle { border-left: 2px solid var(--ks-patina); padding-left: 8px; }
.raises-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.raises-head .fact-label { color: var(--ks-patina); }
.raises-count { font-family: var(--ks-mono); font-size: .58rem; letter-spacing: .14em; color: var(--ks-text-faint); }
.raises-count::after { content: " \\203A"; }
.raises-cycle:hover .raises-count { color: var(--ks-patina); }
.sr-live { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
/* The standing exit as a card: present with full anatomy, never dressed as a
contender. Graphite instead of kinpaku, and it never takes the lead ring. */
.card.canon .face { border-color: var(--ks-rule); background: var(--ks-graphite); }
@@ -596,9 +724,14 @@ function page() {
footer { width: 100%; max-width: 90rem; margin: 1.6rem auto 0; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
#steer { flex: 1; min-width: 16rem; background: var(--ks-lacquer-raised); color: var(--ks-text); border: 1px solid var(--ks-rule); border-radius: 7px; padding: .6rem .85rem; font: inherit; }
#steer:focus { outline: none; border-color: var(--ks-patina); }
#reroll { display: inline-flex; align-items: center; align-self: stretch; gap: 8px; padding: 0 16px; font-family: var(--ks-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--ks-kinpaku); background: transparent; border: 1px solid var(--ks-rule); border-radius: 6px; cursor: pointer; transition: border-color .2s ease, color .2s ease; }
#reroll:hover { color: var(--ks-kinpaku-pale); border-color: var(--ks-kinpaku-deep); }
#reroll svg { width: 15px; height: 15px; }
.reroll-btn { display: inline-flex; align-items: center; align-self: stretch; gap: 8px; padding: 0 16px; font-family: var(--ks-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--ks-kinpaku); background: transparent; border: 1px solid var(--ks-rule); border-radius: 6px; cursor: pointer; transition: border-color .2s ease, color .2s ease; }
.reroll-btn:hover { color: var(--ks-kinpaku-pale); border-color: var(--ks-kinpaku-deep); }
.reroll-btn svg { width: 15px; height: 15px; }
.reroll-btn[disabled] { opacity: .4; cursor: default; }
/* The register steers read quieter than the plain roll: they are exits from
the current register, not the round's main verbs. */
#reroll-safer, #reroll-bolder { color: var(--ks-text-muted); min-height: 38px; }
#reroll-safer:hover, #reroll-bolder:hover { color: var(--ks-text); border-color: var(--ks-text-faint); }
/* The quiet exit: always available, never argued with, visually subordinate
to the dealt cards and the re-roll so it reads as the user's own door,
not a recommendation. */
@@ -643,16 +776,33 @@ function page() {
</main>
<footer>
${payload.steer ? '<input id="steer" placeholder="Optional steer: what should be different or kept?">' : ''}
${payload.reroll ? '<button id="reroll"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="4" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="8.4" cy="8.4" r="1.5" fill="currentColor"/><circle cx="15.6" cy="8.4" r="1.5" fill="currentColor"/><circle cx="8.4" cy="15.6" r="1.5" fill="currentColor"/><circle cx="15.6" cy="15.6" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg><span>Re-roll</span></button>' : ''}
${(() => {
if (!payload.reroll) return '';
const die = '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="4" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="8.4" cy="8.4" r="1.5" fill="currentColor"/><circle cx="15.6" cy="8.4" r="1.5" fill="currentColor"/><circle cx="8.4" cy="15.6" r="1.5" fill="currentColor"/><circle cx="15.6" cy="15.6" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>';
const registers = Array.isArray(payload.reroll.registers) ? payload.reroll.registers.filter((r) => r === 'safer' || r === 'bolder') : [];
// The registers are the user's steering wheel on the familiar-to-bold
// axis; the plain re-roll sits between them so the spatial order matches
// the axis it names.
const safer = registers.includes('safer') ? '<button class="reroll-btn" id="reroll-safer" title="Deal the familiar register: conventional grounded directions plus the category standard against named competitors"><span>&larr; Safer hand</span></button>' : '';
const bolder = registers.includes('bolder') ? '<button class="reroll-btn" id="reroll-bolder" title="Deal foreign forms only, at full commitment"><span>Bolder hand &rarr;</span></button>' : '';
return `${safer}<button class="reroll-btn" id="reroll">${die}<span>Re-roll</span></button>${bolder}`;
})()}
${payload.canon && !payload.canonCard ? '<button id="canon" title="Skip the roll: build the page this category ships, executed impeccably">Play it straight</button>' : ''}
</footer>
<script>
const steer = () => document.getElementById('steer')?.value || '';
// A followup round's pick keeps the tab: the next round arrives via
// --update, so the page shows the loading hand instead of goodbye. Detached
// mode only, and the page must agree with the server: a blocking server
// exits on any pick and has no update channel, so a followup payload there
// still gets the goodbye screen, never a loading hand nothing will resolve.
const FOLLOWUP = ${payload.followup === true && Boolean(detachedKey) ? 'true' : 'false'};
const beat = () => { try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
beat();
setInterval(beat, 5000);
async function answer(optionId) {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
if (FOLLOWUP) { await awaitNextRound(); return; }
document.body.innerHTML = '<div class="done"><svg viewBox="0 0 24 24" width="38" height="38" fill="oklch(84% 0.19 80.46)" aria-hidden="true"><path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/><path d="M16.5 2.5 L19 2.5 Q21.5 2.5 21.5 5 L21.5 19 Q21.5 21.5 19 21.5 L8.5 21.5 Z"/></svg>Choice recorded. The agent is resuming; you can close this tab.</div>';
}
document.querySelectorAll('button.choose').forEach(b => b.addEventListener('click', () => answer(b.dataset.id)));
@@ -661,6 +811,25 @@ function page() {
b.closest('.card').classList.toggle('flipped');
}));
// Raise cycler: click (or Enter) advances to the next donation.
document.querySelectorAll('.raises-cycle').forEach(cycle => {
const raises = [...cycle.querySelectorAll('.raise')];
const count = cycle.querySelector('[data-raises-count]');
let at = 0;
const live = cycle.querySelector('.sr-live');
const show = (announce) => {
raises.forEach((raise, i) => raise.classList.toggle('active', i === at));
if (count) count.textContent = (at + 1) + '/' + raises.length;
// Screen readers hear the raise they just advanced to; the initial
// render stays quiet so page load does not narrate every card.
if (announce && live) live.textContent = 'Raise ' + (at + 1) + ' of ' + raises.length + ': ' + (raises[at]?.textContent || '');
};
show(false);
const advance = (e) => { e.stopPropagation(); at = (at + 1) % raises.length; show(true); };
cycle.addEventListener('click', advance);
cycle.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); advance(e); } });
});
// Deal from the stack: cards begin piled at the grid's center, blurred,
// then travel to their seats with a stagger.
const cards = [...document.querySelectorAll('.card')];
@@ -708,7 +877,7 @@ function page() {
const note = m.querySelector('.sketch-note');
const started = Date.now();
// A live elapsed count is the difference between "working" and "frozen".
const tick = setInterval(() => { if (note) note.textContent = 'sketching · ' + Math.round((Date.now() - started) / 1000) + 's'; }, 1000);
const tick = setInterval(() => { if (note) note.textContent = 'rendering · ' + Math.round((Date.now() - started) / 1000) + 's'; }, 1000);
const settle = () => { clearInterval(tick); m.classList.remove('sketching', 'stand-in'); m.querySelector('.shimmer')?.remove(); m.querySelector('.stand-in-label')?.remove(); };
const standIn = () => {
const pip = m.querySelector('.pip img');
@@ -719,7 +888,7 @@ function page() {
clearInterval(tick);
const label = document.createElement('p');
label.className = 'stand-in-label';
label.textContent = 'inspiration · sketch pending';
label.textContent = 'inspiration · comp pending';
m.appendChild(label);
};
const tryLoad = () => {
@@ -758,15 +927,15 @@ function page() {
if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) artFailed(img);
else img.addEventListener('error', () => artFailed(img), { once: true });
});
// A broken inspiration PIP just leaves; nothing depends on it.
document.querySelectorAll('.pip img').forEach(img => {
const gone = () => img.closest('.pip')?.remove();
// A broken inspiration PIP or thumb just leaves; nothing depends on it.
document.querySelectorAll('.pip img, .inspo img').forEach(img => {
const gone = () => img.closest('.pip, .inspo')?.remove();
if (img.complete && img.naturalWidth === 0) gone();
else img.addEventListener('error', gone, { once: true });
});
// Inspiration PIP opens the full catalog card in the lightbox.
document.querySelectorAll('.pip').forEach(p => p.addEventListener('click', (e) => {
// Inspiration PIP or body thumb opens the full catalog card in the lightbox.
document.querySelectorAll('.pip, .inspo').forEach(p => p.addEventListener('click', (e) => {
e.stopPropagation();
const img = p.querySelector('img');
if (!img) return;
@@ -813,7 +982,7 @@ function page() {
const ambient = document.getElementById('ambient');
document.querySelectorAll('.card').forEach(card => {
card.addEventListener('mouseenter', () => {
const art = card.querySelector('.face.front .media img:not([hidden])') || card.querySelector('.face.front .pip img');
const art = card.querySelector('.face.front .media img:not([hidden])') || card.querySelector('.face.front .pip img') || card.querySelector('.face.front .inspo img');
if (!art || !art.getAttribute('src')) return;
ambient.style.backgroundImage = 'url("' + art.getAttribute('src') + '")'; ambient.style.opacity = '1';
});
@@ -861,8 +1030,11 @@ function page() {
lightbox.addEventListener('click', closeLightbox);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !lightbox.hidden) closeLightbox(); });
document.getElementById('canon')?.addEventListener('click', () => answer('canon'));
document.getElementById('reroll')?.addEventListener('click', async () => {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer() }) });
const dealAgain = async (register) => {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
await awaitNextRound();
};
async function awaitNextRound() {
const grid = document.querySelector('.grid');
const cardsNow = [...grid.querySelectorAll('.card')];
const g = grid.getBoundingClientRect();
@@ -879,14 +1051,17 @@ function page() {
}
const cardHeight = cardsNow[0] ? cardsNow[0].getBoundingClientRect().height : 0;
grid.innerHTML = cardsNow.map(() => '<article class="card skeleton"' + (cardHeight ? ' style="height:' + cardHeight + 'px"' : '') + '><div class="card-inner"><div class="face front"><div class="media"><div class="shimmer"></div></div><div class="body"><div class="line tier w40"></div><div class="line title w70"></div><div class="line w90"></div><div class="line w80"></div><div class="line w60"></div><div class="line button"></div></div></div></div></article>').join('');
document.getElementById('reroll')?.setAttribute('disabled', '');
document.querySelectorAll('.reroll-btn').forEach(b => b.setAttribute('disabled', ''));
const poll = setInterval(async () => {
try {
const status = await (await fetch('/next-status')).json();
if (status.ready) { clearInterval(poll); location.reload(); }
} catch { /* server briefly busy */ }
}, 1200);
});
}
document.getElementById('reroll')?.addEventListener('click', () => dealAgain());
document.getElementById('reroll-safer')?.addEventListener('click', () => dealAgain('safer'));
document.getElementById('reroll-bolder')?.addEventListener('click', () => dealAgain('bolder'));
</script>`;
}
@@ -943,22 +1118,29 @@ const server = http.createServer((req, res) => {
let parsed = {};
try { parsed = JSON.parse(body); } catch { /* empty steer */ }
const chosen = options.find((o) => o.id === parsed.optionId);
const isReroll = parsed.optionId === 'reroll';
// A followup round's pick is not terminal: the table stays open for the
// next round (--update), exactly like a re-roll. Detached mode only;
// the blocking mode has no update channel, so its picks stay terminal.
const followupOpen = Boolean(detachedKey) && payload.followup === true && !isReroll;
const answer = JSON.stringify({
optionId: parsed.optionId ?? null,
steer: parsed.steer ?? '',
...(isReroll && (parsed.register === 'safer' || parsed.register === 'bolder') ? { register: parsed.register } : {}),
...(followupOpen ? { followup: true } : {}),
...(chosen?.hero || chosen?.board ? { hero: chosen.hero ?? null, board: chosen.board ?? null } : {}),
...(chosen?.sketch ? { sketch: chosen.sketch } : {}),
});
const isReroll = parsed.optionId === 'reroll';
if (detachedKey) {
fs.mkdirSync(QUESTION_DIR, { recursive: true });
fs.writeFileSync(answerFile(detachedKey), answer + '\n');
} else {
printAnswer(answer);
}
// A re-roll in detached mode keeps the table open: the client shows a
// loading hand and reloads when --update delivers the next round.
if (!(isReroll && detachedKey)) setTimeout(() => process.exit(0), 150);
// A re-roll or followup pick in detached mode keeps the table open: the
// client shows a loading hand and reloads when --update delivers the
// next round.
if (!((isReroll || followupOpen) && detachedKey)) setTimeout(() => process.exit(0), 150);
});
return;
}
@@ -976,8 +1158,7 @@ server.listen(portArg, '127.0.0.1', () => {
console.log('Waiting for the user to choose in the browser (Ctrl-C aborts)...');
}
if (!hasFlag('no-open')) {
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
try { spawn(opener, [url], { stdio: 'ignore', detached: true }).unref(); } catch { /* URL printed anyway */ }
openSystemBrowser(url);
}
if (timeoutSec > 0) {
setTimeout(() => {
+2 -2
View File
@@ -16,9 +16,9 @@ Your job is production cleanup, not new art direction. Work only from the approv
Do not redesign. Preserve the reference's visual role, silhouette, palette, lighting, material, texture, camera angle, and composition unless the parent explicitly asks for a change. Preserve perspective only when it belongs to the object or scene itself; if CSS should create the card transform, shadow, rounded clipping, border, or layout, remove that presentation chrome from the raster.
## Decision Sketches
## Decision Comps
When the parent hands you a decision card packet instead of an approved mock, the job is one sketch: one card, one file, written to the card's declared `sketch` path the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a sketch is reported back, not padded from imagination. Render through the parent's shared frame, including its aspect: the requested surface's first viewport as a flat, matte design sketch in the card's own palette and type character, deliberately unfinished, no photorealism, no gloss; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. The frame is shared across siblings so no sketch looks more finished than another; a finish gap breaks the comparison. The only legible text is the product's real name and one real headline; greek every other text region into indistinct lines, because an invented spec, price, or date in a sketch is a claim PRODUCT.md never made. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a sketch run.
When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `sketch` path (the field keeps its wire name) the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a comp is reported back, not padded from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (its regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment is what keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
## Input Contract
+4 -4
View File
@@ -16,12 +16,12 @@ A hard turn ceiling ends the run without warning; a run that ends before the fiv
## Input Contract
Expect: the original request; the confirmed user answers; the artifact path(s); desktop and mobile screenshot paths captured by the parent; the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths and the approved comp path; and the skill's `reference/craft-floor.md` path. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped.
Expect: the original request; the confirmed user answers; the artifact path(s); the screenshots the parent captured, which live in `.impeccable/review/` (on the web, `desktop.png` and `mobile.png`; on native, device-class names such as `phone.png` and `tablet.png`, suffixed per OS on adaptive); a screenshot path the calling brief names is authoritative when the file exists, and `.impeccable/review/` is where to look when the brief names none or a named path is missing, never a filename you invent; the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths and, on a comp-led build, the approved comp path (a code-led build has no approved comp; it passes the chosen decision comp as a separate critique-reference input, labeled as such, and nothing in this file that binds “the approved comp” binds it); and the skill's `reference/craft-floor.md` path. On a native (`ios` / `android` / `adaptive`) build the packet also carries the platform reference path(s) (`reference/ios.md` / `reference/android.md`) and a line saying no detector ran: read the platform reference alongside the craft floor and judge every check in the platform's own conventions, the screenshots are device captures rather than browser viewports, and your floor check is the build's only slop gate. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped.
## Checks, in order
1. **Persistence.** PRODUCT.md exists. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comps with no recorded pick mean the approval point was skipped, and that is a material finding.
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element, and its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement, because medium is part of the promise. When no approved comp was supplied, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality, CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. In every material_fixes list, a fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
1. **Persistence.** PRODUCT.md exists. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comp-round comps with no recorded pick mean the approval point was skipped, and that is a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and they imply no approval whatever the build path; a code-led build has no comp round at all.
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element, and its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement, because medium is part of the promise. When no approved comp was supplied, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality, CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. A critique-reference comp, when one arrived on such a build, is provocation rather than spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is the question of what the image dared that the build did not, and the dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. In every material_fixes list, a fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition.
4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped and that is a material fix ahead of any craft point. Then, for each of the five blocks, does the render keep the promise? Apply the memory test to the first viewport.
5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every image-native region of the approved comp shipped as a real asset, not a gradient standing in for one, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind other paint is a compliance token, not a shipped material.
@@ -39,4 +39,4 @@ Return the disposition line first, then exactly five sections: `persistence` (pa
## Verdict Pass
When the parent returns with post-fix recaptures, you are scoring, not re-hunting. The parent's narration of what was fixed is not evidence; a claimed fix you cannot see in the recaptures is unresolved. For each material fix from your review, one line: resolved, partial, or unresolved, tied to what the new screenshots visibly show; a fix answered mechanically, positions moved but the quality the finding named still absent, is partial at best. Then name at most three regressions the fix batch itself introduced, judged by the same matrix rules, and nothing else; no new hunt, no new checks. Return exactly two sections: `verdict` (the scored list) and `remaining` (what stays open, or "clear"), and end with the disposition line recomputed against what remains open; unresolved or partial material findings can never recompute to ship.
When the parent returns with post-fix recaptures, you are scoring, not re-hunting. The parent recaptures over the same screenshot files you read in the review round, so re-read those exact paths for this round; a round-stamped filename you invent points at nothing. The parent's narration of what was fixed is not evidence; a claimed fix you cannot see in the recaptures is unresolved. For each material fix from your review, one line: resolved, partial, or unresolved, tied to what the new screenshots visibly show; a fix answered mechanically, positions moved but the quality the finding named still absent, is partial at best. Then name at most three regressions the fix batch itself introduced, judged by the same matrix rules, and nothing else; no new hunt, no new checks. Return exactly two sections: `verdict` (the scored list) and `remaining` (what stays open, or "clear"), and end with the disposition line recomputed against what remains open; unresolved or partial material findings can never recompute to ship.
+2 -2
View File
@@ -15,11 +15,11 @@ This skill gives you the tools and permission to create design that earns to be
Core principles:
- Go all out. No hedging, no shortcuts. The deliverable must be complete (except assets the user must provide).
- Dream big and bold. Distinct, beautiful, outstanding and highly inspiring work.
- Verify in bounded passes, not a loop, and the ceiling covers the whole cycle: screenshots, defect scans, micro-edits, and rebuilds alike. Build fully, inspect once with a batched round (desktop and mobile together), fix everything it shows in one batch, confirm with at most one more round, and stop polishing. Open-ended self-QA burns the user's money doing worse what the finish handoffs do better.
- Verify in bounded passes, not a loop, and the ceiling covers the whole cycle: screenshots, defect scans, micro-edits, and rebuilds alike. Build fully, inspect once with a batched round (desktop and mobile together on the web; the shipped device classes on a native platform), fix everything it shows in one batch, confirm with at most one more round, and stop polishing. Open-ended self-QA burns the user's money doing worse what the finish handoffs do better.
## Setup
1. Run `node .claude/skills/impeccable/scripts/context.mjs` once per session (if the runtime shows this skill's loaded base directory, run `node <skill-base-dir>/scripts/context.mjs`; keep cwd at the user's project). Pass a named source file or route as `--target <path>`. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it.
1. Run `node <skill-base-dir>/scripts/context.mjs` once per session, where `<skill-base-dir>` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .claude/skills/impeccable/scripts/...` command in this skill and its references, and `.claude/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target <path>`. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it.
2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing.
3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work.
@@ -38,3 +38,9 @@ Would a fluent Android user trust this app, or trip on off-spec components? The
- **One FAB, one primary action.** Never stack FABs or spend one on a secondary task.
- **Snackbars for transient feedback** (actionable when useful, never a toast for that); dialogs only for decisions that must interrupt.
- **Material motion patterns.** Container transform, shared-axis, fade-through, with standard easing and durations; honor the system Remove animations setting with a crossfade or instant cut.
## Verifying the build
- **Screenshots come from the emulator or a connected device, never a browser.** Build and install, then capture with `adb exec-out screencap -p > <path>` (pick a device with `adb -s <serial>` when several are attached). Capture every device class the app ships to, at least one phone and, when tablets are a target, one tablet, and write the files where the review flow expects them.
- **Dark theme and font scale belong in the pass.** `adb shell cmd uimode night yes` flips the theme; `adb shell settings put system font_scale 1.3` (restore `1.0` after) catches the clipped labels a fixed layout hides; with several targets attached, the capture's `-s <serial>` goes on these commands too.
- **Emulators give breadth; gestures, refresh rates, and performance need hardware.** Say which one produced the evidence.
@@ -74,12 +74,15 @@ Keep content visible in the default state so failed scripts do not hide the page
Respect autoplay and sound preferences. Any nonessential loop must stop when offscreen or hidden.
Every web animation needs a `prefers-reduced-motion` path with an intentional alternative. Remove or reduce spatial movement while preserving opacity, color, and state transitions that carry meaning. Reduced motion means fewer and gentler animations, not disabling all motion; feedback that confirms an action should remain legible.
## Verify
- The focal motion is specific to the selected world and surface.
- Every supporting animation explains feedback, state, or relationship.
- Interruption and repeated use behave correctly.
- Desktop, mobile, and keyboard paths remain usable.
- The `prefers-reduced-motion` path reduces movement without erasing meaningful feedback or state changes.
- Expensive effects stay smooth on the target device.
- Removing an animation would lose meaning or authored character, not merely decoration.
@@ -1,5 +1,7 @@
> **Additional context needed**: which section is the target, and what must stay untouched.
An open direction round owns the word first: "bolder" said while a direction decision is on the table is the Bolder hand register steer, a fresh deal of foreign forms (see new-work.md), not this command. This command refines a surface whose world already shipped.
"Bolder" is an amplification request, and almost always it is scoped to something that already exists. The surrounding page, its system, and its conventions are the given. Your job is to raise one part to the conviction the rest already implies, without rebuilding anything the brief did not name. The reflex answer, reaching for more effects, is the opposite of bold; reject it first.
## Scope is sovereign
@@ -12,6 +12,7 @@ Each of these is a check on the built result, not an intention. Run them togethe
- **Type:** body measure 6575ch, display max 6rem, tracking floor -0.04em, balanced headings, obvious scale and weight steps. Run the real copy at every breakpoint and fix what overflows.
- **Motion:** one authored moment, not scattered effects and not one identical entrance on every section. Exponential ease-out from an already-visible default. Reach past transform and opacity: blur, backdrop-filter, clip-path, mask, and shadow belong to the palette when they stay smooth.
- **States:** hover, disabled, loading, error, empty. Plus real content, working controls, responsive composition, keyboard focus.
- **Browser surfaces:** the parts you did not draw still carry the design. Text selection, the caret, custom scrollbars, focus rings, underline offset, and the numerals in tabular data all ship with browser defaults that belong to no design system. Theme them from the palette. This is the cheapest signal that a page was built rather than assembled, and the one models skip most reliably.
- **Copy:** the product's own language. Controls name their action; errors name the problem and the recovery.
- **Coverage:** every brief requirement present and findable within seconds.
@@ -11,9 +11,9 @@ Your job is production cleanup, not new art direction. Work only from the approv
Do not redesign. Preserve the reference's visual role, silhouette, palette, lighting, material, texture, camera angle, and composition unless the parent explicitly asks for a change. Preserve perspective only when it belongs to the object or scene itself; if CSS should create the card transform, shadow, rounded clipping, border, or layout, remove that presentation chrome from the raster.
## Decision Sketches
## Decision Comps
When the parent hands you a decision card packet instead of an approved mock, the job is one sketch: one card, one file, written to the card's declared `sketch` path the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a sketch is reported back, not padded from imagination. Render through the parent's shared frame, including its aspect: the requested surface's first viewport as a flat, matte design sketch in the card's own palette and type character, deliberately unfinished, no photorealism, no gloss; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. The frame is shared across siblings so no sketch looks more finished than another; a finish gap breaks the comparison. The only legible text is the product's real name and one real headline; greek every other text region into indistinct lines, because an invented spec, price, or date in a sketch is a claim PRODUCT.md never made. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a sketch run.
When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `sketch` path (the field keeps its wire name) the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a comp is reported back, not padded from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (its regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment is what keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
## Input Contract
@@ -11,12 +11,12 @@ A hard turn ceiling ends the run without warning; a run that ends before the fiv
## Input Contract
Expect: the original request; the confirmed user answers; the artifact path(s); desktop and mobile screenshot paths captured by the parent; the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths and the approved comp path; and the skill's `reference/craft-floor.md` path. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped.
Expect: the original request; the confirmed user answers; the artifact path(s); the screenshots the parent captured, which live in `.impeccable/review/` (on the web, `desktop.png` and `mobile.png`; on native, device-class names such as `phone.png` and `tablet.png`, suffixed per OS on adaptive); a screenshot path the calling brief names is authoritative when the file exists, and `.impeccable/review/` is where to look when the brief names none or a named path is missing, never a filename you invent; the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths and, on a comp-led build, the approved comp path (a code-led build has no approved comp; it passes the chosen decision comp as a separate critique-reference input, labeled as such, and nothing in this file that binds “the approved comp” binds it); and the skill's `reference/craft-floor.md` path. On a native (`ios` / `android` / `adaptive`) build the packet also carries the platform reference path(s) (`reference/ios.md` / `reference/android.md`) and a line saying no detector ran: read the platform reference alongside the craft floor and judge every check in the platform's own conventions, the screenshots are device captures rather than browser viewports, and your floor check is the build's only slop gate. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped.
## Checks, in order
1. **Persistence.** PRODUCT.md exists. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comps with no recorded pick mean the approval point was skipped, and that is a material finding.
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element, and its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement, because medium is part of the promise. When no approved comp was supplied, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality, CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. In every material_fixes list, a fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
1. **Persistence.** PRODUCT.md exists. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comp-round comps with no recorded pick mean the approval point was skipped, and that is a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and they imply no approval whatever the build path; a code-led build has no comp round at all.
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element, and its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement, because medium is part of the promise. When no approved comp was supplied, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality, CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. A critique-reference comp, when one arrived on such a build, is provocation rather than spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is the question of what the image dared that the build did not, and the dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. In every material_fixes list, a fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition.
4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped and that is a material fix ahead of any craft point. Then, for each of the five blocks, does the render keep the promise? Apply the memory test to the first viewport.
5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every image-native region of the approved comp shipped as a real asset, not a gradient standing in for one, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind other paint is a compliance token, not a shipped material.
@@ -34,4 +34,4 @@ Return the disposition line first, then exactly five sections: `persistence` (pa
## Verdict Pass
When the parent returns with post-fix recaptures, you are scoring, not re-hunting. The parent's narration of what was fixed is not evidence; a claimed fix you cannot see in the recaptures is unresolved. For each material fix from your review, one line: resolved, partial, or unresolved, tied to what the new screenshots visibly show; a fix answered mechanically, positions moved but the quality the finding named still absent, is partial at best. Then name at most three regressions the fix batch itself introduced, judged by the same matrix rules, and nothing else; no new hunt, no new checks. Return exactly two sections: `verdict` (the scored list) and `remaining` (what stays open, or "clear"), and end with the disposition line recomputed against what remains open; unresolved or partial material findings can never recompute to ship.
When the parent returns with post-fix recaptures, you are scoring, not re-hunting. The parent recaptures over the same screenshot files you read in the review round, so re-read those exact paths for this round; a round-stamped filename you invent points at nothing. The parent's narration of what was fixed is not evidence; a claimed fix you cannot see in the recaptures is unresolved. For each material fix from your review, one line: resolved, partial, or unresolved, tied to what the new screenshots visibly show; a fix answered mechanically, positions moved but the quality the finding named still absent, is partial at best. Then name at most three regressions the fix batch itself introduced, judged by the same matrix rules, and nothing else; no new hunt, no new checks. Return exactly two sections: `verdict` (the scored list) and `remaining` (what stays open, or "clear"), and end with the disposition line recomputed against what remains open; unresolved or partial material findings can never recompute to ship.
+12 -6
View File
@@ -48,14 +48,20 @@ The first argument is the action. Defaults to `status`.
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
## Triage 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`.
The hook itself never writes ignore config; every exception goes through `hook-admin.mjs`. Triage each finding into one of three outcomes:
- **Real design problem**: fix it. Never add an ignore to skip a fix or to push a blocked write through.
- **Confident false positive or sanctioned exception**: persist the narrowest ignore yourself and disclose it in your reply. The bar is evidence you can name: an intentional demo or fixture, documentation of bad design, literal or domain-appropriate motion (a ball that bounces), or a choice the user already confirmed. Put that evidence in `--reason` as `"<who decided: evidence>"`; write "user confirmed" only when the user actually did.
- **Unsure**: leave the finding standing and ask the user in one line. Ask once; a one-line question costs less than the hook re-firing on every later edit.
Self-serve stops at `ignore-value`. `ignore-file` and `ignore-rule` silence too much to add on your own judgment; ask the user first.
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 line shows an `ignore-value <rule> <value>` pair, pass it to `hook-admin.mjs ignore-value` with your `--reason`. This writes shared `.impeccable/config.json` by default.
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` for 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`, scope that one rule to the file: `ignore-value <id> "*" --file <path>`. Run `npx impeccable detect <path>` first to see what actually fires there.
- Reach for `ignore-file <path>` only when the whole file is out of scope for design review: a fixture, a generated artifact, a deliberate slop demo. It silences every rule for that file permanently, including rules that have not been written yet. A real UI surface with one noisy rule wants the file-scoped value ignore above.
- 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.
@@ -67,10 +73,10 @@ Example value-specific exception:
node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional"
```
Example intentional motion exception:
Example self-served exception, with the evidence named:
```bash
node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional"
node .claude/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "Agent: literal ball-bounce animation, bounce easing is the subject"
```
Example whole-rule font exception:
@@ -43,3 +43,9 @@ Would a fluent iPhone user trust this app, or pause at off-spec controls? The te
- **System transitions.** Push slides, sheets rise, dismiss reverses the entrance. Custom transitions that fight the navigation model disorient.
- **Honor Reduce Motion.** Crossfade instead of parallax and large slides.
## Verifying the build
- **Screenshots come from the Simulator, never a browser.** Build and run, then capture with `xcrun simctl io booted screenshot <path>` (with several running, replace `booted` with the target's UDID from `xcrun simctl list devices booted`; display names can collide, the UDID never does). Capture every device class the app ships to, at least one iPhone and, when iPad is a target, one iPad, and write the files where the review flow expects them.
- **Dark Mode and Dynamic Type belong in the pass.** `xcrun simctl ui booted appearance dark` flips appearance, reusing the capture's UDID when several are booted; a check at a large Dynamic Type size catches the truncation a fixed layout hides.
- **Simulators give breadth; posture, gestures, and performance need hardware.** Say which one produced the evidence.
@@ -43,12 +43,14 @@ The script assigns which structure gets built; your top-ranked structure is what
1. Name the product's unique mechanism in one sentence, the audience's real scene, its cultural home, and what this first surface must prove. Note the page this category always ships and its predictable opposite; name both as the rut and keep them out of the seven-candidate list. A brief that paints its own picture, a product name, a titled artifact, a governing metaphor, adds its literal reading to the rut: spend at most one candidate on it and derive the rest from elsewhere in the audience's world.
2. From that cultural world, list seven concrete visual systems, artifacts, places, or rituals the audience knows by heart, each with one line on why it resonates and can carry the mechanism, ordered by resonance. The audience's world includes its graphic and screen traditions, not only its physical objects: the notation, publications, identity programs, data graphics, and interfaces it reads daily; a nameable abstract system (a school of poster, a documentation standard) is as concrete a candidate as any artifact. What would this thing look like as a physical object; what did its world look like before the web? Near-duplicates count once. When more than three of the seven share one material family, the derivation stopped at the subject's most obvious artifact; dig until the list spans at least three families.
3. Turn that material into complete directions: each joins a reusable visual world to a concrete first-surface experience.
4. Run `node .claude/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. This step has no substitute and no skip condition: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure, because the roll is the mechanism that keeps every run from converging on the category default. The script assigns which direction gets built and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, and clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity; losing to strong grounded material is a valid outcome, and beating a thin or tool-monoculture list is the point.
5. Present one direction, fully committed: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Alongside it, offer the hand's challengers as named alternates, the weighing's verdict written on each as its one-line case, an honest "fuses poorly because X" included; the weighing informs the user's choice, it never pre-empts it. A hand holds at most three challengers: when the roll deals more, the three strongest join the hand and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add re-roll with an optional one-line steer. Never present a ranked menu of your own grounded candidates; a lineup of those invites the safest card. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool; the structured tool's option list also carries the standing exit as its last option.
4. Run `node .claude/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. This step has no substitute and no skip condition: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure, because the roll is the mechanism that keeps every run from converging on the category default. The script assigns which direction gets built and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, and clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity; losing to strong grounded material is a valid outcome, and beating a thin or tool-monoculture list is the point. The weighing closes with a verdict per challenger, decided before any borrowing is considered: wins (beats the assigned direction on both axes; it becomes the build candidate), competitive (holds one axis; it stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a motif lifted from a declined world is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen.
5. Present one direction, fully committed and already raised by the hand it beat, its raises visible as named lines: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Alongside it, route each dealt challenger by its verdict: winning and competitive challengers are full alternates carrying their QUALITY BAR cards and one-line case, while declined challengers render demoted, compact and quiet, each carrying its verdict plus what the direction kept from it, never full-size and never silently dropped, each still adoptable on request. The verdict informs the user's choice, it never pre-empts it; the demoted row is the hand's proof of judgment, showing why the dealt worlds made the presented direction better. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join the hand and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker MY PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often the one most runs in this category land on, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: the rest of your grounded candidates stay yours, because a lineup of them hands selection back to a taste function and invites the safest card. The pick never takes the lead position, and when the dice assign your top candidate there is no pick card; the assigned card notes it also topped your list. Add re-roll with an optional one-line steer, offered in three registers: plain (a fresh hand, same spread), safer (the familiar register: your remaining conventional grounded candidates plus the canon against named competitors), and bolder (foreign forms only, at full commitment). A register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool; the structured tool's option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit as its last option, while declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel too.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it, in the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path, convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. A standing preference gets recorded as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, the dealt challengers as alternates carrying their QUALITY BAR cards, and re-roll, steer, plus canon enabled; a degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy, thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (run the script with `--schema` for the exact shape); the page renders identity from these fields, and a challenger's catalog image rides as labeled inspiration, never as the promise of the build. Author `canonCard` too: the category standard as one honest card with the same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .claude/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. Only a session where no browser can open at all, headless, CI, an eval worker, a remote shell with no display, puts the same decision through the structured question tool instead; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it, in the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path, convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. A standing preference gets recorded as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading and its raised lines included, the pick card when one exists, the dealt challengers as alternates carrying their QUALITY BAR cards plus each challenger's verdict and kept line, re-roll with its safer and bolder registers, steer, plus canon enabled, and `followup: true` when the execution-contract round will follow (it does whenever image generation exists and no standing build-path preference is recorded); a degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy, thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (run the script with `--schema` for the exact shape); the page renders identity from these fields, routes declined challengers to a demoted row on its own, and a challenger's catalog image rides as labeled inspiration, never as the promise of the build. Author `canonCard` too: the category standard as one honest card with the same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .claude/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. Only a session where no browser can open at all, headless, CI, an eval worker, a remote shell with no display, puts the same decision through the structured question tool instead; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry.
When image generation exists, every card also declares a `sketch` path under `.impeccable/sketches/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the sketches; the page shimmer-waits per slot and the user may answer before they land. Render every sketch through one shared frame so the comparison stays about direction, never rendering luck: the requested surface's first viewport as a flat, matte design sketch in that card's own palette and type character, deliberately unfinished, no photorealism, no gloss, identical framing across cards; a candidate whose sketch looks more finished than the others has broken the comparison, not won it. The frame's aspect is the surface's own: a native app or mobile-first surface sketches portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen sketched landscape is a broken frame, not a neutral default. The only legible text in a sketch is the product's real name and one real headline; every other text region is greeked, indistinct lines standing where copy will go, because a sketch that renders invented specs, prices, or dates puts claims in front of the user that PRODUCT.md never made. Produce in the order the user reads: the assigned card, then the hand, then canon, each file written the moment it is done. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-sketch packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. A sketch answers which world, never which composition: the comp round still renders its full set, and the chosen card's sketch seeds at most one probe. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version.
When image generation exists, every card also declares a `sketch` path under `.impeccable/mocks/decision/` (the field keeps its wire name for compatibility; what it carries is the card's comp), the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity, produced under the comp discipline in [visualize.md](visualize.md): the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished sketch pays sketch quality for comp cost; fairness between cards comes from equal fidelity in each card's own grammar, one surface, one aspect, never from shared unfinishedness. The frame's aspect is the surface's own: a native app or mobile-first surface comps portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen comped landscape is a broken frame, not a neutral default. Produce in the order the user reads, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: on a comp-led build it enters the comp round as compositional option one, and on a code-led build it returns at the finish review as the critique reference, what the image dared that the build did not. The unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
The moment the direction lands, one more round on the same open table decides the execution contract. The direction payload declares `followup: true`, so the table stays open after the pick; deliver the build-path payload through `--update` immediately. Two text-only cards. **Comp-led**: a first-viewport comp is generated and it is law, the finish review audits the build against it; boldest composition on the table, fix rounds expected, motion at risk; choosing it makes the comp non-optional, no silent skipping. **Code-led**: no comp of this page and no apology for it; the QUALITY BAR boards still calibrate finish, and the ambition moves into the written contract, the FIRST VIEWPORT block plus a named signature interaction and motion grammar, which the finish reviewer audits in behavior; code-led is not a discount on commitment, the direction still lands fully committed in code. Lead with the chosen world's fit: a costume-heavy catalog world leads comp-led, a quiet or conventional direction leads code-led; the lead is a default, never a decision, and the user flips it freely. A standing preference, voiced once, is recorded as a brand commitment in PRODUCT.md and skips this round on later surfaces. Without image generation there is no fork and no round: code-led is the only path, stated in one line rather than asked. Only a detached table (`--start`) stays open for `--update`: a blocking serve or the structured-tool channel runs the build-path round as its own second question instead, and `followup: true` belongs only on a detached round.
Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. Open the QUALITY BAR board and hero for the world you build the moment the choice lands, even if you viewed another card earlier; the ANSWER line names the chosen card's images (when the harness only reads files or runs sandboxed, download them into the workspace and open the relative path; sandboxed viewers reject absolute paths outside it). They set the craft level the build must reach, a rendered reference's finish, commitment, and art direction, never the composition; your surface serves this product.
@@ -80,7 +82,7 @@ If the work establishes durable strategy for a route or artifact, read its exist
Keep the brief small: scope and visitor mode; audience, job, action/task, proof/content, and constraints; chosen direction and memorable moment; unresolved decisions. Do not copy global product truth or DESIGN.md tokens into it.
Whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports, the locked direction is visualized before it is built, never skipped: load [visualize.md](visualize.md) and follow it, three compositional options rendered and put before the user for approval. This step is proven to produce the most compositional and ambitious work.
On a comp-led build, whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports, the locked direction is visualized before it is built, never skipped: load [visualize.md](visualize.md) and follow it, three compositional options put before the user for approval, the chosen card's decision comp plus two variations. This step is proven to produce the most compositional and ambitious work. On a code-led build the comp round is skipped by contract, never by drift: the ambition it would have carried lives in the direction contract's FIRST VIEWPORT block and named signature interaction, and the finish reviewer audits those promises in behavior.
For `shape`, return the selected direction to [shape.md](shape.md) and stop before persistence or implementation.
@@ -103,8 +105,8 @@ Preserve semantics, accessibility, performance, responsiveness, project conventi
## 7. Inspect and finish
Inspect desktop and mobile in one batched screenshot round, critique the render against the user's request and the direction contract, fix material gaps, and confirm with one final round; two rounds is the ceiling, and fixes batch between them rather than earning per-tweak screenshots. When an approved comp exists, the critique is a side-by-side: view the comp region and the build region together, the hero and each section as its own crop at legible scale, never one full-page thumbnail, which hides exactly the failures that matter, crude controls, wrong lettering character, flattened material, behind a superficially similar section order. On a Persuade surface, verify the mode did its job: a first-time visitor should know what this is, why it matters, and what to do within seconds, in the form's own vocabulary.
Inspect the surface's target sizes in one batched screenshot round: desktop and mobile on the web; on a native platform (`ios` / `android` / `adaptive`), the shipped device classes per OS, captured from the simulator or emulator the way the platform reference's Verifying the build section describes. Critique the render against the user's request and the direction contract, fix material gaps, and confirm with one final round; two rounds is the ceiling, and fixes batch between them rather than earning per-tweak screenshots. When an approved comp exists, the critique is a side-by-side: view the comp region and the build region together, the hero and each section as its own crop at legible scale, never one full-page thumbnail, which hides exactly the failures that matter, crude controls, wrong lettering character, flattened material, behind a superficially similar section order. On a Persuade surface, verify the mode did its job: a first-time visitor should know what this is, why it matters, and what to do within seconds, in the form's own vocabulary.
After the second inspection round the build thread's polishing is over: no further defect hunts, micro-edit scripts, or rebuilds here; whatever remains ships through the handoffs, where a fresh context does the finding better and cheaper. Where this harness runs no design hook, run `node .claude/skills/impeccable/scripts/detect.mjs --json` on the changed targets once here, fix what is mechanical, and pass the remaining findings to the reviewer; a hookless build that skips this ships every tell the hook exists to catch. Capture desktop and mobile screenshots to files, then spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccable_finish_reviewer` in codex; `/impeccable-finish-reviewer` in Cursor; on GitHub Copilot say "Use the impeccable-finish-reviewer agent"), with the original request, confirmed answers, the artifact path, the screenshot paths, its direction contract, existing hook findings, the QUALITY BAR card and approved comp paths, and the craft-floor reference path. The reviewer has no browser; screenshots you fail to pass are checks it cannot run. Never read the shipped agents' definition files before spawning; the harness loads them at spawn, and you owe only the input packet. Wait on any agent with one long timeout rather than a loop of short polls, and spend the wait on the next independent step. Verify its return carries the five contract sections; on an empty or thrashed return, respawn once with the same inputs before doing anything else. This review never runs inside the build thread and never inherits it: spawn the reviewer fresh, with no forked conversation history (`fork_turns: 0` in codex); a reviewer that inherits your transcript inherits your framing, your optimism, and your abstractions, and everything it needs travels in the inputs above. Only a harness whose tool surface has no subagent capability at all substitutes a fresh in-thread pass after stepping fully out of the build context, run from [degraded/finish-reviewer.md](degraded/finish-reviewer.md), and a substituted or failed-and-replaced review is disclosed in one line at finish, never silently. When the reviewer's first material fix is a rebuild directive, fidelity failed wholesale rather than in patches, so skip the fix batch and execute the rebuild immediately: re-derive the named regions, produce the named assets, and send the result back for a verdict, telling the user what is happening rather than asking permission to fix a failure. The user is consulted only when a second rebuild directive arrives, both verdicts on the table, or when rebuilding would discard content the user approved. Otherwise apply the material fixes in one batch, rebuild once, and recapture the same viewports. A recapture measures positions, loading, and overflow; it cannot measure whether a fix reached the quality the finding named, so send the recaptured screenshots back to the same reviewer for a verdict scoring every material fix resolved, partial, or unresolved (through the harness's agent continuation; without one, run the scoring fresh from [degraded/finish-reviewer.md](degraded/finish-reviewer.md)'s Verdict Pass). Fixes scored partial or unresolved get another batch, recapture, and verdict. Two rounds is the budget an unattended run ends at; an attended session's ceiling belongs to the user, so when the second verdict still lists open items, put the table in front of them and let them choose between shipping as it stands and funding another round. Whoever is deciding, stop the moment a round resolves nothing, and the reviewer's findings are the only list you work from, never your own re-opened hunt. Report the final verdict table to the user as it stands, open items included, under the reviewer's own disposition word: a table with open material findings is never announced as a pass, and never under a softer label than the reviewer wrote. Do not run a second detector.
After the second inspection round the build thread's polishing is over: no further defect hunts, micro-edit scripts, or rebuilds here; whatever remains ships through the handoffs, where a fresh context does the finding better and cheaper. On the web, where this harness runs no design hook, run `node .claude/skills/impeccable/scripts/detect.mjs --json` on the changed targets once here, fix what is mechanical, and pass the remaining findings to the reviewer; a hookless web build that skips this ships every tell the hook exists to catch. A native platform skips the detector entirely: it reads HTML and CSS and has no verdict on native code, so the reviewer's floor check is the only slop gate and the input packet says so. Capture the screenshots into `.impeccable/review/`, one file per captured viewport (on the web, `desktop.png` and `mobile.png`; on native, one per device class, such as `phone.png` and `tablet.png`, suffixed per OS on adaptive), creating that directory when the harness does not; the paths you pass the reviewer are its spec, and that directory is where it looks when a passed path is missing. Then spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccable_finish_reviewer` in codex; `/impeccable-finish-reviewer` in Cursor; on GitHub Copilot say "Use the impeccable-finish-reviewer agent"), with the original request, confirmed answers, the artifact path, the screenshot paths, its direction contract, existing hook findings, the QUALITY BAR card and approved comp paths (on a code-led build there is no approved comp; the chosen decision comp rides in that slot as the critique reference, named as such), the craft-floor reference path, and on a native platform the platform reference path(s), [ios.md](ios.md) / [android.md](android.md), both on adaptive, plus one line saying no detector ran, so the reviewer judges in the platform's conventions rather than the web's. The reviewer has no browser; screenshots you fail to pass are checks it cannot run. Never read the shipped agents' definition files before spawning; the harness loads them at spawn, and you owe only the input packet. Wait on any agent with one long timeout rather than a loop of short polls, and spend the wait on the next independent step. Verify its return carries the five contract sections; on an empty or thrashed return, respawn once with the same inputs before doing anything else. This review never runs inside the build thread and never inherits it: spawn the reviewer fresh, with no forked conversation history (`fork_turns: 0` in codex); a reviewer that inherits your transcript inherits your framing, your optimism, and your abstractions, and everything it needs travels in the inputs above. Only a harness whose tool surface has no subagent capability at all substitutes a fresh in-thread pass after stepping fully out of the build context, run from [degraded/finish-reviewer.md](degraded/finish-reviewer.md), and a substituted or failed-and-replaced review is disclosed in one line at finish, never silently. When the reviewer's first material fix is a rebuild directive, fidelity failed wholesale rather than in patches, so skip the fix batch and execute the rebuild immediately: re-derive the named regions, produce the named assets, and send the result back for a verdict, telling the user what is happening rather than asking permission to fix a failure. The user is consulted only when a second rebuild directive arrives, both verdicts on the table, or when rebuilding would discard content the user approved. Otherwise apply the material fixes in one batch, rebuild once, and recapture the same viewports over the same files. A recapture measures positions, loading, and overflow; it cannot measure whether a fix reached the quality the finding named, so send the recaptured screenshots back to the same reviewer for a verdict scoring every material fix resolved, partial, or unresolved (through the harness's agent continuation; without one, run the scoring fresh from [degraded/finish-reviewer.md](degraded/finish-reviewer.md)'s Verdict Pass). Fixes scored partial or unresolved get another batch, recapture, and verdict. Two rounds is the budget an unattended run ends at; an attended session's ceiling belongs to the user, so when the second verdict still lists open items, put the table in front of them and let them choose between shipping as it stands and funding another round. Whoever is deciding, stop the moment a round resolves nothing, and the reviewer's findings are the only list you work from, never your own re-opened hunt. Report the final verdict table to the user as it stands, open items included, under the reviewer's own disposition word: a table with open material findings is never announced as a pass, and never under a softer label than the reviewer wrote. Do not run a second detector.
Then spawn the shipped documenter, `impeccable-documenter` (`impeccable_documenter` in codex), with the project root, the artifact path, the direction contract, PRODUCT.md, the [document.md](document.md) reference path, and the boundary to write at; it records DESIGN.md and the sidecar from the built world, ground truth over intention; without subagents the pass runs from [degraded/documenter.md](degraded/documenter.md). A clean detector pass is not finished; finished is the contract kept, the comp honored, the review closed, and the system recorded.
@@ -19,7 +19,7 @@ Fix the cause at the narrowest correct level. Ask when a binding system principl
## 2. Gather the evidence
Use the feature yourself at representative desktop and mobile sizes. Determine:
Use the feature yourself at the surface's representative sizes: desktop and mobile on the web; on a native platform (`ios` / `android` / `adaptive`), the shipped device classes on the simulator, emulator, or hardware, captured per the platform reference's Verifying the build section. Determine:
- whether the path is functionally complete;
- the intended quality bar and time available;
@@ -86,10 +86,10 @@ Do not perfect one corner while leaving the rest below the same quality bar.
Walk the complete path again with mouse, keyboard, and touch where applicable. Check:
- mobile, intermediate, and wide layouts;
- mobile, intermediate, and wide layouts on the web; phone and tablet size classes in both supported orientations on native;
- loading, empty, error, success, disabled, long-content, and missing-content states;
- zoom, contrast, focus, semantics, and screen-reader names;
- console errors, layout shift, interaction latency, image loading, and supported browsers;
- console errors, layout shift, interaction latency, and image loading everywhere; supported browsers on the web; supported OS versions, runtime warnings, and dropped frames on native;
- agreement with DESIGN.md, neighboring features, and the user's scope.
Follow the quality guidance supplied by `context.mjs` and hooks, then run any other relevant QA commands. Context requests a manual scan only when no automatic detector is active; never add another detector pass. Fix real defects and document only narrow intentional exceptions. A clean scan does not replace visual judgment.
@@ -1,12 +1,12 @@
# Visualize: Direction Comps & Asset Production
Load this from [new-work.md](new-work.md) whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it.
Load this from [new-work.md](new-work.md) on a comp-led build, when image generation is available (a harness-native tool or the API fallback context.mjs reports). A code-led execution contract skips this file by design, not by drift: its ambition lives in the written direction contract and is audited in behavior, so do not load it for a code-led round. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it.
The purpose of a probe is to test composition, narrative, hierarchy, density, focal moment, signature use, and image requirements. It is not a second identity workshop. Keep DESIGN.md's palette, typography direction, material language, component character, imagery stance, and motion grammar fixed.
## Generate three compositional options
Render three distinct high-fidelity north-star comps of the requested surface, with whatever generation capability exists, saved under `.impeccable/mocks/` so they survive the session. Comp at the surface's own viewport: portrait at device size for a native app or mobile-first surface, desktop landscape otherwise; a phone screen comped landscape misstates the composition before anything gets built against it. Comps are the build thread's own work, never delegated: the thread that writes the comp prompts holds the direction's full context, and it has already seen every comp when the build starts. Open every image you produce or reference by its workspace-relative path, never an absolute one: sandboxed viewers reject absolute paths, and everything under the project root has a relative path. Base them on the real content and the surface concepts already developed with the user. Three is the number: one comp invites rubber-stamping, and the spread between three is what surfaces the composition worth building. A decision-page sketch is not a probe: it chose the direction at deliberately unfinished fidelity, so the three comps render regardless, and the chosen card's sketch seeds at most one of them.
Render three distinct high-fidelity north-star comps of the requested surface, with whatever generation capability exists, saved under `.impeccable/mocks/` so they survive the session. Comp at the surface's own viewport: portrait at device size for a native app or mobile-first surface, desktop landscape otherwise; a phone screen comped landscape misstates the composition before anything gets built against it. Comps are the build thread's own work, never delegated: the thread that writes the comp prompts holds the direction's full context, and it has already seen every comp when the build starts. Open every image you produce or reference by its workspace-relative path, never an absolute one: sandboxed viewers reject absolute paths, and everything under the project root has a relative path. Base them on the real content and the surface concepts already developed with the user. Three is the number: one comp invites rubber-stamping, and the spread between three is what surfaces the composition worth building. The chosen card's decision comp is the first of the three: it already renders this direction at full fidelity under this file's discipline, so this round generates two more that vary what the first held fixed, and all three go to the approval point together. Only a round that arrives with no decision comp, a degraded roll, an identity-mode page, a direction pinned without the decision round, renders all three here.
- A comp is a designed surface, not a picture of the subject. Lead the generation prompt with the surface's own structure, whatever regions this design actually has, named in order with their scale relationships; a page with no navigation states that instead of inventing one, and an unconventional surface states its unconventional skeleton. A prompt that leads with the world's atmosphere gets a vignette back: the model paints the fish market instead of the fish market's website. Self-check every render: if it could hang as a poster, or reads as a photograph or scene with some text on it, it is not a comp; regenerate with the layout scaffold stated more literally.
- When the user shortlisted multiple concepts, spread the three across them.
@@ -22,7 +22,7 @@ Show the three together: in the harness when it can display images, otherwise on
Do not begin code until the user approves a direction or explicitly delegates the choice. If they delegate, choose using the task brief, PRODUCT.md, and DESIGN.md, and state the evidence. Approval refines the task concept; it does not modify DESIGN.md.
This approval point has no substitute and no skip condition. When the structured question tool errors, fall back to the decision page; only after both fail may you treat the choice as delegated, and a delegated pick is still recorded exactly as an approval is and disclosed in your first reply, not your last. The finish reviewer treats a build with generated comps and no recorded approval as carrying a material finding.
This approval point has no substitute and no skip condition. When the structured question tool errors, fall back to the decision page; only after both fail may you treat the choice as delegated, and a delegated pick is still recorded exactly as an approval is and disclosed in your first reply, not your last. The finish reviewer treats a build whose comp round produced comps with no recorded approval as carrying a material finding; decision comps under `.impeccable/mocks/decision/` are the direction round's hand, not comp-round output, and imply no approval on their own.
After approval, record the choice where tools can find it: the approved comp's path goes in the surface brief, and the approved comp's `.json` prompt sidecar gains `"approved": true` (every comp generated through `generate-image.mjs` has one; create it if a native tool didn't). The sidecar travels with the mocks folder, so the approval survives sessions and machines that never see the brief. Then summarize the composition and the parts of the comp that must not be literalized, return to new-work.md, record the direction contract from the approved surface concept, and build.
@@ -31,6 +31,16 @@
* recomputes what rounds 0..n-1 drew, excludes all of it, and rolls a
* fresh assigned index, challengers, and compositions. One base key therefore
* reproduces the entire chain of rounds.
* - REGISTER (--register safer|bolder): the user's steering on the
* familiar-to-bold axis, applied to a re-roll round. A register changes
* only what this round instructs, never what it dealt: the same key and
* reroll count reproduce the same deal whatever the register, so the
* exclusion chain never forks. bolder presents the dealt foreign forms
* as the whole hand (first-dealt leads, dice-assigned by deal order);
* safer spends the dealt hand unseen and presents the familiar register,
* the model's conventional grounded candidates plus the canon against
* named competitors, the one sanctioned lineup of the model's own list.
* Registers are user-requested, never pre-selected by the model.
* - RATINGS: the reviewer's approval ratings weight the challenger draw
* (3-star doubles the odds, 1-star sits out); the approved pool itself
* is unchanged.
@@ -41,7 +51,9 @@
* node scripts/concept-seed.mjs --scope surface --mode operate --grain flow
* node scripts/concept-seed.mjs --scope direction --candidate-count 6
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1
* node scripts/concept-seed.mjs --chosen <challenger-id> --from <key> --scope direction
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1 --register bolder
* node scripts/concept-seed.mjs --chosen <challenger-id> --kind challenger --from <key> --scope direction
* node scripts/concept-seed.mjs --kind assigned --from <key> --scope direction
*
* --grain names how much of the product is in play: product, flow, view, or
* region. A docs site, an onboarding flow, a landing page and a data table are
@@ -62,8 +74,13 @@
* Challenger data resolves in order: a local catalog directory (the private
* service repo, evals, and tests set IMPECCABLE_CATALOG_DIR), then the roll
* API at impeccable.style, then a degraded assignment-only seed when both are
* unavailable. --chosen sends the anonymous choice ping for API-dealt rolls;
* DO_NOT_TRACK or IMPECCABLE_NO_TELEMETRY disables it.
* unavailable. The anonymous choice ping fires once per resolved attended
* round on API-dealt rolls: --kind names which card class won (assigned,
* pick, challenger, canon) so share metrics have a denominator, --chosen
* carries the catalog id when a dealt challenger won, and --register rides
* along when the round came from a steered hand. Grounded candidates' names
* never leave the machine. DO_NOT_TRACK or IMPECCABLE_NO_TELEMETRY disables
* the ping entirely.
*
* Env vars:
* IMPECCABLE_CONCEPT_SEED same as --from; for reproducible eval runs.
@@ -172,17 +189,35 @@ function telemetryDisabled() {
return Boolean(process.env.IMPECCABLE_NO_TELEMETRY || process.env.DO_NOT_TRACK);
}
// Anonymous choice ping: records only that a dealt world was selected.
// Anonymous choice ping: one per resolved attended direction round. kind
// says which card class won (assigned / pick / challenger / canon), so
// pick-share and canon-share have a denominator; chosenId rides along only
// when a dealt catalog world won, and register only when the round came from
// a steered hand. Grounded candidates' names never leave the machine: they
// are derived from the user's project, so the ping carries the kind alone.
// Fire-and-forget; never fails the caller.
export async function pingChosen({ chosenId, key, scope, mode }) {
if (telemetryDisabled() || !chosenId) return false;
const PING_KINDS = new Set(['assigned', 'pick', 'challenger', 'canon']);
export async function pingChosen({ chosenId, key, scope, mode, kind, register }) {
if (telemetryDisabled()) return false;
if (kind && !PING_KINDS.has(kind)) return false;
if (register && register !== 'safer' && register !== 'bolder') return false;
// Legacy shape: a bare challenger id with no kind stays a valid ping.
if (!chosenId && !kind) return false;
if ((kind === 'challenger' || !kind) && !chosenId) return false;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
try {
await fetch(`${API_BASE}/chosen`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chosenId, key, scope, mode }),
body: JSON.stringify({
...(chosenId ? { chosenId } : {}),
key,
scope,
mode,
...(kind ? { kind } : {}),
...(register ? { register } : {}),
}),
signal: controller.signal,
});
return true;
@@ -260,6 +295,7 @@ export function renderConceptSeed({
scope = 'surface',
key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'),
reroll = 0,
register = null,
mode = null,
grain = null,
platform = null,
@@ -273,6 +309,15 @@ export function renderConceptSeed({
if (!Number.isInteger(reroll) || reroll < 0) {
throw new Error('concept-seed: --reroll must be a non-negative integer');
}
if (register !== null && register !== 'safer' && register !== 'bolder') {
throw new Error('concept-seed: --register must be safer or bolder');
}
if (register !== null && reroll < 1) {
throw new Error('concept-seed: --register steers a re-roll round; pass --reroll <n> with it');
}
if (register !== null && scope !== 'direction') {
throw new Error('concept-seed: --register applies to direction rounds only');
}
if (mode !== null && !SEED_MODES.has(mode)) {
throw new Error('concept-seed: --mode must be persuade, operate, read, or experience');
}
@@ -326,6 +371,7 @@ export function renderConceptSeed({
scope,
key,
reroll,
register,
mode,
grain,
platform,
@@ -357,7 +403,11 @@ export function renderConceptSeed({
survive the current task plus navigation, quiet and dense content,
interaction and state, and a substantially different future surface. In an
attended run, present the assigned direction fully committed and offer
re-roll; never present a ranked lineup to choose from. Re-roll yourself only
re-roll. You may add ONE card for your top-ranked grounded candidate when
it is not the assigned direction, kicker MY PICK, with an honest risk line
naming its familiarity; one pick card, never a ranked lineup, and the pick
never takes the lead position. When the assignment IS your top candidate,
there is no pick card. Re-roll yourself only
on named factual grounds, when the assignment cannot carry the product's
truth or task; taste is never grounds.`
: `After ordering the task's grounded structural candidates by resonance,
@@ -374,7 +424,16 @@ export function renderConceptSeed({
conflicts. Weigh the fused result against the assigned direction on exactly
two axes, audience identification and product clarity. Losing to strong
grounded material is a valid outcome; beating a thin or tool-monoculture
list is the point. A fused challenger that wins both axes becomes the build.`
list is the point. A fused challenger that wins both axes becomes the build.
Close the weighing with a verdict per challenger, decided before any
borrowing is considered: wins (beats the assigned direction on both axes),
competitive (holds one axis), or declined (loses both). A declined
challenger is not spent: name the one discipline of its system the assigned
direction lacks, and raise the assigned direction to match before
presenting it. A donation transfers ambition and system discipline, never
the challenger's clothes; one world owns the page. Write each raise as its
own named line on the presented direction, and carry every verdict, kept
line, and raise into the decision page payload.`
: `A challenger wins only when its fused result beats the grounded list on
audience identification and product clarity. It may change task topology or
interaction, but never the committed visual identity.`;
@@ -399,8 +458,39 @@ Ambitious motion, spatial media, or interaction is welcome when it strengthens
the product without weakening semantics, performance, or fallback behavior.`;
if (!data) {
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: degraded; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''} --candidate-count ${candidateCount})
ASSIGNED INDEX: ${buildIndex}
// A degraded roll can still serve the safer register, which needs no
// catalog at all: the assignment machinery is suppressed entirely, the
// same as the non-degraded safer round, because emitting both "the user
// picks" and a mandatory numbered build order hands the model two
// contradicting instructions and the mandatory one tends to win. The
// bolder register is exactly the thing degradation took away, so it
// falls back to a plain grounded round, disclosed.
const degradedHeader = `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: degraded; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''}${register ? ` --register ${register}` : ''} --candidate-count ${candidateCount})`;
if (register === 'safer') {
return `${degradedHeader}
SAFER REGISTER (user-requested): the assigned index is suspended this
round; the user picks, and no candidate is mandated. Present the familiar
register: your remaining grounded candidates from the conventional end, at
most three, as full cards with an honest risk line each, plus the canon
executed against two or three named competitors. This is the one sanctioned
lineup of your own ranked candidates; it exists only by this explicit
request. When the user voices a standing preference for it, record a brand
commitment in PRODUCT.md.
${authorityInstruction}
A user- or brief-pinned decision beats the roll, always.
REGISTER (restated for truncated readers): safer, user-requested; the
assigned index is suspended this round and the user picks; seed key ${key}.
`;
}
const degradedRegister = register === 'bolder'
? `BOLDER REGISTER UNAVAILABLE: bolder deals foreign forms, and this roll ran
degraded with no catalog and no roll service, so there is nothing bold to
deal. Tell the user, then run this round as a plain grounded re-roll; the
assignment below applies.
`
: '';
return `${degradedHeader}
${degradedRegister}ASSIGNED INDEX: ${buildIndex}
${promotedInstruction}
The assignment exists to refuse the model's ranking rut, never to outrank
the user or the brief. Never expose assignment metadata in user-facing labels.
@@ -471,34 +561,76 @@ structure only, never a palette, typeface, or material. Treat them as serious
rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n`
: '';
const rerollBlock = reroll > 0
? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded
and challenger alike, is eliminated and may not return reworded. Derive
? `RE-ROLL ROUND ${reroll}${register ? ` (${register.toUpperCase()} REGISTER, user-requested)` : ''}: every candidate presented in earlier rounds, grounded
and challenger alike, is eliminated and may not return reworded.${register ? '' : ` Derive
genuinely new grounded candidates from unexplored angles before judging
these fresh challengers.\n`
these fresh challengers.`}\n`
: '';
// A register swaps the round's presentation, never its deal: the assigned
// index and challenger fetch stay identical so the chain reproduces, and
// only the instructions change.
const saferBlock = `SAFER REGISTER: the user asked for the familiar end of the spectrum, so this
round's dealt hand is spent unseen, stays excluded from future rounds, and
is not printed. The assigned index is suspended this round; the user picks. Present the familiar register: your remaining grounded
candidates from the conventional end, at most three, as full cards with an
honest risk line each, plus the canon executed against two or three named
competitors. This is the one sanctioned lineup of your own ranked
candidates; it exists only by this explicit request. When the user voices a
standing preference for it, record a brand commitment in PRODUCT.md.`;
const bolderBlock = `BOLDER REGISTER: the user asked for foreign forms at full commitment, so no
grounded direction is presented this round and the assigned index is
suspended. The hand is every dealt challenger below, each fused with the
product and presented as a full card; the FIRST dealt challenger leads, an
assignment by deal order, so the dice still choose. Verdicts and donations
apply between the challengers, weighed against the leader. The pick card
sits out; the canon stays, as always.`;
const telemetryBlock = data.source === 'api'
? `TELEMETRY: if the resolved direction uses one of these challengers, rerun
this script once with --chosen <challenger-id> --from ${key} --scope ${scope}${mode ? ` --mode ${mode}` : ''}
after resolution. The ping is anonymous (chosen id only) and is skipped
automatically when DO_NOT_TRACK or IMPECCABLE_NO_TELEMETRY is set.\n`
? `TELEMETRY: after the user's choice resolves, rerun this script once with
--kind <assigned|pick|challenger|canon> --from ${key} --scope ${scope}${mode ? ` --mode ${mode}` : ''},
adding --chosen <challenger-id> when a dealt challenger won and keeping
--register <safer|bolder> when the resolved round came from a steered hand.
One ping per resolved attended round. The ping is anonymous, the card kind
plus the catalog id when one won; your grounded candidates' names never
leave the machine, and the ping is skipped automatically when DO_NOT_TRACK
or IMPECCABLE_NO_TELEMETRY is set.\n`
: '';
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: ${data.source}; approved pool: ${data.poolRevision}; ${data.approvedCount}/${data.catalogCount} human-approved; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''} --candidate-count ${candidateCount} to reproduce this roll against this catalog revision)
${rerollBlock}ASSIGNED INDEX: ${buildIndex}
const assignedBlock = register === null
? `ASSIGNED INDEX: ${buildIndex}
${promotedInstruction}
The assignment exists to refuse the model's ranking rut, never to outrank
the user or the brief. Never expose assignment metadata in user-facing labels.
CHALLENGERS:
the user or the brief. Never expose assignment metadata in user-facing labels.`
: register === 'safer' ? saferBlock : bolderBlock;
// A bolder round has no assigned grounded direction, so the generic
// weighing instruction (which measures against the assignment) would
// contradict the register; the bolder variant weighs against the leader.
const bolderChallengerInstruction = `Fuse each challenger before judging it: the challenger supplies the form
and its system grammar, the product supplies every fact, and clarity wins
conflicts. Weigh every fused challenger against the fused LEADER, the first
dealt, on exactly two axes, audience identification and product clarity;
verdicts and donations apply between the challengers, and one that beats
the leader on both axes presents as the hand's strongest alternate.`;
const roundChallengerInstruction = register === 'bolder' ? bolderChallengerInstruction : challengerInstruction;
const challengerSection = register === 'safer'
? ''
: `CHALLENGERS:
${data.challengers.map(renderChallenger).join('\n')}
${compositionBlock}${challengerInstruction}
${compositionBlock}${roundChallengerInstruction}
When you can view images, open the QUALITY BAR board and hero for any
challenger you weigh seriously and for the world you build. They exist as a
craft bar, the finish level and commitment the build is expected to reach,
never as a mockup to copy; your surface serves this product, not that render.
${authorityInstruction}
`;
const restated = register === null
? `ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
${buildIndex} of your own grounded list; seed key ${key}.`
: `REGISTER (restated for truncated readers): ${register}, user-requested; the
assigned index is suspended this round; seed key ${key}.`;
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: ${data.source}; approved pool: ${data.poolRevision}; ${data.approvedCount}/${data.catalogCount} human-approved; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''}${register ? ` --register ${register}` : ''} --candidate-count ${candidateCount} to reproduce this roll against this catalog revision)
${rerollBlock}${assignedBlock}
${challengerSection}${authorityInstruction}
${richnessInstruction}
${telemetryBlock}A user- or brief-pinned decision beats the roll, always.
ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
${buildIndex} of your own grounded list; seed key ${key}.
${restated}
`;
}
@@ -507,19 +639,25 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
const fromIdx = args.indexOf('--from');
const scopeIdx = args.indexOf('--scope');
const rerollIdx = args.indexOf('--reroll');
const registerIdx = args.indexOf('--register');
const modeIdx = args.indexOf('--mode');
const grainIdx = args.indexOf('--grain');
const platformIdx = args.indexOf('--platform');
const candidateCountIdx = args.indexOf('--candidate-count');
const chosenIdx = args.indexOf('--chosen');
const kindIdx = args.indexOf('--kind');
try {
if (chosenIdx !== -1) {
if (chosenIdx !== -1 || kindIdx !== -1) {
// Choice ping: always exits 0, telemetry must never fail a design flow.
// --kind alone pings a non-challenger outcome (assigned/pick/canon);
// --chosen alone stays the legacy challenger-win ping.
const sent = await pingChosen({
chosenId: args[chosenIdx + 1],
chosenId: chosenIdx !== -1 ? args[chosenIdx + 1] : undefined,
key: fromIdx !== -1 ? args[fromIdx + 1] : undefined,
scope: scopeIdx !== -1 ? args[scopeIdx + 1] : undefined,
mode: modeIdx !== -1 ? args[modeIdx + 1] : undefined,
kind: kindIdx !== -1 ? args[kindIdx + 1] : undefined,
register: registerIdx !== -1 ? args[registerIdx + 1] : undefined,
});
process.stdout.write(sent ? 'choice recorded\n' : 'choice ping skipped\n');
} else {
@@ -542,6 +680,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
? args[fromIdx + 1]
: (process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex')),
reroll: rerollIdx !== -1 ? Number(args[rerollIdx + 1]) : 0,
register: registerIdx !== -1 ? args[registerIdx + 1] : null,
mode: modeIdx !== -1 ? args[modeIdx + 1] : null,
grain: grainIdx !== -1 ? args[grainIdx + 1] : null,
platform: platformIdx !== -1 ? args[platformIdx + 1] : null,
@@ -553,6 +692,13 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
process.exitCode = 1;
}
// A raced-out fetch may still hold a socket; exit explicitly so the CLI
// never lingers on a dead network path after output is written.
// never lingers on a dead network path after output is written. Destroy
// fetch's global undici dispatcher first: process.exit() with a live
// keep-alive socket trips a libuv assertion on Windows and aborts the
// process after a successful roll (nodejs/node#56645).
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
if (dispatcher && typeof dispatcher.destroy === 'function') {
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
}
process.exit(process.exitCode ?? 0);
}
@@ -22,7 +22,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { loadContext, extractPlatform } from './context.mjs';
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
import { readLatestSnapshotAcrossTargets } from './critique-storage.mjs';
/** Is there code here at all, or just context files / an empty repo? */
function hasCode(cwd) {
@@ -34,23 +34,13 @@ function hasCode(cwd) {
}
/**
* The most recent critique snapshot across all targets. Filenames are
* timestamp-prefixed (`<iso>__<slug>.md`), so a lexical sort is chronological.
* Parses the small frontmatter for score + P0/P1 counts.
* Summarize the most recent critique snapshot across all targets.
*/
function latestCritique(cwd) {
try {
const dir = getCritiqueDir(cwd);
if (!fs.existsSync(dir)) return null;
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')).sort();
if (!files.length) return null;
const newest = files[files.length - 1];
const text = fs.readFileSync(path.join(dir, newest), 'utf-8');
const front = text.split('---')[1] || '';
const get = (k) => {
const m = front.match(new RegExp(`^${k}:\\s*(.+)$`, 'm'));
return m ? m[1].trim() : null;
};
const latest = readLatestSnapshotAcrossTargets({ cwd });
if (!latest) return null;
const get = (key) => latest.meta[key] ?? null;
const num = (v) => {
const n = Number(v);
return Number.isFinite(n) ? n : null;
@@ -61,7 +51,7 @@ function latestCritique(cwd) {
p0: num(get('p0')),
p1: num(get('p1')),
timestamp: get('timestamp'),
file: path.relative(cwd, path.join(dir, newest)),
file: path.relative(cwd, latest.path),
};
} catch {
return null;
@@ -105,28 +105,37 @@ function parseFrontmatter(text) {
}
/**
* Return all snapshot files for `slug`, sorted oldest newest.
* Return snapshot files matching `suffix`, sorted oldest newest.
*/
function listSnapshotsForSlug(slug, cwd) {
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/;
function listSnapshots(suffix, cwd) {
const dir = getCritiqueDir(cwd);
if (!fs.existsSync(dir)) return [];
const suffix = `__${slug}.md`;
return fs.readdirSync(dir)
.filter((f) => f.endsWith(suffix))
.filter((f) => SNAPSHOT_FILENAME.test(f) && f.endsWith(suffix))
.sort()
.map((f) => path.join(dir, f));
}
function readLatestSnapshotMatching(suffix, cwd) {
const filePath = listSnapshots(suffix, cwd).at(-1);
if (!filePath) return null;
const body = fs.readFileSync(filePath, 'utf-8');
return { path: filePath, body, meta: parseFrontmatter(body) };
}
/**
* Return the most recent snapshot for `slug`, or null. Polish reads this
* to find its fix backlog when the slug matches.
*/
export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
const all = listSnapshotsForSlug(slug, cwd);
if (!all.length) return null;
const latest = all[all.length - 1];
const body = fs.readFileSync(latest, 'utf-8');
return { path: latest, body, meta: parseFrontmatter(body) };
return readLatestSnapshotMatching(`__${slug}.md`, cwd);
}
/** Return the most recent snapshot across all targets, or null. */
export function readLatestSnapshotAcrossTargets({ cwd = process.cwd() } = {}) {
return readLatestSnapshotMatching('.md', cwd);
}
/**
@@ -134,7 +143,7 @@ export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
* Critique appends a one-line trend to its output using this.
*/
export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
const all = listSnapshotsForSlug(slug, cwd);
const all = listSnapshots(`__${slug}.md`, cwd);
const slice = all.slice(-limit);
return slice.map((file) => parseFrontmatter(fs.readFileSync(file, 'utf-8')));
}
@@ -683,6 +683,10 @@ if (IS_BROWSER) {
const reasons = collectVisualContrastReasons(el, style);
if (reasons.length === 0) continue;
// Image-only mode filters here, inside the cap: gradient/opacity/filter
// candidates earlier in DOM order must not consume the budget and
// starve the url()-backed texts this mode exists to sample.
if (options.imageOnly && !reasons.includes('image background')) continue;
const textColor = parseRgb(style.color);
const fontSize = parseFloat(style.fontSize) || 16;
@@ -1175,6 +1179,7 @@ if (IS_BROWSER) {
}
async function analyzeVisualContrast(options = {}) {
// imageOnly is enforced inside the collector, before the candidate cap.
const candidates = collectVisualContrastCandidates(options);
const results = [];
const shouldScrollOffscreen = options.scrollOffscreen === true;
@@ -1260,9 +1265,16 @@ if (IS_BROWSER) {
function addBrowserFindings(groupMap, el, findings) {
if (!findings || findings.length === 0) return;
// Element-scoped waivers: a data-impeccable-ignore ancestor suppresses
// matching findings for its whole subtree. Applied at this choke point so
// every per-element attribution (checks, layout, occlusion, rhythm)
// honors it; page-level findings attributed to <body> pass through
// untouched, since body has no ignoring ancestor.
const kept = findings.filter(f => !scopedIgnoreActive(el, f.type));
if (kept.length === 0) return;
const existing = groupMap.get(el);
if (existing) existing.push(...findings);
else groupMap.set(el, [...findings]);
if (existing) existing.push(...kept);
else groupMap.set(el, [...kept]);
}
function browserFindingsFromMap(groupMap) {
@@ -1620,9 +1632,27 @@ if (IS_BROWSER) {
for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) {
node.remove();
}
const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML);
if (htmlPatternFindings.length > 0) {
const mapped = htmlPatternFindings.map(f => {
// Regex findings that name a live selector resolve against the real DOM:
// pseudo-element/class segments are stripped (the host element is the
// anchor), a selector that matches nothing on this page drops the finding
// (the CSS ships here, but the pattern never renders — the live DOM is
// ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level.
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
if (!f.selector) return true;
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false;
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
});
if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => {
const item = { type: f.id, detail: f.snippet };
if (f.severity) {
item.severity = f.severity;
@@ -1652,8 +1682,27 @@ if (IS_BROWSER) {
};
}
// Visual contrast has three modes. Explicit true runs the full sampled
// pass; explicit false disables it entirely (the deterministic-only mode
// the test suites use). Unset — the default overlay run — samples ONLY
// image-backed text: the one class the analytic walk deliberately skips,
// because a url() layer's pixels are unknowable without looking. In-page
// sampling draws the source image alone to a canvas (glyph ink never
// pollutes it), and a cross-origin image without CORS reports unresolved
// instead of guessing.
function visualContrastMode(options = {}) {
const explicit = typeof options.visualContrast === 'boolean'
? options.visualContrast
: typeof window.__IMPECCABLE_CONFIG__?.visualContrast === 'boolean'
? window.__IMPECCABLE_CONFIG__.visualContrast
: null;
if (explicit === true) return 'full';
if (explicit === false) return false;
return 'image-only';
}
function shouldRunVisualContrast(options = {}) {
return options.visualContrast === true || window.__IMPECCABLE_CONFIG__?.visualContrast === true;
return visualContrastMode(options) !== false;
}
function visualContrastOptions(options = {}) {
@@ -1830,6 +1879,7 @@ if (IS_BROWSER) {
return [];
}
const resolvedOptions = visualContrastOptions(options);
if (visualContrastMode(options) === 'image-only') resolvedOptions.imageOnly = true;
const analyses = await analyzeVisualContrast(resolvedOptions);
if (runtime.generation && runtime.generation !== scanGeneration) return analyses;
lastVisualContrastAnalyses = analyses;
@@ -861,6 +861,34 @@ function checkBorders(tag, widths, colors, radius, opts = {}) {
return findings;
}
// ─── Scoped ignores: data-impeccable-ignore ─────────────────────────────────
//
// An element-scoped waiver that travels with the markup: any element carrying
// `data-impeccable-ignore="rule-a rule-b"` (or `*`, or an empty value, for
// every rule) suppresses matching findings from itself and its entire subtree,
// in every engine that walks elements — the browser overlay, the extension,
// and the static scan. This is the DOM twin of the line-based
// `impeccable-disable` comment directives, which the browser cannot apply (a
// live DOM has no line numbers), and the generalization of the one-off
// `data-impeccable-allow-kickers` opt-out.
//
// The intended use is curated exhibits: a page that documents anti-patterns by
// example, or renders a deliberate "before" specimen, marks the container once
// and every engine skips it while still scanning the page around it.
function scopedIgnoreActive(el, ruleId) {
const rule = String(ruleId || '').toLowerCase();
let cur = el;
while (cur && cur.nodeType === 1) {
const attr = typeof cur.getAttribute === 'function' ? cur.getAttribute('data-impeccable-ignore') : null;
if (attr != null) {
const rules = String(attr).trim().toLowerCase().split(/[\s,]+/).filter(Boolean);
if (rules.length === 0 || rules.includes('*') || rules.includes(rule)) return true;
}
cur = cur.parentElement;
}
return false;
}
// Returns true if the given text is composed entirely of emoji characters
// (plus whitespace / variation selectors). Emojis render as multicolor glyphs
// regardless of CSS `color`, so contrast checks against the element's text
@@ -1428,6 +1456,26 @@ function cssTextHasDarkRootBg(content, customProps) {
return false;
}
// Best-effort extraction of the CSS selector whose declaration block contains
// the given index in raw CSS text. Lets CSS-text findings carry a live-DOM
// anchor, so the browser pass can resolve scoped ignores against the actual
// element and drop patterns that render nowhere on the page. Returns null for
// @-rule preludes, keyframe steps, nested blocks, and anything that does not
// read as a selector; those findings stay page-level.
function enclosingCssSelector(cssText, index) {
if (!cssText || !Number.isFinite(index)) return null;
const open = cssText.lastIndexOf('{', index);
if (open === -1) return null;
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
const raw = cssText.slice(prevClose + 1, open).trim().replace(/\s+/g, ' ');
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from`
// and `to` would read as (never-matching) type selectors and get a valid
// finding wrongly dropped by the zero-match rule downstream.
if (/^(?:from|to)(?:\s*,\s*(?:from|to))*$/i.test(raw)) return null;
return raw;
}
function scanCssTextForGlow(content) {
const customProps = collectCssCustomProps(content);
const hasDarkBg = cssTextHasDarkRootBg(content, customProps);
@@ -1739,6 +1787,7 @@ function scanCssTextForPseudoStripe(rawContent) {
id: 'side-tab',
snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`,
index: selectorStart,
selector,
});
}
return findings;
@@ -1801,6 +1850,7 @@ function scanCssTextForInsetStripe(content) {
findings.push({
id: 'side-tab',
snippet: `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`,
selector,
});
break;
}
@@ -1858,7 +1908,7 @@ function collectMarqueeKeyframes(content) {
function scanCssTextForMarquee(content, markup = content) {
const findings = [];
if (/<marquee\b/i.test(markup)) {
findings.push({ id: 'marquee', snippet: '<marquee> element' });
findings.push({ id: 'marquee', snippet: '<marquee> element', selector: 'marquee' });
}
const marqueeKeyframes = collectMarqueeKeyframes(content);
if (marqueeKeyframes.size === 0) return findings;
@@ -1873,7 +1923,7 @@ function scanCssTextForMarquee(content, markup = content) {
const key = `${selector} ${name}`;
if (seen.has(key)) continue;
seen.add(key);
findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"` });
findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"`, selector });
}
}
return findings;
@@ -2244,8 +2294,10 @@ function checkHtmlPatterns(html, corpora) {
const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
if (purpleHexRe.test(styleText)) {
const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
if (purpleTextRe.test(styleText)) {
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
purpleTextRe.lastIndex = 0;
const purpleMatch = purpleTextRe.exec(styleText);
if (purpleMatch) {
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected', selector: enclosingCssSelector(styleText, purpleMatch.index + 1) || undefined });
}
}
@@ -2256,7 +2308,7 @@ function checkHtmlPatterns(html, corpora) {
const start = Math.max(0, gm.index - 200);
const context = styleText.substring(start, gm.index + gm[0].length + 200);
if (/gradient/i.test(context)) {
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient', selector: enclosingCssSelector(styleText, gm.index) || undefined });
break;
}
}
@@ -2322,7 +2374,7 @@ function checkHtmlPatterns(html, corpora) {
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()}` });
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}`, selector: enclosingCssSelector(styleText, bounceMatch.index) || undefined });
}
// Overshoot cubic-bezier
@@ -2331,7 +2383,7 @@ function checkHtmlPatterns(html, corpora) {
while ((bm = bezierRe.exec(styleText)) !== null) {
const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})`, selector: enclosingCssSelector(styleText, bm.index) || undefined });
break;
}
}
@@ -2364,18 +2416,21 @@ function checkHtmlPatterns(html, corpora) {
const glowHits = scanCssTextForGlow(styleText);
if (glowHits.length > 0) {
findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet });
findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet, selector: enclosingCssSelector(styleText, glowHits[0].index) || undefined });
}
// Radial-gradient background halo (gradient-drawn sibling of dark-glow)
const haloHits = scanCssTextForRadialHalo(styleText);
if (haloHits.length > 0) {
findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet });
findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet, selector: enclosingCssSelector(styleText, haloHits[0].index) || undefined });
}
// --- Generated-UI tells: repeating-gradient stripes ---
if (/repeating-(?:linear|radial|conic)-gradient\s*\(/i.test(styleText)) {
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });
{
const stripesMatch = /repeating-(?:linear|radial|conic)-gradient\s*\(/i.exec(styleText);
if (stripesMatch) {
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes', selector: enclosingCssSelector(styleText, stripesMatch.index) || undefined });
}
}
// --- Generated-UI tells: two-axis grid-line background ---
@@ -2393,7 +2448,7 @@ function checkHtmlPatterns(html, corpora) {
// whole gradient layers.
const gridHits = scanCssTextForGridBackground(styleText);
if (gridHits.length > 0) {
findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet });
findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet, selector: enclosingCssSelector(styleText, gridHits[0].index) || undefined });
}
// --- Generated-copy tells: "X theater" framing copy ---
@@ -2413,8 +2468,11 @@ function checkHtmlPatterns(html, corpora) {
// hover:rotate / hover:translate utility on an <img>. Each distinct
// mechanism is its own finding.
const imgHoverCss = /\bimg\b[^,{}]*:hover\b[^{}]*\{[^}]*\btransform\s*:\s*(?:scale|rotate|translate|matrix|skew)/i;
if (imgHoverCss.test(styleText)) {
findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule' });
{
const imgHoverMatch = imgHoverCss.exec(styleText);
if (imgHoverMatch) {
findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule', selector: enclosingCssSelector(styleText, imgHoverMatch.index + imgHoverMatch[0].indexOf('{') + 1) || undefined });
}
}
const imgTagRe = /<img\b[^>]*\bclass\s*=\s*"([^"]*)"/gi;
let im;
@@ -2461,6 +2519,33 @@ function readOwnBackgroundColor(el, computedStyle) {
return bg;
}
// One element's background-color as the cascade walk sees it: computed style
// first (with the modern-color fallback), then, in static mode only,
// custom-prop resolution and the inline-shorthand peek. Shared by
// resolveBackground and resolveGradientStops so both walks read the same
// surfaces.
function readCascadeBackgroundColor(current, style, customPropMap) {
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
// The static engine can return literal "var(--X)" / "oklch(...)" strings.
// Resolve through customPropMap so Tailwind v4 color tokens become RGB.
if (customPropMap) {
bg = parseColorResolved(style.backgroundColor, customPropMap);
}
if (!bg || bg.a < 0.1) {
// Inline-style fallback for colors the static cascade did not surface
// on backgroundColor.
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
}
}
}
return bg;
}
function resolveBackground(el, win, customPropMap) {
let current = el;
// Translucent layers (0.1 < a < 1) found on the way down to an opaque
@@ -2489,24 +2574,7 @@ function resolveBackground(el, win, customPropMap) {
// body backgrounds.
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
// (e.g. any color-mix() result), which plain parseRgb misses.
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
// jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve
// through customPropMap so Tailwind v4 color tokens become RGB.
if (customPropMap) {
bg = parseColorResolved(style.backgroundColor, customPropMap);
}
if (!bg || bg.a < 0.1) {
// Inline-style fallback. jsdom doesn't decompose background
// shorthand, so colors set via inline style are otherwise invisible.
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
}
}
}
const bg = readCascadeBackgroundColor(current, style, customPropMap);
if (bg && bg.a > 0.1) {
if (bg.a >= 0.99) return flatten(bg);
@@ -2524,40 +2592,95 @@ function resolveBackground(el, win, customPropMap) {
// • on other elements: bail to null and let the caller fall back
// to gradient stops (gradient buttons / hero sections are real
// bgs worth checking against).
if (hasGradientOrUrl) {
if (current.tagName === 'BODY' || current.tagName === 'HTML') {
return flatten({ r: 255, g: 255, b: 255, a: 1 });
}
return null;
}
// A gradient or image with no solid color under it, at any level
// including body/html, means the visible ground is that layer itself.
// Return null so the caller measures against the actual gradient stops,
// or skips when nothing is parseable — skipping beats a wrong ratio.
//
// Body/html used to assume white here, a guard written for jsdom, which
// never decomposed the `background:` shorthand and so could not see the
// solid paper color a texture gradient usually sits on. It turned every
// light-on-dark page into a wall of low-contrast false positives (a dark
// oklch body gradient produced ~120 "on #ffffff" findings on one site).
// Both engines can see shorthand solids now — the browser natively, the
// static cascade via expandStaticDeclaration + var() resolution — so a
// missing solid is real, and the old failure case cannot recur: opaque
// stops fully cover any hidden solid (they ARE the ground), alpha stops
// composite over the resolved base or the white canvas default, and
// unresolvable stops drop rather than guess.
if (hasGradientOrUrl) return null;
current = current.parentElement;
}
return flatten({ r: 255, g: 255, b: 255, a: 1 });
}
// parseGradientColors (shared) reads only the legacy serializations: rgb()
// and hex stops. Browsers keep modern-space stops in computed backgroundImage
// exactly as authored — `linear-gradient(oklch(7% 0.006 95), …)` stays oklch —
// which is what every token-driven page produces. Route those through
// parseAnyColor so a gradient ground is measurable rather than invisible.
function parseGradientColorsModern(bgImage) {
if (!bgImage || !/gradient/i.test(bgImage)) return [];
const colors = parseGradientColors(bgImage);
for (const m of bgImage.matchAll(/(?:oklch|oklab|hsla?|hwb)\(\s*[^()]*\)/gi)) {
const c = parseAnyColor(m[0]);
if (c) colors.push(c);
}
return colors;
}
// Walk parents looking for a gradient background and return its color stops.
// Used as a fallback when resolveBackground() returns null because the
// effective background is a gradient (no single solid color to compare against).
// Translucent solid layers found between the element and the gradient (frosted
// panels, glass washes) are composited over every stop, the same way
// resolveBackground flattens them over a solid base — raw stops alone would
// false-flag dark text on a light frosted wash over a dark gradient, and miss
// the inverse.
function resolveGradientStops(el, win, customPropMap) {
let current = el;
const overlays = [];
while (current && current.nodeType === 1) {
const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
const bgImage = style.backgroundImage || '';
// A url() layer anywhere in the stack — alone, or alongside a gradient in
// the same declaration (a translucent wash over a texture photo) — paints
// pixels the analytic walk cannot know. Measuring the gradient stops over
// the wrong base flagged dark ink sitting on a bright gold-leaf image at
// 2.6:1; skipping beats a wrong ratio, and the screenshot subsystem owns
// image-backed text.
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
let stops = null;
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
const parsed = parseGradientColors(bgImage);
const parsed = parseGradientColorsModern(bgImage);
if (parsed.length > 0) stops = parsed;
}
if (!stops && !DETECTOR_IS_BROWSER) {
// jsdom doesn't decompose `background:` shorthand — peek at the raw inline style
// Static mode: peek at the raw inline style for gradients the cascade did not surface
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
if (bgMatch && /gradient/i.test(bgMatch[1])) {
const parsed = parseGradientColors(bgMatch[1]);
const parsed = parseGradientColorsModern(bgMatch[1]);
if (parsed.length > 0) stops = parsed;
}
}
if (stops) return compositeGradientStops(stops, current, win, customPropMap);
if (stops) {
const composited = compositeGradientStops(stops, current, win, customPropMap);
if (!composited || overlays.length === 0) return composited;
return composited.map(stop => {
let acc = stop;
for (let i = overlays.length - 1; i >= 0; i--) acc = compositeColorOver(overlays[i], acc);
return acc;
});
}
const bg = readCascadeBackgroundColor(current, style, customPropMap);
if (bg && bg.a > 0.1) {
// An opaque surface above the gradient means the gradient never shows
// through here; resolveBackground would have returned it, so reaching
// this is defensive — bail rather than measure the wrong layer.
if (bg.a >= 0.99) return null;
overlays.push(bg);
}
current = current.parentElement;
}
return null;
@@ -2777,6 +2900,10 @@ function checkElementColorsDOM(el) {
const rect = el.getBoundingClientRect();
if (rect.width < 10 || rect.height < 10) return [];
const style = getComputedStyle(el);
// Invisible at rest: hidden scene variants (opacity-0 carousels, swap
// decks) are not user-visible, and measuring their inherited colors against
// whatever surface happens to sit behind the stack is noise, not audit.
if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
let effectiveBg = resolveBackground(el);
@@ -3589,11 +3716,13 @@ function checkElementGlowDOM(el) {
// If resolveBackground returns null (gradient), try to infer from the gradient colors
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
if (!parentBg) {
// Gradient background — sample its colors to determine if it's dark
// Gradient background — sample its colors to determine if it's dark.
// Modern-syntax parsing matters here: body-level gradients now reach this
// fallback in browser mode, and their stops usually serialize as oklch.
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const bgImage = getComputedStyle(cur).backgroundImage || '';
const gradColors = parseGradientColors(bgImage);
const gradColors = parseGradientColorsModern(bgImage);
if (gradColors.length > 0) {
// Average the gradient colors
const avg = { r: 0, g: 0, b: 0 };
@@ -4435,6 +4564,14 @@ function checkElementBorders(tag, style, overrides, resolvedRadius, el = null) {
}
function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) {
// Invisible at rest, static twin of the browser walk's skip: opacity does
// not inherit, so walk ancestors multiplying declared opacity down.
if (style.visibility === 'hidden') return [];
let effOpacity = 1;
for (let cur = el; cur && cur.nodeType === 1 && effOpacity > 0.02; cur = cur.parentElement) {
effOpacity *= parseFloat(window.getComputedStyle(cur).opacity || '1');
}
if (effOpacity <= 0.02) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
@@ -5593,6 +5730,11 @@ function isRenderedForBrowserRule(el) {
function checkElementTextOverflowDOM(el) {
const tag = el.tagName.toLowerCase();
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
// scrollWidth/clientWidth are CSS box-model metrics; on SVG content Chrome
// returns arbitrary non-zero values for both (a <text> reported 78/48 while
// its rendered length sat comfortably inside its box), so the delta is
// noise, not overflow. SVG clips to its own viewport anyway.
if (el.namespaceURI === 'http://www.w3.org/2000/svg') 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.
@@ -5977,6 +6119,22 @@ function isPaintedForOcclusion(el) {
// path is pure geometry and runs anywhere on the page.
const OCCLUSION_TEXT_SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'title']);
// An element whose effective opacity multiplies out to ~0 paints nothing at
// rest: it is not user-visible, so visual findings on it (contrast, occlusion)
// measure a state nobody sees. Browser-only — the walk needs live computed
// styles. Cycling scenes that fade such elements in later are the screenshot
// subsystem's territory, not the analytic walk's.
function effectiveOpacityDOM(el) {
let o = 1;
// Walk all the way through body and html: `body { opacity: 0 }` page-fade
// wrappers hide every descendant just as thoroughly as a local wrapper.
for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {
o *= parseFloat(getComputedStyle(cur).opacity || '1');
if (o <= 0.02) return 0;
}
return o;
}
function checkTextOcclusionDOM() {
const findings = [];
const seenVictims = new Set();
@@ -6004,6 +6162,11 @@ function checkTextOcclusionDOM() {
}
return false;
};
// The classic occluder shape this rules out is an opacity-0 interaction
// layer — a range scrubber stretched over a before/after comparison — which
// elementFromPoint still returns and whose UA background-color otherwise
// reads as an opaque box.
const effectiveOpacity = effectiveOpacityDOM;
// Collect renderable text owners in / near the first viewport for the
// elementFromPoint probe. SVG <text> counts too.
@@ -6016,6 +6179,7 @@ function checkTextOcclusionDOM() {
const text = inSvg ? (el.textContent || '').trim() : elementDirectText(el);
if (text.length < 2) continue;
if (!isPaintedForOcclusion(el)) continue;
if (effectiveOpacity(el) <= 0.02) continue;
let rect; try { rect = el.getBoundingClientRect(); } catch { continue; }
if (rect.width < 6 || rect.height < 6) continue;
// Viewport-bound probe: keep text whose box overlaps the live viewport.
@@ -6049,6 +6213,7 @@ function checkTextOcclusionDOM() {
if (top === el || el.contains(top) || top.contains(el)) continue;
const topCs = getComputedStyle(top);
if (isFloated(topCs) || isMarqueeish(top, topCs) || isPinnedOverlay(top)) continue;
if (effectiveOpacity(top) <= 0.02) continue;
const topTag = top.tagName.toLowerCase();
// Text sitting under a raw image/video is contrast territory (deduped
// against the pixel low-contrast rule); leave those alone here.
@@ -6941,6 +7106,10 @@ if (IS_BROWSER) {
const reasons = collectVisualContrastReasons(el, style);
if (reasons.length === 0) continue;
// Image-only mode filters here, inside the cap: gradient/opacity/filter
// candidates earlier in DOM order must not consume the budget and
// starve the url()-backed texts this mode exists to sample.
if (options.imageOnly && !reasons.includes('image background')) continue;
const textColor = parseRgb(style.color);
const fontSize = parseFloat(style.fontSize) || 16;
@@ -7433,6 +7602,7 @@ if (IS_BROWSER) {
}
async function analyzeVisualContrast(options = {}) {
// imageOnly is enforced inside the collector, before the candidate cap.
const candidates = collectVisualContrastCandidates(options);
const results = [];
const shouldScrollOffscreen = options.scrollOffscreen === true;
@@ -7518,9 +7688,16 @@ if (IS_BROWSER) {
function addBrowserFindings(groupMap, el, findings) {
if (!findings || findings.length === 0) return;
// Element-scoped waivers: a data-impeccable-ignore ancestor suppresses
// matching findings for its whole subtree. Applied at this choke point so
// every per-element attribution (checks, layout, occlusion, rhythm)
// honors it; page-level findings attributed to <body> pass through
// untouched, since body has no ignoring ancestor.
const kept = findings.filter(f => !scopedIgnoreActive(el, f.type));
if (kept.length === 0) return;
const existing = groupMap.get(el);
if (existing) existing.push(...findings);
else groupMap.set(el, [...findings]);
if (existing) existing.push(...kept);
else groupMap.set(el, [...kept]);
}
function browserFindingsFromMap(groupMap) {
@@ -7878,9 +8055,27 @@ if (IS_BROWSER) {
for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) {
node.remove();
}
const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML);
if (htmlPatternFindings.length > 0) {
const mapped = htmlPatternFindings.map(f => {
// Regex findings that name a live selector resolve against the real DOM:
// pseudo-element/class segments are stripped (the host element is the
// anchor), a selector that matches nothing on this page drops the finding
// (the CSS ships here, but the pattern never renders — the live DOM is
// ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level.
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
if (!f.selector) return true;
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false;
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
});
if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => {
const item = { type: f.id, detail: f.snippet };
if (f.severity) {
item.severity = f.severity;
@@ -7910,8 +8105,27 @@ if (IS_BROWSER) {
};
}
// Visual contrast has three modes. Explicit true runs the full sampled
// pass; explicit false disables it entirely (the deterministic-only mode
// the test suites use). Unset — the default overlay run — samples ONLY
// image-backed text: the one class the analytic walk deliberately skips,
// because a url() layer's pixels are unknowable without looking. In-page
// sampling draws the source image alone to a canvas (glyph ink never
// pollutes it), and a cross-origin image without CORS reports unresolved
// instead of guessing.
function visualContrastMode(options = {}) {
const explicit = typeof options.visualContrast === 'boolean'
? options.visualContrast
: typeof window.__IMPECCABLE_CONFIG__?.visualContrast === 'boolean'
? window.__IMPECCABLE_CONFIG__.visualContrast
: null;
if (explicit === true) return 'full';
if (explicit === false) return false;
return 'image-only';
}
function shouldRunVisualContrast(options = {}) {
return options.visualContrast === true || window.__IMPECCABLE_CONFIG__?.visualContrast === true;
return visualContrastMode(options) !== false;
}
function visualContrastOptions(options = {}) {
@@ -8088,6 +8302,7 @@ if (IS_BROWSER) {
return [];
}
const resolvedOptions = visualContrastOptions(options);
if (visualContrastMode(options) === 'image-only') resolvedOptions.imageOnly = true;
const analyses = await analyzeVisualContrast(resolvedOptions);
if (runtime.generation && runtime.generation !== scanGeneration) return analyses;
lastVisualContrastAnalyses = analyses;
@@ -35,6 +35,7 @@ export { detectUrl, createBrowserDetector } from './engines/browser/detect-url.m
export { detectText, extractStyleBlocks, extractCSSinJS } from './engines/regex/detect-text.mjs';
export {
walkDir,
hasScannableExtension,
SCANNABLE_EXTENSIONS,
SKIP_DIRS,
buildImportGraph,
@@ -226,6 +226,10 @@ const STATIC_INHERITED_PROPS = new Set([
'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
'webkitHyphens',
// visibility inherits in real CSS, and the invisible-at-rest contrast skip
// relies on descendants of a hidden container computing as hidden. A child
// that declares `visibility: visible` still overrides the inherited value.
'visibility',
]);
const STATIC_DEFAULT_STYLE = {
@@ -278,6 +282,7 @@ const STATIC_DEFAULT_STYLE = {
marginLeft: '0px',
position: 'static',
visibility: 'visible',
opacity: '1',
top: 'auto',
right: 'auto',
bottom: 'auto',
@@ -334,6 +339,7 @@ const STATIC_PROP_MAP = {
'margin-left': 'marginLeft',
'position': 'position',
'visibility': 'visibility',
'opacity': 'opacity',
'top': 'top',
'right': 'right',
'bottom': 'bottom',
@@ -28,6 +28,7 @@ import {
checkCreamPalette,
checkHtmlPatterns,
checkKickerAboveHeadingFromDoc,
scopedIgnoreActive,
checkNumberedSectionLabelsFromDoc,
checkPageLayout,
checkPageQualityFromDoc,
@@ -138,10 +139,21 @@ async function detectHtml(filePath, options = {}) {
domutils,
};
});
} catch {
return detectText(html, filePath, options);
} catch (err) {
if (!globalThis.__impeccableStaticHtmlWarned) {
globalThis.__impeccableStaticHtmlWarned = true;
process.stderr.write(
'impeccable detect: DEGRADED - HTML parser modules unavailable ' +
'(htmlparser2, css-select, css-tree, domutils).\n' +
'Falling back to regex matching. Custom properties, selector matching and computed ' +
'contrast are NOT evaluated; findings are an undercount, not a clean bill of health.\n'
);
}
return detectText(html, filePath, options);
}
const resolvedPath = path.resolve(filePath);
const fileDir = path.dirname(resolvedPath);
const root = profileStep(profile, {
@@ -171,6 +183,9 @@ async function detectHtml(filePath, options = {}) {
const tag = el.tagName.toLowerCase();
const style = window.getComputedStyle(el);
for (const f of runElementCheck(rule.id, () => rule.run(el, tag, style, window, customPropMap))) {
// Element-scoped waivers: a data-impeccable-ignore ancestor suppresses
// matching findings for its subtree, same as the browser walk.
if (scopedIgnoreActive(el, f.id)) continue;
findings.push(finding(f.id, filePath, f.snippet));
}
}
@@ -238,6 +253,17 @@ async function detectHtml(filePath, options = {}) {
for (const f of runPageCheck('html-patterns', () => checkHtmlPatterns(html, patternCorpora).filter(item =>
item.id !== 'bounce-easing' && item.id !== 'layout-transition'
))) {
// Selector-backed page findings honor scoped waivers here too, matching
// the browser pass: resolve the selector and drop the finding when an
// ignoring ancestor covers a match. Unlike the browser, an unmatched
// selector keeps the finding — static scans see partial documents.
if (f.selector) {
let matches = null;
try {
matches = document.querySelectorAll(String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim());
} catch { matches = null; }
if (matches && matches.length > 0 && [...matches].every(el => scopedIgnoreActive(el, f.id))) continue;
}
const item = finding(f.id, filePath, f.snippet);
// Position-aware severity promotion: checks may attach a per-finding
// severity (e.g. a pulsing dot inside a header/nav landmark) that
@@ -26,11 +26,20 @@ const HIDDEN_SOURCE_DIRS = new Set(['.vitepress', '.vuepress', '.storybook']);
const SCANNABLE_EXTENSIONS = new Set([
'.html', '.htm', '.css', '.scss', '.sass', '.less',
'.jsx', '.tsx', '.js', '.ts',
'.vue', '.svelte', '.astro',
'.vue', '.svelte', '.astro', '.blade.php',
]);
const HTML_EXTENSIONS = new Set(['.html', '.htm']);
function hasScannableExtension(filename) {
const lower = filename.toLowerCase();
if (SCANNABLE_EXTENSIONS.has(path.extname(lower))) return true;
for (const ext of SCANNABLE_EXTENSIONS) {
if (ext.indexOf('.', 1) !== -1 && lower.endsWith(ext)) return true;
}
return false;
}
const IMPORT_SPECIFIER_PATTERNS = [
/import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g,
/@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g,
@@ -46,7 +55,7 @@ function walkDir(dir) {
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full));
else if (SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(full);
else if (hasScannableExtension(entry.name)) files.push(full);
}
return files;
}
@@ -194,6 +203,7 @@ export {
SKIP_DIRS,
SCANNABLE_EXTENSIONS,
HTML_EXTENSIONS,
hasScannableExtension,
walkDir,
resolveImport,
buildImportGraph,
@@ -70,6 +70,34 @@ function checkBorders(tag, widths, colors, radius, opts = {}) {
return findings;
}
// ─── Scoped ignores: data-impeccable-ignore ─────────────────────────────────
//
// An element-scoped waiver that travels with the markup: any element carrying
// `data-impeccable-ignore="rule-a rule-b"` (or `*`, or an empty value, for
// every rule) suppresses matching findings from itself and its entire subtree,
// in every engine that walks elements — the browser overlay, the extension,
// and the static scan. This is the DOM twin of the line-based
// `impeccable-disable` comment directives, which the browser cannot apply (a
// live DOM has no line numbers), and the generalization of the one-off
// `data-impeccable-allow-kickers` opt-out.
//
// The intended use is curated exhibits: a page that documents anti-patterns by
// example, or renders a deliberate "before" specimen, marks the container once
// and every engine skips it while still scanning the page around it.
function scopedIgnoreActive(el, ruleId) {
const rule = String(ruleId || '').toLowerCase();
let cur = el;
while (cur && cur.nodeType === 1) {
const attr = typeof cur.getAttribute === 'function' ? cur.getAttribute('data-impeccable-ignore') : null;
if (attr != null) {
const rules = String(attr).trim().toLowerCase().split(/[\s,]+/).filter(Boolean);
if (rules.length === 0 || rules.includes('*') || rules.includes(rule)) return true;
}
cur = cur.parentElement;
}
return false;
}
// Returns true if the given text is composed entirely of emoji characters
// (plus whitespace / variation selectors). Emojis render as multicolor glyphs
// regardless of CSS `color`, so contrast checks against the element's text
@@ -637,6 +665,26 @@ function cssTextHasDarkRootBg(content, customProps) {
return false;
}
// Best-effort extraction of the CSS selector whose declaration block contains
// the given index in raw CSS text. Lets CSS-text findings carry a live-DOM
// anchor, so the browser pass can resolve scoped ignores against the actual
// element and drop patterns that render nowhere on the page. Returns null for
// @-rule preludes, keyframe steps, nested blocks, and anything that does not
// read as a selector; those findings stay page-level.
function enclosingCssSelector(cssText, index) {
if (!cssText || !Number.isFinite(index)) return null;
const open = cssText.lastIndexOf('{', index);
if (open === -1) return null;
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
const raw = cssText.slice(prevClose + 1, open).trim().replace(/\s+/g, ' ');
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from`
// and `to` would read as (never-matching) type selectors and get a valid
// finding wrongly dropped by the zero-match rule downstream.
if (/^(?:from|to)(?:\s*,\s*(?:from|to))*$/i.test(raw)) return null;
return raw;
}
function scanCssTextForGlow(content) {
const customProps = collectCssCustomProps(content);
const hasDarkBg = cssTextHasDarkRootBg(content, customProps);
@@ -948,6 +996,7 @@ function scanCssTextForPseudoStripe(rawContent) {
id: 'side-tab',
snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`,
index: selectorStart,
selector,
});
}
return findings;
@@ -1010,6 +1059,7 @@ function scanCssTextForInsetStripe(content) {
findings.push({
id: 'side-tab',
snippet: `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`,
selector,
});
break;
}
@@ -1067,7 +1117,7 @@ function collectMarqueeKeyframes(content) {
function scanCssTextForMarquee(content, markup = content) {
const findings = [];
if (/<marquee\b/i.test(markup)) {
findings.push({ id: 'marquee', snippet: '<marquee> element' });
findings.push({ id: 'marquee', snippet: '<marquee> element', selector: 'marquee' });
}
const marqueeKeyframes = collectMarqueeKeyframes(content);
if (marqueeKeyframes.size === 0) return findings;
@@ -1082,7 +1132,7 @@ function scanCssTextForMarquee(content, markup = content) {
const key = `${selector} ${name}`;
if (seen.has(key)) continue;
seen.add(key);
findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"` });
findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"`, selector });
}
}
return findings;
@@ -1453,8 +1503,10 @@ function checkHtmlPatterns(html, corpora) {
const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
if (purpleHexRe.test(styleText)) {
const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
if (purpleTextRe.test(styleText)) {
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
purpleTextRe.lastIndex = 0;
const purpleMatch = purpleTextRe.exec(styleText);
if (purpleMatch) {
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected', selector: enclosingCssSelector(styleText, purpleMatch.index + 1) || undefined });
}
}
@@ -1465,7 +1517,7 @@ function checkHtmlPatterns(html, corpora) {
const start = Math.max(0, gm.index - 200);
const context = styleText.substring(start, gm.index + gm[0].length + 200);
if (/gradient/i.test(context)) {
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient', selector: enclosingCssSelector(styleText, gm.index) || undefined });
break;
}
}
@@ -1531,7 +1583,7 @@ function checkHtmlPatterns(html, corpora) {
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()}` });
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}`, selector: enclosingCssSelector(styleText, bounceMatch.index) || undefined });
}
// Overshoot cubic-bezier
@@ -1540,7 +1592,7 @@ function checkHtmlPatterns(html, corpora) {
while ((bm = bezierRe.exec(styleText)) !== null) {
const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})`, selector: enclosingCssSelector(styleText, bm.index) || undefined });
break;
}
}
@@ -1573,18 +1625,21 @@ function checkHtmlPatterns(html, corpora) {
const glowHits = scanCssTextForGlow(styleText);
if (glowHits.length > 0) {
findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet });
findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet, selector: enclosingCssSelector(styleText, glowHits[0].index) || undefined });
}
// Radial-gradient background halo (gradient-drawn sibling of dark-glow)
const haloHits = scanCssTextForRadialHalo(styleText);
if (haloHits.length > 0) {
findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet });
findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet, selector: enclosingCssSelector(styleText, haloHits[0].index) || undefined });
}
// --- Generated-UI tells: repeating-gradient stripes ---
if (/repeating-(?:linear|radial|conic)-gradient\s*\(/i.test(styleText)) {
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });
{
const stripesMatch = /repeating-(?:linear|radial|conic)-gradient\s*\(/i.exec(styleText);
if (stripesMatch) {
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes', selector: enclosingCssSelector(styleText, stripesMatch.index) || undefined });
}
}
// --- Generated-UI tells: two-axis grid-line background ---
@@ -1602,7 +1657,7 @@ function checkHtmlPatterns(html, corpora) {
// whole gradient layers.
const gridHits = scanCssTextForGridBackground(styleText);
if (gridHits.length > 0) {
findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet });
findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet, selector: enclosingCssSelector(styleText, gridHits[0].index) || undefined });
}
// --- Generated-copy tells: "X theater" framing copy ---
@@ -1622,8 +1677,11 @@ function checkHtmlPatterns(html, corpora) {
// hover:rotate / hover:translate utility on an <img>. Each distinct
// mechanism is its own finding.
const imgHoverCss = /\bimg\b[^,{}]*:hover\b[^{}]*\{[^}]*\btransform\s*:\s*(?:scale|rotate|translate|matrix|skew)/i;
if (imgHoverCss.test(styleText)) {
findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule' });
{
const imgHoverMatch = imgHoverCss.exec(styleText);
if (imgHoverMatch) {
findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule', selector: enclosingCssSelector(styleText, imgHoverMatch.index + imgHoverMatch[0].indexOf('{') + 1) || undefined });
}
}
const imgTagRe = /<img\b[^>]*\bclass\s*=\s*"([^"]*)"/gi;
let im;
@@ -1670,6 +1728,33 @@ function readOwnBackgroundColor(el, computedStyle) {
return bg;
}
// One element's background-color as the cascade walk sees it: computed style
// first (with the modern-color fallback), then, in static mode only,
// custom-prop resolution and the inline-shorthand peek. Shared by
// resolveBackground and resolveGradientStops so both walks read the same
// surfaces.
function readCascadeBackgroundColor(current, style, customPropMap) {
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
// The static engine can return literal "var(--X)" / "oklch(...)" strings.
// Resolve through customPropMap so Tailwind v4 color tokens become RGB.
if (customPropMap) {
bg = parseColorResolved(style.backgroundColor, customPropMap);
}
if (!bg || bg.a < 0.1) {
// Inline-style fallback for colors the static cascade did not surface
// on backgroundColor.
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
}
}
}
return bg;
}
function resolveBackground(el, win, customPropMap) {
let current = el;
// Translucent layers (0.1 < a < 1) found on the way down to an opaque
@@ -1698,24 +1783,7 @@ function resolveBackground(el, win, customPropMap) {
// body backgrounds.
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
// (e.g. any color-mix() result), which plain parseRgb misses.
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
// jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve
// through customPropMap so Tailwind v4 color tokens become RGB.
if (customPropMap) {
bg = parseColorResolved(style.backgroundColor, customPropMap);
}
if (!bg || bg.a < 0.1) {
// Inline-style fallback. jsdom doesn't decompose background
// shorthand, so colors set via inline style are otherwise invisible.
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
}
}
}
const bg = readCascadeBackgroundColor(current, style, customPropMap);
if (bg && bg.a > 0.1) {
if (bg.a >= 0.99) return flatten(bg);
@@ -1733,40 +1801,95 @@ function resolveBackground(el, win, customPropMap) {
// • on other elements: bail to null and let the caller fall back
// to gradient stops (gradient buttons / hero sections are real
// bgs worth checking against).
if (hasGradientOrUrl) {
if (current.tagName === 'BODY' || current.tagName === 'HTML') {
return flatten({ r: 255, g: 255, b: 255, a: 1 });
}
return null;
}
// A gradient or image with no solid color under it, at any level
// including body/html, means the visible ground is that layer itself.
// Return null so the caller measures against the actual gradient stops,
// or skips when nothing is parseable — skipping beats a wrong ratio.
//
// Body/html used to assume white here, a guard written for jsdom, which
// never decomposed the `background:` shorthand and so could not see the
// solid paper color a texture gradient usually sits on. It turned every
// light-on-dark page into a wall of low-contrast false positives (a dark
// oklch body gradient produced ~120 "on #ffffff" findings on one site).
// Both engines can see shorthand solids now — the browser natively, the
// static cascade via expandStaticDeclaration + var() resolution — so a
// missing solid is real, and the old failure case cannot recur: opaque
// stops fully cover any hidden solid (they ARE the ground), alpha stops
// composite over the resolved base or the white canvas default, and
// unresolvable stops drop rather than guess.
if (hasGradientOrUrl) return null;
current = current.parentElement;
}
return flatten({ r: 255, g: 255, b: 255, a: 1 });
}
// parseGradientColors (shared) reads only the legacy serializations: rgb()
// and hex stops. Browsers keep modern-space stops in computed backgroundImage
// exactly as authored — `linear-gradient(oklch(7% 0.006 95), …)` stays oklch —
// which is what every token-driven page produces. Route those through
// parseAnyColor so a gradient ground is measurable rather than invisible.
function parseGradientColorsModern(bgImage) {
if (!bgImage || !/gradient/i.test(bgImage)) return [];
const colors = parseGradientColors(bgImage);
for (const m of bgImage.matchAll(/(?:oklch|oklab|hsla?|hwb)\(\s*[^()]*\)/gi)) {
const c = parseAnyColor(m[0]);
if (c) colors.push(c);
}
return colors;
}
// Walk parents looking for a gradient background and return its color stops.
// Used as a fallback when resolveBackground() returns null because the
// effective background is a gradient (no single solid color to compare against).
// Translucent solid layers found between the element and the gradient (frosted
// panels, glass washes) are composited over every stop, the same way
// resolveBackground flattens them over a solid base — raw stops alone would
// false-flag dark text on a light frosted wash over a dark gradient, and miss
// the inverse.
function resolveGradientStops(el, win, customPropMap) {
let current = el;
const overlays = [];
while (current && current.nodeType === 1) {
const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
const bgImage = style.backgroundImage || '';
// A url() layer anywhere in the stack — alone, or alongside a gradient in
// the same declaration (a translucent wash over a texture photo) — paints
// pixels the analytic walk cannot know. Measuring the gradient stops over
// the wrong base flagged dark ink sitting on a bright gold-leaf image at
// 2.6:1; skipping beats a wrong ratio, and the screenshot subsystem owns
// image-backed text.
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
let stops = null;
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
const parsed = parseGradientColors(bgImage);
const parsed = parseGradientColorsModern(bgImage);
if (parsed.length > 0) stops = parsed;
}
if (!stops && !DETECTOR_IS_BROWSER) {
// jsdom doesn't decompose `background:` shorthand — peek at the raw inline style
// Static mode: peek at the raw inline style for gradients the cascade did not surface
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
if (bgMatch && /gradient/i.test(bgMatch[1])) {
const parsed = parseGradientColors(bgMatch[1]);
const parsed = parseGradientColorsModern(bgMatch[1]);
if (parsed.length > 0) stops = parsed;
}
}
if (stops) return compositeGradientStops(stops, current, win, customPropMap);
if (stops) {
const composited = compositeGradientStops(stops, current, win, customPropMap);
if (!composited || overlays.length === 0) return composited;
return composited.map(stop => {
let acc = stop;
for (let i = overlays.length - 1; i >= 0; i--) acc = compositeColorOver(overlays[i], acc);
return acc;
});
}
const bg = readCascadeBackgroundColor(current, style, customPropMap);
if (bg && bg.a > 0.1) {
// An opaque surface above the gradient means the gradient never shows
// through here; resolveBackground would have returned it, so reaching
// this is defensive — bail rather than measure the wrong layer.
if (bg.a >= 0.99) return null;
overlays.push(bg);
}
current = current.parentElement;
}
return null;
@@ -1986,6 +2109,10 @@ function checkElementColorsDOM(el) {
const rect = el.getBoundingClientRect();
if (rect.width < 10 || rect.height < 10) return [];
const style = getComputedStyle(el);
// Invisible at rest: hidden scene variants (opacity-0 carousels, swap
// decks) are not user-visible, and measuring their inherited colors against
// whatever surface happens to sit behind the stack is noise, not audit.
if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
let effectiveBg = resolveBackground(el);
@@ -2798,11 +2925,13 @@ function checkElementGlowDOM(el) {
// If resolveBackground returns null (gradient), try to infer from the gradient colors
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
if (!parentBg) {
// Gradient background — sample its colors to determine if it's dark
// Gradient background — sample its colors to determine if it's dark.
// Modern-syntax parsing matters here: body-level gradients now reach this
// fallback in browser mode, and their stops usually serialize as oklch.
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const bgImage = getComputedStyle(cur).backgroundImage || '';
const gradColors = parseGradientColors(bgImage);
const gradColors = parseGradientColorsModern(bgImage);
if (gradColors.length > 0) {
// Average the gradient colors
const avg = { r: 0, g: 0, b: 0 };
@@ -3644,6 +3773,14 @@ function checkElementBorders(tag, style, overrides, resolvedRadius, el = null) {
}
function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) {
// Invisible at rest, static twin of the browser walk's skip: opacity does
// not inherit, so walk ancestors multiplying declared opacity down.
if (style.visibility === 'hidden') return [];
let effOpacity = 1;
for (let cur = el; cur && cur.nodeType === 1 && effOpacity > 0.02; cur = cur.parentElement) {
effOpacity *= parseFloat(window.getComputedStyle(cur).opacity || '1');
}
if (effOpacity <= 0.02) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
@@ -4802,6 +4939,11 @@ function isRenderedForBrowserRule(el) {
function checkElementTextOverflowDOM(el) {
const tag = el.tagName.toLowerCase();
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
// scrollWidth/clientWidth are CSS box-model metrics; on SVG content Chrome
// returns arbitrary non-zero values for both (a <text> reported 78/48 while
// its rendered length sat comfortably inside its box), so the delta is
// noise, not overflow. SVG clips to its own viewport anyway.
if (el.namespaceURI === 'http://www.w3.org/2000/svg') 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.
@@ -5186,6 +5328,22 @@ function isPaintedForOcclusion(el) {
// path is pure geometry and runs anywhere on the page.
const OCCLUSION_TEXT_SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'title']);
// An element whose effective opacity multiplies out to ~0 paints nothing at
// rest: it is not user-visible, so visual findings on it (contrast, occlusion)
// measure a state nobody sees. Browser-only — the walk needs live computed
// styles. Cycling scenes that fade such elements in later are the screenshot
// subsystem's territory, not the analytic walk's.
function effectiveOpacityDOM(el) {
let o = 1;
// Walk all the way through body and html: `body { opacity: 0 }` page-fade
// wrappers hide every descendant just as thoroughly as a local wrapper.
for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {
o *= parseFloat(getComputedStyle(cur).opacity || '1');
if (o <= 0.02) return 0;
}
return o;
}
function checkTextOcclusionDOM() {
const findings = [];
const seenVictims = new Set();
@@ -5213,6 +5371,11 @@ function checkTextOcclusionDOM() {
}
return false;
};
// The classic occluder shape this rules out is an opacity-0 interaction
// layer — a range scrubber stretched over a before/after comparison — which
// elementFromPoint still returns and whose UA background-color otherwise
// reads as an opaque box.
const effectiveOpacity = effectiveOpacityDOM;
// Collect renderable text owners in / near the first viewport for the
// elementFromPoint probe. SVG <text> counts too.
@@ -5225,6 +5388,7 @@ function checkTextOcclusionDOM() {
const text = inSvg ? (el.textContent || '').trim() : elementDirectText(el);
if (text.length < 2) continue;
if (!isPaintedForOcclusion(el)) continue;
if (effectiveOpacity(el) <= 0.02) continue;
let rect; try { rect = el.getBoundingClientRect(); } catch { continue; }
if (rect.width < 6 || rect.height < 6) continue;
// Viewport-bound probe: keep text whose box overlaps the live viewport.
@@ -5258,6 +5422,7 @@ function checkTextOcclusionDOM() {
if (top === el || el.contains(top) || top.contains(el)) continue;
const topCs = getComputedStyle(top);
if (isFloated(topCs) || isMarqueeish(top, topCs) || isPinnedOverlay(top)) continue;
if (effectiveOpacity(top) <= 0.02) continue;
const topTag = top.tagName.toLowerCase();
// Text sitting under a raw image/video is contrast territory (deduped
// against the pixel low-contrast rule); leave those alone here.
@@ -5468,6 +5633,7 @@ export {
CSS_NAMED_COLORS,
checkBorders,
isEmojiOnlyText,
scopedIgnoreActive,
checkColors,
checkHoverContrast,
checkElementHoverContrast,
@@ -16,11 +16,15 @@ import path from 'node:path';
import {
ALLOWED_EXTS,
DEFAULT_CONFIG,
EDIT_COUNT_THRESHOLD,
GENERATED_PATH,
SENSITIVE_PATH,
appendDesignSystemNote,
appendDesignSystemNoteOnce,
commitFooterShown,
designNoteReserve,
designSystemOptions,
footerModeForSession,
filterFindings,
isNativePlatform,
isScanTargetInsideProject,
@@ -345,13 +349,32 @@ async function detectProposedHtml(detector, content, filePath, scanOptions) {
}
}
function cursorBlockMessage(findings, filePath, config, cwd) {
const rendered = renderTemplate(findings, filePath, config, { cwd });
const blocked = rendered.replace(
// Cursor caps deny messages around 4000 chars. The cap feeds through the
// renderer's clamp, which preserves the policy footer, rather than tail-
// slicing the rendered text, which cut the footer off any message the
// default 8000-char budget let past 4000.
const CURSOR_DENY_LIMIT = 4000;
const BLOCK_PREFIX = 'Impeccable design hook blocked this write before it landed. ';
function cursorBlockMessage(findings, filePath, config, cwd, footerMode, reserveChars) {
const limits = config?.limits || DEFAULT_CONFIG.limits;
// Charge the prefix via reserveChars, not by subtracting from maxChars:
// renderTemplate's 500-char floor re-raises any maxChars pushed below it,
// un-charging a prefix subtracted from maxChars (Greptile P1 on PR #508).
// reserveChars comes off after the floor, so the prefix is charged at every
// config tier and the final prefixed message plus a pending staleness note
// fits the binding limit. Default-config output is byte-identical.
const budget = Math.min(
limits.maxChars || DEFAULT_CONFIG.limits.maxChars,
CURSOR_DENY_LIMIT,
);
const rendered = renderTemplate(findings, filePath,
{ ...config, limits: { ...limits, maxChars: budget } },
{ cwd, footer: footerMode, reserveChars: (reserveChars || 0) + BLOCK_PREFIX.length });
return 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',
`[impeccable@1] ${BLOCK_PREFIX}Design hook findings requiring review`,
);
return blocked.length > 4000 ? `${blocked.slice(0, 3984)}\n...(truncated)` : blocked;
}
function findingSignature(findings) {
@@ -468,9 +491,16 @@ async function main() {
});
}
const message = appendDesignSystemNote(cursorBlockMessage(filtered, filePath, config, cwd), scanOptions);
const sessionId = event.session_id || event.conversation_id || 'unknown';
const cache = readCache(cwd);
// Repeated denials for the same session repeat the findings, not the
// policy: the full footer emits once per session, the short form after.
const footerMode = footerModeForSession(cache, sessionId);
const message = appendDesignSystemNoteOnce(
cursorBlockMessage(filtered, filePath, config, cwd, footerMode, designNoteReserve(scanOptions, cache, sessionId)),
scanOptions, cache, sessionId, config,
);
commitFooterShown(cache, sessionId, message);
const denial = bumpCursorDenial(cache, sessionId, filePath, filtered);
persistCache(cwd, cache);
if (denial.count > EDIT_COUNT_THRESHOLD) {
+278 -88
View File
@@ -22,6 +22,9 @@
* dedupeAgainstCache(findings, cache, sessionId, filePath)
* renderTemplate(findings, filePath, config, opts)
* renderCleanAck(filePath, opts) / renderPendingAck(filePath, known, opts)
* appendDesignSystemNote(text, scanOptions) / appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config)
* designNoteReserve(scanOptions, cache, sessionId)
* footerModeForSession(cache, sessionId) / commitFooterShown(cache, sessionId, text)
* shouldEmitAckForFile(filePath, config?)
* writeAuditLog(env, entry)
* loadDetector() -> Promise<{ detectText, detectHtml }>
@@ -970,7 +973,13 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
if (!Array.isArray(findings) || findings.length === 0) return '';
const limits = config?.limits || DEFAULT_CONFIG.limits;
const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings);
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
// reserveChars holds back room for a note the caller appends after render
// (the DESIGN.md staleness note), so the final payload stays inside the
// configured budget. It comes off after the 500-char floor, so at floor
// configs the note keeps guaranteed delivery room; the clamp budget can
// therefore sit below 500, which clampLastLine's footer-preserving
// fallback handles (Bugbot on PR #508).
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0);
const cwd = opts.cwd || process.cwd();
const display = relativize(filePath, cwd);
@@ -979,11 +988,12 @@ export function renderTemplate(findings, filePath, config, opts = {}) {
const remaining = total - shown.length;
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review in ${display} (${total} issue(s)):`;
const lines = shown.map((f) => formatFindingLine(f));
const seenRules = new Set();
const lines = shown.map((f) => formatDedupedFindingLine(f, seenRules));
const more = remaining > 0
? `... and ${remaining} more (see ${IMPECCABLE_COMMAND} audit).`
: null;
const footer = directiveFooter(display);
const footer = directiveFooter({ mode: opts.footer });
const blocks = [header, ...lines];
if (more) blocks.push(more);
@@ -1007,12 +1017,15 @@ function renderGroupedTemplate(groups, config, opts = {}) {
const limits = config?.limits || DEFAULT_CONFIG.limits;
const cap = Math.max(1, limits.maxFindings || DEFAULT_CONFIG.limits.maxFindings);
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars);
const maxChars = Math.max(500, limits.maxChars || DEFAULT_CONFIG.limits.maxChars) - (opts.reserveChars || 0);
const cwd = opts.cwd || process.cwd();
const total = realGroups.reduce((sum, group) => sum + group.findings.length, 0);
const header = `${ENVELOPE_PREFIX} Design hook findings requiring review across ${realGroups.length} files (${total} issue(s)):`;
const lines = [];
let shownCount = 0;
// One seen-set across all groups: a rule already described under one file
// is not re-described under the next.
const seenRules = new Set();
for (const group of realGroups) {
const display = relativize(group.filePath, cwd);
@@ -1020,7 +1033,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
const remainingCap = Math.max(0, cap - shownCount);
const shown = group.findings.slice(0, remainingCap);
for (const finding of shown) {
lines.push(formatFindingLine(finding));
lines.push(formatDedupedFindingLine(finding, seenRules));
}
shownCount += shown.length;
const hidden = group.findings.length - shown.length;
@@ -1029,7 +1042,7 @@ function renderGroupedTemplate(groups, config, opts = {}) {
}
}
const footer = directiveFooter('the affected files', { grouped: true });
const footer = directiveFooter({ mode: opts.footer });
let text = [header, ...lines, '', footer].join('\n');
if (text.length > maxChars) {
text = clampGroupedToBudget(header, lines, footer, maxChars);
@@ -1037,82 +1050,149 @@ function renderGroupedTemplate(groups, config, opts = {}) {
return text;
}
// The clamp contract, shared by both budget functions: the footer is policy,
// not detail, so it survives every clamp. Try the requested footer first;
// when it cannot fit even after dropping finding lines, retry with the short
// policy rather than sacrifice findings that fit beside it. A result that
// dropped every finding line (a grouped render can fit a bare file header)
// does not count as a fit: findings are why the emission exists.
const isFindingLine = (line) => line.startsWith('- ');
function footerFallbacks(footer) {
const short = directiveFooter({ mode: 'short' });
return footer === short ? [footer] : [footer, short];
}
function clampGroupedToBudget(header, lines, footer, maxChars) {
const assemble = (linesArr, omitted) => [
const assemble = (linesArr, omitted, footerText) => [
header,
...linesArr,
...(omitted ? [`... and more (see ${IMPECCABLE_COMMAND} audit).`] : []),
'',
footer,
footerText,
].join('\n');
let working = lines.slice();
let omitted = false;
let assembled = assemble(working, omitted);
while (assembled.length > maxChars && working.length > 1) {
working.pop();
omitted = true;
assembled = assemble(working, omitted);
for (const footerText of footerFallbacks(footer)) {
let working = lines.slice();
let omitted = false;
let assembled = assemble(working, omitted, footerText);
while (assembled.length > maxChars && working.length > 1) {
working.pop();
omitted = true;
assembled = assemble(working, omitted, footerText);
}
if (assembled.length <= maxChars && working.some(isFindingLine)) return assembled;
}
if (assembled.length > maxChars) {
assembled = `${assembled.slice(0, maxChars - 1)}`;
}
return assembled;
return clampLastLine((linesArr, footerText) => assemble(linesArr, true, footerText),
lines.find(isFindingLine) || lines[0], maxChars);
}
function clampToBudget(header, lines, more, footer, maxChars) {
const assemble = (linesArr, moreText) => {
const assemble = (linesArr, moreText, footerText) => {
const blocks = [header, ...linesArr];
if (moreText) blocks.push(moreText);
blocks.push('');
blocks.push(footer);
blocks.push(footerText);
return blocks.join('\n');
};
let working = lines.slice();
let moreText = more;
let assembled = assemble(working, moreText);
while (assembled.length > maxChars && working.length > 1) {
working.pop();
moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`;
assembled = assemble(working, moreText);
let lastMore = more;
for (const footerText of footerFallbacks(footer)) {
let working = lines.slice();
let moreText = more;
let assembled = assemble(working, moreText, footerText);
while (assembled.length > maxChars && working.length > 1) {
working.pop();
moreText = `... and more (see ${IMPECCABLE_COMMAND} audit).`;
assembled = assemble(working, moreText, footerText);
}
lastMore = moreText;
if (assembled.length <= maxChars) return assembled;
}
if (assembled.length > maxChars) {
assembled = `${assembled.slice(0, maxChars - 1)}`;
}
return assembled;
return clampLastLine((linesArr, footerText) => assemble(linesArr, lastMore, footerText),
lines.find(isFindingLine) || lines[0], maxChars);
}
function formatFindingLine(f) {
// Last resort with one finding line left: the short policy gets the budget
// first, the line is clipped to what remains. The pre-fix tail-slice cut
// whatever happened to be last, which was always the footer.
function clampLastLine(build, line, maxChars) {
const footerText = directiveFooter({ mode: 'short' });
const bare = build([], footerText);
// +1 for the newline the line itself brings when it joins the blocks.
const room = maxChars - bare.length - 1;
if (room >= 24) {
const clipped = line.length > room ? `${line.slice(0, room - 1)}` : line;
return build([clipped], footerText);
}
// No room for even a clipped finding line: the note reservation can pull
// the budget below the 500-char floor, and a deep file path can push the
// header past what remains beside the short policy (Bugbot on PR #508).
// Drop the line, and if the bare header + policy still overflow, clip the
// head. Never tail-slice: the footer sits at the end, so a tail slice is
// exactly the footer cut this renderer exists to prevent.
if (bare.length <= maxChars) return bare;
const head = bare.slice(0, Math.max(0, maxChars - footerText.length - 4));
return `${head}\n\n${footerText}`;
}
// `compact` drops the registry description: within one emission the first
// occurrence of a rule carries the full description and repeats keep only the
// rule id, name, and their own ignore hint (values differ per line, so the
// hint must survive the dedupe).
function formatFindingLine(f, opts = {}) {
const prefix = f.line && f.line > 0 ? `- L${f.line}` : '-';
const desc = (f.description || '').trim();
const desc = opts.compact ? '' : (f.description || '').trim();
const name = (f.name || '').trim();
// Description from the registry already ends in punctuation; join with a
// single space. `name` may have a trailing period already, keep it clean.
const nameSegment = name ? `${name.replace(/\.+\s*$/, '')}.` : '';
const ignoreCommand = formatFindingIgnoreCommand(f);
const ignoreSegment = ignoreCommand
? ` If the user explicitly confirms this value is intentional: \`${ignoreCommand}\`.`
: '';
const ignoreHint = formatFindingIgnoreHint(f);
const ignoreSegment = ignoreHint ? ` If intentional: \`${ignoreHint}\`.` : '';
return `${prefix} [${f.antipattern}] ${nameSegment} ${desc}${ignoreSegment}`.replace(/\s+/g, ' ').trim();
}
function formatFindingIgnoreCommand(finding) {
// Dedupe applied in shown-line order, so the first rendered occurrence of a
// rule always carries the description. The budget clamps pop lines from the
// end, which can never orphan a compact repeat before its described first
// occurrence.
function formatDedupedFindingLine(finding, seenRules) {
const rule = normalizeIgnoreRule(finding?.antipattern);
const compact = rule ? seenRules.has(rule) : false;
if (rule) seenRules.add(rule);
return formatFindingLine(finding, { compact });
}
// The rule/value pair the footer's `hook-admin.mjs ignore-value` command
// takes. Deliberately just the args: the executable prefix, the --reason
// contract, and the disclosure rule live in the directive footer, stated once
// instead of per line.
function formatFindingIgnoreHint(finding) {
if (!finding || typeof finding !== 'object') return '';
const rule = normalizeIgnoreRule(finding.antipattern);
if (!rule) return '';
const normalizedValue = extractFindingIgnoreValue(finding);
if (!normalizedValue) return '';
const value = extractFindingIgnoreValueRaw(finding);
const valueArg = quoteCommandArg(value);
const reason = quoteCommandArg(`User confirmed ${value} is intentional`);
return `${IMPECCABLE_COMMAND} hooks ignore-value ${rule} ${valueArg} --shared --reason ${reason}`;
const valueArg = quoteCommandArg(extractFindingIgnoreValueRaw(finding));
return `ignore-value ${rule} ${valueArg}`;
}
function quoteCommandArg(value) {
const text = String(value || '').trim();
if (/^[A-Za-z0-9._:-]+$/.test(text)) return text;
return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
// The suggestion is meant to be run on this same machine, so quote for its
// shell. POSIX /bin/sh still expands $(...), backticks, and ${} inside
// double quotes, and these values come from scanned file content (a
// font-family name) or a file path, so untrusted input must be
// single-quoted (issue #476). Windows cmd.exe performs no such command
// substitution, but it treats a single quote as a literal character rather
// than a grouping delimiter, so a value or path containing spaces has to
// stay double-quoted there (Greptile #533). Keep the pre-existing
// double-quote escaping on Windows so that path's behavior is unchanged.
if (process.platform === 'win32') {
return `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}
return `'${text.replace(/'/g, `'\\''`)}'`;
}
function relativize(filePath, cwd) {
@@ -1594,36 +1674,105 @@ export function designSystemOptions(config, detector, projectCwd) {
}
}
const DESIGN_STALE_NOTE = `${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`;
export function appendDesignSystemNote(text, scanOptions) {
if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text;
return `${text}\n\n${ENVELOPE_PREFIX} DESIGN.md is newer than .impeccable/design.json. Run ${IMPECCABLE_COMMAND} document to refresh the design-system sidecar.`;
return `${text}\n\n${DESIGN_STALE_NOTE}`;
}
// Session-scoped once-only gate for repeat-prone message parts. Returns true
// the first time a flag is consumed in a session and false after, mirroring
// the `cleanAcked` mechanic: the mtime skew (and the policy footer) do not
// change between edits, so re-stating them on every emission spends context
// to say nothing new. Callers must persist the cache for the flag to stick.
function consumeSessionNoticeFlag(cache, sessionId, flag) {
const session = ensureSession(cache, sessionId);
if (session[flag]) return false;
session[flag] = true;
session.updatedAt = Date.now();
return true;
}
// Once-per-session variant of appendDesignSystemNote for the emission paths
// that have cache access. The staleness note names standing project state,
// not new information, so one mention per session is enough. The note is
// appended after the renderer has clamped to the configured budget: render
// paths reserve room for it via designNoteReserve, and the size check here
// is the safety net for the ack paths, deferring (without consuming the
// flag) to a later emission rather than busting maxChars.
export function appendDesignSystemNoteOnce(text, scanOptions, cache, sessionId, config) {
if (!text || !scanOptions?.designSystem?.mdNewerThanJson) return text;
const maxChars = Math.max(500, config?.limits?.maxChars || DEFAULT_CONFIG.limits.maxChars);
if (text.length + DESIGN_STALE_NOTE.length + 2 > maxChars) return text;
if (!consumeSessionNoticeFlag(cache, sessionId, 'designNoteShown')) return text;
return appendDesignSystemNote(text, scanOptions);
}
// Render-time reservation for the note above: how many characters the
// renderer must hold back so a pending staleness note still fits inside the
// configured budget. Zero once the session has seen the note. Without the
// reservation, a session whose every emission fills the budget would defer
// the note forever.
export function designNoteReserve(scanOptions, cache, sessionId) {
if (!scanOptions?.designSystem?.mdNewerThanJson) return 0;
if (ensureSession(cache, sessionId).designNoteShown) return 0;
return DESIGN_STALE_NOTE.length + 2;
}
// Full directive footer once per session, the short reminder after. Fresh
// emissions and Cursor denials share the session flag (`footerShown`), so a
// session pays the full policy exactly once however it first fires. The mode
// is a peek: the clamp can downgrade a requested full footer under a tight
// budget, so the flag commits only when the complete full policy actually
// reached the output. Matching the whole footer text (not a sentinel) keeps
// the flag honest against any truncation that spares the opening words.
export function footerModeForSession(cache, sessionId) {
return ensureSession(cache, sessionId).footerShown ? 'short' : 'full';
}
export function commitFooterShown(cache, sessionId, text) {
if (!text || !text.includes(directiveFooter())) return;
const session = ensureSession(cache, sessionId);
if (session.footerShown) return;
session.footerShown = true;
session.updatedAt = Date.now();
}
const HOOK_ADMIN_COMMAND = `node ${quoteCommandArg(path.join(__dirname, 'hook-admin.mjs'))}`;
// The directive footer is the part of the hook output that steers model
// behavior. Three intentional moves:
// 1. **Imperative, not advisory.** "Handle these..." beats "Consider
// revising..." which the model treats as a soft suggestion it can
// override when the user asked for any kind of throwaway / demo UI.
// 2. **Explicit judgment clause.** Without it, the model will try to
// "fix" intentional motion, bad fixtures, anti-pattern examples in
// docs, or test cases. Naming the judgment inline beats hoping the
// model infers it from context.
// 3. **Acknowledgement instruction.** Hook output is injected as
// developer-role context, not a chat turn, so the user never sees the
// raw envelope. Asking the model to surface the resolution in its
// reply is the cheapest way to make the feedback loop visible.
function directiveFooter(display, opts = {}) {
// Offer the rule-scoped-to-file form first. `ignore-file` silences every rule
// for the path forever, which is far more than one noisy rule on a real UI
// surface justifies, and it was previously the only option named here.
const target = opts.grouped ? '<path>' : quoteCommandArg(display);
const fileIgnoreGuidance = `run \`${IMPECCABLE_COMMAND} hooks ignore-value <id> "*" --file ${target}\` to scope just that rule to the file, or \`${IMPECCABLE_COMMAND} hooks ignore-file ${target}\` only when the whole file is out of scope for design review (a fixture, a generated artifact, a deliberate demo)`;
// behavior. Intentional moves, in order:
// 1. **Imperative, not advisory.** "Triage each finding..." beats
// "Consider revising...", which the model treats as a soft suggestion.
// 2. **Positive triage branches.** Fix / suppress-and-disclose / ask. The
// suppress branch names the calibration examples (demo, fixture,
// documented bad design, user-confirmed choice) because the agent now
// acts on its own confidence and needs the bar stated.
// 3. **Executable ignore path.** The old footer named only the slash
// command, which an agent reacting to hook output cannot run; the
// hook-admin.mjs invocation is runnable as-is and keeps agents out of
// hand-editing config.json.
// 4. **Honest provenance.** The --reason is the audit trail; "user
// confirmed" appears only when the user actually did.
// 5. **Acknowledgement instruction.** Hook output is injected as
// developer-role context, so the reply is where the user sees the
// resolution, including any ignore the agent persisted.
// 6. **Once per session.** The full policy emits on the session's first
// fire; later emissions carry the one-line short form (mode 'short').
function directiveFooter(opts = {}) {
if (opts.mode === 'short') {
// No command path here: the session's first emission already gave the
// runnable hook-admin.mjs invocation, and restating ~70 chars of absolute
// path on every repeat is the duplication this mode exists to cut.
return 'Triage per the session policy: fix real problems; persist confident false-positive or sanctioned-exception ignores via `hook-admin.mjs ignore-value` and disclose them in your reply; unsure, ask in one line.';
}
return [
'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',
'',
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
'',
`Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable <rule>\` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`${IMPECCABLE_COMMAND} hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`${IMPECCABLE_COMMAND} hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For a finding whose line shows no exact ignore-value command, such as \`side-tab\`, ${fileIgnoreGuidance}; use \`${IMPECCABLE_COMMAND} hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`,
'Triage each finding, then state in your reply what you fixed, what you suppressed, and what you left standing:',
'- Real design problem: fix it. Keep intentional design as designed.',
`- Confident false positive or sanctioned exception (an intentional demo or fixture, documentation of bad design, literal or domain-appropriate motion, a choice the user confirmed): persist the narrowest ignore yourself and disclose it. Run \`${HOOK_ADMIN_COMMAND} ignore-value <rule> "<value>" --reason "<who decided: evidence>"\` with the pair shown on the finding line, or value "*" plus \`--file <path>\` when the line shows none. Write "user confirmed" in a reason only when the user did.`,
'- Unsure: leave it as is and ask the user in one line.',
`Self-serve ends at ignore-value: \`ignore-file\` and \`ignore-rule\` need the user's explicit approval, and never add an ignore to push a blocked write through. Full suppression ladder: ${IMPECCABLE_COMMAND} hooks.`,
].join('\n');
}
@@ -1845,20 +1994,23 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
}
}
// Persist only when the write is earned: fresh findings justify creating
// `.impeccable/` (dedup and suppression need it), deferred findings do
// too (the Stop deep pass needs the touched-file list to surface them),
// and an already-present `.impeccable/` dir marks a project that opted
// in. A non-UI edit, or a clean UI edit in a project with no Impeccable
// footprint, must be a no-op on disk (issues #344, #305).
if (freshGroups.length > 0 || deferredTotal > 0
|| (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) {
persistCache(projectCwd, cache);
}
// The session notice flags mutate the cache, so they must settle before
// the persist that makes them stick across events.
if (freshGroups.length > 0) {
const firstGroup = freshGroups[0];
const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions);
const footerMode = footerModeForSession(cache, sessionId);
const text = appendDesignSystemNoteOnce(
renderGroupedTemplate(freshGroups, config, {
cwd: projectCwd,
footer: footerMode,
reserveChars: designNoteReserve(scanOptions, cache, sessionId),
}),
scanOptions, cache, sessionId, config,
);
commitFooterShown(cache, sessionId, text);
// Fresh findings always earn the cache write, including creating
// `.impeccable/`: dedup, suppression, and the notice flags need it.
persistCache(projectCwd, cache);
const allFindings = freshGroups.flatMap((group) => group.findings);
return {
exitCode: 0,
@@ -1881,6 +2033,33 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
};
}
// Resolve the ack emission before the persist below: appendDesignSystem-
// NoteOnce consumes a session flag, and the flag only sticks when the
// write happens after it. Quiet mode emits nothing, so it consumes
// nothing. The clean arm mirrors the branch order further down: pending
// outranks suppression, suppression outranks clean.
let ack = null;
if (!quietMode && pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) {
ack = {
kind: 'pending',
text: appendDesignSystemNoteOnce(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions, cache, sessionId, config),
};
} else if (!quietMode && !suppressionWinner && cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) {
ack = {
kind: 'clean',
text: appendDesignSystemNoteOnce(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions, cache, sessionId, config),
};
}
// Persist only when the write is earned: deferred findings need the
// touched-file list for the Stop deep pass, and an already-present
// `.impeccable/` dir marks a project that opted in. A non-UI edit, or a
// clean UI edit in a project with no Impeccable footprint, must be a
// no-op on disk (issues #344, #305).
if (deferredTotal > 0 || (cacheDirty && fs.existsSync(path.join(projectCwd, '.impeccable')))) {
persistCache(projectCwd, cache);
}
if (detectorThrewAny && !pendingWinner && !cleanWinner) {
return result({ emitted: false, error: 'detector-threw', durationMs: Date.now() - started });
}
@@ -1889,8 +2068,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
return result({ emitted: false, quiet: true, durationMs: Date.now() - started });
}
if (pendingWinner && shouldEmitAckForFile(pendingWinner.filePath, config)) {
const text = appendDesignSystemNote(renderPendingAck(pendingWinner.filePath, pendingWinner.known, { cwd: projectCwd }), scanOptions);
if (ack?.kind === 'pending') {
const text = ack.text;
return {
exitCode: 0,
stdout: payload(text, 'PostToolUse', harness),
@@ -1923,8 +2102,8 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
};
}
if (cleanWinner && !cleanAckDeduped && shouldEmitAckForFile(cleanWinner.filePath, config)) {
const text = appendDesignSystemNote(renderCleanAck(cleanWinner.filePath, { cwd: projectCwd }), scanOptions);
if (ack?.kind === 'clean') {
const text = ack.text;
return {
exitCode: 0,
stdout: payload(text, 'PostToolUse', harness),
@@ -2108,11 +2287,22 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
}
// Fresh findings earn the cache write so the next Stop fire is silent
// unless new issues appear.
persistCache(projectCwd, cache);
// A per-edit fire earlier in this session already consumed the footer
// flag, so the Stop wall of text carries the one-line short footer.
const footerMode = footerModeForSession(cache, sessionId);
const text = appendDesignSystemNoteOnce(
renderGroupedTemplate(freshGroups, config, {
cwd: projectCwd,
footer: footerMode,
reserveChars: designNoteReserve(scanOptions, cache, sessionId),
}),
scanOptions, cache, sessionId, config,
);
commitFooterShown(cache, sessionId, text);
const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions);
// Fresh findings earn the cache write so the next Stop fire is silent
// unless new issues appear; the notice flags ride along.
persistCache(projectCwd, cache);
return {
exitCode: 0,
stdout: payload(text, 'Stop', harness),
@@ -109,6 +109,18 @@ export function validateConceptEntry(concept, { existingForms = new Map(), axes
|| concept.tags.some(tag => typeof tag !== 'string' || !tag.trim())) {
errors.push(`concept ${id} must have exactly three structural tags`);
}
// The slop this world in particular is at risk of. Optional, because 541
// entries predate it and none of them are wrong for lacking it. A world built
// from posters is at risk of shouting and one built from instruments is at
// risk of dead greys; a global detector cannot know which, and the author can.
if (concept?.avoid !== undefined) {
if (!Array.isArray(concept.avoid)
|| concept.avoid.length < 2
|| concept.avoid.length > 3
|| concept.avoid.some(item => typeof item !== 'string' || item.trim().length < 12 || item.trim().length > 160)) {
errors.push(`concept ${id} avoid must be two or three negations of 12160 characters`);
}
}
if (!Array.isArray(concept?.system)
|| concept.system.length !== SYSTEM_PREFIXES.length
|| concept.system.some(rule => typeof rule !== 'string' || rule.trim().length < 12 || rule.trim().length > 180)) {
@@ -206,10 +206,10 @@ function parseIgnoreColor(value) {
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]);
const r = parseColorChannel(parts[0], COLOR_CHANNEL_FORMATS.rgb);
const g = parseColorChannel(parts[1], COLOR_CHANNEL_FORMATS.rgb);
const b = parseColorChannel(parts[2], COLOR_CHANNEL_FORMATS.rgb);
const a = parts[3] === undefined ? 1 : parseColorChannel(parts[3], COLOR_CHANNEL_FORMATS.alpha);
if ([r, g, b, a].some((v) => v === null)) return null;
return { r, g, b, a };
}
@@ -218,10 +218,10 @@ function parseIgnoreColor(value) {
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]);
const h = parseColorChannel(parts[0], COLOR_CHANNEL_FORMATS.hue);
const s = parseColorChannel(parts[1], COLOR_CHANNEL_FORMATS.percent);
const l = parseColorChannel(parts[2], COLOR_CHANNEL_FORMATS.percent);
const a = parts[3] === undefined ? 1 : parseColorChannel(parts[3], COLOR_CHANNEL_FORMATS.alpha);
if ([h, s, l, a].some((v) => v === null)) return null;
return hslToRgb(h, s, l, a);
}
@@ -230,18 +230,13 @@ function parseIgnoreColor(value) {
}
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 };
const expanded = hex.length <= 4
? [...hex].map((digit) => digit.repeat(2)).join('')
: hex;
const [r, g, b, alpha = 255] = expanded
.match(/../g)
.map((channel) => Number.parseInt(channel, 16));
return { r, g, b, a: alpha / 255 };
}
function splitColorArgs(body) {
@@ -259,47 +254,34 @@ function splitColorArgs(body) {
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);
}
const CSS_NUMBER_RE = /^(-?\d*\.?\d+)(%|deg|rad|turn|grad)?$/;
const identity = (value) => value;
const COLOR_CHANNEL_FORMATS = {
rgb: { units: { '': identity, '%': (value) => value * 2.55 }, min: 0, max: 255, round: true },
alpha: { units: { '': identity, '%': (value) => value / 100 }, min: 0, max: 1 },
hue: {
units: {
'': identity,
deg: identity,
rad: (value) => value * (180 / Math.PI),
turn: (value) => value * 360,
grad: (value) => value * 0.9,
},
},
percent: { units: { '%': (value) => value / 100 }, min: 0, max: 1 },
};
function parseAlphaChannel(raw) {
function parseColorChannel(raw, { units, min = -Infinity, max = Infinity, round = false }) {
const text = String(raw || '').trim();
const match = text.match(/^(-?\d*\.?\d+)(%)?$/);
const match = text.match(CSS_NUMBER_RE);
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;
const convert = units[match[2] || ''];
if (!convert) return null;
const number = Number.parseFloat(match[1]);
if (!Number.isFinite(number)) return null;
const value = convert(number);
if (value < min || value > max) return null;
return round ? Math.round(value) : value;
}
function hslToRgb(hue, saturation, lightness, alpha) {
@@ -13,7 +13,7 @@
* within the first ~300 characters catches non-git projects.
*/
import { execSync } from 'node:child_process';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
@@ -41,7 +41,10 @@ export function isGeneratedFile(filePath, options = {}) {
function isGitIgnored(absPath, cwd) {
try {
execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, {
// argv form, never a shell: this runs on every file the live-mode source
// walk reaches, so a hostile filename embedding $(...) or backticks must
// not be interpretable (issue #476). JSON.stringify is not shell quoting.
execFileSync('git', ['check-ignore', '--quiet', absPath], {
cwd,
stdio: 'ignore',
});
@@ -0,0 +1,26 @@
import { spawn } from 'node:child_process';
export function browserOpenCommand(url, {
platform = process.platform,
comspec = process.env.ComSpec || process.env.COMSPEC || 'cmd.exe',
} = {}) {
if (platform === 'darwin') return { command: 'open', args: [url] };
if (platform === 'win32') return { command: comspec, args: ['/c', 'start', '', url] };
return { command: 'xdg-open', args: [url] };
}
export function openSystemBrowser(url, {
platform = process.platform,
comspec = process.env.ComSpec || process.env.COMSPEC || 'cmd.exe',
spawnImpl = spawn,
} = {}) {
const { command, args } = browserOpenCommand(url, { platform, comspec });
try {
const child = spawnImpl(command, args, { stdio: 'ignore', detached: true });
child.on('error', () => {});
child.unref();
return true;
} catch {
return false;
}
}
@@ -244,7 +244,8 @@ const HOOK_MARKER = /skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs/;
// * bundle-relative: node ".agents/.../hook.mjs"
// * legacy unquoted: node .claude/.../hook.mjs
// * guarded (#399): [ ! -f "PATH" ] || node "PATH" (PATH twice, identical)
// * absolute: node "/Users/.../hook.mjs" (user-level installs)
// * absolute (#476): [ ! -f 'PATH' ] || node 'PATH' (single-quoted since
// the shell-injection fix; older installs double-quote)
// * github portable: node "$(git rev-parse --show-toplevel)/.../hook.mjs"
// A quoted path wins; the guard's two occurrences are identical, so the first
// quoted match is the path. Otherwise fall back to the whitespace/metachar-
@@ -255,6 +256,12 @@ function hookScriptTokenFrom(command) {
if (!HOOK_MARKER.test(str)) return null;
const quoted = str.match(/"([^"]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)"/);
if (quoted) return quoted[1];
// A path containing an apostrophe serializes as '\'' inside single quotes;
// no regex reassembles that, and the bare fallback would misread a fragment
// of it, so return null: the caller never asserts on a path it can't parse.
if (str.includes("'\\''")) return null;
const singleQuoted = str.match(/'([^']*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)'/);
if (singleQuoted) return singleQuoted[1];
const bare = str.match(/([^\s"'|&;()]*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)/);
return bare ? bare[1] : null;
}
@@ -97,23 +97,20 @@
return { value: c.value, label: c.label };
});
const LIVE_CHROME_MOUNT_CONTRACT = ['root', 'transport', 'state', 'actions'];
const LIVE_UI_SURFACES = [
{ key: 'global-bottom-bar', ids: [PREFIX + '-global-bar', PREFIX + '-global-bar-brand', PREFIX + '-pick-toggle', PREFIX + '-insert-toggle', PREFIX + '-detect-toggle', PREFIX + '-detect-badge', PREFIX + '-design-toggle', PREFIX + '-page-chat', PREFIX + '-page-chat-input', PREFIX + '-page-chat-voice', PREFIX + '-page-chat-send'] },
{ key: 'pending-copy-edit-dock', ids: [PREFIX + '-pending-dock'] },
{ key: 'element-selection-chrome', ids: [PREFIX + '-highlight', PREFIX + '-tooltip', PREFIX + '-bar', PREFIX + '-selection-pill', PREFIX + '-input', PREFIX + '-configure-voice', PREFIX + '-configure-bar-tooltip'] },
{ key: 'action-picker', ids: [PREFIX + '-picker'] },
{ key: 'edit-chrome', ids: [PREFIX + '-edit-badge'] },
{ key: 'generating-row', ids: [PREFIX + '-bar', PREFIX + '-shader'] },
{ key: 'variant-cycling-row', ids: [PREFIX + '-bar', PREFIX + '-params-panel'] },
{ key: 'variant-params-panel', ids: [PREFIX + '-params-panel'] },
{ key: 'saving-confirmed-rows', ids: [PREFIX + '-bar'] },
{ key: 'insert-mode-chrome', ids: [PREFIX + '-insert-line', PREFIX + '-insert-placeholder', PREFIX + '-placeholder-resize', PREFIX + '-insert-input', PREFIX + '-insert-voice', PREFIX + '-insert-create', PREFIX + '-insert-create-tooltip'] },
{ key: 'annotation-chrome', ids: [PREFIX + '-annot', PREFIX + '-annot-svg', PREFIX + '-annot-pins', PREFIX + '-annot-clear'] },
{ key: 'design-system-panel', ids: [PREFIX + '-design-host'] },
{ key: 'toasts-and-errors', ids: [PREFIX + '-toast', PREFIX + '-mount-error'] },
{ key: 'css-isolation-boundary', ids: [PREFIX + '-root'] },
];
// The Live chrome inventory (which surfaces exist, and the element ids each
// one owns) comes from the canonical source, skill/scripts/live/ui-surfaces.mjs,
// which the /live.js assembler serializes into these globals alongside the
// token/port/vocabulary. This file is served raw and injected as a classic
// script, so it cannot import that module; the private impeccable-site repo
// imports it directly to check its Live UI lab holds a snapshot for every
// surface, which only works while the list has exactly one definition.
// Add a surface in ui-surfaces.mjs, not here.
const LIVE_CHROME_MOUNT_CONTRACT = Array.isArray(window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__)
? window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__
: ['root', 'transport', 'state', 'actions'];
const LIVE_UI_SURFACES = Array.isArray(window.__IMPECCABLE_LIVE_UI_SURFACES__)
? window.__IMPECCABLE_LIVE_UI_SURFACES__
: [];
const LIVE_UI_COMPONENT_IDS = [...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids))];
//
@@ -14,10 +14,12 @@ import path from 'node:path';
import { createRequire } from 'node:module';
const DEFAULT_TIMEOUT_MS = 60_000;
const BATCH_OP_TEXT_LIMIT = 240;
const require = createRequire(import.meta.url);
export function buildCopyEditBatchPrompt(batch, { cwd = process.cwd() } = {}) {
const repairLines = batch?.repair ? [
const compactBatch = compactBatchForPrompt(batch);
const repairLines = compactBatch.repair ? [
'',
'Repair mode:',
'- The previous Apply attempt changed source, but validation failed.',
@@ -28,7 +30,7 @@ export function buildCopyEditBatchPrompt(batch, { cwd = process.cwd() } = {}) {
'- If failures or candidates show edited text is also a lookup key, update coupled count, animation, icon, image, asset, style, or metadata keys in the current source, or fail that entry without partial edits.',
'- Keep failed and notes as arrays.',
'- Return the same canonical JSON shape after repair.',
JSON.stringify(batch.repair, null, 2),
JSON.stringify(compactBatch.repair, null, 2),
] : [];
return [
'You are the Impeccable staged copy-edit batch applier.',
@@ -80,7 +82,7 @@ export function buildCopyEditBatchPrompt(batch, { cwd = process.cwd() } = {}) {
...repairLines,
'',
'Staged copy-edit batch:',
JSON.stringify(compactBatchForPrompt(batch), null, 2),
JSON.stringify(compactBatch, null, 2),
].join('\n');
}
@@ -292,7 +294,7 @@ function readManualEditValidationScript(cwd) {
function compactBatchForPrompt(batch) {
return {
pageUrl: batch?.pageUrl || null,
repair: batch?.repair || undefined,
repair: compactBatchRepair(batch?.repair),
entries: (batch?.entries || []).map((entry) => ({
id: entry.id,
pageUrl: entry.pageUrl,
@@ -300,7 +302,71 @@ function compactBatchForPrompt(batch) {
element: compactContextForBatch(entry.element),
ops: (entry.ops || []).map(compactBatchOp),
})),
candidates: batch?.candidates || [],
candidates: compactBatchCandidates(batch?.candidates),
};
}
function compactBatchRepair(repair) {
if (!repair || typeof repair !== 'object') return undefined;
return {
status: compactBatchString(repair.status),
attempt: normalizeOptionalBatchNumber(repair.attempt),
attempts: normalizeOptionalBatchNumber(repair.attempts),
maxAttempts: normalizeOptionalBatchNumber(repair.maxAttempts),
reason: compactBatchString(repair.reason),
transactionId: compactBatchString(repair.transactionId),
pageUrl: compactBatchString(repair.pageUrl),
failures: compactBatchDiagnostics(repair.failures),
files: compactBatchStringList(repair.files, 20),
};
}
function compactBatchDiagnostics(items, depth = 0) {
if (!Array.isArray(items)) return undefined;
return items.slice(0, 12).map((item) => ({
entryId: compactBatchString(item?.entryId || item?.id),
reason: compactBatchString(item?.reason || item?.kind),
detail: compactBatchString(item?.detail),
message: compactBatchString(item?.message),
file: compactBatchString(item?.file || item?.relativeFile),
line: normalizeOptionalBatchNumber(item?.line),
ref: compactBatchString(item?.ref),
marker: compactBatchString(item?.marker),
files: compactBatchStringList(item?.files, 8),
candidates: depth < 2 ? compactBatchSourceMatches(item?.candidates, 8) : undefined,
failures: depth < 2 ? compactBatchDiagnostics(item?.failures, depth + 1) : undefined,
checks: depth < 2 ? compactBatchDiagnostics(item?.checks, depth + 1) : undefined,
}));
}
function compactBatchCandidates(candidates) {
return (Array.isArray(candidates) ? candidates : [])
.slice(0, 24)
.map((candidate) => ({
entryId: compactBatchString(candidate?.entryId),
ref: compactBatchString(candidate?.ref),
sourceHint: compactBatchSourceMatch(candidate?.sourceHint),
textMatches: compactBatchSourceMatches(candidate?.textMatches, 8),
objectKeyMatches: compactBatchSourceMatches(candidate?.objectKeyMatches, 8),
contextTextMatches: compactBatchSourceMatches(candidate?.contextTextMatches, 8),
locatorMatches: compactBatchSourceMatches(candidate?.locatorMatches, 6),
}));
}
function compactBatchSourceMatches(matches, limit) {
if (!Array.isArray(matches)) return undefined;
return matches.slice(0, limit).map(compactBatchSourceMatch).filter(Boolean);
}
function compactBatchSourceMatch(match) {
if (!match || typeof match !== 'object') return null;
return {
file: compactBatchString(match.relativeFile || match.file),
line: normalizeBatchNumber(match.line),
column: normalizeBatchNumber(match.column),
kind: compactBatchString(match.kind),
reason: compactBatchString(match.reason || match.kind),
status: compactBatchString(match.status),
};
}
@@ -311,25 +377,77 @@ function compactBatchOp(op) {
contextRef: op.contextRef,
tag: op.tag,
elementId: op.elementId,
classes: op.classes,
classes: compactBatchStringList(op.classes, 24),
originalText: op.originalText,
newText: op.newText,
deleted: op.deleted === true || undefined,
sourceHint: op.sourceHint,
sourceHint: normalizeBatchSourceHint(op.sourceHint),
leaf: compactContextForBatch(op.leaf),
nearbyEditableTexts: Array.isArray(op.nearbyEditableTexts) ? op.nearbyEditableTexts.slice(0, 8) : [],
nearbyEditableTexts: compactNearbyBatchTexts(op.nearbyEditableTexts),
container: compactContextForBatch(op.container),
contextHints: Array.isArray(op.contextHints) ? op.contextHints.slice(0, 12) : [],
contextHints: compactBatchStringList(op.contextHints, 12),
};
}
function normalizeBatchSourceHint(hint) {
if (!hint || typeof hint !== 'object') return null;
let line = normalizeBatchNumber(hint.line);
let column = normalizeBatchNumber(hint.column);
if ((line === null || column === null) && typeof hint.loc === 'string') {
const match = hint.loc.match(/^(\d+)(?::(\d+))?/);
if (match) {
line = Number(match[1]);
if (match[2]) column = Number(match[2]);
}
}
return {
file: compactBatchString(hint.file) || '',
loc: compactBatchString(hint.loc) || '',
line,
column,
};
}
function normalizeBatchNumber(value) {
if (value === null || value === undefined || value === '') return null;
const number = Number(value);
return Number.isFinite(number) ? number : null;
}
function normalizeOptionalBatchNumber(value) {
const number = normalizeBatchNumber(value);
return number === null ? undefined : number;
}
function compactNearbyBatchTexts(items) {
return (Array.isArray(items) ? items : [])
.slice(0, 8)
.map((item) => typeof item === 'string' ? { text: truncate(item, BATCH_OP_TEXT_LIMIT) } : {
ref: compactBatchString(item?.ref),
tag: compactBatchString(item?.tag),
classes: compactBatchStringList(item?.classes, 24),
text: compactBatchString(item?.text),
});
}
function compactBatchStringList(items, limit) {
return (Array.isArray(items) ? items : [])
.slice(0, limit)
.filter((item) => typeof item === 'string')
.map((item) => truncate(item, BATCH_OP_TEXT_LIMIT));
}
function compactBatchString(value) {
return typeof value === 'string' ? truncate(value, BATCH_OP_TEXT_LIMIT) : undefined;
}
function compactContextForBatch(value) {
if (!value || typeof value !== 'object') return value || null;
return {
ref: value.ref,
tagName: value.tagName,
id: value.id,
classes: value.classes,
ref: compactBatchString(value.ref),
tagName: compactBatchString(value.tagName),
id: compactBatchString(value.id),
classes: compactBatchStringList(value.classes, 24),
textContent: truncate(value.textContent, 900),
outerHTML: truncate(stripLiveRuntimeHtml(value.outerHTML), 1800),
};
@@ -470,12 +588,11 @@ function runClaude(prompt, { cwd, env, resultPath, logPath, timeoutMs = DEFAULT_
if (env.IMPECCABLE_LIVE_COPY_AGENT_MODEL) {
args.push('--model', env.IMPECCABLE_LIVE_COPY_AGENT_MODEL);
}
args.push(prompt);
// Forward env as-is so CLAUDE_CODE_OAUTH_TOKEN and ANTHROPIC_API_KEY flow
// through. On macOS, `claude /login` stores creds in the Keychain, which a
// non-TTY subprocess cannot read; setting CLAUDE_CODE_OAUTH_TOKEN (via
// `claude setup-token`) is the supported headless auth path.
return runAgentProcess('claude', args, '', { cwd, env, logPath, timeoutMs, mirrorOutputPath: resultPath });
return runAgentProcess('claude', args, prompt, { cwd, env, logPath, timeoutMs, mirrorOutputPath: resultPath });
}
function runAgentProcess(command, args, stdin, { cwd, env, logPath, timeoutMs, mirrorOutputPath }) {
+10 -4
View File
@@ -17,7 +17,7 @@
* node live.mjs --help
*/
import { execSync } from 'node:child_process';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -316,11 +316,17 @@ function globToRegex(pattern) {
function runScript(name, args, options = {}) {
const scriptPath = path.join(__dirname, name);
const cmd = `node "${scriptPath}" ${args.map(a => `"${a}"`).join(' ')}`;
try {
return execSync(cmd, { encoding: 'utf-8', cwd: options.cwd || process.cwd(), timeout: 15_000 });
// argv form, never a shell: string interpolation into double quotes would
// let a `"` or `$(...)` in any future caller's arg escape into the shell
// (issue #476).
return execFileSync(process.execPath, [scriptPath, ...args], {
encoding: 'utf-8',
cwd: options.cwd || process.cwd(),
timeout: 15_000,
});
} catch (err) {
// execSync throws on non-zero exit; return stdout if any
// execFileSync throws on non-zero exit; return stdout if any
return err.stdout || err.message || '';
}
}
@@ -1,6 +1,8 @@
import fs from 'node:fs';
import path from 'node:path';
import { LIVE_CHROME_MOUNT_CONTRACT, LIVE_UI_SURFACES } from './ui-surfaces.mjs';
export const LIVE_BROWSER_SCRIPT_PARTS = Object.freeze([
Object.freeze({ name: 'session-state', file: 'live-browser-session.js' }),
Object.freeze({ name: 'dom-helpers', file: 'live-browser-dom.js' }),
@@ -32,7 +34,20 @@ export function readLiveBrowserScriptParts(parts, readFile = (filePath) => fs.re
}));
}
export function assembleLiveBrowserScript({ token, port, vocabulary, commandPrefix = '/', appRoot = null, parts }) {
export function assembleLiveBrowserScript({
token,
port,
vocabulary,
commandPrefix = '/',
appRoot = null,
parts,
// Defaulted rather than threaded through live-server.mjs: the browser bundle
// must always carry the canonical inventory, and a default makes that true by
// construction instead of by every caller remembering to pass it. Overridable
// so tests can assemble with a stand-in.
uiSurfaces = LIVE_UI_SURFACES,
mountContract = LIVE_CHROME_MOUNT_CONTRACT,
}) {
const prelude =
`window.__IMPECCABLE_TOKEN__ = '${token}';\n` +
`window.__IMPECCABLE_PORT__ = ${port};\n` +
@@ -44,7 +59,14 @@ export function assembleLiveBrowserScript({ token, port, vocabulary, commandPref
`window.__IMPECCABLE_COMMAND_PREFIX__ = ${JSON.stringify(commandPrefix)};\n` +
// Canonical command vocabulary (values + labels + icons). live-browser.js
// builds its action picker from this instead of an inline copy.
`window.__IMPECCABLE_VOCAB__ = ${JSON.stringify(vocabulary)};\n`;
`window.__IMPECCABLE_VOCAB__ = ${JSON.stringify(vocabulary)};\n` +
// Canonical Live chrome inventory from live/ui-surfaces.mjs. live-browser.js
// is a classic script and cannot import an ES module at runtime, so the list
// is serialized here and read off the global there. Node consumers (this
// repo's tests, the impeccable-site Live UI lab) import the module directly,
// which is what keeps the two from drifting.
`window.__IMPECCABLE_LIVE_UI_SURFACES__ = ${JSON.stringify(uiSurfaces)};\n` +
`window.__IMPECCABLE_LIVE_MOUNT_CONTRACT__ = ${JSON.stringify(mountContract)};\n`;
const body = parts.map((part) => {
const file = part.file || path.basename(part.path || '');
@@ -0,0 +1,75 @@
/**
* Canonical inventory of the Live overlay's UI surfaces: one entry per piece of
* chrome Live mounts on the user's page, with the element ids that make it up.
*
* Single source of truth, consumed by:
* - skill/scripts/live/browser-script-parts.mjs serializes this into
* window.__IMPECCABLE_LIVE_UI_SURFACES__ in the /live.js prelude.
* - skill/scripts/live-browser.js publishes it on
* window.__IMPECCABLE_LIVE_CHROME_CORE__ for adapters and E2E probes. That
* file is served raw and injected as a classic <script>, so it cannot
* import this module at runtime; it reads the injected global instead, the
* same path live/vocabulary.mjs already takes for the command palette.
* - the private impeccable-site repo site/components/LiveUiGallery.astro
* and tests/live-ui-lab.test.mjs import LIVE_UI_SURFACES at build time and
* fail the site build when the Live UI lab has no snapshot for a surface
* defined here. That guard only guards if it reads this list rather than a
* copy the site keeps, so this module must stay importable from Node.
* Renaming a key or the module is a breaking change for that build; the
* list was briefly inlined into live-browser.js and the site had to parse
* it back out with a regex.
*
* Add a surface here and both the browser bundle and the site lab follow.
*/
/** Id prefix every Live chrome element carries. Mirrored by PREFIX in live-browser.js. */
export const LIVE_UI_PREFIX = 'impeccable-live';
const id = (suffix) => `${LIVE_UI_PREFIX}-${suffix}`;
/**
* The mount contract every Live chrome adapter (DOM, Svelte, ...) satisfies.
* Published alongside the surfaces on __IMPECCABLE_LIVE_CHROME_CORE__.
*/
export const LIVE_CHROME_MOUNT_CONTRACT = Object.freeze(['root', 'transport', 'state', 'actions']);
export const LIVE_UI_SURFACES = Object.freeze([
{
key: 'global-bottom-bar',
ids: [
id('global-bar'), id('global-bar-brand'), id('pick-toggle'), id('insert-toggle'),
id('detect-toggle'), id('detect-badge'), id('design-toggle'), id('page-chat'),
id('page-chat-input'), id('page-chat-voice'), id('page-chat-send'),
],
},
{ key: 'pending-copy-edit-dock', ids: [id('pending-dock')] },
{
key: 'element-selection-chrome',
ids: [
id('highlight'), id('tooltip'), id('bar'), id('selection-pill'), id('input'),
id('configure-voice'), id('configure-bar-tooltip'),
],
},
{ key: 'action-picker', ids: [id('picker')] },
{ key: 'edit-chrome', ids: [id('edit-badge')] },
{ key: 'generating-row', ids: [id('bar'), id('shader')] },
{ key: 'variant-cycling-row', ids: [id('bar'), id('params-panel')] },
{ key: 'variant-params-panel', ids: [id('params-panel')] },
{ key: 'saving-confirmed-rows', ids: [id('bar')] },
{
key: 'insert-mode-chrome',
ids: [
id('insert-line'), id('insert-placeholder'), id('placeholder-resize'), id('insert-input'),
id('insert-voice'), id('insert-create'), id('insert-create-tooltip'),
],
},
{ key: 'annotation-chrome', ids: [id('annot'), id('annot-svg'), id('annot-pins'), id('annot-clear')] },
{ key: 'design-system-panel', ids: [id('design-host')] },
{ key: 'toasts-and-errors', ids: [id('toast'), id('mount-error')] },
{ key: 'css-isolation-boundary', ids: [id('root')] },
].map((surface) => Object.freeze({ ...surface, ids: Object.freeze(surface.ids) })));
/** Every id any surface owns, de-duplicated, in surface order. */
export const LIVE_UI_COMPONENT_IDS = Object.freeze([
...new Set(LIVE_UI_SURFACES.flatMap((surface) => surface.ids)),
]);
+6 -4
View File
@@ -93,15 +93,17 @@ function commandPrefixForSkillsDir(skillsDir) {
return CODEX_HARNESSES.has(basename(dirname(skillsDir))) ? '$' : '/';
}
function generatePinnedSkill(command, metadata, commandPrefix) {
function generatePinnedSkill(command, metadata, commandPrefix, isCodex) {
const desc = metadata[command]?.description || `Shortcut for ${commandPrefix}impeccable ${command}.`;
const hint = metadata[command]?.argumentHint || '[target]';
const providerFrontmatter = isCodex
? `metadata:\n argument-hint: "${hint}"`
: `argument-hint: "${hint}"\nuser-invocable: true`;
return `---
name: ${command}
description: "${desc}"
argument-hint: "${hint}"
user-invocable: true
${providerFrontmatter}
---
${PIN_MARKER}
@@ -128,7 +130,7 @@ function pin(command, projectRoot) {
for (const skillsDir of harnessDirs) {
const commandPrefix = commandPrefixForSkillsDir(skillsDir);
const content = generatePinnedSkill(command, metadata, commandPrefix);
const content = generatePinnedSkill(command, metadata, commandPrefix, commandPrefix === '$');
// Check if skill already exists (and isn't a pin)
const skillDir = join(skillsDir, command);
if (existsSync(skillDir)) {
@@ -29,25 +29,50 @@
* "materials": ["letterpress", "newsprint"], // optional, rendered as tags
* "viewport": "one line: the first-viewport composition", // optional
* "case": "one line: the fusion verdict, honest", // optional
* "verdict": "competitive", // optional routing tier: "wins" |
* // "competitive" | "declined". Declined cards
* // render demoted after the full cards:
* // narrow, quiet, catalog art as a labeled
* // thumb, "Adopt anyway" instead of "Build
* // this". Still choosable; never deleted.
* "kept": "one line: what the direction kept from this declined world",
* "raised": [ { "from": "challenger-x", "raise": "one line" } ],
* // assigned card only: donations taken from
* // declined challengers, rendered as named
* // raise lines under the identity row
* "risk": "one line: the honest risk", // optional
* "body": "fallback prose when the structured fields are absent",
* "sketch": ".impeccable/sketches/assigned.webp", // optional; may not exist
* // yet: the page shimmer-waits and polls the
* // slot until the file lands, so serve first
* // and generate after
* "sketch": ".impeccable/mocks/decision/assigned.webp", // optional; the card's
* // full-fidelity direction comp (the field
* // keeps the sketch era's wire name). May not
* // exist yet: the page shimmer-waits and
* // polls the slot until the file lands, so
* // serve first and generate after
* "hero": "https://... or /abs/path.webp", // optional inspiration image;
* // rides picture-in-picture when a sketch exists
* "board": "https://... or /abs/path.webp" // optional secondary image
* }, ...
* ],
* "reroll": true, // adds a re-roll action (returns {"optionId":"reroll"})
* // or { "registers": ["safer", "bolder"] } to add
* // the register steers beside it: the answer then
* // carries "register" and the agent re-runs
* // concept-seed with --register <value>
* "canon": true, // adds the "Play it straight" standing exit;
* // direction rounds only (returns {"optionId":"canon"})
* "canonCard": { ... }, // optional: the standing exit as a full card with the
* // same anatomy (label, thesis, palette, sketch, ...);
* // rendered last and visually subordinate. Without it,
* // canon stays a quiet footer action.
* "steer": true // adds a free-text steer field returned with any answer
* "steer": true, // adds a free-text steer field returned with any answer
* "followup": true // this round's pick is not terminal: the server
* // stays open awaiting --update with the next
* // round (detached mode only), the page shows a
* // loading hand instead of goodbye, and the
* // answer carries followup:true so --wait knows
* // to keep the table. Use it when a decision has
* // a known second half, e.g. direction first,
* // then the execution contract.
* }
*
* Options render as large cards: the sketch leads when present, with the
@@ -79,6 +104,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { openSystemBrowser } from './lib/open-system-browser.mjs';
function arg(name, fallback = null) {
const i = process.argv.indexOf(`--${name}`);
@@ -123,11 +149,17 @@ function printAnswer(raw) {
console.log("CHOSEN CARD: open the chosen world's board and hero images now, before any code. When your harness only reads files, or runs sandboxed, download them INTO the workspace and open the relative path; a sandboxed viewer rejects absolute paths outside it. They set the craft bar the build must reach.");
}
if (a.sketch) {
console.log('CHOSEN SKETCH: the decision sketch at that path may seed one comp probe; the comp round still renders its full set, because a sketch chose the direction, not the composition.');
console.log('CHOSEN COMP: the decision comp at that path is compositional option one. On a comp-led build the comp round adds two variations beside it; on a code-led build it returns at the finish review as the critique reference. Never regenerate it from scratch.');
}
if (a.optionId === 'canon') {
console.log('CANON CHOSEN: the user picked the category standard on purpose. Ask once for two or three products this should sit alongside; their craft level becomes the quality bar. Execute the canon at full commitment, conventions embraced without irony or smuggled quirk.');
}
if (a.optionId === 'reroll' && a.register) {
console.log(`REGISTER: the user steered the next hand to the ${a.register} register. Re-run concept-seed with the same key, the next --reroll round, and --register ${a.register}, then follow what it prints; the register is the user's steering, never yours to pre-select.`);
}
if (a.followup && a.optionId !== 'reroll') {
console.log('FOLLOWUP OPEN: the table stays open and the page is showing a loading hand. Deliver the next round now with --update --key <key> --payload <file>, then collect it with --wait; never leave the page waiting on a round you have not sent.');
}
} catch { /* raw answer */ }
}
@@ -143,15 +175,17 @@ if (hasFlag('schema')) {
title: 'Choose the visual world',
question: 'The roll assigned Fillmore Handbill. Keep it, take an alternate, or re-roll.',
options: [
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', thesis: 'The gig poster that treats every release like a one-night stand.', palette: ['#e8452c', '#f5d64c', '#1b2a52', '#f3ead8'], materials: ['letterpress', 'split-fountain ink'], viewport: 'A full-bleed dated bill with the product name in warped display type.', risk: 'Reads nostalgic when the type is set timidly.', sketch: '.impeccable/sketches/assigned.webp', hero: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill.webp' },
{ id: 'challenger-teletext', label: 'Teletext Service', lineage: 'broadcast teletext magazines', thesis: 'The catalog as a broadcast index: pages, not sections.', case: 'Fuses cleanly: releases map to numbered pages.', sketch: '.impeccable/sketches/challenger-teletext.webp', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', thesis: 'The gig poster that treats every release like a one-night stand.', palette: ['#e8452c', '#f5d64c', '#1b2a52', '#f3ead8'], materials: ['letterpress', 'split-fountain ink'], viewport: 'A full-bleed dated bill with the product name in warped display type.', risk: 'Reads nostalgic when the type is set timidly.', raised: [{ from: 'challenger-microfiche', raise: 'The bill now owns its whole viewport as one continuous printed sheet.' }], sketch: '.impeccable/mocks/decision/assigned.webp', hero: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/posters-covers-sleeves-fillmore-handbill.webp' },
{ id: 'model-pick', label: 'The Broadside Ballad', kicker: 'MY PICK', lineage: 'street-sold ballad sheets', thesis: 'Every release printed as the days ballad sheet.', palette: ['#1f1c18', '#efe5d0', '#a33327'], materials: ['woodcut', 'rag paper'], viewport: 'One tall sheet, the newest release as todays ballad.', risk: 'Also the direction most runs in this category land on.', sketch: '.impeccable/mocks/decision/model-pick.webp' },
{ id: 'challenger-teletext', label: 'Teletext Service', verdict: 'competitive', lineage: 'broadcast teletext magazines', thesis: 'The catalog as a broadcast index: pages, not sections.', palette: ['#0000c0', '#ffff00', '#00c000', '#ffffff'], materials: ['block mosaic', 'phosphor glow'], viewport: 'P100 index page, releases as numbered rows.', case: 'Fuses cleanly: releases map to numbered pages; loses narrowly on clarity.', risk: 'Reads retro-novelty when the grid is not strict.', sketch: '.impeccable/mocks/decision/challenger-teletext.webp', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
{ id: 'challenger-microfiche', label: 'Microfiche Reader', verdict: 'declined', lineage: 'library microfiche stations', palette: ['#101418', '#9fb4c0'], materials: ['film grain', 'backlit glass'], case: 'Fuses poorly: listeners do not identify with archival retrieval.', kept: 'Total environmental commitment.', hero: 'https://impeccable.style/worlds/cards/archives-microfiche-reader-hero.webp' },
],
reroll: true,
reroll: { registers: ['safer', 'bolder'] },
canon: true,
canonCard: { label: 'The category standard', thesis: 'What this category ships, executed impeccably.', viewport: 'The arrangement a visitor expects, at full craft.', sketch: '.impeccable/sketches/canon.webp' },
canonCard: { label: 'The category standard', thesis: 'What this category ships, executed impeccably.', palette: ['#ffffff', '#111827', '#2563eb'], materials: ['clean grid', 'product photography'], viewport: 'The arrangement a visitor expects, at full craft.', risk: 'Indistinguishable from the competition by design.', sketch: '.impeccable/mocks/decision/canon.webp' },
steer: true,
}, null, 2));
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/sketch accept URLs or local paths; sketch slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the sketch, never as the promise of the build. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. A card with no imagery at all has no back; its full read renders on the front, so a text-only round loses nothing. Sketch aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either.');
console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board/sketch accept URLs or local paths; sketch slots may point at files that do not exist yet (serve first, generate after; the page polls until they land, so never block serving on generation). hero on a challenger is the inspiration it draws from and renders picture-in-picture beside the sketch, never as the promise of the build. verdict routes rendering: "wins" and "competitive" challengers keep full cards, "declined" ones render demoted after them (narrow, quiet, art as a labeled thumb, "Adopt anyway"), with their kept line on the front; the page reorders declined cards to the end on its own. raised on the assigned card renders each donation as a named raise line. Salience parity: when the assigned card declares no sketch (no image generation this round), catalog art on every card demotes to a labeled thumb, so what looks important is the verdicts call, never rendering luck. canonCard renders the standing exit as a subordinate card with the same anatomy; without it, canon stays a quiet footer action. Include canon only for visual-direction rounds; never present it as your own recommendation. The pick card is a kicker convention, not a field: kicker "MY PICK" on your top-ranked grounded candidate, one at most, never in the lead slot. Every card gets the full anatomy, challengers, canon, and declined included: thesis, palette, materials, viewport, risk; the seed already hands you each challengers system rules, so a card with no palette chips is an authoring gap, not a data gap. Keep thesis and each fact to one short sentence: the card front shows thesis, identity, and a two-line risk, while first viewport and the case read on the card back behind the Details chip, so long facts cost the reader a flip, not the page its scanability. A card with no imagery at all has no back; its full read renders on the front, so a text-only round loses nothing. The sketch slot carries the cards full-fidelity direction comp (the field keeps its wire name for compatibility). Comp aspect follows the surface: portrait at device viewport for native or mobile-first surfaces, landscape otherwise; the page adapts its cards to either. reroll accepts true or { "registers": ["safer", "bolder"] }: the register buttons steer the next hand along the familiar-to-bold axis, the answer carries "register", and you re-run concept-seed with --register <value> for the next round; offer the registers on direction rounds, and never pre-select one. followup: true keeps the table open after a pick for a second round via --update (direction first, then the execution contract); send the next payload immediately, the page is waiting on it.');
process.exit(0);
}
@@ -195,12 +229,16 @@ if (hasFlag('wait')) {
if (!answered()) { console.log(`WAITING: no answer yet after ${pollSec}s; run --wait --key ${key} again`); process.exit(3); }
const collected = fs.readFileSync(answerFile(key), 'utf8').trim();
printAnswer(collected);
// A re-roll keeps the table open: the server stays alive awaiting --update,
// so only the answer file is consumed. Terminal choices clean up fully.
let isRerollAnswer = false;
try { isRerollAnswer = JSON.parse(collected).optionId === 'reroll'; } catch { /* treat as terminal */ }
// A re-roll or a followup-round pick keeps the table open: the server stays
// alive awaiting --update, so only the answer file is consumed. Terminal
// choices clean up fully.
let keepsTableOpen = false;
try {
const parsedAnswer = JSON.parse(collected);
keepsTableOpen = parsedAnswer.optionId === 'reroll' || parsedAnswer.followup === true;
} catch { /* treat as terminal */ }
try { fs.rmSync(answerFile(key)); } catch { /* already gone */ }
if (!isRerollAnswer) { try { fs.rmSync(stateFile(key)); } catch { /* already gone */ } }
if (!keepsTableOpen) { try { fs.rmSync(stateFile(key)); } catch { /* already gone */ } }
process.exit(0);
}
@@ -301,11 +339,19 @@ function loadRound(json) {
sketchSrc: sketchSrc(option.sketch),
});
options = parsed.options.map(decorate);
// The verdict routes rendering: full cards first, then the canon, then the
// declined cards dead last in their own payload order. The reorder happens
// here so a payload that interleaves them still renders the weighing's
// shape, and the deck reads as a gradient of standing: contenders, the
// familiar door, then the demoted row.
const declined = options.filter((o) => o.verdict === 'declined');
options = options.filter((o) => o.verdict !== 'declined');
// The standing exit as a full card: same anatomy, reserved id, rendered
// subordinate by the page. Without it, canon stays the quiet footer action.
if (parsed.canonCard && typeof parsed.canonCard === 'object') {
options = [...options, { ...decorate(parsed.canonCard), id: 'canon', isCanon: true }];
}
options = [...options, ...declined];
}
try { loadRound(raw); } catch (error) { console.error(`serve-question: ${error.message}`); process.exit(1); }
const detachedKey = hasFlag('detached-serve') ? arg('key') : null;
@@ -321,7 +367,19 @@ function page() {
// and material tags give a text-only direction an immediate identity that
// no generation luck can distort.
const fact = (label, value, cls = '') => value ? `<p class="fact${cls ? ` ${cls}` : ''}"><span class="fact-label">${label}</span>${esc(value)}</p>` : '';
const hasMedia = (option) => Boolean(option.sketchSrc || option.heroSrc || option.boardSrc);
const demoted = (option) => option.verdict === 'declined';
// Salience parity: a card's imagery weight is capped by the assigned card's.
// When the lead card has no media at all (no image generation this round,
// and no catalog art of its own), full-bleed catalog art beside a text-only
// assigned card would let rendering luck outvote the weighing: users click
// the colorful thing. Declined cards are thumb-only regardless; the verdict
// demoted them, and a full-bleed hero would promote them right back.
const identityRound = !(options[0] && (options[0].sketchSrc || options[0].heroSrc || options[0].boardSrc));
// A declined card never renders a full media face, sketch included: even a
// declared sketch would buy back the salience the verdict took away.
const faceSketch = (option) => demoted(option) ? null : option.sketchSrc;
const thumbOnly = (option) => !faceSketch(option) && Boolean(option.heroSrc || option.boardSrc) && (demoted(option) || identityRound);
const hasMedia = (option) => Boolean(faceSketch(option) || ((option.heroSrc || option.boardSrc) && !thumbOnly(option)));
// The back exists to keep long facts off a card whose front is an image;
// a card with no art has no flip chip to reach it, so it gets no back and
// the full read lives on the front instead.
@@ -337,6 +395,30 @@ function page() {
idBits.push(option.materials.slice(0, 4).map((m) => `<span class="tag">${esc(m)}</span>`).join(''));
}
if (idBits.length) rows.push(`<div class="identity">${idBits.join('')}</div>`);
// Donations from declined challengers render as named raise lines: the
// assigned card arrives already raised by the hand it beat, and the raise
// is readable, because a raise nobody can read did not happen. One raise
// renders inline; several become a compact cycler (click advances), so a
// generous hand cannot blow the card out of proportion.
if (Array.isArray(option.raised) && option.raised.length) {
const nameOf = (id) => options.find((o) => o.id === id)?.label || String(id ?? '');
const raiseLines = option.raised.slice(0, 6).map((r) => `<p class="raise"><span class="fact-label">Raised by ${esc(nameOf(r.from))}</span>${esc(r.raise || r.kept || '')}</p>`);
if (raiseLines.length > 1) {
rows.push(`<div class="raises raises-cycle" role="button" tabindex="0" title="Click or press Enter to see the next raise" aria-label="Raised by the hand; activate to see the next raise">
<div class="raises-head"><span class="fact-label">Raised by the hand</span><span class="raises-count" data-raises-count>1/${raiseLines.length}</span></div>
${raiseLines.join('')}
<span class="sr-live" aria-live="polite"></span>
</div>`);
} else {
rows.push(`<div class="raises">${raiseLines[0]}</div>`);
}
}
// Demoted art stays reachable as a labeled thumb: the catalog world
// explains where the direction comes from without buying it back the
// salience the verdict took away.
if (thumbOnly(option)) {
rows.push(`<figure class="inspo" title="Inspiration: the world this direction draws from. Your page will not look like this image."><img src="${esc(option.heroSrc || option.boardSrc)}" alt=""><figcaption>inspired by</figcaption></figure>`);
}
// The front carries only what the choice needs: thesis, identity, and the
// honest risk clamped to two lines. First viewport and the case read on
// the card's back; once the sketch lands, the first viewport is a picture.
@@ -347,6 +429,7 @@ function page() {
} else {
rows.push(fact('First viewport', option.viewport));
rows.push(fact('The case', option.case));
rows.push(fact('Kept', option.kept));
rows.push(fact('Risk', option.risk));
}
if (!option.thesis && option.body) rows.push(`<p class="detail">${esc(option.body)}</p>`);
@@ -356,6 +439,7 @@ function page() {
const backFacts = (option) => [
fact('First viewport', option.viewport),
fact('The case', option.case),
fact('Kept', option.kept),
fact('Risk', option.risk),
option.body && option.thesis ? `<p class="detail more">${esc(option.body)}</p>` : '',
].filter(Boolean).join('\n ');
@@ -365,9 +449,12 @@ function page() {
<figcaption>inspiration</figcaption>
</figure>` : '';
const details = hasBack(option) ? flipChip('Details') : '';
if (option.sketchSrc) {
// Thumb-only art renders inside the body via anatomy(), never as a face,
// and a declined card's sketch slot is ignored outright.
if (thumbOnly(option)) return '';
if (faceSketch(option)) {
return `<div class="media sketching" data-sketch="${esc(option.sketchSrc)}">
<div class="shimmer"><span class="sketch-note">sketching&hellip;</span></div>
<div class="shimmer"><span class="sketch-note">rendering&hellip;</span></div>
<img class="sketch" alt="" hidden>
${inspiration}
<div class="chips">${expandChip}${details}</div>
@@ -384,17 +471,18 @@ function page() {
}
return '';
};
const chooseLabel = (option) => option.isCanon ? 'Play it straight' : demoted(option) ? 'Adopt anyway' : 'Build this';
const cards = options.map((option, index) => `
<article class="card${option.isCanon ? ' canon' : ''}" style="--fan:${index === 0 ? '0deg' : (index % 2 ? '1.4deg' : '-1.2deg')};--deal:${index * 90}ms" data-id="${esc(option.id)}">
<article class="card${option.isCanon ? ' canon' : ''}${demoted(option) ? ' declined' : ''}" style="--fan:${index === 0 ? '0deg' : (index % 2 ? '1.4deg' : '-1.2deg')};--deal:${index * 90}ms" data-id="${esc(option.id)}">
<div class="card-inner">
<div class="face front${index === 0 ? ' lead' : ''}${media(option) ? '' : ' text-only'}">
${option.kicker ? `<span class="kicker">${esc(option.kicker)}</span>` : option.isCanon ? '<span class="kicker standing">The standing door</span>' : ''}
${option.kicker ? `<span class="kicker">${esc(option.kicker)}</span>` : demoted(option) ? '<span class="kicker declined-k">Declined</span>' : option.isCanon ? '<span class="kicker standing">The standing door</span>' : ''}
${media(option)}
<div class="body">
${option.lineage ? `<p class="tier">${esc(option.lineage)}</p>` : ''}
<h2>${esc(option.label)}</h2>
${anatomy(option)}
<button class="choose" data-id="${esc(option.id)}">${option.isCanon ? 'Play it straight' : 'Build this'}</button>
<button class="choose" data-id="${esc(option.id)}">${chooseLabel(option)}</button>
</div>
</div>
${hasBack(option) ? `<div class="face back${index === 0 ? ' lead' : ''}">
@@ -405,7 +493,7 @@ function page() {
<div class="body back-body">
${option.boardSrc ? `<p class="tier">The full read &middot; ${esc(option.label)}</p>` : ''}
${backFacts(option)}
<button class="choose" data-id="${esc(option.id)}">${option.isCanon ? 'Play it straight' : 'Build this'}</button>
<button class="choose" data-id="${esc(option.id)}">${chooseLabel(option)}</button>
</div>
</div>` : ''}
</div>
@@ -496,6 +584,10 @@ function page() {
.nav.next { right: auto; left: 50%; top: auto; bottom: 6px; transform: translate(-50%, 0); }
.fade-prev { top: 0; left: 0; right: 0; bottom: auto; width: auto; height: 72px; background: linear-gradient(180deg, var(--ks-lacquer), transparent); }
.fade-next { top: auto; left: 0; right: 0; bottom: 0; width: auto; height: 72px; background: linear-gradient(0deg, var(--ks-lacquer), transparent); }
/* In the vertical deck the cross axis is horizontal: flex-start would
shrink a declined card to content WIDTH, not height, so it stretches
like every other card and its height is already its own. */
.grid > .card.declined { align-self: stretch; }
}
.card { position: relative; perspective: 1400px; transform: rotate(var(--fan, 0deg)); transition: transform .25s cubic-bezier(.16, 1, .3, 1); }
.card:hover { transform: rotate(0deg) translateY(-4px); }
@@ -584,6 +676,42 @@ function page() {
/* The generic .media img display:block would defeat [hidden] and float an
empty block over the shimmer; an unloaded sketch must truly not render. */
.media img[hidden] { display: none; }
/* Declined challengers: the weighing demoted them, so the card is narrower
and quieter, its catalog art rides as a labeled thumb in the body, and
the action reads "Adopt anyway". Adoptable, never deleted: the demoted
row is the hand's proof of judgment. */
/* Narrow AND short: without align-self the stretch default drags a thin
declined card to the tallest contender's height, a strange stilt of a
card beside the full hand. */
.grid > .card.declined { flex: 0 0 clamp(15rem, 21vw, 21rem); align-self: flex-start; }
.card.declined .face { background: var(--ks-graphite); }
.card.declined:hover .face { border-color: var(--ks-text-faint); }
.card.declined h2 { font-size: 1rem; color: var(--ks-text); }
.kicker.declined-k { background: transparent; border: 1px solid var(--ks-rule); color: var(--ks-text-faint); }
.card.declined button.choose { background: transparent; color: var(--ks-text-muted); border: 1px solid var(--ks-rule); font-size: .85rem; padding: 8px 22px; }
.card.declined button.choose:hover { background: var(--ks-graphite-2); border-color: var(--ks-text-muted); }
/* Thumb-scale inspiration: present, labeled, zoomable, and incapable of
outshouting a text-only assigned card. */
.inspo { position: relative; flex: none; margin: 2px 0; width: 104px; height: 64px; border: 1px solid var(--ks-rule); border-radius: 6px; overflow: hidden; cursor: zoom-in; background: var(--ks-lacquer); }
.inspo img { display: block; width: 100%; height: 100%; object-fit: cover; }
.inspo figcaption { position: absolute; left: 0; right: 0; bottom: 0; font-family: var(--ks-mono); font-size: .48rem; letter-spacing: .16em; text-transform: uppercase; color: var(--ks-text); text-align: center; padding: 2px 0 3px; background: oklch(7% 0.006 95 / 0.72); }
/* Raises: the donations the assigned direction took from the hand it beat,
each named for its donor. Patina, not kinpaku: a raise is provenance. */
.raises { display: flex; flex-direction: column; gap: 4px; margin: 2px 0; }
.raise { font-size: .78rem; color: var(--ks-text-muted); line-height: 1.45; border-left: 2px solid var(--ks-patina); padding-left: 8px; }
.raise .fact-label { color: var(--ks-patina); }
/* Several raises cycle instead of stacking: one visible at a time, a
counter for the rest, the whole block advances on click. */
.raises-cycle { cursor: pointer; border-radius: 6px; }
.raises-cycle .raise { display: none; border-left: none; padding-left: 0; }
.raises-cycle .raise.active { display: block; }
.raises-cycle { border-left: 2px solid var(--ks-patina); padding-left: 8px; }
.raises-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.raises-head .fact-label { color: var(--ks-patina); }
.raises-count { font-family: var(--ks-mono); font-size: .58rem; letter-spacing: .14em; color: var(--ks-text-faint); }
.raises-count::after { content: " \\203A"; }
.raises-cycle:hover .raises-count { color: var(--ks-patina); }
.sr-live { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
/* The standing exit as a card: present with full anatomy, never dressed as a
contender. Graphite instead of kinpaku, and it never takes the lead ring. */
.card.canon .face { border-color: var(--ks-rule); background: var(--ks-graphite); }
@@ -596,9 +724,14 @@ function page() {
footer { width: 100%; max-width: 90rem; margin: 1.6rem auto 0; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
#steer { flex: 1; min-width: 16rem; background: var(--ks-lacquer-raised); color: var(--ks-text); border: 1px solid var(--ks-rule); border-radius: 7px; padding: .6rem .85rem; font: inherit; }
#steer:focus { outline: none; border-color: var(--ks-patina); }
#reroll { display: inline-flex; align-items: center; align-self: stretch; gap: 8px; padding: 0 16px; font-family: var(--ks-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--ks-kinpaku); background: transparent; border: 1px solid var(--ks-rule); border-radius: 6px; cursor: pointer; transition: border-color .2s ease, color .2s ease; }
#reroll:hover { color: var(--ks-kinpaku-pale); border-color: var(--ks-kinpaku-deep); }
#reroll svg { width: 15px; height: 15px; }
.reroll-btn { display: inline-flex; align-items: center; align-self: stretch; gap: 8px; padding: 0 16px; font-family: var(--ks-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--ks-kinpaku); background: transparent; border: 1px solid var(--ks-rule); border-radius: 6px; cursor: pointer; transition: border-color .2s ease, color .2s ease; }
.reroll-btn:hover { color: var(--ks-kinpaku-pale); border-color: var(--ks-kinpaku-deep); }
.reroll-btn svg { width: 15px; height: 15px; }
.reroll-btn[disabled] { opacity: .4; cursor: default; }
/* The register steers read quieter than the plain roll: they are exits from
the current register, not the round's main verbs. */
#reroll-safer, #reroll-bolder { color: var(--ks-text-muted); min-height: 38px; }
#reroll-safer:hover, #reroll-bolder:hover { color: var(--ks-text); border-color: var(--ks-text-faint); }
/* The quiet exit: always available, never argued with, visually subordinate
to the dealt cards and the re-roll so it reads as the user's own door,
not a recommendation. */
@@ -643,16 +776,33 @@ function page() {
</main>
<footer>
${payload.steer ? '<input id="steer" placeholder="Optional steer: what should be different or kept?">' : ''}
${payload.reroll ? '<button id="reroll"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="4" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="8.4" cy="8.4" r="1.5" fill="currentColor"/><circle cx="15.6" cy="8.4" r="1.5" fill="currentColor"/><circle cx="8.4" cy="15.6" r="1.5" fill="currentColor"/><circle cx="15.6" cy="15.6" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg><span>Re-roll</span></button>' : ''}
${(() => {
if (!payload.reroll) return '';
const die = '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="4" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="8.4" cy="8.4" r="1.5" fill="currentColor"/><circle cx="15.6" cy="8.4" r="1.5" fill="currentColor"/><circle cx="8.4" cy="15.6" r="1.5" fill="currentColor"/><circle cx="15.6" cy="15.6" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>';
const registers = Array.isArray(payload.reroll.registers) ? payload.reroll.registers.filter((r) => r === 'safer' || r === 'bolder') : [];
// The registers are the user's steering wheel on the familiar-to-bold
// axis; the plain re-roll sits between them so the spatial order matches
// the axis it names.
const safer = registers.includes('safer') ? '<button class="reroll-btn" id="reroll-safer" title="Deal the familiar register: conventional grounded directions plus the category standard against named competitors"><span>&larr; Safer hand</span></button>' : '';
const bolder = registers.includes('bolder') ? '<button class="reroll-btn" id="reroll-bolder" title="Deal foreign forms only, at full commitment"><span>Bolder hand &rarr;</span></button>' : '';
return `${safer}<button class="reroll-btn" id="reroll">${die}<span>Re-roll</span></button>${bolder}`;
})()}
${payload.canon && !payload.canonCard ? '<button id="canon" title="Skip the roll: build the page this category ships, executed impeccably">Play it straight</button>' : ''}
</footer>
<script>
const steer = () => document.getElementById('steer')?.value || '';
// A followup round's pick keeps the tab: the next round arrives via
// --update, so the page shows the loading hand instead of goodbye. Detached
// mode only, and the page must agree with the server: a blocking server
// exits on any pick and has no update channel, so a followup payload there
// still gets the goodbye screen, never a loading hand nothing will resolve.
const FOLLOWUP = ${payload.followup === true && Boolean(detachedKey) ? 'true' : 'false'};
const beat = () => { try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
beat();
setInterval(beat, 5000);
async function answer(optionId) {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
if (FOLLOWUP) { await awaitNextRound(); return; }
document.body.innerHTML = '<div class="done"><svg viewBox="0 0 24 24" width="38" height="38" fill="oklch(84% 0.19 80.46)" aria-hidden="true"><path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/><path d="M16.5 2.5 L19 2.5 Q21.5 2.5 21.5 5 L21.5 19 Q21.5 21.5 19 21.5 L8.5 21.5 Z"/></svg>Choice recorded. The agent is resuming; you can close this tab.</div>';
}
document.querySelectorAll('button.choose').forEach(b => b.addEventListener('click', () => answer(b.dataset.id)));
@@ -661,6 +811,25 @@ function page() {
b.closest('.card').classList.toggle('flipped');
}));
// Raise cycler: click (or Enter) advances to the next donation.
document.querySelectorAll('.raises-cycle').forEach(cycle => {
const raises = [...cycle.querySelectorAll('.raise')];
const count = cycle.querySelector('[data-raises-count]');
let at = 0;
const live = cycle.querySelector('.sr-live');
const show = (announce) => {
raises.forEach((raise, i) => raise.classList.toggle('active', i === at));
if (count) count.textContent = (at + 1) + '/' + raises.length;
// Screen readers hear the raise they just advanced to; the initial
// render stays quiet so page load does not narrate every card.
if (announce && live) live.textContent = 'Raise ' + (at + 1) + ' of ' + raises.length + ': ' + (raises[at]?.textContent || '');
};
show(false);
const advance = (e) => { e.stopPropagation(); at = (at + 1) % raises.length; show(true); };
cycle.addEventListener('click', advance);
cycle.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); advance(e); } });
});
// Deal from the stack: cards begin piled at the grid's center, blurred,
// then travel to their seats with a stagger.
const cards = [...document.querySelectorAll('.card')];
@@ -708,7 +877,7 @@ function page() {
const note = m.querySelector('.sketch-note');
const started = Date.now();
// A live elapsed count is the difference between "working" and "frozen".
const tick = setInterval(() => { if (note) note.textContent = 'sketching · ' + Math.round((Date.now() - started) / 1000) + 's'; }, 1000);
const tick = setInterval(() => { if (note) note.textContent = 'rendering · ' + Math.round((Date.now() - started) / 1000) + 's'; }, 1000);
const settle = () => { clearInterval(tick); m.classList.remove('sketching', 'stand-in'); m.querySelector('.shimmer')?.remove(); m.querySelector('.stand-in-label')?.remove(); };
const standIn = () => {
const pip = m.querySelector('.pip img');
@@ -719,7 +888,7 @@ function page() {
clearInterval(tick);
const label = document.createElement('p');
label.className = 'stand-in-label';
label.textContent = 'inspiration · sketch pending';
label.textContent = 'inspiration · comp pending';
m.appendChild(label);
};
const tryLoad = () => {
@@ -758,15 +927,15 @@ function page() {
if (img.complete && img.naturalWidth === 0 && img.getAttribute('src')) artFailed(img);
else img.addEventListener('error', () => artFailed(img), { once: true });
});
// A broken inspiration PIP just leaves; nothing depends on it.
document.querySelectorAll('.pip img').forEach(img => {
const gone = () => img.closest('.pip')?.remove();
// A broken inspiration PIP or thumb just leaves; nothing depends on it.
document.querySelectorAll('.pip img, .inspo img').forEach(img => {
const gone = () => img.closest('.pip, .inspo')?.remove();
if (img.complete && img.naturalWidth === 0) gone();
else img.addEventListener('error', gone, { once: true });
});
// Inspiration PIP opens the full catalog card in the lightbox.
document.querySelectorAll('.pip').forEach(p => p.addEventListener('click', (e) => {
// Inspiration PIP or body thumb opens the full catalog card in the lightbox.
document.querySelectorAll('.pip, .inspo').forEach(p => p.addEventListener('click', (e) => {
e.stopPropagation();
const img = p.querySelector('img');
if (!img) return;
@@ -813,7 +982,7 @@ function page() {
const ambient = document.getElementById('ambient');
document.querySelectorAll('.card').forEach(card => {
card.addEventListener('mouseenter', () => {
const art = card.querySelector('.face.front .media img:not([hidden])') || card.querySelector('.face.front .pip img');
const art = card.querySelector('.face.front .media img:not([hidden])') || card.querySelector('.face.front .pip img') || card.querySelector('.face.front .inspo img');
if (!art || !art.getAttribute('src')) return;
ambient.style.backgroundImage = 'url("' + art.getAttribute('src') + '")'; ambient.style.opacity = '1';
});
@@ -861,8 +1030,11 @@ function page() {
lightbox.addEventListener('click', closeLightbox);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !lightbox.hidden) closeLightbox(); });
document.getElementById('canon')?.addEventListener('click', () => answer('canon'));
document.getElementById('reroll')?.addEventListener('click', async () => {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer() }) });
const dealAgain = async (register) => {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer(), ...(register ? { register } : {}) }) });
await awaitNextRound();
};
async function awaitNextRound() {
const grid = document.querySelector('.grid');
const cardsNow = [...grid.querySelectorAll('.card')];
const g = grid.getBoundingClientRect();
@@ -879,14 +1051,17 @@ function page() {
}
const cardHeight = cardsNow[0] ? cardsNow[0].getBoundingClientRect().height : 0;
grid.innerHTML = cardsNow.map(() => '<article class="card skeleton"' + (cardHeight ? ' style="height:' + cardHeight + 'px"' : '') + '><div class="card-inner"><div class="face front"><div class="media"><div class="shimmer"></div></div><div class="body"><div class="line tier w40"></div><div class="line title w70"></div><div class="line w90"></div><div class="line w80"></div><div class="line w60"></div><div class="line button"></div></div></div></div></article>').join('');
document.getElementById('reroll')?.setAttribute('disabled', '');
document.querySelectorAll('.reroll-btn').forEach(b => b.setAttribute('disabled', ''));
const poll = setInterval(async () => {
try {
const status = await (await fetch('/next-status')).json();
if (status.ready) { clearInterval(poll); location.reload(); }
} catch { /* server briefly busy */ }
}, 1200);
});
}
document.getElementById('reroll')?.addEventListener('click', () => dealAgain());
document.getElementById('reroll-safer')?.addEventListener('click', () => dealAgain('safer'));
document.getElementById('reroll-bolder')?.addEventListener('click', () => dealAgain('bolder'));
</script>`;
}
@@ -943,22 +1118,29 @@ const server = http.createServer((req, res) => {
let parsed = {};
try { parsed = JSON.parse(body); } catch { /* empty steer */ }
const chosen = options.find((o) => o.id === parsed.optionId);
const isReroll = parsed.optionId === 'reroll';
// A followup round's pick is not terminal: the table stays open for the
// next round (--update), exactly like a re-roll. Detached mode only;
// the blocking mode has no update channel, so its picks stay terminal.
const followupOpen = Boolean(detachedKey) && payload.followup === true && !isReroll;
const answer = JSON.stringify({
optionId: parsed.optionId ?? null,
steer: parsed.steer ?? '',
...(isReroll && (parsed.register === 'safer' || parsed.register === 'bolder') ? { register: parsed.register } : {}),
...(followupOpen ? { followup: true } : {}),
...(chosen?.hero || chosen?.board ? { hero: chosen.hero ?? null, board: chosen.board ?? null } : {}),
...(chosen?.sketch ? { sketch: chosen.sketch } : {}),
});
const isReroll = parsed.optionId === 'reroll';
if (detachedKey) {
fs.mkdirSync(QUESTION_DIR, { recursive: true });
fs.writeFileSync(answerFile(detachedKey), answer + '\n');
} else {
printAnswer(answer);
}
// A re-roll in detached mode keeps the table open: the client shows a
// loading hand and reloads when --update delivers the next round.
if (!(isReroll && detachedKey)) setTimeout(() => process.exit(0), 150);
// A re-roll or followup pick in detached mode keeps the table open: the
// client shows a loading hand and reloads when --update delivers the
// next round.
if (!((isReroll || followupOpen) && detachedKey)) setTimeout(() => process.exit(0), 150);
});
return;
}
@@ -976,8 +1158,7 @@ server.listen(portArg, '127.0.0.1', () => {
console.log('Waiting for the user to choose in the browser (Ctrl-C aborts)...');
}
if (!hasFlag('no-open')) {
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
try { spawn(opener, [url], { stdio: 'ignore', detached: true }).unref(); } catch { /* URL printed anyway */ }
openSystemBrowser(url);
}
if (timeoutSec > 0) {
setTimeout(() => {
+2 -2
View File
@@ -14,9 +14,9 @@ Your job is production cleanup, not new art direction. Work only from the approv
Do not redesign. Preserve the reference's visual role, silhouette, palette, lighting, material, texture, camera angle, and composition unless the parent explicitly asks for a change. Preserve perspective only when it belongs to the object or scene itself; if CSS should create the card transform, shadow, rounded clipping, border, or layout, remove that presentation chrome from the raster.
## Decision Sketches
## Decision Comps
When the parent hands you a decision card packet instead of an approved mock, the job is one sketch: one card, one file, written to the card's declared `sketch` path the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a sketch is reported back, not padded from imagination. Render through the parent's shared frame, including its aspect: the requested surface's first viewport as a flat, matte design sketch in the card's own palette and type character, deliberately unfinished, no photorealism, no gloss; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. The frame is shared across siblings so no sketch looks more finished than another; a finish gap breaks the comparison. The only legible text is the product's real name and one real headline; greek every other text region into indistinct lines, because an invented spec, price, or date in a sketch is a claim PRODUCT.md never made. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a sketch run.
When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `sketch` path (the field keeps its wire name) the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a comp is reported back, not padded from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (its regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment is what keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
## Input Contract
+4 -4
View File
@@ -15,12 +15,12 @@ A hard turn ceiling ends the run without warning; a run that ends before the fiv
## Input Contract
Expect: the original request; the confirmed user answers; the artifact path(s); desktop and mobile screenshot paths captured by the parent; the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths and the approved comp path; and the skill's `reference/craft-floor.md` path. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped.
Expect: the original request; the confirmed user answers; the artifact path(s); the screenshots the parent captured, which live in `.impeccable/review/` (on the web, `desktop.png` and `mobile.png`; on native, device-class names such as `phone.png` and `tablet.png`, suffixed per OS on adaptive); a screenshot path the calling brief names is authoritative when the file exists, and `.impeccable/review/` is where to look when the brief names none or a named path is missing, never a filename you invent; the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths and, on a comp-led build, the approved comp path (a code-led build has no approved comp; it passes the chosen decision comp as a separate critique-reference input, labeled as such, and nothing in this file that binds “the approved comp” binds it); and the skill's `reference/craft-floor.md` path. On a native (`ios` / `android` / `adaptive`) build the packet also carries the platform reference path(s) (`reference/ios.md` / `reference/android.md`) and a line saying no detector ran: read the platform reference alongside the craft floor and judge every check in the platform's own conventions, the screenshots are device captures rather than browser viewports, and your floor check is the build's only slop gate. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped.
## Checks, in order
1. **Persistence.** PRODUCT.md exists. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comps with no recorded pick mean the approval point was skipped, and that is a material finding.
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element, and its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement, because medium is part of the promise. When no approved comp was supplied, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality, CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. In every material_fixes list, a fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
1. **Persistence.** PRODUCT.md exists. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comp-round comps with no recorded pick mean the approval point was skipped, and that is a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and they imply no approval whatever the build path; a code-led build has no comp round at all.
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element, and its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement, because medium is part of the promise. When no approved comp was supplied, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality, CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. A critique-reference comp, when one arrived on such a build, is provocation rather than spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is the question of what the image dared that the build did not, and the dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. In every material_fixes list, a fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition.
4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped and that is a material fix ahead of any craft point. Then, for each of the five blocks, does the render keep the promise? Apply the memory test to the first viewport.
5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every image-native region of the approved comp shipped as a real asset, not a gradient standing in for one, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind other paint is a compliance token, not a shipped material.
@@ -38,4 +38,4 @@ Return the disposition line first, then exactly five sections: `persistence` (pa
## Verdict Pass
When the parent returns with post-fix recaptures, you are scoring, not re-hunting. The parent's narration of what was fixed is not evidence; a claimed fix you cannot see in the recaptures is unresolved. For each material fix from your review, one line: resolved, partial, or unresolved, tied to what the new screenshots visibly show; a fix answered mechanically, positions moved but the quality the finding named still absent, is partial at best. Then name at most three regressions the fix batch itself introduced, judged by the same matrix rules, and nothing else; no new hunt, no new checks. Return exactly two sections: `verdict` (the scored list) and `remaining` (what stays open, or "clear"), and end with the disposition line recomputed against what remains open; unresolved or partial material findings can never recompute to ship.
When the parent returns with post-fix recaptures, you are scoring, not re-hunting. The parent recaptures over the same screenshot files you read in the review round, so re-read those exact paths for this round; a round-stamped filename you invent points at nothing. The parent's narration of what was fixed is not evidence; a claimed fix you cannot see in the recaptures is unresolved. For each material fix from your review, one line: resolved, partial, or unresolved, tied to what the new screenshots visibly show; a fix answered mechanically, positions moved but the quality the finding named still absent, is partial at best. Then name at most three regressions the fix batch itself introduced, judged by the same matrix rules, and nothing else; no new hunt, no new checks. Return exactly two sections: `verdict` (the scored list) and `remaining` (what stays open, or "clear"), and end with the disposition line recomputed against what remains open; unresolved or partial material findings can never recompute to ship.
+2 -2
View File
@@ -10,11 +10,11 @@ This skill gives you the tools and permission to create design that earns to be
Core principles:
- Go all out. No hedging, no shortcuts. The deliverable must be complete (except assets the user must provide).
- Dream big and bold. Distinct, beautiful, outstanding and highly inspiring work.
- Verify in bounded passes, not a loop, and the ceiling covers the whole cycle: screenshots, defect scans, micro-edits, and rebuilds alike. Build fully, inspect once with a batched round (desktop and mobile together), fix everything it shows in one batch, confirm with at most one more round, and stop polishing. Open-ended self-QA burns the user's money doing worse what the finish handoffs do better.
- Verify in bounded passes, not a loop, and the ceiling covers the whole cycle: screenshots, defect scans, micro-edits, and rebuilds alike. Build fully, inspect once with a batched round (desktop and mobile together on the web; the shipped device classes on a native platform), fix everything it shows in one batch, confirm with at most one more round, and stop polishing. Open-ended self-QA burns the user's money doing worse what the finish handoffs do better.
## Setup
1. Run `node .cursor/skills/impeccable/scripts/context.mjs` once per session (if the runtime shows this skill's loaded base directory, run `node <skill-base-dir>/scripts/context.mjs`; keep cwd at the user's project). Pass a named source file or route as `--target <path>`. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it.
1. Run `node <skill-base-dir>/scripts/context.mjs` once per session, where `<skill-base-dir>` is the loaded base directory the runtime reports for this skill; keep cwd at the user's project. That base directory resolves every `node .cursor/skills/impeccable/scripts/...` command in this skill and its references, and `.cursor/skills/impeccable/scripts` is the fallback only when the runtime reports no base directory. Pass a named source file or route as `--target <path>`. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it.
2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing.
3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work.
@@ -38,3 +38,9 @@ Would a fluent Android user trust this app, or trip on off-spec components? The
- **One FAB, one primary action.** Never stack FABs or spend one on a secondary task.
- **Snackbars for transient feedback** (actionable when useful, never a toast for that); dialogs only for decisions that must interrupt.
- **Material motion patterns.** Container transform, shared-axis, fade-through, with standard easing and durations; honor the system Remove animations setting with a crossfade or instant cut.
## Verifying the build
- **Screenshots come from the emulator or a connected device, never a browser.** Build and install, then capture with `adb exec-out screencap -p > <path>` (pick a device with `adb -s <serial>` when several are attached). Capture every device class the app ships to, at least one phone and, when tablets are a target, one tablet, and write the files where the review flow expects them.
- **Dark theme and font scale belong in the pass.** `adb shell cmd uimode night yes` flips the theme; `adb shell settings put system font_scale 1.3` (restore `1.0` after) catches the clipped labels a fixed layout hides; with several targets attached, the capture's `-s <serial>` goes on these commands too.
- **Emulators give breadth; gestures, refresh rates, and performance need hardware.** Say which one produced the evidence.
@@ -74,12 +74,15 @@ Keep content visible in the default state so failed scripts do not hide the page
Respect autoplay and sound preferences. Any nonessential loop must stop when offscreen or hidden.
Every web animation needs a `prefers-reduced-motion` path with an intentional alternative. Remove or reduce spatial movement while preserving opacity, color, and state transitions that carry meaning. Reduced motion means fewer and gentler animations, not disabling all motion; feedback that confirms an action should remain legible.
## Verify
- The focal motion is specific to the selected world and surface.
- Every supporting animation explains feedback, state, or relationship.
- Interruption and repeated use behave correctly.
- Desktop, mobile, and keyboard paths remain usable.
- The `prefers-reduced-motion` path reduces movement without erasing meaningful feedback or state changes.
- Expensive effects stay smooth on the target device.
- Removing an animation would lose meaning or authored character, not merely decoration.
@@ -1,5 +1,7 @@
> **Additional context needed**: which section is the target, and what must stay untouched.
An open direction round owns the word first: "bolder" said while a direction decision is on the table is the Bolder hand register steer, a fresh deal of foreign forms (see new-work.md), not this command. This command refines a surface whose world already shipped.
"Bolder" is an amplification request, and almost always it is scoped to something that already exists. The surrounding page, its system, and its conventions are the given. Your job is to raise one part to the conviction the rest already implies, without rebuilding anything the brief did not name. The reflex answer, reaching for more effects, is the opposite of bold; reject it first.
## Scope is sovereign
@@ -12,6 +12,7 @@ Each of these is a check on the built result, not an intention. Run them togethe
- **Type:** body measure 6575ch, display max 6rem, tracking floor -0.04em, balanced headings, obvious scale and weight steps. Run the real copy at every breakpoint and fix what overflows.
- **Motion:** one authored moment, not scattered effects and not one identical entrance on every section. Exponential ease-out from an already-visible default. Reach past transform and opacity: blur, backdrop-filter, clip-path, mask, and shadow belong to the palette when they stay smooth.
- **States:** hover, disabled, loading, error, empty. Plus real content, working controls, responsive composition, keyboard focus.
- **Browser surfaces:** the parts you did not draw still carry the design. Text selection, the caret, custom scrollbars, focus rings, underline offset, and the numerals in tabular data all ship with browser defaults that belong to no design system. Theme them from the palette. This is the cheapest signal that a page was built rather than assembled, and the one models skip most reliably.
- **Copy:** the product's own language. Controls name their action; errors name the problem and the recovery.
- **Coverage:** every brief requirement present and findable within seconds.
@@ -11,9 +11,9 @@ Your job is production cleanup, not new art direction. Work only from the approv
Do not redesign. Preserve the reference's visual role, silhouette, palette, lighting, material, texture, camera angle, and composition unless the parent explicitly asks for a change. Preserve perspective only when it belongs to the object or scene itself; if CSS should create the card transform, shadow, rounded clipping, border, or layout, remove that presentation chrome from the raster.
## Decision Sketches
## Decision Comps
When the parent hands you a decision card packet instead of an approved mock, the job is one sketch: one card, one file, written to the card's declared `sketch` path the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a sketch is reported back, not padded from imagination. Render through the parent's shared frame, including its aspect: the requested surface's first viewport as a flat, matte design sketch in the card's own palette and type character, deliberately unfinished, no photorealism, no gloss; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. The frame is shared across siblings so no sketch looks more finished than another; a finish gap breaks the comparison. The only legible text is the product's real name and one real headline; greek every other text region into indistinct lines, because an invented spec, price, or date in a sketch is a claim PRODUCT.md never made. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a sketch run.
When the parent hands you a decision card packet instead of an approved mock, the job is one comp: one card, one file, written to the card's declared `sketch` path (the field keeps its wire name) the moment it renders. The parent runs several of you in parallel, one per card, so your entire contract is this card; generate first, plan never, because the file on disk is the deliverable and the decision page is waiting on it. Work from the card's structured fields and PRODUCT.md alone; a card too thin to brief a comp is reported back, not padded from imagination. Render the card's direction as a north-star comp at full fidelity: the requested surface's first viewport, prompt led by the surface's own structure (its regions named in order with their scale relationships, never the world's atmosphere), fully committed in the card's own palette, type character, and material world; a native app or mobile-first surface is a portrait frame at its device viewport, never a landscape default. Every sibling renders at the same full fidelity in its own grammar, one surface, one aspect; equal commitment is what keeps the comparison honest. Real product name and real content only; never invent commercial claims, prices, benchmarks, or dates PRODUCT.md does not carry. Write the prompt sidecar beside the file. Return one line naming the path and any deviation, nothing more. Everything below this section is the asset-production job; none of it applies to a decision-comp run.
## Input Contract
@@ -11,12 +11,12 @@ A hard turn ceiling ends the run without warning; a run that ends before the fiv
## Input Contract
Expect: the original request; the confirmed user answers; the artifact path(s); desktop and mobile screenshot paths captured by the parent; the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths and the approved comp path; and the skill's `reference/craft-floor.md` path. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped.
Expect: the original request; the confirmed user answers; the artifact path(s); the screenshots the parent captured, which live in `.impeccable/review/` (on the web, `desktop.png` and `mobile.png`; on native, device-class names such as `phone.png` and `tablet.png`, suffixed per OS on adaptive); a screenshot path the calling brief names is authoritative when the file exists, and `.impeccable/review/` is where to look when the brief names none or a named path is missing, never a filename you invent; the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths and, on a comp-led build, the approved comp path (a code-led build has no approved comp; it passes the chosen decision comp as a separate critique-reference input, labeled as such, and nothing in this file that binds “the approved comp” binds it); and the skill's `reference/craft-floor.md` path. On a native (`ios` / `android` / `adaptive`) build the packet also carries the platform reference path(s) (`reference/ios.md` / `reference/android.md`) and a line saying no detector ran: read the platform reference alongside the craft floor and judge every check in the platform's own conventions, the screenshots are device captures rather than browser viewports, and your floor check is the build's only slop gate. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped.
## Checks, in order
1. **Persistence.** PRODUCT.md exists. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comps with no recorded pick mean the approval point was skipped, and that is a material finding.
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element, and its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement, because medium is part of the promise. When no approved comp was supplied, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality, CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. In every material_fixes list, a fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
1. **Persistence.** PRODUCT.md exists. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too, the surface brief naming the approved comp or an `approved` flag in its sidecar; comp-round comps with no recorded pick mean the approval point was skipped, and that is a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and they imply no approval whatever the build path; a code-led build has no comp round at all.
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element, and its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Two rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement, because medium is part of the promise. When no approved comp was supplied, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality, CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never actually renders, as contradicted on its face; imitation material is the single most reliable mark of machine-made design. A critique-reference comp, when one arrived on such a build, is provocation rather than spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is the question of what the image dared that the build did not, and the dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. In every material_fixes list, a fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition.
4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped and that is a material fix ahead of any craft point. Then, for each of the five blocks, does the render keep the promise? Apply the memory test to the first viewport.
5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every image-native region of the approved comp shipped as a real asset, not a gradient standing in for one, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind other paint is a compliance token, not a shipped material.
@@ -34,4 +34,4 @@ Return the disposition line first, then exactly five sections: `persistence` (pa
## Verdict Pass
When the parent returns with post-fix recaptures, you are scoring, not re-hunting. The parent's narration of what was fixed is not evidence; a claimed fix you cannot see in the recaptures is unresolved. For each material fix from your review, one line: resolved, partial, or unresolved, tied to what the new screenshots visibly show; a fix answered mechanically, positions moved but the quality the finding named still absent, is partial at best. Then name at most three regressions the fix batch itself introduced, judged by the same matrix rules, and nothing else; no new hunt, no new checks. Return exactly two sections: `verdict` (the scored list) and `remaining` (what stays open, or "clear"), and end with the disposition line recomputed against what remains open; unresolved or partial material findings can never recompute to ship.
When the parent returns with post-fix recaptures, you are scoring, not re-hunting. The parent recaptures over the same screenshot files you read in the review round, so re-read those exact paths for this round; a round-stamped filename you invent points at nothing. The parent's narration of what was fixed is not evidence; a claimed fix you cannot see in the recaptures is unresolved. For each material fix from your review, one line: resolved, partial, or unresolved, tied to what the new screenshots visibly show; a fix answered mechanically, positions moved but the quality the finding named still absent, is partial at best. Then name at most three regressions the fix batch itself introduced, judged by the same matrix rules, and nothing else; no new hunt, no new checks. Return exactly two sections: `verdict` (the scored list) and `remaining` (what stays open, or "clear"), and end with the disposition line recomputed against what remains open; unresolved or partial material findings can never recompute to ship.
+12 -6
View File
@@ -48,14 +48,20 @@ The first argument is the action. Defaults to `status`.
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
## Triage 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`.
The hook itself never writes ignore config; every exception goes through `hook-admin.mjs`. Triage each finding into one of three outcomes:
- **Real design problem**: fix it. Never add an ignore to skip a fix or to push a blocked write through.
- **Confident false positive or sanctioned exception**: persist the narrowest ignore yourself and disclose it in your reply. The bar is evidence you can name: an intentional demo or fixture, documentation of bad design, literal or domain-appropriate motion (a ball that bounces), or a choice the user already confirmed. Put that evidence in `--reason` as `"<who decided: evidence>"`; write "user confirmed" only when the user actually did.
- **Unsure**: leave the finding standing and ask the user in one line. Ask once; a one-line question costs less than the hook re-firing on every later edit.
Self-serve stops at `ignore-value`. `ignore-file` and `ignore-rule` silence too much to add on your own judgment; ask the user first.
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 line shows an `ignore-value <rule> <value>` pair, pass it to `hook-admin.mjs ignore-value` with your `--reason`. This writes shared `.impeccable/config.json` by default.
- For value-specific findings such as `overused-font` and `bounce-easing`, use `ignore-value` for 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`, scope that one rule to the file: `ignore-value <id> "*" --file <path>`. Run `npx impeccable detect <path>` first to see what actually fires there.
- Reach for `ignore-file <path>` only when the whole file is out of scope for design review: a fixture, a generated artifact, a deliberate slop demo. It silences every rule for that file permanently, including rules that have not been written yet. A real UI surface with one noisy rule wants the file-scoped value ignore above.
- 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.
@@ -67,10 +73,10 @@ Example value-specific exception:
node .cursor/skills/impeccable/scripts/hook-admin.mjs ignore-value overused-font Inter --shared --reason "User confirmed Inter is intentional"
```
Example intentional motion exception:
Example self-served exception, with the evidence named:
```bash
node .cursor/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "User confirmed ball bounce animation is intentional"
node .cursor/skills/impeccable/scripts/hook-admin.mjs ignore-value bounce-easing bounce-ball --shared --reason "Agent: literal ball-bounce animation, bounce easing is the subject"
```
Example whole-rule font exception:
@@ -43,3 +43,9 @@ Would a fluent iPhone user trust this app, or pause at off-spec controls? The te
- **System transitions.** Push slides, sheets rise, dismiss reverses the entrance. Custom transitions that fight the navigation model disorient.
- **Honor Reduce Motion.** Crossfade instead of parallax and large slides.
## Verifying the build
- **Screenshots come from the Simulator, never a browser.** Build and run, then capture with `xcrun simctl io booted screenshot <path>` (with several running, replace `booted` with the target's UDID from `xcrun simctl list devices booted`; display names can collide, the UDID never does). Capture every device class the app ships to, at least one iPhone and, when iPad is a target, one iPad, and write the files where the review flow expects them.
- **Dark Mode and Dynamic Type belong in the pass.** `xcrun simctl ui booted appearance dark` flips appearance, reusing the capture's UDID when several are booted; a check at a large Dynamic Type size catches the truncation a fixed layout hides.
- **Simulators give breadth; posture, gestures, and performance need hardware.** Say which one produced the evidence.
@@ -43,12 +43,14 @@ The script assigns which structure gets built; your top-ranked structure is what
1. Name the product's unique mechanism in one sentence, the audience's real scene, its cultural home, and what this first surface must prove. Note the page this category always ships and its predictable opposite; name both as the rut and keep them out of the seven-candidate list. A brief that paints its own picture, a product name, a titled artifact, a governing metaphor, adds its literal reading to the rut: spend at most one candidate on it and derive the rest from elsewhere in the audience's world.
2. From that cultural world, list seven concrete visual systems, artifacts, places, or rituals the audience knows by heart, each with one line on why it resonates and can carry the mechanism, ordered by resonance. The audience's world includes its graphic and screen traditions, not only its physical objects: the notation, publications, identity programs, data graphics, and interfaces it reads daily; a nameable abstract system (a school of poster, a documentation standard) is as concrete a candidate as any artifact. What would this thing look like as a physical object; what did its world look like before the web? Near-duplicates count once. When more than three of the seven share one material family, the derivation stopped at the subject's most obvious artifact; dig until the list spans at least three families.
3. Turn that material into complete directions: each joins a reusable visual world to a concrete first-surface experience.
4. Run `node .cursor/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. This step has no substitute and no skip condition: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure, because the roll is the mechanism that keeps every run from converging on the category default. The script assigns which direction gets built and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, and clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity; losing to strong grounded material is a valid outcome, and beating a thin or tool-monoculture list is the point.
5. Present one direction, fully committed: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Alongside it, offer the hand's challengers as named alternates, the weighing's verdict written on each as its one-line case, an honest "fuses poorly because X" included; the weighing informs the user's choice, it never pre-empts it. A hand holds at most three challengers: when the roll deals more, the three strongest join the hand and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add re-roll with an optional one-line steer. Never present a ranked menu of your own grounded candidates; a lineup of those invites the safest card. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool; the structured tool's option list also carries the standing exit as its last option.
4. Run `node .cursor/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. This step has no substitute and no skip condition: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure, because the roll is the mechanism that keeps every run from converging on the category default. The script assigns which direction gets built and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, and clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity; losing to strong grounded material is a valid outcome, and beating a thin or tool-monoculture list is the point. The weighing closes with a verdict per challenger, decided before any borrowing is considered: wins (beats the assigned direction on both axes; it becomes the build candidate), competitive (holds one axis; it stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a motif lifted from a declined world is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen.
5. Present one direction, fully committed and already raised by the hand it beat, its raises visible as named lines: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Alongside it, route each dealt challenger by its verdict: winning and competitive challengers are full alternates carrying their QUALITY BAR cards and one-line case, while declined challengers render demoted, compact and quiet, each carrying its verdict plus what the direction kept from it, never full-size and never silently dropped, each still adoptable on request. The verdict informs the user's choice, it never pre-empts it; the demoted row is the hand's proof of judgment, showing why the dealt worlds made the presented direction better. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join the hand and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker MY PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often the one most runs in this category land on, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: the rest of your grounded candidates stay yours, because a lineup of them hands selection back to a taste function and invites the safest card. The pick never takes the lead position, and when the dice assign your top candidate there is no pick card; the assigned card notes it also topped your list. Add re-roll with an optional one-line steer, offered in three registers: plain (a fresh hand, same spread), safer (the familiar register: your remaining conventional grounded candidates plus the canon against named competitors), and bolder (foreign forms only, at full commitment). A register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool; the structured tool's option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit as its last option, while declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel too.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it, in the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path, convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. A standing preference gets recorded as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, the dealt challengers as alternates carrying their QUALITY BAR cards, and re-roll, steer, plus canon enabled; a degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy, thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (run the script with `--schema` for the exact shape); the page renders identity from these fields, and a challenger's catalog image rides as labeled inspiration, never as the promise of the build. Author `canonCard` too: the category standard as one honest card with the same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .cursor/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. Only a session where no browser can open at all, headless, CI, an eval worker, a remote shell with no display, puts the same decision through the structured question tool instead; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it, in the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path, convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. A standing preference gets recorded as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading and its raised lines included, the pick card when one exists, the dealt challengers as alternates carrying their QUALITY BAR cards plus each challenger's verdict and kept line, re-roll with its safer and bolder registers, steer, plus canon enabled, and `followup: true` when the execution-contract round will follow (it does whenever image generation exists and no standing build-path preference is recorded); a degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy, thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (run the script with `--schema` for the exact shape); the page renders identity from these fields, routes declined challengers to a demoted row on its own, and a challenger's catalog image rides as labeled inspiration, never as the promise of the build. Author `canonCard` too: the category standard as one honest card with the same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .cursor/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. Only a session where no browser can open at all, headless, CI, an eval worker, a remote shell with no display, puts the same decision through the structured question tool instead; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry.
When image generation exists, every card also declares a `sketch` path under `.impeccable/sketches/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the sketches; the page shimmer-waits per slot and the user may answer before they land. Render every sketch through one shared frame so the comparison stays about direction, never rendering luck: the requested surface's first viewport as a flat, matte design sketch in that card's own palette and type character, deliberately unfinished, no photorealism, no gloss, identical framing across cards; a candidate whose sketch looks more finished than the others has broken the comparison, not won it. The frame's aspect is the surface's own: a native app or mobile-first surface sketches portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen sketched landscape is a broken frame, not a neutral default. The only legible text in a sketch is the product's real name and one real headline; every other text region is greeked, indistinct lines standing where copy will go, because a sketch that renders invented specs, prices, or dates puts claims in front of the user that PRODUCT.md never made. Produce in the order the user reads: the assigned card, then the hand, then canon, each file written the moment it is done. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-sketch packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. A sketch answers which world, never which composition: the comp round still renders its full set, and the chosen card's sketch seeds at most one probe. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version.
When image generation exists, every card also declares a `sketch` path under `.impeccable/mocks/decision/` (the field keeps its wire name for compatibility; what it carries is the card's comp), the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity, produced under the comp discipline in [visualize.md](visualize.md): the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished sketch pays sketch quality for comp cost; fairness between cards comes from equal fidelity in each card's own grammar, one surface, one aspect, never from shared unfinishedness. The frame's aspect is the surface's own: a native app or mobile-first surface comps portrait at its device viewport, a desktop web surface landscape, and the decision page adapts to either, so a phone screen comped landscape is a broken frame, not a neutral default. Produce in the order the user reads, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. When the harness runs subagents in parallel, fan the set out as one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight at once. A slot still empty when its agent returns is regenerated inline, and a slot still empty when the user answers is dropped without ceremony; no other supervision is owed. Without parallel subagents, generate in the main thread after serving, in the same reading order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: on a comp-led build it enters the comp round as compositional option one, and on a code-led build it returns at the finish review as the critique reference, what the image dared that the build did not. The unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, the cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
The moment the direction lands, one more round on the same open table decides the execution contract. The direction payload declares `followup: true`, so the table stays open after the pick; deliver the build-path payload through `--update` immediately. Two text-only cards. **Comp-led**: a first-viewport comp is generated and it is law, the finish review audits the build against it; boldest composition on the table, fix rounds expected, motion at risk; choosing it makes the comp non-optional, no silent skipping. **Code-led**: no comp of this page and no apology for it; the QUALITY BAR boards still calibrate finish, and the ambition moves into the written contract, the FIRST VIEWPORT block plus a named signature interaction and motion grammar, which the finish reviewer audits in behavior; code-led is not a discount on commitment, the direction still lands fully committed in code. Lead with the chosen world's fit: a costume-heavy catalog world leads comp-led, a quiet or conventional direction leads code-led; the lead is a default, never a decision, and the user flips it freely. A standing preference, voiced once, is recorded as a brand commitment in PRODUCT.md and skips this round on later surfaces. Without image generation there is no fork and no round: code-led is the only path, stated in one line rather than asked. Only a detached table (`--start`) stays open for `--update`: a blocking serve or the structured-tool channel runs the build-path round as its own second question instead, and `followup: true` belongs only on a detached round.
Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. Open the QUALITY BAR board and hero for the world you build the moment the choice lands, even if you viewed another card earlier; the ANSWER line names the chosen card's images (when the harness only reads files or runs sandboxed, download them into the workspace and open the relative path; sandboxed viewers reject absolute paths outside it). They set the craft level the build must reach, a rendered reference's finish, commitment, and art direction, never the composition; your surface serves this product.
@@ -78,7 +80,7 @@ If the work establishes durable strategy for a route or artifact, read its exist
Keep the brief small: scope and visitor mode; audience, job, action/task, proof/content, and constraints; chosen direction and memorable moment; unresolved decisions. Do not copy global product truth or DESIGN.md tokens into it.
Whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports, the locked direction is visualized before it is built, never skipped: load [visualize.md](visualize.md) and follow it, three compositional options rendered and put before the user for approval. This step is proven to produce the most compositional and ambitious work.
On a comp-led build, whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports, the locked direction is visualized before it is built, never skipped: load [visualize.md](visualize.md) and follow it, three compositional options put before the user for approval, the chosen card's decision comp plus two variations. This step is proven to produce the most compositional and ambitious work. On a code-led build the comp round is skipped by contract, never by drift: the ambition it would have carried lives in the direction contract's FIRST VIEWPORT block and named signature interaction, and the finish reviewer audits those promises in behavior.
For `shape`, return the selected direction to [shape.md](shape.md) and stop before persistence or implementation.
@@ -101,8 +103,8 @@ Preserve semantics, accessibility, performance, responsiveness, project conventi
## 7. Inspect and finish
Inspect desktop and mobile in one batched screenshot round, critique the render against the user's request and the direction contract, fix material gaps, and confirm with one final round; two rounds is the ceiling, and fixes batch between them rather than earning per-tweak screenshots. When an approved comp exists, the critique is a side-by-side: view the comp region and the build region together, the hero and each section as its own crop at legible scale, never one full-page thumbnail, which hides exactly the failures that matter, crude controls, wrong lettering character, flattened material, behind a superficially similar section order. On a Persuade surface, verify the mode did its job: a first-time visitor should know what this is, why it matters, and what to do within seconds, in the form's own vocabulary.
Inspect the surface's target sizes in one batched screenshot round: desktop and mobile on the web; on a native platform (`ios` / `android` / `adaptive`), the shipped device classes per OS, captured from the simulator or emulator the way the platform reference's Verifying the build section describes. Critique the render against the user's request and the direction contract, fix material gaps, and confirm with one final round; two rounds is the ceiling, and fixes batch between them rather than earning per-tweak screenshots. When an approved comp exists, the critique is a side-by-side: view the comp region and the build region together, the hero and each section as its own crop at legible scale, never one full-page thumbnail, which hides exactly the failures that matter, crude controls, wrong lettering character, flattened material, behind a superficially similar section order. On a Persuade surface, verify the mode did its job: a first-time visitor should know what this is, why it matters, and what to do within seconds, in the form's own vocabulary.
After the second inspection round the build thread's polishing is over: no further defect hunts, micro-edit scripts, or rebuilds here; whatever remains ships through the handoffs, where a fresh context does the finding better and cheaper. Where this harness runs no design hook, run `node .cursor/skills/impeccable/scripts/detect.mjs --json` on the changed targets once here, fix what is mechanical, and pass the remaining findings to the reviewer; a hookless build that skips this ships every tell the hook exists to catch. Capture desktop and mobile screenshots to files, then spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccable_finish_reviewer` in codex; `/impeccable-finish-reviewer` in Cursor; on GitHub Copilot say "Use the impeccable-finish-reviewer agent"), with the original request, confirmed answers, the artifact path, the screenshot paths, its direction contract, existing hook findings, the QUALITY BAR card and approved comp paths, and the craft-floor reference path. The reviewer has no browser; screenshots you fail to pass are checks it cannot run. Never read the shipped agents' definition files before spawning; the harness loads them at spawn, and you owe only the input packet. Wait on any agent with one long timeout rather than a loop of short polls, and spend the wait on the next independent step. Verify its return carries the five contract sections; on an empty or thrashed return, respawn once with the same inputs before doing anything else. This review never runs inside the build thread and never inherits it: spawn the reviewer fresh, with no forked conversation history (`fork_turns: 0` in codex); a reviewer that inherits your transcript inherits your framing, your optimism, and your abstractions, and everything it needs travels in the inputs above. Only a harness whose tool surface has no subagent capability at all substitutes a fresh in-thread pass after stepping fully out of the build context, run from [degraded/finish-reviewer.md](degraded/finish-reviewer.md), and a substituted or failed-and-replaced review is disclosed in one line at finish, never silently. When the reviewer's first material fix is a rebuild directive, fidelity failed wholesale rather than in patches, so skip the fix batch and execute the rebuild immediately: re-derive the named regions, produce the named assets, and send the result back for a verdict, telling the user what is happening rather than asking permission to fix a failure. The user is consulted only when a second rebuild directive arrives, both verdicts on the table, or when rebuilding would discard content the user approved. Otherwise apply the material fixes in one batch, rebuild once, and recapture the same viewports. A recapture measures positions, loading, and overflow; it cannot measure whether a fix reached the quality the finding named, so send the recaptured screenshots back to the same reviewer for a verdict scoring every material fix resolved, partial, or unresolved (through the harness's agent continuation; without one, run the scoring fresh from [degraded/finish-reviewer.md](degraded/finish-reviewer.md)'s Verdict Pass). Fixes scored partial or unresolved get another batch, recapture, and verdict. Two rounds is the budget an unattended run ends at; an attended session's ceiling belongs to the user, so when the second verdict still lists open items, put the table in front of them and let them choose between shipping as it stands and funding another round. Whoever is deciding, stop the moment a round resolves nothing, and the reviewer's findings are the only list you work from, never your own re-opened hunt. Report the final verdict table to the user as it stands, open items included, under the reviewer's own disposition word: a table with open material findings is never announced as a pass, and never under a softer label than the reviewer wrote. Do not run a second detector.
After the second inspection round the build thread's polishing is over: no further defect hunts, micro-edit scripts, or rebuilds here; whatever remains ships through the handoffs, where a fresh context does the finding better and cheaper. On the web, where this harness runs no design hook, run `node .cursor/skills/impeccable/scripts/detect.mjs --json` on the changed targets once here, fix what is mechanical, and pass the remaining findings to the reviewer; a hookless web build that skips this ships every tell the hook exists to catch. A native platform skips the detector entirely: it reads HTML and CSS and has no verdict on native code, so the reviewer's floor check is the only slop gate and the input packet says so. Capture the screenshots into `.impeccable/review/`, one file per captured viewport (on the web, `desktop.png` and `mobile.png`; on native, one per device class, such as `phone.png` and `tablet.png`, suffixed per OS on adaptive), creating that directory when the harness does not; the paths you pass the reviewer are its spec, and that directory is where it looks when a passed path is missing. Then spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccable_finish_reviewer` in codex; `/impeccable-finish-reviewer` in Cursor; on GitHub Copilot say "Use the impeccable-finish-reviewer agent"), with the original request, confirmed answers, the artifact path, the screenshot paths, its direction contract, existing hook findings, the QUALITY BAR card and approved comp paths (on a code-led build there is no approved comp; the chosen decision comp rides in that slot as the critique reference, named as such), the craft-floor reference path, and on a native platform the platform reference path(s), [ios.md](ios.md) / [android.md](android.md), both on adaptive, plus one line saying no detector ran, so the reviewer judges in the platform's conventions rather than the web's. The reviewer has no browser; screenshots you fail to pass are checks it cannot run. Never read the shipped agents' definition files before spawning; the harness loads them at spawn, and you owe only the input packet. Wait on any agent with one long timeout rather than a loop of short polls, and spend the wait on the next independent step. Verify its return carries the five contract sections; on an empty or thrashed return, respawn once with the same inputs before doing anything else. This review never runs inside the build thread and never inherits it: spawn the reviewer fresh, with no forked conversation history (`fork_turns: 0` in codex); a reviewer that inherits your transcript inherits your framing, your optimism, and your abstractions, and everything it needs travels in the inputs above. Only a harness whose tool surface has no subagent capability at all substitutes a fresh in-thread pass after stepping fully out of the build context, run from [degraded/finish-reviewer.md](degraded/finish-reviewer.md), and a substituted or failed-and-replaced review is disclosed in one line at finish, never silently. When the reviewer's first material fix is a rebuild directive, fidelity failed wholesale rather than in patches, so skip the fix batch and execute the rebuild immediately: re-derive the named regions, produce the named assets, and send the result back for a verdict, telling the user what is happening rather than asking permission to fix a failure. The user is consulted only when a second rebuild directive arrives, both verdicts on the table, or when rebuilding would discard content the user approved. Otherwise apply the material fixes in one batch, rebuild once, and recapture the same viewports over the same files. A recapture measures positions, loading, and overflow; it cannot measure whether a fix reached the quality the finding named, so send the recaptured screenshots back to the same reviewer for a verdict scoring every material fix resolved, partial, or unresolved (through the harness's agent continuation; without one, run the scoring fresh from [degraded/finish-reviewer.md](degraded/finish-reviewer.md)'s Verdict Pass). Fixes scored partial or unresolved get another batch, recapture, and verdict. Two rounds is the budget an unattended run ends at; an attended session's ceiling belongs to the user, so when the second verdict still lists open items, put the table in front of them and let them choose between shipping as it stands and funding another round. Whoever is deciding, stop the moment a round resolves nothing, and the reviewer's findings are the only list you work from, never your own re-opened hunt. Report the final verdict table to the user as it stands, open items included, under the reviewer's own disposition word: a table with open material findings is never announced as a pass, and never under a softer label than the reviewer wrote. Do not run a second detector.
Then spawn the shipped documenter, `impeccable-documenter` (`impeccable_documenter` in codex), with the project root, the artifact path, the direction contract, PRODUCT.md, the [document.md](document.md) reference path, and the boundary to write at; it records DESIGN.md and the sidecar from the built world, ground truth over intention; without subagents the pass runs from [degraded/documenter.md](degraded/documenter.md). A clean detector pass is not finished; finished is the contract kept, the comp honored, the review closed, and the system recorded.
@@ -19,7 +19,7 @@ Fix the cause at the narrowest correct level. Ask when a binding system principl
## 2. Gather the evidence
Use the feature yourself at representative desktop and mobile sizes. Determine:
Use the feature yourself at the surface's representative sizes: desktop and mobile on the web; on a native platform (`ios` / `android` / `adaptive`), the shipped device classes on the simulator, emulator, or hardware, captured per the platform reference's Verifying the build section. Determine:
- whether the path is functionally complete;
- the intended quality bar and time available;
@@ -86,10 +86,10 @@ Do not perfect one corner while leaving the rest below the same quality bar.
Walk the complete path again with mouse, keyboard, and touch where applicable. Check:
- mobile, intermediate, and wide layouts;
- mobile, intermediate, and wide layouts on the web; phone and tablet size classes in both supported orientations on native;
- loading, empty, error, success, disabled, long-content, and missing-content states;
- zoom, contrast, focus, semantics, and screen-reader names;
- console errors, layout shift, interaction latency, image loading, and supported browsers;
- console errors, layout shift, interaction latency, and image loading everywhere; supported browsers on the web; supported OS versions, runtime warnings, and dropped frames on native;
- agreement with DESIGN.md, neighboring features, and the user's scope.
Follow the quality guidance supplied by `context.mjs` and hooks, then run any other relevant QA commands. Context requests a manual scan only when no automatic detector is active; never add another detector pass. Fix real defects and document only narrow intentional exceptions. A clean scan does not replace visual judgment.
@@ -1,12 +1,12 @@
# Visualize: Direction Comps & Asset Production
Load this from [new-work.md](new-work.md) whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it.
Load this from [new-work.md](new-work.md) on a comp-led build, when image generation is available (a harness-native tool or the API fallback context.mjs reports). A code-led execution contract skips this file by design, not by drift: its ambition lives in the written direction contract and is audited in behavior, so do not load it for a code-led round. PRODUCT.md and DESIGN.md are preconditions. New-work has already resolved the visual world; this file must not reopen it.
The purpose of a probe is to test composition, narrative, hierarchy, density, focal moment, signature use, and image requirements. It is not a second identity workshop. Keep DESIGN.md's palette, typography direction, material language, component character, imagery stance, and motion grammar fixed.
## Generate three compositional options
Render three distinct high-fidelity north-star comps of the requested surface, with whatever generation capability exists, saved under `.impeccable/mocks/` so they survive the session. Comp at the surface's own viewport: portrait at device size for a native app or mobile-first surface, desktop landscape otherwise; a phone screen comped landscape misstates the composition before anything gets built against it. Comps are the build thread's own work, never delegated: the thread that writes the comp prompts holds the direction's full context, and it has already seen every comp when the build starts. Open every image you produce or reference by its workspace-relative path, never an absolute one: sandboxed viewers reject absolute paths, and everything under the project root has a relative path. Base them on the real content and the surface concepts already developed with the user. Three is the number: one comp invites rubber-stamping, and the spread between three is what surfaces the composition worth building. A decision-page sketch is not a probe: it chose the direction at deliberately unfinished fidelity, so the three comps render regardless, and the chosen card's sketch seeds at most one of them.
Render three distinct high-fidelity north-star comps of the requested surface, with whatever generation capability exists, saved under `.impeccable/mocks/` so they survive the session. Comp at the surface's own viewport: portrait at device size for a native app or mobile-first surface, desktop landscape otherwise; a phone screen comped landscape misstates the composition before anything gets built against it. Comps are the build thread's own work, never delegated: the thread that writes the comp prompts holds the direction's full context, and it has already seen every comp when the build starts. Open every image you produce or reference by its workspace-relative path, never an absolute one: sandboxed viewers reject absolute paths, and everything under the project root has a relative path. Base them on the real content and the surface concepts already developed with the user. Three is the number: one comp invites rubber-stamping, and the spread between three is what surfaces the composition worth building. The chosen card's decision comp is the first of the three: it already renders this direction at full fidelity under this file's discipline, so this round generates two more that vary what the first held fixed, and all three go to the approval point together. Only a round that arrives with no decision comp, a degraded roll, an identity-mode page, a direction pinned without the decision round, renders all three here.
- A comp is a designed surface, not a picture of the subject. Lead the generation prompt with the surface's own structure, whatever regions this design actually has, named in order with their scale relationships; a page with no navigation states that instead of inventing one, and an unconventional surface states its unconventional skeleton. A prompt that leads with the world's atmosphere gets a vignette back: the model paints the fish market instead of the fish market's website. Self-check every render: if it could hang as a poster, or reads as a photograph or scene with some text on it, it is not a comp; regenerate with the layout scaffold stated more literally.
- When the user shortlisted multiple concepts, spread the three across them.
@@ -22,7 +22,7 @@ Show the three together: in the harness when it can display images, otherwise on
Do not begin code until the user approves a direction or explicitly delegates the choice. If they delegate, choose using the task brief, PRODUCT.md, and DESIGN.md, and state the evidence. Approval refines the task concept; it does not modify DESIGN.md.
This approval point has no substitute and no skip condition. When the structured question tool errors, fall back to the decision page; only after both fail may you treat the choice as delegated, and a delegated pick is still recorded exactly as an approval is and disclosed in your first reply, not your last. The finish reviewer treats a build with generated comps and no recorded approval as carrying a material finding.
This approval point has no substitute and no skip condition. When the structured question tool errors, fall back to the decision page; only after both fail may you treat the choice as delegated, and a delegated pick is still recorded exactly as an approval is and disclosed in your first reply, not your last. The finish reviewer treats a build whose comp round produced comps with no recorded approval as carrying a material finding; decision comps under `.impeccable/mocks/decision/` are the direction round's hand, not comp-round output, and imply no approval on their own.
After approval, record the choice where tools can find it: the approved comp's path goes in the surface brief, and the approved comp's `.json` prompt sidecar gains `"approved": true` (every comp generated through `generate-image.mjs` has one; create it if a native tool didn't). The sidecar travels with the mocks folder, so the approval survives sessions and machines that never see the brief. Then summarize the composition and the parts of the comp that must not be literalized, return to new-work.md, record the direction contract from the approved surface concept, and build.
@@ -31,6 +31,16 @@
* recomputes what rounds 0..n-1 drew, excludes all of it, and rolls a
* fresh assigned index, challengers, and compositions. One base key therefore
* reproduces the entire chain of rounds.
* - REGISTER (--register safer|bolder): the user's steering on the
* familiar-to-bold axis, applied to a re-roll round. A register changes
* only what this round instructs, never what it dealt: the same key and
* reroll count reproduce the same deal whatever the register, so the
* exclusion chain never forks. bolder presents the dealt foreign forms
* as the whole hand (first-dealt leads, dice-assigned by deal order);
* safer spends the dealt hand unseen and presents the familiar register,
* the model's conventional grounded candidates plus the canon against
* named competitors, the one sanctioned lineup of the model's own list.
* Registers are user-requested, never pre-selected by the model.
* - RATINGS: the reviewer's approval ratings weight the challenger draw
* (3-star doubles the odds, 1-star sits out); the approved pool itself
* is unchanged.
@@ -41,7 +51,9 @@
* node scripts/concept-seed.mjs --scope surface --mode operate --grain flow
* node scripts/concept-seed.mjs --scope direction --candidate-count 6
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1
* node scripts/concept-seed.mjs --chosen <challenger-id> --from <key> --scope direction
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1 --register bolder
* node scripts/concept-seed.mjs --chosen <challenger-id> --kind challenger --from <key> --scope direction
* node scripts/concept-seed.mjs --kind assigned --from <key> --scope direction
*
* --grain names how much of the product is in play: product, flow, view, or
* region. A docs site, an onboarding flow, a landing page and a data table are
@@ -62,8 +74,13 @@
* Challenger data resolves in order: a local catalog directory (the private
* service repo, evals, and tests set IMPECCABLE_CATALOG_DIR), then the roll
* API at impeccable.style, then a degraded assignment-only seed when both are
* unavailable. --chosen sends the anonymous choice ping for API-dealt rolls;
* DO_NOT_TRACK or IMPECCABLE_NO_TELEMETRY disables it.
* unavailable. The anonymous choice ping fires once per resolved attended
* round on API-dealt rolls: --kind names which card class won (assigned,
* pick, challenger, canon) so share metrics have a denominator, --chosen
* carries the catalog id when a dealt challenger won, and --register rides
* along when the round came from a steered hand. Grounded candidates' names
* never leave the machine. DO_NOT_TRACK or IMPECCABLE_NO_TELEMETRY disables
* the ping entirely.
*
* Env vars:
* IMPECCABLE_CONCEPT_SEED same as --from; for reproducible eval runs.
@@ -172,17 +189,35 @@ function telemetryDisabled() {
return Boolean(process.env.IMPECCABLE_NO_TELEMETRY || process.env.DO_NOT_TRACK);
}
// Anonymous choice ping: records only that a dealt world was selected.
// Anonymous choice ping: one per resolved attended direction round. kind
// says which card class won (assigned / pick / challenger / canon), so
// pick-share and canon-share have a denominator; chosenId rides along only
// when a dealt catalog world won, and register only when the round came from
// a steered hand. Grounded candidates' names never leave the machine: they
// are derived from the user's project, so the ping carries the kind alone.
// Fire-and-forget; never fails the caller.
export async function pingChosen({ chosenId, key, scope, mode }) {
if (telemetryDisabled() || !chosenId) return false;
const PING_KINDS = new Set(['assigned', 'pick', 'challenger', 'canon']);
export async function pingChosen({ chosenId, key, scope, mode, kind, register }) {
if (telemetryDisabled()) return false;
if (kind && !PING_KINDS.has(kind)) return false;
if (register && register !== 'safer' && register !== 'bolder') return false;
// Legacy shape: a bare challenger id with no kind stays a valid ping.
if (!chosenId && !kind) return false;
if ((kind === 'challenger' || !kind) && !chosenId) return false;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), apiBudgetMs());
try {
await fetch(`${API_BASE}/chosen`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chosenId, key, scope, mode }),
body: JSON.stringify({
...(chosenId ? { chosenId } : {}),
key,
scope,
mode,
...(kind ? { kind } : {}),
...(register ? { register } : {}),
}),
signal: controller.signal,
});
return true;
@@ -260,6 +295,7 @@ export function renderConceptSeed({
scope = 'surface',
key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'),
reroll = 0,
register = null,
mode = null,
grain = null,
platform = null,
@@ -273,6 +309,15 @@ export function renderConceptSeed({
if (!Number.isInteger(reroll) || reroll < 0) {
throw new Error('concept-seed: --reroll must be a non-negative integer');
}
if (register !== null && register !== 'safer' && register !== 'bolder') {
throw new Error('concept-seed: --register must be safer or bolder');
}
if (register !== null && reroll < 1) {
throw new Error('concept-seed: --register steers a re-roll round; pass --reroll <n> with it');
}
if (register !== null && scope !== 'direction') {
throw new Error('concept-seed: --register applies to direction rounds only');
}
if (mode !== null && !SEED_MODES.has(mode)) {
throw new Error('concept-seed: --mode must be persuade, operate, read, or experience');
}
@@ -326,6 +371,7 @@ export function renderConceptSeed({
scope,
key,
reroll,
register,
mode,
grain,
platform,
@@ -357,7 +403,11 @@ export function renderConceptSeed({
survive the current task plus navigation, quiet and dense content,
interaction and state, and a substantially different future surface. In an
attended run, present the assigned direction fully committed and offer
re-roll; never present a ranked lineup to choose from. Re-roll yourself only
re-roll. You may add ONE card for your top-ranked grounded candidate when
it is not the assigned direction, kicker MY PICK, with an honest risk line
naming its familiarity; one pick card, never a ranked lineup, and the pick
never takes the lead position. When the assignment IS your top candidate,
there is no pick card. Re-roll yourself only
on named factual grounds, when the assignment cannot carry the product's
truth or task; taste is never grounds.`
: `After ordering the task's grounded structural candidates by resonance,
@@ -374,7 +424,16 @@ export function renderConceptSeed({
conflicts. Weigh the fused result against the assigned direction on exactly
two axes, audience identification and product clarity. Losing to strong
grounded material is a valid outcome; beating a thin or tool-monoculture
list is the point. A fused challenger that wins both axes becomes the build.`
list is the point. A fused challenger that wins both axes becomes the build.
Close the weighing with a verdict per challenger, decided before any
borrowing is considered: wins (beats the assigned direction on both axes),
competitive (holds one axis), or declined (loses both). A declined
challenger is not spent: name the one discipline of its system the assigned
direction lacks, and raise the assigned direction to match before
presenting it. A donation transfers ambition and system discipline, never
the challenger's clothes; one world owns the page. Write each raise as its
own named line on the presented direction, and carry every verdict, kept
line, and raise into the decision page payload.`
: `A challenger wins only when its fused result beats the grounded list on
audience identification and product clarity. It may change task topology or
interaction, but never the committed visual identity.`;
@@ -399,8 +458,39 @@ Ambitious motion, spatial media, or interaction is welcome when it strengthens
the product without weakening semantics, performance, or fallback behavior.`;
if (!data) {
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: degraded; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''} --candidate-count ${candidateCount})
ASSIGNED INDEX: ${buildIndex}
// A degraded roll can still serve the safer register, which needs no
// catalog at all: the assignment machinery is suppressed entirely, the
// same as the non-degraded safer round, because emitting both "the user
// picks" and a mandatory numbered build order hands the model two
// contradicting instructions and the mandatory one tends to win. The
// bolder register is exactly the thing degradation took away, so it
// falls back to a plain grounded round, disclosed.
const degradedHeader = `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: degraded; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''}${register ? ` --register ${register}` : ''} --candidate-count ${candidateCount})`;
if (register === 'safer') {
return `${degradedHeader}
SAFER REGISTER (user-requested): the assigned index is suspended this
round; the user picks, and no candidate is mandated. Present the familiar
register: your remaining grounded candidates from the conventional end, at
most three, as full cards with an honest risk line each, plus the canon
executed against two or three named competitors. This is the one sanctioned
lineup of your own ranked candidates; it exists only by this explicit
request. When the user voices a standing preference for it, record a brand
commitment in PRODUCT.md.
${authorityInstruction}
A user- or brief-pinned decision beats the roll, always.
REGISTER (restated for truncated readers): safer, user-requested; the
assigned index is suspended this round and the user picks; seed key ${key}.
`;
}
const degradedRegister = register === 'bolder'
? `BOLDER REGISTER UNAVAILABLE: bolder deals foreign forms, and this roll ran
degraded with no catalog and no roll service, so there is nothing bold to
deal. Tell the user, then run this round as a plain grounded re-roll; the
assignment below applies.
`
: '';
return `${degradedHeader}
${degradedRegister}ASSIGNED INDEX: ${buildIndex}
${promotedInstruction}
The assignment exists to refuse the model's ranking rut, never to outrank
the user or the brief. Never expose assignment metadata in user-facing labels.
@@ -471,34 +561,76 @@ structure only, never a palette, typeface, or material. Treat them as serious
rivals to your habitual layout, and keep only what makes this product clearer.${grainNote}\n`
: '';
const rerollBlock = reroll > 0
? `RE-ROLL ROUND ${reroll}: every candidate presented in earlier rounds, grounded
and challenger alike, is eliminated and may not return reworded. Derive
? `RE-ROLL ROUND ${reroll}${register ? ` (${register.toUpperCase()} REGISTER, user-requested)` : ''}: every candidate presented in earlier rounds, grounded
and challenger alike, is eliminated and may not return reworded.${register ? '' : ` Derive
genuinely new grounded candidates from unexplored angles before judging
these fresh challengers.\n`
these fresh challengers.`}\n`
: '';
// A register swaps the round's presentation, never its deal: the assigned
// index and challenger fetch stay identical so the chain reproduces, and
// only the instructions change.
const saferBlock = `SAFER REGISTER: the user asked for the familiar end of the spectrum, so this
round's dealt hand is spent unseen, stays excluded from future rounds, and
is not printed. The assigned index is suspended this round; the user picks. Present the familiar register: your remaining grounded
candidates from the conventional end, at most three, as full cards with an
honest risk line each, plus the canon executed against two or three named
competitors. This is the one sanctioned lineup of your own ranked
candidates; it exists only by this explicit request. When the user voices a
standing preference for it, record a brand commitment in PRODUCT.md.`;
const bolderBlock = `BOLDER REGISTER: the user asked for foreign forms at full commitment, so no
grounded direction is presented this round and the assigned index is
suspended. The hand is every dealt challenger below, each fused with the
product and presented as a full card; the FIRST dealt challenger leads, an
assignment by deal order, so the dice still choose. Verdicts and donations
apply between the challengers, weighed against the leader. The pick card
sits out; the canon stays, as always.`;
const telemetryBlock = data.source === 'api'
? `TELEMETRY: if the resolved direction uses one of these challengers, rerun
this script once with --chosen <challenger-id> --from ${key} --scope ${scope}${mode ? ` --mode ${mode}` : ''}
after resolution. The ping is anonymous (chosen id only) and is skipped
automatically when DO_NOT_TRACK or IMPECCABLE_NO_TELEMETRY is set.\n`
? `TELEMETRY: after the user's choice resolves, rerun this script once with
--kind <assigned|pick|challenger|canon> --from ${key} --scope ${scope}${mode ? ` --mode ${mode}` : ''},
adding --chosen <challenger-id> when a dealt challenger won and keeping
--register <safer|bolder> when the resolved round came from a steered hand.
One ping per resolved attended round. The ping is anonymous, the card kind
plus the catalog id when one won; your grounded candidates' names never
leave the machine, and the ping is skipped automatically when DO_NOT_TRACK
or IMPECCABLE_NO_TELEMETRY is set.\n`
: '';
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: ${data.source}; approved pool: ${data.poolRevision}; ${data.approvedCount}/${data.catalogCount} human-approved; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''} --candidate-count ${candidateCount} to reproduce this roll against this catalog revision)
${rerollBlock}ASSIGNED INDEX: ${buildIndex}
const assignedBlock = register === null
? `ASSIGNED INDEX: ${buildIndex}
${promotedInstruction}
The assignment exists to refuse the model's ranking rut, never to outrank
the user or the brief. Never expose assignment metadata in user-facing labels.
CHALLENGERS:
the user or the brief. Never expose assignment metadata in user-facing labels.`
: register === 'safer' ? saferBlock : bolderBlock;
// A bolder round has no assigned grounded direction, so the generic
// weighing instruction (which measures against the assignment) would
// contradict the register; the bolder variant weighs against the leader.
const bolderChallengerInstruction = `Fuse each challenger before judging it: the challenger supplies the form
and its system grammar, the product supplies every fact, and clarity wins
conflicts. Weigh every fused challenger against the fused LEADER, the first
dealt, on exactly two axes, audience identification and product clarity;
verdicts and donations apply between the challengers, and one that beats
the leader on both axes presents as the hand's strongest alternate.`;
const roundChallengerInstruction = register === 'bolder' ? bolderChallengerInstruction : challengerInstruction;
const challengerSection = register === 'safer'
? ''
: `CHALLENGERS:
${data.challengers.map(renderChallenger).join('\n')}
${compositionBlock}${challengerInstruction}
${compositionBlock}${roundChallengerInstruction}
When you can view images, open the QUALITY BAR board and hero for any
challenger you weigh seriously and for the world you build. They exist as a
craft bar, the finish level and commitment the build is expected to reach,
never as a mockup to copy; your surface serves this product, not that render.
${authorityInstruction}
`;
const restated = register === null
? `ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
${buildIndex} of your own grounded list; seed key ${key}.`
: `REGISTER (restated for truncated readers): ${register}, user-requested; the
assigned index is suspended this round; seed key ${key}.`;
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: ${data.source}; approved pool: ${data.poolRevision}; ${data.approvedCount}/${data.catalogCount} human-approved; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''}${register ? ` --register ${register}` : ''} --candidate-count ${candidateCount} to reproduce this roll against this catalog revision)
${rerollBlock}${assignedBlock}
${challengerSection}${authorityInstruction}
${richnessInstruction}
${telemetryBlock}A user- or brief-pinned decision beats the roll, always.
ASSIGNED INDEX (restated for truncated readers): ${buildIndex}. Build candidate
${buildIndex} of your own grounded list; seed key ${key}.
${restated}
`;
}
@@ -507,19 +639,25 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
const fromIdx = args.indexOf('--from');
const scopeIdx = args.indexOf('--scope');
const rerollIdx = args.indexOf('--reroll');
const registerIdx = args.indexOf('--register');
const modeIdx = args.indexOf('--mode');
const grainIdx = args.indexOf('--grain');
const platformIdx = args.indexOf('--platform');
const candidateCountIdx = args.indexOf('--candidate-count');
const chosenIdx = args.indexOf('--chosen');
const kindIdx = args.indexOf('--kind');
try {
if (chosenIdx !== -1) {
if (chosenIdx !== -1 || kindIdx !== -1) {
// Choice ping: always exits 0, telemetry must never fail a design flow.
// --kind alone pings a non-challenger outcome (assigned/pick/canon);
// --chosen alone stays the legacy challenger-win ping.
const sent = await pingChosen({
chosenId: args[chosenIdx + 1],
chosenId: chosenIdx !== -1 ? args[chosenIdx + 1] : undefined,
key: fromIdx !== -1 ? args[fromIdx + 1] : undefined,
scope: scopeIdx !== -1 ? args[scopeIdx + 1] : undefined,
mode: modeIdx !== -1 ? args[modeIdx + 1] : undefined,
kind: kindIdx !== -1 ? args[kindIdx + 1] : undefined,
register: registerIdx !== -1 ? args[registerIdx + 1] : undefined,
});
process.stdout.write(sent ? 'choice recorded\n' : 'choice ping skipped\n');
} else {
@@ -542,6 +680,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
? args[fromIdx + 1]
: (process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex')),
reroll: rerollIdx !== -1 ? Number(args[rerollIdx + 1]) : 0,
register: registerIdx !== -1 ? args[registerIdx + 1] : null,
mode: modeIdx !== -1 ? args[modeIdx + 1] : null,
grain: grainIdx !== -1 ? args[grainIdx + 1] : null,
platform: platformIdx !== -1 ? args[platformIdx + 1] : null,
@@ -553,6 +692,13 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
process.exitCode = 1;
}
// A raced-out fetch may still hold a socket; exit explicitly so the CLI
// never lingers on a dead network path after output is written.
// never lingers on a dead network path after output is written. Destroy
// fetch's global undici dispatcher first: process.exit() with a live
// keep-alive socket trips a libuv assertion on Windows and aborts the
// process after a successful roll (nodejs/node#56645).
const dispatcher = globalThis[Symbol.for('undici.globalDispatcher.1')];
if (dispatcher && typeof dispatcher.destroy === 'function') {
try { await dispatcher.destroy(); } catch { /* exit regardless */ }
}
process.exit(process.exitCode ?? 0);
}
@@ -22,7 +22,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { loadContext, extractPlatform } from './context.mjs';
import { getCritiqueDir } from './lib/impeccable-paths.mjs';
import { readLatestSnapshotAcrossTargets } from './critique-storage.mjs';
/** Is there code here at all, or just context files / an empty repo? */
function hasCode(cwd) {
@@ -34,23 +34,13 @@ function hasCode(cwd) {
}
/**
* The most recent critique snapshot across all targets. Filenames are
* timestamp-prefixed (`<iso>__<slug>.md`), so a lexical sort is chronological.
* Parses the small frontmatter for score + P0/P1 counts.
* Summarize the most recent critique snapshot across all targets.
*/
function latestCritique(cwd) {
try {
const dir = getCritiqueDir(cwd);
if (!fs.existsSync(dir)) return null;
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.md')).sort();
if (!files.length) return null;
const newest = files[files.length - 1];
const text = fs.readFileSync(path.join(dir, newest), 'utf-8');
const front = text.split('---')[1] || '';
const get = (k) => {
const m = front.match(new RegExp(`^${k}:\\s*(.+)$`, 'm'));
return m ? m[1].trim() : null;
};
const latest = readLatestSnapshotAcrossTargets({ cwd });
if (!latest) return null;
const get = (key) => latest.meta[key] ?? null;
const num = (v) => {
const n = Number(v);
return Number.isFinite(n) ? n : null;
@@ -61,7 +51,7 @@ function latestCritique(cwd) {
p0: num(get('p0')),
p1: num(get('p1')),
timestamp: get('timestamp'),
file: path.relative(cwd, path.join(dir, newest)),
file: path.relative(cwd, latest.path),
};
} catch {
return null;
@@ -105,28 +105,37 @@ function parseFrontmatter(text) {
}
/**
* Return all snapshot files for `slug`, sorted oldest newest.
* Return snapshot files matching `suffix`, sorted oldest newest.
*/
function listSnapshotsForSlug(slug, cwd) {
const SNAPSHOT_FILENAME = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z__.+\.md$/;
function listSnapshots(suffix, cwd) {
const dir = getCritiqueDir(cwd);
if (!fs.existsSync(dir)) return [];
const suffix = `__${slug}.md`;
return fs.readdirSync(dir)
.filter((f) => f.endsWith(suffix))
.filter((f) => SNAPSHOT_FILENAME.test(f) && f.endsWith(suffix))
.sort()
.map((f) => path.join(dir, f));
}
function readLatestSnapshotMatching(suffix, cwd) {
const filePath = listSnapshots(suffix, cwd).at(-1);
if (!filePath) return null;
const body = fs.readFileSync(filePath, 'utf-8');
return { path: filePath, body, meta: parseFrontmatter(body) };
}
/**
* Return the most recent snapshot for `slug`, or null. Polish reads this
* to find its fix backlog when the slug matches.
*/
export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
const all = listSnapshotsForSlug(slug, cwd);
if (!all.length) return null;
const latest = all[all.length - 1];
const body = fs.readFileSync(latest, 'utf-8');
return { path: latest, body, meta: parseFrontmatter(body) };
return readLatestSnapshotMatching(`__${slug}.md`, cwd);
}
/** Return the most recent snapshot across all targets, or null. */
export function readLatestSnapshotAcrossTargets({ cwd = process.cwd() } = {}) {
return readLatestSnapshotMatching('.md', cwd);
}
/**
@@ -134,7 +143,7 @@ export function readLatestSnapshot(slug, { cwd = process.cwd() } = {}) {
* Critique appends a one-line trend to its output using this.
*/
export function readTrend(slug, { limit = 5, cwd = process.cwd() } = {}) {
const all = listSnapshotsForSlug(slug, cwd);
const all = listSnapshots(`__${slug}.md`, cwd);
const slice = all.slice(-limit);
return slice.map((file) => parseFrontmatter(fs.readFileSync(file, 'utf-8')));
}
@@ -683,6 +683,10 @@ if (IS_BROWSER) {
const reasons = collectVisualContrastReasons(el, style);
if (reasons.length === 0) continue;
// Image-only mode filters here, inside the cap: gradient/opacity/filter
// candidates earlier in DOM order must not consume the budget and
// starve the url()-backed texts this mode exists to sample.
if (options.imageOnly && !reasons.includes('image background')) continue;
const textColor = parseRgb(style.color);
const fontSize = parseFloat(style.fontSize) || 16;
@@ -1175,6 +1179,7 @@ if (IS_BROWSER) {
}
async function analyzeVisualContrast(options = {}) {
// imageOnly is enforced inside the collector, before the candidate cap.
const candidates = collectVisualContrastCandidates(options);
const results = [];
const shouldScrollOffscreen = options.scrollOffscreen === true;
@@ -1260,9 +1265,16 @@ if (IS_BROWSER) {
function addBrowserFindings(groupMap, el, findings) {
if (!findings || findings.length === 0) return;
// Element-scoped waivers: a data-impeccable-ignore ancestor suppresses
// matching findings for its whole subtree. Applied at this choke point so
// every per-element attribution (checks, layout, occlusion, rhythm)
// honors it; page-level findings attributed to <body> pass through
// untouched, since body has no ignoring ancestor.
const kept = findings.filter(f => !scopedIgnoreActive(el, f.type));
if (kept.length === 0) return;
const existing = groupMap.get(el);
if (existing) existing.push(...findings);
else groupMap.set(el, [...findings]);
if (existing) existing.push(...kept);
else groupMap.set(el, [...kept]);
}
function browserFindingsFromMap(groupMap) {
@@ -1620,9 +1632,27 @@ if (IS_BROWSER) {
for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) {
node.remove();
}
const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML);
if (htmlPatternFindings.length > 0) {
const mapped = htmlPatternFindings.map(f => {
// Regex findings that name a live selector resolve against the real DOM:
// pseudo-element/class segments are stripped (the host element is the
// anchor), a selector that matches nothing on this page drops the finding
// (the CSS ships here, but the pattern never renders — the live DOM is
// ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level.
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
if (!f.selector) return true;
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false;
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
});
if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => {
const item = { type: f.id, detail: f.snippet };
if (f.severity) {
item.severity = f.severity;
@@ -1652,8 +1682,27 @@ if (IS_BROWSER) {
};
}
// Visual contrast has three modes. Explicit true runs the full sampled
// pass; explicit false disables it entirely (the deterministic-only mode
// the test suites use). Unset — the default overlay run — samples ONLY
// image-backed text: the one class the analytic walk deliberately skips,
// because a url() layer's pixels are unknowable without looking. In-page
// sampling draws the source image alone to a canvas (glyph ink never
// pollutes it), and a cross-origin image without CORS reports unresolved
// instead of guessing.
function visualContrastMode(options = {}) {
const explicit = typeof options.visualContrast === 'boolean'
? options.visualContrast
: typeof window.__IMPECCABLE_CONFIG__?.visualContrast === 'boolean'
? window.__IMPECCABLE_CONFIG__.visualContrast
: null;
if (explicit === true) return 'full';
if (explicit === false) return false;
return 'image-only';
}
function shouldRunVisualContrast(options = {}) {
return options.visualContrast === true || window.__IMPECCABLE_CONFIG__?.visualContrast === true;
return visualContrastMode(options) !== false;
}
function visualContrastOptions(options = {}) {
@@ -1830,6 +1879,7 @@ if (IS_BROWSER) {
return [];
}
const resolvedOptions = visualContrastOptions(options);
if (visualContrastMode(options) === 'image-only') resolvedOptions.imageOnly = true;
const analyses = await analyzeVisualContrast(resolvedOptions);
if (runtime.generation && runtime.generation !== scanGeneration) return analyses;
lastVisualContrastAnalyses = analyses;
@@ -861,6 +861,34 @@ function checkBorders(tag, widths, colors, radius, opts = {}) {
return findings;
}
// ─── Scoped ignores: data-impeccable-ignore ─────────────────────────────────
//
// An element-scoped waiver that travels with the markup: any element carrying
// `data-impeccable-ignore="rule-a rule-b"` (or `*`, or an empty value, for
// every rule) suppresses matching findings from itself and its entire subtree,
// in every engine that walks elements — the browser overlay, the extension,
// and the static scan. This is the DOM twin of the line-based
// `impeccable-disable` comment directives, which the browser cannot apply (a
// live DOM has no line numbers), and the generalization of the one-off
// `data-impeccable-allow-kickers` opt-out.
//
// The intended use is curated exhibits: a page that documents anti-patterns by
// example, or renders a deliberate "before" specimen, marks the container once
// and every engine skips it while still scanning the page around it.
function scopedIgnoreActive(el, ruleId) {
const rule = String(ruleId || '').toLowerCase();
let cur = el;
while (cur && cur.nodeType === 1) {
const attr = typeof cur.getAttribute === 'function' ? cur.getAttribute('data-impeccable-ignore') : null;
if (attr != null) {
const rules = String(attr).trim().toLowerCase().split(/[\s,]+/).filter(Boolean);
if (rules.length === 0 || rules.includes('*') || rules.includes(rule)) return true;
}
cur = cur.parentElement;
}
return false;
}
// Returns true if the given text is composed entirely of emoji characters
// (plus whitespace / variation selectors). Emojis render as multicolor glyphs
// regardless of CSS `color`, so contrast checks against the element's text
@@ -1428,6 +1456,26 @@ function cssTextHasDarkRootBg(content, customProps) {
return false;
}
// Best-effort extraction of the CSS selector whose declaration block contains
// the given index in raw CSS text. Lets CSS-text findings carry a live-DOM
// anchor, so the browser pass can resolve scoped ignores against the actual
// element and drop patterns that render nowhere on the page. Returns null for
// @-rule preludes, keyframe steps, nested blocks, and anything that does not
// read as a selector; those findings stay page-level.
function enclosingCssSelector(cssText, index) {
if (!cssText || !Number.isFinite(index)) return null;
const open = cssText.lastIndexOf('{', index);
if (open === -1) return null;
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
const raw = cssText.slice(prevClose + 1, open).trim().replace(/\s+/g, ' ');
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from`
// and `to` would read as (never-matching) type selectors and get a valid
// finding wrongly dropped by the zero-match rule downstream.
if (/^(?:from|to)(?:\s*,\s*(?:from|to))*$/i.test(raw)) return null;
return raw;
}
function scanCssTextForGlow(content) {
const customProps = collectCssCustomProps(content);
const hasDarkBg = cssTextHasDarkRootBg(content, customProps);
@@ -1739,6 +1787,7 @@ function scanCssTextForPseudoStripe(rawContent) {
id: 'side-tab',
snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`,
index: selectorStart,
selector,
});
}
return findings;
@@ -1801,6 +1850,7 @@ function scanCssTextForInsetStripe(content) {
findings.push({
id: 'side-tab',
snippet: `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`,
selector,
});
break;
}
@@ -1858,7 +1908,7 @@ function collectMarqueeKeyframes(content) {
function scanCssTextForMarquee(content, markup = content) {
const findings = [];
if (/<marquee\b/i.test(markup)) {
findings.push({ id: 'marquee', snippet: '<marquee> element' });
findings.push({ id: 'marquee', snippet: '<marquee> element', selector: 'marquee' });
}
const marqueeKeyframes = collectMarqueeKeyframes(content);
if (marqueeKeyframes.size === 0) return findings;
@@ -1873,7 +1923,7 @@ function scanCssTextForMarquee(content, markup = content) {
const key = `${selector} ${name}`;
if (seen.has(key)) continue;
seen.add(key);
findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"` });
findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"`, selector });
}
}
return findings;
@@ -2244,8 +2294,10 @@ function checkHtmlPatterns(html, corpora) {
const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
if (purpleHexRe.test(styleText)) {
const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
if (purpleTextRe.test(styleText)) {
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
purpleTextRe.lastIndex = 0;
const purpleMatch = purpleTextRe.exec(styleText);
if (purpleMatch) {
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected', selector: enclosingCssSelector(styleText, purpleMatch.index + 1) || undefined });
}
}
@@ -2256,7 +2308,7 @@ function checkHtmlPatterns(html, corpora) {
const start = Math.max(0, gm.index - 200);
const context = styleText.substring(start, gm.index + gm[0].length + 200);
if (/gradient/i.test(context)) {
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient', selector: enclosingCssSelector(styleText, gm.index) || undefined });
break;
}
}
@@ -2322,7 +2374,7 @@ function checkHtmlPatterns(html, corpora) {
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()}` });
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}`, selector: enclosingCssSelector(styleText, bounceMatch.index) || undefined });
}
// Overshoot cubic-bezier
@@ -2331,7 +2383,7 @@ function checkHtmlPatterns(html, corpora) {
while ((bm = bezierRe.exec(styleText)) !== null) {
const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})`, selector: enclosingCssSelector(styleText, bm.index) || undefined });
break;
}
}
@@ -2364,18 +2416,21 @@ function checkHtmlPatterns(html, corpora) {
const glowHits = scanCssTextForGlow(styleText);
if (glowHits.length > 0) {
findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet });
findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet, selector: enclosingCssSelector(styleText, glowHits[0].index) || undefined });
}
// Radial-gradient background halo (gradient-drawn sibling of dark-glow)
const haloHits = scanCssTextForRadialHalo(styleText);
if (haloHits.length > 0) {
findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet });
findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet, selector: enclosingCssSelector(styleText, haloHits[0].index) || undefined });
}
// --- Generated-UI tells: repeating-gradient stripes ---
if (/repeating-(?:linear|radial|conic)-gradient\s*\(/i.test(styleText)) {
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });
{
const stripesMatch = /repeating-(?:linear|radial|conic)-gradient\s*\(/i.exec(styleText);
if (stripesMatch) {
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes', selector: enclosingCssSelector(styleText, stripesMatch.index) || undefined });
}
}
// --- Generated-UI tells: two-axis grid-line background ---
@@ -2393,7 +2448,7 @@ function checkHtmlPatterns(html, corpora) {
// whole gradient layers.
const gridHits = scanCssTextForGridBackground(styleText);
if (gridHits.length > 0) {
findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet });
findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet, selector: enclosingCssSelector(styleText, gridHits[0].index) || undefined });
}
// --- Generated-copy tells: "X theater" framing copy ---
@@ -2413,8 +2468,11 @@ function checkHtmlPatterns(html, corpora) {
// hover:rotate / hover:translate utility on an <img>. Each distinct
// mechanism is its own finding.
const imgHoverCss = /\bimg\b[^,{}]*:hover\b[^{}]*\{[^}]*\btransform\s*:\s*(?:scale|rotate|translate|matrix|skew)/i;
if (imgHoverCss.test(styleText)) {
findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule' });
{
const imgHoverMatch = imgHoverCss.exec(styleText);
if (imgHoverMatch) {
findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule', selector: enclosingCssSelector(styleText, imgHoverMatch.index + imgHoverMatch[0].indexOf('{') + 1) || undefined });
}
}
const imgTagRe = /<img\b[^>]*\bclass\s*=\s*"([^"]*)"/gi;
let im;
@@ -2461,6 +2519,33 @@ function readOwnBackgroundColor(el, computedStyle) {
return bg;
}
// One element's background-color as the cascade walk sees it: computed style
// first (with the modern-color fallback), then, in static mode only,
// custom-prop resolution and the inline-shorthand peek. Shared by
// resolveBackground and resolveGradientStops so both walks read the same
// surfaces.
function readCascadeBackgroundColor(current, style, customPropMap) {
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
// The static engine can return literal "var(--X)" / "oklch(...)" strings.
// Resolve through customPropMap so Tailwind v4 color tokens become RGB.
if (customPropMap) {
bg = parseColorResolved(style.backgroundColor, customPropMap);
}
if (!bg || bg.a < 0.1) {
// Inline-style fallback for colors the static cascade did not surface
// on backgroundColor.
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
}
}
}
return bg;
}
function resolveBackground(el, win, customPropMap) {
let current = el;
// Translucent layers (0.1 < a < 1) found on the way down to an opaque
@@ -2489,24 +2574,7 @@ function resolveBackground(el, win, customPropMap) {
// body backgrounds.
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
// (e.g. any color-mix() result), which plain parseRgb misses.
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
// jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve
// through customPropMap so Tailwind v4 color tokens become RGB.
if (customPropMap) {
bg = parseColorResolved(style.backgroundColor, customPropMap);
}
if (!bg || bg.a < 0.1) {
// Inline-style fallback. jsdom doesn't decompose background
// shorthand, so colors set via inline style are otherwise invisible.
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
}
}
}
const bg = readCascadeBackgroundColor(current, style, customPropMap);
if (bg && bg.a > 0.1) {
if (bg.a >= 0.99) return flatten(bg);
@@ -2524,40 +2592,95 @@ function resolveBackground(el, win, customPropMap) {
// • on other elements: bail to null and let the caller fall back
// to gradient stops (gradient buttons / hero sections are real
// bgs worth checking against).
if (hasGradientOrUrl) {
if (current.tagName === 'BODY' || current.tagName === 'HTML') {
return flatten({ r: 255, g: 255, b: 255, a: 1 });
}
return null;
}
// A gradient or image with no solid color under it, at any level
// including body/html, means the visible ground is that layer itself.
// Return null so the caller measures against the actual gradient stops,
// or skips when nothing is parseable — skipping beats a wrong ratio.
//
// Body/html used to assume white here, a guard written for jsdom, which
// never decomposed the `background:` shorthand and so could not see the
// solid paper color a texture gradient usually sits on. It turned every
// light-on-dark page into a wall of low-contrast false positives (a dark
// oklch body gradient produced ~120 "on #ffffff" findings on one site).
// Both engines can see shorthand solids now — the browser natively, the
// static cascade via expandStaticDeclaration + var() resolution — so a
// missing solid is real, and the old failure case cannot recur: opaque
// stops fully cover any hidden solid (they ARE the ground), alpha stops
// composite over the resolved base or the white canvas default, and
// unresolvable stops drop rather than guess.
if (hasGradientOrUrl) return null;
current = current.parentElement;
}
return flatten({ r: 255, g: 255, b: 255, a: 1 });
}
// parseGradientColors (shared) reads only the legacy serializations: rgb()
// and hex stops. Browsers keep modern-space stops in computed backgroundImage
// exactly as authored — `linear-gradient(oklch(7% 0.006 95), …)` stays oklch —
// which is what every token-driven page produces. Route those through
// parseAnyColor so a gradient ground is measurable rather than invisible.
function parseGradientColorsModern(bgImage) {
if (!bgImage || !/gradient/i.test(bgImage)) return [];
const colors = parseGradientColors(bgImage);
for (const m of bgImage.matchAll(/(?:oklch|oklab|hsla?|hwb)\(\s*[^()]*\)/gi)) {
const c = parseAnyColor(m[0]);
if (c) colors.push(c);
}
return colors;
}
// Walk parents looking for a gradient background and return its color stops.
// Used as a fallback when resolveBackground() returns null because the
// effective background is a gradient (no single solid color to compare against).
// Translucent solid layers found between the element and the gradient (frosted
// panels, glass washes) are composited over every stop, the same way
// resolveBackground flattens them over a solid base — raw stops alone would
// false-flag dark text on a light frosted wash over a dark gradient, and miss
// the inverse.
function resolveGradientStops(el, win, customPropMap) {
let current = el;
const overlays = [];
while (current && current.nodeType === 1) {
const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
const bgImage = style.backgroundImage || '';
// A url() layer anywhere in the stack — alone, or alongside a gradient in
// the same declaration (a translucent wash over a texture photo) — paints
// pixels the analytic walk cannot know. Measuring the gradient stops over
// the wrong base flagged dark ink sitting on a bright gold-leaf image at
// 2.6:1; skipping beats a wrong ratio, and the screenshot subsystem owns
// image-backed text.
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
let stops = null;
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
const parsed = parseGradientColors(bgImage);
const parsed = parseGradientColorsModern(bgImage);
if (parsed.length > 0) stops = parsed;
}
if (!stops && !DETECTOR_IS_BROWSER) {
// jsdom doesn't decompose `background:` shorthand — peek at the raw inline style
// Static mode: peek at the raw inline style for gradients the cascade did not surface
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
if (bgMatch && /gradient/i.test(bgMatch[1])) {
const parsed = parseGradientColors(bgMatch[1]);
const parsed = parseGradientColorsModern(bgMatch[1]);
if (parsed.length > 0) stops = parsed;
}
}
if (stops) return compositeGradientStops(stops, current, win, customPropMap);
if (stops) {
const composited = compositeGradientStops(stops, current, win, customPropMap);
if (!composited || overlays.length === 0) return composited;
return composited.map(stop => {
let acc = stop;
for (let i = overlays.length - 1; i >= 0; i--) acc = compositeColorOver(overlays[i], acc);
return acc;
});
}
const bg = readCascadeBackgroundColor(current, style, customPropMap);
if (bg && bg.a > 0.1) {
// An opaque surface above the gradient means the gradient never shows
// through here; resolveBackground would have returned it, so reaching
// this is defensive — bail rather than measure the wrong layer.
if (bg.a >= 0.99) return null;
overlays.push(bg);
}
current = current.parentElement;
}
return null;
@@ -2777,6 +2900,10 @@ function checkElementColorsDOM(el) {
const rect = el.getBoundingClientRect();
if (rect.width < 10 || rect.height < 10) return [];
const style = getComputedStyle(el);
// Invisible at rest: hidden scene variants (opacity-0 carousels, swap
// decks) are not user-visible, and measuring their inherited colors against
// whatever surface happens to sit behind the stack is noise, not audit.
if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
let effectiveBg = resolveBackground(el);
@@ -3589,11 +3716,13 @@ function checkElementGlowDOM(el) {
// If resolveBackground returns null (gradient), try to infer from the gradient colors
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
if (!parentBg) {
// Gradient background — sample its colors to determine if it's dark
// Gradient background — sample its colors to determine if it's dark.
// Modern-syntax parsing matters here: body-level gradients now reach this
// fallback in browser mode, and their stops usually serialize as oklch.
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const bgImage = getComputedStyle(cur).backgroundImage || '';
const gradColors = parseGradientColors(bgImage);
const gradColors = parseGradientColorsModern(bgImage);
if (gradColors.length > 0) {
// Average the gradient colors
const avg = { r: 0, g: 0, b: 0 };
@@ -4435,6 +4564,14 @@ function checkElementBorders(tag, style, overrides, resolvedRadius, el = null) {
}
function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) {
// Invisible at rest, static twin of the browser walk's skip: opacity does
// not inherit, so walk ancestors multiplying declared opacity down.
if (style.visibility === 'hidden') return [];
let effOpacity = 1;
for (let cur = el; cur && cur.nodeType === 1 && effOpacity > 0.02; cur = cur.parentElement) {
effOpacity *= parseFloat(window.getComputedStyle(cur).opacity || '1');
}
if (effOpacity <= 0.02) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
@@ -5593,6 +5730,11 @@ function isRenderedForBrowserRule(el) {
function checkElementTextOverflowDOM(el) {
const tag = el.tagName.toLowerCase();
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
// scrollWidth/clientWidth are CSS box-model metrics; on SVG content Chrome
// returns arbitrary non-zero values for both (a <text> reported 78/48 while
// its rendered length sat comfortably inside its box), so the delta is
// noise, not overflow. SVG clips to its own viewport anyway.
if (el.namespaceURI === 'http://www.w3.org/2000/svg') 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.
@@ -5977,6 +6119,22 @@ function isPaintedForOcclusion(el) {
// path is pure geometry and runs anywhere on the page.
const OCCLUSION_TEXT_SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'title']);
// An element whose effective opacity multiplies out to ~0 paints nothing at
// rest: it is not user-visible, so visual findings on it (contrast, occlusion)
// measure a state nobody sees. Browser-only — the walk needs live computed
// styles. Cycling scenes that fade such elements in later are the screenshot
// subsystem's territory, not the analytic walk's.
function effectiveOpacityDOM(el) {
let o = 1;
// Walk all the way through body and html: `body { opacity: 0 }` page-fade
// wrappers hide every descendant just as thoroughly as a local wrapper.
for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {
o *= parseFloat(getComputedStyle(cur).opacity || '1');
if (o <= 0.02) return 0;
}
return o;
}
function checkTextOcclusionDOM() {
const findings = [];
const seenVictims = new Set();
@@ -6004,6 +6162,11 @@ function checkTextOcclusionDOM() {
}
return false;
};
// The classic occluder shape this rules out is an opacity-0 interaction
// layer — a range scrubber stretched over a before/after comparison — which
// elementFromPoint still returns and whose UA background-color otherwise
// reads as an opaque box.
const effectiveOpacity = effectiveOpacityDOM;
// Collect renderable text owners in / near the first viewport for the
// elementFromPoint probe. SVG <text> counts too.
@@ -6016,6 +6179,7 @@ function checkTextOcclusionDOM() {
const text = inSvg ? (el.textContent || '').trim() : elementDirectText(el);
if (text.length < 2) continue;
if (!isPaintedForOcclusion(el)) continue;
if (effectiveOpacity(el) <= 0.02) continue;
let rect; try { rect = el.getBoundingClientRect(); } catch { continue; }
if (rect.width < 6 || rect.height < 6) continue;
// Viewport-bound probe: keep text whose box overlaps the live viewport.
@@ -6049,6 +6213,7 @@ function checkTextOcclusionDOM() {
if (top === el || el.contains(top) || top.contains(el)) continue;
const topCs = getComputedStyle(top);
if (isFloated(topCs) || isMarqueeish(top, topCs) || isPinnedOverlay(top)) continue;
if (effectiveOpacity(top) <= 0.02) continue;
const topTag = top.tagName.toLowerCase();
// Text sitting under a raw image/video is contrast territory (deduped
// against the pixel low-contrast rule); leave those alone here.
@@ -6941,6 +7106,10 @@ if (IS_BROWSER) {
const reasons = collectVisualContrastReasons(el, style);
if (reasons.length === 0) continue;
// Image-only mode filters here, inside the cap: gradient/opacity/filter
// candidates earlier in DOM order must not consume the budget and
// starve the url()-backed texts this mode exists to sample.
if (options.imageOnly && !reasons.includes('image background')) continue;
const textColor = parseRgb(style.color);
const fontSize = parseFloat(style.fontSize) || 16;
@@ -7433,6 +7602,7 @@ if (IS_BROWSER) {
}
async function analyzeVisualContrast(options = {}) {
// imageOnly is enforced inside the collector, before the candidate cap.
const candidates = collectVisualContrastCandidates(options);
const results = [];
const shouldScrollOffscreen = options.scrollOffscreen === true;
@@ -7518,9 +7688,16 @@ if (IS_BROWSER) {
function addBrowserFindings(groupMap, el, findings) {
if (!findings || findings.length === 0) return;
// Element-scoped waivers: a data-impeccable-ignore ancestor suppresses
// matching findings for its whole subtree. Applied at this choke point so
// every per-element attribution (checks, layout, occlusion, rhythm)
// honors it; page-level findings attributed to <body> pass through
// untouched, since body has no ignoring ancestor.
const kept = findings.filter(f => !scopedIgnoreActive(el, f.type));
if (kept.length === 0) return;
const existing = groupMap.get(el);
if (existing) existing.push(...findings);
else groupMap.set(el, [...findings]);
if (existing) existing.push(...kept);
else groupMap.set(el, [...kept]);
}
function browserFindingsFromMap(groupMap) {
@@ -7878,9 +8055,27 @@ if (IS_BROWSER) {
for (const node of docClone.querySelectorAll('[id^="impeccable-live-"]')) {
node.remove();
}
const htmlPatternFindings = checkHtmlPatterns(docClone.outerHTML);
if (htmlPatternFindings.length > 0) {
const mapped = htmlPatternFindings.map(f => {
// Regex findings that name a live selector resolve against the real DOM:
// pseudo-element/class segments are stripped (the host element is the
// anchor), a selector that matches nothing on this page drops the finding
// (the CSS ships here, but the pattern never renders — the live DOM is
// ground truth in the browser), and a match under a data-impeccable-ignore
// ancestor is waived. Selector-less findings stay page-level.
const scopedHtmlFindings = checkHtmlPatterns(docClone.outerHTML).filter(f => {
if (!f.selector) return true;
const query = String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim().replace(/,\s*(?=,|$)/g, '');
if (!query || /^[,\s]*$/.test(query)) return true;
let matches;
try {
matches = document.querySelectorAll(query);
} catch {
return true;
}
if (matches.length === 0) return false;
return [...matches].some(el => !scopedIgnoreActive(el, f.id));
});
if (scopedHtmlFindings.length > 0) {
const mapped = scopedHtmlFindings.map(f => {
const item = { type: f.id, detail: f.snippet };
if (f.severity) {
item.severity = f.severity;
@@ -7910,8 +8105,27 @@ if (IS_BROWSER) {
};
}
// Visual contrast has three modes. Explicit true runs the full sampled
// pass; explicit false disables it entirely (the deterministic-only mode
// the test suites use). Unset — the default overlay run — samples ONLY
// image-backed text: the one class the analytic walk deliberately skips,
// because a url() layer's pixels are unknowable without looking. In-page
// sampling draws the source image alone to a canvas (glyph ink never
// pollutes it), and a cross-origin image without CORS reports unresolved
// instead of guessing.
function visualContrastMode(options = {}) {
const explicit = typeof options.visualContrast === 'boolean'
? options.visualContrast
: typeof window.__IMPECCABLE_CONFIG__?.visualContrast === 'boolean'
? window.__IMPECCABLE_CONFIG__.visualContrast
: null;
if (explicit === true) return 'full';
if (explicit === false) return false;
return 'image-only';
}
function shouldRunVisualContrast(options = {}) {
return options.visualContrast === true || window.__IMPECCABLE_CONFIG__?.visualContrast === true;
return visualContrastMode(options) !== false;
}
function visualContrastOptions(options = {}) {
@@ -8088,6 +8302,7 @@ if (IS_BROWSER) {
return [];
}
const resolvedOptions = visualContrastOptions(options);
if (visualContrastMode(options) === 'image-only') resolvedOptions.imageOnly = true;
const analyses = await analyzeVisualContrast(resolvedOptions);
if (runtime.generation && runtime.generation !== scanGeneration) return analyses;
lastVisualContrastAnalyses = analyses;
@@ -35,6 +35,7 @@ export { detectUrl, createBrowserDetector } from './engines/browser/detect-url.m
export { detectText, extractStyleBlocks, extractCSSinJS } from './engines/regex/detect-text.mjs';
export {
walkDir,
hasScannableExtension,
SCANNABLE_EXTENSIONS,
SKIP_DIRS,
buildImportGraph,
@@ -226,6 +226,10 @@ const STATIC_INHERITED_PROPS = new Set([
'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight', 'fontVariant',
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
'webkitHyphens',
// visibility inherits in real CSS, and the invisible-at-rest contrast skip
// relies on descendants of a hidden container computing as hidden. A child
// that declares `visibility: visible` still overrides the inherited value.
'visibility',
]);
const STATIC_DEFAULT_STYLE = {
@@ -278,6 +282,7 @@ const STATIC_DEFAULT_STYLE = {
marginLeft: '0px',
position: 'static',
visibility: 'visible',
opacity: '1',
top: 'auto',
right: 'auto',
bottom: 'auto',
@@ -334,6 +339,7 @@ const STATIC_PROP_MAP = {
'margin-left': 'marginLeft',
'position': 'position',
'visibility': 'visibility',
'opacity': 'opacity',
'top': 'top',
'right': 'right',
'bottom': 'bottom',
@@ -28,6 +28,7 @@ import {
checkCreamPalette,
checkHtmlPatterns,
checkKickerAboveHeadingFromDoc,
scopedIgnoreActive,
checkNumberedSectionLabelsFromDoc,
checkPageLayout,
checkPageQualityFromDoc,
@@ -138,10 +139,21 @@ async function detectHtml(filePath, options = {}) {
domutils,
};
});
} catch {
return detectText(html, filePath, options);
} catch (err) {
if (!globalThis.__impeccableStaticHtmlWarned) {
globalThis.__impeccableStaticHtmlWarned = true;
process.stderr.write(
'impeccable detect: DEGRADED - HTML parser modules unavailable ' +
'(htmlparser2, css-select, css-tree, domutils).\n' +
'Falling back to regex matching. Custom properties, selector matching and computed ' +
'contrast are NOT evaluated; findings are an undercount, not a clean bill of health.\n'
);
}
return detectText(html, filePath, options);
}
const resolvedPath = path.resolve(filePath);
const fileDir = path.dirname(resolvedPath);
const root = profileStep(profile, {
@@ -171,6 +183,9 @@ async function detectHtml(filePath, options = {}) {
const tag = el.tagName.toLowerCase();
const style = window.getComputedStyle(el);
for (const f of runElementCheck(rule.id, () => rule.run(el, tag, style, window, customPropMap))) {
// Element-scoped waivers: a data-impeccable-ignore ancestor suppresses
// matching findings for its subtree, same as the browser walk.
if (scopedIgnoreActive(el, f.id)) continue;
findings.push(finding(f.id, filePath, f.snippet));
}
}
@@ -238,6 +253,17 @@ async function detectHtml(filePath, options = {}) {
for (const f of runPageCheck('html-patterns', () => checkHtmlPatterns(html, patternCorpora).filter(item =>
item.id !== 'bounce-easing' && item.id !== 'layout-transition'
))) {
// Selector-backed page findings honor scoped waivers here too, matching
// the browser pass: resolve the selector and drop the finding when an
// ignoring ancestor covers a match. Unlike the browser, an unmatched
// selector keeps the finding — static scans see partial documents.
if (f.selector) {
let matches = null;
try {
matches = document.querySelectorAll(String(f.selector).replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, '').trim());
} catch { matches = null; }
if (matches && matches.length > 0 && [...matches].every(el => scopedIgnoreActive(el, f.id))) continue;
}
const item = finding(f.id, filePath, f.snippet);
// Position-aware severity promotion: checks may attach a per-finding
// severity (e.g. a pulsing dot inside a header/nav landmark) that
@@ -26,11 +26,20 @@ const HIDDEN_SOURCE_DIRS = new Set(['.vitepress', '.vuepress', '.storybook']);
const SCANNABLE_EXTENSIONS = new Set([
'.html', '.htm', '.css', '.scss', '.sass', '.less',
'.jsx', '.tsx', '.js', '.ts',
'.vue', '.svelte', '.astro',
'.vue', '.svelte', '.astro', '.blade.php',
]);
const HTML_EXTENSIONS = new Set(['.html', '.htm']);
function hasScannableExtension(filename) {
const lower = filename.toLowerCase();
if (SCANNABLE_EXTENSIONS.has(path.extname(lower))) return true;
for (const ext of SCANNABLE_EXTENSIONS) {
if (ext.indexOf('.', 1) !== -1 && lower.endsWith(ext)) return true;
}
return false;
}
const IMPORT_SPECIFIER_PATTERNS = [
/import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g,
/@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g,
@@ -46,7 +55,7 @@ function walkDir(dir) {
if (entry.isDirectory() && entry.name.startsWith('.') && !HIDDEN_SOURCE_DIRS.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) files.push(...walkDir(full));
else if (SCANNABLE_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) files.push(full);
else if (hasScannableExtension(entry.name)) files.push(full);
}
return files;
}
@@ -194,6 +203,7 @@ export {
SKIP_DIRS,
SCANNABLE_EXTENSIONS,
HTML_EXTENSIONS,
hasScannableExtension,
walkDir,
resolveImport,
buildImportGraph,
@@ -70,6 +70,34 @@ function checkBorders(tag, widths, colors, radius, opts = {}) {
return findings;
}
// ─── Scoped ignores: data-impeccable-ignore ─────────────────────────────────
//
// An element-scoped waiver that travels with the markup: any element carrying
// `data-impeccable-ignore="rule-a rule-b"` (or `*`, or an empty value, for
// every rule) suppresses matching findings from itself and its entire subtree,
// in every engine that walks elements — the browser overlay, the extension,
// and the static scan. This is the DOM twin of the line-based
// `impeccable-disable` comment directives, which the browser cannot apply (a
// live DOM has no line numbers), and the generalization of the one-off
// `data-impeccable-allow-kickers` opt-out.
//
// The intended use is curated exhibits: a page that documents anti-patterns by
// example, or renders a deliberate "before" specimen, marks the container once
// and every engine skips it while still scanning the page around it.
function scopedIgnoreActive(el, ruleId) {
const rule = String(ruleId || '').toLowerCase();
let cur = el;
while (cur && cur.nodeType === 1) {
const attr = typeof cur.getAttribute === 'function' ? cur.getAttribute('data-impeccable-ignore') : null;
if (attr != null) {
const rules = String(attr).trim().toLowerCase().split(/[\s,]+/).filter(Boolean);
if (rules.length === 0 || rules.includes('*') || rules.includes(rule)) return true;
}
cur = cur.parentElement;
}
return false;
}
// Returns true if the given text is composed entirely of emoji characters
// (plus whitespace / variation selectors). Emojis render as multicolor glyphs
// regardless of CSS `color`, so contrast checks against the element's text
@@ -637,6 +665,26 @@ function cssTextHasDarkRootBg(content, customProps) {
return false;
}
// Best-effort extraction of the CSS selector whose declaration block contains
// the given index in raw CSS text. Lets CSS-text findings carry a live-DOM
// anchor, so the browser pass can resolve scoped ignores against the actual
// element and drop patterns that render nowhere on the page. Returns null for
// @-rule preludes, keyframe steps, nested blocks, and anything that does not
// read as a selector; those findings stay page-level.
function enclosingCssSelector(cssText, index) {
if (!cssText || !Number.isFinite(index)) return null;
const open = cssText.lastIndexOf('{', index);
if (open === -1) return null;
const prevClose = Math.max(cssText.lastIndexOf('}', open - 1), cssText.lastIndexOf(';', open - 1));
const raw = cssText.slice(prevClose + 1, open).trim().replace(/\s+/g, ' ');
if (!raw || raw.startsWith('@') || /^\d/.test(raw) || /[{}<]/.test(raw)) return null;
// Keyframe steps: percentage steps fail the digit test above, but `from`
// and `to` would read as (never-matching) type selectors and get a valid
// finding wrongly dropped by the zero-match rule downstream.
if (/^(?:from|to)(?:\s*,\s*(?:from|to))*$/i.test(raw)) return null;
return raw;
}
function scanCssTextForGlow(content) {
const customProps = collectCssCustomProps(content);
const hasDarkBg = cssTextHasDarkRootBg(content, customProps);
@@ -948,6 +996,7 @@ function scanCssTextForPseudoStripe(rawContent) {
id: 'side-tab',
snippet: `${selector} — absolute ${thicknessPx}px pseudo-element stripe (${edge}: 0)`,
index: selectorStart,
selector,
});
}
return findings;
@@ -1010,6 +1059,7 @@ function scanCssTextForInsetStripe(content) {
findings.push({
id: 'side-tab',
snippet: `${selector} — inset box-shadow ${ay === 0 ? ax : ay}px stripe (${edge})`,
selector,
});
break;
}
@@ -1067,7 +1117,7 @@ function collectMarqueeKeyframes(content) {
function scanCssTextForMarquee(content, markup = content) {
const findings = [];
if (/<marquee\b/i.test(markup)) {
findings.push({ id: 'marquee', snippet: '<marquee> element' });
findings.push({ id: 'marquee', snippet: '<marquee> element', selector: 'marquee' });
}
const marqueeKeyframes = collectMarqueeKeyframes(content);
if (marqueeKeyframes.size === 0) return findings;
@@ -1082,7 +1132,7 @@ function scanCssTextForMarquee(content, markup = content) {
const key = `${selector} ${name}`;
if (seen.has(key)) continue;
seen.add(key);
findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"` });
findings.push({ id: 'marquee', snippet: `${selector} — infinite horizontal loop animation "${name}"`, selector });
}
}
return findings;
@@ -1453,8 +1503,10 @@ function checkHtmlPatterns(html, corpora) {
const purpleHexRe = /#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9|6366f1|764ba2|667eea)\b/gi;
if (purpleHexRe.test(styleText)) {
const purpleTextRe = /(?:(?:^|;)\s*color\s*:\s*(?:.*?)(?:#(?:7c3aed|8b5cf6|a855f7|9333ea|7e22ce|6d28d9))|gradient.*?#(?:7c3aed|8b5cf6|a855f7|764ba2|667eea))/gi;
if (purpleTextRe.test(styleText)) {
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected' });
purpleTextRe.lastIndex = 0;
const purpleMatch = purpleTextRe.exec(styleText);
if (purpleMatch) {
findings.push({ id: 'ai-color-palette', snippet: 'Purple/violet accent colors detected', selector: enclosingCssSelector(styleText, purpleMatch.index + 1) || undefined });
}
}
@@ -1465,7 +1517,7 @@ function checkHtmlPatterns(html, corpora) {
const start = Math.max(0, gm.index - 200);
const context = styleText.substring(start, gm.index + gm[0].length + 200);
if (/gradient/i.test(context)) {
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient' });
findings.push({ id: 'gradient-text', snippet: 'background-clip: text + gradient', selector: enclosingCssSelector(styleText, gm.index) || undefined });
break;
}
}
@@ -1531,7 +1583,7 @@ function checkHtmlPatterns(html, corpora) {
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()}` });
findings.push({ id: 'bounce-easing', snippet: `animation: ${animationToken || bounceMatch[1].trim()}`, selector: enclosingCssSelector(styleText, bounceMatch.index) || undefined });
}
// Overshoot cubic-bezier
@@ -1540,7 +1592,7 @@ function checkHtmlPatterns(html, corpora) {
while ((bm = bezierRe.exec(styleText)) !== null) {
const y1 = parseFloat(bm[2]), y2 = parseFloat(bm[4]);
if (y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1) {
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})` });
findings.push({ id: 'bounce-easing', snippet: `cubic-bezier(${bm[1]}, ${bm[2]}, ${bm[3]}, ${bm[4]})`, selector: enclosingCssSelector(styleText, bm.index) || undefined });
break;
}
}
@@ -1573,18 +1625,21 @@ function checkHtmlPatterns(html, corpora) {
const glowHits = scanCssTextForGlow(styleText);
if (glowHits.length > 0) {
findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet });
findings.push({ id: 'dark-glow', snippet: glowHits[0].snippet, selector: enclosingCssSelector(styleText, glowHits[0].index) || undefined });
}
// Radial-gradient background halo (gradient-drawn sibling of dark-glow)
const haloHits = scanCssTextForRadialHalo(styleText);
if (haloHits.length > 0) {
findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet });
findings.push({ id: 'radial-halo', snippet: haloHits[0].snippet, selector: enclosingCssSelector(styleText, haloHits[0].index) || undefined });
}
// --- Generated-UI tells: repeating-gradient stripes ---
if (/repeating-(?:linear|radial|conic)-gradient\s*\(/i.test(styleText)) {
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes' });
{
const stripesMatch = /repeating-(?:linear|radial|conic)-gradient\s*\(/i.exec(styleText);
if (stripesMatch) {
findings.push({ id: 'repeating-stripes-gradient', snippet: 'repeating-gradient decorative stripes', selector: enclosingCssSelector(styleText, stripesMatch.index) || undefined });
}
}
// --- Generated-UI tells: two-axis grid-line background ---
@@ -1602,7 +1657,7 @@ function checkHtmlPatterns(html, corpora) {
// whole gradient layers.
const gridHits = scanCssTextForGridBackground(styleText);
if (gridHits.length > 0) {
findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet });
findings.push({ id: 'codex-grid-background', snippet: gridHits[0].snippet, selector: enclosingCssSelector(styleText, gridHits[0].index) || undefined });
}
// --- Generated-copy tells: "X theater" framing copy ---
@@ -1622,8 +1677,11 @@ function checkHtmlPatterns(html, corpora) {
// hover:rotate / hover:translate utility on an <img>. Each distinct
// mechanism is its own finding.
const imgHoverCss = /\bimg\b[^,{}]*:hover\b[^{}]*\{[^}]*\btransform\s*:\s*(?:scale|rotate|translate|matrix|skew)/i;
if (imgHoverCss.test(styleText)) {
findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule' });
{
const imgHoverMatch = imgHoverCss.exec(styleText);
if (imgHoverMatch) {
findings.push({ id: 'image-hover-transform', snippet: 'img:hover { transform } rule', selector: enclosingCssSelector(styleText, imgHoverMatch.index + imgHoverMatch[0].indexOf('{') + 1) || undefined });
}
}
const imgTagRe = /<img\b[^>]*\bclass\s*=\s*"([^"]*)"/gi;
let im;
@@ -1670,6 +1728,33 @@ function readOwnBackgroundColor(el, computedStyle) {
return bg;
}
// One element's background-color as the cascade walk sees it: computed style
// first (with the modern-color fallback), then, in static mode only,
// custom-prop resolution and the inline-shorthand peek. Shared by
// resolveBackground and resolveGradientStops so both walks read the same
// surfaces.
function readCascadeBackgroundColor(current, style, customPropMap) {
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
// The static engine can return literal "var(--X)" / "oklch(...)" strings.
// Resolve through customPropMap so Tailwind v4 color tokens become RGB.
if (customPropMap) {
bg = parseColorResolved(style.backgroundColor, customPropMap);
}
if (!bg || bg.a < 0.1) {
// Inline-style fallback for colors the static cascade did not surface
// on backgroundColor.
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
}
}
}
return bg;
}
function resolveBackground(el, win, customPropMap) {
let current = el;
// Translucent layers (0.1 < a < 1) found on the way down to an opaque
@@ -1698,24 +1783,7 @@ function resolveBackground(el, win, customPropMap) {
// body backgrounds.
// Real browsers serialize wide-gamut computed values as oklab()/oklch()
// (e.g. any color-mix() result), which plain parseRgb misses.
let bg = parseRgb(style.backgroundColor) || parseAnyColor(style.backgroundColor);
if (!DETECTOR_IS_BROWSER && (!bg || bg.a < 0.1)) {
// jsdom returns literal "var(--X)" / "oklch(...)" strings. Resolve
// through customPropMap so Tailwind v4 color tokens become RGB.
if (customPropMap) {
bg = parseColorResolved(style.backgroundColor, customPropMap);
}
if (!bg || bg.a < 0.1) {
// Inline-style fallback. jsdom doesn't decompose background
// shorthand, so colors set via inline style are otherwise invisible.
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-color)?\s*:\s*([^;]+)/i);
const inlineBg = bgMatch ? bgMatch[1].trim() : '';
if (inlineBg && !/gradient/i.test(inlineBg) && !/url\s*\(/i.test(inlineBg)) {
bg = parseColorResolved(inlineBg, customPropMap) || parseAnyColor(inlineBg);
}
}
}
const bg = readCascadeBackgroundColor(current, style, customPropMap);
if (bg && bg.a > 0.1) {
if (bg.a >= 0.99) return flatten(bg);
@@ -1733,40 +1801,95 @@ function resolveBackground(el, win, customPropMap) {
// • on other elements: bail to null and let the caller fall back
// to gradient stops (gradient buttons / hero sections are real
// bgs worth checking against).
if (hasGradientOrUrl) {
if (current.tagName === 'BODY' || current.tagName === 'HTML') {
return flatten({ r: 255, g: 255, b: 255, a: 1 });
}
return null;
}
// A gradient or image with no solid color under it, at any level
// including body/html, means the visible ground is that layer itself.
// Return null so the caller measures against the actual gradient stops,
// or skips when nothing is parseable — skipping beats a wrong ratio.
//
// Body/html used to assume white here, a guard written for jsdom, which
// never decomposed the `background:` shorthand and so could not see the
// solid paper color a texture gradient usually sits on. It turned every
// light-on-dark page into a wall of low-contrast false positives (a dark
// oklch body gradient produced ~120 "on #ffffff" findings on one site).
// Both engines can see shorthand solids now — the browser natively, the
// static cascade via expandStaticDeclaration + var() resolution — so a
// missing solid is real, and the old failure case cannot recur: opaque
// stops fully cover any hidden solid (they ARE the ground), alpha stops
// composite over the resolved base or the white canvas default, and
// unresolvable stops drop rather than guess.
if (hasGradientOrUrl) return null;
current = current.parentElement;
}
return flatten({ r: 255, g: 255, b: 255, a: 1 });
}
// parseGradientColors (shared) reads only the legacy serializations: rgb()
// and hex stops. Browsers keep modern-space stops in computed backgroundImage
// exactly as authored — `linear-gradient(oklch(7% 0.006 95), …)` stays oklch —
// which is what every token-driven page produces. Route those through
// parseAnyColor so a gradient ground is measurable rather than invisible.
function parseGradientColorsModern(bgImage) {
if (!bgImage || !/gradient/i.test(bgImage)) return [];
const colors = parseGradientColors(bgImage);
for (const m of bgImage.matchAll(/(?:oklch|oklab|hsla?|hwb)\(\s*[^()]*\)/gi)) {
const c = parseAnyColor(m[0]);
if (c) colors.push(c);
}
return colors;
}
// Walk parents looking for a gradient background and return its color stops.
// Used as a fallback when resolveBackground() returns null because the
// effective background is a gradient (no single solid color to compare against).
// Translucent solid layers found between the element and the gradient (frosted
// panels, glass washes) are composited over every stop, the same way
// resolveBackground flattens them over a solid base — raw stops alone would
// false-flag dark text on a light frosted wash over a dark gradient, and miss
// the inverse.
function resolveGradientStops(el, win, customPropMap) {
let current = el;
const overlays = [];
while (current && current.nodeType === 1) {
const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current);
const bgImage = style.backgroundImage || '';
// A url() layer anywhere in the stack — alone, or alongside a gradient in
// the same declaration (a translucent wash over a texture photo) — paints
// pixels the analytic walk cannot know. Measuring the gradient stops over
// the wrong base flagged dark ink sitting on a bright gold-leaf image at
// 2.6:1; skipping beats a wrong ratio, and the screenshot subsystem owns
// image-backed text.
if (bgImage && bgImage !== 'none' && /url\s*\(/i.test(bgImage)) return null;
let stops = null;
if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) {
const parsed = parseGradientColors(bgImage);
const parsed = parseGradientColorsModern(bgImage);
if (parsed.length > 0) stops = parsed;
}
if (!stops && !DETECTOR_IS_BROWSER) {
// jsdom doesn't decompose `background:` shorthand — peek at the raw inline style
// Static mode: peek at the raw inline style for gradients the cascade did not surface
const rawStyle = current.getAttribute?.('style') || '';
const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i);
if (bgMatch && /gradient/i.test(bgMatch[1])) {
const parsed = parseGradientColors(bgMatch[1]);
const parsed = parseGradientColorsModern(bgMatch[1]);
if (parsed.length > 0) stops = parsed;
}
}
if (stops) return compositeGradientStops(stops, current, win, customPropMap);
if (stops) {
const composited = compositeGradientStops(stops, current, win, customPropMap);
if (!composited || overlays.length === 0) return composited;
return composited.map(stop => {
let acc = stop;
for (let i = overlays.length - 1; i >= 0; i--) acc = compositeColorOver(overlays[i], acc);
return acc;
});
}
const bg = readCascadeBackgroundColor(current, style, customPropMap);
if (bg && bg.a > 0.1) {
// An opaque surface above the gradient means the gradient never shows
// through here; resolveBackground would have returned it, so reaching
// this is defensive — bail rather than measure the wrong layer.
if (bg.a >= 0.99) return null;
overlays.push(bg);
}
current = current.parentElement;
}
return null;
@@ -1986,6 +2109,10 @@ function checkElementColorsDOM(el) {
const rect = el.getBoundingClientRect();
if (rect.width < 10 || rect.height < 10) return [];
const style = getComputedStyle(el);
// Invisible at rest: hidden scene variants (opacity-0 carousels, swap
// decks) are not user-visible, and measuring their inherited colors against
// whatever surface happens to sit behind the stack is noise, not audit.
if (style.visibility === 'hidden' || effectiveOpacityDOM(el) <= 0.02) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
let effectiveBg = resolveBackground(el);
@@ -2798,11 +2925,13 @@ function checkElementGlowDOM(el) {
// If resolveBackground returns null (gradient), try to infer from the gradient colors
let parentBg = el.parentElement ? resolveBackground(el.parentElement) : resolveBackground(el);
if (!parentBg) {
// Gradient background — sample its colors to determine if it's dark
// Gradient background — sample its colors to determine if it's dark.
// Modern-syntax parsing matters here: body-level gradients now reach this
// fallback in browser mode, and their stops usually serialize as oklch.
let cur = el.parentElement;
while (cur && cur.nodeType === 1) {
const bgImage = getComputedStyle(cur).backgroundImage || '';
const gradColors = parseGradientColors(bgImage);
const gradColors = parseGradientColorsModern(bgImage);
if (gradColors.length > 0) {
// Average the gradient colors
const avg = { r: 0, g: 0, b: 0 };
@@ -3644,6 +3773,14 @@ function checkElementBorders(tag, style, overrides, resolvedRadius, el = null) {
}
function checkElementColors(el, style, tag, window, customPropMap, hasAnchorInheritRule) {
// Invisible at rest, static twin of the browser walk's skip: opacity does
// not inherit, so walk ancestors multiplying declared opacity down.
if (style.visibility === 'hidden') return [];
let effOpacity = 1;
for (let cur = el; cur && cur.nodeType === 1 && effOpacity > 0.02; cur = cur.parentElement) {
effOpacity *= parseFloat(window.getComputedStyle(cur).opacity || '1');
}
if (effOpacity <= 0.02) return [];
const directText = [...el.childNodes].filter(n => n.nodeType === 3).map(n => n.textContent).join('');
const hasDirectText = directText.trim().length > 0;
@@ -4802,6 +4939,11 @@ function isRenderedForBrowserRule(el) {
function checkElementTextOverflowDOM(el) {
const tag = el.tagName.toLowerCase();
if (TEXT_OVERFLOW_SKIP_TAGS.has(tag)) return [];
// scrollWidth/clientWidth are CSS box-model metrics; on SVG content Chrome
// returns arbitrary non-zero values for both (a <text> reported 78/48 while
// its rendered length sat comfortably inside its box), so the delta is
// noise, not overflow. SVG clips to its own viewport anyway.
if (el.namespaceURI === 'http://www.w3.org/2000/svg') 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.
@@ -5186,6 +5328,22 @@ function isPaintedForOcclusion(el) {
// path is pure geometry and runs anywhere on the page.
const OCCLUSION_TEXT_SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'title']);
// An element whose effective opacity multiplies out to ~0 paints nothing at
// rest: it is not user-visible, so visual findings on it (contrast, occlusion)
// measure a state nobody sees. Browser-only — the walk needs live computed
// styles. Cycling scenes that fade such elements in later are the screenshot
// subsystem's territory, not the analytic walk's.
function effectiveOpacityDOM(el) {
let o = 1;
// Walk all the way through body and html: `body { opacity: 0 }` page-fade
// wrappers hide every descendant just as thoroughly as a local wrapper.
for (let cur = el; cur && cur.nodeType === 1; cur = cur.parentElement) {
o *= parseFloat(getComputedStyle(cur).opacity || '1');
if (o <= 0.02) return 0;
}
return o;
}
function checkTextOcclusionDOM() {
const findings = [];
const seenVictims = new Set();
@@ -5213,6 +5371,11 @@ function checkTextOcclusionDOM() {
}
return false;
};
// The classic occluder shape this rules out is an opacity-0 interaction
// layer — a range scrubber stretched over a before/after comparison — which
// elementFromPoint still returns and whose UA background-color otherwise
// reads as an opaque box.
const effectiveOpacity = effectiveOpacityDOM;
// Collect renderable text owners in / near the first viewport for the
// elementFromPoint probe. SVG <text> counts too.
@@ -5225,6 +5388,7 @@ function checkTextOcclusionDOM() {
const text = inSvg ? (el.textContent || '').trim() : elementDirectText(el);
if (text.length < 2) continue;
if (!isPaintedForOcclusion(el)) continue;
if (effectiveOpacity(el) <= 0.02) continue;
let rect; try { rect = el.getBoundingClientRect(); } catch { continue; }
if (rect.width < 6 || rect.height < 6) continue;
// Viewport-bound probe: keep text whose box overlaps the live viewport.
@@ -5258,6 +5422,7 @@ function checkTextOcclusionDOM() {
if (top === el || el.contains(top) || top.contains(el)) continue;
const topCs = getComputedStyle(top);
if (isFloated(topCs) || isMarqueeish(top, topCs) || isPinnedOverlay(top)) continue;
if (effectiveOpacity(top) <= 0.02) continue;
const topTag = top.tagName.toLowerCase();
// Text sitting under a raw image/video is contrast territory (deduped
// against the pixel low-contrast rule); leave those alone here.
@@ -5468,6 +5633,7 @@ export {
CSS_NAMED_COLORS,
checkBorders,
isEmojiOnlyText,
scopedIgnoreActive,
checkColors,
checkHoverContrast,
checkElementHoverContrast,

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