Compare commits

...
101 Commits
Author SHA1 Message Date
9ffd3211d5 Neo Kinpaku design system + Live Mode v3 (#169)
* Add neo kinpaku design system page

* skill: rip out baked-in category recipes and saturated-default motion tropes

Programmatic bias mining (impeccable-evals) traced four major defects
back to specific lines in this skill that contradicted SKILL.md's own
first-order-reflex warning:

- brand.md "Pairing and voice" prescribed four category→aesthetic
  recipes (editorial → serif+sans, tech/dev/fintech → tight tracking,
  consumer/food/travel → script/display serif, creative → rule-break).
  These directly drove OpenAI's 76% extreme-negative letter-spacing
  on tech briefs and Anthropic/Google's 28-34% italic-serif-display
  slop on editorial/food briefs. Replaced with one sentence: the
  shape depends on the brand, not on the brand's category.
- brand.md "Brand permissions" had "Typographic risk. Enormous
  display type, unexpected italic cuts, mixed cases, hand-drawn
  headlines, a single oversize word as a hero." — a four-for-one
  slop driver behind 97% OpenAI comically-large H1, 42% bad-SVG
  illustration, and the editorial-italic slop. Deleted outright.
- typeset.md and teach.md repeated the same category recipes;
  trimmed to the principle without the recipe.
- SKILL.md Typography: added a hard hero-H1 ceiling (clamp() max
  ≤ 6rem ≈ 96px), with a <codex> block to make it explicit since
  OpenAI over-indexes here (97% ≥128px vs 24% for Anthropic).
- animate.md, bolder.md, brand.md: removed "staggered reveals" and
  "scroll-triggered transitions" as the prescribed default ambitious
  motion. By 2026 that's the saturated AI tell, not a choreography.
  Reserved stagger for legitimate list-sibling rhythm.

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

* skill: anti-cream + codex-specific defect bans + universal slop bans

Second pass after measuring more biases against the eval corpus.

- SKILL.md Color: explicit "cream/sand/beige body bg is the saturated
  AI default of 2026" rule. Tone down the "tint every neutral" line so
  it doesn't read as "default to warm-tinted near-white" (which OpenAI
  hits at 74% and Anthropic at 31%-47%).
- SKILL.md Absolute bans: add universal bans for two slop patterns
  detected at 55-95% across providers — tiny uppercase tracked eyebrow
  above every section (the 2023-era kicker that's now AI grammar) and
  numbered section markers (01/02/03). Also explicit "text that
  overflows its container is the universal defect on tablet/mobile."
- SKILL.md Absolute bans → <codex> block: ban the GPT-specific defects
  Paul annotated repeatedly — `border:1px solid` + soft-wide-shadow
  (≥16px blur) "ghost cards", `border-radius:32px+` over-rounding,
  hand-drawn/sketchy SVG illustrations (loose-sketch / *-sketch classes,
  feTurbulence paper-grain filters), repeating-linear-gradient stripes,
  "X theater" AI-slop copy phrases.
- SKILL.md Motion → <gemini> block: the image :hover transform tell
  (38% Google skill-on rate). Hover effects on images add no info; the
  image isn't an action target. Animate card chrome, not the image.
- SKILL.md Typography: hard display letter-spacing floor ≥-0.04em
  (OpenAI defaults to -0.075em → cramped). Existing hero ceiling
  <codex> block extended with the letter-spacing rule.
- codex.md Step A example: stop seeding "warm-grounded (deep oxblood +
  cream)" as the warm-palette template, which primes the cream default.
- colorize.md Tinted backgrounds: stop printing the literal cream
  recipe `oklch(97% 0.01 60)`; replace with brand-anchored guidance.
- document.md examples: warm-ash-cream → cool-paper so the example
  doesn't seed cream as the canonical neutral example.

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

* skill: universal anti-slop bans + contrast/font-count/all-caps-body rules

Third pass after measuring the rest of the cross-provider matrix:

- Color: explicit "Verify contrast" rule. Low-contrast text fires at
  68% across all providers skill-on (90+% off). The most common
  failure is muted gray body on a tinted near-white; light-gray-for-
  elegance is named as the single biggest cause of unreadable AI
  pages.
- Typography: max-3-font-families rule. Overused-fonts (>4 families)
  fires at 28% Anthropic / 36% Google / 0% OpenAI skill-on; >50% off.
  Also: universal "no all-caps body copy" (moved from brand-only ban
  to Shared design laws since product-register also overuses caps).
- Copy: anti-aphoristic-cadence ban targets Anthropic's signature
  "X. No Y." / "X. Just Y." voice (63% skill-on copy-slop rate, 77%
  off — the worst rate in the matrix). Once-is-voice / three-or-more-
  is-tell framing per the runner's copy-slop detector.
- Copy: anti-SaaS-buzzword-string ban with the literal phrase list
  the detector watches for (streamline/empower/supercharge, trusted-
  by-leading, best-in-class/enterprise-grade/cutting-edge, etc).

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

* skill: strengthen anti-cream rule across full warm-neutral band

Smoke validation showed the cream fix worked for Google + OpenAI but
Anthropic Sonnet italian-restaurant still shipped `--paper: oklch(90%
.018 88)` — cream just outside the L≥95% band the rule cited.

Broaden the rule:
- Band: OKLCH L 0.84-0.97, C < 0.06, hue 40-100 (was 95-97% / 60-95).
- Name the token-name tells explicitly (paper / cream / sand / bone /
  flour / linen / parchment / wheat / biscuit / ivory) — the model
  defaults to one of these regardless of what hex it lands on.
- Call out the specific brief patterns ("warm, traditional, family-
  coastal-Italian" / "editorial-restraint") that the model translates
  into cream by reflex. Then provide three explicit non-cream options:
  saturated brand color, true off-white at C=0, or darker mid-tone.

Warmth in the brand is carried by accent + typography + imagery, not
by body bg.

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

* v3.2.0: skill bias-fix release

Bumps version from 3.1.1 to mark the four-commit skill cleanup that
rips out baked-in category recipes (brand.md), saturated-default motion
tropes (staggered reveals everywhere), the cream/sand body-bg AI tell,
codex-specific defects (1px+wide-shadow, over-rounding, hand-drawn SVGs,
stripes, X-theater copy), the extreme-letter-spacing default, and
universal slop bans (all-caps eyebrow on every section, numbered-section
markers, all-caps body, font-family-count > 3, aphoristic copy cadence,
SaaS buzzword strings). Plus a hard hero-H1 ceiling (clamp() ≤6rem) and
a Gemini-specific image:hover transform block.

Validated against ~190 post-fix samples — see impeccable-evals
biases tab for per-provider deltas.

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

* drop "no pure black/white" rule entirely

The rule was contested in the design world and causing more damage than
good — pushing every page into the tinted-near-white default which is
the cream/sand AI tell we already explicitly ban elsewhere. Vercel,
SVKMS, Brutalist sites, et al. use pure black/white successfully; the
skill shouldn't second-guess that.

Skill markdown deletions:
- SKILL.md Color: drop the "Never use #000 or #fff" bullet.
- color-and-contrast.md: drop the "Never Use Pure Gray or Pure Black"
  subsection, the "Never pure black" table-row prescription, and the
  "Avoid: Using pure black for large areas" bullet.
- colorize.md: drop the "NEVER use pure black or pure white for large
  areas" bullet.
- polish.md: drop the "Tinted neutrals: No pure gray or pure black"
  half of the bullet (the gray-on-color bullet survives).

Detector code (cli/engine):
- registry/antipatterns.mjs: remove the `pure-black-white` entry.
- rules/checks.mjs: remove the three `findings.push({ id:
  'pure-black-white', ... })` emit points (inline #000 bg, Tailwind
  bg-black class, plain-HTML scan path).
- engines/regex/detect-text.mjs: remove the two pure-black-white regex
  rules (CSS `background: #000…` + Tailwind `bg-black`).
- detect-antipatterns-browser.js: regenerated via
  scripts/build-browser-detector.js.

Tests:
- detect-antipatterns-fixtures.test.mjs: invert the assertion that
  pure-black-white fires; expect it to NOT fire post-v3.2. Drop the
  Tailwind bg-black-opacity edge-case test (no longer relevant).
- detect-antipatterns.test.js: drop the standalone "detects pure-
  black-white in styled-components" test and remove pure-black-white
  from the multi-detector assertions in PricingCard, globals.css, and
  GlobalStyle.tsx tests.

166 bun tests pass; 24 node fixture tests pass.

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

* skill: strip example patterns from copy rules, strengthen gemini block

v3.2 rerun validation surfaced two issues:

1. Copy-slop detector fires more on Gemini under v3.2 (48% → 84%) than
   under no-skill baseline. Root cause: the anti-aphoristic-cadence rule
   printed the literal "X. No Y." / "X. Just Y." patterns as examples,
   and Gemini imitated them as the recommended voice. Same recipe-becomes-
   bias trap we hit with brand.md:116's "Enormous display type, unexpected
   italic cuts, mixed cases, hand-drawn headlines" enumeration. Fix:
   describe the cadence as a rhythm ("serious statement, then punchy
   short negation") without printing literal patterns. Buzzword list
   trimmed to a single inline phrase family rather than quoted strings.

2. Gemini image:hover transform Gemini-tell hadn't dropped (31% off →
   32% v3.2). Strengthen the <gemini> block: explicit "Never animate
   <img> elements on hover", call out the Tailwind group-hover:scale /
   group-hover:rotate / group-hover:translate parent-hover patterns by
   name (Gemini was reaching for these via Tailwind even though the
   prior text talked about :hover on the image directly).

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

* skill: simplify context loading and inline register directive

Replaces load-context.mjs's JSON output with a tight markdown block from
the renamed context.mjs. The script now extracts PRODUCT.md's `## Register`
field and appends a `NEXT STEP:` directive naming the matching reference
(brand.md / product.md), which moved Gemini from skipping the register
load entirely to honoring it. Drops the `.impeccable.md` auto-migration;
makes IMPECCABLE_CONTEXT_DIR a lazy escape hatch consulted only when the
default paths come up empty.

Setup is now four bullets in one list. The DESIGN.md nudge is gone; in
its place, a "familiarize with the existing design system" step that
calls out CSS / tokens / running app as authoritative sources alongside
DESIGN.md. The standalone `### Register` H3 stays for the cascade rules
(task cue → surface → register field).

New LLM-backed test suite at tests/skill-behavior/ runs five scenarios
against claude-haiku-4-5, gpt-5.4-mini, and gemini-3.1-flash-lite via
Vercel AI SDK. Captures real tool traces, asserts on context.mjs calls,
brand.md loads, and teach.md fallback. Skips cleanly when API keys are
unset. 13-14/15 pass; only stable failure is the v3.2.0-era gpt-mini S4
"don't re-run" regression. Adds @ai-sdk/google as devDep and the
test:skill-behavior npm script.

Touches em-dashes in skill/SKILL.md and four reference files so
`bun run build:skills` passes its skill-prose validator. teach.md and
document.md drop their "re-run the loader to refresh session cache"
steps since the agent's own write is now the freshest source.

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

* skill: merge orphan reference files into command sub-skills + inline S-tier invariants

Two related restructurings:

1. SKILL.md now carries the cross-domain invariants that catch defects in any
   project (contrast/placeholder/gray-on-color, similar-font pairing, text-wrap,
   tabular-nums, centered-stack default, Flex/Grid choice, auto-fit grids,
   semantic z-index, reduced motion, stagger vs section-fade, premium motion
   materials, focus-visible, placeholders-aren't-labels, dropdown overflow trap,
   button/link copy). Greenfield-only rules (theme picking, color strategy,
   tinted neutrals) live under "New projects only".

2. Reference files merged into their command counterparts:
   - spatial-design.md  -> layout.md
   - motion-design.md   -> animate.md
   - color-and-contrast.md -> colorize.md
   - responsive-design.md  -> adapt.md
   - ux-writing.md         -> clarify.md
   - typography.md         -> typeset.md (bolder.md redirected)
   - cognitive-load.md + heuristics-scoring.md + personas.md -> critique.md

   craft.md and shape.md "load references" lists updated to new file homes.
   interaction-design.md stays standalone (no 1:1 command verb).

Net: 36 -> 27 reference files. Same content, fewer files, no orphaned
reference loaded only from craft.md.

Also extends the routing rules: if the user's first word doesn't match a
command but the intent clearly maps to one, load that command's reference
and proceed as if invoked.

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

* skill: add sub-command + existing-project scenarios; move sub-command load to step 2

Adds three new LLM-backed scenarios to tests/skill-behavior:
- S6: `/impeccable polish` → loads polish.md
- S7: `/impeccable audit` → loads audit.md
- S8: existing SvelteKit project (PRODUCT.md + DESIGN.md + src/app.css +
  src/lib/components/*.svelte + src/routes/+page.svelte) → agent reads
  at least one project code file to understand the existing design system

S6/S7 surface a real model-floor: gpt-5.4-mini reads brand.md, reads the
target index.html, and just does the polish/audit without ever loading
the sub-command reference. Stronger SKILL.md wording didn't move it.
Captured in the README baseline as a known weakness. Claude and Gemini
honor the load reliably.

To fix Gemini on S6/S7, sub-command reference loading is now Setup step 2
(right after context.mjs), not step 4 — placing it before the model gets
focused on "doing the work". Step 3 (design-system familiarization) is
tightened to require at least one project code read even when a
sub-command reference loads in step 2, so Claude doesn't laser-focus on
the sub-command flow and skip the broader exploration.

Two new fixtures: MINIMAL_LANDING_HTML (a tiny static landing page for
S6/S7) and SVELTE_PROJECT_FILES (a minimal SvelteKit scaffold with
tokens, components, and a routes/+page.svelte for S8). Both designed to
look real enough that agents treat them as production code.

Suite is now 24 tests across three providers; baseline is 21-22/24, with
the stable failures being gpt-5.4-mini scenarios 6 and 7.

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

* skill: add reveal-animation safety rule (must enhance, not gate visibility)

Class-triggered visibility transitions pause on hidden tabs and headless
renderers. The italian-restaurant smoke produced a build where 2 sections
shipped opacity:0 because the CSS transition never advanced past
currentTime=0 (timeline paused). Added one-liner under Motion to prevent
the antipattern: reveals must enhance an already-visible default, never
gate content visibility on a class-triggered transition.

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

* skill: restore prescriptive cream/sand/beige paragraph

Bisection across 5 historical skill commits on Gemini 3.5 flash fast
lane n=3 found that 0cf2debd was the peak quality state. The regression
between 0cf2debd and HEAD came from simplifying the long anti-cream
paragraph into a one-liner.

Restoring the paragraph (with em-dashes replaced by parens to satisfy
prose lint) recovers ~0.22pt average on Gemini vs HEAD, with the
largest gains on:
- 09-luxury-hotel: +0.50 (restores editorial drama in photo-led briefs)
- 10-food-magazine: +0.67
- 03-italian-restaurant: +0.51

The paragraph's load-bearing parts are the (a)(b)(c) alternatives that
give the model actionable replacements for cream-tinted body bg
("saturated brand color as body", "true off-white at chroma 0",
"darker mid-tone tinted neutral"). Without them, the one-line warning
left the model with no concrete alternative.

Cross-provider validation showed the pattern matches historical
behavior: Gemini benefits from prescriptive scaffold (+0.12 over off),
Sonnet is roughly neutral (+0.01), GPT-5.5 slightly regresses (-0.11
matching the v3.1.0 pattern of -0.11). The skill has never been
uniformly better than skill-off across providers; this is the closest
achievable state without provider-specific rework.

The structural improvements from the prior restructure stay (file
merges, S-tier inlines, routing rule extension, reveal-animation
safety rule).

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

* docs: teach CLAUDE.md / AGENTS.md / DEVELOP.md about the skill-behavior tests

Adds the `bun run test:skill-behavior` script to the test commands lists
in all three docs. CLAUDE.md gets a full `### Skill-behavior tests`
subsection paralleling the existing Live-mode E2E one: how the suite
works (inlines source SKILL.md, scoped tools, asserts on the trace),
which providers it always runs (claude-haiku-4-5, gpt-5.4-mini,
gemini-3.1-flash-lite — all three every run), the eight scenarios, the
baseline (21-22/24 with stable gpt-mini sub-command-routing failures),
auth via repo-root `.env`, and how to add a scenario.

AGENTS.md gets the one-liner plus a paragraph in Testing Guidelines that
points contributors at the suite for Setup-touching edits (SKILL.md
Setup section, context.mjs, teach.md, document.md, register / sub-command
refs).

DEVELOP.md gets a short Testing section that didn't exist before, plus a
nudge in the "Test across providers" bullet pointing at the new suite as
the automated way to do that.

No code changes.

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

* detector: add 5 new antipatterns (em-dash-overuse, broken-image, marketing-buzzword, numbered-section-markers, aphoristic-cadence)

Consolidates eval-side detection logic into the canonical impeccable
detector. Before this change, the eval harness had its own duplicate
implementations of em-dash, copy-slop, and broken-image checks. They
now live alongside the existing 28 antipatterns in the impeccable
registry, available to the CLI, browser extension, critique skill,
and eval (via the existing slop grader child-process call).

New antipatterns:
- em-dash-overuse: 5+ em-dashes in body text content (threshold
  permits legitimate prose use of em-dash; only triggers on AI
  cadence-level density)
- broken-image: <img> with empty src, missing src, or src="#"
- marketing-buzzword: SaaS phrase list (streamline / empower /
  supercharge / enterprise-grade / cutting-edge / etc)
- numbered-section-markers: repeated 01 / 02 / 03 sequence as
  section labels — the AI editorial scaffold one tier deeper than
  tracked eyebrow chips
- aphoristic-cadence: 3+ manufactured-contrast ("Not a X. A Y.")
  or short-rebuttal ("Sentence. No clause." / "Sentence. Just
  clause.") constructions in body text

Engine wiring:
- broken-image runs as a static-html element rule (selector: img)
  and a fallback regex matcher (for non-HTML files)
- em-dash / buzzword / numbered / aphoristic run as regex
  page-analyzers, factored into a new runTextContentAnalyzers()
  helper that both detectText (non-HTML) and detectHtml (HTML)
  call, so .html files get the same coverage as .css/.tsx

Tests: 166 detector + 12 browser + 24 fixture all pass.
Browser detector rebuilt (162.7 KB).

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

* skill: drop unvalidated anti-centering rule; add image-led hero carve-out

The anti-centering rule ("Don't default to centering everything") was
added without empirical support. We have a detector for it
(everything-centered, threshold ≥70%) that fires on 0 / 998 samples
in the corpus — never validated, never useful.

Meanwhile the rule was almost certainly responsible for collapsing
Gemini 3.5 flash's luxury-hotel skill-on output from the canonical
"full-bleed photo + centered overlay headline" cinematic hero (the
shape skill-off Gemini chooses 67% of the time) to a 50/50
magazine grid (full-bleed rate drops to 18% under skill-on, -49pp).

Changes:
- skill/SKILL.md #### Layout: drop "Don't default to centering..."
- skill/reference/brand.md ## Layout: drop the same rule; replace
  with a positive carve-out — image-led briefs (hotels, restaurants,
  magazines, photography) often want full-bleed hero with overlaid
  menu and centered headline; let the photograph be the design
- skill/reference/layout.md: drop the assessment question and the
  "asymmetric breaks centered-content pattern" framing

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

* Apply neo-kinpaku design system and improve live picker UX

Restyle the live picker to match the site kinpaku kit, persist pick mode
in localStorage, fix DESIGN.md color swatches in the parser, and land the
neo-kinpaku site refresh with new tokens, assets, palette script, and
detector rules.

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

* Add live Steer end-to-end: poll protocol, browser UI, and E2E harness.

Wire page-level Steer through the live server and agent poll loop with steer_done
unlock semantics, extend live.md for agents, and add smoke tests with LLM
handleSteer plus recovery for hidden heroes, HMR lag, and dev-tool overlays.

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

* Add experimental live-poll --stream mode; keep one-shot default for Cursor.

Stream keeps one process alive with ack-aware resume, but live.md documents
that Cursor should stay on one-shot background notify after testing showed
~5s pickup vs sub-second on exit-based notify.

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

* Sync harness output and fix build validators for poll stream release.

Regenerate provider skills after live-poll --stream work, update homepage
detection counts to 41, and replace em dashes in site/skill copy so
bun run build passes prose and count checks.

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

* homepage: add testimonials marquee section

A two-row testimonial marquee on a tinted graphite plinth, sitting
between the hero and the slop teaser.

29 testimonials sourced via api.fxtwitter.com (lightly cleaned: leading
@-mention reply targets stripped, trailing self-links removed). Avatars
downloaded into site/public/assets/testimonials/ so they're served
locally. Quote order curated for impact — both rows lead with the
punchiest quotes (Ben Davis spotlight, "Impeccable > Claude design",
"THIS. This shit works.", "Uninstall whatever frontend skill you're
using.") so the first viewport is loaded with the most memorable
testimonials.

Engineering notes:
- Section uses width:100vw + margin-left:calc(50% - 50vw) to escape
  main.site-content's max-width + side padding (cards now clip cleanly
  at the actual viewport edges).
- Marquee runs at 110s linear infinite. Both rows share the same
  duration so on-screen speeds match; track is doubled so the loop
  back to 0 reads as continuous.
- Hero min-height reduced from 100svh to calc(100svh - 115px) so the
  dotted divider and top of row A peek above the fold on landing,
  signalling the section is there.

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

* homepage: keep the hero demo clear of the fixed header on short viewports

The hero centers its content in the full viewport (the site header is a fixed
overlay), so on shorter screens the tall Live Mode demo tucked under the nav.
Raise the hero's top padding above the 97px header (113px wide, 108/92px when
stacked) so content always pins below the header while still centering on tall
viewports, and cap the demo frame to the viewport so the whole demo stays on
screen.

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

* Add steer voice input and refine processing animation.

Wire Web Speech API on the Steer mic with auto-submit, block Cursor's preview browser with a clear message, and replace truncated "Working" text with a dots-only processing state.

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

* Add agent poll connectivity indicator and tighten global bar spacing.

Surface poller state on the Impeccable mark via SSE and /status, with an instant disconnected tooltip, steer timeout failsafe, and matched brand/chat section gaps.

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

* Fix steer focus to allow page text selection without losing type-to-steer.

Blur the hidden steer input on page interaction, pause refocus during selection gestures, and reschedule focus recovery after clicks and cleared selections.

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

* site: rework "Design in production" section glyphs and audience band

Put the three how-it-works steps back into thin-line cards and drop the
overused browser-chrome bars from each glyph. Redraw the step 2 and 3
visuals to mirror the real Live Mode UI: step 2 shows the on-canvas pick
outline with an attached comment bubble, step 3 shows the floating
contextual accept bar plus the source-write confirmation. Re-treat the
audience tiles as verdigris-lined text (no card box) under a "Who it's
for" eyebrow, so each role reads as distinct from the gold step band.

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

* Add live insert mode with HMR-safe placeholder recovery.

Ships insert picking, scaffold helpers, variant cycling fixes for hidden
variants, and placeholder snapshot/recreation so Astro HMR does not drop
the wait-state box or re-anchor to the hero container.

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

* site: mobile pass — hamburger nav + designing hero overflow fix

The header was rendering inline nav links + GitHub button that overflowed
narrow viewports (~363px). Pre-existing display:none hacks hid Designing
and Live to make the row fit, but those items still belonged in the menu.

Header.astro: added a hamburger toggle button + inline script. The right
cluster (nav + GitHub) becomes a collapsible drawer below the header on
mobile, with data-nav-open driving the open/closed state and animating
the two-line glyph into an X.

kinpaku-kit.css: hamburger button (kinpaku-bordered glyph), mobile drawer
panel (solid lacquer-deep bg, hairline separators between rows, full-width
tappable rows), and overrides for the older sub-pages.css mobile rules
(horizontal-scroll mask on the nav, hidden [data-nav="home"] item, hidden
GitHub star label) — all redundant now that the drawer surfaces everything.

home-kinpaku.css: dropped the @media (max-width: 560px) block that hid
Designing / Live / GitHub. The drawer pattern shows them all.

designing-kinpaku.css: hero h1 "Designing with Impeccable" was overflowing
at narrow viewports. Three fixes:
  - grid-template-columns 1fr → minmax(0, 1fr) so the column shrinks to
    fit container instead of growing to "Impeccable"'s 472px intrinsic
    min-content width.
  - mobile h1 size override (clamp(2.2rem, 11vw, 3rem) at <=480px) since
    the display token's 3.4rem minimum is sized for desktop hero impact.
  - hide the decorative loop-wheel SVG below 600px (was overflowing 22px
    past the right edge).

Verified clean at both 363px and 403px viewports across /, /docs,
/docs/animate, /slop, /designing, /live-mode. scrollWidth matches viewport
width on every page (no horizontal scroll).

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

* detector: refine new rules + run provider tells in browser env

Follow-up to the detector port (rules landed in 7648af00):
- oversized-h1: flag long headlines set at display size, not punchy
  one/two-word heroes (length, not size alone, is the tell)
- provider tells (--gpt/--gemini) now always run in a real browser env
  (detector page, live overlay, extension); gating is a CLI-output
  concern only, applied in the Node engine return paths
- move theater-slop-phrase into checkHtmlPatterns so it runs in the
  bundled browser path, not just CLI/static (browser bundle excludes
  detect-text.mjs)
- hero-eyebrow-chip overlay highlights the eyebrow, not the heading
- gemini-tells fixture: data-URI images so the hover-zoom renders
- rebuild browser bundle

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

* site: migrate /detector lab to neo-kinpaku design system

Rebuild the detector lab tool shell on --ks-* tokens (lacquer ground,
gold hairlines, champagne/mono type) instead of the legacy warm-paper
palette. Swap the "/" placeholder for the real carved-tile brand lockup,
restyle the toolbar actions as kinpaku primary/secondary buttons, and
recolor the finding overlay from off-brand magenta to vermilion.

Update the global theme-color from #fafafa to #010101 (the sRGB render
of the lacquer ground) so the browser chrome matches the dark site.

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

* Homepage: hero finalist, compact live demo, real picker bar.

Switch the hero to m-01-v2-01, tighten the in-hero demo layout, and replace
the marketing gbar with a shared LiveDemoGbar that mirrors live-browser.js.
Size the bar with max-content so controls are not clipped inside the capsule.

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

* site: migrate /cases/neo-mirai to neo-kinpaku design system

Rebuild the Neo Mirai case-study page on --ks-* tokens: lacquer ground
(drops the off-brand magenta radial spotlight), Alumni Sans Pinstripe
display headings instead of the banned italic serif, gold eyebrow/labels,
gold hairline image frames, kinpaku primary/secondary buttons, and a
lacquer-deep command panel with a gold-bordered code block.

Opt .neon-case-page into the shared kinpaku site-header/footer chrome in
kinpaku-kit.css (per the "add new kinpaku pages to the selector list"
note) so the global header and footer go dark to match the page.

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

* site: consolidate kinpaku header+footer into one reusable .kinpaku-chrome class

The dark header/footer were not a reusable unit: the header was scoped to
a per-page selector list, the github star pill was home-only, and the
default footer was copy-pasted into four page stylesheets. Pages not on
the lists (like /cases/neo-mirai) fell back to the legacy light chrome.

Collapse all of it into one `.kinpaku-chrome` block in kinpaku-kit.css —
header, github pill, and default footer — and opt every kinpaku page in
via a single body class. Delete the four duplicated per-page footer
blocks and the home-only github pill. The home page keeps its textured
verdigris footer as a deliberate override, raised to body.home-kinpaku
specificity so it wins regardless of import order. Genuinely light pages
(privacy, tutorials) just omit the class.

Fixes on /cases/neo-mirai: footer and github star now render dark/kinpaku
(were legacy-light), and the content sections are wrapped in the .neon-case
container so they sit in header-aligned gutters instead of bleeding to the
viewport edge.

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

* site: migrate privacy + tutorials to kinpaku via a reusable surface class

These were the last two light pages. Rather than rewrite their per-rule
styling, add a reusable .kinpaku-surface class that remaps the legacy
--color-* / --font-* tokens to kinpaku values at the body scope, so the
existing legacy-token CSS (sub-pages.css prose, the pages' inline styles)
renders dark for free. Same trick docs-kinpaku/slop-kinpaku use per page,
lifted into one shared class. Pair it with .kinpaku-chrome for header +
footer.

privacy + both tutorials pages now carry both classes. Also force the
sub-1.2rem headings (tutorial card titles, prose h1/h2) back to the
upright body face: the legacy display face was italic serif, and the
kinpaku Pinstripe face reads wrong synthesized-italic at small sizes.

No light pages remain.

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

* site: re-add Tutorials to the /docs sidebar

Tutorials lost its docs placement across two refactors: the Astro docs
rebuild never carried over the sidebar tutorials list the old generated
pages had, and the kinpaku homepage redesign dropped the "Full
walkthrough" link. It survived only via /designing and /live-mode.

Add a "Tutorials" group at the top of the docs sidebar (matching the
command-category styling) linking the index plus all four tutorials,
restoring the old information architecture.

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

* site: make kinpaku the default — flip legacy :root tokens to dark (phase 1)

Repoint the legacy design tokens in tokens.css from light-mode to kinpaku:
--font-* now reference the --ks-* brand faces (retiring Cormorant/Instrument/
Space Grotesk), surfaces carry dark-lacquer oklch, and --color-accent is gold
instead of magenta. Values mirror the per-page kinpaku remaps.

Every live page already overrides these at its body-class scope, so this
changes the fallback (any classless/new page now renders kinpaku) without
altering existing pages — verified home, designing, slop, live-mode, docs
unchanged, and the deliberate-light demos (slop specimens, home's Aurelia
mock) still render light via their own colors.

First step toward removing the per-page remaps; those become redundant next.

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

* detector + slop: cream-palette rule, drop everything-centered, polish catalog

- new deterministic cream-palette rule ("claude beige"): flags warm
  lightly-tinted off-white page backgrounds; wired into static + browser
  engines, with fixture + test
- remove everything-centered rule entirely (no longer in the skill) from
  registry, regex analyzer (+ index-offset fix), checkPageLayout, and tests
- catch Instrument Serif in overused-font (regex + OVERUSED_FONTS)
- /slop: reconcile catalog (cream card in, everything-centered out; counts),
  and fix demo visuals — visible hairline border, gigantic clipped hero,
  more extreme crushed tracking, padded gray-on-color card, uniform-rhythm
  monotonous-spacing, long line-length line, elastic-overshoot dialog for
  bounce easing, real zooming image for image-hover; flip the demo surface
  off warm beige to a cool neutral

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

* detector page: add cream-palette fixture to the catalog

Surfaces the new cream/beige palette rule on /detector alongside the
other Color specimens.

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

* site: shared docs sidebar + tutorial pages join the layout

Extract the /docs section sidebar into a reusable DocsSidebar component
and wire it into all three entry points so the navigation is consistent
across docs index, command pages, and tutorial pages.

site/components/DocsSidebar.astro (new): one source of truth. Loads the
tutorials + skills collections, renders Tutorials → Commands grouped by
category, and highlights the active entry via activeCommand / activeTutorial
props.

site/pages/docs/index.astro: swap the inline sidebar markup for the
component. Drop the "All tutorials" link — the dedicated tutorials
listing page wasn't earning its slot in the rail.

site/layouts/Doc.astro: same swap. Command pages now also see the
Tutorials section above Commands, matching /docs.

site/pages/tutorials/[...slug].astro: rewrite from a standalone page
(custom .tutorial-page wrapper, ad-hoc breadcrumb) to the full
skills-layout shell with DocsSidebar in the left rail. Tutorial content
now reads in the same layout as command reference pages.

site/content/tutorials/brand-vs-product.md (deleted): the skill picks
the register automatically from PRODUCT.md, so a tutorial telling users
to pick it themselves was misleading.

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

* detector: catch Tailwind warm-light bg utilities in cream-palette

The static engine can't resolve Tailwind classes to computed CSS, so a
`bg-amber-50` on <body> slipped past the cream-palette rule. Add a
class-list fallback that scans body/html for arbitrary `bg-[...]` values
and named warm-light utilities (amber/orange/yellow/stone), each run
through the same isCreamColor test so neutrals and over-saturated shades
drop out. Fixture + test for the class-only case.

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

* site: drop redundant per-page token remaps (phase 2)

With kinpaku now the :root default, the --color-* / --font-* remap blocks
in docs/slop/designing/live-mode-kinpaku.css re-declared values identical
to :root. Removed them, keeping only the --ks-muted alias (still read by
name in those files) and each page's shell (gradient bg, color, min-height).

home-kinpaku.css keeps its remap: it uses home-specific values (e.g.
--color-charcoal: var(--ks-text), --color-cream: var(--ks-lacquer-raised))
plus the --cat-* gradient overrides, so it is not redundant.

Verified designing (PRODUCT.md viz), slop (specimens stay light), docs,
live-mode unchanged.

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

* site: drop italic from 15 dead editorial-serif heading rules

Audited every font-style: italic in sub-pages.css and main.css against
the live markup. Removed italic from the 15 rules whose selectors don't
appear in any page/component/content/script:

  sub-pages.css: docs-home-card-title, docs-category-title,
    tutorial-embed-caption, skill-demo-caption, skill-source-card-subtitle,
    skill-references-heading, skill-reference-title
  main.css: hero-title-combined, hero-tagline-combined, impeccable-title,
    loading-state, install-primary-howto .install-path-desc em,
    install-howto-steps > li::before, install-step-status, consulting-title

These were dormant remnants of the retired Cormorant italic-serif look —
the kinpaku Pinstripe face renders them as bad synthesized-italic, but
no markup matches the selectors so nothing rendered. Removed only the
font-style declaration; the rest of each rule stays (whole-rule cleanup
is out of scope).

Kept the 5 live selectors (slop-section-heading, tutorial-card-title,
visual-mode-demo-caption, visual-mode-method-name, gallery-card-title)
per the "if they're not used anywhere" condition, plus .prose em (real
emphasis) and .prose blockquote (conventional blockquote italic).

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

* skill: brand-seed palette.mjs + Setup step to run it

New-brand color now starts from a curated seed color (129 OKLCH seeds)
instead of the model guessing or defaulting to warm-cream. The script
returns one seed + composition guidance (pure-bg architecture, perceptual
text-on-fill, anti-cliché moods, jewel-tone range), with inverse-frequency
hue weighting for fair rainbow exposure and deterministic --from picking.
SKILL.md Setup step 5 makes it run for greenfield projects. Curation
tooling lives in the impeccable-evals repo (tools/palette/).

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

* Remove accidental live mode inject from Base.astro.

The localhost live.js tag was left in the site layout after a dev session and should never ship in the Astro template.

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

* site: dedicated /changelog + /faq, epic v3.5.0 notes, Live Mode → Beta

Split changelog and FAQ out of the homepage into two standalone kinpaku
pages, linked from the footer (and a quiet hint under the Get-started CTA).

/changelog: every release inline (no collapsible), newest first. The
v3.5.0 entry leads with a one-line summary, a real before/after pair from
the GPT-5.5 eval corpus (luxury-hotel brief, skill off vs on), and a stat
row (74% cream-bg, 76% extreme tracking, 90%+ low-contrast — measured
across ~190 samples). Then five scannable bold-led bullets, biggest
takeaway first: per-provider skill compilation, the bias-fix, Live Mode,
the 7 new detector rules, the tighter skill. Before/after JPGs optimized
to ~470KB total (down from ~2.5MB PNGs).

/faq: the six support questions, each deep-linkable.

Live Mode is now Beta everywhere it surfaces: the /live-mode eyebrow
badge and note, the homepage bento tile badge, and the changelog entry.
The historical v3.0 changelog entry stays "Alpha" — accurate to what
shipped then.

Footer trimmed to the four links not already in the top nav (Changelog,
FAQ, Privacy, GitHub).

Version bumped 3.2.0 → 3.5.0 across the three plugin manifests; the
3.2 bias-fix work folds into this release rather than shipping separately.

astro.config.mjs: disable the dev toolbar.

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

* site: point /design-system hero at the m-01-v2-01 finalist

design-system.css referenced kintsugi-hero-v2.png, an untracked orphan
that was never committed. Repoint it at the committed m-01-v2-01 finalist
so /design-system and the homepage hero share one image, and the page
no longer depends on a file outside the repo. The v2 orphan moved to tmp/.

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

* build: sync harness mirrors + green the prose gate

Rebuild propagates the committed skill source (palette.mjs Setup step,
detector rule updates, brand.md) into the 13 harness output dirs and the
plugin subtree, which had drifted from source.

Also fixes the prose validator, which had been red on six pre-existing
hits across committed files:
- Four em dashes in code comments (Testimonials.astro, LiveDemoGbar.astro,
  index.astro) and one in skill/reference/live.md — reworded to colons/commas.
- Two in the slop catalog (an em-dash-overuse specimen and the
  marketing-buzzword rule naming "empower"). Those are intentional: the
  slop page documents every antipattern by example, so it must contain
  them. Exempted site/pages/slop from validateProse rather than neutering
  the specimens.

`bun run build` is now green end to end: counts validate, prose passes,
site builds.

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

* skill: rewrite no-section-fade rule to fix Gemini zero-motion overcorrection

The old rule ("whole-section fade-on-scroll is the saturated AI motion
reflex") drove Gemini to overcorrect into shipping pages with no motion
at all: motion-variety 39% / zero-motion 12% with the skill on, vs
~74-78% variety and ~3% zero-motion without it.

Rewrite keeps the legitimate-stagger carve-out, names the defect at
shape level (one identical entrance on every section) without
enumerating motion primitives, and adds an explicit clause that
suppressing the reflex is never grounds for a static page.

Validated on Gemini 3.5-flash (n=10, luxury-hotel + infra-platform):
motion-variety 39% -> 70%, zero-motion 12% -> 0%, staggered-reveal
stays 0% (reflex not re-inflated).

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

* release: bump CLI to 2.2.0 and extension to 1.1.0

Both ship the expanded detector: the 7 new rules (cream-palette,
em-dash-overuse, marketing-buzzword, numbered-section-markers,
aphoristic-cadence, broken-image, italic-serif-display) plus
hero-eyebrow-chip, with everything-centered removed. 41 rules total.

The extension settings page already supports toggling them: the rule
list renders from detector/antipatterns.json, grouped by category, and
disabledRules flows through chrome.storage.sync into the scan config,
which detect.js honors by rule id. New rules are toggleable with no UI
change.

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

* release: fix release.mjs for the moved changelog + add CLI/ext entries

The changelog moved from site/pages/index.astro to its own
site/pages/changelog.astro with new markup (cf-version / cf-entry /
cf-items), which left release.mjs reading the wrong file with the old
selectors. All three release commands would have failed at note
extraction. Point it at changelog.astro, match cf-version, and scope
notes to the <ul class="cf-items"> bullet list — that also skips the
lead paragraph, before/after figure, and stat row on the v3.5.0 entry,
keeping release notes to clean bullets.

Add CLI v2.2.0 and Extension v1.1.0 changelog entries (the shared
detector update: 7 new rules, everything-centered removed, 41 total;
plus the extension's per-rule toggles) so release:cli and release:ext
have notes to extract.

Verified extraction for all three labels: v3.5.0 (5 bullets),
CLI v2.2.0 (3), Extension v1.1.0 (2).

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

* fix: correct dev server port to 4321 and drop stale pnpm-lock

Astro serves on 4321, not 3000 as the docs claimed; update CLAUDE.md,
AGENTS.md, and screenshot-antipatterns.js. Remove the leftover
pnpm-lock.yaml from the Astro migration so Cloudflare's frozen install
uses the maintained, in-sync bun.lock instead of a drifted pnpm lockfile.

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

* site: rework /designing flow, rhythm, Live Mode mock, and CTA

Restructure the page so iteration reads as the core value, not net-new.
The four loop phases are wrapped in a track with a sticky scroll-spy nav
(Start/Iterate/Polish/Maintain) that pins under the header and highlights
the active phase; the surfaces section (skill/CLI/extension) moves out of
the loop into the post-loop context group so the loop runs uninterrupted.

Fix the iterate split: shared subgrid row tracks so the terminal and the
Live Mode mock align on the same baseline regardless of paragraph length,
wider intro measure (52ch, was a crammed 36ch), and a deeper picker stage
so the context and global bars breathe instead of stacking on the card.

Rebuild the Live Mode mock to mirror the real picker: carved-tile mark plus
Pick / Insert / Detect / DESIGN.md controls on lacquer-deep with the gold
border, and a /impeccable live entry line so the reader knows how to start.

Reframe Start as the hard mode, move h3 subheads off the thin display face
onto Albert Sans, and trim Start so it no longer dominates the loop.

Rework the closing CTA into two standalone raised cards (the bento plinth
made them read as boxes nested in a box), and fix the tutorials copy: there
are three walkthroughs now, and the brand-vs-product tutorial is gone, so
drop it from the CTA and remove the dead lane link to it.

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

* site: reorder Get Started so usage follows setup, link out to more

Move the /impeccable usage examples below the Chrome extension, CLI, and
Stay-updated block. Running a command is the logical next step once the
skill, extension, CLI, and subscriptions are all in place, so the section
now reads install -> set up the extras -> use it. Add a closing "Go deeper"
line linking to the Designing with Impeccable workflow page and the docs.

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

* fix: install compiled per-provider skill variants, not uncompiled source

`npx skills add` (and `impeccable skills install`, which wrapped it) installed
the uncompiled skill/ source verbatim: the skills CLI dedupes discovery by name
and picks skill/SKILL.md first, so installs shipped unresolved {{placeholders}}
and no vendored detector (#168).

- Rename skill/SKILL.md -> skill/SKILL.src.md so the skills CLI's discovery
  skips the source and falls through to a compiled .agents variant; update the
  build reader, skill-behavior harness, and docs to match.
- Refactor `impeccable skills install` to copy each harness's compiled variant
  from the universal bundle (real dirs, no npx skills, no symlink), with
  project/global harness detection and a --providers override.
- Fix stale unit tests (replacePlaceholders, readPatterns, transformer
  prefix/summary) that asserted removed pre-v3.0 behavior, and wire the three
  orphaned test files into `bun run test` so the drift can't recur.
- Split skills-cli.test.js: pure blocks run by default, network blocks move
  behind a new `bun run test:cli-e2e`; fix its stale update assertions.

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

* site: default to `npx impeccable skills install`, restore install-method panel

Get Started recommended `npx skills add`, which installs a single shared build
across harnesses. Make our CLI the default (it installs the build compiled for
each harness) and bring back the "Other install methods" disclosure the
neo-kinpaku redesign dropped.

- Homepage: primary command is now `npx impeccable skills install`; a native
  <details> panel offers the Claude Code plugin and `npx skills` (caveated as
  installing one shared build rather than the per-harness one).
- FAQ: recommend `npx impeccable skills install` to install, `--force` to
  reinstall, and note the npx skills shared-build caveat.

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

* site: reword craft tagline so it doesn't lead with "Shape"

The craft card's tagline began with the word "Shape", which reads like
the name of the sibling /shape command and made the two cards look
swapped (#166). Reword to "Design it, then build it, all in one flow."
No data was actually swapped; this is a copy collision fix.

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

* skill: rename teach -> init and expand its setup flow

Rename the `/impeccable teach` command to `/impeccable init` across the
skill, site, CLI, and tests. `teach` stays as a deprecated router alias and
/docs/teach + /skills/teach redirect to /docs/init.

Expand the command beyond writing PRODUCT.md/DESIGN.md: the same codebase
crawl now also pre-configures `.impeccable/live/config.json` (Step 6, with
CSP consent) so live mode boots with no first-time detour, and the flow ends
by recommending the best commands to run next from what the scan surfaced
(Step 7).

Fold two items into the unreleased v3.5.0 changelog entry: the init rename
and the brand-seed palette picker. No version bump.

Regenerates all harness skill output dirs and the _redirects file.

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

* docs: lead README install + usage with the CLI installer

Add `npx impeccable skills install` as the recommended install option and
update the Usage section to the `/impeccable <command>` form, dropping the
nonexistent `/normalize` example.

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

* test(skill-behavior): swap to production-tier models (sonnet + gpt-5.5)

Replace the cheap-tier default lineup (claude-haiku-4-5, gpt-5.4-mini) with
production-tier models (claude-sonnet-4-6, gpt-5.5) so the skill-behavior
suite reflects what users actually run. gemini stays on flash-lite.

Sync the docs (CLAUDE.md, AGENTS.md, tests/skill-behavior/README.md): new
model names, cost estimate raised to ~$0.50-1.50/sweep, and the old 21-22/24
baseline reframed as previous-cheap-tier history pending re-measurement on
the new lineup.

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

* feat: self-updating skill via boot-time version check

context.mjs now polls a new lightweight /api/version endpoint at most once
per day (cached globally in ~/.impeccable) and appends an UPDATE_AVAILABLE
directive when a newer skill version has shipped, prompting the agent to
offer `npx impeccable skills update`. Best-effort and silent on any failure;
asks before updating; suppresses re-prompts for a declined version for a
week. Opt out with IMPECCABLE_NO_UPDATE_CHECK=1.

- skill/scripts/context.mjs: version read, throttle + anti-nag cache, directive
- scripts/build.js + _redirects: /api/version endpoint (from plugin.json version)
- skill/SKILL.src.md: document the UPDATE_AVAILABLE boot branch
- tests/context.test.mjs: coverage for cached/newer/suppressed/opt-out paths
- changelog: v3.5.0 entry
- synced harness skill dirs via bun run build

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

* test: cover the self-update path (network + LLM behavior)

context.test.mjs: add a localhost stub-server integration test for the live
fetch path (poll /api/version, cache a newer version, stay silent on
same-or-older, fail silent + stamp lastCheck when unreachable). Runs against
127.0.0.1 only, never the real site; uses async spawn so the in-process stub
isn't deadlocked by spawnSync blocking the event loop.

skill-behavior: add scenario 9 asserting the agent surfaces UPDATE_AVAILABLE
but never auto-runs `npx impeccable skills update` without asking. New
prepareWorkspace `skillVersion` copy-mode (so context.mjs has a SKILL.md to
version-check), env threading through runTurn -> execBash, and bash-output
capture to prove the agent actually received the directive. Passed on
claude-sonnet-4-6, gpt-5.5, and gemini-3.1-flash-lite.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-28 14:22:22 -07:00
Abdul WahabandGitHub 84135db0e6 Add DeepSeek live E2E adapter (#163)
* Add DeepSeek live E2E adapter

* Fix DeepSeek live E2E review issues

* Harden live-e2e helpers against silent failures

- htmlToJsx: match multi-line inline style attributes ([\s\S]*?)
- readCliOption: throw when --flag value is missing or another --flag
- llm-agent: echo parsed payload (first 500 chars) in schema-error throws

* Bind hoisted inline styles to their owning tag

normalizeVariantOutput previously hoisted every stripped style attribute
onto a selector derived from the variant's first tag, so a style on a
nested <span> landed on <h1>. Now walks each opening tag and emits one
rule per styled element with a descendant combinator so nested-element
styles target the correct node. Also fixes the duplicated multi-line
style regex bug (.*?) -> ([\s\S]*?) that survived the previous round.

Extracts parseVariantResponse from llm-agent for direct schema-throw
testing, and lifts readCliOption into its own module so its new
missing-value throws can be unit-tested.

Adds tests for:
- multi-line style hoisting
- nested-element tag binding and per-tag rule emission
- astro-global-prefixed selector shape
- no-op identity-return path
- opts.config short-circuit in createLlmAgent
- all four parseVariantResponse schema previews + JSON-parse failure
- readCliOption value/throw matrix

* Hoist inline styles via data attribute, not tag name

Two bugs in normalizeVariantOutput that Bugbot flagged:

1. Hoisted rules like `:scope span` matched every same-tag descendant of
   the variant wrap, so a style on one of several <span>s leaked onto its
   siblings.
2. The opening-tag scan used `[^>]*` for attributes, so a literal `>`
   inside a quoted attribute value (e.g. `aria-label="x > y"`) terminated
   the match early and the trailing `style="..."` was never seen.

stripInlineStylesPerElement now walks each opening tag character by
character respecting quoted attribute values, and tags every styled
element with `data-impeccable-hoist-id="N"`. Rules select on the
attribute so they bind to exactly the one element they came from.
The attribute is stripped during carbonize cleanup so it does not
survive into the final source.

* Harden live E2E variant CSS normalization

* Fix Radix tests

* Harden live E2E pick clicks
2026-05-22 09:28:36 -07:00
Paul BakausandClaude Opus 4.7 642f03d5a1 fix(live-server-test): isolate shared server cwd so tests cannot pollute repo
Previously the main `live-server integration` describe block spawned its
shared server against REPO_ROOT, so its session journals/snapshots
(a1b2c3d4-dc, aa11bb22, sse-test, test-e2e-1) were written into the
real repo's `.impeccable/live/sessions/`. On the next `npx impeccable
live` run, restorePendingEventsFromStore replayed those into the poll
queue, surfacing as synthetic test events to the agent.

Run the shared server against a mkdtempSync tmpdir, seed a minimal
package.json so the /source endpoint test still passes, and route the
inline journal/snapshot reads (and the live-complete.mjs call) through
server.cwd.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:47:01 -07:00
Paul Bakaus bc1894889e Improve critique skill reliability
- add provider-specific block compilation and tests

- bundle detector scripts for skill critique runs

- harden critique orchestration, browser handling, and storage
2026-05-18 15:15:14 -07:00
Paul BakausandGitHub e1d3ea0b6f Detector architecture v2: static engine, benchmarks, lab, and visual contrast (#156)
* Add detector benchmark lab and visual contrast fallback

* Expand visual contrast fixture coverage

* Add browser visual contrast fallback

* Show visual contrast overlays in detector lab

* Fix detector lab short viewport layout

* Fix detector lab visual overlays

* Add visual contrast to browser scan overlays

* Avoid browser scroll jumps during visual contrast scans

* Resolve visual contrast lazily on scroll

* Refresh detector lab visual counts lazily

* Update pnpm lockfile for static parser deps

* Address Bugbot detector API comments

* Report extension visual contrast errors

* Refactor detector into engine modules

* Address Bugbot detector comments

* Fix latest Bugbot detector notes

* Fix visual contrast fixture labels

* Refine detector lab fixtures

* Fix stale detector overlay references

* Fix detector lab fixture URLs

* Fix typography lab fixture highlights

* Fix typography lab page-level signal

* Fix visual overlay lifecycle cleanup

* Remove dead spotlight timer cleanup

* Make browser async APIs reject consistently
2026-05-17 19:49:38 -07:00
Paul BakausandClaude Opus 4.7 4af581e23f chore(skill): bump to v3.1.1 + changelog
Windows fix for /impeccable critique CLI guard (#155).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:52:50 -07:00
Paul BakausandClaude Opus 4.7 5f15163c2b fix(critique-storage): make CLI entry-point check Windows-safe (#155)
The `import.meta.url === \`file://\${process.argv[1]}\`` guard at the
bottom of critique-storage.mjs silently failed on Windows: Node sets
import.meta.url to file:///D:/... (forward slashes) but process.argv[1]
is D:\... (backslashes), so the string compare returns false, main()
never runs, and the script exits 0 with no output. The OpenCode reporter
saw "/impeccable critique" skip the snapshot save with no error.

Switch to pathToFileURL(process.argv[1]).href, the standard cross-
platform pattern already used everywhere else in the repo.

Adds three CLI subprocess tests so future regressions of this guard
are caught even on macOS/Linux CI.

Fixes #155.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:48:27 -07:00
Paul BakausandClaude Opus 4.7 e493504496 chore: sync bun.lock to jsdom 29.1.1 from #154
PR #154 bumped jsdom in package.json and pnpm-lock.yaml but left
bun.lock at 29.0.0, so the next bun install regenerates this diff.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:48:14 -07:00
Paul Bakaus de9aa13a53 ignore talks 2026-05-14 15:42:44 -07:00
1e8356fa25 fix(cli): pass --copy to npx skills add to avoid symlinking provider dirs (#148)
By default, `npx skills add` installs to .agents/skills/ and symlinks
.claude/skills/ to it. That symlink fails to be created on fresh projects
with no .claude/ directory, and on Windows without elevated privileges,
leading to `Cannot find module .../.claude/skills/impeccable/scripts/
load-context.mjs` (issue #140).

It also collapses meaningful per-provider differences between the two
directories (Claude-specific frontmatter, command prefix, paths) into a
single shared file.

The skills CLI's `--copy` flag installs each provider's variant separately
without any symlinks, fixing both problems at once.

Fixes #140.

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-14 10:52:24 -07:00
4027e17f4c chore: bump jsdom to 29.1.1, drop border-radius shorthand workaround (#154)
jsdom 29.1.1 ships two fixes that unblock this:
- 5f66329: Fix background-origin/background-clip in background shorthand
- ad8af77: Fix border shorthand handling

The `resolveBorderRadiusPx` fallback chain (inline-style scan, stylesheet
walk) was only needed because jsdom 29.0.x returned "" for
`style.borderRadius` when the value came from a CSS shorthand rule. With
29.1.1 the computed shorthand value resolves correctly, so the plain
`parseRadiusToPx(style.borderRadius)` path succeeds and the fallbacks
are dead code. Test suite confirms 173/173 unit tests and 23/23 jsdom
fixture tests pass with the simplified function.

Closes jsdom/jsdom#4153 (from our side).

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-14 10:51:31 -07:00
Paul BakausandClaude Opus 4.7 dc715c7359 craft + codex: explicit user gates before code (Codex test fix)
A live Codex test against v3.1.0 caught the model skipping both
shape questions (when PRODUCT.md was present) and image generation
entirely, going straight to implementation after the compact shape
brief. The loophole: "confirm or override" at the end of compact
shape reads as the final gate, even though codex.md adds four more
gates before code.

Three tightenings:

- craft.md: new "Gates: do not compress" section at the top that
  names the four gates explicitly (shape brief, direction questions,
  palette, mock approval). Compact shape's closing line now says it
  advances to Step 3 and codex.md, not Step 4. New precondition at
  the top of Step 4 forbids mentioning implementation, file paths,
  or patch plans until codex.md Steps A-D are complete.

- codex.md: new "Four stop points before code" intro listing the
  user-facing gates as a numbered checklist. Step A now says it's
  required even when shape just produced a confirmed brief; the
  shape and Step-A questions cover different ground. Explicit STOP
  markers added to Steps A, B, and D.

- Changelog: v3.1.0 "Shape gates restored" bullet rewritten as
  "Shape and craft gates strengthened" to cover the additional
  craft.md sequencing.

Retagging v3.1.0 to include this fix rather than cutting a 3.1.1
since the original tag is minutes old, no marketing went out, and
the gate work is what the asset-producer story needs to actually
work in Codex.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 00:42:08 -07:00
Paul BakausandClaude Opus 4.7 23b6b9cc0e chore(cli): bump to v2.1.9 + changelog
Patch bump. Detector adds the body-text-viewport-edge rule (29 total)
and fixes a class of false positives in modern token-based codebases
(OKLCH and var() resolution, anchor inheritance). Live screenshot
overlay no longer flashes solid black during loading.

The "live mode survives disconnects" CLI work and the italic-serif /
hero-eyebrow-chip rules from PR #129 already shipped in the v3.0.7
skill release notes, so they're not re-announced here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 00:29:28 -07:00
Paul BakausandClaude Opus 4.7 735a0f4e4f chore(skill): bump to v3.1.0 + changelog
Minor bump because the changes since v3.0.7 are genuinely additive:

- Native subagent cross-compile pipeline and the Codex
  impeccable_asset_producer agent (Codex-only by design).
- Critique persistence: per-target snapshots, ignore.md, polish
  reads matching snapshot as additional signal.
- Codex-specific image flow extracted to reference/codex.md with
  the palette-first gate; craft.md is leaner for non-Codex.
- Detector: body-text-viewport-edge rule (29 total),
  OKLCH/var-resolution and anchor-inherit FP fixes.
- Brand register: inverse test and cultural-symbol palette
  guardrail.
- Shape gates restored where weakening had crossed the line
  (image-gen announcement, explicit brief confirmation).

Hero version link and full-history block updated. Manifests and
harness SKILL.md frontmatter all on 3.1.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 00:27:42 -07:00
Paul BakausandClaude Opus 4.7 8cef296996 craft + codex: extract Codex-specific image flow into codex.md
Codex is the only harness today with native image generation
(image_gen) and the shipped impeccable_asset_producer subagent. The
detailed mock/palette/asset workflow only applies there. Inlining
it in craft.md made every non-Codex harness read past 49 lines of
instructions it can't act on, and weakened the Codex-specific
guidance by averaging it down to a cross-provider lowest common
denominator.

Split:

- New skill/reference/codex.md: Steps A-F covering direction
  exploration, palette-first generation, mock generation against
  confirmed palette, approval loop, mock-fidelity inventory, and
  asset slicing via the impeccable_asset_producer subagent. Adds
  the "palette first" forcing function that wasn't in craft.md
  before; locking the palette before any mocks is what stops
  generated comps from drifting.

- craft.md Step 3 is now a one-line conditional: if the harness has
  native image generation, load codex.md and follow Steps A-F. If
  not, state in one line that the step is being skipped because the
  harness lacks native image generation, then proceed using the
  brief as the visual reference. Same announcement-required pattern
  as shape.md.

- craft.md Step 4 (asset extraction) is absorbed into codex.md
  Step F. Steps renumber: 5 (build), 6 (iterate), 7 (present)
  become 4, 5, 6.

Net: craft.md goes from 155 lines to 106 lines for non-Codex
providers. Codex gets a sharper 92-line image flow with the
palette-first gate that wasn't there before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 00:24:16 -07:00
Paul BakausandClaude Opus 4.7 afc974d630 shape: restore image-gen announcement + explicit brief confirmation
Two specific gates that ea2e372 weakened, restored. The cadence
relaxation (one round is the default) and the compact-brief option
stay; this commit only undoes the changes that traded forcing
functions for model-judgment calls the model is bad at making.

1. Image-gen skip is announced, not silent. "Skip silently and
   proceed" gave models like GPT 5.5 license to elide image
   generation entirely. Restored to "state in one line that the
   image step is skipped because the harness lacks native image
   generation, then proceed." The one-line announcement is the
   conscious-decision forcing function.

2. Brief confirmation is explicit, not assumed. "If the user
   already said 'approved' or 'go' during discovery for the exact
   direction you'd present, that counts as confirmation" gave the
   model an out to skip the pause based on its own read. Restored
   to "stop and wait for explicit confirmation. You are not the
   judge of whether the user already approved."

Net: shape still asks one round by default and supports compact
briefs, but the two specific places where the model could skip
discipline without saying so are gone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 00:24:03 -07:00
93a13f9882 Critique persistence: per-run snapshots, ignore list, polish reads as signal (#153)
* critique-storage: new helper for per-run snapshot persistence

Adds skill/scripts/critique-storage.mjs with:
- slugFromTarget(): mechanically derive a stable slug from a resolved
  file path or URL (NOT from the user's natural-language phrasing),
  so the same target lands in the same stream across runs even when
  dev-server ports drift or the user phrases it differently.
- writeSnapshot(): writes .impeccable/critique/<timestamp>__<slug>.md
  with a small YAML frontmatter (timestamp, slug, target, total_score,
  p0_count, p1_count) plus the report body.
- readLatestSnapshot(): newest snapshot for a slug, used by polish.
- readTrend(): last N frontmatter entries for a slug, used by critique
  to print the score trend line.
- readIgnoreList(): non-empty non-comment lines from ignore.md, the
  ONLY input critique consumes from prior runs.

No separate index.json. The snapshot files are the single source of
truth; trend reader globs them and parses frontmatter. Deleting a
snapshot removes it from the trend cleanly with no orphan rows.

CRITIQUE_DIR constant + getCritiqueDir / getCritiqueIgnorePath added
to impeccable-paths.mjs alongside the existing live-dir helpers.

19 unit tests in tests/critique-storage.test.mjs cover slug stability,
URL and file inputs, round-trip read/write, trend filtering by slug,
and ignore-list parsing.

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

* critique: persist snapshot per run, respect ignore.md

Two new steps wired into the critique flow:

- Setup: Resolve Target and Load Ignore List. Before gathering
  assessments, resolve the user's natural-language target ("the
  homepage") to a concrete artifact, compute the slug via
  critique-storage.mjs, and read ignore.md. Matching findings drop
  silently from the report. This is the only prior-run input
  critique consumes; anchoring on prior findings would defeat
  independent assessment.

- Persist the Snapshot. After the report is finalized (before Ask
  the User), write it to .impeccable/critique/<ts>__<slug>.md with
  structured frontmatter, then surface a one-line trend ("Trend for
  index-astro: 24 → 28 → 32") and the written path. First run says
  "no trend yet". Persistence is fire-and-forget; failures print and
  move on rather than blocking the rest of the flow.

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

* polish: read latest matching critique as fix backlog

When polish is invoked after critique on the same target, the
critique's P0/P1 findings are the right backlog; don't re-derive
them.

Adds a Setup step that resolves the target, computes the slug via
critique-storage.mjs slug, and reads the latest matching snapshot
via critique-storage.mjs latest. Found → use those P0/P1 items as
the polish backlog and mention the snapshot path. Not found →
proceed independently from a clean slate.

Explicitly does NOT read snapshots for other targets (cross-target
context is pollution). Explicitly does NOT cascade to atomic moves
(bolder, quieter, clarify, animate, etc.); those act on a specific
selection where the page-level critique would be noise.

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

* gitignore: .impeccable/critique/, opt ignore.md back in

Per-run critique snapshots are local artifacts (same precedent as
.impeccable/live/sessions/), but ignore.md carries user-curated
deferrals that may be worth sharing across a team. Negate-pattern
keeps it trackable while the snapshot files stay local.

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

* polish: reframe prior critique as additional signal, not backlog

Three corrections to the previous polish.md addition:

- "Polish is usually invoked after critique" is wrong; people polish
  without ever running critique. Dropped the presumption.
- "This is the only command that auto-reads prior critique" leaks
  cross-command scope into polish's reference file. Dropped.
- Treating critique findings as THE polish backlog biased polish to
  only fix what critique flagged, skipping its own checklist. The
  critique is one input among many; fold its P0/P1 items into the
  polish list, then do the normal pass.

Now lives as a short item 4 in Pre-Polish Assessment ("Pull in any
prior critique — optional signal") instead of a top-level Setup
section. Less prominent, doesn't presume invocation order.

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

* critique-storage: drop the ignore subcommand, read ignore.md directly

The ignore-list helper did nothing the model can't do inline: read a
markdown file, skip blank and #-prefix lines. It added a tool
roundtrip for no real value. Other helpers earn their keep by doing
work the model can't trivially do (path normalization, filename
generation, glob + frontmatter parsing); ignore-list did not.

Removed:
- `ignore` CLI subcommand
- readIgnoreList() module export + its tests
- getCritiqueIgnorePath() from impeccable-paths.mjs (now dead code)

Critique.md step 3 now just says "read .impeccable/critique/ignore.md
if it exists" and explains the format inline. Simpler.

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

* critique-storage: caller meta cannot override timestamp or slug

Spotted by Cursor Bugbot on the PR. writeSnapshot built frontmatter as
{ timestamp, slug, ...meta } so a caller-supplied meta blob (parsed
from the IMPECCABLE_CRITIQUE_META env var) could silently clobber the
computed timestamp and slug. The filename keeps the computed values,
so the frontmatter would drift from the filename and readTrend would
attribute scores to wrong timestamps with no visible error.

Swap to { ...meta, timestamp, slug } so internal values always win.
Add a regression test that passes corrupt meta and asserts the
frontmatter still matches the filename.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 00:06:02 -07:00
Paul BakausandClaude Opus 4.7 c32daaf3b0 fix(site): update GitHub star count to 27k
Live count via GH API is 27,337. Bumps the shared Header component's
visible label and aria-label from 24k → 27k.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:16:46 -07:00
e7e923c4ef Skill + craft cleanup, detector hardening, native subagent pipeline (#152)
* skill: drop quality tiers, keep the real brand-craft guardrails

Codex's craft/brand pass introduced fast/ship/showpiece "quality bars"
plus brand-specific build gates, asset ledgers, sub-agent review, and
self-graded fallback labels. In practice those tiers became escape
hatches rather than craft pressure: the final output should always be
10/10, and the real decision points are splashiness and maximalism, not
quality.

Removed:
- All quality-bar / showpiece / fast / ship framing in shape.md and craft.md
- Standalone Brand Direction (#4) and Asset Requirements (#10) sections
  in shape's brief; renumbered back to 1-10
- The Brand hard rules section in brand.md (folded its real prohibitions
  into the existing Imagery and Brand bans sections)
- Brand-specific build-gate item, mock-fidelity bullet, production-bar
  bullet, present-step bullet in craft.md
- Asset ledger ceremony in craft Step 4
- Review-only sub-agents and "self-reviewed fallback, not independently
  validated" machinery in craft.md and polish.md
- The For brand surfaces, assess hard failures subsection in polish.md
  and the brand checklist row
- tests/brand-showpiece-reference.test.mjs (and its package.json wiring)

Kept (the real nuggets):
- Asset-substitution prohibition: image-led briefs ship real/generated
  assets or canvas/SVG/WebGL, not generic CSS panels, cards, bullets,
  or copy
- Repeated tiny uppercase tracked kicker labels as a brand ban
- Detector/QA output is defect evidence only, never proof of quality
- "What visual assets are real content here?" discovery question
- Inspect each major section individually for brand and long-form work
- repeated-section-kickers detection rule + fixture
- CLI improvements (JSON to stdout, -json/-fast aliases, severity field)
- critique.md: npx impeccable detect --json fix

Harness output dirs refreshed via bun run build. Full test suite (186)
passes.

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

* skill: strip gate ceremony; require shape pause; allow compact briefs

The setup gate table and IMPECCABLE_PREFLIGHT banner pushed every
craft run through ritual restatement (PRODUCT.md → original prompt
→ round 1 → round 2 → 70-line "confirmed brief" → critique → summary,
all saying the same thing). Replaced with imperative prose that still
demands the same work but skips the user-facing telemetry.

Specifically:

SKILL.md
- Drop the Setup gate table and IMPECCABLE_PREFLIGHT banner.
- Keep the imperative steps explicitly: load context, identify register
  and load brand.md or product.md, AND load the matching command
  reference (craft.md / shape.md / etc.) when a sub-command is invoked.
  The command-reference step is non-negotiable; without craft.md loaded
  the agent skips the shape-and-confirm pause.

craft.md
- Drop the Build Gate / Craft Contract formal sections; replace with
  one paragraph stating prerequisites.
- Step 1 explicitly requires ending the response after presenting the
  shape output; the user must confirm before any code lands. Allows a
  compact 3-5 bullet brief when the prompt + PRODUCT.md already pin
  direction (full 10-section structure reserved for genuinely
  ambiguous tasks).
- Step 3 image gate skips silently when image generation isn't
  natively available; no user-facing announcement.
- Step 6 explicitly legitimizes "first pass clean, shipping" as a
  valid endpoint and bans inventing fake defects to demonstrate
  iteration.

shape.md
- Cap discovery at 1 round by default; second round only when first
  leaves material gaps.
- Adds an "assert-then-confirm, not menu-with-escape" rule: when
  PRODUCT.md and the prompt make one option obvious, name it and ask
  for confirm or override instead of enumerating "Restrained /
  Committed / Or something else?" as a real choice.
- Phase 2 brief has two forms now: compact (default for clear briefs)
  and full structured (genuinely ambiguous). Open Questions can't
  double as leading-with-Recommend; if you'd write "Recommend: X",
  decide X.
- Image gate same as craft.md.

Validated end-to-end with a Haiku skill-on observability run: agent
loads craft.md plus the brief's recommended implementation refs,
pauses for one productive question (accent color, trace fidelity,
CTA), and ships an artifact with zero side-tab violations vs. the
original v1 baseline. Cost trades up modestly for that quality.

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

* craft.md Step 6: Reading the screenshot is the inspection, not taking it

A v4 eval run took 4 targeted screenshots (hero, mobile, tablet,
query-section) and then never Read any of them back. The agent treated
browser_screenshot itself as "I inspected" and shipped without the
multimodal feedback loop ever closing. Detector caught the resulting
slop (5+ side-tab violations) on adjacent runs that did the same thing.

Step 6 now spells out the pattern explicitly: take the screenshot,
then Read the resulting PNG so its image content enters the
conversation as multimodal input, then critique what you actually see
in the image. With a check: "if your critique could have been written
without looking at the image, you didn't look at the image."

Validated with v5b: agent took 6 screenshots, Read all 6 back, and
shipped with zero detector findings (vs the previous greenfield runs
that hit 1-12 findings each).

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

* craft + brand: framework foundation, build-pipeline respect, image verification

Three closely-linked additions surfaced by an eval-harness session
investigating why the agent always shipped flat single-file HTML and
zero imagery on greenfield brand briefs.

1. craft.md gains a new Step 0 "Project Foundation" before Shape.
   Detects existing framework / component library / icon set and
   uses what's there. Greenfield: ask the user via AskUserQuestion
   with sensible defaults framed by the brief (Astro for content/
   brand sites, SvelteKit/Next/Nuxt for app surfaces, single
   index.html only for one-shot demos). Skipping the framework
   decision and writing flat HTML "to satisfy the spec" produces
   work that reads as a 2018 prototype regardless of visual
   quality.

2. craft.md Step 5 production bar gains two bullets:
   - Respect the build pipeline. Edit source files and run the
     project's `npm run build`; do not write to build/ / dist/ /
     .next/ directly with cat/heredoc/Bash redirects. Bypassing
     the pipeline skips asset hashing, image optimization, code
     splitting, and CSS extraction.
   - Verify external image URLs before referencing them. Use an
     image-search MCP, web-fetch tool, or browser if available;
     guessed photo IDs ship as broken-image placeholders.

3. brand.md "Imagery" section:
   - Generalizes the Unsplash URL guidance to "verify URLs
     before referencing them" with a hierarchy: image-search MCP
     > web-fetch > confidence-restricted manual selection >
     fewer photos.
   - Tightens the tech/dev-tool exception. Old line "zero imagery
     can be correct" gave models a permission slip. New framing
     keeps the underlying truth (typography + code + diagrams
     primarily carry voice) but raises the floor: imagery still
     earns its place when it serves the brief, and skipping it
     requires naming the typographic/diagrammatic move that's
     carrying the visual weight instead. "Zero imagery is the
     failure mode of laziness, not restraint."

Eval-harness corpus that prompted this: 19/19 brand landing tasks
shipped 0 images each, including ones where Opus had taste enough
to break the dev-tool color default lane. The skill needs to teach
both halves of the decision; the harness shouldn't have to nudge.

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

* detector: body-text-viewport-edge rule + OKLCH/var-resolution + anchor-inherit FP fixes

New rule: body-text-viewport-edge flags body paragraphs that render flush
against the left/right viewport edges (no container padding). Tested via
the new tests/fixtures/antipatterns/body-text-viewport-edge.html fixture
(3 flag cases, 5 pass cases) and the test in detect-antipatterns-browser.

False-positive class fixes — all jsdom-mode only (real browsers resolve
the cascade correctly so these gates stay inert there). Five related
gaps that compounded into ~14× spurious contrast findings on Tailwind v4
pages with OKLCH color tokens:

  • OKLCH parser. jsdom returns the literal "oklch(...)" string from
    getComputedStyle; the detector now converts to sRGB via Björn
    Ottosson's matrices. Handles Tailwind v4's compact minified form
    "oklch(21.5%.02 50)" (no space after %).
  • var() resolution. resolveBackground + checkElementColors now
    accept the existing customPropMap and parse `var(--color-paper)`
    etc. as proper RGB via the new parseColorResolved helper.
  • bg-color before bg-image. The old order bailed on any gradient
    ancestor before checking for a solid background-color underneath,
    causing the body's decorative paper-grain gradient to be measured
    against instead of the page's actual `bg-paper` cream.
  • body/html-level gradient → white fallback. When the only opaque
    ancestor we can read is body/html with a gradient overlay (and
    jsdom can't decompose `background: var(--paper) gradient` to
    extract the solid color), return white instead of falling through
    to resolveGradientStops — which was picking up paper-grain noise
    colors and using them as the bg.
  • Anchor-inherit workaround for jsdom :link UA specificity.
    Tailwind v4's preflight declares `a { color: inherit }` (0,0,1).
    jsdom's UA stylesheet has `:link { color: blue }` at (0,1,1) and
    wins the cascade. Real Chrome wraps :link in :where() (0,0,0) so
    the page rule wins. When the page declares the inherit rule AND
    we see jsdom's default `rgb(0,0,238)` on an anchor, walk to the
    nearest non-anchor ancestor and use its color.
  • Alpha-fallback safety gate. When text has alpha<1 AND we couldn't
    find an opaque ancestor (effectiveBg null), skip the contrast
    finding. Covers any remaining FP class the deeper fixes miss.

Verified end-to-end against an Opus iter-1 artifact on Tailwind v4 with
14 cream/cream FPs + 2 blue-link UA FPs before; 0 findings after, while
the color.html fixture's 12 real low-contrast cases continue to flag
(verified via direct detectHtml calls).

cli/engine/detect-antipatterns-browser.js is the generated browser
distribution — regenerated from .mjs via scripts/build-browser-detector.js
(no manual edits to the generated file).

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

* craft.md: tighten verbose passages, de-codex Step 6, cut redundancies

Cumulative reduction: 218 → 155 lines (-29%).

Step 0: drop the "Why this matters" paragraph at the end. The body of
Step 0 already makes the framework-pick point; the paragraph just
re-explains it with extra rhetoric.

Step 1: replace the 4-sentence "you must end your response" block with a
single line. The original said the same thing three different ways.

Step 3: trim the conditional / defensive scaffolding (Purpose subsection,
"do not skip because the eventual UI is semantic..." paragraph,
duplicated approval-loop guidance). Mock fidelity inventory preserved.

Step 4: drop the "keep UI text semantic" sentence; it duplicates Step 5's
"Semantic first" rule. The rasterized-vs-semantic decision rule stays.

Step 5: tighten each production-bar bullet to bold-lead + specifics
format. All 15 rules preserved (real content, mock ingredients, semantic
first, spacing/alignment, typography, state coverage, interaction quality,
icon set, build pipeline, image URL verification, optimized imagery,
premium motion, maintainability, technical cleanliness, ask-when-uncertain).

Step 6: rewrite around "look at what you built like a designer would —
your eyes are whatever the harness gives you." Drops Codex-specific
"In Codex, use browser-use" bias. Drops the verbose 3-step Read pattern
(condensed to one sentence). Drops the 1-8 numbered checklist (replaced
by a tight paragraph). Keeps the load-bearing rules: read the PNG,
don't fabricate iteration, mock fidelity reference, exit bar = studio
defensibility.

Step 7: drop the closing "Iterate based on feedback. Good design is
rarely right on the first pass" preachy filler.

All em-dashes converted to semicolons / colons / periods to satisfy
the skill prose validator.

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

* build: native subagent pipeline + Codex-only asset producer

Adds an agent cross-compile pipeline alongside the existing skill
pipeline. Sources live at skill/agents/*.md; providers that declare
agentFormat (codex-toml, claude-md) emit native subagent files. An
optional providers: <list> field on an agent gates which harnesses
get a copy; default (no field) ships everywhere.

The impeccable-asset-producer agent is opt-in to Codex only. It's
useful for Codex's native image generation path and is untested
elsewhere; Claude has no native image gen anyway.

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

* brand: inverse-test + cultural-symbol palette guardrail

Two additions to the brand register reference:

- Inverse slop test: describe the page the way a competitor would
  describe theirs. If that sentence fits the modal landing page in
  the category, restart.
- Palette guardrail: when a cultural-symbol palette is the obvious
  pull, reach past it. Let cultural reading come from typography,
  imagery, and copy.

Harness mirrors regenerated; some also catch up to the image-
verification paragraph from e3ad2ef that hadn't been re-synced.

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

* PRODUCT.md: widen audience beyond developers

Designers, product managers, and engineers all use AI coding tools
and want better design output. Keeping the audience narrow to
"frontend and full-stack developers" understates who the skill is
actually for. Also retitles "developer" to "user/builder" in the
purpose statement.

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

* site + build: bump rule count to 29, strip changelog from detector check

Two changes:
- site/pages/index.astro: three live mentions of "28 rules / checks"
  bumped to 29 after the body-text-viewport-edge rule landed in
  b9bf496.
- scripts/build.js: the detection-count validator was reading the
  unstripped content, so historical counts inside changelog entries
  (e.g. "28 rules" from an older release note) were flagging against
  the current detector total. The command-count check already strips
  the changelog ul; the detection check now does the same.

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

* test: align hero-eyebrow-chip fixture with relaxed rule gates

b9bf496 intentionally relaxed two gates in checkHeroEyebrow:
- removed the heading-size ≥ 48px anchor (modern hero h1s use
  clamp/vw/var that jsdom can't resolve)
- raised the eyebrow text ceiling from 30 to 60 chars

Two fixture cases that satisfied the negative side of the old gates
now match the rule:

- "Body-Sized Heading Below Eyebrow" — 24px h1 with tracked-caps
  label above. Per the rule's stated intent ("a tiny tan label
  directly above any h1 is the antipattern regardless of how big
  the h1 ends up"), this is a flag.
- "Long Uppercase Sentence Above Hero" — 46-char tracked-caps label
  is under the new 60-char ceiling, so still eyebrow-shaped.

Both cases moved from the should-pass column to should-flag, with
case descriptions rewritten to explain the gate they exercise.

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

---------

Co-authored-by: Paul Bakaus <paulbakaus@pauls-mbp-3.lan>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:11:18 -07:00
e587004ee4 Refactor: cleaner top-level directory structure (#138)
* refactor(content): merge content/site/ into site/content/

Phase 1 step 1 of the directory restructure. The dual content tree was
called out in CLAUDE.md as cleanup; both trees were already in sync
except for anti-patterns-catalog.js, which moves to site/data/.

- Delete content/site/skills/ and content/site/tutorials/ (duplicates of
  site/content/, which is what Astro's content collection actually reads).
- Move content/site/anti-patterns-catalog.js -> site/data/.
- Update scripts/lib/sub-pages-data.js and scripts/build.js to read from
  site/content/ and site/data/.
- Drop content/site/ from validateProse target list (site/content was
  already there).
- Rewrite the "Two content trees" section in CLAUDE.md as a single-tree
  pointer; update stale dev-server text mentioning the deleted
  server/index.js.

Tests: 186/186 pass. Skills build: clean.

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

* refactor(skill): rename source/skills/impeccable/ -> skill/

Phase 1 step 2 of the directory restructure. The path was redundantly
nested ("source/" wrapper plus "skills/impeccable/" — singular content
hidden behind the plural). Collapses to flat skill/SKILL.md +
skill/reference/ + skill/scripts/.

- Move source/skills/impeccable/ -> skill/.
- Rewrite scripts/lib/utils.js readSourceFiles(): drop the multi-skill
  iteration (CLAUDE.md commits to a single user-invocable skill); read
  skill/SKILL.md directly.
- Update scripts/build.js, scripts/generate-og-image.js, and the
  sub-pages data layer to point at skill/.
- Update tests/lib/utils.test.js: drop the "multi-skill" and "dir-name
  fallback" cases, update single-skill paths to skill/.
- Update tests/build.test.js similarly: drop "multiple skills"
  integration test, update paths.
- Update non-glob path joins in tests/framework-fixtures.test.mjs,
  tests/live-e2e/session.mjs, tests/live-e2e/agents/llm-agent.mjs,
  tools/live-loop.mjs.
- Update prose/text references in CLAUDE.md, AGENTS.md, DEVELOP.md,
  README.md, scripts/lib/sub-pages-data.js, bin/commands/skills.mjs,
  site/data/anti-patterns-catalog.js, site/pages/docs/[...slug].astro,
  docs/adr-live-variant-mode.md, docs/plans/.

Eval framework note: the separate impeccable-evals repo reads
../impeccable/source/skills/impeccable/ and needs a coordinated
rename to ../impeccable/skill/.

Tests: 186/186 pass. Skills build: clean.

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

* refactor: rename docs/ -> notes/

Phase 1 step 3 of the directory restructure. The internal docs/ dir
(ADRs and plans) clashed with the site's /docs route. Renaming it
"notes/" makes the difference unambiguous: notes/ is project-internal
process, /docs is the user-facing route under site/pages/docs/.

No code references the dir; the rename is a clean git mv.

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

* refactor(site): move public/ under site/public/

Phase 2 step 4 of the directory restructure. Public assets and the
Astro publicDir now live alongside the rest of the site, so site/
is fully self-contained for static content.

- git mv public site/public.
- astro.config.mjs: add publicDir: './site/public'. Astro defaults to
  ./public at the project root, so the override is required.
- scripts/build.js: write generated _data, _headers, _redirects,
  _routes.json, and js/detect-antipatterns-browser.js into
  site/public/. Also delete the dead _REMOVED() Bun static-site
  builder (replaced by Astro at #130; the placeholder no longer earns
  its keep).
- scripts/build.js validateProse: replace the stale public/index.html
  reference (deleted at the Astro migration) with site/pages/index.astro
  in the count-validation file list, restoring homepage drift detection.
- scripts/generate-og-image.js: write OG image into site/public/.
- scripts/screenshot-antipatterns.js: read examples from + write
  screenshots to site/public/antipattern-{examples,images}/.
- scripts/lib/sub-pages-data.js: load command demos from
  site/public/js/demos/commands.
- .gitignore: rename the public/* generator-output entries to
  site/public/*.
- CLAUDE.md: refresh CSS/data-file paths (still pointing at the old
  pre-Astro public/css/ + public/js/ tree), point the changelog and
  command-add checklists at site/pages/index.astro and
  site/scripts/data.js + site/scripts/components/framework-viz.js.

Cloudflare Pages note: functions/ stays at the repo root because
CF Pages auto-discovers it there with no configuration knob to
relocate. Moving it under site/ would either break deployment or
require a build-time copy step that adds more complexity than the
cleanup is worth.

Tests: 186/186 pass. Skills + site build clean. _headers,
_redirects, _routes.json, _data/ all land in build/ correctly.

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

* refactor(cli): consolidate bin/ + src/ + lib/ under cli/

Phase 2 step 5 of the directory restructure. The CLI surface was split
across three top-level dirs whose names were easy to mistake for each
other (especially src/ vs source/ pre-step-2). Consolidates under cli/.

- git mv bin -> cli/bin (CLI entry + skills sub-command)
- git mv src -> cli/engine (detect-antipatterns engine + browser variant)
- git mv lib -> cli/lib (download-providers helper)

Update package.json:
- bin.impeccable: cli/bin/cli.js
- main + exports: cli/engine/detect-antipatterns.mjs and the
  ./browser variant
- files: ["cli/", "LICENSE"]

Update internal references:
- cli/bin/cli.js: dynamic import points at ../engine/, package.json
  read goes one level deeper (../../package.json).
- functions/api/download/[type]/[provider]/[id].js + bundle/[provider].js:
  cli/lib/download-providers.js path.
- scripts/build.js, scripts/build-browser-detector.js,
  scripts/build-extension.js: cli/engine path constants.
- scripts/lib/sub-pages-data.js, scripts/lib/utils.js, skill/scripts/
  live-server.mjs: comment refs.
- tests/detect-antipatterns{,-browser,-fixtures}.test.{js,mjs},
  tests/windows-path-fix.test.js: import + read paths.
- AGENTS.md, CLAUDE.md: doc paths.

Verified:
- npx node cli/bin/cli.js --version, --help, detect --help all work.
- bun run build, bun run build:browser, bun run build:extension all
  clean. Browser detector lands at cli/engine/detect-antipatterns-browser.js;
  extension/detector/detect.js still emits to the same location.
- bun run test: 186/186 pass.

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

* fix: update browser-detector paths missed in cli/ rename

Bugbot caught two runtime path leaks where the comment got renamed
to cli/engine/ but the actual code still used the old src/ segment.

- skill/scripts/live-server.mjs: detectPaths array now joins cli, engine,
  detect-antipatterns-browser.js for both the repo-relative lookup
  (4 dirs up from .claude/skills/impeccable/scripts/ to repo root) and
  the npm node_modules fallback. Without this fix, the detection
  overlay would silently not load during live-server sessions.

- scripts/build.js: the post-build copy of the browser detector into
  site/public/js/ was reading from src/. The if (fs.existsSync(...))
  guard meant the copy was silently skipping, so antipattern-examples
  pages would 404 on /js/detect-antipatterns-browser.js once the site
  was deployed.

Tests: 186/186 pass. Build clean. site/public/js/detect-antipatterns-browser.js
re-emits as expected.

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

* fix: cleanup-deprecated import path missed an extra .. in cli/ rename

Bugbot caught three call sites in cli/bin/commands/skills.mjs that
import '../../skill/scripts/cleanup-deprecated.mjs'. Pre-rename, that
was correct from bin/commands/ (one parent to bin/, one to repo root).
After moving the file from bin/commands/ to cli/bin/commands/, the
path is one directory deeper, so it needs three .. segments to reach
the repo root. Without the fix, every cleanup invocation throws on
import and gets swallowed by the surrounding try/catch — silent skip.

cli/bin/cli.js's package.json read already uses '../../package.json'
(the same depth pattern), confirming three levels is correct.

Verified: dynamic import resolves and exports the expected functions.

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

* chore: sweep stale path/file references missed in the restructure

Same root cause as the two bugbot finds: some references in moved or
related files weren't tracked because they didn't match a simple
sed pattern. Caught the rest by walking each moved dir's depth and
each Astro-migration deletion.

Stale path references (post-Astro migration, missed earlier):
- CLAUDE.md: legacy URL redirects "live in server/index.js" -> point
  at the actual sources (scripts/build.js generateCFConfig +
  site/public/_redirects).
- AGENTS.md: counts.js path (public/ -> site/public/), changelog file
  (public/index.html -> site/pages/index.astro), screenshots note
  (public/ -> site/), source-of-truth dirs (source/, src/ -> skill/,
  cli/).
- tests/detect-antipatterns-browser.test.mjs: comment about routes
  "in server/index.js".
- skill/reference/live.md: workflow.css example for "this repo" was
  pre-Astro (public/css/) -> site/styles/. (User-project Vite/Next
  example unchanged.)

Stale path that pointed at moved files:
- tests/skills-cli.test.js: CLI path was '..', 'bin', 'cli.js'; now
  '..', 'cli', 'bin', 'cli.js'. Test isn't wired into bun run test
  but it would have failed if invoked.

Dead files (orphaned by Astro migration, never cleaned up):
- tests/server/download-validation.test.js: imported from
  ../../server/lib/{validation,api-handlers}.js which were deleted in
  b8f09c8. Test was a silent failure waiting to happen.
- scripts/lib/render-markdown.js: 156-line module with zero consumers
  (the only caller, scripts/lib/render-page.js, was deleted in the
  Astro cleanup).
- scripts/build.js: dead commented-out generateSubPages import.

Tests: 186/186 pass. Build clean.

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

* fix(build): remove invalid Corepack packageManager spec

Cloudflare Pages rejects the build with `Unsupported package manager
specification (bun@1.3.11)`. The packageManager field follows
Corepack's syntax which only validates npm/pnpm/yarn — `bun@X.Y.Z`
parses as a malformed Corepack directive even though Bun itself
treats it as a hint.

Pre-existing on main since d874af0 (CF Pages deploy on main also
failing); just surfaces here because the PR triggers a fresh deploy.

CF Pages auto-detects Bun anyway (the build log confirms:
"Detected the following tools from environment: bun@1.3.11,
pnpm@10.11.1, nodejs@22.16.0"). Removing the field unblocks the
deploy without changing local dev behavior.

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

---------

Co-authored-by: Paul Bakaus <paulbakaus@pauls-mbp-3.lan>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 16:38:03 -07:00
Paul BakausandClaude Opus 4.7 2aeac48b19 chore: track .impeccable/live/config.json for this repo
Live mode injection config for the Astro site (Base.astro, before </body>,
HTML comment syntax). The .gitignore already permits tracking generated
sidecars; this commit makes the choice explicit so contributors get the
same wiring on first run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 14:09:02 -07:00
Paul BakausandClaude Opus 4.7 f7ab774fe4 fix(release): read changelog from site/pages/index.astro after Astro migration
The release script still pointed at public/index.html, which the Astro
migration deleted. The changelog lives in site/pages/index.astro now.
The substring extraction logic works unchanged because the source
contains the same markup that the build emits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 08:37:48 -07:00
Paul BakausandClaude Opus 4.7 8e3d4d2b04 chore(skill): bump to v3.0.7 + changelog
- Detector: italic-serif display heroes and hero eyebrow chips
  (#129, contributed by @vinaypokharkar).
- Live mode: durable session journal, status/resume/complete
  commands (#125, contributed by @nqh-packages).
- Reference files: stripped "Remember:" closer chants, brochure-style
  openers (12 files), and 419 em-dashes. Less context per command load,
  less repetition the model reads past.

Refresh harness output dirs and plugin/ subtree.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 08:35:17 -07:00
d874af046a feat(live): make live sessions recoverable (#125)
* feat(live): make live sessions recoverable

tired of live mode losing the plot when the browser moved faster than the agent.
now the state is boring: journal it, resume it, finish it.

---
- add durable live-session journal, checkpoint events, and status/resume/complete commands
- split browser session storage into a testable helper and harden accept/discard completion
- fix Astro live CSS preview mode and add recovery/live E2E coverage
- declare Bun as the package manager and add a Bun-native audit script

* fix(live): acknowledge fallback recovery states

* fix(live): flush recoverable handoffs promptly

* fix(live): keep recovery handoffs accurate

* fix(live): preserve poll reply metadata

* fix(live): treat event HTTP failures as failed sends

* fix(live): acknowledge manual completion through helper

* Add .impeccable project state paths

* Fix live disconnect recovery phase

* Refine live CSS authoring contract

* Test live CSS authoring guidance

* Harden live LLM E2E recovery

* Fix live recovery review issues

---------

Co-authored-by: Paul Bakaus <paulbakaus@pauls-mbp-3.lan>
2026-05-03 19:03:22 -07:00
88b82ae5f5 Remove Tessl skill review workflow (#136)
Co-authored-by: Paul Bakaus <paulbakaus@pauls-mbp-3.lan>
2026-05-03 11:06:58 -07:00
ea930268a8 docs(skill): apply STYLE.md to source/skills/impeccable (#135)
Follow-up to #134, which scoped validateProse to user-facing copy and
left the LLM-facing skill files alone. Bring those to the same bar,
phased so hardening repetition stays intact.

- Em dashes: 419 → 0 across SKILL.md and 35 reference files. Each
  replacement picks the right relationship (colon, semicolon, period,
  or parens) instead of letting the dash hide the choice.
- Closer cleanup: deleted or rewrote the "Remember:" sermonettes that
  were pure adjective chants (bolder/quieter/clarify/delight/extract/
  colorize/layout/typeset/audit/adapt). Survivors that load-bear an
  instruction now hand off to /impeccable polish instead of summarizing.
- Opener taglines: rewrote the "[Verb] [object] to [outcome]" brochure
  openers in 12 older files to lead with the failure mode, the
  strongest claim, or a directive. Newer files (live, brand, product,
  audit, critique, harden) kept their existing openers.
- data-driven: rephrased the two technical hits in live.md so the
  validator can stay strict on this term.
- validateSkillProse: narrow validator scoped to source/skills/impeccable/.
  Em-dash check + the small denylist of phrases with no technical
  reading. Hardening repetition and structural-prose rules are
  deliberately not enforced — those need human judgment.

Test failure on detectUrl is pre-existing (puppeteer needs --no-sandbox
when running as root); unrelated to these changes.

https://claude.ai/code/session_013zZY6rbB1bS8z3D63rX5hW

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-03 11:06:18 -07:00
122a82f715 docs: strip AI prose, add STYLE.md and validateProse (#134)
Site copy was being called out as AI slop (specifically the word
"load-bearing"). Five-pass cleanup with a build validator to keep it
from creeping back.

Pass 1 — mechanical purge:
- Remove "load-bearing" from impeccable.md, brand.md, live.md,
  iterate-live.md
- Remove "highest-leverage" from critique.md, typeset.md, designing
- Remove em dashes from all 9 slop-page rule cards
- Replace "leverage" verb in personas.md

Pass 2 — rewrite the worst offenders:
- Changelog v2.0 "Data-driven skill rewrite" entry: drop "data-driven",
  "frontier models", "collapses into monoculture", "biggest unlock",
  "reflex defaults"; name the actual mechanism
- README opener: drop "deeper expertise and more control"; replace with
  three concrete differentiators (7 reference files, 23 commands, 27
  detection rules)
- Neo Mirai case study opener: action-first, name the image model used

Pass 3 — editorials:
- Fix negation pivot in distill.md ("simplicity is not about ... It is
  about ...")

Pass 4 — homepage why-panels:
- Foundation lead: name the 7 reference files specifically
- Language lead: show the discipline mapping with real command names
- Production-codebases panel: drop "Impeccable isn't a sketchpad"
  negation pivot
- Ships-code panel: replace "is native to that world" with "runs there"

Pass 5 — STYLE.md and validator:
- New STYLE.md at root: editorial brief with 12 principles and the
  enforced denylist (each rule with a rationale and a suggested
  replacement)
- scripts/build.js: validateNoEmDashes becomes validateProse. Adds 21
  phrase rules with rationales, catches the \`--\` em-dash substitute,
  expands target list to site/pages, site/content, README.md,
  README.npm.md
- CLAUDE.md: replace the em-dash section with a STYLE.md pointer and
  document the two-content-tree footgun (content/site/ vs site/content/
  must be edited in lockstep until they're unified)

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-02 23:40:32 -07:00
eecdfa128e fix(site): style Astro-rendered <pre> blocks in prose bodies (#133)
The Astro migration switched fenced code blocks from the hand-written
`<div class="code-block-wrap"><pre class="code-block">` wrapper to
Astro+Shiki's auto-generated `<pre class="astro-code">`. The existing
CSS only targeted the legacy class names, so docs and tutorial code
blocks rendered with no padding, no border-radius, and no margin.
On top of that, the inner `<code>` inherited `.prose code`'s cream
pill styling and showed it through Shiki's dark theme.

Extends `.prose .code-block` rules to cover `.prose pre`, adds the
missing margin and max-width, and resets `.prose pre code` to drop
the inline-code background and border. Shiki's inline background
color still wins on `.astro-code`, so the box keeps its dark theme;
hand-written `.code-block` blocks on the case-study page get the
warmer oklch palette as before.

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-02 22:56:11 -07:00
ccf3573579 fix(site): restore .prose class on docs and tutorial bodies (#132)
The Astro migration (b8f09c8) replaced the old generator's
`<section class="skill-detail-editorial prose">` wrapper with
`<div class="skills-detail-body docs-body">`, dropping the prose
class. The .prose rules in sub-pages.css were left intact but no
longer applied, so markdown bodies fell back to default browser
margins — heading top-margin shrank from 2.2em to ~0.83em and
line-height from 1.7 to 1.6, which read as cramped vertical
rhythm on mobile.

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-02 22:31:26 -07:00
444e4acad3 Detector: add italic-serif display headline + hero eyebrow chip rules (#127) (#129)
* feat(detector): flag italic-serif display heroes and uppercase eyebrow chips (#127)

Two new rules covering the structural tells of late-2025/early-2026
AI-generated marketing pages.

- italic-serif-display: oversized italic serif (Fraunces, Recoleta,
  Newsreader, Playfair, Cormorant, Tiempos, ...) as the primary hero
  headline. Anchored on h1 (or h2 at >= 48px) with font-style: italic
  and a serif primary face.
- hero-eyebrow-chip: uppercase letter-spaced label sitting as the
  previousElementSibling of a hero h1 (font-size >= 48px). Bounded
  text length 2-30 chars, letter-spacing >= 1.6px, font-size <= 14px.
  The pill-chip variant (background + border-radius: 999px) falls out
  of the same gates for free.

Both follow the existing icon-tile-stack pattern: pure check function +
browser DOM adapter + jsdom adapter, wired into both element loops.
Two-column fixtures (4 flag / 6 pass each) drive the jsdom tests.

Skill copy in source/skills/impeccable/reference/typography.md and
critique.md calls out the patterns by name. The italic-serif rule's
description acknowledges that editorial/magazine register may legitimately
want the pattern -- judge by context.

Closes #127

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

* Add sandbox gotchas for Codex

* Trim verbose detector skill copy

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Paul Bakaus <paulbakaus@pauls-mbp-3.lan>
2026-05-02 12:01:43 -07:00
b8f09c8142 Migrate site from Bun to Astro (#130)
* feat(site): scaffold Astro migration, convert 3 pages

Phase 1+2 of the Astro migration:

- Astro v6.2.1 installed, srcDir: 'site', static output to build/
- Shared layout: Base.astro (head, fonts, meta, slots), Header.astro
  (star count in one place: 23k), Footer.astro
- CSS moved from public/css/ to site/styles/ (9 files, @import chains
  resolve via Vite)
- Three pages converted: privacy, cases/neo-mirai, live-mode
  (all return 200 on astro dev)

Remaining: designing, slop, homepage, content collections (docs),
JS migration, server/index.js deletion, build.js cleanup.

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

* feat(site): migrate all 6 main pages to Astro

Converts the remaining pages:
- designing/index.html → site/pages/designing/index.astro (551 lines)
- slop/index.html → site/pages/slop/index.astro (909 lines)
- index.html → site/pages/index.astro (1278 lines, the homepage)

Base.astro gains OG meta tag props, before-header/after-header
slots (for grain overlay and section nav), and configurable mainId.

Homepage uses link tags to public/css/ instead of frontmatter CSS
imports to avoid esbuild choking on :has() in main.css. Curly
braces inside <code> elements (CSS snippets in changelog) escaped
with HTML entities to prevent Astro JSX expression parsing.

All 6 pages return 200 on astro dev. Branch: feat/astro-migration.

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

* feat(site): content collections for docs and tutorials

Replaces the 1532-line build-sub-pages.js generator with Astro v6
content collections:

- 24 skill editorial files move to site/content/skills/
- 4 tutorial files move to site/content/tutorials/
- site/content.config.ts defines both collections with glob loaders
- site/pages/docs/[...slug].astro reads skills collection + command
  metadata from source/skills/ at build time
- site/pages/docs/index.astro renders the command grid grouped by
  category (create, evaluate, refine, simplify, harden, system)
- site/pages/tutorials/ mirrors the pattern with ordered index
- Doc.astro layout provides sidebar nav, breadcrumbs, and related-
  command chips from the COMMAND_RELATIONSHIPS data
- Category/relationship data extracted to site/data/sub-pages-data.ts

All 15 tested pages return 200: 6 main pages + 5 docs + 2 tutorials
+ 2 index pages. The old generator is not yet deleted (Task #6).

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

* feat(site): move JS source from public/js/ to site/scripts/

Moves all 49 JS files (app.js + 48 in js/) into site/scripts/.
Vite now processes them through its module bundler instead of
serving them raw from public/.

app.js import paths updated from ./js/X to ./X (the js/ nesting
is gone since app.js now lives alongside the subdirectories).

Homepage and live-mode page switch from <script is:inline src="/app.js">
to Vite-processed <script> imports, so tree-shaking, bundling,
and minification happen automatically at build time.

public/js/ still exists for now (cleanup in Task #6) and the
generated/counts.js build output path needs updating there too.
@paper-design/shaders added to npm dependencies (was missing).

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

* refactor(site): delete old Bun server, generator, and duplicated files

Cleanup after the Astro migration:

Deleted:
- server/index.js (233 lines, replaced by `astro dev`)
- scripts/build-sub-pages.js (1532 lines, replaced by content collections)
- scripts/lib/render-page.js (247 lines, replaced by Base.astro layout)
- content/site/partials/header.html (replaced by Header.astro component)
- public/index.html, privacy.html, designing/, live-mode/, cases/
  (replaced by .astro pages in site/pages/)
- public/css/ (moved to site/styles/)
- public/js/ old source files (moved to site/scripts/)
- public/app.js (moved to site/scripts/app.js)

Kept in public/:
- antipattern-examples/ (standalone HTML demos, not Astro pages)
- antipattern-images/, assets/, neo-mirai/ (static assets)
- js/detect-antipatterns-browser.js (referenced by antipattern examples)
- js/generated/counts.js (build output from scripts/build.js)
- _data/api/ (generated API data, now written to public/ so Astro
  passes it through to build/)

Updated:
- astro.config.mjs: added redirects (skills->docs, cheatsheet->docs,
  gallery->slop, neon-mirai->neo-mirai, etc.)
- package.json: dev->astro dev, build->build:skills+build:site,
  preview->astro preview
- scripts/build.js: removed buildStaticSite(), generateSubPages(),
  static-asset copying. API data writes to public/_data/ instead of
  build/_data/. Site-header validator is a no-op (shared component).
  Em-dash validator scans site/components + site/layouts, not pages
  (pages contain content from other sources like detector descriptions).
- .gitignore: removed public/slop/ entry

Tests: 186/186 pass. Skills build: clean.

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

* fix(site): fix redirect config for Astro compatibility

Move the dynamic /skills/:id -> /docs/:id redirect to public/_redirects
(Cloudflare Pages native format) since Astro's redirect config can't
handle dynamic routes that don't match existing page patterns.

Remove duplicate trailing-slash redirect entries that caused warnings.

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

* fix(site): switch remaining pages from /css/ link tags to frontmatter imports

Doc.astro, docs/index, tutorials/index, and tutorials/[slug] were
still using <link href="/css/sub-pages.css"> which pointed at the
deleted public/css/ directory. Switched to frontmatter CSS imports
(import '../../styles/sub-pages.css') which Vite resolves from
site/styles/.

Homepage also switches from link tags to frontmatter imports for
main.css and sub-pages.css — the esbuild error that originally
forced the link-tag workaround was caused by unescaped curly braces
in the HTML content (since fixed), not by the CSS itself.

All pages verified visually in Chrome: homepage hero, foundation
grid, docs index (card grid with categories), docs detail (sidebar +
editorial content + visual mockups), designing (core loop diagram),
privacy, tutorials. Header renders with 23k stars on every page.

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

* fix(site): fix edge-to-edge sections, broken API paths, CSS links

Three fixes:

1. Homepage sections sat on the viewport edge because Base.astro's
   <main> lacked the site-content class (provides max-width + padding).
   Added mainClass prop to Base.astro; homepage sets mainClass="site-content".

2. "Failed to load commands" because app.js fetched /api/commands
   which only existed in the old Bun server's routing. Updated to
   fetch from /_data/api/commands.json (the static JSON files that
   build:skills writes to public/_data/).

3. CSS reference fix (previous commit was incomplete): Doc.astro,
   docs/index, tutorials pages all used <link href="/css/sub-pages.css">
   pointing at deleted public/css/. Switched to frontmatter imports.

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

* fix(site): add sidebar to docs index page

The docs index was using Base.astro directly without the skills-layout
grid, so it rendered without a sidebar. Added the same sidebar structure
from Doc.astro (category-grouped command list) and wrapped the content
in the skills-layout grid.

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

* fix(site): extract footer CSS to shared file, import in Base.astro

Footer was unstyled on sub-pages because footer CSS lived only in
main.css (loaded by the homepage) not in sub-pages.css. Extracted
the 95 lines of footer rules into site/styles/footer.css and
imported it in Base.astro so every page gets footer styles regardless
of which page-specific CSS it loads.

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

* feat(demos): move landing-demo into repo, add as slop specimens

Moves ~/code/landing-demo/ into demos/landing-demo/ (without
node_modules or the redundant .claude/.agents skill copies — the
repo root's skill is found by walking up). PRODUCT.md, DESIGN.md,
DESIGN.json, PROMPT.md, and SCRIPT.md stay in place so running
Claude from demos/landing-demo/ picks up the project context.

Also copies both pages as slop specimens to public/antipattern-examples/
with the detector script baked in:
- new-slop-2026.html (Fraunces + warm cream editorial monoculture)
- old-slop-2022.html (purple gradient + glassmorphism + neon glow)

These can be linked from the slop page gallery alongside the
existing 11 synthetic specimens.

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

* feat(slop): replace single demo iframe with Then vs Now comparison

The "See it" section (01) on the slop page now shows two side-by-side
browser frames: 2022 slop (purple gradients, glassmorphism, neon glow)
and 2026 slop (Fraunces, warm cream, editorial restraint). Both run
the detector overlay live — hover either to see which rules fire.

Replaces the single visual-mode-demo.html iframe. Responsive: stacks
vertically on viewports below 900px.

Caption: "Same engine, different decade, both flagged."

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

* fix(slop): switch to single-frame era toggle, center the section

Replaces the side-by-side dual-iframe layout with a single large
frame and a segmented 2022/2026 toggle. Clicking the toggle swaps
which iframe is visible (both pre-loaded, instant switch). Browser
chrome title updates to match the active era.

Centers the lede text and toggle above the frame for visual
cohesion with the full-width iframe below.

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

* fix(slop): left-align See It section, toggle inline with lede

Moves the era toggle to the right of the lede paragraph using a
flex row (align-items: flex-end). Left-aligned text + right-docked
toggle matches the rest of the page's flow instead of standing out
as a centered island. Stacks vertically on narrow viewports.

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

* fix(slop): left-align iframe, remove max-width and auto margin

The visual-mode-preview had max-width: 1040px + margin: 0 auto
which centered it within the column. Override both in the
.slop-then-now context so the frame fills the full content width
flush with the text above. Caption left-aligned to match.

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

* chore(site): update star count to 24k (24,062)

One file, one edit. The Astro migration working as intended.

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

* fix(build): regenerate pnpm-lock.yaml for astro + shaders deps

Cloudflare Pages uses pnpm with frozen-lockfile. The lockfile was
stale after adding astro, @astrojs/cloudflare, and
@paper-design/shaders via npm.

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

* fix(build): resolve 3 bugbot review issues

1. Restore public/slop/ to .gitignore — prevents accidental legacy
   generator output from conflicting with the Astro page.

2. Move astro and @paper-design/shaders to devDependencies — these
   are site-build tools, not CLI runtime deps. Removes @astrojs/cloudflare
   entirely (unused; static output mode needs no adapter).

3. Fix Astro wiping build:skills output — CF config (_headers,
   _redirects, _routes.json) and API data now write to public/ so
   Astro copies them through. Dist ZIPs copy to build/_data/dist/
   as a post-build step (after Astro finishes).

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

* fix(build): merge duplicate devDependencies, use npx for astro CLI

The previous commit created a second devDependencies key in
package.json. JSON doesn't support duplicate keys — pnpm ignored
the first block (with astro), so `astro build` wasn't found.

Merged astro and @paper-design/shaders into the existing
devDependencies block. Changed `astro build/dev/preview` to
`npx astro build/dev/preview` so pnpm finds the local binary
on Cloudflare Pages (which doesn't add node_modules/.bin to PATH
by default).

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

* fix(demos): remove private demo script and prompt from public repo

SCRIPT.md contained a detailed conference talk script with personal
delivery strategies, rehearsed Q&A answers, and venue details.
PROMPT.md contained the origin brief for the demo page. Neither
belongs in a public repo.

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

* fix(build): gitignore generated public/ artifacts, consolidate redirects

1. Generated files written to public/ by build:skills (API data,
   CF config, browser detector, counts.js) are now gitignored.
   Prevents noisy diffs and merge conflicts from committed build
   artifacts.

2. Removed duplicate redirects from astro.config.mjs. All redirects
   now live in one place: the _redirects file generated by
   scripts/build.js (which Cloudflare Pages processes natively).
   Eliminates the dual-maintenance risk where the two sources
   could drift apart.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 11:29:10 -07:00
Paul BakausandClaude Opus 4.7 a312da5ec7 fix(site): update GitHub star count to 23k, add changelog highlight reel
Star count was 21k on sub-pages and the header partial, 22k on the
homepage. Updated all seven source files to 23k (actual: 23,692).

Changelog section gains a curated "Highlights since v3.0" block
above the full version history, which now collapses behind a
disclosure toggle. Fixes the vertical bloat from 9 entries in
three weeks while keeping v3.0's anchor content visible.

Also fixes a nesting bug where an orphan </div> from the old
changelog-list wrapper prematurely closed the changelog-section,
breaking the two-column changelog+FAQ grid layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 09:44:28 -07:00
Paul BakausandClaude Opus 4.7 8c4ea9f0fd chore(build): refresh harness output dirs for v3.0.6
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:32:13 -07:00
Paul BakausandClaude Opus 4.7 a08f808edb chore(skill): bump to v3.0.6 + changelog
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:31:23 -07:00
Paul BakausandClaude Opus 4.7 f4b2b1b0ca fix(skill): remove lane catalog from live departure mode, reinforce params
The seven-lane list in Phase C departure mode was acting as a menu:
the model ran "furthest from editorial" as its selection criterion and
converged on Swiss-grid / Terminal / Industrial-signage every time.
Replaced with a brand-voice derivation process (read personality
words, imagine physical experiences, derive visual directions).
Explicitly names the failure mode so the model can't fall into it.

Phase D family-pass labels are now open-ended nouns, not a fixed
vocabulary list that re-anchored the same categories.

Reinforced parameter generation: Phase C (both modes) now requires
naming 2-3 parameter knobs alongside each variant during planning,
not as an afterthought. The freeform bias paragraph aligns with
the budget table (2-3 for large compositions, not 1-2) and frames
0-param heroes as mistakes, not judgment calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:29:20 -07:00
Paul BakausandClaude Opus 4.7 64c6df216b fix(detector): contrast checks run on styled <a> and <button> (v1.0.3)
SAFE_TAGS skipped <a> and <button> categorically to avoid noise on
inline links and unstyled controls. The blanket skip overshot: a
pill-style anchor or styled button with its own opaque background
was silently exempted from the contrast check, so a "Get started"
button with charcoal text on near-black background (~2:1) read as
fine to both the CLI and the browser overlay.

The bail in checkColors now permits <a> and <button> when they have
their own opaque background AND direct text. Inline links and bare
controls keep skipping. checkElementColorsDOM no longer short-circuits
before reaching checkColors so the exception fires on the browser path.

Adds readOwnBackgroundColor() helper to handle jsdom's missing
shorthand decomposition; falls back to parsing the inline style attr
when getComputedStyle returns empty (real browsers always decompose,
so the fallback is a no-op there).

Fixture gains four cases: pill-style <a> low-contrast (flag),
<button> low-contrast (flag), inline <a> with no own bg (pass),
pill-style <a> with high contrast (pass). Three new tests assert
the right flags fire and the no-regression cases stay clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 11:51:38 -07:00
Paul BakausandClaude Opus 4.7 d154a5feb3 fix(extension): popup scan stuck on Scanning after page reload (v1.0.2)
The service worker's webNavigation.onCompleted handler only cleared
csInjected when DevTools was open. The popup-only flow never
registered the tab in devtoolsTabs, so a page reload left a stale
csInjected: true even though the content script had been destroyed.
The next popup scan saw the stale flag, skipped re-injection, and
silently sent its scan request to a tab with no listener — popup
UI got stuck on "Scanning..." indefinitely.

Reset is now unconditional (page reload always destroys the content
script regardless of which UI is open). Auto-rescan stays gated to
DevTools, since the popup is strictly user-driven.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 11:32:10 -07:00
Paul BakausandClaude Opus 4.7 ac2237a7a9 feat(skill): identity-first variant planning in /impeccable live
Restructures live.md Step 4 into four phases: identity extraction
(Phase A, non-skippable, falls back to CSS variables and computed
styles when DESIGN.md is absent), default vs departure mode pick
(Phase B), variant planning by primary axis or aesthetic lane
(Phase C), and a family-level squint test before the sentence
pass (Phase D). Default mode preserves identity and varies
expression; departure mode only triggers on explicit signals
(PRODUCT.md anti-references calling out the current surface, or
the user prompt asking for departure).

Adds reflex-reject aesthetic lanes to brand.md as a parallel to
the existing font reflex-reject list. Editorial-typographic is
the first entry. Expands SKILL.md's category-reflex check to two
altitudes (theme + palette from category, then aesthetic family
from category + anti-references).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 11:31:56 -07:00
Paul BakausandClaude Opus 4.7 b5c203f38a feat(detector): flag Fraunces, Geist, and the new monoculture fonts
Adds Fraunces, Geist (Sans/Mono), Mona Sans, Plus Jakarta Sans,
Space Grotesk, Recoleta, and Instrument Sans to OVERUSED_FONTS.
Brand-domain exceptions for Geist on vercel.com / nextjs.org /
v0.app and Mona Sans on github.com / githubnext.com.

Updates four should-pass fixtures that used the now-flagged faces
as "good examples"; switches to Newsreader + Karla. Adds positive
assertions for Fraunces and Geist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 11:31:38 -07:00
Paul BakausandClaude Opus 4.7 856b90e52b chore(release): print a tweet-ready string after a successful release
Pulls the bold lead text from each <li><strong>...</strong> in the
changelog entry as a tweet-grade summary, fits as many bullets as
possible under the 280-char limit (first highlight always wins since
it's already the most user-facing line), and prints inside a labeled box
with a live char count so the user can copy-paste into @impeccable_ai.

Adds tweetHeader and tweetCta to each component config (skill / cli /
extension); skill uses the npx skills install line, CLI uses npm i -g,
extension drops the CTA entirely (link to the release page is enough
since the user has to upload to Chrome Web Store separately).

Falls back to header + URL only if even the first highlight overflows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:01:34 -07:00
Dan DocaandGitHub c332c7aa91 feat(load-context): resolve context dir outside repo root (#119) (#123)
Adds a configurable lookup path for PRODUCT.md / DESIGN.md / DESIGN.json so
they don't have to live at the project root. Resolution order (first match
wins):

  1. process.env.IMPECCABLE_CONTEXT_DIR (absolute or relative to cwd)
  2. cwd, when canonical or legacy files are at the root (back-compat)
  3. Auto-fallback subdirs of cwd: .agents/context/ then docs/
  4. cwd as a default "no context found" location

Existing layouts (PRODUCT.md / DESIGN.md at repo root) keep working unchanged
- step 2 preserves the current behaviour. The auto-fallback covers the two
most common conventions seen in the wild (.agents/context/ for AGENTS.md
auto-import setups, docs/ for the request in the issue) without needing any
configuration.

Changes:
  - load-context.mjs: export resolveContextDir() and use it inside
    loadContext(); add contextDir to the JSON output
  - live-server.mjs: import resolveContextDir and read PRODUCT.md /
    DESIGN.md / DESIGN.json from the resolved dir instead of process.cwd()
  - SKILL.md: short note on the env var and fallback dirs in Setup -> Context
  - tests/load-context.test.mjs: 19 cases covering env var, fallbacks,
    legacy migration scope, and back-compat

Legacy .impeccable.md -> PRODUCT.md auto-migration stays scoped to cwd root;
fallback dirs are read-only as far as auto-rename is concerned.

Closes #119
2026-04-29 08:52:40 -07:00
9a5d0e71a9 fix(live): switch live-poll to execFileSync, validate ids strictly (#124)
* fix(live): switch live-poll to execFileSync, validate ids strictly

live-poll.mjs built the live-accept invocation with execSync and string
interpolation of event.id and event.variantId. Both fields originate in
the browser; validateEvent only checked truthiness, so shell metacharacters
in either field would land in the shell-parsed command.

Real exploitability is gated by the per-session token (loopback only,
unguessable UUID), so risk is low. The construction itself is structurally
unsafe though, and the fix is small.

- live-poll.mjs: execSync(string) → execFileSync('node', argv). Drops the
  hand-rolled single-quote wrap for --param-values; execFileSync passes
  each arg as a discrete argv slot, no shell parsing.
- live-server.mjs validateEvent: tighten id and variantId to match the
  actual generator shapes (8 hex chars and 1-3 digit numeric strings).
  Defense in depth so any value reaching downstream code is inert by
  construction.
- live-server.test.mjs: add three regression tests covering accept/discard
  rejection of shell-metachar ids and non-numeric variantIds. Update the
  three existing fixture ids to match the new pattern.

Reported in #122.

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

* chore: refresh pnpm-lock.yaml to match package.json

Cloudflare Pages runs pnpm install --frozen-lockfile and was failing on
ERR_PNPM_OUTDATED_LOCKFILE: the lockfile was missing entries for
@ai-sdk/anthropic, @ai-sdk/openai, @anthropic-ai/claude-agent-sdk,
@anthropic-ai/sdk, @google/genai, ai, modern-screenshot, zod, and had
stale specifiers for jsdom, marked, playwright, wrangler, puppeteer.

Drift was introduced when package.json was last edited without a lockfile
regen. Running pnpm install --lockfile-only resolves it; verified with
pnpm install --frozen-lockfile (clean install succeeds).

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

---------

Co-authored-by: Paul Bakaus <paulbakaus@pauls-mbp-3.lan>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 19:00:04 -07:00
7e0ce5e6b1 ci: add Tessl skill review on SKILL.md changes (#74)
Use tesslio/skill-review to run tessl skill review on changed skills and
post results on the PR. No Tessl account required; only GITHUB_TOKEN.

Made-with: Cursor

Co-authored-by: rohan-tessl <rohan-tessl@users.noreply.github.com>
2026-04-28 18:27:09 -07:00
18fa503d44 fix: normalize quoted user-invocable frontmatter (#87)
* fix: normalize quoted user-invokable frontmatter

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix: preserve quoted non-boolean frontmatter values

Only normalize quoted booleans for the user-invocable frontmatter flag so other quoted fields like argument-hint and description continue to round-trip as plain strings.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

---------

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-28 18:19:50 -07:00
xiaolaiandGitHub 04709eadf0 security: use JSON.stringify for selector escaping in devtools panel (#93)
The inspectElement function previously used manual replace() chains to
escape backslashes and single quotes in CSS selectors before passing
them to chrome.devtools.inspectedWindow.eval(). This escaping was
incomplete: selectors containing crafted sequences of special characters
(backticks, newlines, Unicode escapes) could break out of the string
literal and inject arbitrary JS into the inspected page context.

JSON.stringify produces a properly escaped JS string literal that handles
all special characters, eliminating the injection surface entirely.
2026-04-28 18:09:54 -07:00
Paul BakausandGitHub efedf2d3d3 Merge pull request #97 from Gujiassh/fix/community-section
docs: add community & ecosystem section
2026-04-28 18:05:56 -07:00
Paul BakausandGitHub f67add25e5 Merge pull request #120 from vinaypokharkar/feature/qoder-support
feat: add Qoder harness support
2026-04-28 17:55:52 -07:00
Paul Bakaus 6b507e0c6a Release impeccable skill v3.0.5
Live-mode bug-fix release. Closes #114, with thanks again to @dergachoff.

- Live mode lands valid TSX through the wrap → preview → accept →
  carbonize loop on Vite/Next React/TSX projects, including repeated
  sibling branches and JSX `asChild` parents (Radix `<Title>` etc.).
- Wrap correctly disambiguates repeated identical-class siblings via
  the picked element's textContent (`--text` flag).
- Carbonize CSS no longer double-wraps in nested template literals on
  TSX targets.
- JSX accept/discard restores at the picked element's original indent;
  relative depth between lines preserved through the round trip.
- Screenshot overlay during loading no longer flashes solid black on
  default-background pages.
- live-inject's CSP-meta patch+revert byte-for-byte preserves
  self-closing tag whitespace.
- live.md gained explicit guidance on `:scope` descendant combinators
  (authoring trap), the new `--text` flag, JSX `<style>` template-
  literal wrapping, and the agent-side abort path.
2026-04-28 17:45:20 -07:00
Paul BakausandGitHub c1e1104e31 Merge pull request #118 from pbakaus/feat/live-jsx-wrap-and-carbonize
fix(live): land valid TSX through wrap → preview → accept → carbonize
2026-04-28 17:34:00 -07:00
Paul BakausandClaude Opus 4.7 1f760aff61 fix(live): expandReplaceRange handles multi-line self-closing JSX <div />
Cursor Bugbot review on 8660d3a flagged a real corruption bug:

> Multi-line self-closing div breaks depth tracking in expandReplaceRange.
> The forward div-depth walk applies openRe / selfCloseRe / closeRe
> per-line. A multi-line `<div\n  className="spacer"\n/>` causes openRe
> to match the opener line but selfCloseRe fails on both lines because
> `/<div\b[^>]*\/\s*>/` requires the full tag on one line. Depth is
> permanently over-counted by 1, so the walk overshoots.

Trace on the JSX-marker-inside-wrapper layout:
- Inside the wrapped element, a multi-line `<div … />` increments depth
  at the `<div` line and never decrements.
- Forward walk's depth never returns to 0 → end stays at block.end (the
  inner marker comment) → replace range stops there.
- Wrapper's outer `</div>` is left orphaned in the file after
  accept/discard, breaking the JSX. Worse: an unrelated subsequent
  `<div className="next-card">…</div>` sibling gets its `</div>`
  mis-counted as the wrapper close, and the depth walk corrupts further.

Fix: rewrite the forward walk on JOINED text instead of per-line. A
single regex `/<div\b[^>]*?(\/?)>|<\/div\s*>/g` spans newlines (because
`[^>]` matches `\n`), so it correctly identifies multi-line opens,
closes, AND self-closes. Convert the match offset back to a file line
index to set `end`. Walk-back logic for the wrapper opener is
unchanged.

Test coverage:
- New `expandReplaceRange handles multi-line self-closing <div />` test
  in live-accept.test.mjs constructs the exact Bugbot scenario: a
  multi-line `<div\n  className="spacer"\n/>` inside the picked
  element AND an unrelated `<div className="next-card">After</div>`
  sibling right after. Asserts the discard removes ALL impeccable
  markers / wrapper attrs, preserves the next-card sibling intact, and
  the multi-line `<div />` survives inside the restored content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:19:07 -07:00
Paul BakausandClaude Opus 4.7 8660d3aa22 fix(live): wrap shape-of-output bugs from second Bugbot review
Two more Cursor Bugbot findings on commit 11dfad81:

1. `filterByText` short-text returned the wrong sentinel value.
   When the trimmed snippet was shorter than 8 chars, the function
   returned `candidates.slice()` (all candidates). The caller then
   sees `filtered.length > 1` and fires `element_ambiguous` — exactly
   the opposite of the documented short-text fallback ("caller falls
   back to first-match," which corresponds to `filtered.length === 0`).
   So any picker event with a short textContent on a page with multiple
   matching siblings spuriously errored.
   Fix: return `[]` for short text, matching the JSDoc.

2. `endLine` in the wrap output was wrong for multi-line picked elements.
   `wrapperLines.length` counts ARRAY elements, but one element is a
   `\n`-joined multi-line string (originalIndented). The actual
   wrapper-region row count is `wrapperLines.length + (originalLines.length
   - 1)`. Reporting `endLine = startLine + wrapperLines.length` placed
   the boundary inside the wrapper for any multi-line pick, giving
   downstream agents an incorrect range.
   Fix: add the originalLines offset (matching what `insertLine` already
   does after the prior commit).

Test coverage:
- `short --text falls back to first-match instead of erroneously firing
  element_ambiguous` covers fix #1.
- `returns endLine that includes the multi-line original content offset`
  covers fix #2 by wrapping a 5-line <section> in a real HTML file and
  asserting the reported endLine points at the variants-end marker (and
  the next line is </main>, proving no rows were missed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:52:02 -07:00
Paul BakausandClaude Opus 4.7 11dfad81da fix(live): CSP-meta patch+revert preserves space before self-closing /
Sanity-check on the live-inject unwrap path turned up a real round-trip
bug on HTML files that ship a `<meta http-equiv="Content-Security-Policy"
content="..." />` tag (the leading space before `/>` is the canonical
self-closing form).

Trace:
- The tag-finder regex (`<meta\s+([^>]*?)\/?>`) captures any whitespace
  between the last attribute and the closing `/>` as part of `attrs`.
- patchCspMeta did `attrs.replace(content, newContent) + ' ' + marker`,
  appending the marker AFTER that captured trailing whitespace. Result:
  `...content="..."  data-...="..."` — a double space inside attrs and
  the original space-before-slash gone.
- revertCspMeta then strips the marker via `\s*${origAttr.full}`, which
  greedily eats both spaces — so the round trip leaves `"/>` with no
  space, even though the original was `" />`.

Fix: split off the trailing whitespace from `attrs` before patching,
splice the marker into the attribute body with a single leading space,
and re-append the original trailing whitespace. The marker-removal
regex then consumes exactly one space and the trailing space rides
through unchanged.

Test coverage:
- New `round-trips through CSP-meta patch and revert` test in
  live-inject.test.mjs covers the canonical Vite shape (CSP meta with
  ` />`).
- Plus a `round-trips with insertAfter` test for symmetry — the existing
  suite only covered insertBefore.
- Existing 4 round-trip tests (HTML, JSX layout, multi-file, column-0)
  all still pass byte-for-byte.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:03:14 -07:00
Paul BakausandClaude Opus 4.7 a701ee613a fix(live): wrap preserves relative indent of multi-line picked elements
Companion to the prior outer-indent fix. live-wrap.mjs's
`originalLines.map(l => indent + '    ' + l.trimStart())` calls
`trimStart()` on every line, which strips ALL leading whitespace and
collapses multi-line picked elements to a uniform indent. So a 6/8/6
shape like

    <aside className="card">
      <h1 className="hero-title">Hero</h1>
    </aside>

was being reindented to 10/10/10 inside the wrapper, and on
accept/discard the round-trip restored 6/6/6 — the <h1> ended up at
its parent's depth instead of nested inside it.

Fix: extract `minLeadingSpaces(lines)` and strip only the COMMON
minimum across the picked lines before reindenting under the wrapper.
That mirrors how `deindentContent` on the accept side already works,
so wrap+accept now form a clean round-trip.

Test coverage:
- Expanded the indent regression test in live-accept.test.mjs to
  also assert the inner `<h1>` at 8-space indent and the closing
  `</aside>` at 6 — proving the relative depth survives wrap and
  discard end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:59:07 -07:00
Paul BakausandClaude Opus 4.7 99e68370b9 fix(live): JSX accept/discard restores at original indent (Bugbot review)
Cursor Bugbot caught this on PR #118 review:

> JSX discard/accept restores content with wrong indentation. In the JSX
> path, `indent` is captured from `lines[block.start]` — the marker comment
> line inside the wrapper div, which is indented 2 extra spaces relative
> to the original element. But `expandReplaceRange` expands the replacement
> to include the outer `<div data-impeccable-variants>` wrapper, which sits
> at the original element's indent level. `deindentContent(original, indent)`
> restores content to the marker's deeper indent, so all restored lines end
> up 2 spaces deeper than the original element was.

I'd actually noticed the symptom during the live testing session ("some
odd indentation in card-2 after discard") and dismissed it as cosmetic.
Bugbot's analysis matches exactly.

Fix: anchor the deindent base on `replaceRange.start` instead of
`block.start`. For HTML the two are identical (markers sit outside the
wrapper), so HTML is unchanged. For JSX `replaceRange.start` is the
outer `<div>` at the original element's indent — correct base.

Also dropped a duplicate `expandReplaceRange` call in handleAccept that
the earlier edit left orphaned.

Test coverage:
- Two new regression tests in live-accept.test.mjs:
  - `discard restores JSX content at the original indent` runs the
    real wrap CLI and asserts the restored <aside> opener lands at
    its original 6-space indent (was 8 before the fix).
  - `accept (no carbonize, raw HTML) restores at the original indent
    on JSX` exercises the same anchor on the accept path.
- Inner-element indent loss inside the wrapped content (`<h1>` ending
  up at the same indent as its parent `<aside>`) is a separate,
  pre-existing wrap behavior — left for a follow-up; explicitly
  noted in the test comments.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:55:27 -07:00
Paul BakausandClaude Opus 4.7 fdb9e7c6f8 fix(live): screenshot overlay no longer flashes solid black during loading
Same alpha-string trap pattern as the recent detectPageTheme fix, on a
different code path. resolveCanvasBackground walks parents looking for
an opaque background; on a page that doesn't set its own bg the loop
runs out and fell through to:

  return getComputedStyle(document.body).backgroundColor
    || getComputedStyle(document.documentElement).backgroundColor
    || '#ffffff';

`getComputedStyle(body).backgroundColor` for a default-bg page returns
the literal string "rgba(0, 0, 0, 0)" — non-empty, truthy — so the `||`
chain short-circuits to transparent-black instead of falling through to
'#ffffff'. modern-screenshot then composites the capture onto a black
canvas; the WebGL shader overlay flashes solid black until the shader
finishes loading.

Fix: drop the buggy fallback. The while-loop already covered <body> and
<html>; if neither is opaque the only sensible answer is the browser's
default canvas color (white).

Test coverage:
- New tests/live-browser-regression.test.mjs pins the anti-pattern
  with a static-source check (live-browser.js is an IIFE with no module
  exports, so this is the cheapest reliable regression guard). Also
  pins the equivalent guard for detectPageTheme's readOpaque helper
  added in the prior commit.
- Wired the new test file into `bun run test`'s explicit list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:40:24 -07:00
Paul BakausandClaude Opus 4.7 9ec904302b fix(live): textContent disambiguation handles missing inter-element whitespace
While driving the new live loop end-to-end against the repeated-aside
fixture, --text disambiguation silently fell back to first-match instead
of landing on the picked card.

Root cause: `el.textContent` concatenates child text nodes without
inserting whitespace, so `<h1>Hero Two</h1><p>Second card body copy.</p>`
reads as "Hero TwoSecond card body copy." — but the source has whitespace
between </h1> and <p>. The single-space normalization on both sides
missed the join boundary; substring comparison failed; filterByText
returned [] and the caller fell through to first-match.

Fix: filterByText now compares both single-space AND no-whitespace
normalizations on each side, accepting the candidate if EITHER matches.
Bumped the minimum-target-length threshold from 6 to 8 to compensate
for the slightly looser comparison.

Plus two doc clarifications surfaced during the same session:

- live.md now warns that variant CSS using bare `:scope { ... }` styles
  the variant wrapper div, not the picked element. Always use a
  descendant combinator (`:scope > .card`, `:scope .hero-title`, etc.) —
  the fake test agent's CSS is the canonical template.
- live.md documents the agent-side abort path. Aborting an in-flight
  generate via `live-accept --discard` only mutates source — the browser
  bar stays in GENERATING forever. Use `live-poll --reply EVENT_ID error
  "msg"` instead so the browser receives the error SSE and resets.

Test coverage:
- New unit test in live-wrap.test.mjs covering the textContent-without-
  inter-element-whitespace shape (three identical <aside> branches each
  with <h1> + <p>, picks the second by --text).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:03:49 -07:00
Paul BakausandClaude Opus 4.7 54d9f05ea5 fix(live): land valid TSX through wrap → preview → accept → carbonize
Closes #114.

Three orthogonal bugs that surfaced together when live mode picked an
element inside a Vite React/TSX component with sibling branches:

1. JSX wrapper insertion produced invalid TSX
   - Replacing a single picked JSX child with [comment, <div>, comment]
     yields three adjacent siblings, which oxc rejects with "Adjacent
     JSX elements must be wrapped in an enclosing tag."
   - A Fragment `<></>` solves the adjacency case but breaks
     `cloneElement`-using parents (Radix `asChild`, Headless UI, etc.)
     with "Invalid prop supplied to React.Fragment."
   - Fix: keep the wrapper `<div data-impeccable-variants="ID">` as the
     single JSX-slot child and tuck both marker comments INSIDE it.
     accept/discard now expands its replacement range to include the
     wrapper's `<div>` open/close lines via div-depth tracking.

2. carbonize produced nested template literals in TSX `<style>`
   - extractCss captured `{` / `` `} `` lines from the agent's existing
     `<style>{`…`}</style>` template, then handleAccept re-wrapped with
     another pair, producing `<style>{`{`@scope…`}`}</style>` which oxc
     rejects with "Expected `}` but found `@`".
   - Fix: extractCss now strips a leading `{` and trailing `` `} ``
     wherever they appear in the captured content (own line OR attached
     to the first/last CSS line), so re-wrapping always yields exactly
     one `{` ` … ` `}` pair.

3. Ambiguous source matching for repeated JSX branches
   - `findElement` returned the first substring match. Multiple
     `<aside className="card">` siblings all matched the same query, so
     wrap silently landed on the first regardless of which one the user
     picked.
   - Fix: live-wrap accepts `--text TEXT` (the picked element's
     textContent), collects ALL candidates via `findAllElements`, and
     narrows by a tag-stripped, JSX-expression-stripped substring match.
     Returns `element_ambiguous + candidates[]` when multiple branches
     match equally; falls back to first-match when source uses dynamic
     content (`<h1>{title}</h1>`) so existing flows aren't broken.
   - The fake e2e agent now forwards `event.element.textContent` to
     wrap, and live.md tells the agent to do the same.

Test coverage:
- New `vite8-react-tsx-repeated-aside` e2e fixture: three identical
  `<aside>` branches, picks the second card's <h1>, runs the full
  wrap → Go → cycle → accept → carbonize cycle on a real Vite + TSX
  dev server, asserts that Hero One and Hero Three survive untouched
  (proving wrap landed on the correct branch).
- Six new unit tests across live-wrap.test.mjs and live-accept.test.mjs
  covering the Fragment-replacement design, both leading/trailing
  template-literal placements, --text disambiguation, the dynamic-
  content fallback, and the element_ambiguous error shape.
- New `runtime.assertSourceContains` fixture hook so other regression
  fixtures can assert sibling-branch survivability cheaply.

All 186 unit + static-fixture tests pass; all 21 live e2e fixtures
(20 prior + new TSX) pass with no console errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 13:23:20 -07:00
Paul BakausandClaude Opus 4.7 638af20566 Document the release workflow in CLAUDE.md and AGENTS.md
Covers the per-component tag prefixes, the changelog-label convention
that the release script matches against, the cleanliness gates, the
attached artifacts, and the manual post-release steps for the CLI
(npm publish) and the extension (Chrome Web Store upload).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 11:03:34 -07:00
Paul BakausandClaude Opus 4.7 5881a0843b Thank @dergachoff for #113 in v3.0.4 changelog
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:48:06 -07:00
Paul BakausandClaude Opus 4.7 27af49f190 Strip leading whitespace in release-notes markdown extraction
The HTML changelog source lives 12 spaces deep inside its containers,
so list items emitted by htmlToMarkdown carried that indentation. Four
or more leading spaces in markdown is a code block, so all bullets
after the first (which the final .trim() rescued) rendered as code on
the GitHub release page. Strip leading whitespace per line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:44:49 -07:00
Paul BakausandClaude Opus 4.7 bf2bc55aa1 Fold v3.0.3 changelog into v3.0.4
v3.0.3 was never installable as a distinct version: the manifest jumped
3.0.2 → 3.0.4 in a single commit (5f5e2b0), so plugin users picked up
the craft/shape hardening and the modal-host live-picker fix together
with the 3.0.4 work. Merging the changelog matches what actually
shipped and keeps a single GitHub release for that batch of changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:40:42 -07:00
Paul BakausandClaude Opus 4.7 a923346bcc Add release tooling and bump CLI to 2.1.8
- scripts/release.mjs tags and publishes GitHub releases for the three
  independently versioned components (skill, cli, extension). Refuses on
  dirty tree, unpushed HEAD, missing changelog entry, or stale build
  outputs. Skill release attaches dist/universal.zip; extension release
  runs build:extension and attaches dist/extension.zip. Prints a manual
  next-step hint for npm publish (CLI) and Chrome Web Store upload.
- package.json: bump CLI to 2.1.8, add release:{skill,cli,ext} scripts.
- public/index.html: add CLI v2.1.8 changelog entry covering the
  Windows path fix (#95) and border-radius detector hardening. Adopt
  "CLI v" / "Extension v" prefix convention to disambiguate components
  in the shared changelog timeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 10:05:29 -07:00
VinaywhoandClaude Opus 4.7 c812d76b6f feat: wire qoder into the download API allowlist
Add qoder to FILE_DOWNLOAD_PROVIDER_CONFIG_DIRS so the download endpoint
accepts /api/download/skill/qoder/* and resolves to dist/qoder/.qoder/.
Without this, the website install surface returned 400 Invalid provider
even though qoder was a first-class harness everywhere else.

Cover the new provider with two assertions in download-validation.test.js
(allowlist + path resolution).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:00:30 +05:30
VinaywhoandClaude Opus 4.7 7cfa7759f5 fix: parseFrontmatter handles CRLF line endings
On Windows checkouts, SKILL.md may have CRLF line endings, which caused
parseFrontmatter to fall back to {} and the build to prepend a fresh
frontmatter block while leaving the original one in the body. The result
was the dist Qoder SKILL.md shipping with two frontmatter blocks and
losing user-invocable / argument-hint / license / allowed-tools metadata.

Make the regex and YAML line split CRLF-tolerant. Regenerated tracked
.qoder/skills/impeccable/SKILL.md is now a single, well-formed
frontmatter block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:00:19 +05:30
VinaywhoandClaude Opus 4.7 9a76c7bc17 fix: include qoder in normalizeForHash provider regex
PROVIDER_DIRS got the .qoder entry but the path-normalization regex in
normalizeForHash() did not. Without it, .qoder/skills/ paths in SKILL.md
are not collapsed to the synthetic .PROVIDER/skills/ token during
update-detection hashing, so a Qoder install can hash differently from
the upstream bundle and trigger spurious "update available" results
when symlinked provider dirs are involved.

Caught by Cursor Bugbot review on c6ca721.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:32:25 +05:30
VinaywhoandClaude Opus 4.7 c6ca721786 docs: add Qoder to README install and supported-tools sections
Adds the project-and-global cp install snippet (matches Pi/Rovo Dev
shape since Qoder supports both .qoder/skills/ and ~/.qoder/skills/
per docs.qoder.com/extensions/skills) and adds qoder.com to the
Supported Tools list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:17:59 +05:30
VinaywhoandClaude Opus 4.7 4f66eb9c08 feat: add Qoder harness support (closes #76)
Qoder ships an Agent Skills system at .qoder/skills/{name}/SKILL.md with
slash-command invocation, mapping cleanly onto the existing transformer
pipeline. Adds Qoder as a 13th first-class harness:

- PROVIDER_PLACEHOLDERS entry in scripts/lib/utils.js (model, config_file,
  ask_instruction, command_prefix) mirroring the Pi/Rovo Dev shape.
- PROVIDERS entry in scripts/lib/transformers/providers.js with
  configDir=.qoder and the OpenCode/Claude Code frontmatter field set
  (user-invocable, argument-hint, license, compatibility, metadata,
  allowed-tools), since Qoder docs explicitly support those.
- transformQoder named export in scripts/lib/transformers/index.js for
  test-spy parity (kept per CLAUDE.md guidance, even though build.js uses
  PROVIDERS directly).
- .qoder added to PROVIDER_DIRS in bin/commands/skills.mjs so the CLI
  detects existing Qoder installs.
- HARNESSES.md updated: official docs row, frontmatter support column,
  directory structure row, and "Last verified" date bumped.
- DEVELOP.md reference link added.
- .github/ISSUE_TEMPLATE/feature_request.md and PULL_REQUEST_TEMPLATE.md
  extended with Qoder in the provider checklists.
- Built .qoder/skills/impeccable/ tree committed (per CLAUDE.md harness
  output dirs are tracked so npx skills can read them at install time).

The dynamic providers.test.js loop picks up Qoder automatically; all
non-prefix Qoder cases pass. The pre-existing Windows-only prefix-test
flake affects every provider equally and is out of scope for this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:16:12 +05:30
Paul Bakaus 5f5e2b013d Release impeccable skill v3.0.4 2026-04-28 00:16:30 -07:00
Paul Bakaus 54f6ccf6f0 codex in auto-review became way too autonomous - significantly harden craft/shape flows 2026-04-27 23:41:23 -07:00
Paul BakausandGitHub 62ce35ac8e Merge pull request #116 from pbakaus/feat/live-modal-host-friendliness
feat(live): make picker chrome modal-host friendly (Radix, Headless UI, vaul)
2026-04-27 16:39:39 -07:00
Paul BakausandClaude Opus 4.7 630e586b01 feat(live): make picker chrome modal-host friendly (Radix, Headless UI, vaul)
Closes #113.

Picker chrome could become unclickable inside Radix Dialog portals, and
clicking it dismissed the host dialog. Three orthogonal issues surfaced
during manual verification:

1. Modal-aware chrome
   - Add `defangOutsideHandlers` and apply it to bar, picker, params
     panel, annotation overlay, global bar, and design panel host.
   - Sets `pointer-events: auto !important` on interactive chrome so
     Radix's `body { pointer-events: none }` modal scroll-lock can't
     silence our UI.
   - Stops `pointerdown` / `mousedown` / `focusin` propagation at the
     chrome boundary so DismissableLayer / FocusScope outside-handlers
     never fire for clicks that land on us.

2. detectPageTheme: misread transparent body as black
   - `getComputedStyle(body).backgroundColor` returns `rgba(0,0,0,0)`
     when no bg is set; the prior regex captured (0,0,0) and ignored
     alpha, calling every default-bg page "dark."
   - Honor alpha, walk body → html, fall back to
     `prefers-color-scheme` only when both are transparent.

3. Exit X invisible on host pages with `button { padding: ... }`
   - Every other chrome button sets padding inline; exitBtn didn't.
     Host resets like `button { padding: 0.5rem 1rem }` (in the new
     fixture, common in the wild) inflated the 24x24 button into 56x40
     and pushed the SVG into a non-rendering region — DevTools showed
     the right styles, the X just didn't paint.
   - Pin `padding: 0` + `box-sizing: border-box`, match the toggle
     icon spec (14 / stroke 1.5 / textDim → text on hover).

4. Toast no longer obscures the global bar
   - Position the toast above globalBarEl's actual rect instead of a
     fixed bottom: 16px that overlapped the bar's bottom: 14px.

Test coverage: new `vite8-react-radix-dialog` fixture exercises the
full pick → Go → cycle → Accept loop with `@radix-ui/react-dialog`
+ `Portal` + `Overlay` + `Content`. Without the fix, clicking Go
dismisses the dialog and unmounts the picked element. All 20 live
e2e fixtures pass; all 180 unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 16:22:00 -07:00
Paul BakausandGitHub 39bec7c08c Merge pull request #115 from pbakaus/feat/harden-jsdom-border-radius
feat(detector): harden border-radius reads against jsdom CSS regressions
2026-04-27 14:55:12 -07:00
Paul BakausandGitHub e3d488e123 Merge pull request #101 from voidborne-d/fix/windows-detect-path-drive-letter
fix: use fileURLToPath for Windows path resolution
2026-04-27 14:49:13 -07:00
Paul BakausandClaude Opus 4.7 668263843f test: wire windows-path-fix into bun test script + rebase notes
- Added tests/windows-path-fix.test.js to package.json's test script so
  the regression suite actually runs in CI; without this the file lived
  on disk but no command picked it up. Verified with bun run test:
  170 bun tests / 3 files, all green.
- Rebased onto current main. The PR's second hunk (live-mode browser
  script load) no longer applies because that code path was extracted
  into source/skills/impeccable/scripts/live-*.mjs during the live-mode
  rewrite. The remaining puppeteer site at line 2700 still had the bug
  and now uses fileURLToPath, matching the PR's intent.
- The added test file's mix of ESM imports + require/__dirname runs
  cleanly under Bun's test runner; left as-is to preserve the PR's
  authorship.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:46:58 -07:00
voidborne-dandPaul Bakaus 94b315ef63 fix: use fileURLToPath for Windows path resolution (#95)
On Windows, `new URL(import.meta.url).pathname` returns `/C:/...`
(with a leading slash). Passing that to `path.resolve()` or
`path.join()` causes Node to prepend the drive letter again, producing
doubled paths like `C:\C:\Users\...\detect-antipatterns-browser.js`.

Replace both occurrences (puppeteer scan at ~L2690 and live detect at
~L3506) with `fileURLToPath(import.meta.url)` from `node:url`, which
correctly strips the leading slash on Windows while remaining a no-op
on POSIX.

Add regression tests verifying the source no longer uses the raw
`.pathname` accessor for local path construction and that
`fileURLToPath` handles both Windows and POSIX file URLs correctly.

Closes #95
2026-04-27 14:44:25 -07:00
Paul BakausandClaude Opus 4.7 28875097b0 fix(detector): preserve percent-radius signal when width is missing
parseRadiusToPx("50%", 0) used to return 0, and resolveBorderRadiusPx's
"if (fromComputed !== null) return fromComputed" guard short-circuited
with that 0 before ever consulting longhand / inline / stylesheet
fallbacks. Callers that gate on `> 0` (border-accent-on-rounded and
isCardLike's hasRadius) silently lost findings the old
parseFloat(style.borderRadius) === 50 heuristic happened to keep.

In jsdom this is reachable any time style.width resolves to "auto" or
an empty string — parseFloat yields NaN, the `|| 0` fallback turns it
into 0, and any percent radius collapses to nothing. Real-world cards
with `width: 100%` hit this on every load.

Fix: when widthPx is 0 / missing, return the raw percentage number
instead. The percent-to-px conversion only makes sense with a width
reference; without one, the value still serves as a positive presence
signal for boolean checks. The icon-tile circle exclusion is
unaffected because that rule already gates on `siblingWidth >= 32`.

Caught by Cursor Bugbot on PR #115.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:33:54 -07:00
Paul BakausandClaude Opus 4.7 65bbd6cb5f feat(detector): harden border-radius reads against jsdom CSS regressions
Adds resolveBorderRadiusPx(el, style, widthPx, win), a helper that walks
computed style → longhand → inline DOM → raw style attribute → matching
stylesheet rules to recover a pixel value, converting % to px when
needed.

Three jsdom adapter sites now use it: checkElementBorders (via a new
optional resolvedRadius param threaded from detectHtml), the icon-tile
sibling check in checkElementIconTile, and isCardLike's hasRadius gate.
Browser DOM adapters hit the fast path on the first line since real
getComputedStyle resolves both shorthand and percentages.

Background: from jsdom 29.0.2 onward, getComputedStyle(el).borderRadius
returns "" for the shorthand and "0" for longhand reads when the rule
used the shorthand. checkIconTile relied on parseFloat(borderRadius) >=
width/2 to exclude circular avatars; that comparison broke and circles
got false-flagged as icon-tile-stack. jsdom 29.1.0 has a separate
parser crash on <h*> + linear-gradient inline style which keeps the
pin at exactly 29.0.0 for now, but landing the helper means we can
move forward as soon as the gradient crash is fixed upstream without
touching detector code again.

The change is also strictly more correct than the old parseFloat
approach: percentage values now convert to actual pixel sizes, so
checkIconTile no longer relies on parseFloat("50%") == 50 happening
to satisfy `>= width/2` only for elements <= 100px wide.

bun run test passes (174/174); bun run build:browser and
bun run build:extension regenerated to mirror the helper into
bundled artifacts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 14:09:49 -07:00
17fe31baa9 chore: bump in-range deps; pin jsdom to 29.0.0
- @ai-sdk/anthropic 3.0.69 → 3.0.71
- @anthropic-ai/claude-agent-sdk 0.2.110 → 0.2.119
- ai 6.0.162 → 6.0.168
- playwright 1.58.2 → 1.59.1
- wrangler 4.75.0 → 4.85.0
- puppeteer 24.39.1 → 24.42.0 (optional)
- marked range floor bumped to 16.4.2 (already installed)

jsdom is intentionally pinned to exact 29.0.0. From 29.0.2 onward,
getComputedStyle(el).borderRadius returns "" (empty string) instead
of "50%" for percentage values that the engine can't resolve to px
without layout. checkIconTile relies on parseFloat(borderRadius) ≥
width/2 to exclude circular avatars; with the empty string, all
circles get re-flagged as icon-tile-stack. Real browsers resolve
the percentage so the public-site overlay and Chrome extension are
unaffected — only the Node/jsdom path used by `npx impeccable detect`
on HTML files breaks. Hardening the detector to read raw stylesheet
rules as a fallback is a follow-up; pinning is the safe move today.

Skipped: marked 16 → 18 (major bump, unrelated to this work).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:59:06 -07:00
427128e073 chore: reconcile bun.lock with @anthropic-ai/sdk ^0.91.1
package.json was updated to ^0.91.1 in d26ccac (live-mode E2E LLM
agent), but the lockfile was not committed alongside. The next
bun install bumped @anthropic-ai/sdk from 0.81.0 to 0.91.1 to match
the declared range. The nested resolution under
@anthropic-ai/claude-agent-sdk stays at 0.81.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:59:06 -07:00
70a9246401 fix(plugin): slim Claude Code install (291MB→770KB) + fix skills path
- Marketplace source moved from "./" to "./plugin", a thin generated
  subtree containing only the plugin manifest and the impeccable skill.
  Per-version plugin cache shrinks ~378× (~770 KB instead of ~291 MB),
  and the lockfile is no longer included in the source path so the
  cache extraction never runs bun install. (#107)
- skills field in plugin.json now ends with a trailing slash to match
  the documented schema (code.claude.com/docs/en/plugins-reference,
  every directory example uses ./path/). Three reporters converged on
  this fix because Claude Code's plugin loader skips command
  registration on some setups when the slash is missing. (#86)
- Anti-patterns maintenance agent moved out of .claude/agents/ into
  CLAUDE.md / AGENTS.md as concise inline guidance, since it is
  repo-internal dev workflow, not user-facing. The plugin was also
  the only place this agent was exposed to install users.
- Skills version bumped to 3.0.2 so existing users pick up the new
  install path on next /plugin update.
- Top-level harness directories (.claude/skills/, .cursor/skills/, ...)
  intentionally stay where they are; npx skills add reads them
  directly from the GitHub repo and that path is unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:59:06 -07:00
Paul BakausandGitHub 8548003cc1 Merge pull request #111 from vivshaw/main
Rename all references to `Neon Mirai` -> `Neo Mirai`, to match case study site's actual content
2026-04-27 11:07:48 -07:00
vivshaw 579006cda5 fix: add missing trailing-slash redirect for /cases/neon-mirai/ 2026-04-26 19:30:09 -04:00
vivshaw ceb0ef8f67 chore: rename all references Neon Mirai -> Neo Mirai to match site title 2026-04-26 19:10:46 -04:00
Paul BakausandClaude Opus 4.7 25e6c820aa chore(site): bump GitHub star count to 22k
Live count is 21,826 stars; rounding up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 01:35:37 -07:00
Paul BakausandClaude Opus 4.7 6e96f62803 fix(live): readable freeform input on dark bar + tools/live-loop.mjs
The configure row's text input filled its background with translucent
magenta (BP.accentSoft) on focus. Composited against the dark bar surface
this produced a murky purple where the browser's default placeholder
gray washed out — flagged in a real session as "godawful styling, gray
text on dark magenta really hurts my eyes". Fix: focus state shows an
accent-colored border only, no fill; placeholder color is set explicitly
to BP.textDim via a one-shot stylesheet so it reads in both themes.

tests/live-e2e/agent.mjs: runAgentLoop's wrapTarget now accepts either a
static {classes,tag,elementId} (test fixture mode) OR a function that
derives the target from each generate event (real-use mode where the
picked element is unknown ahead of time).

tools/live-loop.mjs: standalone runner that attaches the LLM agent to a
running live-server. Used as a test-harness shortcut for validating live
mode out of band; in production the user's coding agent (Claude Code,
Cursor, etc.) plays this role directly via the live skill spec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 01:35:32 -07:00
Paul Bakaus 74f16d6310 Add Neon Mirai showcase 2026-04-25 01:23:51 -07:00
Paul BakausandClaude Opus 4.7 d26ccac1be feat(test): pluggable LLM agent for live-mode E2E suite
tests/live-e2e/agents/llm-agent.mjs: a Claude-backed VariantAgent that
implements the same one-method interface as the fake agent
(generateVariants(event, context) → { scopedCss, variants[] }). Default
model claude-haiku-4-5; override via IMPECCABLE_E2E_LLM_MODEL.

Prompt caching is on — the system prompt (instructions + the live-mode
spec from reference/live.md) is the cacheable prefix. First call writes
~10K tokens to cache; subsequent fixtures pay only the cache-read rate.
JSON output is validated for shape (scopedCss, variants[N].innerHtml),
with light error messages on parse failure.

tests/live-e2e.test.mjs: read IMPECCABLE_E2E_AGENT (fake|llm). When 'llm',
construct the LLM agent and skip the case cleanly if ANTHROPIC_API_KEY is
unset. Param-manifest assertions are gated to fake mode (LLM may emit
zero-param "fixed point" variants per the live.md spec). The accepted-h1
class assertion now allows hero-title as one of multiple classes so an
LLM agent that adds classes alongside the original still passes.

Test timeouts widen for LLM mode: 25s first-pass on conditional-render
fixtures (vs 5s for fake), 60s on direct waits (vs 30s). Without these,
the LLM's 3-8s generate latency races the orchestration's state-loss
recovery window.

tests/live-e2e/ui.mjs: clickGo retries up to 3× on stability failures.
Required because conditional-render fixtures (modal/tabs) animate the bar
mid-transition when preActions trigger framework HMR; a single click can
land during a re-render and Playwright's stability gate times out.

Pass rate on a typical sweep: 18/19 in LLM mode, 19/19 in fake mode.
The modal fixture's intrinsic state-loss flake (Fast Refresh resetting
useState(open) when source changes) is amplified by LLM latency and may
need a re-run; documented in CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 01:06:06 -07:00
Paul BakausandClaude Opus 4.7 4310352423 fix(live): variant observer detects wrappers added as descendants
startVariantObserver's "dominated" check only matched when the variant
wrapper was added directly as a mutation's addedNode. SvelteKit (and any
framework whose HMR replaces a whole subtree on edit) adds the wrapper as
a descendant of an added <main> or similar — the observer ignored those
mutations and the session stayed in GENERATING forever even with all 3
variants present in the DOM.

Surfaced by the LLM-agent E2E run on vite8-sveltekit. The fake-agent path
masked the issue because its splice timing happened before Vite's reload
finalized; the slower LLM call shifted timing into the failure window.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 01:05:28 -07:00
Paul BakausandClaude Opus 4.7 89ffd73d4b improve(polish): make polish a true superset of retired /normalize
Aligning to the design system is now non-optional, drift gets named by
root cause (missing token / one-off / conceptual), and a new Information
Architecture & Flow dimension covers the user-flow shape that polish
previously left to chance. Folds the missing pieces from the deprecated
normalize skill into the v3.0.1 changelog bullet rather than a new bump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 00:56:35 -07:00
Paul BakausandClaude Opus 4.7 4fa02bf573 docs: add live-mode E2E test instructions to CLAUDE.md and AGENTS.md
Documents `bun run test:live-e2e`, the IMPECCABLE_E2E_ONLY scope env var,
the IMPECCABLE_E2E_DEBUG diagnostic flag, the one-time
`npx playwright install chromium` setup, and why the suite is kept off
the default `bun run test` path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 23:44:14 -07:00
Paul BakausandClaude Opus 4.7 7baf77a457 chore: bump impeccable skill to v3.0.1
User-facing changes shipped in this patch:
- Live mode runs in strict-CSP apps (auto-patches meta CSP, reverts on stop)
- Live mode survives conditional-render content (modal/tab/collapsible)
- Live mode no longer breaks JSX projects (carbonize stash + accept rewrite)
- SvelteKit hydration race fixed
- Headless Chromium WebGL fallback fixed

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 23:39:49 -07:00
Paul BakausandClaude Opus 4.7 c3e18fe664 fix(live): four bugs surfaced by E2E suite + CSP meta auto-patch
CSP meta-tag auto-patch (live-inject.mjs)
  When the user's HTML carries <meta http-equiv="Content-Security-Policy">,
  the cross-origin load of /live.js and the SSE/POST stream back to
  localhost:PORT are both blocked. Insert: append http://localhost:PORT to
  script-src and connect-src, plus blob: to img-src (the shader overlay),
  stash the original content value as a base64 data-impeccable-csp-original
  attribute. Remove: decode the marker and restore the original verbatim.
  Header-based CSP (Next/Nuxt/SvelteKit configs) intentionally untouched —
  those flow through the existing detect-csp.mjs reference path.

JSX-aware accept (live-accept.mjs)
  - Carbonize stash now emits style={{ display: 'contents' }} for JSX targets
    instead of style="display: contents" (HTML form). React 19 was throwing
    "Failed to set indexed property [0] on CSSStyleDeclaration" on the
    string form because it iterated chars onto the style object.
  - extractCss now matches </style> anywhere on a line, not just at line
    start. Previously a JSX template-literal close like `}</style> would
    leak the backtick + brace into the carbonize stash, breaking JSX.
  - Carbonize stash wraps the CSS body in {` … `} for JSX targets so curly
    braces in CSS rules don't get parsed as JSX expressions.

Conditional-render UX (live-browser.js)
  - Drop the 2s-then-window.location.reload() fallback in the SSE 'done'
    handler. That reload was masking a real failure mode: when the picked
    element lives inside conditional render (closed modal, hidden tab,
    other-route), Fast Refresh remounts the parent and state resets, so
    the variants land in source but never reach the DOM. Reload also reset
    state to default, leaving the user stuck.
  - Replace with a 6s contextual toast: "Variants ready. If the picked
    element isn't visible, retrace the path that revealed it — they'll
    appear automatically." The MutationObserver stays armed and
    auto-transitions to CYCLING once the variants finally mount.
  - Pick-time heads-up: when the picked element is inside [role="dialog"],
    [data-state="open"], a multi-tab tabpanel, or an aria-expanded
    collapsible, fire a brief upfront toast so the user knows what to
    expect if state resets during generation.

Hydration race (live-browser.js)
  - SvelteKit (and any framework that hydrates after HTML parse) was
    failing post-Vite-page-reload because init() ran resumeSession()
    before the variant wrapper hydrated into the DOM. The OLD reload
    fallback masked this by triggering a second reload whose hydration
    benefited from warm cache. Without that, fix it properly: install a
    scout MutationObserver in init() that retries resumeSession() once
    [data-impeccable-variants] lands in the DOM.

Shader overlay (live-browser.js)
  - WebGL fallback in showShaderOverlay used Object.assign(img.style,
    canvas.style, …), which throws on modern Chromium because
    CSSStyleDeclaration's indexed properties are not writable. Use
    cssText to copy positioning instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 23:37:45 -07:00
Paul BakausandClaude Opus 4.7 c8de59d81e test(live): add full-cycle E2E framework-fixture suite with pluggable agent
19 fixtures (11 styling/build variants + 4 conditional-render scenarios + 4
meta-frameworks) drive the entire user flow end-to-end: handshake, pick,
configure, Go, cycle, accept, carbonize cleanup. Each fixture installs real
deps, boots the framework dev server, and runs Playwright Chromium against a
deterministic fake agent that produces realistic variants (colocated style
with @scope rules, full data-impeccable-params manifests covering range +
steps + toggle, JSX/HTML/Svelte syntax-aware rendering).

The agent is pluggable via a one-method interface — generateVariants(event) —
so a future LLM-backed agent slots in by implementing the same shape. The
orchestrator handles wrap, file write, accept, and carbonize cleanup
deterministically regardless of which agent is plugged in.

Schema extensions (tests/framework-fixtures/README.md): runtime block adds
preActions / reloadProbe / pickSelector / scheme / ignoreHTTPSErrors so
fixtures can drive conditional UI (modal, tab, route) before pick and verify
the carbonized variant survives a reload.

Static fixture suite filtered to skip dirs without fixture.json so empty
scaffold dirs no longer break discovery. Total: 178 static checks, 19 E2E
full cycles, ~107s wall clock for the E2E suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 23:37:04 -07:00
Paul Bakaus d29a690797 Fix Neon Mirai active navigation 2026-04-24 23:18:23 -07:00
Paul Bakaus d340f075e8 Improve Neon Mirai manifesto artwork 2026-04-24 23:10:34 -07:00
Paul Bakaus 7de610c620 Add Neon Mirai conference example 2026-04-24 17:22:59 -07:00
Paul BakausandClaude Opus 4.7 25353448e2 fix(site): restore docs-viz-caption top margin squashed by .prose p
The .prose p rule (specificity 0,1,1) was overriding .docs-viz-caption's
intended margin-top, leaving 0px between the caption and the cards/file/
report above it on every docs page that uses the hero block.

Bump specificity with .docs-viz-hero .docs-viz-caption and set
margin: 16px 0 0 so the caption sits 16px below the visual and lets the
hero's 24px padding alone control the bottom gap. Symmetric inside the
cream box.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 15:45:59 -07:00
Paul BakausandClaude Opus 4.7 346ce25952 docs(site): add image gen bullet and Live Mode alpha tag to v3.0 changelog
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:21:01 -07:00
Paul BakausandClaude Opus 4.7 f5e82162c1 fix(site): strip dev-only live.js inject tags from production HTML
public/index.html and public/privacy.html had stale
`<script src="http://localhost:8400/live.js">` scaffolding from a
local /impeccable live session. On impeccable.style (Cloudflare) this
fired Chrome's private-network-access prompt on every page load. The
inject is dev-only; normally stripped by live-server.mjs stop, but
these two slipped through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:21:26 -07:00
Paul BakausandGitHub 6816558d7a Merge pull request #109 from pbakaus/3.0
v3.0: single /impeccable skill, Live Mode, /designing orientation, visualize-first
2026-04-23 18:08:34 -07:00
gujishh b93d36dcfe docs: add community & ecosystem section 2026-04-12 14:19:51 +09:00
1718 changed files with 359855 additions and 45466 deletions
+88 -70
View File
@@ -5,81 +5,86 @@ description: Use when the user wants to design, redesign, shape, critique, audit
Designs and iterates production-grade frontend interfaces. Real working code, committed design choices, exceptional craft.
## Setup (non-optional)
## Setup
Two steps before any design work. Both are required. Skipping either produces generic output that ignores the project.
You MUST do these steps before proceeding:
### 1. Context gathering
1. Run `node .agents/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/<command>.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
5. **If the project is brand-new (no existing CSS tokens / theme / committed brand colors found in step 3)**, run `node .agents/skills/impeccable/scripts/palette.mjs` to receive a brand seed color and composition guidance. This is the anchor for your primary brand color. Compose the rest of the palette (bg, surface, ink, accent, muted) around it per the script's instructions. Use OKLCH throughout. **Skip this step only if step 3 found committed brand colors in existing tokens; in that case identity-preservation wins.**
Two files at the project root, case-insensitive:
## Design guidance
- **PRODUCT.md** — required. Users, brand, tone, anti-references, strategic principles.
- **DESIGN.md** — optional, strongly recommended. Colors, typography, elevation, components.
Produce ready-to-ship, production-grade code, not prototypes or starting points. Take no shortcuts unless the user asks for them (when in doubt, ask). Don't stop until arriving at a complete implementation (beautiful, responsive, fast, precise, bug-free, on brand). You take attention to detail seriously: every page, section or component crafted is battle tested using the tools available to you (browser screenshotting, computer use, etc). GPT is capable of extraordinary work. Don't hold back.
Load both in one call:
### General rules
```bash
node .agents/skills/impeccable/scripts/load-context.mjs
```
#### Color
Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`.
- **Verify contrast.** Body text must hit ≥4.5:1 against its background; large text (≥18px or bold ≥14px) needs ≥3:1. Placeholder text needs the same 4.5:1, not the muted-gray default. The most common failure: muted gray body text on a tinted near-white. If the contrast is even close, bump the body color toward the ink end of the ramp; light gray "for elegance" is the single biggest reason AI designs feel hard to read.
- Gray text on a colored background looks washed out. Use a darker shade of the background's own hue, or a transparency of the text color.
If the output is already in this session's conversation history, don't re-run. Exceptions requiring a fresh load: you just ran `$impeccable teach` or `$impeccable document` (they rewrite the files), or the user manually edited one.
`$impeccable live` already warms context via `live.mjs` — if you've run `live.mjs`, don't also run `load-context.mjs` this session.
If PRODUCT.md is missing, empty, or placeholder (`[TODO]` markers, <200 chars): run `$impeccable teach`, then resume the user's original task with the fresh context.
If DESIGN.md is missing: nudge once per session (*"Run `$impeccable document` for more on-brand output"*), then proceed.
### 2. Register
Every design task is **brand** (marketing, landing, campaign, long-form content, portfolio — design IS the product) or **product** (app UI, admin, dashboard, tool — design SERVES the product).
Identify before designing. Priority: (1) cue in the task itself ("landing page" vs "dashboard"); (2) the surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. First match wins.
If PRODUCT.md lacks the `register` field (legacy), infer it once from its "Users" and "Product Purpose" sections, then cache the inferred value for the session. Suggest the user run `$impeccable teach` to add the field explicitly.
Load the matching reference: [reference/brand.md](reference/brand.md) or [reference/product.md](reference/product.md). The shared design laws below apply to both.
## Shared design laws
Apply to every design, both registers. Match implementation complexity to the aesthetic vision — maximalism needs elaborate code, minimalism needs precision. Interpret creatively. Vary across projects; never converge on the same choices. GPT is capable of extraordinary work — don't hold back.
### Color
- Use OKLCH. Reduce chroma as lightness approaches 0 or 100 — high chroma at extremes looks garish.
- Never use `#000` or `#fff`. Tint every neutral toward the brand hue (chroma 0.0050.01 is enough).
- Pick a **color strategy** before picking colors. Four steps on the commitment axis:
- **Restrained** — tinted neutrals + one accent ≤10%. Product default; brand minimalism.
- **Committed** — one saturated color carries 3060% of the surface. Brand default for identity-driven pages.
- **Full palette** — 34 named roles, each used deliberately. Brand campaigns; product data viz.
- **Drenched** — the surface IS the color. Brand heroes, campaign pages.
- The "one accent ≤10%" rule is Restrained only. Committed / Full palette / Drenched exceed it on purpose. Don't collapse every design to Restrained by reflex.
### Theme
Dark vs. light is never a default. Not dark "because tools look cool dark." Not light "to be safe."
Before choosing, write one sentence of physical scene: who uses this, where, under what ambient light, in what mood. If the sentence doesn't force the answer, it's not concrete enough — add detail until it does.
"Observability dashboard" does not force an answer. "SRE glancing at incident severity on a 27-inch monitor at 2am in a dim room" does. Run the sentence, not the category.
### Typography
#### Typography
- Cap body line length at 6575ch.
- Hierarchy through scale + weight contrast (≥1.25 ratio between steps). Avoid flat scales.
- Cap font-family count at 3 (display + body + optional mono). More than 3 reads as indecision, not richness. One well-tuned family with weight contrast usually beats three competing typefaces.
- Don't pair fonts that are similar but not identical (two geometric sans-serifs, two humanist sans-serifs). Pair on a contrast axis (serif + sans, geometric + humanist) or use one family in multiple weights.
- No all-caps body copy. Reserve uppercase for short labels (≤4 words), section eyebrows (used sparingly per the Absolute bans), and badges. Sentences in ALL CAPS are unreadable at body sizes.
- Hero / display heading ceiling: clamp() max ≤ 6rem (~96px). Above that the page is shouting, not designing.
- Display heading letter-spacing floor: ≥ -0.04em. Anything tighter and letters touch; cramped, not "designed".
- Use `text-wrap: balance` on h1h3 for even line lengths; `text-wrap: pretty` on long prose to reduce orphans.
### Layout
Two hard typographic ceilings you currently miss:
- Hero clamp() max ≤ 6rem. 811rem (128176px) reads as comically loud, not bold.
- Display letter-spacing ≥ -0.04em. Your default of -0.05 to -0.085em on display H1s makes the letters touch and reads as cramped. -0.02 to -0.03em is plenty for tight grotesque display; -0.04em is the floor.
- Vary spacing for rhythm. Same padding everywhere is monotony.
#### Layout
- Vary spacing for rhythm.
- Cards are the lazy answer. Use them only when they're truly the best affordance. Nested cards are always wrong.
- Don't wrap everything in a container. Most things don't need one.
- Flexbox for 1D, Grid for 2D. Don't default to Grid when `flex-wrap` would be simpler.
- For responsive grids without breakpoints: `repeat(auto-fit, minmax(280px, 1fr))`.
- Build a semantic z-index scale (dropdown → sticky → modal-backdrop → modal → toast → tooltip). Never arbitrary values like 999 or 9999.
### Motion
- Don't animate CSS layout properties.
#### Motion
- Motion should be intentional, and not be an afterthought. consider it as part of the build.
- Don't animate CSS layout properties unless truly needed.
- Ease out with exponential curves (ease-out-quart / quint / expo). No bounce, no elastic.
- Use libraries for more advanced motion needs (e.g. motion, gsap, anime.js, lenis etc)
- Reduced motion is not optional. Every animation needs a `@media (prefers-reduced-motion: reduce)` alternative: typically a crossfade or instant transition.
- Staggering the items within one list is legitimate. The tell is the uniform reflex (one identical entrance applied to every section), not motion itself; each reveal should fit what it reveals. Suppressing the reflex is never a reason to ship a page with no motion at all.
- Reveal animations must enhance an already-visible default. Don't gate content visibility on a class-triggered transition; transitions pause on hidden tabs and headless renderers, so the reveal never fires and the section ships blank.
- Premium motion materials are not just transform/opacity. Blur, backdrop-filter, clip-path, mask, and shadow/glow are part of the palette when they materially improve the effect and stay smooth.
#### Interaction
- Dropdowns rendered with `position: absolute` inside an `overflow: hidden` or `overflow: auto` container will be clipped. Use the native `<dialog>` / popover API, `position: fixed`, or a portal to escape the stacking context.
### Copy
- Every word earns its place. No restated headings, no intros that repeat the title.
- **No em dashes.** Use commas, colons, semicolons, periods, or parentheses. Also not `--`.
- **No aphoristic-cadence body copy as a default voice.** Don't fall into the rhythm of "serious statement, then punchy short negation" as the page's recurring voice. If three or more section copy blocks on the page land on a short rebuttal-shaped sentence, rewrite. Specific, not aphoristic.
- **No marketing buzzwords.** The streamline / empower / supercharge / leverage / unleash / transform / seamless / world-class / enterprise-grade / next-generation / cutting-edge / game-changer / mission-critical family of phrases. Pick a specific noun and a verb that describes what the product literally does.
- Button labels: verb + object. "Save changes" beats "OK"; "Delete project" beats "Yes". The label should say what will happen.
- Link text needs standalone meaning. "View pricing plans" beats "Click here"; screen readers announce links out of context.
### New projects only (when no prior work exists)
#### Color & Theme
- Use OKLCH.
- **The cream / sand / beige body bg is the saturated AI default of 2026.** The whole warm-neutral band (OKLCH L 0.84-0.97, C < 0.06, hue 40-100) reads as cream/sand/paper/parchment regardless of what you call it. Token names like `--paper`, `--cream`, `--sand`, `--bone`, `--flour`, `--linen`, `--parchment`, `--wheat`, `--biscuit`, `--ivory` are tells in themselves. If the brief is "warm, traditional, family-coastal-Italian" or "magazine-warm" or "editorial-restraint", DO NOT translate that into a near-white warm-tinted bg; that's the AI move. Pick: (a) a saturated brand color as the body (terracotta, oxblood, deep ochre, near-black), (b) a true off-white at chroma 0 (or chroma toward the brand's own hue, not toward warmth-by-default), or (c) a darker mid-tone tinted neutral that's clearly the brand's own. "Warmth" in the brand is carried by accent + typography + imagery, not by body bg.
- Tinted neutrals: add 0.0050.015 chroma toward the brand's hue. Don't default-tint toward warm or cool "because the brand feels that way"; that's the cross-project monoculture move.
- When picking a theme: Dark vs. light is never a default. Not dark "because tools look cool dark." Not light "to be safe.".Before choosing, write one sentence of physical scene: who uses this, where, under what ambient light, in what mood. If the sentence doesn't force the answer, it's not concrete enough. Add detail until it does.
- Pick a **color strategy** before picking colors. Four steps on the commitment axis:
- **Restrained**: tinted neutrals + one accent ≤10%. Product default; brand minimalism.
- **Committed**: one saturated color carries 3060% of the surface. Brand default for identity-driven pages.
- **Full palette**: 34 named roles, each used deliberately. Brand campaigns; product data viz.
- **Drenched**: the surface IS the color. Brand heroes, campaign pages.
### Absolute bans
@@ -90,18 +95,26 @@ Match-and-refuse. If you're about to write any of these, rewrite the element wit
- **Glassmorphism as default.** Blurs and glass cards used decoratively. Rare and purposeful, or nothing.
- **The hero-metric template.** Big number, small label, supporting stats, gradient accent. SaaS cliché.
- **Identical card grids.** Same-sized cards with icon + heading + text, repeated endlessly.
- **Modal as first thought.** Modals are usually laziness. Exhaust inline / progressive alternatives first.
- **Tiny uppercase tracked eyebrow above every section.** The 2023-era kicker (small all-caps text with wide tracking, "ABOUT" "PROCESS" "PRICING" above each heading) is now the saturated AI scaffold; it appears on 55-95% of generations regardless of brief, which is the definition of a tell. One named kicker as a deliberate brand system is voice; an eyebrow on every section is AI grammar. Choose a different cadence.
- **Numbered section markers as default scaffolding (01 / 02 / 03).** Putting `01 · About / 02 · Process / 03 · Pricing` above every section is the eyebrow trope one tier deeper: reach for it because "landing pages do this" and you're scaffolding by reflex. Numbers earn their place when the section actually IS a sequence (a real 3-step process, an ordered flow, a typed timeline) and the order carries information the reader needs. One deliberate numbered sequence on one page is voice; numbered eyebrows on every section across the site is AI grammar.
- **Text that overflows its container.** Long heading words plus large clamp scales plus narrow grids cause headline overflow on tablet/mobile. Test the heading copy at every breakpoint; if it overflows, reduce the clamp max or rewrite the copy. The viewport is part of the design.
### Copy
**Codex-specific defects** (your most-frequent giveaways; refuse-and-rewrite):
- Every word earns its place. No restated headings, no intros that repeat the title.
- **No em dashes.** Use commas, colons, semicolons, periods, or parentheses. Also not `--`.
- **`border: 1px solid X` + `box-shadow: 0 Npx Mpx ...` with M ≥ 16px** on the same element. The "ghost-card" pattern: 1px border plus soft wide drop shadow on buttons and cards. Don't pair them. Pick one (a single solid border at the brand color, OR a defined shadow at no more than 8px blur), never both as decoration.
- **`border-radius: 32px+` on cards / sections / inputs.** You over-round. Cards top out at 1216px; full-pill is fine for tags/buttons. Picking 24/28/32/40px on a card is the codex tell; no brand wants "insanely rounded".
- **Hand-drawn / sketchy SVG illustrations.** Class names like `loose-sketch`, `*-sketch`, `doodle`, `wavy`; `feTurbulence` / `feDisplacementMap` "paper grain" filters; 5-to-30 path crude scenes meant to depict a tangible subject (an otter, a table-and-fork, an album cover). All of these read as amateurish, not whimsical. If you can't render the scene with real assets, ship no illustration. Don't attempt sketchy SVG as a fallback.
- **`repeating-linear-gradient(...)` stripe backgrounds.** Diagonal stripes in `body:before` or section backgrounds are pure codex decoration. Don't.
- **"X theater" / "actually X" / "not just X, it's Y" copy.** "Productivity theater", "engagement theater", "growth theater": instant AI slop. Choose a specific noun, not a meta-criticism phrase.
### The AI slop test
If someone could look at this interface and say "AI made that" without doubt, it's failed. Cross-register failures are the absolute bans above. Register-specific failures live in each reference.
**Category-reflex check.** If someone could guess the theme and palette from the category name alone — "observability → dark blue", "healthcare → white + teal", "finance → navy + gold", "crypto → neon on black" — it's the training-data reflex. Rework the scene sentence and color strategy until the answer is no longer obvious from the domain.
**Category-reflex check.** Run at two altitudes; the second one catches what the first one misses.
- **First-order:** if someone could guess the theme + palette from the category alone, it's the first training-data reflex. Rework the scene sentence and color strategy until the answer isn't obvious from the domain.
- **Second-order:** if someone could guess the aesthetic family from category-plus-anti-references ("AI workflow tool that's not SaaS-cream → editorial-typographic", "fintech that's not navy-and-gold → terminal-native dark mode"), it's the trap one tier deeper. The first reflex was avoided; the second wasn't. Rework until both answers are not obvious. The brand register's [reflex-reject aesthetic lanes](reference/brand.md) list catches the currently-saturated families.
## Commands
@@ -109,7 +122,7 @@ If someone could look at this interface and say "AI made that" without doubt, it
|---|---|---|---|
| `craft [feature]` | Build | Shape, then build a feature end-to-end | [reference/craft.md](reference/craft.md) |
| `shape [feature]` | Build | Plan UX/UI before writing code | [reference/shape.md](reference/shape.md) |
| `teach` | Build | Set up PRODUCT.md and DESIGN.md context | [reference/teach.md](reference/teach.md) |
| `init` | Build | Set up project context: PRODUCT.md, DESIGN.md, live config, next steps | [reference/init.md](reference/init.md) |
| `document` | Build | Generate DESIGN.md from existing project code | [reference/document.md](reference/document.md) |
| `extract [target]` | Build | Pull reusable tokens and components into design system | [reference/extract.md](reference/extract.md) |
| `critique [target]` | Evaluate | UX design review with heuristic scoring | [reference/critique.md](reference/critique.md) |
@@ -131,16 +144,21 @@ If someone could look at this interface and say "AI made that" without doubt, it
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
| `live` | Iterate | Visual variant mode: pick elements in the browser, generate alternatives | [reference/live.md](reference/live.md) |
Plus two management commands `pin <command>` and `unpin <command>`, detailed below.
Plus two management commands: `pin <command>` and `unpin <command>`, detailed below.
### Routing rules
1. **No argument** render the table above as the user-facing command menu, grouped by category. Ask what they'd like to do.
2. **First word matches a command** load its reference file and follow its instructions. Everything after the command name is the target.
3. **First word doesn't match** — general design invocation. Apply the setup steps, shared design laws, and the loaded register reference, using the full argument as context.
1. **No argument**: render the table above as the user-facing command menu, grouped by category. Ask what they'd like to do.
2. **First word matches a command**: load its reference file and follow its instructions. Everything after the command name is the target.
3. **First word doesn't match, but the intent clearly maps to one command** (e.g. "fix the spacing" → `layout`, "rewrite this error message" → `clarify`, "the colors feel flat" → `colorize`): load that command's reference and proceed as if invoked. If two commands could fit, ask once which.
4. **No clear command match**: general design invocation. Apply the setup steps, the General rules, and the loaded register reference, using the full argument as context.
Setup (context gathering, register) is already loaded by then; sub-commands don't re-invoke `$impeccable`.
If the first word is `craft`, setup still runs first, but [reference/craft.md](reference/craft.md) owns the rest of the flow. If setup invokes `init` as a blocker, finish init, refresh context, then resume the original command and target.
`teach` is a deprecated alias for `init`: if the user types it, load [reference/init.md](reference/init.md) and proceed as if they ran `init`.
## Pin / Unpin
**Pin** creates a standalone shortcut so `$<command>` invokes `$impeccable <command>` directly. **Unpin** removes it. The script writes to every harness directory present in the project.
@@ -149,4 +167,4 @@ Setup (context gathering, register) is already loaded by then; sub-commands don'
node .agents/skills/impeccable/scripts/pin.mjs <pin|unpin> <command>
```
Valid `<command>` is any command from the table above. Report the script's result concisely — confirm the new shortcut on success, relay stderr verbatim on error.
Valid `<command>` is any command from the table above. Report the script's result concisely. Confirm the new shortcut on success, relay stderr verbatim on error.
+125 -4
View File
@@ -1,6 +1,6 @@
> **Additional context needed**: target platforms/devices and usage contexts.
Adapt existing designs to work effectively across different contexts - different screen sizes, devices, platforms, or use cases.
Adapt an existing design to a different context: another screen size, device, platform, or use case. The trap is treating adaptation as scaling. The job is rethinking the experience for the new context.
---
@@ -27,7 +27,7 @@ Understand what needs adaptation and why:
- What won't work? (Hover states on touch, tiny touch targets)
- What's inappropriate? (Desktop patterns on mobile, mobile patterns on desktop)
**CRITICAL**: Adaptation is not just scaling - it's rethinking the experience for the new context.
**CRITICAL**: Adaptation is rethinking the experience for the new context, not scaling pixels.
## Plan Adaptation Strategy
@@ -164,7 +164,7 @@ Choose appropriate breakpoints:
- Persistent side navigation on desktop
- Breadcrumbs on smaller screens for context
**IMPORTANT**: Test on real devices, not just browser DevTools. Device emulation is helpful but not perfect.
**IMPORTANT**: Test on real devices. Device emulation in DevTools is helpful but not perfect.
**NEVER**:
- Hide core functionality on mobile (if it matters, make it work)
@@ -187,4 +187,125 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **Slow connections**: Test on throttled network
Remember: You're a cross-platform design expert. Make experiences that feel native to each context while maintaining brand and functionality consistency. Adapt intentionally, test thoroughly.
When the adaptation feels native to each context, hand off to `$impeccable polish` for the final pass.
---
## Reference Material
The sections below were previously `responsive-design.md` and live inline now so the adapt flow has its deep responsive reference in one place.
### Responsive Design
#### Mobile-First: Write It Right
Start with base styles for mobile, use `min-width` queries to layer complexity. Desktop-first (`max-width`) means mobile loads unnecessary styles first.
#### Breakpoints: Content-Driven
Don't chase device sizes; let content tell you where to break. Start narrow, stretch until design breaks, add breakpoint there. Three breakpoints usually suffice (640, 768, 1024px). Use `clamp()` for fluid values without breakpoints.
#### Detect Input Method, Not Just Screen Size
**Screen size doesn't tell you input method.** A laptop with touchscreen, a tablet with keyboard. Use pointer and hover queries:
```css
/* Fine pointer (mouse, trackpad) */
@media (pointer: fine) {
.button { padding: 8px 16px; }
}
/* Coarse pointer (touch, stylus) */
@media (pointer: coarse) {
.button { padding: 12px 20px; } /* Larger touch target */
}
/* Device supports hover */
@media (hover: hover) {
.card:hover { transform: translateY(-2px); }
}
/* Device doesn't support hover (touch) */
@media (hover: none) {
.card { /* No hover state - use active instead */ }
}
```
**Critical**: Don't rely on hover for functionality. Touch users can't hover.
#### Safe Areas: Handle the Notch
Modern phones have notches, rounded corners, and home indicators. Use `env()`:
```css
body {
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
/* With fallback */
.footer {
padding-bottom: max(1rem, env(safe-area-inset-bottom));
}
```
**Enable viewport-fit** in your meta tag:
```html
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
```
#### Responsive Images: Get It Right
##### srcset with Width Descriptors
```html
<img
src="hero-800.jpg"
srcset="
hero-400.jpg 400w,
hero-800.jpg 800w,
hero-1200.jpg 1200w
"
sizes="(max-width: 768px) 100vw, 50vw"
alt="Hero image"
>
```
**How it works**:
- `srcset` lists available images with their actual widths (`w` descriptors)
- `sizes` tells the browser how wide the image will display
- Browser picks the best file based on viewport width AND device pixel ratio
##### Picture Element for Art Direction
When you need different crops/compositions (not just resolutions):
```html
<picture>
<source media="(min-width: 768px)" srcset="wide.jpg">
<source media="(max-width: 767px)" srcset="tall.jpg">
<img src="fallback.jpg" alt="...">
</picture>
```
#### Layout Adaptation Patterns
**Navigation**: Three stages: hamburger + drawer on mobile, horizontal compact on tablet, full with labels on desktop. **Tables**: Transform to cards on mobile using `display: block` and `data-label` attributes. **Progressive disclosure**: Use `<details>/<summary>` for content that can collapse on mobile.
#### Testing: Don't Trust DevTools Alone
DevTools device emulation is useful for layout but misses:
- Actual touch interactions
- Real CPU/memory constraints
- Network latency patterns
- Font rendering differences
- Browser chrome/keyboard appearances
**Test on at least**: One real iPhone, one real Android, a tablet if relevant. Cheap Android phones reveal performance issues you'll never see on simulators.
---
**Avoid**: Desktop-first design. Device detection instead of feature detection. Separate mobile/desktop codebases. Ignoring tablet and landscape. Assuming all mobile devices are powerful.
+53 -25
View File
@@ -1,14 +1,14 @@
> **Additional context needed**: performance constraints.
Analyze a feature and strategically add animations and micro-interactions that enhance understanding, provide feedback, and create delight.
Add motion that conveys state, gives feedback, and clarifies hierarchy. Cut motion that exists only for decoration. Animation fatigue is a real cost; spend the budget on the moments that need it.
---
## Register
Brand: orchestrated page-load sequences, staggered reveals, scroll-driven animation. Motion is part of the voice; one well-rehearsed entrance beats scattered micro-interactions.
Brand: motion is part of the voice; one well-rehearsed entrance beats scattered micro-interactions. The saturated AI default is fade-and-rise reveals on every scrolled section; that's a tell, not a choreography. Reserve scroll-triggered motion for moments that earn it.
Product: 150250 ms on most transitions. Motion conveys state feedback, reveal, loading, transitions between views. No page-load choreography; users are in a task and won't wait for it.
Product: 150250 ms on most transitions. Motion conveys state: feedback, reveal, loading, transitions between views. No page-load choreography; users are in a task and won't wait for it.
---
@@ -29,7 +29,7 @@ Analyze where motion would improve the experience:
- Who's the audience? (Motion-sensitive users? Power users who want speed?)
- What matters most? (One hero animation vs many micro-interactions?)
If any of these are unclear from the codebase, ask the user directly to clarify what you cannot infer.
If any of these are unclear from the codebase, STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer.
**CRITICAL**: Respect `prefers-reduced-motion`. Always provide non-animated alternatives for users who need them.
@@ -49,10 +49,11 @@ Create a purposeful animation plan:
Add motion systematically across these categories:
### Entrance Animations
- **Page load choreography**: Stagger element reveals (100-150ms delays), fade + slide combinations
- **Hero section**: Dramatic entrance for primary content (scale, parallax, or creative effects)
- **Content reveals**: Scroll-triggered animations using intersection observer
- **Modal/drawer entry**: Smooth slide + fade, backdrop fade, focus management
- **List rhythm**: Sibling stagger is legitimate for cards-in-a-grid or list-items-appearing. Whole-section fade-on-scroll is not a list and is not legitimate. Cap total stagger time: 10 items at 50ms each = 500ms total. For more items, reduce per-item delay or cap the staggered count.
Use CSS custom properties for clean stagger: `animation-delay: calc(var(--i, 0) * 50ms)` with `style="--i: 0"`, `style="--i: 1"`, etc. on each item.
### Micro-interactions
- **Button feedback**:
@@ -97,20 +98,23 @@ Use appropriate techniques for each animation:
### Timing & Easing
**Durations by purpose:**
- **100-150ms**: Instant feedback (button press, toggle)
- **200-300ms**: State changes (hover, menu open)
- **300-500ms**: Layout changes (accordion, modal)
- **500-800ms**: Entrance animations (page load)
**Duration: the 100/300/500 rule.** Timing matters more than easing for "feels right":
| Duration | Use Case | Examples |
|----------|----------|----------|
| **100150ms** | Instant feedback | Button press, toggle, color change |
| **200300ms** | State changes | Menu open, tooltip, hover state |
| **300500ms** | Layout changes | Accordion, modal, drawer |
| **500800ms** | Entrance animations | Page load, hero reveal |
**Easing curves (use these, not CSS defaults):**
```css
/* Recommended - natural deceleration */
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1); /* Smooth, refined */
/* Recommended: natural deceleration */
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1); /* Smooth */
--ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1); /* Slightly snappier */
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); /* Confident, decisive */
/* AVOID - feel dated and tacky */
/* AVOID: feel dated and tacky */
/* bounce: cubic-bezier(0.34, 1.56, 0.64, 1); */
/* elastic: cubic-bezier(0.68, -0.6, 0.32, 1.6); */
```
@@ -122,7 +126,8 @@ Use appropriate techniques for each animation:
/* Prefer for simple, declarative animations */
- transitions for state changes
- @keyframes for complex sequences
- transform + opacity only (GPU-accelerated)
- transform and opacity for reliable movement
- blur, filters, masks, clip paths, shadows, and color shifts for premium atmospheric effects when verified smooth
```
### JavaScript Animation
@@ -133,12 +138,35 @@ Use appropriate techniques for each animation:
- GSAP for complex sequences
```
### Motion Materials
Transform and opacity are reliable defaults, not the whole palette. Premium interfaces often need atmospheric properties. Match material to effect:
- **Transform / opacity**: movement, press feedback, simple reveals, list choreography
- **Blur / filter / backdrop-filter**: focus pulls, depth, glass or lens effects, softened entrances
- **Clip-path / masks**: wipes, reveals, editorial cropping, product-like transitions
- **Shadow / glow / color filters**: energy, affordance, focus, warmth, active state
- **Grid-template-rows or FLIP-style transforms**: expanding and reflowing layout without animating `height` directly
The hard rule isn't "transform and opacity only." It's: avoid animating layout-driving properties casually (`width`, `height`, `top`, `left`, margins), keep expensive effects bounded to small or isolated areas, and verify smoothness in-browser on target viewports.
### Performance
- **GPU acceleration**: Use `transform` and `opacity`, avoid layout properties
- **will-change**: Add sparingly for known expensive animations
- **Reduce paint**: Minimize repaints, use `contain` where appropriate
- **Layout safety**: Avoid casual animation of layout-driving properties (`width`, `height`, `top`, `left`, margins)
- **will-change**: Add sparingly for known expensive animations only (e.g. on `:hover` or an `.animating` class), never preemptively across the whole page
- **Scroll triggers**: Use Intersection Observer instead of scroll event listeners; unobserve after the animation fires once
- **Bound expensive effects**: Keep blur/filter/shadow areas small or isolated, use `contain` where appropriate
- **Monitor FPS**: Ensure 60fps on target devices
### Perceived Performance
Nobody cares how fast your site *is*, only how fast it feels. The 80ms threshold: anything under ~80ms feels instant because our brains buffer sensory input for that long to synchronize perception. Target this for micro-interactions.
- **Preemptive start**: Begin transitions immediately while loading (iOS app zoom, skeleton UI). Users perceive work happening.
- **Early completion**: Show content progressively, don't wait for everything (progressive images, streaming HTML, skeleton fade-ins).
- **Optimistic UI**: Update the interface immediately, handle failures gracefully. Use for low-stakes actions (likes, follows). Avoid for payments or destructive operations.
- **Easing affects perceived duration**: Ease-in (accelerating toward completion) makes tasks feel shorter because the peak-end effect weights final moments heavily. Ease-out feels satisfying for entrances.
- **Caution**: Too-fast responses can decrease perceived value for complex operations (search, analysis). Sometimes a brief delay signals "real work" is happening.
### Accessibility
```css
@media (prefers-reduced-motion: reduce) {
@@ -151,12 +179,12 @@ Use appropriate techniques for each animation:
```
**NEVER**:
- Use bounce or elastic easing curvesthey feel dated and draw attention to the animation itself
- Animate layout properties (width, height, top, left)—use transform instead
- Use durations over 500ms for feedbackit feels laggy
- Animate without purposeevery animation needs a reason
- Ignore `prefers-reduced-motion`this is an accessibility violation
- Animate everythinganimation fatigue makes interfaces feel exhausting
- Use bounce or elastic easing curves; they feel dated and draw attention to the animation itself
- Animate layout properties casually (`width`, `height`, `top`, `left`, margins) when transform, FLIP, or grid-based techniques would work
- Use durations over 500ms for feedback (it feels laggy)
- Animate without purpose (every animation needs a reason)
- Ignore `prefers-reduced-motion` (this is an accessibility violation)
- Animate everything (animation fatigue makes interfaces feel exhausting)
- Block interaction during animations unless intentional
## Verify Quality
@@ -170,4 +198,4 @@ Test animations thoroughly:
- **Doesn't block**: Users can interact during/after animations
- **Adds value**: Makes interface clearer or more delightful
Remember: Motion should enhance understanding and provide feedback, not just add decoration. Animate with purpose, respect performance constraints, and always consider accessibility. Great animation is invisible - it just makes everything feel right.
When the motion clarifies state instead of decorating it, hand off to `$impeccable polish` for the final pass.
+9 -10
View File
@@ -1,4 +1,4 @@
Run systematic **technical** quality checks and generate a comprehensive report. Don't fix issues document them for other commands to address.
Run systematic **technical** quality checks and generate a comprehensive report. Don't fix issues; document them for other commands to address.
This is a code-level audit, not a design critique. Check what's measurable and verifiable in the implementation.
@@ -22,7 +22,7 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Layout thrashing**: Reading/writing layout properties in loops
- **Expensive animations**: Animating layout properties (width, height, top, left) instead of transform/opacity
- **Expensive animations**: Casual layout-property animation, unbounded blur/filter/shadow effects, or effects that visibly drop frames
- **Missing optimization**: Images without lazy loading, unoptimized assets, missing will-change
- **Bundle size**: Unnecessary imports, unused dependencies
- **Render performance**: Unnecessary re-renders, missing memoization
@@ -83,10 +83,10 @@ Check against ALL the **DON'T** guidelines from the parent impeccable skill (alr
### Detailed Findings by Severity
Tag every issue with **P0-P3 severity**:
- **P0 Blocking**: Prevents task completion — fix immediately
- **P1 Major**: Significant difficulty or WCAG AA violation — fix before release
- **P2 Minor**: Annoyance, workaround exists — fix in next pass
- **P3 Polish**: Nice-to-fix, no real user impact — fix if time permits
- **P0 Blocking**: Prevents task completion. Fix immediately
- **P1 Major**: Significant difficulty or WCAG AA violation. Fix before release
- **P2 Minor**: Annoyance, workaround exists. Fix in next pass
- **P3 Polish**: Nice-to-fix, no real user impact. Fix if time permits
For each issue, document:
- **[P?] Issue name**
@@ -105,14 +105,14 @@ Identify recurring problems that indicate systemic gaps rather than one-off mist
### Positive Findings
Note what's working well good practices to maintain and replicate.
Note what's working well: good practices to maintain and replicate.
## Recommended Actions
List recommended commands in priority order (P0 first, then P1, then P2):
1. **[P?] `$command-name`** Brief description (specific context from audit findings)
2. **[P?] `$command-name`** Brief description (specific context)
1. **[P?] `$command-name`**: Brief description (specific context from audit findings)
2. **[P?] `$command-name`**: Brief description (specific context)
**Rules**: Only recommend commands from: $impeccable adapt, $impeccable animate, $impeccable audit, $impeccable bolder, $impeccable clarify, $impeccable colorize, $impeccable critique, $impeccable delight, $impeccable distill, $impeccable document, $impeccable harden, $impeccable layout, $impeccable onboard, $impeccable optimize, $impeccable overdrive, $impeccable polish, $impeccable quieter, $impeccable shape, $impeccable typeset. Map findings to the most appropriate command. End with `$impeccable polish` as the final step if any fixes were recommended.
@@ -131,4 +131,3 @@ After presenting the summary, tell the user:
- Forget to prioritize (everything can't be P0)
- Report false positives without verification
Remember: You're a technical quality auditor. Document systematically, prioritize ruthlessly, cite specific code locations, and provide clear paths to improvement.
+13 -13
View File
@@ -1,4 +1,4 @@
Increase visual impact and personality in designs that are too safe, generic, or visually underwhelming, creating more engaging and memorable experiences.
When asked for "bolder," AI defaults to the same tired tricks: cyan/purple gradients, glassmorphism, neon accents on dark backgrounds, gradient text on metrics. These are the opposite of bold. Reject them first, then increase visual impact and personality through stronger hierarchy, committed scale, and decisive type.
---
@@ -6,7 +6,7 @@ Increase visual impact and personality in designs that are too safe, generic, or
Brand: "bolder" means distinctive. Extreme scale, unexpected color, typographic risk, committed POV.
Product: "bolder" rarely means theatrics those undermine trust. It means stronger hierarchy, clearer weight contrast, one sharper accent, more committed density. The amplification is in clarity, not drama.
Product: "bolder" rarely means theatrics; those undermine trust. It means stronger hierarchy, clearer weight contrast, one sharper accent, more committed density. The amplification is in clarity, not drama.
---
@@ -28,11 +28,11 @@ Analyze what makes the design feel too safe or boring:
- Who's the audience? (What will resonate?)
- What are the constraints? (Brand guidelines, accessibility, performance)
If any of these are unclear from the codebase, ask the user directly to clarify what you cannot infer.
If any of these are unclear from the codebase, STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer.
**CRITICAL**: "Bolder" doesn't mean chaotic or garish. It means distinctive, memorable, and confident. Think intentional drama, not random chaos.
**WARNING - AI SLOP TRAP**: When making things "bolder," AI defaults to the same tired tricks: cyan/purple gradients, glassmorphism, neon accents on dark backgrounds, gradient text on metrics. These are the OPPOSITE of bold. They're generic. Review ALL the DON'T guidelines from the parent impeccable skill (already loaded in this context) before proceeding. Bold means distinctive, not "more effects."
**WARNING - AI SLOP TRAP**: Review ALL the DON'T guidelines from the parent impeccable skill (already loaded in this context) before proceeding. Bold means distinctive, not "more effects."
## Plan Amplification
@@ -50,14 +50,14 @@ Create a strategy to increase impact while maintaining coherence:
Systematically increase impact across these dimensions:
### Typography Amplification
- **Replace generic fonts**: Swap system fonts for distinctive choices (see the parent skill's typography guidelines and [typography.md](typography.md) for inspiration)
- **Replace generic fonts**: Swap system fonts for distinctive choices (see the parent skill's typography guidelines and the [Reference Material section of typeset.md](typeset.md#reference-material) for inspiration)
- **Extreme scale**: Create dramatic size jumps (3x-5x differences, not 1.5x)
- **Weight contrast**: Pair 900 weights with 200 weights, not 600 with 400
- **Unexpected choices**: Variable fonts, display fonts for headlines, condensed/extended widths, monospace as intentional accent (not as lazy "dev tool" default)
### Color Intensification
- **Increase saturation**: Shift to more vibrant, energetic colors (but not neon)
- **Bold palette**: Introduce unexpected color combinations—avoid the purple-blue gradient AI slop
- **Bold palette**: Introduce unexpected color combinations. Avoid the purple-blue gradient AI slop
- **Dominant color strategy**: Let one bold color own 60% of the design
- **Sharp accents**: High-contrast accent colors that pop
- **Tinted neutrals**: Replace pure grays with tinted grays that harmonize with your palette
@@ -73,15 +73,15 @@ Systematically increase impact across these dimensions:
### Visual Effects
- **Dramatic shadows**: Large, soft shadows for elevation (but not generic drop shadows on rounded rectangles)
- **Background treatments**: Mesh patterns, noise textures, geometric patterns, intentional gradients (not purple-to-blue)
- **Texture & depth**: Grain, halftone, duotone, layered elementsNOT glassmorphism (it's overused AI slop)
- **Texture & depth**: Grain, halftone, duotone, layered elements. NOT glassmorphism (it's overused AI slop)
- **Borders & frames**: Thick borders, decorative frames, custom shapes (not rounded rectangles with colored border on one side)
- **Custom elements**: Illustrative elements, custom icons, decorative details that reinforce brand
### Motion & Animation
- **Entrance choreography**: Staggered, dramatic page load animations with 50-100ms delays
- **Scroll effects**: Parallax, reveal animations, scroll-triggered sequences
- **Micro-interactions**: Satisfying hover effects, click feedback, state changes
- **Transitions**: Smooth, noticeable transitions using ease-out-quart/quint/expo (not bounce or elastic—they cheapen the effect)
- **Hero moment**: One signature entrance, once. Not on every visit and not on every section.
- **Micro-interactions**: Satisfying hover effects, click feedback, state changes.
- **Transitions**: Smooth, noticeable transitions using ease-out-quart/quint/expo (not bounce or elastic, which cheapen the effect).
- **Bolder ≠ scroll-fade-rise on every section.** That's the saturated AI default, the opposite of bold.
### Composition Boldness
- **Hero moments**: Create clear focal points with dramatic treatment
@@ -92,7 +92,7 @@ Systematically increase impact across these dimensions:
**NEVER**:
- Add effects randomly without purpose (chaos ≠ bold)
- Sacrifice readability for aesthetics (body text must be readable)
- Make everything bold (then nothing is bold - need contrast)
- Make everything bold (then nothing is bold; you need contrast)
- Ignore accessibility (bold design must still meet WCAG standards)
- Overwhelm with motion (animation fatigue is real)
- Copy trendy aesthetics blindly (bold means distinctive, not derivative)
@@ -110,4 +110,4 @@ Ensure amplification maintains usability and coherence:
**The test**: If you showed this to someone and said "AI made this bolder," would they believe you immediately? If yes, you've failed. Bold means distinctive, not "more AI effects."
Remember: Bold design is confident design. It takes risks, makes statements, and creates memorable experiences. But bold without strategy is just loud. Be intentional, be dramatic, be unforgettable.
When the result feels right, hand off to `$impeccable polish` for the final pass.
+33 -29
View File
@@ -1,47 +1,52 @@
# Brand register
When design IS the product: brand sites, landing pages, marketing surfaces, campaign pages, portfolios, long-form content, about pages. The deliverable is the design itself a visitor's impression is the thing being made.
When design IS the product: brand sites, landing pages, marketing surfaces, campaign pages, portfolios, long-form content, about pages. The deliverable is the design itself; a visitor's impression is the thing being made.
The register spans every genre. A tech brand (Stripe, Linear, Vercel). A luxury brand (a hotel, a fashion house). A consumer product (a restaurant, a travel site, a CPG packaging page). A creative studio, an agency portfolio, a band's album page. They all share the stance *communicate, not transact* and diverge wildly in aesthetic. Don't collapse them into a single look.
The register spans every genre. A tech brand (Stripe, Linear, Vercel). A luxury brand (a hotel, a fashion house). A consumer product (a restaurant, a travel site, a CPG packaging page). A creative studio, an agency portfolio, a band's album page. They all share the stance (*communicate, not transact*) and diverge wildly in aesthetic. Don't collapse them into a single look.
## The brand slop test
If someone could look at this and say "AI made that" without hesitation, it's failed. The bar is distinctiveness a visitor should ask "how was this made?", not "which AI made this?"
If someone could look at this and say "AI made that" without hesitation, it's failed. The bar is distinctiveness; a visitor should ask "how was this made?", not "which AI made this?"
Brand isn't a neutral register. AI-generated landing pages have flooded the internet, and average is no longer findable. Restraint without intent now reads as mediocre, not refined. Brand surfaces need a POV, a specific audience, a willingness to risk strangeness. Go big or go home.
**The second slop test: aesthetic lane.** Before committing to moves, name the reference. A Klim-style specimen page is one lane; Stripe-minimal is another; Liquid-Death-acid-maximalism is another. Don't drift into editorial-magazine aesthetics on a brief that isn't editorial. A hiking brand with Cormorant italic drop caps has the wrong register within the register.
Then the inverse test: in one sentence, describe what you're about to build the way a competitor would describe theirs. If that sentence fits the modal landing page in the category, restart.
## Typography
### Font selection procedure
Every project. Never skip.
1. Read the brief. Write three concrete brand-voice words — not "modern" or "elegant," but "warm and mechanical and opinionated" or "calm and clinical and careful." Physical-object words.
2. List the three fonts you'd reach for by reflex. If any appear in the reflex-reject list below, reject them they are training-data defaults and they create monoculture.
3. Browse a real catalog (Google Fonts, Pangram Pangram, Future Fonts, Adobe Fonts, ABC Dinamo, Klim, Velvetyne) with the three words in mind. Find the font for the brand as a *physical object* a museum caption, a 1970s terminal manual, a fabric label, a cheap-newsprint children's book, a concert poster, a receipt from a mid-century diner. Reject the first thing that "looks designy."
1. Read the brief. Write three concrete brand-voice words. Not "modern" or "elegant," but "warm and mechanical and opinionated" or "calm and clinical and careful." Physical-object words.
2. List the three fonts you'd reach for by reflex. If any appear in the reflex-reject list below, reject them; they are training-data defaults and they create monoculture.
3. Browse a real catalog (Google Fonts, Pangram Pangram, Future Fonts, Adobe Fonts, ABC Dinamo, Klim, Velvetyne) with the three words in mind. Find the font for the brand as a *physical object*: a museum caption, a 1970s terminal manual, a fabric label, a cheap-newsprint children's book, a concert poster, a receipt from a mid-century diner. Reject the first thing that "looks designy."
4. Cross-check. "Elegant" is not necessarily serif. "Technical" is not necessarily sans. "Warm" is not Fraunces. If the final pick lines up with the original reflex, start over.
### Reflex-reject list
Training-data defaults. Ban list — look further:
Training-data defaults. Ban list. Look further:
Fraunces · Newsreader · Lora · Crimson · Crimson Pro · Crimson Text · Playfair Display · Cormorant · Cormorant Garamond · Syne · IBM Plex Mono · IBM Plex Sans · IBM Plex Serif · Space Mono · Space Grotesk · Inter · DM Sans · DM Serif Display · DM Serif Text · Outfit · Plus Jakarta Sans · Instrument Sans · Instrument Serif
### Reflex-reject aesthetic lanes
Parallel to the font list. Currently saturated aesthetic families that have flooded brand surfaces. If a brief lands in one of these lanes without a register reason that *requires* it (a literal magazine, a literal terminal, a literal industrial signage system), it's the second-order training reflex: the trap one tier deeper than picking a Fraunces font. Look further.
- **Editorial-typographic.** Display serif (often italic) + small mono labels + ruled separators + monochromatic restraint. Klim-influenced, magazine-cover affectation. By 2026, every Stripe-adjacent and Notion-adjacent brand has landed here. The fingerprint: three rule-separated columns, an italic Fraunces / Recoleta / Newsreader headline, lowercase track-spaced metadata, no imagery.
(More entries land here on the same cadence the font list updates. Brutalist-utility and acid-maximalism may join when they saturate. Removing entries when they fall back below saturation is also fine.)
The reflex-reject lists apply to **new design choices**. When the existing brand has already committed to a font or a lane as part of its identity, identity-preservation wins; variants on an existing surface don't second-guess what's already shipping. The reflex-reject lists are for greenfield decisions and for departure-mode variants in [live.md](live.md).
### Pairing and voice
Distinctive + refined is the goal — the specific shape depends on the brand:
- **Editorial / long-form / luxury**: display serif + sans body (a magazine shape).
- **Tech / dev tools / fintech**: one committed sans, usually; custom-tight tracking, strong weight contrast inside a single family.
- **Consumer / food / travel**: warmer pairings, often a humanist sans plus a script or display serif.
- **Creative studios / agencies**: rule-breaking welcome — mono-only, or display-only, or custom-drawn type as voice.
Distinctive + refined is the goal. The specific shape depends on the brand, not on the brand's category. A category ("restaurant", "dev tool", "magazine", "fintech") is not a recipe; treating it as one is the first-order reflex SKILL.md warns against.
Two families minimum is the rule *only* when the voice needs it. A single well-chosen family with committed weight/size contrast is stronger than a timid display+body pair.
Vary across projects. If the last brief was a serif-display landing page, this one isn't.
### Scale
Modular scale, fluid `clamp()` for headings, ≥1.25 ratio between steps. Flat scales (1.1× apart) read as uncommitted.
@@ -50,38 +55,37 @@ Light text on dark backgrounds: add 0.050.1 to line-height. Light type reads
## Color
Brand surfaces have permission for Committed, Full palette, and Drenched strategies. Use them. A single saturated color spread across a hero is not excess it's voice. A beige-and-muted-slate landing page ignores the register.
Brand surfaces have permission for Committed, Full palette, and Drenched strategies. Use them. A single saturated color spread across a hero is not excess; it's voice. A beige-and-muted-slate landing page ignores the register.
- Name a real reference before picking a strategy. "Klim Type Foundry #ff4500 orange drench", "Stripe purple-on-white restraint", "Liquid Death acid-green full palette", "Mailchimp yellow full palette", "Condé Nast Traveler muted navy restraint", "Vercel pure black monochrome". Unnamed ambition becomes beige.
- Palette IS voice. A calm brand and a restless brand should not share palette mechanics.
- When the strategy is Committed or Drenched, the color is load-bearing. Don't hedge with neutrals around the edges — commit.
- When the strategy is Committed or Drenched, color carries the brand. Don't hedge with neutrals around the edges. Commit.
- Don't converge across projects. If the last brand surface was restrained-on-cream, this one is not.
- When a cultural-symbol palette is the obvious pull, reach past it. Let the cultural reading come from typography, imagery, and copy, not the palette.
## Layout
- Asymmetric compositions are one option. Break the grid intentionally for emphasis.
- Fluid spacing with `clamp()` that breathes on larger viewports. Vary for rhythm generous separations, tight groupings.
- Alternative: a strict, visible grid as the voice (brutalist / Swiss / tech-spec aesthetics). Either asymmetric or rigorously-gridded can be "designed" — the failure mode is splitting the difference into a generic centered stack.
- Don't default to centering everything. Left-aligned with asymmetric layouts feels more designed; a strict grid reads as confident structure. A centered-stack hero with icon-title-subtitle cards reads as template.
- When cards ARE the right affordance, use `grid-template-columns: repeat(auto-fit, minmax(280px, 1fr))` — breakpoint-free responsiveness.
- Fluid spacing with `clamp()` that breathes on larger viewports. Vary for rhythm: generous separations, tight groupings.
- For image-led briefs (hotels, restaurants, magazines, photography), full-bleed hero imagery with overlaid menu and centered headline is a canonical move; let the photograph be the design.
- When cards ARE the right affordance, use `grid-template-columns: repeat(auto-fit, minmax(280px, 1fr))` for breakpoint-free responsiveness.
## Imagery
Brand surfaces lean on imagery. A restaurant, hotel, magazine, or product landing page without any imagery reads as incomplete, not as restrained. A solid-color rectangle where a hero image should go is worse than a representative stock photo.
**When the brief implies imagery (restaurants, hotels, magazines, photography, hobbyist communities, food, travel, fashion, product), you must ship imagery.** Zero images is a bug, not a design choice. "Restraint" is not an excuse.
**When the brief implies imagery (restaurants, hotels, magazines, photography, hobbyist communities, food, travel, fashion, product), you must ship imagery.** Zero images is a bug, not a design choice. "Restraint" is not an excuse. If the approved comp or brief is image-led, ship real project assets, generated raster assets, or a credible canvas/SVG/WebGL scene. Do not replace photographic, architectural, product, or place imagery with generic CSS panels, decorative diagrams, cards, bullets, or copy.
- **For greenfield work without local assets, use stock imagery** Unsplash is the default. The URL shape is `https://images.unsplash.com/photo-{id}?auto=format&fit=crop&w=1600&q=80`. Pick real Unsplash photo IDs you're confident exist (`photo-1559339352-11d035aa65de`, `photo-1590490360182-c33d57733427`, etc.); if unsure, pick fewer photos but don't substitute colored `<div>` placeholders.
- **For greenfield work without local assets, use stock imagery.** Unsplash is the default. The URL shape is `https://images.unsplash.com/photo-{id}?auto=format&fit=crop&w=1600&q=80`. **Verify the URLs before referencing them.** If you have an image-search MCP, web-fetch tool, or browser access, use it to find real photo IDs and confirm they resolve. Guessed IDs (even ones that look real) often 404 and ship as broken-image placeholders. Without a verification path, pick fewer photos you're confident exist over more that you guessed; never substitute colored `<div>` placeholders.
- **Search for the brand's physical object**, not the generic category: "handmade pasta on a scratched wooden table" beats "Italian food"; "cypress trees above a limestone hotel facade at dusk" beats "luxury hotel".
- **One decisive photo beats five mediocre ones.** Hero imagery should commit to a mood; padding with more stock doesn't rescue an indecisive one.
- **Alt text is part of the voice.** "Coastal fettuccine, hand-cut, served on the terrace" beats "pasta dish".
Tech / dev-tool brands are the exception where zero imagery can be correct — a developer landing page often carries its voice through typography, code samples, diagrams. Know which kind of brand you're working on.
"Imagery" here is broader than stock photography: product screenshots, custom data visualizations, generated SVG, and canvas/WebGL scenes are all imagery. Text-only pages where typography alone carries the entire visual weight are the failure mode.
## Motion
- One well-orchestrated page-load with staggered reveals beats scattered micro-interactions when the brand invites it. Tech-minimal brands often skip entrance motion entirely; the restraint is the voice.
- For collapsing/expanding sections, transition `grid-template-rows` rather than `height`.
- One well-orchestrated page-load beats scattered micro-interactions, when the brand invites it. Some brands skip entrance motion entirely; the restraint is the voice.
## Brand bans (on top of the shared absolute bans)
@@ -92,13 +96,13 @@ Tech / dev-tool brands are the exception where zero imagery can be correct — a
- Timid palettes and average layouts. Safe = invisible.
- Zero imagery on a brief that implies imagery (restaurant, hotel, food, travel, fashion, photography, hobbyist). Colored blocks where a hero photo belongs.
- Defaulting to editorial-magazine aesthetics (display serif + italic + drop caps + broadsheet grid) on briefs that aren't magazine-shaped. Editorial is ONE aesthetic lane, not the default brand aesthetic.
- Repeated tiny uppercase tracked labels above every section heading. A single strong kicker can be voice; repeating it as section grammar is AI scaffolding unless it's a deliberate, named brand system.
## Brand permissions
Brand can afford things product can't. Take them.
- Ambitious first-load motion. Reveals, scroll-triggered transitions, typographic choreography.
- Ambitious first-load motion. Reveals and typographic choreography that earn their place; not fade-on-scroll for every section.
- Single-purpose viewports. One dominant idea per fold, long scroll, deliberate pacing.
- Typographic risk. Enormous display type, unexpected italic cuts, mixed cases, hand-drawn headlines, a single oversize word as a hero.
- Unexpected color strategies. Palette IS voice — a calm brand and a restless brand should not share palette mechanics.
- Unexpected color strategies. Palette IS voice; a calm brand and a restless brand should not share palette mechanics.
- Art direction per section. Different sections can have different visual worlds if the narrative demands it. Consistency of voice beats consistency of treatment.
+117 -3
View File
@@ -1,6 +1,6 @@
> **Additional context needed**: audience technical level and users' mental state in context.
Identify and improve unclear, confusing, or poorly written interface text to make the product easier to understand and use.
Find the unclear, confusing, or poorly written interface text and rewrite it. Vague copy creates support tickets and abandonment; specific copy gets users through the task.
---
@@ -146,7 +146,7 @@ Every piece of copy should follow these rules:
2. **Be concise**: Cut unnecessary words (but don't sacrifice clarity)
3. **Be active**: "Save changes" not "Changes will be saved"
4. **Be human**: "Oops, something went wrong" not "System error encountered"
5. **Be helpful**: Tell users what to do, not just what happened
5. **Tell users what to do**, not just what happened
6. **Be consistent**: Use same terms throughout (don't vary for variety)
**NEVER**:
@@ -171,4 +171,118 @@ Test that copy improvements work:
- **Consistency**: Does it match terminology elsewhere?
- **Tone**: Is it appropriate for the situation?
Remember: You're a clarity expert with excellent communication skills. Write like you're explaining to a smart friend who's unfamiliar with the product. Be clear, be helpful, be human.
When the copy reads cleanly, hand off to `$impeccable polish` for the final pass.
---
## Reference Material
The sections below were previously `ux-writing.md` and live inline now so the clarify flow has its deep UX-writing reference in one place.
### UX Writing
#### The Button Label Problem
**Never use "OK", "Submit", or "Yes/No".** These are lazy and ambiguous. Use specific verb + object patterns:
| Bad | Good | Why |
|-----|------|-----|
| OK | Save changes | Says what will happen |
| Submit | Create account | Outcome-focused |
| Yes | Delete message | Confirms the action |
| Cancel | Keep editing | Clarifies what "cancel" means |
| Click here | Download PDF | Describes the destination |
**For destructive actions**, name the destruction:
- "Delete" not "Remove" (delete is permanent, remove implies recoverable)
- "Delete 5 items" not "Delete selected" (show the count)
#### Error Messages: The Formula
Every error message should answer: (1) What happened? (2) Why? (3) How to fix it? Example: "Email address isn't valid. Please include an @ symbol." not "Invalid input".
##### Error Message Templates
| Situation | Template |
|-----------|----------|
| **Format error** | "[Field] needs to be [format]. Example: [example]" |
| **Missing required** | "Please enter [what's missing]" |
| **Permission denied** | "You don't have access to [thing]. [What to do instead]" |
| **Network error** | "We couldn't reach [thing]. Check your connection and [action]." |
| **Server error** | "Something went wrong on our end. We're looking into it. [Alternative action]" |
##### Don't Blame the User
Reframe errors: "Please enter a date in MM/DD/YYYY format" not "You entered an invalid date".
#### Empty States Are Opportunities
Empty states are onboarding moments: (1) Acknowledge briefly, (2) Explain the value of filling it, (3) Provide a clear action. "No projects yet. Create your first one to get started." not just "No items".
#### Voice vs Tone
**Voice** is your brand's personality, consistent everywhere.
**Tone** adapts to the moment.
| Moment | Tone Shift |
|--------|------------|
| Success | Celebratory, brief: "Done! Your changes are live." |
| Error | Empathetic, helpful: "That didn't work. Here's what to try..." |
| Loading | Reassuring: "Saving your work..." |
| Destructive confirm | Serious, clear: "Delete this project? This can't be undone." |
**Never use humor for errors.** Users are already frustrated. Be helpful, not cute.
#### Writing for Accessibility
**Link text** must have standalone meaning: "View pricing plans" not "Click here". **Alt text** describes information, not the image: "Revenue increased 40% in Q4" not "Chart". Use `alt=""` for decorative images. **Icon buttons** need `aria-label` for screen reader context.
#### Writing for Translation
##### Plan for Expansion
German text is ~30% longer than English. Allocate space:
| Language | Expansion |
|----------|-----------|
| German | +30% |
| French | +20% |
| Finnish | +30-40% |
| Chinese | -30% (fewer chars, but same width) |
##### Translation-Friendly Patterns
Keep numbers separate ("New messages: 3" not "You have 3 new messages"). Use full sentences as single strings (word order varies by language). Avoid abbreviations ("5 minutes ago" not "5 mins ago"). Give translators context about where strings appear.
#### Consistency: The Terminology Problem
Pick one term and stick with it:
| Inconsistent | Consistent |
|--------------|------------|
| Delete / Remove / Trash | Delete |
| Settings / Preferences / Options | Settings |
| Sign in / Log in / Enter | Sign in |
| Create / Add / New | Create |
Build a terminology glossary and enforce it. Variety creates confusion.
#### Avoid Redundant Copy
If the heading explains it, the intro is redundant. If the button is clear, don't explain it again. Say it once, say it well.
#### Loading States
Be specific: "Saving your draft..." not "Loading...". For long waits, set expectations ("This usually takes 30 seconds") or show progress.
#### Confirmation Dialogs: Use Sparingly
Most confirmation dialogs are design failures; consider undo instead. When you must confirm: name the action, explain consequences, use specific button labels ("Delete project" / "Keep project", not "Yes" / "No").
#### Form Instructions
Show format with placeholders, not instructions. For non-obvious fields, explain why you're asking.
---
**Avoid**: Jargon without explanation. Blaming users ("You made an error" → "This field is required"). Vague errors ("Something went wrong"). Varying terminology for variety. Humor for errors.
@@ -0,0 +1,105 @@
# Codex: Visual Direction & Asset Production
This file is loaded by `$impeccable craft` when the harness has native image generation (currently Codex via `image_gen`). Other harnesses skip it. It covers the two craft steps that depend on real image generation: landing the visual direction, and producing the raster assets the implementation will compose.
Read this *before* generating any images. The order matters, and the per-step user pauses are what keep generated imagery from drifting away from the brief.
### Four stop points before code
Steps A through D each end with the user. Do not advance past any of them on your own read of the situation.
1. **STOP after Step A questions.** Wait for answers.
2. **STOP after Step B palette generation.** Wait for "confirm palette."
3. **STOP after Step C mocks.** Wait for direction approval or delegation.
4. **Only after Step D approves a direction** do you return to craft.md Step 4 and write code.
Prior shape approval does **not** satisfy any of these. Shape's "confirm or override" advances you into Step A; it is not a substitute for it.
## Step A: Explore Directions with the User
Before generating anything, run a brief direction conversation grounded in the shape brief.
**Step A is required even when shape just produced a confirmed brief.** The shape questions and Step A questions cover different ground: shape pins purpose, content, scope; Step A pins palette, atmosphere, and named visual references for the comps you're about to generate. The only time you can skip Step A is when the user has already answered these exact palette/atmosphere/reference questions in the same session.
Ask **2-3 targeted questions** about visual lane, color strategy, atmosphere, and named anchor references. Don't enumerate generic menus; tie each question to the shape brief's answers. Example shape-grounded questions:
- "Brief says 'specimen-page restraint.' Are we closer to a quiet typographic page or a wider editorial spread with hero imagery?"
- "Palette strategy from shape was 'Committed.' Which one color carries the surface (a brand-driven pick rather than a default warm-or-cool framing)? (And no, the answer isn't a cream/sand body bg; that's the saturated AI default.)"
**STOP and wait for answers.** These pin the palette before any pixel gets generated. Do not proceed to Step B until the user has responded.
## Step B: Generate the Brand Palette First
Generate **one** palette artifact before any mocks. This is a small, focused image: typography pairing on the chosen background, primary + accent color swatches, one signature ornament or motif. Single image, single pass.
Why palette first: mocks generated against a vague color sense produce noise that drowns out the structural decisions. A confirmed palette is the first concrete contract for everything downstream.
Show the palette to the user. Ask one question: "This is the palette I'm locking in for the mocks. Confirm, or call out what to shift?"
**STOP and wait for confirmation.** Do not generate mocks against an unconfirmed palette. "Probably good enough" is the wrong call here; the palette is the contract for everything downstream.
## Step C: Generate 1-3 Visual Mocks Against the Palette
Once the palette is confirmed, generate **1 to 3** high-fidelity north-star comps. Each mock must use the confirmed palette and typography. Mocks differ in *structural* direction (hierarchy, topology, density, composition), not in color or motif.
- Brand work: push visual identity, composition, mood, and signature motifs.
- Product work: push hierarchy, topology, density, tone, grounded in realistic product structure.
- Landing pages and long-form brand surfaces: show enough of the second fold to establish the system beyond the hero.
Use the `image_gen` tool directly (or via the imagegen skill when available). Don't ask the user to install anything.
## Step D: Approval Loop
Show the comps. Ask what carries forward. Iterate until **one direction is approved** or the user explicitly delegates.
**STOP and wait for the approval or the delegation.** Do not begin Step E or return to craft.md Step 4 until a single direction is named. If the user delegates, pick the strongest direction and explain it from the brief, not personal taste.
Before moving to assets, summarize what to carry into code and what *not* to literalize from the mock. This is the handoff between visual exploration and semantic implementation.
## Step E: Mock Fidelity Inventory
Inventory the approved mock's major visible ingredients. For each, decide implementation: semantic HTML/CSS/SVG, generated raster, sourced raster, icon library, canvas/WebGL, or accepted omission.
Common ingredients to inventory:
- Hero silhouette and dominant composition
- Signature motifs (planets, devices, portraits, charts, route lines, insets, badges, etc.)
- Nav and primary CTA treatment
- Section sequence, especially the second fold
- Image-native content the concept depends on
- Typography, density, color/material treatment, motion cues
Treat the mock as a north star, not a screenshot to trace. Don't rasterize core UI text. But if the live result lacks the mock's major ingredients, the implementation is wrong.
If a photographic, architectural, product, or place-led mock becomes generic CSS scenery, decorative diagrams, bullets, or copy, stop and fix it. That's a broken implementation, not a harmless interpretation.
Don't substitute a different hero composition or visual driver post-approval without user sign-off.
## Step F: Asset Slicing via the Asset Producer
Raster ingredients identified in Step E need clean production assets. Use the bundled `impeccable_asset_producer` subagent rather than producing inline.
Spawn it as a scoped subagent. If you do not have explicit permission to use agents, stop and ask:
```text
Asset production will work better as a scoped subagent job. Should I spawn the Impeccable asset producer subagent for this step?
```
Pass to the agent:
- Approved mock path or screenshot reference
- Crop paths or a contact sheet with crop ids
- Output directory
- Required dimensions, format, transparency needs
- Avoid list
- Notes on what should remain semantic HTML/CSS/SVG instead of raster
Attach image generation capability to the spawned agent when the harness supports it. Do **not** load image-generation reference material into the parent thread.
Inline asset production is allowed only if the user declines subagents, the harness cannot spawn the authorized agent, or the user explicitly asks for single-thread mode.
Prefer HTML/CSS/SVG/canvas when they can credibly reproduce an ingredient; reach for real, generated, or stock imagery when the mock or subject matter calls for actual visual content.
## After This File
Once Steps A through F are complete, return to `craft.md` Step 5 (Build to Production Quality). The implementation builds against the confirmed palette, approved mock, and the assets the producer wrote.
@@ -1,106 +0,0 @@
# Cognitive Load Assessment
Cognitive load is the total mental effort required to use an interface. Overloaded users make mistakes, get frustrated, and leave. This reference helps identify and fix cognitive overload.
---
## Three Types of Cognitive Load
### Intrinsic Load — The Task Itself
Complexity inherent to what the user is trying to do. You can't eliminate this, but you can structure it.
**Manage it by**:
- Breaking complex tasks into discrete steps
- Providing scaffolding (templates, defaults, examples)
- Progressive disclosure — show what's needed now, hide the rest
- Grouping related decisions together
### Extraneous Load — Bad Design
Mental effort caused by poor design choices. **Eliminate this ruthlessly** — it's pure waste.
**Common sources**:
- Confusing navigation that requires mental mapping
- Unclear labels that force users to guess meaning
- Visual clutter competing for attention
- Inconsistent patterns that prevent learning
- Unnecessary steps between user intent and result
### Germane Load — Learning Effort
Mental effort spent building understanding. This is *good* cognitive load — it leads to mastery.
**Support it by**:
- Progressive disclosure that reveals complexity gradually
- Consistent patterns that reward learning
- Feedback that confirms correct understanding
- Onboarding that teaches through action, not walls of text
---
## Cognitive Load Checklist
Evaluate the interface against these 8 items:
- [ ] **Single focus**: Can the user complete their primary task without distraction from competing elements?
- [ ] **Chunking**: Is information presented in digestible groups (≤4 items per group)?
- [ ] **Grouping**: Are related items visually grouped together (proximity, borders, shared background)?
- [ ] **Visual hierarchy**: Is it immediately clear what's most important on the screen?
- [ ] **One thing at a time**: Can the user focus on a single decision before moving to the next?
- [ ] **Minimal choices**: Are decisions simplified (≤4 visible options at any decision point)?
- [ ] **Working memory**: Does the user need to remember information from a previous screen to act on the current one?
- [ ] **Progressive disclosure**: Is complexity revealed only when the user needs it?
**Scoring**: Count the failed items. 01 failures = low cognitive load (good). 23 = moderate (address soon). 4+ = high cognitive load (critical fix needed).
---
## The Working Memory Rule
**Humans can hold ≤4 items in working memory at once** (Miller's Law revised by Cowan, 2001).
At any decision point, count the number of distinct options, actions, or pieces of information a user must simultaneously consider:
- **≤4 items**: Within working memory limits — manageable
- **57 items**: Pushing the boundary — consider grouping or progressive disclosure
- **8+ items**: Overloaded — users will skip, misclick, or abandon
**Practical applications**:
- Navigation menus: ≤5 top-level items (group the rest under clear categories)
- Form sections: ≤4 fields visible per group before a visual break
- Action buttons: 1 primary, 12 secondary, group the rest in a menu
- Dashboard widgets: ≤4 key metrics visible without scrolling
- Pricing tiers: ≤3 options (more causes analysis paralysis)
---
## Common Cognitive Load Violations
### 1. The Wall of Options
**Problem**: Presenting 10+ choices at once with no hierarchy.
**Fix**: Group into categories, highlight recommended, use progressive disclosure.
### 2. The Memory Bridge
**Problem**: User must remember info from step 1 to complete step 3.
**Fix**: Keep relevant context visible, or repeat it where it's needed.
### 3. The Hidden Navigation
**Problem**: User must build a mental map of where things are.
**Fix**: Always show current location (breadcrumbs, active states, progress indicators).
### 4. The Jargon Barrier
**Problem**: Technical or domain language forces translation effort.
**Fix**: Use plain language. If domain terms are unavoidable, define them inline.
### 5. The Visual Noise Floor
**Problem**: Every element has the same visual weight — nothing stands out.
**Fix**: Establish clear hierarchy: one primary element, 23 secondary, everything else muted.
### 6. The Inconsistent Pattern
**Problem**: Similar actions work differently in different places.
**Fix**: Standardize interaction patterns. Same type of action = same type of UI.
### 7. The Multi-Task Demand
**Problem**: Interface requires processing multiple simultaneous inputs (reading + deciding + navigating).
**Fix**: Sequence the steps. Let the user do one thing at a time.
### 8. The Context Switch
**Problem**: User must jump between screens/tabs/modals to gather info for a single decision.
**Fix**: Co-locate the information needed for each decision. Reduce back-and-forth.
@@ -1,105 +0,0 @@
# Color & Contrast
## Color Spaces: Use OKLCH
**Stop using HSL.** Use OKLCH (or LCH) instead. It's perceptually uniform, meaning equal steps in lightness *look* equal—unlike HSL where 50% lightness in yellow looks bright while 50% in blue looks dark.
The OKLCH function takes three components: `oklch(lightness chroma hue)` where lightness is 0-100%, chroma is roughly 0-0.4, and hue is 0-360. To build a primary color and its lighter / darker variants, hold the chroma+hue roughly constant and vary the lightness — but **reduce chroma as you approach white or black**, because high chroma at extreme lightness looks garish.
The hue you pick is a brand decision and should not come from a default. Do not reach for blue (hue 250) or warm orange (hue 60) by reflex — those are the dominant AI-design defaults, not the right answer for any specific brand.
## Building Functional Palettes
### Tinted Neutrals
**Pure gray is dead.** A neutral with zero chroma feels lifeless next to a colored brand. Add a tiny chroma value (0.005-0.015) to all your neutrals, hued toward whatever your brand color is. The chroma is small enough not to read as "tinted" consciously, but it creates subconscious cohesion between brand color and UI surfaces.
The hue you tint toward should come from THIS project's brand, not from a "warm = friendly, cool = tech" formula. If your brand color is teal, your neutrals lean toward teal. If your brand color is amber, they lean toward amber. The point is cohesion with the SPECIFIC brand, not a stock palette.
**Avoid** the trap of always tinting toward warm orange or always tinting toward cool blue. Those are the two laziest defaults and they create their own monoculture across projects.
### Palette Structure
A complete system needs:
| Role | Purpose | Example |
|------|---------|---------|
| **Primary** | Brand, CTAs, key actions | 1 color, 3-5 shades |
| **Neutral** | Text, backgrounds, borders | 9-11 shade scale |
| **Semantic** | Success, error, warning, info | 4 colors, 2-3 shades each |
| **Surface** | Cards, modals, overlays | 2-3 elevation levels |
**Skip secondary/tertiary unless you need them.** Most apps work fine with one accent color. Adding more creates decision fatigue and visual noise.
### The 60-30-10 Rule (Applied Correctly)
This rule is about **visual weight**, not pixel count:
- **60%**: Neutral backgrounds, white space, base surfaces
- **30%**: Secondary colors—text, borders, inactive states
- **10%**: Accent—CTAs, highlights, focus states
The common mistake: using the accent color everywhere because it's "the brand color." Accent colors work *because* they're rare. Overuse kills their power.
## Contrast & Accessibility
### WCAG Requirements
| Content Type | AA Minimum | AAA Target |
|--------------|------------|------------|
| Body text | 4.5:1 | 7:1 |
| Large text (18px+ or 14px bold) | 3:1 | 4.5:1 |
| UI components, icons | 3:1 | 4.5:1 |
| Non-essential decorations | None | None |
**The gotcha**: Placeholder text still needs 4.5:1. That light gray placeholder you see everywhere? Usually fails WCAG.
### Dangerous Color Combinations
These commonly fail contrast or cause readability issues:
- Light gray text on white (the #1 accessibility fail)
- **Gray text on any colored background**—gray looks washed out and dead on color. Use a darker shade of the background color, or transparency
- Red text on green background (or vice versa)—8% of men can't distinguish these
- Blue text on red background (vibrates visually)
- Yellow text on white (almost always fails)
- Thin light text on images (unpredictable contrast)
### Never Use Pure Gray or Pure Black
Pure gray (`oklch(50% 0 0)`) and pure black (`#000`) don't exist in nature—real shadows and surfaces always have a color cast. Even a chroma of 0.005-0.01 is enough to feel natural without being obviously tinted. (See tinted neutrals example above.)
### Testing
Don't trust your eyes. Use tools:
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
- Browser DevTools → Rendering → Emulate vision deficiencies
- [Polypane](https://polypane.app/) for real-time testing
## Theming: Light & Dark Mode
### Dark Mode Is Not Inverted Light Mode
You can't just swap colors. Dark mode requires different design decisions:
| Light Mode | Dark Mode |
|------------|-----------|
| Shadows for depth | Lighter surfaces for depth (no shadows) |
| Dark text on light | Light text on dark (reduce font weight) |
| Vibrant accents | Desaturate accents slightly |
| White backgrounds | Never pure black—use dark gray (oklch 12-18%) |
In dark mode, depth comes from surface lightness, not shadow. Build a 3-step surface scale where higher elevations are lighter (e.g. 15% / 20% / 25% lightness). Use the SAME hue and chroma as your brand color (whatever it is for THIS project — do not reach for blue) and only vary the lightness. Reduce body text weight slightly (e.g. 350 instead of 400) because light text on dark reads as heavier than dark text on light.
### Token Hierarchy
Use two layers: primitive tokens (`--blue-500`) and semantic tokens (`--color-primary: var(--blue-500)`). For dark mode, only redefine the semantic layer—primitives stay the same.
## Alpha Is A Design Smell
Heavy use of transparency (rgba, hsla) usually means an incomplete palette. Alpha creates unpredictable contrast, performance overhead, and inconsistency. Define explicit overlay colors for each context instead. Exception: focus rings and interactive states where see-through is needed.
---
**Avoid**: Relying on color alone to convey information. Creating palettes without clear roles for each color. Using pure black (#000) for large areas. Skipping color blindness testing (8% of men affected).
+116 -13
View File
@@ -1,14 +1,14 @@
> **Additional context needed**: existing brand colors.
Strategically introduce color to designs that are too monochromatic, gray, or lacking in visual warmth and personality.
Replace timid grayscale or single-accent designs with a strategic palette: pick a color strategy, choose a hue family that fits the brand, then apply color with intent. More color ≠ better. Strategic color beats rainbow vomit.
---
## Register
Brand: palette IS voice. Pick a color strategy first per SKILL.md (Restrained / Committed / Full palette / Drenched) and follow its dosage. Committed, Full palette, and Drenched deliberately exceed the ≤10% rule that rule is Restrained only. Unexpected combinations are allowed; a dominant color can own the page when the chosen strategy calls for it.
Brand: palette IS voice. Pick a color strategy first per SKILL.md (Restrained / Committed / Full palette / Drenched) and follow its dosage. Committed, Full palette, and Drenched deliberately exceed the ≤10% rule; that rule is Restrained only. Unexpected combinations are allowed; a dominant color can own the page when the chosen strategy calls for it.
Product: semantic-first and almost always Restrained. Accent color is reserved for primary action, current selection, and state indicators — not decoration. Every color has a consistent meaning across every screen.
Product: semantic-first and almost always Restrained. Accent color is reserved for primary action, current selection, and state indicators. Not decoration. Every color has a consistent meaning across every screen.
---
@@ -30,7 +30,7 @@ Analyze the current state and identify opportunities:
- **Wayfinding**: Helping users navigate and understand structure
- **Delight**: Moments of visual interest and personality
If any of these are unclear from the codebase, ask the user directly to clarify what you cannot infer.
If any of these are unclear from the codebase, STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer.
**CRITICAL**: More color ≠ better. Strategic color beats rainbow vomit every time. Every color should have a purpose.
@@ -68,10 +68,10 @@ Add color systematically across these dimensions:
- **Hover states**: Introduce color on interaction
### Background & Surfaces
- **Tinted backgrounds**: Replace pure gray (`#f5f5f5`) with warm neutrals (`oklch(97% 0.01 60)`) or cool tints (`oklch(97% 0.01 250)`)
- **Tinted backgrounds**: If you replace pure gray, tint toward the brand hue, not toward a generic-warm-or-cool pair. The default-warm-tint (`oklch(97% 0.01 60)` and its neighbors) is now the AI cream/sand giveaway. Be specific to the brand or stay neutral.
- **Colored sections**: Use subtle background colors to separate areas
- **Gradient backgrounds**: Add depth with subtle, intentional gradients (not generic purple-blue)
- **Cards & surfaces**: Tint cards or surfaces slightly for warmth
- **Cards & surfaces**: Tint cards or surfaces toward the brand, not "for warmth" by reflex
**Use OKLCH for color**: It's perceptually uniform, meaning equal steps in lightness *look* equal. Great for generating harmonious scales.
@@ -81,13 +81,13 @@ Add color systematically across these dimensions:
- **Comparison**: Color coding for different datasets or timeframes
### Borders & Accents
- **Hairline borders**: 1px colored borders on full perimeter (not side-stripes see the absolute ban on `border-left/right > 1px`)
- **Hairline borders**: 1px colored borders on full perimeter (not side-stripes; see the absolute ban on `border-left/right > 1px`)
- **Underlines**: Color underlines for emphasis or active states
- **Dividers**: Subtle colored dividers instead of gray lines
- **Focus rings**: Colored focus indicators matching brand
- **Surface tints**: A 4-8% background wash of the accent color instead of a stripe
**NEVER**: `border-left` or `border-right` greater than 1px as a colored accent stripe. This is one of the three absolute bans in the parent skill. If you want to mark a card as "active" or "warning", use a full hairline border, a background tint, a leading glyph, or a numbered prefix — not a side stripe.
**NEVER**: `border-left` or `border-right` greater than 1px as a colored accent stripe. This is one of the three absolute bans in the parent skill. If you want to mark a card as "active" or "warning", use a full hairline border, a background tint, a leading glyph, or a numbered prefix. Not a side stripe.
### Typography Color
- **Colored headings**: Use brand colors for section headings (maintain contrast)
@@ -123,9 +123,7 @@ Ensure color addition improves rather than overwhelms:
**NEVER**:
- Use every color in the rainbow (choose 2-4 colors beyond neutrals)
- Apply color randomly without semantic meaning
- Put gray text on colored backgrounds—it looks washed out; use a darker shade of the background color or transparency instead
- Use pure gray for neutrals—add subtle color tint (warm or cool) for sophistication
- Use pure black (`#000`) or pure white (`#fff`) for large areas
- Put gray text on colored backgrounds. It looks washed out; use a darker shade of the background color or transparency instead
- Violate WCAG contrast requirements
- Use color as the only indicator (accessibility issue)
- Make everything colorful (defeats the purpose)
@@ -141,14 +139,119 @@ Test that colorization improves the experience:
- **Still accessible**: Do all color combinations meet WCAG standards?
- **Not overwhelming**: Is color balanced and purposeful?
Remember: Color is emotional and powerful. Use it to create warmth, guide attention, communicate meaning, and express personality. But restraint and strategy matter more than saturation and variety. Be colorful, but be intentional.
When the palette earns its place, hand off to `$impeccable polish` for the final pass.
## Live-mode signature params
When invoked from live mode, each variant MUST declare a `color-amount` param so the user can dial between a restrained accent and a drenched surface without regeneration. Author the variant's CSS against `var(--p-color-amount, 0.5)` typically as the alpha multiplier on backgrounds, or as a scaling factor on the chroma axis in an OKLCH expression. 0 = neutral/monochrome, 1 = full saturation / dominant coverage.
When invoked from live mode, each variant MUST declare a `color-amount` param so the user can dial between a restrained accent and a drenched surface without regeneration. Author the variant's CSS against `var(--p-color-amount, 0.5)`, typically as the alpha multiplier on backgrounds, or as a scaling factor on the chroma axis in an OKLCH expression. 0 = neutral/monochrome, 1 = full saturation / dominant coverage.
```json
{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"}
```
Layer 1-2 variant-specific params on top: palette selection (`steps` with named options), temperature warmth, or tint vs. true color. See `reference/live.md` for the full params contract.
---
## Reference Material
The sections below were previously `color-and-contrast.md` and live inline now so the colorize flow has its deep color reference in one place.
### Color & Contrast
#### Color Spaces: Use OKLCH
**Stop using HSL.** Use OKLCH (or LCH) instead. It's perceptually uniform, meaning equal steps in lightness *look* equal, unlike HSL where 50% lightness in yellow looks bright while 50% in blue looks dark.
The OKLCH function takes three components: `oklch(lightness chroma hue)` where lightness is 0-100%, chroma is roughly 0-0.4, and hue is 0-360. To build a primary color and its lighter / darker variants, hold the chroma+hue roughly constant and vary the lightness, but **reduce chroma as you approach white or black**, because high chroma at extreme lightness looks garish.
The hue you pick is a brand decision and should not come from a default. Do not reach for blue (hue 250) or warm orange (hue 60) by reflex; those are the dominant AI-design defaults, not the right answer for any specific brand.
#### Building Functional Palettes
##### Tinted Neutrals
**Pure gray is dead.** A neutral with zero chroma feels lifeless next to a colored brand. Add a tiny chroma value (0.005-0.015) to all your neutrals, hued toward whatever your brand color is. The chroma is small enough not to read as "tinted" consciously, but it creates subconscious cohesion between brand color and UI surfaces.
The hue you tint toward should come from THIS project's brand, not from a "warm = friendly, cool = tech" formula. If your brand color is teal, your neutrals lean toward teal. If your brand color is amber, they lean toward amber. The point is cohesion with the SPECIFIC brand, not a stock palette.
**Avoid** the trap of always tinting toward warm orange or always tinting toward cool blue. Those are the two laziest defaults and they create their own monoculture across projects.
##### Palette Structure
A complete system needs:
| Role | Purpose | Example |
|------|---------|---------|
| **Primary** | Brand, CTAs, key actions | 1 color, 3-5 shades |
| **Neutral** | Text, backgrounds, borders | 9-11 shade scale |
| **Semantic** | Success, error, warning, info | 4 colors, 2-3 shades each |
| **Surface** | Cards, modals, overlays | 2-3 elevation levels |
**Skip secondary/tertiary unless you need them.** Most apps work fine with one accent color. Adding more creates decision fatigue and visual noise.
##### The 60-30-10 Rule (Applied Correctly)
This rule is about **visual weight**, not pixel count:
- **60%**: Neutral backgrounds, white space, base surfaces
- **30%**: Secondary colors: text, borders, inactive states
- **10%**: Accent: CTAs, highlights, focus states
The common mistake: using the accent color everywhere because it's "the brand color." Accent colors work *because* they're rare. Overuse kills their power.
#### Contrast & Accessibility
##### WCAG Requirements
| Content Type | AA Minimum | AAA Target |
|--------------|------------|------------|
| Body text | 4.5:1 | 7:1 |
| Large text (18px+ or 14px bold) | 3:1 | 4.5:1 |
| UI components, icons | 3:1 | 4.5:1 |
| Non-essential decorations | None | None |
##### Dangerous Color Combinations
These commonly fail contrast or cause readability issues:
- Light gray text on white (the #1 accessibility fail)
- Red text on green background (or vice versa): 8% of men can't distinguish these
- Blue text on red background (vibrates visually)
- Yellow text on white (almost always fails)
- Thin light text on images (unpredictable contrast)
##### Testing
Don't trust your eyes. Use tools:
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
- Browser DevTools → Rendering → Emulate vision deficiencies
- [Polypane](https://polypane.app/) for real-time testing
#### Theming: Light & Dark Mode
##### Dark Mode Is Not Inverted Light Mode
You can't just swap colors. Dark mode requires different design decisions:
| Light Mode | Dark Mode |
|------------|-----------|
| Shadows for depth | Lighter surfaces for depth (no shadows) |
| Dark text on light | Light text on dark (reduce font weight) |
| Vibrant accents | Desaturate accents slightly |
| White backgrounds | Either pure black or a deep surface that fits the brand (a brand-tinted near-black at oklch 12-18% works too) |
In dark mode, depth comes from surface lightness, not shadow. Build a 3-step surface scale where higher elevations are lighter (e.g. 15% / 20% / 25% lightness). Use the SAME hue and chroma as your brand color (whatever it is for THIS project; do not reach for blue) and only vary the lightness. Reduce body text weight slightly (e.g. 350 instead of 400) because light text on dark reads as heavier than dark text on light.
##### Token Hierarchy
Use two layers: primitive tokens (`--blue-500`) and semantic tokens (`--color-primary: var(--blue-500)`). For dark mode, only redefine the semantic layer; primitives stay the same.
#### Alpha Is A Design Smell
Heavy use of transparency (rgba, hsla) usually means an incomplete palette. Alpha creates unpredictable contrast, performance overhead, and inconsistency. Define explicit overlay colors for each context instead. Exception: focus rings and interactive states where see-through is needed.
---
**Avoid**: Relying on color alone to convey information. Creating palettes without clear roles for each color. Skipping color blindness testing (8% of men affected).
+86 -89
View File
@@ -1,126 +1,123 @@
# Craft Flow
Build a feature with impeccable UX and UI quality through a structured process: shape the design, load the right references, then build and iterate visually until the result is delightful.
Build a feature with impeccable UX and UI quality: shape the design, land the visual direction, build real production code, inspect and improve in-browser until it meets a high-end studio bar.
Before writing code, you need: PRODUCT.md loaded, register identified and the matching reference loaded, and a confirmed design direction for this task (either from `shape` or supplied by the user). PRODUCT.md is project context, not a task-specific brief.
Treat any approved visual direction (generated mock or stated reference) as a concrete contract for composition, hierarchy, density, atmosphere, signature motifs, and distinctive visual moves. Don't let mocks replace structure, copy, accessibility, or state design. But if the live result lacks the approved direction's major ingredients, the implementation is wrong.
### Gates: do not compress
Craft has **multiple user gates**, not one. When the harness has native image generation (Codex via `image_gen`), the gate sequence before code is:
1. **Shape brief confirmed** (Step 1)
2. **Direction questions answered** (codex.md Step A)
3. **Palette confirmed** (codex.md Step B)
4. **One mock direction approved or delegated** (codex.md Step D)
You must stop at every gate. **Shape confirmation alone is NOT a green light to start coding.** It is the green light to begin codex.md Step A. Compressing gates 2 through 4 because the shape brief felt complete is the dominant failure mode of this flow.
When the harness lacks native image generation, gates 2-4 collapse into the brief itself, and shape confirmation does advance straight to code.
## Step 0: Project Foundation
Before shape, before code: figure out what kind of project you're working in.
Look at the working directory. Run `ls`. Check for:
- An existing framework: `astro.config.mjs/ts`, `next.config.js/ts`, `nuxt.config.ts`, `svelte.config.js`, `vite.config.js/ts`, `package.json` with framework deps, `Cargo.toml` + Leptos/Yew, `Gemfile` + Rails. **If found, use it.** Do not start a parallel build, do not introduce a second framework, do not write to `dist/` or `build/` directly. Whatever pipeline the project has, respect it.
- An existing component library or design system: `src/components/`, `app/components/`, a `tokens.css` / `theme.ts`, an `astro.config` `integrations`. Read what's there before adding to it.
- An existing icon set: `lucide-react`, `@phosphor-icons/react`, `@iconify/*`, hand-rolled SVG sprites in `assets/icons/`. **Use what's already in the project**; don't introduce a second set.
If the directory is empty (greenfield), don't pick a framework silently. Ask the user via the AskUserQuestion tool, with sensible defaults framed by the brief:
```text
What should this be built on?
- Astro (default for content-led brand sites, landing pages, marketing surfaces)
- SvelteKit / Next.js / Nuxt (when the brief implies an app surface or significant interactivity)
- Single index.html (one-shot demo, prototype, or a deliberately framework-free experiment)
```
Default: Astro for brand briefs, the project's existing framework for product briefs. Ask once; don't re-ask mid-task.
## Step 1: Shape the Design
Run $impeccable shape, passing along whatever feature description the user provided.
Run $impeccable shape, passing along whatever feature description the user provided. Shape is **required** for craft; it is what produces a confirmed direction.
Wait for the design brief to be fully confirmed before proceeding. The brief is your blueprint, and every implementation decision should trace back to it.
Present the shape output and stop. Wait for the user to confirm, override, or course-correct before writing code.
If the user has already run $impeccable shape and has a confirmed design brief, skip this step and use the existing brief.
If the user already supplied a confirmed brief or ran shape separately, use it and skip this step.
When the original prompt + PRODUCT.md already answer scope, content, and visual direction with no real ambiguity, the shape output can be **compact** (3-5 bullets stating what you're building and the visual lane, ending with one or two specific questions or "confirm or override"). The full 10-section structured brief is reserved for genuinely ambiguous, multi-screen, or stakeholder-heavy tasks. Don't pad a clear brief into a long one to look thorough; equally, don't skip the pause to look efficient.
If the harness has native image generation (Codex), a compact shape's "confirm or override" advances to **Step 3 and the codex.md flow**, not to Step 4. Phrase the closing line accordingly: "Confirm or override; once we lock direction, I'll run a couple of palette and reference questions before generating any mocks." This stops the model from reading shape confirmation as code-green.
## Step 2: Load References
Based on the design brief's "Recommended References" section, consult the relevant impeccable reference files. At minimum, always consult:
- [spatial-design.md](spatial-design.md) for layout and spacing
- [typography.md](typography.md) for type hierarchy
- [layout.md](layout.md) for layout, spacing, grid, container queries, optical adjustments
- [typeset.md](typeset.md) for type hierarchy, font selection, web font loading, OpenType features (Reference Material section)
Then add references based on the brief's needs:
- Complex interactions or forms? Consult [interaction-design.md](interaction-design.md)
- Animation or transitions? Consult [motion-design.md](motion-design.md)
- Color-heavy or themed? Consult [color-and-contrast.md](color-and-contrast.md)
- Responsive requirements? Consult [responsive-design.md](responsive-design.md)
- Heavy on copy, labels, or errors? Consult [ux-writing.md](ux-writing.md)
- Animation or transitions? Consult [animate.md](animate.md) (Reference Material covers motion materials, durations, easing, perceived performance)
- Color-heavy or themed? Consult [colorize.md](colorize.md) (Reference Material covers OKLCH, palette structure, dark mode, contrast)
- Responsive requirements? Consult [adapt.md](adapt.md) (Reference Material covers breakpoints, input methods, safe areas, responsive images)
- Heavy on copy, labels, or errors? Consult [clarify.md](clarify.md) (Reference Material covers button labels, error formula, voice/tone, translation)
## Step 3: North Star Mock (Capability-Gated)
## Step 3: Visual Direction & Assets (Harness-Gated)
Before implementation, generate a small set of high-fidelity visual comps when all of these are true:
If the harness has **native image generation** (currently Codex via `image_gen`), this step is mandatory. **Stop and load [codex.md](codex.md)**. It covers palette generation, mock exploration, the approval loop, mock-fidelity inventory, and asset slicing via the `impeccable_asset_producer` subagent. Follow Steps A-F in that file, then return here for Step 4.
- The work is **net-new** or visually open-ended enough that composition exploration will improve the build.
- The brief's scope is **mid-fi, high-fi, or production-ready**.
- The current harness has **built-in image generation capability** (for example, Codex with a native image tool). Do **not** ask the user to set up external APIs, shell scripts, or one-off tooling just to do this.
If the harness lacks native image generation, **state in one line that the visual-direction-by-generation step is being skipped because the harness lacks native image generation, then proceed**. The one-line announcement is required; it forces a conscious decision instead of letting the step quietly evaporate. The brief is your only visual reference. Implement directly from it, treating any named anchor references and the brief's "Design Direction" as the contract.
When those conditions are met, this step is the default for **both brand and product work**.
Whether you generated mocks or not: don't replace required imagery with generic cards, bullets, emoji, fake metrics, decorative CSS panels, or filler copy. Image-led briefs (restaurants, hotels, magazines, photography, hobbyist communities, food, travel, fashion, product) need real or sourced imagery in the build, not CSS scenery.
### Purpose
## Step 4: Build to Production Quality
Use the mock step to find a stronger visual lane than code-first generation would reliably discover on its own. The brief remains authoritative on user, purpose, content, constraints, states, and anti-goals. The mock clarifies composition, hierarchy, density, typography, and visual tone.
**Precondition.** If Step 3 routed you to codex.md (native image generation available), Steps A through D in that file must be complete before any code: questions answered, palette confirmed, mocks generated, one direction approved or delegated. **Do not mention implementation, file paths, or patch plans until that's done.** A confirmed shape brief is not enough; the model that compressed those gates is the model that already failed this flow.
### What to generate
Implement the feature following the design brief. Build in passes so structure, visual system, states, motion/media, and responsive behavior each get deliberate attention. The list below is the definition of done, not inspiration.
Generate **1 to 3** high-fidelity north-star comps based on the confirmed brief.
### Production bar
- For brand work, push visual identity, composition, and mood aggressively.
- For product work, still push hierarchy, topology, density, and tone, but keep the comps grounded in realistic product structure and states.
- **Real content.** No placeholder copy, placeholder images, dead links, fake controls, or unused scaffold at presentation time.
- **Preserve the approved mock's major ingredients.** Missing hero objects, world/product imagery, section structure, CTA/nav treatment, or distinctive motifs are blocking defects unless the user accepted the change.
- **Semantic first.** Real headings, landmarks, labels, form associations, button/link semantics, accessible names, state announcements where needed.
- **Deliberate spacing and alignment.** No default gaps, arbitrary margins, unbalanced whitespace, or accidental optical misalignment.
- **Intentional typography.** Chosen loading strategy, clear hierarchy, readable measure, stable line breaks, no overflow at any width.
- **Realistic state coverage.** Default, hover, focus-visible, active, disabled, loading, error, success, empty, overflow, long/short text, first-run.
- **Finished interaction quality.** Keyboard paths, touch targets, feedback timing, scroll behavior, state transitions, no hover-only functionality.
- **Coherent icon set.** Use the project's established set; otherwise pick one library or use accessible text. Don't mix.
- **Respect the build pipeline.** Edit source files and run the project's build (`npm run build` or equivalent). Don't write to `build/` / `dist/` / `.next/` with `cat`, heredoc, or Bash redirects; that skips asset hashing, image optimization, code splitting, and CSS extraction, and produces output the dev server won't serve.
- **Verify image URLs before referencing them.** Use image-search MCP or web-fetch when available; guessed photo IDs ship as broken-image placeholders. Without verification, prefer fewer images you're confident about.
- **Optimized imagery and media.** Correct dimensions, useful alt text, lazy loading below the fold, modern formats when practical, responsive `srcset`/`picture` for raster, no project-referenced asset left outside the workspace.
- **Premium motion.** Use atmospheric blur, filter, mask, shadow, reveal when they improve the experience. Avoid casual layout-property animation, bound expensive effects, verify smoothness in-browser, respect reduced motion, and avoid choreography that blocks task completion.
- **Maintainable.** Reusable local patterns, clear component boundaries, project conventions. No rasterized UI text or one-off hacks when a local pattern exists.
- **Technically clean.** Production build passes, no console errors, no avoidable layout shift, no needless dependencies, no broken asset paths.
- **Ask when uncertain.** If a discovery materially changes the brief or approved direction, stop and ask. Don't guess.
The comps must be genuinely different in primary visual direction, not just color variants.
## Step 5: Iterate Visually
### After generation
Look at what you built like a designer would. Your eyes are whatever the harness gives you: a connected browser, a screenshotting tool, Playwright, or asking the user. Use them for responsive testing (mobile, tablet, desktop minimum) and general visual validation.
Choose a direction with the user, or if the user delegated the decision, pick the strongest one and explain why.
If your tool returns a file path, read the PNG back into the conversation. A screenshot you didn't read doesn't count.
Before moving to implementation, summarize:
For long-form brand surfaces, inspect major sections individually. Thumbnails hide spacing, clipping, and cascade defects.
- What to carry into code
- What **not** to literalize from the mock
After the first pass, write an honest critique against the brief, the approved mock's major ingredients (hero silhouette, motifs, imagery, nav/CTA, density), and impeccable's DON'Ts. Patch material defects and re-inspect. **Don't invent defects to demonstrate iteration.** A confident "first pass clean, shipping" beats a fake fix.
Treat the mock as a **north star**, not a screenshot to trace. Do **not** let it override the confirmed brief.
Actively check: responsive behavior (composes, not shrinks), every state (empty / error / loading / edge), craft details (spacing, alignment, hierarchy, contrast, motion timing, focus), performance basics. The exit bar: defensible in a high-end studio review.
## Step 4: Asset Extraction (Optional)
Detector or QA output is defect evidence only; never proof the work is finished.
If the chosen direction includes image-native visual ingredients that would materially improve the implementation, generate them as separate assets before building.
Good candidates:
- stickers
- badges
- seals
- tickets
- graphic labels
- textures
- abstract objects
- decorative marks
- non-semantic scene elements
Do **not** export assets for core UI text, navigation, body copy, or any structure that should stay semantic and editable in code.
Usually **1 to 5** extracted assets is enough. If the design can be built cleanly in HTML/CSS/SVG, prefer that over raster assets.
## Step 5: Build
Implement the feature following the design brief. Work in this order:
1. **Structure first**: HTML/semantic structure for the primary state. No styling yet.
2. **Layout and spacing**: Establish the spatial rhythm and visual hierarchy.
3. **Typography and color**: Apply the type scale and color system.
4. **Interactive states**: Hover, focus, active, disabled.
5. **Edge case states**: Empty, loading, error, overflow, first-run.
6. **Motion**: Purposeful transitions and animations (if appropriate).
7. **Responsive**: Adapt for different viewports. Don't just shrink; redesign for the context.
### During Build
- Test with real (or realistic) data at every step, not placeholder text
- Check each state as you build it, not all at the end
- If you discover a design question, stop and ask rather than guessing
- Every visual choice should trace back to something in the design brief or the chosen north-star direction
- Keep text semantic, layout real, and interactions accessible. Do not turn the mock into a pile of rasterized UI
- If assets were extracted, use them intentionally. They support the build; they do not replace interface structure
## Step 6: Visual Iteration
**This step is critical.** Do not stop after the first implementation pass.
Open the result in a browser window. If browser automation tools are available, use them to navigate to the page and visually inspect the result. If not, ask the user to open it and provide feedback.
Iterate through these checks visually:
1. **Does it match the brief?** Compare the live result against every section of the design brief. Fix discrepancies.
2. **Does it pass the AI slop test?** If someone saw this and said "AI made this," would they believe it immediately? If yes, it needs more design intention.
3. **Check against impeccable's DON'T guidelines.** Fix any anti-pattern violations.
4. **Check every state.** Navigate through empty, error, loading, and edge case states. Each one should feel intentional, not like an afterthought.
5. **Check responsive.** Resize the viewport. Does it adapt well or just shrink?
6. **Check the details.** Spacing consistency, type hierarchy clarity, color contrast, interactive feedback, motion timing.
After each round of fixes, visually verify again. **Repeat until you would be proud to show this to the user.** The bar is not "it works"; the bar is "this delights."
## Step 7: Present
## Step 6: Present
Present the result to the user:
- Show the feature in its primary state
- Summarize the browser/viewports checked and the most important fixes made after inspection
- Walk through the key states (empty, error, responsive)
- Explain design decisions that connect back to the design brief and, when used, the chosen north-star mock
- Explain design decisions that connect back to the design brief and, when used, the chosen north-star mock. Include any accepted deviations from the mock; do not hide unimplemented mock ingredients.
- Note any remaining limitations or follow-up risks honestly
- Ask: "What's working? What isn't?"
Iterate based on feedback. Good design is rarely right on the first pass.
+643 -66
View File
@@ -1,95 +1,100 @@
> **Additional context needed**: what the interface is trying to accomplish.
### Purpose
### Gather Assessments
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive/backlog for future commands.
Launch two independent assessments. **Neither may see the other's output** — this isolation is what makes the combined score honest. Running both in one head silently anchors them to each other; do not shortcut it for cost, speed, or context-size reasons.
### Hard Invariants
Delegate each assessment to a separate sub-agent (Claude Code's `Agent` tool, Codex's subagent spawning, etc.). Each returns structured findings as text. Do NOT output findings to the user yet.
- Assessment A (design review) and Assessment B (detector/browser evidence) are both required.
- Assessment A must finish before detector findings enter the parent synthesis context. Detector output is deterministic, but it still anchors judgment.
- If sub-agents are unavailable, fall back sequentially: finish and record Assessment A first, then run Assessment B, then synthesize.
- A skipped detector is a failed critique run unless `detect.mjs` is missing or crashes after a real attempt.
- Viewable targets require browser inspection when available.
- Any local server started only for critique visualization must run in the background, have a recorded stop method, and be stopped before final reporting unless the user asks to keep it.
- Do not claim a user-visible overlay exists unless script injection succeeded and the detector ran in the page.
Fall back to sequential in-head work only if the environment genuinely cannot spawn sub-agents.
### Setup
**Tab isolation**: When browser automation is available, each assessment MUST create its own new tab. Never reuse an existing tab, even if one is already open at the correct URL. This prevents the two assessments from interfering with each other's page state.
1. **Resolve the target** to a concrete file path or URL. Prefer a source path over a dev-server URL when both identify the same surface; ports drift, paths do not.
- "the homepage" -> `site/pages/index.astro` or `index.html`
- "the settings modal" -> the primary component file
- "this page" -> the current URL or source file
2. **Compute the slug**:
```bash
node .agents/skills/impeccable/scripts/critique-storage.mjs slug "<resolved-path-or-url>"
```
Keep it. If the command exits non-zero, skip persistence and trend for this run, but continue the critique.
3. **Read `.impeccable/critique/ignore.md`** if it exists. Drop matching findings silently; it is the only prior-run input critique consumes.
#### Assessment A: LLM Design Review
### Assessment Orchestration
Read the relevant source files (HTML, CSS, JS/TS) and, if browser automation is available, visually inspect the live page. **Create a new tab** for this; do not reuse existing tabs. After navigation, label the tab by setting the document title:
```javascript
document.title = '[LLM] ' + document.title;
```
Think like a design director. Evaluate:
Delegate Assessment A and Assessment B to separate sub-agents when possible. They must not see each other's output. Do not show findings to the user until synthesis.
**AI Slop Detection (CRITICAL)**: Does this look like every other AI-generated interface? Review against ALL **DON'T** guidelines from the parent impeccable skill (already loaded in this context). Check for AI color palette, gradient text, dark glows, glassmorphism, hero metric layouts, identical card grids, generic fonts, and all other tells. **The test**: If someone said "AI made this," would you believe them immediately?
Codex sub-agent gate:
- If `spawn_agent` is exposed and the user explicitly allowed sub-agents, delegation, or parallel agent work, spawn A and B immediately.
- If `spawn_agent` is exposed but the user did not explicitly allow sub-agents, ask exactly once: "Impeccable critique is designed to run two independent sub-agents for an unanchored assessment. May I use sub-agents for this critique?" Then stop until the user answers.
- If allowed, spawn A and B. If declined, run sequentially and report `Assessment independence: degraded (sub-agents declined by user)`.
- If `spawn_agent` is not exposed, do not ask; run sequentially and report `Assessment independence: degraded (spawn_agent unavailable in this session)`.
- If spawning fails after permission, run sequentially and report `Assessment independence: degraded (sub-agent spawn failed: <exact error>)`.
Prefer `fork_context: false` with self-contained prompts containing cwd, target, live URL, references, product context, and output contract. If using `fork_context: true`, omit `agent_type`, `model`, and `reasoning_effort`.
**Holistic Design Review**: visual hierarchy (eye flow, primary action clarity), information architecture (structure, grouping, cognitive load), emotional resonance (does it match brand and audience?), discoverability (are interactive elements obvious?), composition (balance, whitespace, rhythm), typography (hierarchy, readability, font choices), color (purposeful use, cohesion, accessibility), states & edge cases (empty, loading, error, success), microcopy (clarity, tone, helpfulness).
If browser automation is available, each assessment creates its own new tab. Never reuse an existing tab, even if it is already at the right URL.
**Cognitive Load** (consult [cognitive-load](cognitive-load.md)):
- Run the 8-item cognitive load checklist. Report failure count: 0-1 = low (good), 2-3 = moderate, 4+ = critical.
- Count visible options at each decision point. If >4, flag it.
- Check for progressive disclosure: is complexity revealed only when needed?
### Assessment A: Design Review
**Emotional Journey**:
- What emotion does this interface evoke? Is that intentional?
- **Peak-end rule**: Is the most intense moment positive? Does the experience end well?
- **Emotional valleys**: Check for anxiety spikes at high-stakes moments (payment, delete, commit). Are there design interventions (progress indicators, reassurance copy, undo options)?
Read relevant source files and visually inspect the live page when browser automation is available. Think like a design director.
**Nielsen's Heuristics** (consult [heuristics-scoring](heuristics-scoring.md)):
Score each of the 10 heuristics 0-4. This scoring will be presented in the report.
Evaluate:
- **AI slop**: Would someone believe "AI made this" immediately? Check all DON'T guidance from the parent Impeccable skill.
- **Holistic design**: hierarchy, IA, emotional fit, discoverability, composition, typography, color, accessibility, states, copy, and edge cases.
- **Cognitive load**: consult the [Cognitive Load Assessment](#cognitive-load-assessment) section below; report checklist failures and decision points with >4 visible options.
- **Emotional journey**: peak-end rule, emotional valleys, reassurance at high-stakes moments.
- **Nielsen heuristics**: consult the [Heuristics Scoring Guide](#heuristics-scoring-guide) section below; score all 10 heuristics 0-4.
Return structured findings covering: AI slop verdict, heuristic scores, cognitive load assessment, what's working (2-3 items), priority issues (3-5 with what/why/fix), minor observations, and provocative questions.
Return: AI slop verdict, heuristic scores, cognitive load, emotional journey, 2-3 strengths, 3-5 priority issues, persona red flags, minor observations, and provocative questions.
#### Assessment B: Automated Detection
### Assessment B: Detector + Browser Evidence
Run the bundled deterministic detector, which flags 25 specific patterns (AI slop tells + general design quality).
Run the bundled detector and browser visualization evidence. Assessment B is mandatory and must remain isolated from Assessment A until both are complete.
**CLI scan**:
CLI scan:
```bash
npx impeccable --json [--fast] [target]
node .agents/skills/impeccable/scripts/detect.mjs --json [--fast] [target]
```
- Pass HTML/JSX/TSX/Vue/Svelte files or directories as `[target]` (anything with markup). Do not pass CSS-only files.
- For URLs, skip the CLI scan (it requires Puppeteer). Use browser visualization instead.
- For large directories (200+ scannable files), use `--fast` (regex-only, skips jsdom)
- For 500+ files, narrow scope or ask the user
- Exit code 0 = clean, 2 = findings
- Pass markup files/directories as `[target]`; do not pass CSS-only files.
- For URLs, skip CLI scan and use browser visualization.
- For 200+ scannable files, use `--fast`; for 500+, narrow scope or ask.
- Exit code 0 = clean; 2 = findings.
- If the detector entrypoint is missing or fails to load, report deterministic scan unavailable and continue with browser/manual review.
**Browser visualization****required** when browser automation tools are available AND the target is a viewable page. The `[Human]` overlay tab is the user-facing deliverable; the critique is incomplete without it. Skip only if the target is not a viewable page (CSS-only file, non-browser target).
Browser visualization is required for a viewable target when browser automation is available. Use a localhost dev/static URL for local files; avoid `file://` unless the available browser explicitly supports this workflow. Overlay flow:
The overlay is a **visual aid for the user**. It highlights issues directly in their browser. Do NOT scroll through the page to screenshot overlays. Instead, read the console output to get the results programmatically.
1. Create a fresh tab and navigate.
2. Preflight mutable injection by setting `document.title` and appending a `<script>` tag. Read-only evaluate APIs do not count.
3. If mutation is unavailable, skip live server, browser presentation, and injection; report fallback signal.
4. If mutation is available, start `node .agents/skills/impeccable/scripts/live-server.mjs --background`, present the browser if supported, label `[Human]`, scroll top, inject `http://localhost:PORT/detect.js`, wait 2-3 seconds, read `impeccable` console messages, then stop the live server.
5. For multi-view targets, inject on 3-5 representative pages.
1. **Start the live detection server**:
```bash
npx impeccable live &
```
Note the port printed to stdout (auto-assigned). Use `--port=PORT` to fix it.
2. **Create a new tab** and navigate to the page (use dev server URL for local files, or direct URL). Do not reuse existing tabs.
3. **Label the tab** via `javascript_tool` so the user can distinguish it:
```javascript
document.title = '[Human] ' + document.title;
```
4. **Scroll to top** to ensure the page is scrolled to the very top before injection
5. **Inject** via `javascript_tool` (replace PORT with the port from step 1):
```javascript
const s = document.createElement('script'); s.src = 'http://localhost:PORT/detect.js'; document.head.appendChild(s);
```
6. Wait 2-3 seconds for the detector to render overlays
7. **Read results from console** using `read_console_messages` with pattern `impeccable`. The detector logs all findings with the `[impeccable]` prefix. Do NOT scroll through the page to take screenshots of the overlays.
8. **Cleanup**: Stop the live server when done:
```bash
npx impeccable live stop
```
Codex Browser note: Use the Browser skill. Do not spend a Browser attempt on `file://`. Only call `visibility.set(true)` after mutable script injection is confirmed for the `[Human]` overlay path; verify with `get()`. Use `tab.dev.logs({ filter: "impeccable" })` for console results. Its Playwright `evaluate(...)` surface is read-only; do not rely on it for mutation.
For multi-view targets, inject on 3-5 representative pages. If injection fails, continue with CLI results only.
Return: CLI findings JSON/counts, browser console findings if applicable, false positives, and skipped/failed browser steps with concrete reasons.
Return: CLI findings (JSON), browser console findings (if applicable), and any false positives noted.
After Assessment B returns usable CLI findings, reuse them. Do not rerun `detect.mjs` in the parent unless Assessment B failed, was truncated, or omitted count, rule names, or file locations.
Codex failure accounting: final Run Notes must include target slug, ignore list, assessment independence, CLI detector, browser visibility, overlay injection, live-server cleanup, temp-file cleanup, and any fallback signal used. Do not run repo status checks, late API spelunking, or unrelated verification after the report is assembled.
### Generate Combined Critique Report
Synthesize both assessments into a single report. Do NOT simply concatenate. Weave the findings together, noting where the LLM review and detector agree, where the detector caught issues the LLM missed, and where detector findings are false positives.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is only an archive/backlog for later commands.
Codex final-answer note: `$impeccable critique` produces a report artifact, so the final chat response should intentionally exceed the usual concise close-out style. Do not title the final response "Critique Summary" unless the user explicitly asked for a summary.
Structure your feedback as a design director would:
#### Design Health Score
> *Consult [heuristics-scoring](heuristics-scoring.md)*
> *Consult the [Heuristics Scoring Guide](#heuristics-scoring-guide) section below.*
Present the Nielsen's 10 heuristics scores as a table:
@@ -117,7 +122,7 @@ Be honest with scores. A 4 means genuinely excellent. Most real interfaces score
**Deterministic scan**: Summarize what the automated detector found, with counts and file locations. Note any additional issues the detector caught that you missed, and flag any false positives.
**Visual overlays** (if browser was used): Tell the user that overlays are now visible in the **[Human]** tab in their browser, highlighting the detected issues. Summarize what the console output reported.
**Visual overlays** (if injection succeeded): Tell the user that overlays are now visible in the **[Human]** tab in their browser, highlighting the detected issues. Summarize what the console output reported. If browser visualization was attempted but injection failed, say that no reliable user-visible overlay is available and report the fallback signal instead.
#### Overall Impression
A brief gut reaction: what works, what doesn't, and the single biggest opportunity.
@@ -128,16 +133,16 @@ Highlight 2-3 things done well. Be specific about why they work.
#### Priority Issues
The 3-5 most impactful design problems, ordered by importance.
For each issue, tag with **P0-P3 severity** (consult [heuristics-scoring](heuristics-scoring.md) for severity definitions):
For each issue, tag with **P0-P3 severity** (see [Issue Severity below](#issue-severity-p0p3) for definitions):
- **[P?] What**: Name the problem clearly
- **Why it matters**: How this hurts users or undermines goals
- **Fix**: What to do about it (be concrete)
- **Suggested command**: Which command could address this (from: $impeccable adapt, $impeccable animate, $impeccable audit, $impeccable bolder, $impeccable clarify, $impeccable colorize, $impeccable critique, $impeccable delight, $impeccable distill, $impeccable document, $impeccable harden, $impeccable layout, $impeccable onboard, $impeccable optimize, $impeccable overdrive, $impeccable polish, $impeccable quieter, $impeccable shape, $impeccable typeset)
#### Persona Red Flags
> *Consult [personas](personas.md)*
> *Consult the [Personas reference](#persona-based-design-testing) below.*
Auto-select 2-3 personas most relevant to this interface type (use the selection table in the reference). If `AGENTS.md` contains a `## Design Context` section from `impeccable teach`, also generate 1-2 project-specific personas from the audience/brand info.
Auto-select 2-3 personas most relevant to this interface type (use the selection table in the reference). If `AGENTS.md` contains a `## Design Context` section from `impeccable init`, also generate 1-2 project-specific personas from the audience/brand info.
For each selected persona, walk through the primary user action and list specific red flags found:
@@ -156,17 +161,56 @@ Provocative questions that might unlock better solutions:
- "Does this need to feel this complex?"
- "What would a confident version of this look like?"
#### Run Notes
Keep this compact. Include status for target slug, ignore list, assessment independence, CLI detector, browser visibility, overlay injection, live server cleanup, and temp-file cleanup. For failed or skipped steps, give the concrete observed reason and the fallback signal used. In the final chat response, also include snapshot write and trend read status after persistence has run.
Codex Run Notes are final-chat only. Do not include this section in the persisted snapshot body, because persistence, trend read, and temp cleanup happen after the snapshot write and would otherwise archive stale status such as "pending after persistence."
**Remember**:
- Be direct. Vague feedback wastes everyone's time.
- Be specific. "The submit button," not "some elements."
- Say what's wrong AND why it matters to users.
- Give concrete suggestions, not just "consider exploring..."
- Give concrete suggestions. Cut "consider exploring..." entirely.
- Prioritize ruthlessly. If everything is important, nothing is.
- Don't soften criticism. Developers need honest feedback to ship great design.
### Persist the Snapshot
Once the report above is finalized, write it to `.impeccable/critique/` so the user can refer back, and so `$impeccable polish` can pick up the priority issues without a copy-paste.
Skip this step if the Setup slug was null (vague or root-level target).
1. **Write the body to a temp file** so you can pipe it to the helper. Use the full critique report (heuristic table, anti-patterns verdict, priority issues, persona red flags, minor observations, and questions), but stop before the "Ask the User" / "Recommended Actions" sections that come later.
Codex: exclude Run Notes from the temp body file; Run Notes are final-chat only because persistence, trend read, and temp cleanup happen after the snapshot write.
2. **Pass the structured metadata** through `IMPECCABLE_CRITIQUE_META` (JSON), then run the write command:
```bash
IMPECCABLE_CRITIQUE_META='{"target":"<user phrasing>","total_score":<n>,"p0_count":<n>,"p1_count":<n>}' \
node .agents/skills/impeccable/scripts/critique-storage.mjs write <slug> <body-file>
```
The helper prints the absolute path it wrote.
3. **Delete the temp body file** after the write attempt completes, whether the write succeeded or failed. If deletion fails, mention `temp-file cleanup failed: <reason>` briefly in the final output, but do not block the critique.
4. **Read the trend** for context:
```bash
node .agents/skills/impeccable/scripts/critique-storage.mjs trend <slug> 5
```
This returns a JSON array of the last 5 frontmatter entries (including the one you just wrote).
5. **Append a single line to the user-visible output**, after the report and before the questions:
> **Trend for `<slug>` (last 5 runs): 24 → 28 → 32 → 29 → 32**
> Wrote `.impeccable/critique/<filename>`.
If this is the first run for the slug, the trend is just one score; say so: "First run for this target, no trend yet."
This is fire-and-forget. Do not show the user the helper's JSON output; only the human-readable trend line and the written path. Failures here should not block the rest of the flow; print the error and move on.
### Ask the User
**After presenting findings**, use targeted questions based on what was actually found. ask the user directly to clarify what you cannot infer. These answers will shape the action plan.
**After presenting findings**, use targeted questions based on what was actually found. STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. These answers will shape the action plan.
Ask questions along these lines (adapt to the specific findings; do NOT ask generic questions):
@@ -184,6 +228,8 @@ Ask questions along these lines (adapt to the specific findings; do NOT ask gene
- Offer concrete options, not open-ended prompts.
- If findings are straightforward (e.g., only 1-2 clear issues), skip questions and go directly to Recommended Actions.
Codex final-question gate: The user-visible response must either include the targeted questions or explicitly say `Questions skipped: <reason>` because the findings were straightforward. Each question must include 2-3 concrete answer options tied to the actual critique findings. Do not end with only open-ended questions.
### Recommended Actions
**After receiving the user's answers**, present a prioritized action summary reflecting the user's priorities and scope from Ask the User.
@@ -211,3 +257,534 @@ After presenting the summary, tell the user:
> You can ask me to run these one at a time, all at once, or in any order you prefer.
>
> Re-run `$impeccable critique` after fixes to see your score improve.
---
## Reference Material
The sections below were previously separate reference files (`cognitive-load.md`, `heuristics-scoring.md`, `personas.md`). They live inline now so the critique flow has all its deep context in one place.
### Cognitive Load Assessment
Cognitive load is the total mental effort required to use an interface. Overloaded users make mistakes, get frustrated, and leave. This reference helps identify and fix cognitive overload.
---
#### Three Types of Cognitive Load
##### Intrinsic Load: The Task Itself
Complexity inherent to what the user is trying to do. You can't eliminate this, but you can structure it.
**Manage it by**:
- Breaking complex tasks into discrete steps
- Providing scaffolding (templates, defaults, examples)
- Progressive disclosure: show what's needed now, hide the rest
- Grouping related decisions together
##### Extraneous Load: Bad Design
Mental effort caused by poor design choices. **Eliminate this ruthlessly.** It's pure waste.
**Common sources**:
- Confusing navigation that requires mental mapping
- Unclear labels that force users to guess meaning
- Visual clutter competing for attention
- Inconsistent patterns that prevent learning
- Unnecessary steps between user intent and result
##### Germane Load: Learning Effort
Mental effort spent building understanding. This is *good* cognitive load; it leads to mastery.
**Support it by**:
- Progressive disclosure that reveals complexity gradually
- Consistent patterns that reward learning
- Feedback that confirms correct understanding
- Onboarding that teaches through action, not walls of text
---
#### Cognitive Load Checklist
Evaluate the interface against these 8 items:
- [ ] **Single focus**: Can the user complete their primary task without distraction from competing elements?
- [ ] **Chunking**: Is information presented in digestible groups (≤4 items per group)?
- [ ] **Grouping**: Are related items visually grouped together (proximity, borders, shared background)?
- [ ] **Visual hierarchy**: Is it immediately clear what's most important on the screen?
- [ ] **One thing at a time**: Can the user focus on a single decision before moving to the next?
- [ ] **Minimal choices**: Are decisions simplified (≤4 visible options at any decision point)?
- [ ] **Working memory**: Does the user need to remember information from a previous screen to act on the current one?
- [ ] **Progressive disclosure**: Is complexity revealed only when the user needs it?
**Scoring**: Count the failed items. 01 failures = low cognitive load (good). 23 = moderate (address soon). 4+ = high cognitive load (critical fix needed).
---
#### The Working Memory Rule
**Humans can hold ≤4 items in working memory at once** (Miller's Law revised by Cowan, 2001).
At any decision point, count the number of distinct options, actions, or pieces of information a user must simultaneously consider:
- **≤4 items**: Within working memory limits, manageable
- **57 items**: Pushing the boundary; consider grouping or progressive disclosure
- **8+ items**: Overloaded; users will skip, misclick, or abandon
**Practical applications**:
- Navigation menus: ≤5 top-level items (group the rest under clear categories)
- Form sections: ≤4 fields visible per group before a visual break
- Action buttons: 1 primary, 12 secondary, group the rest in a menu
- Dashboard widgets: ≤4 key metrics visible without scrolling
- Pricing tiers: ≤3 options (more causes analysis paralysis)
---
#### Common Cognitive Load Violations
##### 1. The Wall of Options
**Problem**: Presenting 10+ choices at once with no hierarchy.
**Fix**: Group into categories, highlight recommended, use progressive disclosure.
##### 2. The Memory Bridge
**Problem**: User must remember info from step 1 to complete step 3.
**Fix**: Keep relevant context visible, or repeat it where it's needed.
##### 3. The Hidden Navigation
**Problem**: User must build a mental map of where things are.
**Fix**: Always show current location (breadcrumbs, active states, progress indicators).
##### 4. The Jargon Barrier
**Problem**: Technical or domain language forces translation effort.
**Fix**: Use plain language. If domain terms are unavoidable, define them inline.
##### 5. The Visual Noise Floor
**Problem**: Every element has the same visual weight; nothing stands out.
**Fix**: Establish clear hierarchy: one primary element, 23 secondary, everything else muted.
##### 6. The Inconsistent Pattern
**Problem**: Similar actions work differently in different places.
**Fix**: Standardize interaction patterns. Same type of action = same type of UI.
##### 7. The Multi-Task Demand
**Problem**: Interface requires processing multiple simultaneous inputs (reading + deciding + navigating).
**Fix**: Sequence the steps. Let the user do one thing at a time.
##### 8. The Context Switch
**Problem**: User must jump between screens/tabs/modals to gather info for a single decision.
**Fix**: Co-locate the information needed for each decision. Reduce back-and-forth.
---
### Heuristics Scoring Guide
Score each of Nielsen's 10 Usability Heuristics on a 04 scale. Be honest: a 4 means genuinely excellent, not "good enough."
#### Nielsen's 10 Heuristics
##### 1. Visibility of System Status
Keep users informed about what's happening through timely, appropriate feedback.
**Check for**:
- Loading indicators during async operations
- Confirmation of user actions (save, submit, delete)
- Progress indicators for multi-step processes
- Current location in navigation (breadcrumbs, active states)
- Form validation feedback (inline, not just on submit)
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | No feedback; user is guessing what happened |
| 1 | Rare feedback; most actions produce no visible response |
| 2 | Partial; some states communicated, major gaps remain |
| 3 | Good; most operations give clear feedback, minor gaps |
| 4 | Excellent; every action confirms, progress is always visible |
##### 2. Match Between System and Real World
Speak the user's language. Follow real-world conventions. Information appears in natural, logical order.
**Check for**:
- Familiar terminology (no unexplained jargon)
- Logical information order matching user expectations
- Recognizable icons and metaphors
- Domain-appropriate language for the target audience
- Natural reading flow (left-to-right, top-to-bottom priority)
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | Pure tech jargon, alien to users |
| 1 | Mostly confusing; requires domain expertise to navigate |
| 2 | Mixed; some plain language, some jargon leaks through |
| 3 | Mostly natural; occasional term needs context |
| 4 | Speaks the user's language fluently throughout |
##### 3. User Control and Freedom
Users need a clear "emergency exit" from unwanted states without extended dialogue.
**Check for**:
- Undo/redo functionality
- Cancel buttons on forms and modals
- Clear navigation back to safety (home, previous)
- Easy way to clear filters, search, selections
- Escape from long or multi-step processes
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | Users get trapped; no way out without refreshing |
| 1 | Difficult exits; must find obscure paths to escape |
| 2 | Some exits; main flows have escape, edge cases don't |
| 3 | Good control; users can exit and undo most actions |
| 4 | Full control; undo, cancel, back, and escape everywhere |
##### 4. Consistency and Standards
Users shouldn't wonder whether different words, situations, or actions mean the same thing.
**Check for**:
- Consistent terminology throughout the interface
- Same actions produce same results everywhere
- Platform conventions followed (standard UI patterns)
- Visual consistency (colors, typography, spacing, components)
- Consistent interaction patterns (same gesture = same behavior)
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | Inconsistent everywhere; feels like different products stitched together |
| 1 | Many inconsistencies; similar things look/behave differently |
| 2 | Partially consistent; main flows match, details diverge |
| 3 | Mostly consistent; occasional deviation, nothing confusing |
| 4 | Fully consistent; cohesive system, predictable behavior |
##### 5. Error Prevention
Better than good error messages is a design that prevents problems in the first place.
**Check for**:
- Confirmation before destructive actions (delete, overwrite)
- Constraints preventing invalid input (date pickers, dropdowns)
- Smart defaults that reduce errors
- Clear labels that prevent misunderstanding
- Autosave and draft recovery
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | Errors easy to make; no guardrails anywhere |
| 1 | Few safeguards; some inputs validated, most aren't |
| 2 | Partial prevention; common errors caught, edge cases slip |
| 3 | Good prevention; most error paths blocked proactively |
| 4 | Excellent; errors nearly impossible through smart constraints |
##### 6. Recognition Rather Than Recall
Minimize memory load. Make objects, actions, and options visible or easily retrievable.
**Check for**:
- Visible options (not buried in hidden menus)
- Contextual help when needed (tooltips, inline hints)
- Recent items and history
- Autocomplete and suggestions
- Labels on icons (not icon-only navigation)
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | Heavy memorization; users must remember paths and commands |
| 1 | Mostly recall; many hidden features, few visible cues |
| 2 | Some aids; main actions visible, secondary features hidden |
| 3 | Good recognition; most things discoverable, few memory demands |
| 4 | Everything discoverable; users never need to memorize |
##### 7. Flexibility and Efficiency of Use
Accelerators, invisible to novices, speed up expert interaction.
**Check for**:
- Keyboard shortcuts for common actions
- Customizable interface elements
- Recent items and favorites
- Bulk/batch actions
- Power user features that don't complicate the basics
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | One rigid path; no shortcuts or alternatives |
| 1 | Limited flexibility; few alternatives to the main path |
| 2 | Some shortcuts; basic keyboard support, limited bulk actions |
| 3 | Good accelerators; keyboard nav, some customization |
| 4 | Highly flexible; multiple paths, power features, customizable |
##### 8. Aesthetic and Minimalist Design
Interfaces should not contain irrelevant or rarely needed information. Every element should serve a purpose.
**Check for**:
- Only necessary information visible at each step
- Clear visual hierarchy directing attention
- Purposeful use of color and emphasis
- No decorative clutter competing for attention
- Focused, uncluttered layouts
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | Overwhelming; everything competes for attention equally |
| 1 | Cluttered; too much noise, hard to find what matters |
| 2 | Some clutter; main content clear, periphery noisy |
| 3 | Mostly clean; focused design, minor visual noise |
| 4 | Perfectly minimal; every element earns its pixel |
##### 9. Help Users Recognize, Diagnose, and Recover from Errors
Error messages should use plain language, precisely indicate the problem, and constructively suggest a solution.
**Check for**:
- Plain language error messages (no error codes for users)
- Specific problem identification ("Email is missing @" not "Invalid input")
- Actionable recovery suggestions
- Errors displayed near the source of the problem
- Non-blocking error handling (don't wipe the form)
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | Cryptic errors; codes, jargon, or no message at all |
| 1 | Vague errors; "Something went wrong" with no guidance |
| 2 | Clear but unhelpful; names the problem but not the fix |
| 3 | Clear with suggestions; identifies problem and offers next steps |
| 4 | Perfect recovery; pinpoints issue, suggests fix, preserves user work |
##### 10. Help and Documentation
Even if the system is usable without docs, help should be easy to find, task-focused, and concise.
**Check for**:
- Searchable help or documentation
- Contextual help (tooltips, inline hints, guided tours)
- Task-focused organization (not feature-organized)
- Concise, scannable content
- Easy access without leaving current context
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | No help available anywhere |
| 1 | Help exists but hard to find or irrelevant |
| 2 | Basic help; FAQ or docs exist, not contextual |
| 3 | Good documentation; searchable, mostly task-focused |
| 4 | Excellent contextual help; right info at the right moment |
---
#### Score Summary
**Total possible**: 40 points (10 heuristics × 4 max)
| Score Range | Rating | What It Means |
|-------------|--------|---------------|
| 3640 | Excellent | Minor polish only; ship it |
| 2835 | Good | Address weak areas, solid foundation |
| 2027 | Acceptable | Significant improvements needed before users are happy |
| 1219 | Poor | Major UX overhaul required; core experience broken |
| 011 | Critical | Redesign needed; unusable in current state |
---
#### Issue Severity (P0P3)
Tag each individual issue found during scoring with a priority level:
| Priority | Name | Description | Action |
|----------|------|-------------|--------|
| **P0** | Blocking | Prevents task completion entirely | Fix immediately; this is a showstopper |
| **P1** | Major | Causes significant difficulty or confusion | Fix before release |
| **P2** | Minor | Annoyance, but workaround exists | Fix in next pass |
| **P3** | Polish | Nice-to-fix, no real user impact | Fix if time permits |
**Tip**: If you're unsure between two levels, ask: "Would a user contact support about this?" If yes, it's at least P1.
---
### Persona-Based Design Testing
Test the interface through the eyes of 5 distinct user archetypes. Each persona exposes different failure modes that a single "design director" perspective would miss.
**How to use**: Select 23 personas most relevant to the interface being critiqued. Walk through the primary user action as each persona. Report specific red flags, not generic concerns.
---
#### 1. Impatient Power User: "Alex"
**Profile**: Expert with similar products. Expects efficiency, hates hand-holding. Will find shortcuts or leave.
**Behaviors**:
- Skips all onboarding and instructions
- Looks for keyboard shortcuts immediately
- Tries to bulk-select, batch-edit, and automate
- Gets frustrated by required steps that feel unnecessary
- Abandons if anything feels slow or patronizing
**Test Questions**:
- Can Alex complete the core task in under 60 seconds?
- Are there keyboard shortcuts for common actions?
- Can onboarding be skipped entirely?
- Do modals have keyboard dismiss (Esc)?
- Is there a "power user" path (shortcuts, bulk actions)?
**Red Flags** (report these specifically):
- Forced tutorials or unskippable onboarding
- No keyboard navigation for primary actions
- Slow animations that can't be skipped
- One-item-at-a-time workflows where batch would be natural
- Redundant confirmation steps for low-risk actions
---
#### 2. Confused First-Timer: "Jordan"
**Profile**: Never used this type of product. Needs guidance at every step. Will abandon rather than figure it out.
**Behaviors**:
- Reads all instructions carefully
- Hesitates before clicking anything unfamiliar
- Looks for help or support constantly
- Misunderstands jargon and abbreviations
- Takes the most literal interpretation of any label
**Test Questions**:
- Is the first action obviously clear within 5 seconds?
- Are all icons labeled with text?
- Is there contextual help at decision points?
- Does terminology assume prior knowledge?
- Is there a clear "back" or "undo" at every step?
**Red Flags** (report these specifically):
- Icon-only navigation with no labels
- Technical jargon without explanation
- No visible help option or guidance
- Ambiguous next steps after completing an action
- No confirmation that an action succeeded
---
#### 3. Accessibility-Dependent User: "Sam"
**Profile**: Uses screen reader (VoiceOver/NVDA), keyboard-only navigation. May have low vision, motor impairment, or cognitive differences.
**Behaviors**:
- Tabs through the interface linearly
- Relies on ARIA labels and heading structure
- Cannot see hover states or visual-only indicators
- Needs adequate color contrast (4.5:1 minimum)
- May use browser zoom up to 200%
**Test Questions**:
- Can the entire primary flow be completed keyboard-only?
- Are all interactive elements focusable with visible focus indicators?
- Do images have meaningful alt text?
- Is color contrast WCAG AA compliant (4.5:1 for text)?
- Does the screen reader announce state changes (loading, success, errors)?
**Red Flags** (report these specifically):
- Click-only interactions with no keyboard alternative
- Missing or invisible focus indicators
- Meaning conveyed by color alone (red = error, green = success)
- Unlabeled form fields or buttons
- Time-limited actions without extension option
- Custom components that break screen reader flow
---
#### 4. Deliberate Stress Tester: "Riley"
**Profile**: Methodical user who pushes interfaces beyond the happy path. Tests edge cases, tries unexpected inputs, and probes for gaps in the experience.
**Behaviors**:
- Tests edge cases intentionally (empty states, long strings, special characters)
- Submits forms with unexpected data (emoji, RTL text, very long values)
- Tries to break workflows by navigating backwards, refreshing mid-flow, or opening in multiple tabs
- Looks for inconsistencies between what the UI promises and what actually happens
- Documents problems methodically
**Test Questions**:
- What happens at the edges (0 items, 1000 items, very long text)?
- Do error states recover gracefully or leave the UI in a broken state?
- What happens on refresh mid-workflow? Is state preserved?
- Are there features that appear to work but produce broken results?
- How does the UI handle unexpected input (emoji, special chars, paste from Excel)?
**Red Flags** (report these specifically):
- Features that appear to work but silently fail or produce wrong results
- Error handling that exposes technical details or leaves UI in a broken state
- Empty states that show nothing useful ("No results" with no guidance)
- Workflows that lose user data on refresh or navigation
- Inconsistent behavior between similar interactions in different parts of the UI
---
#### 5. Distracted Mobile User: "Casey"
**Profile**: Using phone one-handed on the go. Frequently interrupted. Possibly on a slow connection.
**Behaviors**:
- Uses thumb only; prefers bottom-of-screen actions
- Gets interrupted mid-flow and returns later
- Switches between apps frequently
- Has limited attention span and low patience
- Types as little as possible, prefers taps and selections
**Test Questions**:
- Are primary actions in the thumb zone (bottom half of screen)?
- Is state preserved if the user leaves and returns?
- Does it work on slow connections (3G)?
- Can forms use autocomplete and smart defaults?
- Are touch targets at least 44×44pt?
**Red Flags** (report these specifically):
- Important actions positioned at the top of the screen (unreachable by thumb)
- No state persistence; progress lost on tab switch or interruption
- Large text inputs required where selection would work
- Heavy assets loading on every page (no lazy loading)
- Tiny tap targets or targets too close together
---
#### Selecting Personas
Choose personas based on the interface type:
| Interface Type | Primary Personas | Why |
|---------------|-----------------|-----|
| Landing page / marketing | Jordan, Riley, Casey | First impressions, trust, mobile |
| Dashboard / admin | Alex, Sam | Power users, accessibility |
| E-commerce / checkout | Casey, Riley, Jordan | Mobile, edge cases, clarity |
| Onboarding flow | Jordan, Casey | Confusion, interruption |
| Data-heavy / analytics | Alex, Sam | Efficiency, keyboard nav |
| Form-heavy / wizard | Jordan, Sam, Casey | Clarity, accessibility, mobile |
---
#### Project-Specific Personas
If `AGENTS.md` contains a `## Design Context` section (generated by `impeccable init`), derive 12 additional personas from the audience and brand information:
1. Read the target audience description
2. Identify the primary user archetype not covered by the 5 predefined personas
3. Create a persona following this template:
```
##### [Role]: "[Name]"
**Profile**: [2-3 key characteristics derived from Design Context]
**Behaviors**: [3-4 specific behaviors based on the described audience]
**Red Flags**: [3-4 things that would alienate this specific user type]
```
Only generate project-specific personas when real Design Context data is available. Don't invent audience details; use the 5 predefined personas when no context exists.
@@ -1,12 +1,12 @@
> **Additional context needed**: what's appropriate for the domain (playful vs professional vs quirky vs elegant).
Identify opportunities to add moments of joy, personality, and unexpected polish that transform functional interfaces into delightful experiences.
Find the moments where personality and unexpected polish would turn a functional interface into one users remember and tell other people about. Add only where the moment earns it; delight everywhere reads as noise.
---
## Register
Brand: delight can be distributed copy voice, section transitions, discovery rewards, seasonal touches, personality across the whole surface.
Brand: delight can be distributed across copy voice, section transitions, discovery rewards, seasonal touches, personality across the whole surface.
Product: delight at specific moments, not pages. Completion, first-time actions, error recovery, milestone crossings. Reliability and consistency carry the rest of the experience; delight pushed everywhere reads as noise.
@@ -37,7 +37,7 @@ Identify where delight would enhance (not distract from) the experience:
- **Helpful surprises**: Anticipating needs before users ask (productivity tools)
- **Sensory richness**: Satisfying sounds, smooth animations (creative tools)
If any of these are unclear from the codebase, ask the user directly to clarify what you cannot infer.
If any of these are unclear from the codebase, STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer.
**CRITICAL**: Delight should enhance usability, never obscure it. If users notice the delight more than accomplishing their goal, you've gone too far.
@@ -236,14 +236,14 @@ Add personality and joy through these methods:
- Countdown with encouraging messages
```
Loading messages write ones specific to your product, not generic AI filler:
Loading messages: write ones specific to your product, not generic AI filler:
- "Crunching your latest numbers..."
- "Syncing with your team's changes..."
- "Preparing your dashboard..."
- "Checking for updates since yesterday..."
```
**WARNING**: Avoid cliched loading messages like "Herding pixels", "Teaching robots to dance", "Consulting the magic 8-ball", "Counting backwards from infinity". These are AI-slop copy instantly recognizable as machine-generated. Write messages that are specific to what your product actually does.
**WARNING**: Avoid cliched loading messages like "Herding pixels", "Teaching robots to dance", "Consulting the magic 8-ball", "Counting backwards from infinity". These are AI-slop copy, instantly recognizable as machine-generated. Write messages that are specific to what your product actually does.
### Celebration Moments
@@ -299,4 +299,4 @@ Test that delight actually delights:
- **Appropriate**: Matches brand and context
- **Accessible**: Works with reduced motion, screen readers
Remember: Delight is the difference between a tool and an experience. Add personality, surprise users positively, and create moments worth sharing. But always respect usability - delight should enhance, never obstruct.
When the moments feel earned, hand off to `$impeccable polish` for the final pass.
@@ -1,4 +1,4 @@
Remove unnecessary complexity from designs, revealing the essential elements and creating clarity through ruthless simplification.
Strip a design to its essence. Remove anything that doesn't earn its place: redundant elements, repeated information, decorative noise, cosmetic complexity.
---
@@ -21,9 +21,9 @@ Analyze what makes the design feel complex or cluttered:
- What can be removed, hidden, or combined?
- What's the 20% that delivers 80% of value?
If any of these are unclear from the codebase, ask the user directly to clarify what you cannot infer.
If any of these are unclear from the codebase, STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer.
**CRITICAL**: Simplicity is not about removing features - it's about removing obstacles between users and their goals. Every element should justify its existence.
**CRITICAL**: Simplicity is not about removing features. It's about removing obstacles between users and their goals. Every element should justify its existence.
## Plan Simplification
@@ -51,7 +51,7 @@ Systematically remove complexity across these dimensions:
- **Reduce color palette**: Use 1-2 colors plus neutrals, not 5-7 colors
- **Limit typography**: One font family, 3-4 sizes maximum, 2-3 weights
- **Remove decorations**: Eliminate borders, shadows, backgrounds that don't serve hierarchy or function
- **Flatten structure**: Reduce nesting, remove unnecessary containersnever nest cards inside cards
- **Flatten structure**: Reduce nesting, remove unnecessary containers; never nest cards inside cards
- **Remove unnecessary cards**: Cards aren't needed for basic layout; use spacing and alignment instead
- **Consistent spacing**: Use one spacing scale, remove arbitrary gaps
@@ -108,4 +108,4 @@ If you removed features or options:
- Consider if they need alternative access points
- Note any user feedback to monitor
Remember: You have great taste and judgment. Simplification is an act of confidence - knowing what to keep and courage to remove the rest. As Antoine de Saint-Exupéry said: "Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away."
When the cuts feel right, hand off to `$impeccable polish` for the final pass. As Antoine de Saint-Exupéry put it: "Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away."
+60 -58
View File
@@ -44,8 +44,8 @@ Rules that matter:
- **Token refs** use `{path.to.token}` (e.g. `{colors.primary}`, `{rounded.md}`). Components may reference primitives; primitives may not reference each other.
- **Stitch validates colors as hex sRGB only** (`#RGB` / `#RGBA` / `#RRGGBB` / `#RRGGBBAA`); OKLCH/HSL/P3 trigger a linter warning, not a hard error. YAML accepts the string either way and our own parser is format-agnostic. Choose based on project posture: (a) if the project has an "OKLCH-only" doctrine or uses Display-P3 values that don't round-trip through sRGB, put OKLCH directly in the frontmatter and accept the Stitch linter warning; (b) if the project wants strict Stitch compliance or plans to use their Tailwind/DTCG export pipeline, put hex in the frontmatter and keep OKLCH in prose as the canonical reference. Never split the source of truth without explicit reason.
- **Component sub-tokens** are limited to 8 props: `backgroundColor`, `textColor`, `typography`, `rounded`, `padding`, `size`, `height`, `width`. Shadows, motion, focus rings, backdrop-filter none of those fit. Carry them in the sidecar (Step 4b).
- **Scale keys are open-ended.** Use whatever names the project already uses (`warm-ash-cream`, `surface-container-low`). Don't rename to Material defaults.
- **Component sub-tokens** are limited to 8 props: `backgroundColor`, `textColor`, `typography`, `rounded`, `padding`, `size`, `height`, `width`. Shadows, motion, focus rings, backdrop-filter: none of those fit. Carry them in the sidecar (Step 4b).
- **Scale keys are open-ended.** Use whatever names the project already uses (`oxblood-deep`, `surface-container-low`). Don't rename to Material defaults.
- **Variants are naming convention, not schema.** `button-primary` / `button-primary-hover` / `button-primary-active` as sibling keys.
## The markdown body: six sections (exact order)
@@ -57,23 +57,23 @@ Rules that matter:
5. `## Components`
6. `## Do's and Don'ts`
Optional evocative subtitles are allowed in the form `## 2. Colors: The [Name] Palette` Stitch's own outputs do this but the literal word in each header (Overview, Colors, Typography, Elevation, Components, Do's and Don'ts) must be present. Do NOT add extra top-level sections (Layout Principles, Responsive Behavior, Motion, Agent Prompt Guide). Fold that content into the six spec sections where it naturally belongs.
Optional evocative subtitles are allowed in the form `## 2. Colors: The [Name] Palette` (Stitch's own outputs do this), but the literal word in each header (Overview, Colors, Typography, Elevation, Components, Do's and Don'ts) must be present. Do NOT add extra top-level sections (Layout Principles, Responsive Behavior, Motion, Agent Prompt Guide). Fold that content into the six spec sections where it naturally belongs.
## When to run
- The user just ran `$impeccable teach` and needs the visual side documented.
- The user just ran `$impeccable init` and needs the visual side documented.
- The skill noticed no `DESIGN.md` exists and nudged the user to create one.
- An existing `DESIGN.md` is stale (the design has drifted).
- Before a large redesign, to capture the current state as a reference.
If a `DESIGN.md` already exists, **do not silently overwrite it**. Show the user the existing file and ask the user directly to clarify what you cannot infer. whether to refresh, overwrite, or merge.
If a `DESIGN.md` already exists, **do not silently overwrite it**. Show the user the existing file and STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. whether to refresh, overwrite, or merge.
## Two paths
- **Scan mode** (default): the project has design tokens, components, or rendered output. Extract, then confirm descriptive language. Use when there's code to analyze.
- **Seed mode**: the project is pre-implementation (fresh teach, nothing built yet). Interview for five high-level answers, write a minimal DESIGN.md marked `<!-- SEED -->`. Re-run in scan mode once there's code.
- **Seed mode**: the project is pre-implementation (fresh init, nothing built yet). Interview for five high-level answers, write a minimal DESIGN.md marked `<!-- SEED -->`. Re-run in scan mode once there's code.
Decide by scanning first (Scan mode Step 1). If the scan finds no tokens, no component files, and no rendered site, offer seed mode don't silently switch. `$impeccable document --seed` forces seed mode regardless of code presence.
Decide by scanning first (Scan mode Step 1). If the scan finds no tokens, no component files, and no rendered site, offer seed mode; don't silently switch. `$impeccable document --seed` forces seed mode regardless of code presence.
## Scan mode (approach C: auto-extract, then confirm descriptive language)
@@ -81,29 +81,29 @@ Decide by scanning first (Scan mode Step 1). If the scan finds no tokens, no com
Search the codebase in priority order:
1. **CSS custom properties** grep for `--color-`, `--font-`, `--spacing-`, `--radius-`, `--shadow-`, `--ease-`, `--duration-` declarations in CSS files (usually `src/styles/`, `public/css/`, `app/globals.css`, etc.). Record name, value, and the file it's defined in.
2. **Tailwind config** if `tailwind.config.{js,ts,mjs}` exists, read the `theme.extend` block for colors, fontFamily, spacing, borderRadius, boxShadow.
3. **CSS-in-JS theme files** styled-components, emotion, vanilla-extract, stitches: look for `theme.ts`, `tokens.ts`, or equivalent.
4. **Design token files** `tokens.json`, `design-tokens.json`, Style Dictionary output, W3C token community group format.
5. **Component library** scan the main button, card, input, navigation, dialog components. Note their variant APIs and default styles.
6. **Global stylesheet** the root CSS file usually has the base typography and color assignments.
7. **Visible rendered output** if browser automation tools are available, load the live site and sample computed styles from key elements (body, h1, a, button, .card). This catches values that tokens miss.
1. **CSS custom properties**: grep for `--color-`, `--font-`, `--spacing-`, `--radius-`, `--shadow-`, `--ease-`, `--duration-` declarations in CSS files (usually `src/styles/`, `public/css/`, `app/globals.css`, etc.). Record name, value, and the file it's defined in.
2. **Tailwind config**: if `tailwind.config.{js,ts,mjs}` exists, read the `theme.extend` block for colors, fontFamily, spacing, borderRadius, boxShadow.
3. **CSS-in-JS theme files**: styled-components, emotion, vanilla-extract, stitches; look for `theme.ts`, `tokens.ts`, or equivalent.
4. **Design token files**: `tokens.json`, `design-tokens.json`, Style Dictionary output, W3C token community group format.
5. **Component library**: scan the main button, card, input, navigation, dialog components. Note their variant APIs and default styles.
6. **Global stylesheet**: the root CSS file usually has the base typography and color assignments.
7. **Visible rendered output**: if browser automation tools are available, load the live site and sample computed styles from key elements (body, h1, a, button, .card). This catches values that tokens miss.
### Step 2: Auto-extract what can be auto-extracted
Build a structured draft from the discovered tokens. For each token class:
- **Colors**: Group into Primary / Secondary / Tertiary / Neutral (the Material-derived roles Stitch uses). If the project only has one accent, express it as Primary + Neutral omit Secondary and Tertiary rather than inventing them.
- **Colors**: Group into Primary / Secondary / Tertiary / Neutral (the Material-derived roles Stitch uses). If the project only has one accent, express it as Primary + Neutral; omit Secondary and Tertiary rather than inventing them.
- **Typography**: Map observed sizes and weights to the Material hierarchy (display / headline / title / body / label). Note font-family stacks and the scale ratio.
- **Elevation**: Catalogue the shadow vocabulary. If the project is flat and uses tonal layering instead, that's a valid answer state it explicitly.
- **Elevation**: Catalogue the shadow vocabulary. If the project is flat and uses tonal layering instead, that's a valid answer; state it explicitly.
- **Components**: For each common component (button, card, input, chip, list item, tooltip, nav), extract shape (radius), color assignment, hover/focus treatment, internal padding.
- **Spacing + layout**: Fold into Overview or relevant Components. The spec does NOT have a Layout section.
### Step 2b: Stage the frontmatter
From the auto-extracted tokens, draft the YAML frontmatter now (you'll write it at the top of DESIGN.md in Step 4). This is the machine-readable layer what the live panel and Stitch's linter consume.
From the auto-extracted tokens, draft the YAML frontmatter now (you'll write it at the top of DESIGN.md in Step 4). This is the machine-readable layer: what the live panel and Stitch's linter consume.
- **Colors**: one entry per extracted color. Key = descriptive slug (`warm-ash-cream`, `editorial-magenta`, not `blue-800`). Value = whichever format the project treats as canonical (OKLCH or hex see the frontmatter rules above). Don't split the source of truth: one format in the frontmatter, don't redefine the same token in prose with a different value.
- **Colors**: one entry per extracted color. Key = descriptive slug (`oxblood-deep`, `editorial-magenta`, not `blue-800`). Value = whichever format the project treats as canonical (OKLCH or hex; see the frontmatter rules above). Don't split the source of truth: one format in the frontmatter, don't redefine the same token in prose with a different value.
- **Typography**: one entry per role (`display`, `headline`, `title`, `body`, `label`). Typography is an object; include only the props that are real for the project (`fontFamily`, `fontSize`, `fontWeight`, `lineHeight`, `letterSpacing`, `fontFeature`, `fontVariation`).
- **Rounded / Spacing**: whatever scale steps the project actually uses, keyed by whatever scale name the project uses (`sm` / `md` / `lg`, or `surface-sm`, or numeric steps).
- **Components**: one entry per variant (`button-primary`, `button-primary-hover`, `button-ghost`). Reference primitives via `{colors.X}`, `{rounded.Y}`. If a variant needs a property Stitch's 8-prop set doesn't cover (shadow, focus ring, backdrop-filter), carry the full snippet in the sidecar instead.
@@ -149,7 +149,7 @@ colors:
### Primary
- **[Descriptive Name]** (#HEX / oklch(...)): [Where and why this color is used. Be specific about context, not just role.]
### Secondary (optional omit if the project has only one accent)
### Secondary (optional; omit if the project has only one accent)
- **[Descriptive Name]** (#HEX): [Role.]
### Tertiary (optional)
@@ -160,7 +160,7 @@ colors:
- [...]
### Named Rules (optional, powerful)
**The [Rule Name] Rule.** [Short, forceful prohibition or doctrine e.g. "The One Voice Rule. The primary accent is used on ≤10% of any given screen. Its rarity is the point."]
**The [Rule Name] Rule.** [Short, forceful prohibition or doctrine, e.g. "The One Voice Rule. The primary accent is used on ≤10% of any given screen. Its rarity is the point."]
## 3. Typography
@@ -171,7 +171,7 @@ colors:
**Character:** [1-2 sentence personality description of the pairing.]
### Hierarchy
- **Display** ([weight], [size/clamp], [line-height]): [Purpose where it appears.]
- **Display** ([weight], [size/clamp], [line-height]): [Purpose; where it appears.]
- **Headline** ([weight], [size], [line-height]): [Purpose.]
- **Title** ([weight], [size], [line-height]): [Purpose.]
- **Body** ([weight], [size], [line-height]): [Purpose. Include max line length like 6575ch if relevant.]
@@ -214,34 +214,34 @@ For each component, lead with a short character line, then specify shape, color
### Inputs / Fields
- **Style:** [stroke, background, radius]
- **Focus:** [treatment glow, border shift, etc.]
- **Focus:** [treatment, e.g. glow, border shift, etc.]
- **Error / Disabled:** [if applicable]
### Navigation
- **Style, typography, default/hover/active states, mobile treatment.**
### [Signature Component] (optional if the project has a distinctive custom component worth documenting)
### [Signature Component] (optional; if the project has a distinctive custom component worth documenting)
[Description.]
## 6. Do's and Don'ts
Concrete, forceful guardrails. Lead each with "Do" or "Don't". Be specific include exact colors, pixel values, and named anti-patterns the user mentioned in PRODUCT.md. **Every anti-reference in PRODUCT.md should show up here as a "Don't" with the same language**, so the visual spec carries the strategic line through. Quote PRODUCT.md directly where possible: if PRODUCT.md says *"avoid dark mode with purple gradients, neon accents, glassmorphism"*, the Don'ts here should repeat that by name.
Concrete, forceful guardrails. Lead each with "Do" or "Don't". Be specific: include exact colors, pixel values, and named anti-patterns the user mentioned in PRODUCT.md. **Every anti-reference in PRODUCT.md should show up here as a "Don't" with the same language**, so the visual spec carries the strategic line through. Quote PRODUCT.md directly where possible: if PRODUCT.md says *"avoid dark mode with purple gradients, neon accents, glassmorphism"*, the Don'ts here should repeat that by name.
### Do:
- **Do** [specific prescription with exact values / named rule].
- **Do** [...]
### Don't:
- **Don't** [specific prohibition e.g. "use border-left greater than 1px as a colored stripe"].
- **Don't** [specific prohibition, e.g. "use border-left greater than 1px as a colored stripe"].
- **Don't** [...]
- **Don't** [...]
```
### Step 4b: Write DESIGN.json sidecar (extensions only)
### Step 4b: Write .impeccable/design.json sidecar (extensions only)
The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `DESIGN.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it.
The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `.impeccable/design.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it.
Regenerate the sidecar whenever you regenerate DESIGN.md. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve DESIGN.md and write only DESIGN.json.
Regenerate the sidecar whenever you regenerate root `DESIGN.md`. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve `DESIGN.md` and write only `.impeccable/design.json`.
#### Schema
@@ -253,7 +253,7 @@ Regenerate the sidecar whenever you regenerate DESIGN.md. If the user only asks
"extensions": {
"colorMeta": {
"primary": { "role": "primary", "displayName": "Editorial Magenta", "canonical": "oklch(60% 0.25 350)", "tonalRamp": ["...", "...", "..."] },
"warm-ash-cream": { "role": "neutral", "displayName": "Warm Ash Cream", "canonical": "oklch(96% 0.005 350)", "tonalRamp": ["...", "...", "..."] }
"cool-paper": { "role": "neutral", "displayName": "Cool Paper", "canonical": "oklch(96% 0.005 230)", "tonalRamp": ["...", "...", "..."] }
},
"typographyMeta": {
"display": { "displayName": "Display", "purpose": "Hero headlines only." }
@@ -280,7 +280,7 @@ Regenerate the sidecar whenever you regenerate DESIGN.md. If the user only asks
],
"narrative": {
"northStar": "The Editorial Sanctuary",
"overview": "2-3 paragraphs of the philosophy pulled from DESIGN.md Overview section.",
"overview": "2-3 paragraphs of the philosophy, pulled from DESIGN.md Overview section.",
"keyCharacteristics": ["...", "..."],
"rules": [{ "name": "The One Voice Rule", "body": "...", "section": "colors|typography|elevation" }],
"dos": ["Do use ..."],
@@ -289,14 +289,14 @@ Regenerate the sidecar whenever you regenerate DESIGN.md. If the user only asks
}
```
**What changed from schemaVersion 1.** The old sidecar carried token primitive arrays (`tokens.colors[]`, `tokens.typography[]`, etc.). Those values now live in the frontmatter. The sidecar only carries metadata that can't live in the frontmatter tonal ramps, canonical OKLCH when the hex is an approximation, display names, role hints keyed by the frontmatter token name (`colorMeta.<token-name>`, `typographyMeta.<token-name>`). Components still carry full HTML/CSS because Stitch's 8-prop set can't hold them.
**What changed from schemaVersion 1.** The old sidecar carried token primitive arrays (`tokens.colors[]`, `tokens.typography[]`, etc.). Those values now live in the frontmatter. The sidecar only carries metadata that can't live in the frontmatter (tonal ramps, canonical OKLCH when the hex is an approximation, display names, role hints), keyed by the frontmatter token name (`colorMeta.<token-name>`, `typographyMeta.<token-name>`). Components still carry full HTML/CSS because Stitch's 8-prop set can't hold them.
#### Component translation rules
The `html` and `css` fields must be **self-contained, drop-in snippets** that render correctly when injected into a shadow DOM. The panel applies them directly no post-processing, no framework runtime.
The `html` and `css` fields must be **self-contained, drop-in snippets** that render correctly when injected into a shadow DOM. The panel applies them directly: no post-processing, no framework runtime.
1. **Tailwind expansion.** If the source uses Tailwind (className="bg-primary text-white rounded-lg px-6 py-3"), expand every utility to literal CSS properties in the `css` string. Do **not** reference Tailwind classes; do **not** assume a Tailwind CSS bundle is loaded. Each component is self-contained.
2. **Token resolution.** If the project exposes tokens as CSS custom properties on `:root` (e.g. `--color-primary`, `--radius-md`), reference them via `var(--color-primary)` they inherit through the shadow DOM and stay live-bound. If tokens live only in JS theme objects (styled-components, CSS-in-JS), resolve to literal values at generation time.
2. **Token resolution.** If the project exposes tokens as CSS custom properties on `:root` (e.g. `--color-primary`, `--radius-md`), reference them via `var(--color-primary)`; they inherit through the shadow DOM and stay live-bound. If tokens live only in JS theme objects (styled-components, CSS-in-JS), resolve to literal values at generation time.
3. **Icons.** Inline as SVG. Do not reference Lucide/Heroicons packages, icon fonts, or `<img src="...">`. A typical icon is 16-24px; copy the SVG path data directly.
4. **States.** Include `:hover`, `:focus-visible`, and (if meaningful) `:active` rules inline. A static default-only snapshot makes the panel feel dead. Hover + focus rules in the CSS make it feel alive.
5. **Reset bloat.** Extract only the component's *distinctive* CSS (background, color, padding, border-radius, typography, transition). Skip universal resets (`box-sizing: border-box`, `line-height: inherit`, `-webkit-font-smoothing`). The panel already has a neutral canvas; don't re-ship resets.
@@ -308,13 +308,13 @@ Aim for a tight set of **5-10 components** that best represent the visual system
- **Canonical primitives (always include if the project has them):** button (each variant as a separate component entry), input/text field, navigation, chip/tag, card.
- **Signature components (include if distinctive):** hero CTA, featured card, filter pill, any custom pattern the user mentioned as important in PRODUCT.md.
- **Skip the rest.** Utility components, form building blocks, wrapper layouts not worth documenting unless visually distinctive.
- **Skip the rest.** Utility components, form building blocks, wrapper layouts: not worth documenting unless visually distinctive.
If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every DESIGN.json has *something* to render, even on day zero.
If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every `.impeccable/design.json` has *something* to render, even on day zero.
#### Tonal ramps
For each color token, generate an 8-step `tonalRamp` array dark to light, same hue and chroma, stepped lightness from ~15% to ~95%. The panel renders this as a strip under the swatch. If the project already defines a tonal scale (Material `surface-container-low` family, Tailwind-style `blue-50..blue-900`), use those values. Otherwise synthesize in OKLCH.
For each color token, generate an 8-step `tonalRamp` array: dark to light, same hue and chroma, stepped lightness from ~15% to ~95%. The panel renders this as a strip under the swatch. If the project already defines a tonal scale (Material `surface-container-low` family, Tailwind-style `blue-50..blue-900`), use those values. Otherwise synthesize in OKLCH.
#### Narrative mapping
@@ -328,12 +328,13 @@ Pull directly from the DESIGN.md you just wrote:
Do not reword. The panel shows these as secondary collapsible context; the same voice that's in the Markdown carries through.
### Step 5: Confirm, refine, and refresh session cache
### Step 5: Confirm and refine
1. Show the user the full DESIGN.md you wrote. Briefly highlight the non-obvious creative choices (descriptive color names, atmosphere language, named rules).
2. Mention that `DESIGN.json` was also written alongside the live panel will now render this project's actual button/input/nav primitives instead of generic approximations.
2. Mention that `.impeccable/design.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations.
3. Offer to refine any section: "Want me to revise a section, add component patterns I missed, or adjust the atmosphere language?"
4. **Refresh the session cache.** Run `node .agents/skills/impeccable/scripts/load-context.mjs` one final time so the newly-written DESIGN.md lands in conversation. Subsequent commands in this session will use the fresh version automatically without re-reading.
Your own write is the freshest source; subsequent commands in this session don't need a reload.
## Seed mode
@@ -350,24 +351,24 @@ If the user prefers to skip, stop. No file.
Group into one `AskUserQuestion` interaction. Options must be concrete.
1. **Color strategy.** Pick one:
- Restrained tinted neutrals + one accent ≤10%
- Committed one saturated color carries 3060% of the surface
- Full palette 34 named color roles, each deliberate
- Drenched the surface IS the color
- Restrained: tinted neutrals + one accent ≤10%
- Committed: one saturated color carries 3060% of the surface
- Full palette: 34 named color roles, each deliberate
- Drenched: the surface IS the color
Then: one hue family or anchor reference ("deep teal", "mustard", "Klim #ff4500 orange").
2. **Typography direction.** Pick one (specific fonts come later):
- Serif display + sans body
- Single sans (warm / technical / geometric / humanist pick a feel)
- Single sans (warm / technical / geometric / humanist; pick a feel)
- Display + mono
- Mono-forward
- Editorial script + sans
3. **Motion energy.** Pick one:
- Restrained state changes only
- Responsive feedback + transitions, no choreography
- Choreographed orchestrated entrances, scroll-driven sequences
- Restrained: state changes only
- Responsive: feedback + transitions, no choreography
- Choreographed: orchestrated entrances, scroll-driven sequences
4. **Three named references.** Brands, products, printed objects. Not adjectives.
@@ -380,36 +381,37 @@ Use the six-section spec from Scan mode. Populate what the interview answers; le
Lead the file with:
```markdown
<!-- SEED re-run $impeccable document once there's code to capture the actual tokens and components. -->
<!-- SEED: re-run $impeccable document once there's code to capture the actual tokens and components. -->
```
Per-section guidance in seed mode:
- **Overview**: Creative North Star and philosophy phrased from the answers (color strategy + motion energy + references). Reference the user's anti-reference directly.
- **Colors**: Color strategy as a Named Rule (e.g. *"The Drenched Rule. The surface IS the color."*). Hue family or anchor reference. No hex values mark as `[to be resolved during implementation]`.
- **Typography**: the direction the user picked (e.g. "Serif display + sans body"). No font names yet `[font pairing to be chosen at implementation]`.
- **Colors**: Color strategy as a Named Rule (e.g. *"The Drenched Rule. The surface IS the color."*). Hue family or anchor reference. No hex values; mark as `[to be resolved during implementation]`.
- **Typography**: the direction the user picked (e.g. "Serif display + sans body"). No font names yet: `[font pairing to be chosen at implementation]`.
- **Elevation**: inferred from motion energy. Restrained/Responsive → flat by default; Choreographed → layered. One sentence.
- **Components**: omit entirely no components exist yet.
- **Components**: omit entirely; no components exist yet.
- **Do's and Don'ts**: carry PRODUCT.md's anti-references directly plus the anti-reference named in Q5.
Seed mode writes a minimal frontmatter with `name` and `description` only no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `DESIGN.json` sidecar in seed mode for the same reason: nothing to render.
Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `.impeccable/design.json` sidecar in seed mode for the same reason: nothing to render.
### Step 4: Confirm and refresh session cache
### Step 4: Confirm
1. Show the seed DESIGN.md. Call out that it is a seed (the marker is the literal commitment).
2. Tell the user: "Re-run `$impeccable document` once you have some code. That pass will extract real tokens and generate the sidecar."
3. Run `node .agents/skills/impeccable/scripts/load-context.mjs` once so the seed lands in conversation for the rest of the session.
Your own write is the freshest source; no reload needed.
## Style guidelines
- **Frontmatter first, prose second.** Tokens go in the YAML frontmatter; prose contextualizes them. Don't redefine a token value in two places the frontmatter is normative.
- **Frontmatter first, prose second.** Tokens go in the YAML frontmatter; prose contextualizes them. Don't redefine a token value in two places; the frontmatter is normative.
- **Cite PRODUCT.md anti-references by name** in the Do's and Don'ts section. If PRODUCT.md lists "SaaS landing-page clichés" or "generic AI tool marketing" as anti-references, the DESIGN.md Don'ts should repeat those phrases verbatim so the visual spec enforces the strategic line.
- **Match the spec, don't invent new sections.** The six section names are fixed. If you have Layout/Motion/Responsive content to document, fold it into Overview (philosophy-level rules) or Components (per-component behavior).
- **Descriptive > technical**: "Gently curved edges (8px radius)" > "rounded-lg". Include the technical value in parens, lead with the description.
- **Functional > decorative**: for each token, explain WHERE and WHY it's used, not just WHAT it is.
- **Exact values in parens**: hex codes, px/rem values, font weights always the number in parens alongside the description.
- **Exact values in parens**: hex codes, px/rem values, font weights; always the number in parens alongside the description.
- **Use Named Rules**: `**The [Name] Rule.** [short doctrine]`. These are memorable, citable, and much stickier for AI consumers than bullet lists. Stitch's own outputs use them heavily ("The No-Line Rule", "The Ghost Border Fallback"). Aim for 1-3 per section.
- **Be forceful**. The voice of a design director. "Prohibited", "forbidden", "never", "always" not "consider", "might", "prefer". Match PRODUCT.md's tone.
- **Be forceful**. The voice of a design director. "Prohibited", "forbidden", "never", "always", not "consider", "might", "prefer". Match PRODUCT.md's tone.
- **Concrete anti-pattern tests**. Stitch writes things like *"If it looks like a 2014 app, the shadow is too dark and the blur is too small."* A one-sentence audit test beats a paragraph of principle.
- **Reference PRODUCT.md**. The anti-references section of PRODUCT.md should directly inform the Do's and Don'ts section here. Quote or paraphrase.
- **Group colors by role**, not by hex-order or hue-order. Primary / Secondary / Tertiary / Neutral is the spec ordering.
@@ -417,7 +419,7 @@ Seed mode writes a minimal frontmatter with `name` and `description` only — no
## Pitfalls
- Don't paste raw CSS class names. Translate to descriptive language.
- Don't extract every token. Stop at what's actually reused one-offs pollute the system.
- Don't extract every token. Stop at what's actually reused; one-offs pollute the system.
- Don't invent components that don't exist. If the project only has buttons and cards, only document those.
- Don't overwrite an existing DESIGN.md without asking.
- Don't duplicate content from PRODUCT.md. DESIGN.md is strictly visual.
@@ -6,7 +6,7 @@ Identify reusable patterns, components, and design tokens, then extract and cons
Find the design system, component library, or shared UI directory. Understand its structure: component organization, naming conventions, design token structure, import/export conventions.
**CRITICAL**: If no design system exists, ask the user directly to clarify what you cannot infer. before creating one. Understand the preferred location and structure first.
**CRITICAL**: If no design system exists, STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. before creating one. Understand the preferred location and structure first.
## Step 2: Identify Patterns
@@ -67,4 +67,3 @@ Update design system documentation:
- Create tokens for every single value (tokens should have semantic meaning)
- Extract things that differ in intent (two buttons that look similar but serve different purposes should stay separate)
Remember: A good design system is a living system. Extract patterns as they emerge, enrich them thoughtfully, and maintain them consistently.
@@ -1,4 +1,4 @@
Strengthen interfaces against edge cases, errors, internationalization issues, and real-world usage scenarios that break idealized designs.
Designs that only work with perfect data aren't production-ready. Harden the interface against the inputs, errors, languages, and network conditions that real users will throw at it.
## Assess Hardening Needs
@@ -344,4 +344,4 @@ Test thoroughly with edge cases:
- **Errors**: Force API errors, test all error states
- **Empty**: Remove all data, test empty states
Remember: You're hardening for production reality, not demo perfection. Expect users to input weird data, lose connection mid-flow, and use your product in unexpected ways. Build resilience into every component.
When edge cases are covered, hand off to `$impeccable polish` for the final pass.
@@ -1,234 +0,0 @@
# Heuristics Scoring Guide
Score each of Nielsen's 10 Usability Heuristics on a 04 scale. Be honest — a 4 means genuinely excellent, not "good enough."
## Nielsen's 10 Heuristics
### 1. Visibility of System Status
Keep users informed about what's happening through timely, appropriate feedback.
**Check for**:
- Loading indicators during async operations
- Confirmation of user actions (save, submit, delete)
- Progress indicators for multi-step processes
- Current location in navigation (breadcrumbs, active states)
- Form validation feedback (inline, not just on submit)
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | No feedback — user is guessing what happened |
| 1 | Rare feedback — most actions produce no visible response |
| 2 | Partial — some states communicated, major gaps remain |
| 3 | Good — most operations give clear feedback, minor gaps |
| 4 | Excellent — every action confirms, progress is always visible |
### 2. Match Between System and Real World
Speak the user's language. Follow real-world conventions. Information appears in natural, logical order.
**Check for**:
- Familiar terminology (no unexplained jargon)
- Logical information order matching user expectations
- Recognizable icons and metaphors
- Domain-appropriate language for the target audience
- Natural reading flow (left-to-right, top-to-bottom priority)
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | Pure tech jargon, alien to users |
| 1 | Mostly confusing — requires domain expertise to navigate |
| 2 | Mixed — some plain language, some jargon leaks through |
| 3 | Mostly natural — occasional term needs context |
| 4 | Speaks the user's language fluently throughout |
### 3. User Control and Freedom
Users need a clear "emergency exit" from unwanted states without extended dialogue.
**Check for**:
- Undo/redo functionality
- Cancel buttons on forms and modals
- Clear navigation back to safety (home, previous)
- Easy way to clear filters, search, selections
- Escape from long or multi-step processes
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | Users get trapped — no way out without refreshing |
| 1 | Difficult exits — must find obscure paths to escape |
| 2 | Some exits — main flows have escape, edge cases don't |
| 3 | Good control — users can exit and undo most actions |
| 4 | Full control — undo, cancel, back, and escape everywhere |
### 4. Consistency and Standards
Users shouldn't wonder whether different words, situations, or actions mean the same thing.
**Check for**:
- Consistent terminology throughout the interface
- Same actions produce same results everywhere
- Platform conventions followed (standard UI patterns)
- Visual consistency (colors, typography, spacing, components)
- Consistent interaction patterns (same gesture = same behavior)
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | Inconsistent everywhere — feels like different products stitched together |
| 1 | Many inconsistencies — similar things look/behave differently |
| 2 | Partially consistent — main flows match, details diverge |
| 3 | Mostly consistent — occasional deviation, nothing confusing |
| 4 | Fully consistent — cohesive system, predictable behavior |
### 5. Error Prevention
Better than good error messages is a design that prevents problems in the first place.
**Check for**:
- Confirmation before destructive actions (delete, overwrite)
- Constraints preventing invalid input (date pickers, dropdowns)
- Smart defaults that reduce errors
- Clear labels that prevent misunderstanding
- Autosave and draft recovery
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | Errors easy to make — no guardrails anywhere |
| 1 | Few safeguards — some inputs validated, most aren't |
| 2 | Partial prevention — common errors caught, edge cases slip |
| 3 | Good prevention — most error paths blocked proactively |
| 4 | Excellent — errors nearly impossible through smart constraints |
### 6. Recognition Rather Than Recall
Minimize memory load. Make objects, actions, and options visible or easily retrievable.
**Check for**:
- Visible options (not buried in hidden menus)
- Contextual help when needed (tooltips, inline hints)
- Recent items and history
- Autocomplete and suggestions
- Labels on icons (not icon-only navigation)
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | Heavy memorization — users must remember paths and commands |
| 1 | Mostly recall — many hidden features, few visible cues |
| 2 | Some aids — main actions visible, secondary features hidden |
| 3 | Good recognition — most things discoverable, few memory demands |
| 4 | Everything discoverable — users never need to memorize |
### 7. Flexibility and Efficiency of Use
Accelerators — invisible to novices — speed up expert interaction.
**Check for**:
- Keyboard shortcuts for common actions
- Customizable interface elements
- Recent items and favorites
- Bulk/batch actions
- Power user features that don't complicate the basics
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | One rigid path — no shortcuts or alternatives |
| 1 | Limited flexibility — few alternatives to the main path |
| 2 | Some shortcuts — basic keyboard support, limited bulk actions |
| 3 | Good accelerators — keyboard nav, some customization |
| 4 | Highly flexible — multiple paths, power features, customizable |
### 8. Aesthetic and Minimalist Design
Interfaces should not contain irrelevant or rarely needed information. Every element should serve a purpose.
**Check for**:
- Only necessary information visible at each step
- Clear visual hierarchy directing attention
- Purposeful use of color and emphasis
- No decorative clutter competing for attention
- Focused, uncluttered layouts
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | Overwhelming — everything competes for attention equally |
| 1 | Cluttered — too much noise, hard to find what matters |
| 2 | Some clutter — main content clear, periphery noisy |
| 3 | Mostly clean — focused design, minor visual noise |
| 4 | Perfectly minimal — every element earns its pixel |
### 9. Help Users Recognize, Diagnose, and Recover from Errors
Error messages should use plain language, precisely indicate the problem, and constructively suggest a solution.
**Check for**:
- Plain language error messages (no error codes for users)
- Specific problem identification ("Email is missing @" not "Invalid input")
- Actionable recovery suggestions
- Errors displayed near the source of the problem
- Non-blocking error handling (don't wipe the form)
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | Cryptic errors — codes, jargon, or no message at all |
| 1 | Vague errors — "Something went wrong" with no guidance |
| 2 | Clear but unhelpful — names the problem but not the fix |
| 3 | Clear with suggestions — identifies problem and offers next steps |
| 4 | Perfect recovery — pinpoints issue, suggests fix, preserves user work |
### 10. Help and Documentation
Even if the system is usable without docs, help should be easy to find, task-focused, and concise.
**Check for**:
- Searchable help or documentation
- Contextual help (tooltips, inline hints, guided tours)
- Task-focused organization (not feature-organized)
- Concise, scannable content
- Easy access without leaving current context
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | No help available anywhere |
| 1 | Help exists but hard to find or irrelevant |
| 2 | Basic help — FAQ or docs exist, not contextual |
| 3 | Good documentation — searchable, mostly task-focused |
| 4 | Excellent contextual help — right info at the right moment |
---
## Score Summary
**Total possible**: 40 points (10 heuristics × 4 max)
| Score Range | Rating | What It Means |
|-------------|--------|---------------|
| 3640 | Excellent | Minor polish only — ship it |
| 2835 | Good | Address weak areas, solid foundation |
| 2027 | Acceptable | Significant improvements needed before users are happy |
| 1219 | Poor | Major UX overhaul required — core experience broken |
| 011 | Critical | Redesign needed — unusable in current state |
---
## Issue Severity (P0P3)
Tag each individual issue found during scoring with a priority level:
| Priority | Name | Description | Action |
|----------|------|-------------|--------|
| **P0** | Blocking | Prevents task completion entirely | Fix immediately — this is a showstopper |
| **P1** | Major | Causes significant difficulty or confusion | Fix before release |
| **P2** | Minor | Annoyance, but workaround exists | Fix in next pass |
| **P3** | Polish | Nice-to-fix, no real user impact | Fix if time permits |
**Tip**: If you're unsure between two levels, ask: "Would a user contact support about this?" If yes, it's at least P1.
+172
View File
@@ -0,0 +1,172 @@
# Init Flow
The setup command for a project. One codebase crawl feeds everything it writes:
- **PRODUCT.md** (strategic): root project file for register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why".
- **DESIGN.md** (visual): root project file for visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks".
- **`.impeccable/live/config.json`** (live mode): pre-configured so `$impeccable live` boots straight into variant mode with no first-time detour.
It closes by pointing the user at the best command to run next. Every other impeccable command reads PRODUCT.md and DESIGN.md before doing any work.
## Step 1: Load current state
Check what already exists. PRODUCT.md and DESIGN.md live at the project root, or under `.agents/context/` or `docs/` (case-insensitive). Read whichever are present with your native file tool. Also note whether `.impeccable/live/config.json` already exists (Step 6 leaves it untouched if so).
Decision tree:
- **Neither file exists (empty project or no context yet)**: do Steps 2-4 (write PRODUCT.md), then decide on DESIGN.md based on whether there's code to analyze.
- **PRODUCT.md exists, DESIGN.md missing**: skip to Step 5 and offer to run `$impeccable document` for DESIGN.md.
- **PRODUCT.md exists but has no `## Register` section (legacy)**: add it. Infer a hypothesis from the codebase (see Step 2), confirm with the user, write the field.
- **Both exist**: STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. Ask which file to refresh. Skip the one the user doesn't want changed.
- **Just DESIGN.md exists (unusual)**: do Steps 2-4 to produce PRODUCT.md.
Never silently overwrite an existing file. Always confirm first.
If init was invoked as a setup blocker by another command, such as `$impeccable craft landing page`, pause that command here. Complete init, then resume the original command. Your own writes are the freshest source; no reload needed. For craft, resume into shape next; init creates project context, but it is not a substitute for the task-specific shape interview and confirmed design brief.
## Step 2: Explore the codebase
Before asking questions, thoroughly scan the project to discover what you can. This single crawl feeds PRODUCT.md, DESIGN.md, **and** the live-mode framework detection in Step 6, so be thorough once rather than re-scanning later:
- **README and docs**: Project purpose, target audience, any stated goals
- **Package.json / config files**: Tech stack, dependencies, existing design libraries, **and the framework** (Vite/SPA, Next.js, Nuxt, SvelteKit, Astro, multi-page static) plus the HTML entry the browser actually loads
- **Existing components**: Current design patterns, spacing, typography in use
- **Brand assets**: Logos, favicons, color values already defined
- **Design tokens / CSS variables**: Existing color palettes, font stacks, spacing scales
- **Any style guides or brand documentation**
Also form a **register hypothesis** from what you find:
- Brand signals: `/`, `/about`, `/pricing`, `/blog/*`, `/docs/*`, hero sections, big typography, scroll-driven sections, landing-page-shaped content.
- Product signals: `/app/*`, `/dashboard`, `/settings`, `/(auth)`, forms, data tables, side/top nav, app-shell components.
Register is a hypothesis at this point, not a decision; Step 3 confirms it.
Note what you've learned and what remains unclear. Also note any rough edges worth a follow-up command (thin hierarchy, flat or gray palette, missing error/empty states, dull copy); Step 7 turns these into concrete recommendations without re-analyzing.
## Step 3: Ask strategic questions (for PRODUCT.md)
STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. Ask only about what you couldn't infer from the codebase.
### Interview mode, not confirmation mode
If the repo is empty or the user's brief is sparse, run a short interview before proposing PRODUCT.md. Do **not** turn a one-sentence request into a complete inferred PRODUCT.md and ask for blanket confirmation.
- Use the harness's structured question tool when one exists. Otherwise, ask directly in chat and stop.
- Ask **2-3 questions per round**, then wait for answers.
- Use inferred answers as hypotheses or options, not as finished facts.
- Complete at least one real user-answer round before drafting PRODUCT.md, unless every required answer is directly discoverable from repo docs.
- Round 1 should establish register, users/purpose, and desired outcome.
- Round 2 should establish brand personality or references, anti-references, and accessibility needs.
### Minimum viable interview
Ask enough to complete PRODUCT.md. At minimum, cover register confirmation, users and purpose, brand personality, anti-references, and accessibility needs unless each answer is directly discoverable from repo context. After at least one interview round, you may propose inferred answers, but the user must confirm them before you write PRODUCT.md. Never synthesize PRODUCT.md from the original task prompt alone.
### Register (ask first; it shapes everything below)
Every design task is either **brand** (marketing, landing, campaign, long-form content, portfolio: design IS the product) or **product** (app UI, admin, dashboards, tools: design SERVES the product).
If Step 2 produced a clear hypothesis, lead with it: *"From the codebase, this looks like a [brand / product] surface. Does that match your intent, or should we treat it differently?"*
If the signal is genuinely split (e.g. a product with a big marketing landing), STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. Ask which register describes the **primary** surface. The register can be overridden per task later, but PRODUCT.md carries one default.
### Users & Purpose
- Who uses this? What's their context when using it?
- What job are they trying to get done?
- For brand: what emotions should the interface evoke? (confidence, delight, calm, urgency)
- For product: what workflow are they in? What's the primary task on any given screen?
### Brand & Personality
- How would you describe the brand personality in 3 words?
- Reference sites or apps that capture the right feel? What specifically about them?
- Push for specific named references with the *specific* thing about them that fits this brand, not generic "modern" adjectives or category-bucket lanes.
- What should this explicitly NOT look like? Any anti-references?
### Accessibility & Inclusion
- Specific accessibility requirements? (WCAG level, known user needs)
- Considerations for reduced motion, color blindness, or other accommodations?
Skip questions where the answer is already clear. **Do NOT ask about colors, fonts, radii, or visual styling here.** Those belong in DESIGN.md, not PRODUCT.md.
## Step 4: Write PRODUCT.md
Write PRODUCT.md only after the user has confirmed the strategic answers from Step 3. If an inferred answer is uncertain or unconfirmed, ask before writing.
Synthesize into a strategic document:
```markdown
# Product
## Register
product
## Users
[Who they are, their context, the job to be done]
## Product Purpose
[What this product does, why it exists, what success looks like]
## Brand Personality
[Voice, tone, 3-word personality, emotional goals]
## Anti-references
[What this should NOT look like. Specific bad-example sites or patterns to avoid.]
## Design Principles
[3-5 strategic principles derived from the conversation. Principles like "practice what you preach", "show, don't tell", "expert confidence". NOT visual rules like "use OKLCH" or "magenta accent".]
## Accessibility & Inclusion
[WCAG level, known user needs, considerations]
```
Register is either `brand` or `product` as a bare value. No prose, no commentary.
Write to `PROJECT_ROOT/PRODUCT.md`. If `.impeccable.md` existed, the loader already renamed it; merge into that content rather than starting from scratch.
## Step 5: Decide on DESIGN.md
Offer `$impeccable document` either way. Two paths:
- **Code exists** (CSS tokens, components, a running site): "I can generate a DESIGN.md that captures your visual system (colors, typography, components) so variants stay on-brand. Want to do that now?"
- **Pre-implementation** (empty project): "I can seed a starter DESIGN.md from five quick questions about color strategy, type direction, motion energy, and references. You can re-run once there's code, to capture the real tokens. Want to do that now?"
If the user agrees, delegate to `$impeccable document` (it auto-detects scan vs seed). Load its reference and follow that flow.
If the user prefers to skip, mention they can run `$impeccable document` any time later.
## Step 6: Configure live mode (when code exists)
If the project has code with HTML entries and a dev server (the same "code exists" condition that puts `$impeccable document` in scan mode), pre-configure live mode now. You already identified the framework and the served HTML entry in Step 2, so this is nearly free, and it spares the user the first-time setup detour when they later run `$impeccable live`.
**Skip this step for empty / pre-implementation projects** (nothing to inject into yet). Tell the user live mode will configure itself the first time they run it once there's code.
**If `.impeccable/live/config.json` already exists, leave it untouched** and note that live mode is already configured.
Otherwise:
1. Write `.impeccable/live/config.json`. Choose `files` (the HTML entries the browser actually loads), `insertBefore`, and `commentSyntax` from the framework table in [live.md](live.md)'s **First-time setup** section, using the framework you found in Step 2. That table is canonical; do not restate it here. For multi-page static sites, prefer a glob (`["public/**/*.html"]`) over a literal list.
2. Run `node .agents/skills/impeccable/scripts/detect-csp.mjs`. If it reports a patchable shape (`append-arrays` / `append-string`), use the **consent prompt template** from live.md before editing any source file. On decline, skip the patch. For `middleware` / `meta-tag` shapes, surface the detected files and ask the user to add `http://localhost:8400` to `script-src` and `connect-src` manually. For `null`, there's nothing to do.
3. Set `cspChecked: true` in the config once CSP is handled (patched, declined, manual, or not needed). The schema and per-shape patch details live in live.md's First-time setup; follow it rather than duplicating.
Writing the config file is harmless and needs no consent; only the CSP **source-file patch** requires a yes.
## Step 7: Recommend starting points, then wrap up
Summarize tersely:
- Register captured (brand / product)
- What was written (PRODUCT.md, DESIGN.md, live config, or a subset)
- The 3-5 strategic principles from PRODUCT.md that will guide future work
- If DESIGN.md or live config is pending, one line on how to set it up later
Then recommend the **best commands to run next**, drawn from what your Step 2 crawl already surfaced. Do not run a fresh analysis here; surface observations you already have. Tailor to register and to what you saw, offer the 2-4 most relevant (not a menu dump), and give the exact command to type. Group by intent:
- **Build something new**: `$impeccable craft <feature>` (shape, then build end-to-end) or `$impeccable shape <feature>` (plan first). Lead with this for empty or early-stage projects.
- **Improve what's there**: name the specific surface. `$impeccable critique <page>` for a scored UX review; `$impeccable audit <area>` for a11y / perf / responsive checks; `$impeccable polish <component>` for a pre-ship pass. When the crawl flagged a specific weakness, point the matching command at it: thin hierarchy or spacing → `layout`, flat or gray palette → `colorize`, missing error / empty states → `harden` or `onboard`, dull or unclear copy → `clarify`.
- **Iterate visually**: `$impeccable live` (configured in Step 6) to pick elements in the browser and generate variants in place.
The full command menu is one bare `$impeccable` away; keep this list short and pointed.
If init was invoked as a blocker by another impeccable command (e.g. the user ran `$impeccable polish` with no PRODUCT.md), resume that original task now. Your own writes are the freshest source; no reload needed.
Optionally STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. Ask whether they'd like a brief summary of PRODUCT.md appended to AGENTS.md for easier agent reference. If yes, append a short **Design Context** pointer section there.
@@ -42,11 +42,11 @@ button:focus-visible {
## Form Design: The Non-Obvious
**Placeholders aren't labels**—they disappear on input. Always use visible `<label>` elements. **Validate on blur**, not on every keystroke (exception: password strength). Place errors **below** fields with `aria-describedby` connecting them.
**Placeholders aren't labels.** They disappear on input. Always use visible `<label>` elements. **Validate on blur**, not on every keystroke (exception: password strength). Place errors **below** fields with `aria-describedby` connecting them.
## Loading States
**Optimistic updates**: Show success immediately, rollback on failure. Use for low-stakes actions (likes, follows), not payments or destructive actions. **Skeleton screens > spinners**they preview content shape and feel faster than generic spinners.
**Optimistic updates**: Show success immediately, rollback on failure. Use for low-stakes actions (likes, follows), not payments or destructive actions. **Skeleton screens > spinners**: they preview content shape and feel faster than generic spinners.
## Modals: The Inert Approach
@@ -150,15 +150,9 @@ For browsers without anchor positioning support, `position: fixed` with manual c
Check viewport boundaries before rendering. If the dropdown would overflow the bottom edge, flip it above the trigger. If it would overflow the right edge, align it to the trigger's right side instead.
### Anti-Patterns
- **`position: absolute` inside `overflow: hidden`** - The dropdown will be clipped. Use `position: fixed` or the top layer instead.
- **Arbitrary z-index values** like `z-index: 9999` - Use a semantic z-index scale: `dropdown (100) -> sticky (200) -> modal-backdrop (300) -> modal (400) -> toast (500) -> tooltip (600)`.
- **Rendering dropdown markup inline** without an escape hatch from the parent's stacking context. Either use `popover` (top layer), a portal, or `position: fixed`.
## Destructive Actions: Undo > Confirm
**Undo is better than confirmation dialogs**—users click through confirmations mindlessly. Remove from UI immediately, show undo toast, actually delete after toast expires. Use confirmation only for truly irreversible actions (account deletion), high-cost actions, or batch operations.
**Undo is better than confirmation dialogs.** Users click through confirmations mindlessly. Remove from UI immediately, show undo toast, actually delete after toast expires. Use confirmation only for truly irreversible actions (account deletion), high-cost actions, or batch operations.
## Keyboard Navigation Patterns
+53 -33
View File
@@ -1,10 +1,10 @@
Assess and improve layout and spacing that feels monotonous, crowded, or structurally weak — turning generic arrangements into intentional, rhythmic compositions.
Space is the most underused design tool. Find the layout's actual problem (monotone spacing, weak hierarchy, identical card grids) and fix the structure, not the surface.
---
## Register
Brand: asymmetric compositions, fluid spacing with `clamp()`, intentional grid-breaking for emphasis. Rhythm through contrast tight groupings paired with generous separations.
Brand: asymmetric compositions, fluid spacing with `clamp()`, intentional grid-breaking for emphasis. Rhythm through contrast: tight groupings paired with generous separations.
Product: predictable grids, consistent densities, familiar navigation patterns. Responsive behavior is structural (collapse sidebar, responsive table), not fluid typography. Consistency IS an affordance.
@@ -20,14 +20,13 @@ Analyze what's weak about the current spatial design:
- Are related elements grouped tightly, with generous space between groups?
2. **Visual hierarchy**:
- Apply the squint test: blur your (metaphorical) eyes — can you still identify the most important element, second most important, and clear groupings?
- Is hierarchy achieved effectively? (Space and weight alone can be enough — but is the current approach working?)
- Apply the squint test: blur your (metaphorical) eyes. Can you still identify the most important element, second most important, and clear groupings?
- Is hierarchy achieved effectively? (Space and weight alone can be enough; is the current approach working?)
- Does whitespace guide the eye to what matters?
3. **Grid & structure**:
- Is there a clear underlying structure, or does the layout feel random?
- Are identical card grids used everywhere? (Icon + heading + text, repeated endlessly)
- Is everything centered? (Left-aligned with asymmetric layouts feels more designed, but not a hard and fast rule)
4. **Rhythm & variety**:
- Does the layout have visual rhythm? (Alternating tight/generous spacing)
@@ -39,15 +38,13 @@ Analyze what's weak about the current spatial design:
- Is the layout too sparse? (Excessive whitespace without purpose)
- Does density match the content type? (Data-dense UIs need tighter spacing; marketing pages need more air)
**CRITICAL**: Layout problems are often the root cause of interfaces feeling "off" even when colors and fonts are fine. Space is a design material use it with intention.
**CRITICAL**: Layout problems are often the root cause of interfaces feeling "off" even when colors and fonts are fine. Space is a design material; use it with intention.
## Plan Layout Improvements
Consult the [spatial design reference](spatial-design.md) for detailed guidance on grids, rhythm, and container queries.
Create a systematic plan:
- **Spacing system**: Use a consistent scale — whether that's a framework's built-in scale (e.g., Tailwind), rem-based tokens, or a custom system. The specific values matter less than consistency.
- **Spacing system**: Use a consistent scale (a framework's built-in scale like Tailwind's, rem-based tokens, or a custom system). The specific values matter less than consistency.
- **Hierarchy strategy**: How will space communicate importance?
- **Layout approach**: What structure fits the content? Flex for 1D, Grid for 2D, named areas for complex page layouts.
- **Rhythm**: Where should spacing be tight vs generous?
@@ -56,58 +53,81 @@ Create a systematic plan:
### Establish a Spacing System
- Use a consistent spacing scale framework scales (Tailwind, etc.), rem-based tokens, or a custom scale all work. What matters is that values come from a defined set, not arbitrary numbers.
- Use a consistent spacing scale (framework scales like Tailwind, rem-based tokens, or a custom scale all work). What matters is that values come from a defined set, not arbitrary numbers.
- Prefer a 4pt base scale (4, 8, 12, 16, 24, 32, 48, 64, 96px) over 8pt; 8pt is too coarse and you'll frequently need 12px between 8 and 16.
- Name tokens semantically if using custom properties: `--space-xs` through `--space-xl`, not `--spacing-8`
- Use `gap` for sibling spacing instead of margins eliminates margin collapse hacks
- Use `gap` for sibling spacing instead of margins; eliminates margin collapse hacks
- Apply `clamp()` for fluid spacing that breathes on larger screens
### Create Visual Rhythm
- **Tight grouping** for related elements (8-12px between siblings)
- **Generous separation** between distinct sections (48-96px)
- **Varied spacing** within sections not every row needs the same gap
- **Asymmetric compositions** — break the predictable centered-content pattern when it makes sense
- **Varied spacing** within sections (not every row needs the same gap)
- **Asymmetric compositions**: a deliberate choice when the content invites it (not a default to chase).
### Choose the Right Layout Tool
- **Use Flexbox for 1D layouts**: Rows of items, nav bars, button groups, card contents, most component internals. Flex is simpler and more appropriate for the majority of layout tasks.
- **Use Flexbox for 1D layouts**: Rows of items, nav bars, button groups, card contents, most component internals.
- **Use Grid for 2D layouts**: Page-level structure, dashboards, data-dense interfaces, anything where rows AND columns need coordinated control.
- **Don't default to Grid** when Flexbox with `flex-wrap` would be simpler and more flexible.
- Use `repeat(auto-fit, minmax(280px, 1fr))` for responsive grids without breakpoints.
- Use named grid areas (`grid-template-areas`) for complex page layouts — redefine at breakpoints.
- Use named grid areas (`grid-template-areas`) for complex page layouts; redefine at breakpoints.
- Use **container queries** for components, viewport queries for page layouts. A card in a narrow sidebar can stay compact while the same card in a main content area expands automatically:
```css
.card-container { container-type: inline-size; }
.card { display: grid; gap: var(--space-md); }
@container (min-width: 400px) {
.card { grid-template-columns: 120px 1fr; }
}
```
### Break Card Grid Monotony
- Don't default to card grids for everything spacing and alignment create visual grouping naturally
- Use cards only when content is truly distinct and actionable — never nest cards inside cards
- Don't default to card grids for everything; spacing and alignment create visual grouping naturally
- Use cards only when content is truly distinct and actionable. Never nest cards inside cards
- Vary card sizes, span columns, or mix cards with non-card content to break repetition
### Strengthen Visual Hierarchy
- Use the fewest dimensions needed for clear hierarchy. Space alone can be enough generous whitespace around an element draws the eye. Some of the most sophisticated designs achieve rhythm with just space and weight. Add color or size contrast only when simpler means aren't sufficient.
- Be aware of reading flow — in LTR languages, the eye naturally scans top-left to bottom-right, but primary action placement depends on context (e.g., bottom-right in dialogs, top in navigation).
- Use the fewest dimensions needed for clear hierarchy. Space alone can be enough; generous whitespace around an element draws the eye. Some of the most polished designs achieve rhythm with just space and weight. Add color or size contrast only when simpler means aren't sufficient.
- The best hierarchy combines 23 dimensions at once. A heading that's larger, bolder, AND has more space above it reads as primary without trying:
| Tool | Strong Hierarchy | Weak Hierarchy |
|------|------------------|----------------|
| **Size** | 3:1 ratio or more | <2:1 ratio |
| **Weight** | Bold vs Regular | Medium vs Regular |
| **Color** | High contrast | Similar tones |
| **Position** | Top/left (primary) | Bottom/right |
| **Space** | Surrounded by white space | Crowded |
- Be aware of reading flow: in LTR languages, the eye naturally scans top-left to bottom-right, but primary action placement depends on context (e.g., bottom-right in dialogs, top in navigation).
- Create clear content groupings through proximity and separation.
### Manage Depth & Elevation
- Create a semantic z-index scale (dropdown → sticky → modal-backdrop → modal → toast → tooltip)
- Build a consistent shadow scale (sm → md → lg → xl) — shadows should be subtle
- Build a consistent shadow scale (sm → md → lg → xl); shadows should be subtle
- Use elevation to reinforce hierarchy, not as decoration
### Optical Adjustments
- If an icon looks visually off-center despite being geometrically centered, nudge it — but only if you're confident it actually looks wrong. Don't adjust speculatively.
- If an icon looks visually off-center despite being geometrically centered, nudge it. But only if you're confident it actually looks wrong. Don't adjust speculatively.
- Text at `margin-left: 0` looks slightly indented because of letterform whitespace; a negative margin (`-0.05em`) optically aligns it. Geometrically centered glyphs often look off-center (play icons need to shift right, arrows shift toward their direction).
- Touch targets must be 44×44px minimum even when the visual element is smaller. Expand the hit area with padding or a pseudo-element:
```css
.icon-button { width: 24px; height: 24px; position: relative; }
.icon-button::before {
content: ''; position: absolute; inset: -10px;
}
```
**NEVER**:
- Use arbitrary spacing values outside your scale
- Make all spacing equal variety creates hierarchy
- Wrap everything in cards not everything needs a container
- Nest cards inside cards use spacing and dividers for hierarchy within
- Make all spacing equal (variety creates hierarchy)
- Wrap everything in cards (not everything needs a container)
- Nest cards inside cards (use spacing and dividers for hierarchy within)
- Use identical card grids everywhere (icon + heading + text, repeated)
- Center everything — left-aligned with asymmetry feels more designed
- Default to the hero metric layout (big number, small label, stats, gradient) as a template. If showing real user data, a prominent metric can work — but it should display actual data, not decorative numbers.
- Default to CSS Grid when Flexbox would be simpler — use the simplest tool for the job
- Use arbitrary z-index values (999, 9999) — build a semantic scale
- Default to the hero metric layout (big number, small label, stats, gradient) as a template. If showing real user data, a prominent metric can work, but it should display actual data, not decorative numbers.
## Verify Layout Improvements
@@ -118,11 +138,11 @@ Create a systematic plan:
- **Consistency**: Is the spacing system applied uniformly?
- **Responsiveness**: Does the layout adapt gracefully across screen sizes?
Remember: Space is the most underused design tool. A layout with the right rhythm and hierarchy can make even simple content feel polished and intentional.
When the rhythm and hierarchy land, hand off to `$impeccable polish` for the final pass.
## Live-mode signature params
Each variant MUST declare a `density` param. Drive all spacing tokens in the variant's scoped CSS through `calc(var(--p-density, 1) * <base>)` paddings, gaps, column widths. Users slide from airy to packed and see layout re-breathe with no regeneration.
Each variant MUST declare a `density` param. Drive all spacing tokens in the variant's scoped CSS through `calc(var(--p-density, 1) * <base>)`: paddings, gaps, column widths. Users slide from airy to packed and see layout re-breathe with no regeneration.
```json
{"id":"density","kind":"range","min":0.6,"max":1.4,"step":0.05,"default":1,"label":"Density"}
+284 -112
View File
@@ -4,22 +4,28 @@ Interactive live variant mode: select elements in the browser, pick a design act
A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR a static HTML file open in the browser.
Codex: run live helper commands, the app dev server, and any dependency-installing setup with `sandbox_permissions: "require_escalated"` from the start; live mode depends on localhost and package-manager network access that the sandbox blocks.
## The contract (read once)
Execute in order. No step skipped, no step reordered.
1. `live.mjs` boot.
2. Navigate to the URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). If you can't infer it confidently, tell the user once to open their dev/preview URL. Never use `serverPort` as that URL — it's the helper, not the app.
1. `live.mjs`: boot.
2. Open the app URL that serves `pageFile` (infer from `package.json`, docs, terminal output, or an open tab). Never use `serverPort`; it's the helper, not the app. **Cursor:** `browser_navigate` to that URL before polling; do not skip. **Other harnesses:** use the available browser tool; if the URL is uncertain, ask the user once.
3. Poll loop with the default long timeout (600000 ms). After every event or `--reply`, run `live-poll.mjs` again immediately. Never pass a short `--timeout=`.
4. On `generate` — read screenshot if present; load the action's reference; plan three distinct directions; write all variants in one edit; `--reply done`; poll again.
5. On `accept` / `discard` — the poll script already cleaned up; just poll again.
6. On `exit` — run the cleanup at the bottom.
The global bar **Impeccable mark** dims and shows a pulsing amber dot when no agent is long-polling `/poll`. Hover the mark for the hint; restart `live-poll.mjs` to reconnect.
4. On `generate`: read screenshot if present; load the action's reference; plan three distinct directions; write all variants in one edit; `--reply done`; poll again.
5. On `steer`: read the message and `pageUrl`; do the work (page edits, navigation help, or a short reply in the `--reply` message); `--reply steer_done`; poll again. No pickup ack. The Steer bar unlocks when `steer_done` arrives over SSE.
6. On `accept` / `discard`: the poll script runs `live-accept.mjs`, acknowledges the delivered event, and prints `_completionAck`. Plain accepts/discards are terminal immediately; carbonize accepts remain recoverable until you finish cleanup, run `live-complete.mjs --id EVENT_ID`, and only then poll again.
7. If interrupted, run `live-status.mjs` or `live-resume.mjs` before guessing. The durable journal replays unacknowledged work after helper restart.
8. On `exit`: run the cleanup at the bottom.
Harness policy:
- **Claude Code**: run the poll as a **background task** (no short timeout). The harness notifies you when it completes, so the main conversation stays free. Do not block the shell.
- **Cursor**: run the poll in the **foreground** (blocking shell — not a background terminal, not a subagent). Cursor background terminals and subagents do not reliably resume the chat with poll stdout.
- **Codex**: run the poll in the **foreground** (blocking shell not a background task, not a subagent). Codex background exec sessions do not reliably surface poll stdout back into the conversation at the moment events arrive, so a "fire-and-forget" background poll will stall live mode.
- **Other harnesses**: foreground unless you know stdout reliably returns to this session.
- **Cursor**: run **one-shot** poll in a **background terminal** with notify on `"type":"(steer|generate|accept|discard|exit)"`. After each event the poll exits; handle it, `--reply`, then start `live-poll.mjs` again. Do **not** use `--stream` on Cursor: incremental stdout notify is slower in practice than exit-based notify (~5s vs sub-second in testing).
- **Codex**: run the poll in the **foreground** (blocking shell; not a background task, not a subagent). Codex background exec sessions do not reliably surface poll stdout back into the conversation at the moment events arrive, so a "fire-and-forget" background poll will stall live mode.
- **Other harnesses**: one-shot foreground unless you know stdout reliably returns to this session when a shell exits.
Chat is overhead. No recap, no tutorial output, no pasting PRODUCT / DESIGN bodies. Spend tokens on tools and edits; on failure, one or two short sentences.
@@ -29,7 +35,7 @@ Chat is overhead. No recap, no tutorial output, no pasting PRODUCT / DESIGN bodi
node .agents/skills/impeccable/scripts/live.mjs
```
Output JSON: `{ ok, serverPort, serverToken, pageFiles, hasProduct, product, productPath, hasDesign, design, designPath, migrated }`. `pageFiles` is the list of HTML entries the live script was injected into. Keep PRODUCT.md and DESIGN.md in mind for variant generation **DESIGN.md wins on visual decisions; PRODUCT.md wins on strategic/voice decisions.** If `migrated: true`, the loader auto-renamed legacy `.impeccable.md` to `PRODUCT.md`; mention this once and suggest `$impeccable document` for the matching DESIGN.md.
Output JSON: `{ ok, serverPort, serverToken, pageFiles, hasProduct, product, productPath, hasDesign, design, designPath }`. `pageFiles` is the list of HTML entries the live script was injected into. Keep PRODUCT.md and DESIGN.md in mind for variant generation; **DESIGN.md wins on visual decisions; PRODUCT.md wins on strategic/voice decisions.** When DESIGN.md is missing, identity is **not** absent; extract it from CSS variables, computed styles, and sibling components on the page (see Step 4 Phase A). Identity preservation is the default; departure from existing identity requires an explicit trigger from PRODUCT.md anti-references or the user's freeform prompt.
`serverPort` and `serverToken` belong to the small **Impeccable live helper** HTTP server (serves `/live.js`, SSE, and `/poll`). That port is **not** your dev server and is usually not the URL you open to view the app. The browser page is whatever origin serves one of the `pageFiles` entries (Vite / Next / Bun / tunnel / LAN hostname).
@@ -37,28 +43,82 @@ If output is `{ ok: false, error: "config_missing" | "config_invalid", path }`,
## Poll loop
**Default (portable, all harnesses):**
```
LOOP:
node .agents/skills/impeccable/scripts/live-poll.mjs # default long timeout; no --timeout=
Read JSON; dispatch on "type"
"generate" → Handle Generate; reply done; LOOP
"accept" → Handle Accept; LOOP
"steer" → Handle Steer; reply steer_done; LOOP
"accept" → Handle Accept; complete carbonize cleanup if required; LOOP
"discard" → Handle Discard; LOOP
"prefetch" → Handle Prefetch; LOOP
"timeout" → LOOP
"exit" → break → Cleanup
```
**Stream mode (experimental, not for Cursor):**
```
node .agents/skills/impeccable/scripts/live-poll.mjs --stream # stays running; one JSON line per event
Handle event; run --reply in a separate command
Repeat until "exit" line → Cleanup
```
Stream keeps one process alive and waits for `--reply` ack before polling again. Useful only when the harness reads incremental stdout reliably and quickly. **Cursor is not one of those:** background pattern notify on a long-running shell was ~5s to pick up events vs sub-second for one-shot exit notify. Default to one-shot everywhere unless you have measured otherwise.
## Recovery commands
The live helper persists an append-only journal under `.impeccable/live/sessions/`. Browser checkpoints are advisory but durable; the journal is canonical. This is local durable recovery state, not project source.
Use these commands when the chat was interrupted, polling was missed, the helper restarted, or the browser reloaded:
```bash
node .agents/skills/impeccable/scripts/live-status.mjs
node .agents/skills/impeccable/scripts/live-resume.mjs --id SESSION_ID
node .agents/skills/impeccable/scripts/live-complete.mjs --id SESSION_ID
```
- `live-status.mjs` prints connected helper state, active durable sessions, and queued pending events. It works even when the helper is down by reading the journal directly.
- `live-resume.mjs` prints the active snapshot, pending event, checkpoint phase, visible variant, parameter values, and the next safe agent action.
- `live-complete.mjs` is the canonical manual final acknowledgement. Use it after carbonize/manual cleanup is verified and no further poll acknowledgement will happen automatically.
Server restart rule: start `live-server.mjs` again, then poll. Startup requeues unacknowledged pending events from the journal, so do not ask the user to click Go again unless `live-resume.mjs` says no active session exists.
## Handle `generate`
Event: `{id, action, freeformPrompt?, count, pageUrl, element, screenshotPath?, comments?, strokes?}`.
**Replace mode** (default): `{id, action, freeformPrompt?, count, pageUrl, element, screenshotPath?, comments?, strokes?}`.
Speed matters — the user is watching a spinner. Minimize tool calls by using the `wrap` helper and writing all variants in a single edit.
**Insert mode** (`event.mode === "insert"`): `{id, mode: "insert", count, pageUrl, insert: { position, anchor }, placeholder: { width, height }, freeformPrompt?, screenshotPath?, comments?, strokes?}`. No `action`. Requires a non-empty `freeformPrompt` **or** annotations. Screenshot is sent only when annotations exist (same rule as replace). Use `placeholder` dimensions as a soft size hint for net-new content.
Speed matters; the user is watching a spinner. Minimize tool calls by using the wrap/insert helper and writing all variants in a single edit.
### Insert mode branch
When `event.mode === "insert"`:
1. Read the screenshot if `event.screenshotPath` is present (annotations only).
2. Run the insert helper instead of wrap:
```bash
node .agents/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVENT_COUNT --position after \
--element-id "ANCHOR_ID" --classes "class1,class2" --tag "section" --text "ANCHOR_TEXT"
```
- `--position``event.insert.position` (`before` | `after`)
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Load `brand.md` or `product.md` (freeform only, no action sub-command). Write all variants in one edit, then `--reply done`.
On accept/discard, `live-accept.mjs` removes the wrapper block; the anchor element is untouched.
### Replace mode (default)
### 1. Read the screenshot (if present)
`event.screenshotPath` is **only sent when the user placed at least one comment or stroke before Go.** When present, it's an absolute path to a PNG of the element as rendered with the annotations baked in. **Read it before planning** annotations encode user intent not recoverable from `element.outerHTML` alone.
`event.screenshotPath` is **only sent when the user placed at least one comment or stroke before Go.** When present, it's an absolute path to a PNG of the element as rendered with the annotations baked in. **Read it before planning**: annotations encode user intent not recoverable from `element.outerHTML` alone.
When `screenshotPath` is absent, don't ask for one and don't go looking for the current rendering. The omission is deliberate: without annotations, a screenshot would anchor the model on the existing design and fight the three-distinct-directions brief. Work from `element.outerHTML`, the computed styles in `event.element`, and the freeform prompt if present.
@@ -66,32 +126,42 @@ When `screenshotPath` is absent, don't ask for one and don't go looking for the
Reading annotations precisely:
- **Comment position is load-bearing.** Its `{x, y}` is element-local CSS px (same coord space as `element.boundingRect`). Find the child under that point and apply the comment text LOCALLY to that sub-element. A comment near the title is about the title, not a global description.
- **Comment position carries meaning.** Its `{x, y}` is element-local CSS px (same coord space as `element.boundingRect`). Find the child under that point and apply the comment text LOCALLY to that sub-element. A comment near the title is about the title, not a global description.
- **Comments and strokes are independent annotations** unless clearly paired by overlap or tight proximity. Don't let the visual weight of a prominent stroke override the precise location of a textually-specific comment elsewhere.
- **Strokes are gestures read them by shape.** Closed loop = "this thing" (emphasis / focus); arrow = direction (move / point to); cross or slash = delete; free scribble = emphasis or delete depending on context. A loop around region X means "pay attention to X," not "only change pixels inside X."
- **Strokes are gestures; read them by shape.** Closed loop = "this thing" (emphasis / focus); arrow = direction (move / point to); cross or slash = delete; free scribble = emphasis or delete depending on context. A loop around region X means "pay attention to X," not "only change pixels inside X."
- **When a stroke's intent is ambiguous** (circle or arrow? emphasis or move?), state your reading in one sentence of rationale rather than silently guessing. If the uncertainty materially changes the brief, ask one short clarifying question before generating.
### 2. Wrap the element
```bash
node .agents/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
node .agents/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET"
```
Flag mapping — keep them separate, don't collapse into `--query`:
Flag mapping. Keep them separate, don't collapse into `--query`:
- `--element-id``event.element.id`
- `--classes``event.element.classes` joined with commas
- `--tag``event.element.tagName`
- `--text` ← first ~80 chars of `event.element.textContent` (trim, single-line). **Pass this every call.** When the picked element shares classes + tag with sibling components (a list of `<Card>`s, repeating sections), this is what disambiguates which branch in source to wrap. Without it, wrap silently lands on the first match and may rewrite the wrong element.
The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only do not use it for normal element lookups.
The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only; do not use it for normal element lookups.
Output on success: `{ file, insertLine, commentSyntax }`.
If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"`: read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow.
**Fallback errors.** Wrap only writes into files it judges to be source (tracked by git, not marked GENERATED, not listed in config's `generatedFiles`). If it can't land on a source file, it errors without writing — accepting a variant into a generated file is silent data loss. Three shapes:
Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`.
- `{ error: "file_is_generated", file, hint }` — user-supplied `--file` points at a generated file.
- `{ error: "element_not_in_source", generatedMatch, hint }` — element exists only in a generated file (the next build would wipe any edits).
- `{ error: "element_not_found", hint }` — element isn't in any project file; likely runtime-injected (JS component, data-driven render).
`styleMode` controls how preview CSS must be authored. Treat it as a detected capability mode, not a framework guess:
- `scoped`: use `@scope ([data-impeccable-variant="N"])` rules.
- `astro-global-prefixed`: use explicit `[data-impeccable-variant="N"]` selector prefixes and the exact `styleTag` returned by the tool.
Use `cssAuthoring` as the source of truth for the current file. It includes the exact `styleTag`, selector strategy, selector examples, requirements, and forbidden patterns. Do not apply a framework-specific exception unless the returned `styleMode` / `cssAuthoring.mode` says to.
**Fallback errors.** Wrap only writes into files it judges to be source (tracked by git, not marked GENERATED, not listed in config's `generatedFiles`). If it can't land on a source file, it errors without writing; accepting a variant into a generated file is silent data loss. Three shapes:
- `{ error: "file_is_generated", file, hint }`: user-supplied `--file` points at a generated file.
- `{ error: "element_not_in_source", generatedMatch, hint }`: element exists only in a generated file (the next build would wipe any edits).
- `{ error: "element_not_found", hint }`: element isn't in any project file; likely runtime-injected (JS component, dynamic render from data).
All three carry `fallback: "agent-driven"`. Follow **Handle fallback** below.
@@ -101,68 +171,118 @@ If `event.action` is `impeccable` (the default freeform action), use SKILL.md's
Any other `event.action` (`bolder`, `quieter`, `distill`, `polish`, `typeset`, `colorize`, `layout`, `adapt`, `animate`, `delight`, `overdrive`): Read `reference/<action>.md` before planning. Each sub-command encodes a specific discipline; skipping its reference produces generic output. Those files may require specific params; layer them on top of the §7 budget, not instead of it.
### 4. Plan three genuinely distinct directions
### 4. Plan three variants: identity first, then mode, then axes
Before writing a single line of code, name each variant.
The wrong frame for live mode is "show three different design directions." Live runs on an existing surface; the brand has already been chosen. The job is variation **within identity**, not selection between identities. Failure mode: three editorial-typographic variants on a brief that wasn't editorial. Bigger failure mode: three off-brand variants the user can't accept because they don't look like their product.
**For freeform (`action` is `impeccable`, or the user supplied a free prompt):** each variant must anchor to a different **archetype** — a real-world design analogue specific enough to be recognizable at a glance. Not "modern landing page." Not "minimal product hero." Examples:
Four phases. Do them in order.
- *Broadsheet masthead with rule-divided columns* (think NYT print edition)
- *Klim Type Foundry specimen page* (dense, technical, catalog-driven)
- *Japanese print-poster minimalism with a single oversize glyph*
- *Bloomberg Terminal status bar*
- *Condé Nast Traveler feature layout*
#### Phase A: Extract the identity (non-skippable)
Then commit each variant to a different **primary axis** of difference:
The existing surface has an identity already. Read it before planning anything. Sources, in priority order:
1. **Hierarchy** — which element commands the eye?
2. **Layout topology** — stacked / side-by-side / grid / asymmetric / overlay
3. **Typographic system** — pairing, scale ratio, case/weight strategy
4. **Color strategy** — Restrained / Committed / Full palette / Drenched
5. **Density** — minimal / comfortable / dense
6. **Structural decomposition** — merge, split, progressive disclosure
1. **DESIGN.md** if loaded: read the visual system fields (palette, type pairing, motion, components). This is the authoritative answer.
2. **CSS custom properties** in the page's stylesheets (`:root { --color-...; --font-...; ... }`): these are de-facto tokens.
3. **Computed styles** on the picked element and its parent: colors, fonts, spacing scales, corner radii.
4. **Sibling components on the page**: what visual rhetoric do existing components use? (Asymmetric or centered? Dense or airy? Bold or quiet?)
Three variants → three DIFFERENT primary axes, not three riffs on color.
Write down what you see in **one sentence**. The sentence describes the surface that's actually on screen; it is not aspirational, not opinionated, not edited toward what the brand "should" be. Capture, in roughly this order:
**When the primary axis is color or theme, forbid the trio from sharing theme + dominant hue.** Two dark-plus-one-dark is not distinct. Aim for one dark-neutral-accent, one light-drenched, one full-palette-saturated — three color worlds, not three shades of the same.
- The dominant surface color and accent color, by hex or token name (use the actual values, not categories like "warm" or "neutral").
- The type pairing: the actual font names loaded, primary first.
- The layout topology: how the dominant elements are arranged (stacked / side-by-side / grid / asymmetric / overlay).
- The surface treatment: corners, borders, shadows, density of decoration.
- The voice tone you read off the copy itself, not off the aesthetic feel.
**The squint test (before writing code).** Write the three one-sentence descriptions side by side:
Be specific. "Modern" is not a color, "elegant" is not a type pairing, "clean" is not a layout. If you can't extract a real value for an axis, skip it rather than fabricate. The point is to record what is, not to describe what you wish it were.
> V1: Broadsheet masthead, ruled columns, 24px ink on cream.
> V2: Enormous italic title, catalog spec rows, heavy monospace data.
> V3: Card-framed poster with one oversize glyph, magenta veil.
Do not include adjectives that name an aesthetic family ("editorial-leaning", "terminal-flavored", "brutalist"); those are conclusions, not data. They belong to Phase C lane selection in departure mode, not to identity description. Letting them sneak into Phase A is how the identity-lock collapses into a self-fulfilling prophecy.
If two of them rhyme ("both use big type" / "both are stacks of sections" / "both feature the CTA prominently"), rework the offender. Freeform variants failing the squint test is the primary failure mode of this flow — three-of-the-same with minor styling tweaks.
This sentence is the **identity lock**. Every variant must be readable as the same brand if rendered side by side. Skipping this phase is the primary cause of off-brand variants. Absence of DESIGN.md is never an excuse; extract from CSS and computed styles instead.
#### Phase B: Pick mode (default vs departure)
**Default mode**: the existing identity is preserved. Variants vary expression axes within it. *This is the right mode for ~90% of live sessions.* The user picked an element on a real product they're shipping; they expect variants of *their* hero, not three different brands' heroes.
**Departure mode**: the existing identity is rejected. Variants propose alternatives consistent with PRODUCT.md voice. Trigger only when at least one is true:
- PRODUCT.md anti-references explicitly call out the current surface ("the current `index.html` is itself an example"; "diffuse away from this"; "the page on screen is the failure"). Generic anti-references that describe what to avoid in general do **not** trigger departure mode; only ones that point at *this* surface specifically.
- The user's freeform prompt explicitly asks for departure ("rebuild this from scratch", "what if it weren't editorial at all", "show me something completely different").
If you're unsure, you're in default mode. The cost of being wrong about default is "three on-brand variants with similar feel": recoverable, the user picks none. The cost of being wrong about departure is "three off-brand variants": unrecoverable, the user is annoyed.
#### Phase C: Plan three variants
**Default mode.** Each variant commits to a different **primary axis** of difference, while preserving the identity sentence. The six axes:
1. **Hierarchy**: which element commands the eye?
2. **Layout topology**: stacked / side-by-side / grid / asymmetric / overlay
3. **Typographic system**: pairing logic, scale ratio, case/weight strategy *within the available faces*
4. **Color strategy**: which existing palette role carries the surface (Restrained / Committed / Full palette / Drenched). Use the brand's existing palette tokens, not new colors.
5. **Density**: minimal / comfortable / dense
6. **Structural decomposition**: merge, split, progressive disclosure
Three variants → three DIFFERENT axes. The trio reads as *the same brand at three angles*. Do not introduce new fonts, new palette hues, or new aesthetic-family signals; those belong to departure mode.
**While planning each variant, also name its 23 parameter knobs** (per the §7 budget table). Parameters are part of the design, not a decoration added afterward. If the variant explores density, expose a density knob. If it explores color commitment, expose a color-amount range. Deciding "what's tunable" during planning produces better knobs than retrofitting them onto finished HTML.
**Departure mode.** Each variant anchors to a different **aesthetic direction**, derived from the brand's stated voice and register in PRODUCT.md. Do NOT pick from a fixed catalog of lane categories. The right three directions for this brand are not the same as the right three for another brand, and picking from a list is itself the training-data reflex (the model selects "Swiss-grid, Terminal, Industrial-signage" every time because those are the furthest-from-editorial items in any enumerated list).
Instead, work from the brand:
1. Read PRODUCT.md's Brand Personality words. What physical, spatial, or material experiences would embody those words if design were not involved? (A personality described as "specific, earned, unmistakable" evokes a hand-stamped letter, a numbered print, a watchmaker's loupe. A personality described as "restless, loud, unfiltered" evokes a concert poster, a spray-painted wall, a megaphone.)
2. From those physical experiences, derive three visual directions that are genuinely different from each other AND from the current surface you're departing.
3. Avoid the **reflex-reject lanes** in [brand.md](brand.md). Don't trade one monoculture for another. If you find yourself reaching for "Swiss-grid" or "Terminal" or "Industrial-signage" by reflex, you are pattern-matching a catalog in your training data, not reading the brand. Start over from the personality words.
4. Each direction must be expressible in one concrete sentence that names a real-world referent ("a museum exhibition label system for a contemporary art gallery" not "clean and minimal"). If your sentence contains only adjectives, it's not concrete enough.
5. **While planning each direction, also name its 23 parameter knobs** (per the §7 budget table). The same principle as default mode: decide "what's tunable" during planning, not after writing the HTML. A departure-mode hero with 0 parameters is not "bold creative vision," it's a missed opportunity for the user to fine-tune the direction they pick.
#### Phase D: Squint test
**Default mode squint.** Read each variant's identity sentence and compare to the locked identity from Phase A. If any variant has drifted to a different palette, type voice, or visual rhetoric, it has crossed into departure mode by accident; rework. Then check that each variant commits to a different primary axis. Three "tighter density" variants is failure.
**Departure mode squint.** Two passes, family before sentence:
1. **Family pass.** Label each variant with one design-family word of your own choosing (any concrete noun: *exhibition, storefront, cockpit, recipe-card, playbill, field-manual*). If any two variants share a label, or if the label could apply to the other variants equally well, rework. Do not use a fixed vocabulary list for the labels. *This pass is non-negotiable in departure mode and catches the monoculture failure that the sentence pass misses.*
2. **Sentence pass.** Write three one-sentence descriptions side by side. If two of them rhyme ("both feature big type" / "both are stacks of sections" / "both center the CTA"), rework the offender.
**When the primary axis is color or theme, forbid the trio from sharing theme + dominant hue.** Two dark-plus-one-dark is not distinct. Aim for three color worlds, not three shades of the same.
**For action-specific invocations**, each variant must vary along the dimension the action names:
- `bolder` amplify a different dimension per variant (scale / saturation / structural change). Not three "slightly bigger" variants.
- `quieter` pull back a different dimension (color / ornament / spacing).
- `distill` remove a different class of excess (visual noise / redundant content / nested structure).
- `polish` target a different refinement axis (rhythm / hierarchy / micro-details like corner radii, focus states, optical kerning).
- `typeset` different type pairing AND different scale ratio each. Not three riffs on one pairing.
- `colorize` different hue family each (not shades of one hue). Vary chroma and contrast strategy.
- `layout` different structural arrangement (stacked / side-by-side / grid / asymmetric). Not spacing tweaks.
- `adapt` different target context per variant (mobile-first / tablet / desktop / print or low-data). Don't make three mobile layouts.
- `animate` different motion vocabulary (cascade stagger / clip wipe / scale-and-focus / morph / parallax). Not three staggered fades.
- `delight` different flavor of personality (unexpected micro-interaction / typographic surprise / illustrated accent / sonic-or-haptic moment / easter-egg interaction).
- `overdrive` different convention broken (scale / structure / motion / input model / state transitions). Skip `overdrive.md`'s "propose and ask" step live mode is non-interactive.
- `bolder`: amplify a different dimension per variant (scale / saturation / structural change). Not three "slightly bigger" variants.
- `quieter`: pull back a different dimension (color / ornament / spacing).
- `distill`: remove a different class of excess (visual noise / redundant content / nested structure).
- `polish`: target a different refinement axis (rhythm / hierarchy / micro-details like corner radii, focus states, optical kerning).
- `typeset`: different type pairing AND different scale ratio each. Not three riffs on one pairing.
- `colorize`: different hue family each (not shades of one hue). Vary chroma and contrast strategy.
- `layout`: different structural arrangement (stacked / side-by-side / grid / asymmetric). Not spacing tweaks.
- `adapt`: different target context per variant (mobile-first / tablet / desktop / print or low-data). Don't make three mobile layouts.
- `animate`: different motion vocabulary (cascade stagger / clip wipe / scale-and-focus / morph / parallax). Not three staggered fades.
- `delight`: different flavor of personality (unexpected micro-interaction / typographic surprise / illustrated accent / sonic-or-haptic moment / easter-egg interaction).
- `overdrive`: different convention broken (scale / structure / motion / input model / state transitions). Skip `overdrive.md`'s "propose and ask" step; live mode is non-interactive.
### 5. Apply the freeform prompt (if present)
`event.freeformPrompt` is the user's ceiling on direction all variants must honor it but still explore meaningfully different *interpretations*. "Make it feel like a newspaper front page" → variant 1 = broadsheet masthead + rule-divided columns, variant 2 = tabloid headline + single dominant image, variant 3 = minimalist editorial with oversized drop cap. Not three newspapers in the same voice.
`event.freeformPrompt` is the user's ceiling on direction (all variants must honor it), but still explore meaningfully different *interpretations*. The interpretations stay within whichever mode you picked in Phase B.
In **default mode**, the prompt narrows the axes you choose, not the identity. *"Make it feel more confident"* → variant 1 amplifies hierarchy (one element commands the eye), variant 2 commits the existing accent color (Committed strategy on the brand's hue), variant 3 tightens density and removes decorative slack. Three different axes, same brand.
In **departure mode**, the prompt narrows the lanes you draw from, not the families. *"Make it feel like a newspaper front page"* would itself be a departure-mode prompt; honor it but pick three meaningfully different newspaper-adjacent lanes (broadsheet vs. tabloid vs. trade journal), and run the family pass to confirm they don't collapse into one.
When the prompt and PRODUCT.md anti-references conflict (the prompt asks for X, the anti-references ban X), the anti-references win; they describe the brand's standing position, the prompt is one moment.
### 6. Write all variants in a single edit
Complete HTML replacement of the original element for each variant, not a CSS-only patch. Consider the element's context (computed styles, parent structure, CSS variables from `event.element`).
Write CSS + all variants in ONE edit at the `insertLine` reported by `wrap`. Colocate scoped CSS as a `<style>` tag inside the variant wrapper `<style>` works anywhere in modern browsers and this ensures CSS and HTML arrive atomically (no FOUC).
Write CSS + all variants in ONE edit at the `insertLine` reported by `wrap`. Colocate CSS as a `<style>` tag inside the variant wrapper; `<style>` works anywhere in modern browsers and this ensures CSS and HTML arrive atomically (no FOUC).
Use the `cssAuthoring` object returned by `live-wrap.mjs` to author the temporary preview CSS. The style opening tag shown below is the common case; replace it with `cssAuthoring.styleTag` when the tool returns a different one. The variant markup shape is otherwise stable:
```html
<!-- Variants: insert below this line -->
<style data-impeccable-css="SESSION_ID">
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
/* rules matching cssAuthoring.rulePattern */
</style>
<div data-impeccable-variant="1">
<!-- variant 1: full element replacement (single top-level element) -->
@@ -175,11 +295,30 @@ Write CSS + all variants in ONE edit at the `insertLine` reported by `wrap`. Col
</div>
```
**Each variant div contains exactly one top-level element the full replacement for the original.** Use the same tag as the original (e.g. `<section>` if the user picked a `<section>`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child.
**Each variant div contains exactly one top-level element: the full replacement for the original.** Use the same tag as the original (e.g. `<section>` if the user picked a `<section>`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child.
The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the `<style>` tag entirely. Use `@scope` for CSS isolation (Chrome 118+ / Firefox 128+ / Safari 17.4+).
The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no preview CSS, omit the `<style>` tag entirely.
One edit, all variants the browser's MutationObserver picks everything up in one pass.
One edit, all variants; the browser's MutationObserver picks everything up in one pass.
For `styleMode: "scoped"`, author every `:scope` rule with a descendant combinator. The `@scope` boundary is the **variant wrapper `<div data-impeccable-variant="N">`**, not the element you're designing. A bare `:scope { background: cream; }` styles the wrapper, not the inner replacement, so the cream lands on a `display: contents` shell while the actual element keeps page defaults. Always step in: `:scope > .card`, `:scope > section`, `:scope .hero-title`, etc. The fake test agent's CSS in `tests/live-e2e/agent.mjs` is a faithful template; every scoped rule starts `:scope > ...`.
**JSX / TSX target files.** Wrap `<style>` content in a template literal so the CSS `{` / `}` aren't parsed as JSX expressions, and use `className=` / `style={{…}}` on every variant element. Keep `data-impeccable-*` attributes as-is; they're plain strings:
```tsx
<style data-impeccable-css="SESSION_ID">{`
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
`}</style>
<div data-impeccable-variant="1">
{/* variant 1 */}
</div>
<div data-impeccable-variant="2" style={{ display: 'none' }}>
{/* variant 2 */}
</div>
```
The wrap script already gives you a single-rooted JSX wrapper: a `<div data-impeccable-variants="…">` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX.
### 7. Parameters (composition-sized, 04 per variant)
@@ -189,18 +328,18 @@ Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement
**When to add.** As soon as the variants scoped CSS has a meaningful continuous or stepped axis: density, color amount, type scale, motion intensity, column weight, and so on. If you can imagine the user muttering “a bit tighter” or “a touch more accent” **without** wanting a full regeneration, wire that axis. **Not** micro-margins or one-off nudges; those are not parameters.
**Freeform (`action` is `impeccable`) bias.** You did not load `reference/bolder.md` (etc.), so you must **choose** 12 signature-like axes yourself. Prefer knobs that sit on the same dimensions as your three directions (e.g. all three riffs on editorial density expose `density` or a `steps` “air / snug / packed”; two directions differ mostly in chroma → add `color-amount`). A hero, section, or other **large** surface that ships with **0** params needs a one-line reason in your head (e.g. “truly a fixed-point A/B/C comparison, no shared dial”), not a default habit.
**Freeform (`action` is `impeccable`) bias.** You did not load a sub-command reference, so you must **choose** signature axes yourself. Match the budget table: for a hero or large composition, that means **23 axes per variant**, not 1. Prefer knobs that sit on the dimensions where your three variants actually differ (if density varies, expose it as a `steps` knob; if color commitment varies, expose it as a `range`). A hero that ships with **0** params is almost always a mistake, not a judgment call. A hero with exactly **1** param is underweight unless the design is genuinely a fixed-point comparison. Start from the budget table, not from zero.
**Budget scales with the element's visual weight, not token budget.** Knobs need real estate to read as tunable; three sliders on a single control are noise.
- **Leaf / tiny** a single button, icon, input, bare heading, solitary paragraph: **0 params.**
- **Small composition** labeled input, simple card, short callout (≤ ~5 visual children): **01** params when one dominant axis is obvious; otherwise **0.**
- **Medium composition** section component, nav cluster, dense card, short feature block (615 visual children): **target 2**; **1** is acceptable if the block is simple; **0** only when variants are truly fixed points.
- **Large composition** hero section, full page region, spread layout, strong internal structure (16+ visual children or multiple sub-sections): **target 23**; **up to 4** when several independent axes (e.g. structure `steps` + `density` + one accent) are all authored in scoped CSS.
- **Leaf / tiny**: a single button, icon, input, bare heading, solitary paragraph: **0 params.**
- **Small composition**: labeled input, simple card, short callout (≤ ~5 visual children): **01** params when one dominant axis is obvious; otherwise **0.**
- **Medium composition**: section component, nav cluster, dense card, short feature block (615 visual children): **target 2**; **1** is acceptable if the block is simple; **0** only when variants are truly fixed points.
- **Large composition**: hero section, full page region, spread layout, strong internal structure (16+ visual children or multiple sub-sections): **target 23**; **up to 4** when several independent axes (e.g. structure `steps` + `density` + one accent) are all authored in scoped CSS.
**When in doubt, ask whether a dial exists before defaulting to zero.** The user can always request more variants, but the point of live mode is instant tuning without another Go. Crowding the panel is bad; **under-shipping** knobs on a dense composition is the more common failure for freeform. Count by **visual** children, not DOM depth; a shallow-but-wide hero is still large.
**Hard cap per variant** at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it.
**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it.
**How to declare.** Put a JSON manifest on the variant wrapper:
@@ -220,9 +359,9 @@ Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement
**Three kinds:**
- `range` smooth slider. Drives a CSS custom property `--p-<id>` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`.
- `steps` segmented radio. Drives a data attribute `data-p-<id>` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`.
- `toggle` on/off switch. Drives BOTH a CSS var (`--p-<id>: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`.
- `range`: smooth slider. Drives a CSS custom property `--p-<id>` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`.
- `steps`: segmented radio. Drives a data attribute `data-p-<id>` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`.
- `toggle`: on/off switch. Drives BOTH a CSS var (`--p-<id>: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`.
**Signature params per action.** For named sub-commands, read that actions `reference/<action>.md` for one or two **MUST** params (e.g. `layout``density`). Those are non-negotiable when the design can express them. **Freeform has no file-level MUST**; the **Freeform (`impeccable`) bias** in this section is the stand-in. If the users action is both stylized and sub-command (e.g. `colorize`), the sub-commands MUST list takes precedence for its axes; still respect the **Hard cap** and add no redundant duplicate knobs.
@@ -242,10 +381,20 @@ The carbonize cleanup step (see below) reads that comment and bakes the chosen v
node .agents/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
```
`RELATIVE_PATH` is relative to project root (`public/index.html`, `src/App.tsx`, etc.) the browser fetches source directly if the dev server lacks HMR.
`RELATIVE_PATH` is relative to project root (`public/index.html`, `src/App.tsx`, etc.); the browser fetches source directly if the dev server lacks HMR.
Then run `live-poll.mjs` again immediately.
### Aborting an in-flight session
If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING:
```bash
node .agents/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason"
```
Don't run `live-accept --discard` for this; that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered.
## Handle fallback
When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here.
@@ -256,9 +405,9 @@ The goal is the same: give the user three variants to choose from AND persist th
Use the error payload:
- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"` the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element.
- `element_not_found` the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it.
- `file_is_generated` with `file: "..."` user pointed at a generated file explicitly. Same resolution as `element_not_in_source`.
- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"`: the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element.
- `element_not_found`: the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it.
- `file_is_generated` with `file: "..."`: user pointed at a generated file explicitly. Same resolution as `element_not_in_source`.
Read the candidate source until you're confident where a change to the element would belong. If the change is purely visual, that source might be a shared stylesheet, not the template.
@@ -266,19 +415,19 @@ Read the candidate source until you're confident where a change to the element w
The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something:
1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces `<!-- impeccable-variants-start ID --><div data-impeccable-variants="ID" data-impeccable-variant-count="3" style="display: contents">…</div><!-- end -->`.
1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces; `<!-- impeccable-variants-start ID --><div data-impeccable-variants="ID" data-impeccable-variant-count="3" style="display: contents">…</div><!-- end -->`.
2. Insert your three variant divs inside it, same shape as the deterministic path.
3. Signal done with `--reply EVENT_ID done --file <served file>`. The browser's no-HMR fallback will fetch and inject.
This served-file edit is **temporary** next regen wipes it, and that's fine. The real work happens on accept.
This served-file edit is **temporary**: next regen wipes it, and that's fine. The real work happens on accept.
### Step 3: On accept, write to true source
When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1:
When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files; see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1:
- Structural change → edit the template / component source.
- Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `<style>` scope.
- Data-driven → update the data source or the render logic.
- Dynamic from data → update the data source or the render logic.
Then remove the temporary wrapper from the served file if it's still there.
@@ -288,45 +437,68 @@ Remove the wrapper you inserted in Step 2. Nothing else to do.
## Handle `accept`
Event: `{id, variantId, _acceptResult}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically; the browser DOM is already updated.
Event: `{id, variantId, _acceptResult, _completionAck}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically, then acknowledged event delivery to the helper. The browser DOM is already updated.
- `_acceptResult.handled: true` and `carbonize: false` — nothing to do. Poll again.
- `_acceptResult.handled: true` and `carbonize: true`**post-accept cleanup is required before the next poll.** See the "Required after accept (carbonize)" section below. The `event._acceptResult.todo` field and a stderr banner both list the steps explicitly; neither is decorative.
- `_acceptResult.handled: false, mode: "fallback"` — the session lived in a generated file and the script refused to persist there. You've already written the accepted variant into true source during Handle fallback Step 3; just clean up the temporary wrapper in the served file if any, and poll again.
- `_acceptResult.handled: false` without `mode` — manual cleanup: read file, find markers, edit.
- `_completionAck.ok !== true`: do not poll yet. Run `live-status.mjs` / `live-resume.mjs`, complete the cleanup manually if needed, then run `live-complete.mjs --id EVENT_ID`.
- `_acceptResult.handled: true` and `carbonize: false`: nothing to do. Poll again.
- `_acceptResult.handled: true` and `carbonize: true`: **post-accept cleanup is required before the next poll.** See the "Required after accept (carbonize)" section below. The `event._acceptResult.todo` field, `_completionAck.requiresComplete`, and a stderr banner all point at this required follow-up; none are decorative. After cleanup, run `live-complete.mjs --id EVENT_ID`, then poll again.
- `_acceptResult.handled: false, mode: "fallback"`: the session lived in a generated file and the script refused to persist there. You've already written the accepted variant into true source during Handle fallback Step 3; just clean up the temporary wrapper in the served file if any, and poll again.
- `_acceptResult.handled: false` without `mode`: manual cleanup: read file, find markers, edit.
### Required after accept (carbonize)
When `_acceptResult.carbonize === true`, the accepted variant was stitched into source with helper markers and inline CSS so the browser can render it immediately with no visual gap. That stitch-in is **temporary**. The agent must rewrite it into permanent form before doing anything else. Skipping this leaves dead `@scope` rules for unaccepted variants, a pointless `data-impeccable-variant` wrapper, and `impeccable-carbonize-start/end` comment noise in the source file all of which accumulate across sessions.
When `_acceptResult.carbonize === true`, the accepted variant was stitched into source with helper markers and inline CSS so the browser can render it immediately with no visual gap. That stitch-in is **temporary**. The agent must rewrite it into permanent form before doing anything else. Skipping this leaves dead `@scope` rules for unaccepted variants, a pointless `data-impeccable-variant` wrapper, and `impeccable-carbonize-start/end` comment noise in the source file; all of which accumulate across sessions.
Do these five steps in the current thread, synchronously, before the next poll. Do not poll again until the file is clean.
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values read it first; it drives steps 3 and 4 below.
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `public/css/workflow.css` for this repo, or the component's co-located CSS file for a Vite/Next project pick whichever already owns styling for the surrounding element).
1. **Locate the carbonize block** in the source file (`_acceptResult.file`). It's bracketed by `<!-- impeccable-carbonize-start SESSION_ID -->` and `<!-- impeccable-carbonize-end SESSION_ID -->` and contains a `<style data-impeccable-css="SESSION_ID">` element. If the variant declared parameters, an `<!-- impeccable-param-values SESSION_ID: {...} -->` comment sits alongside the style tag with the user's chosen values; read it first; it drives steps 3 and 4 below.
2. **Move the CSS rules** into the project's real stylesheet. Which stylesheet depends on the project (e.g. `site/styles/workflow.css` for an Astro project, or the component's co-located CSS file for a Vite/Next project; pick whichever already owns styling for the surrounding element).
3. **Bake in parameter values while rewriting selectors.** For `@scope ([data-impeccable-variant="N"])` wrappers: retarget to real, semantic classes on the accepted HTML (`.why-visual--v2 .v2-label { … }`). For `:scope[data-p-<id>="VALUE"]` selectors: keep only the branch matching the chosen value from the param-values comment; drop the others (they're dead after accept). For `var(--p-<id>, DEFAULT)` in the CSS: either substitute the literal value, or if the param is still useful as a knob going forward, leave the var and update its initial declaration to the chosen value.
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it those are live-mode plumbing, not source.
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one those are dead code now.
4. **Unwrap the accepted content.** Delete the `<div data-impeccable-variant="N" style="display: contents">` that wraps it. Drop `data-impeccable-params` and any `data-p-*` attributes from it; those are live-mode plumbing, not source.
5. **Delete the inline `<style>` block, the `<!-- impeccable-param-values -->` comment if present, and both `<!-- impeccable-carbonize-start/end -->` markers.** Also drop any `@scope` rules for variants other than the accepted one; those are dead code now.
Then poll again.
After the file is clean, run `live-complete.mjs --id SESSION_ID`, verify it reports `phase: "completed"`, then poll again.
A background agent may be used for the rewrite, but the current thread is responsible for verifying the five steps are complete before issuing the next poll. In practice, inline is usually faster and less error-prone.
## Handle `discard`
Event: `{id, _acceptResult}`. The poll script already restored the original and removed all variant markers. Nothing to do. Poll again.
Event: `{id, _acceptResult, _completionAck}`. The poll script already restored the original, removed all variant markers, and acknowledged `discarded` durable completion. Nothing to do unless `_completionAck.ok !== true`; in that case run `live-complete.mjs --id EVENT_ID --discarded`, then poll again.
## Handle `steer`
Event: `{id, message, pageUrl}`. The user typed or spoke into the global bar **Steer** control: page-level direction without picking an element or launching variant generation.
The mic button uses the browser **Web Speech API** (MVP): click to start, speak, stop automatically when the utterance ends, then the transcript submits as a steer event. Click again while listening to cancel without submitting.
This is lighter than `generate`: no screenshot, no element context, no variant cycling. Read `message` and inspect the live page or project files as needed, then either make edits or answer in prose.
When finished:
```bash
node .agents/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID steer_done ["Optional short note for a browser toast"]
```
On failure:
```bash
node .agents/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason"
```
Then poll again immediately. Do not send a separate "picked up" reply. The Steer bar stays locked until `steer_done` or `error` arrives over SSE.
## Handle `prefetch`
Event: `{pageUrl}`. The browser fires this the first time the user selects an element on a given route, as a latency shortcut it signals the user is likely about to Go on a page you haven't read yet.
Event: `{pageUrl}`. The browser fires this the first time the user selects an element on a given route, as a latency shortcut; it signals the user is likely about to Go on a page you haven't read yet.
Resolve `pageUrl` to the underlying file:
- Root `/` → the `pageFile` returned by `live.mjs` (usually `public/index.html` or equivalent).
- Sub-routes (e.g. `/docs`, `/docs/live`) → the generated or source file for that route. Use your knowledge of the project layout (multi-page static sites often resolve `/foo``public/foo/index.html`; SPAs may map all routes to a single entry).
Read the file into context, then poll again. No `--reply` this is speculative pre-work; Go will come later. If you can't confidently resolve the route to a file, skip and poll again.
Read the file into context, then poll again. No `--reply`: this is speculative pre-work; Go will come later. If you can't confidently resolve the route to a file, skip and poll again.
Dedupe is the browser's job (one prefetch per unique pathname per session) trust it. If the same file shows up twice from different routes mapping to the same file, the second Read is cached anyway.
Dedupe is the browser's job (one prefetch per unique pathname per session); trust it. If the same file shows up twice from different routes mapping to the same file, the second Read is cached anyway.
## Exit
@@ -343,7 +515,7 @@ When the poll returns `exit`, proceed to cleanup. If the poll is still running a
node .agents/skills/impeccable/scripts/live-server.mjs stop
```
Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `config.json` persists for future sessions.
Stops the HTTP server and runs `live-inject.mjs --remove` to strip `localhost:…/live.js` from the HTML entry. To stop the server but keep the inject tag (for a quick restart), use `stop --keep-inject`. `.impeccable/live/config.json` persists as project config for future sessions.
Then:
- Remove any leftover variant wrappers (search for `impeccable-variants-start` markers).
@@ -351,7 +523,7 @@ Then:
## First-time setup (config missing or invalid)
If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path.
If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write the live config at the reported path. By default this is `.impeccable/live/config.json`.
Schema:
@@ -365,7 +537,7 @@ Schema:
}
```
`files` is the inject target **the HTML files the browser actually loads**, not necessarily source. Each entry is either a literal path (`"public/index.html"`) or a glob pattern (`"public/**/*.html"`). Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
`files` is the inject target; **the HTML files the browser actually loads**, not necessarily source. Each entry is either a literal path (`"public/index.html"`) or a glob pattern (`"public/**/*.html"`). Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
`exclude` (optional) is a list of glob patterns matching files to skip, even if a `files` glob would have included them. Use for email templates, demo fixtures, or any HTML that isn't a live page.
@@ -383,13 +555,13 @@ Schema:
| Nuxt | `["app.vue"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | `["public/**/*.html"]` a glob covering the served directory | `</body>` | `html` |
| Multi-page (separate HTML per route) | `["public/**/*.html"]`: a glob covering the served directory | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed.
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected it writes to true source via the fallback flow.
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected; it writes to true source via the fallback flow.
### Drift-heal warning
@@ -417,7 +589,7 @@ When `configDrift` is present, surface it to the user once per session before en
>
> Add them, or switch `files` to a glob like `["public/**/*.html"]` and let it track new pages automatically?
Don't auto-update the config let the user decide. `configDrift` is `null` when there's no drift.
Don't auto-update the config; let the user decide. `configDrift` is `null` when there's no drift.
### CSP detection (first-time only)
@@ -431,21 +603,21 @@ node .agents/skills/impeccable/scripts/detect-csp.mjs
Output: `{ shape, signals }` where `shape` is one of `append-arrays`, `append-string`, `middleware`, `meta-tag`, or `null`. The shape is named by *patch mechanism*, so one template covers many frameworks.
- **`null`** no CSP; skip to writing `config.json` with `cspChecked: true`.
- **`append-arrays`** CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers:
- **`null`**: no CSP; skip to writing `.impeccable/live/config.json` with `cspChecked: true`.
- **`append-arrays`**: CSP defined as structured directive arrays. Auto-patchable. See *append-arrays* below. Covers:
- Monorepo helpers with `additionalScriptSrc` / `additionalConnectSrc` options (Next.js + shared config package)
- SvelteKit `kit.csp.directives`
- Nuxt `nuxt-security` module's `contentSecurityPolicy`
- **`append-string`** CSP written as a literal value string. Auto-patchable. See *append-string* below. Covers:
- **`append-string`**: CSP written as a literal value string. Auto-patchable. See *append-string* below. Covers:
- Inline `next.config.*` `headers()` with a CSP literal
- Nuxt `routeRules` / `nitro.routeRules` headers
- **`middleware`** or **`meta-tag`** rarer. Detected but not auto-patched in v1. Show the user the detected files and ask them to add `http://localhost:8400` to `script-src` and `connect-src` manually, then mark `cspChecked: true` and proceed.
- **`middleware`** or **`meta-tag`**: rarer. Detected but not auto-patched in v1. Show the user the detected files and ask them to add `http://localhost:8400` to `script-src` and `connect-src` manually, then mark `cspChecked: true` and proceed.
#### Consent prompt template
Use this phrasing so the experience is consistent across agents:
> **CSP patch needed.** I detected a Content Security Policy in your project that blocks `http://localhost:8400` the live picker won't load without an allowance. Here's the change I'd make:
> **CSP patch needed.** I detected a Content Security Policy in your project that blocks `http://localhost:8400`: the live picker won't load without an allowance. Here's the change I'd make:
>
> ```diff
> [file: <patchTarget>]
@@ -472,9 +644,9 @@ const __impeccableLiveDev =
**Append `...__impeccableLiveDev` to the script-src and connect-src directive arrays.** Per-framework specifics:
- **Next.js + monorepo helper** edit the *app's* `next.config.*` (not the shared helper), appending to `additionalScriptSrc` and `additionalConnectSrc` passed into `createBaseNextConfig` (or equivalent). Keeps the shared package clean.
- **SvelteKit** edit `svelte.config.js`, appending to `kit.csp.directives['script-src']` and `kit.csp.directives['connect-src']`.
- **Nuxt + nuxt-security** edit `nuxt.config.*`, appending to `security.headers.contentSecurityPolicy['script-src']` and `['connect-src']`.
- **Next.js + monorepo helper**: edit the *app's* `next.config.*` (not the shared helper), appending to `additionalScriptSrc` and `additionalConnectSrc` passed into `createBaseNextConfig` (or equivalent). Keeps the shared package clean.
- **SvelteKit**: edit `svelte.config.js`, appending to `kit.csp.directives['script-src']` and `kit.csp.directives['connect-src']`.
- **Nuxt + nuxt-security**: edit `nuxt.config.*`, appending to `security.headers.contentSecurityPolicy['script-src']` and `['connect-src']`.
Reference outputs:
- `tests/framework-fixtures/nextjs-turborepo/expected-after-patch.ts` (Next.js)
@@ -499,8 +671,8 @@ Then in the CSP value string:
(Leading space on the dev string so it concatenates cleanly into the existing value. Convert the literal CSP directives into template strings as part of the edit if they aren't already.)
Per-framework specifics:
- **Next.js inline `headers()`** edit `next.config.*`, splicing the variable into the CSP value.
- **Nuxt `routeRules`** edit `nuxt.config.*`, splicing into the CSP in `routeRules['/**'].headers['Content-Security-Policy']`.
- **Next.js inline `headers()`**: edit `next.config.*`, splicing the variable into the CSP value.
- **Nuxt `routeRules`**: edit `nuxt.config.*`, splicing into the CSP in `routeRules['/**'].headers['Content-Security-Policy']`.
Reference outputs:
- `tests/framework-fixtures/nextjs-inline-csp/expected-after-patch.js` (Next.js)
@@ -508,6 +680,6 @@ Reference outputs:
### Troubleshooting
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `config.json` and re-run `live.mjs` setup will ask again.
If a user says "no" to the CSP patch at setup time and later complains that live doesn't work: their dev CSP blocks `http://localhost:8400`. Fix: delete `cspChecked` from `.impeccable/live/config.json` and re-run `live.mjs`: setup will ask again.
Then re-run `live.mjs`.
@@ -1,99 +0,0 @@
# Motion Design
## Duration: The 100/300/500 Rule
Timing matters more than easing. These durations feel right for most UI:
| Duration | Use Case | Examples |
|----------|----------|----------|
| **100-150ms** | Instant feedback | Button press, toggle, color change |
| **200-300ms** | State changes | Menu open, tooltip, hover states |
| **300-500ms** | Layout changes | Accordion, modal, drawer |
| **500-800ms** | Entrance animations | Page load, hero reveals |
**Exit animations are faster than entrances**—use ~75% of enter duration.
## Easing: Pick the Right Curve
**Don't use `ease`.** It's a compromise that's rarely optimal. Instead:
| Curve | Use For | CSS |
|-------|---------|-----|
| **ease-out** | Elements entering | `cubic-bezier(0.16, 1, 0.3, 1)` |
| **ease-in** | Elements leaving | `cubic-bezier(0.7, 0, 0.84, 0)` |
| **ease-in-out** | State toggles (there → back) | `cubic-bezier(0.65, 0, 0.35, 1)` |
**For micro-interactions, use exponential curves**—they feel natural because they mimic real physics (friction, deceleration):
```css
/* Quart out - smooth, refined (recommended default) */
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1);
/* Quint out - slightly more dramatic */
--ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1);
/* Expo out - snappy, confident */
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
```
**Avoid bounce and elastic curves.** They were trendy in 2015 but now feel tacky and amateurish. Real objects don't bounce when they stop—they decelerate smoothly. Overshoot effects draw attention to the animation itself rather than the content.
## The Only Two Properties You Should Animate
**transform** and **opacity** only—everything else causes layout recalculation. For height animations (accordions), use `grid-template-rows: 0fr → 1fr` instead of animating `height` directly.
## Staggered Animations
Use CSS custom properties for cleaner stagger: `animation-delay: calc(var(--i, 0) * 50ms)` with `style="--i: 0"` on each item. **Cap total stagger time**—10 items at 50ms = 500ms total. For many items, reduce per-item delay or cap staggered count.
## Reduced Motion
This is not optional. Vestibular disorders affect ~35% of adults over 40.
```css
/* Define animations normally */
.card {
animation: slide-up 500ms ease-out;
}
/* Provide alternative for reduced motion */
@media (prefers-reduced-motion: reduce) {
.card {
animation: fade-in 200ms ease-out; /* Crossfade instead of motion */
}
}
/* Or disable entirely */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
```
**What to preserve**: Functional animations like progress bars, loading spinners (slowed down), and focus indicators should still work—just without spatial movement.
## Perceived Performance
**Nobody cares how fast your site is—just how fast it feels.** Perception can be as effective as actual performance.
**The 80ms threshold**: Our brains buffer sensory input for ~80ms to synchronize perception. Anything under 80ms feels instant and simultaneous. This is your target for micro-interactions.
**Active vs passive time**: Passive waiting (staring at a spinner) feels longer than active engagement. Strategies to shift the balance:
- **Preemptive start**: Begin transitions immediately while loading (iOS app zoom, skeleton UI). Users perceive work happening.
- **Early completion**: Show content progressively—don't wait for everything. Video buffering, progressive images, streaming HTML.
- **Optimistic UI**: Update the interface immediately, handle failures gracefully. Instagram likes work offline—the UI updates instantly, syncs later. Use for low-stakes actions; avoid for payments or destructive operations.
**Easing affects perceived duration**: Ease-in (accelerating toward completion) makes tasks feel shorter because the peak-end effect weights final moments heavily. Ease-out feels satisfying for entrances, but ease-in toward a task's end compresses perceived time.
**Caution**: Too-fast responses can decrease perceived value. Users may distrust instant results for complex operations (search, analysis). Sometimes a brief delay signals "real work" is happening.
## Performance
Don't use `will-change` preemptively—only when animation is imminent (`:hover`, `.animating`). For scroll-triggered animations, use Intersection Observer instead of scroll events; unobserve after animating once. Create motion tokens for consistency (durations, easings, common transitions).
---
**Avoid**: Animating everything (animation fatigue is real). Using >500ms for UI feedback. Ignoring `prefers-reduced-motion`. Using animation to hide slow loading.
@@ -1,6 +1,6 @@
> **Additional context needed**: the "aha moment" you want users to reach, and users' experience level.
Create or improve onboarding experiences that help users understand, adopt, and succeed with the product quickly.
Get users to first value as fast as possible. Onboarding's job is not to teach the product. Its job is to get people to the moment that proves the product is worth their time.
## Assess Onboarding Needs
@@ -231,4 +231,4 @@ Test with real users:
- **Completion rate**: Are users completing? (If low, simplify)
- **Time to value**: How long until users get first value?
Remember: You're a product educator with excellent teaching instincts. Get users to their "aha moment" as quickly as possible. Teach the essential, make it contextual, respect user time and intelligence.
When users hit the aha moment fast and don't drop off, hand off to `$impeccable polish` for the final pass.
@@ -1,4 +1,4 @@
Identify and fix performance issues to create faster, smoother user experiences.
Performance is a feature. Identify the actual bottleneck for THIS interface, fix it, then measure. Don't optimize what isn't slow.
## Assess Performance Issues
@@ -109,10 +109,10 @@ elements.forEach((el, i) => {
- Virtual scrolling for very long lists (react-window, react-virtualized)
**Reduce Paint & Composite**:
- Use `transform` and `opacity` for animations (GPU-accelerated)
- Avoid animating layout properties (width, height, top, left)
- Use `transform` and `opacity` for reliable movement, but allow blur, filters, masks, clip paths, shadows, and color shifts when they create meaningful polish
- Avoid casual animation of layout-driving properties (`width`, `height`, `top`, `left`, margins)
- Use `will-change` sparingly for known expensive operations
- Minimize paint areas (smaller is faster)
- Bound expensive paint areas for blur/filter/shadow effects (smaller and isolated is faster)
### Animation Performance
@@ -255,4 +255,4 @@ Test that optimizations worked:
- **No regressions**: Ensure functionality still works
- **User perception**: Does it *feel* faster?
Remember: Performance is a feature. Fast experiences feel more responsive, more polished, more professional. Optimize systematically, measure ruthlessly, and prioritize user-perceived performance.
When the user-facing numbers move, hand off to `$impeccable polish` for the final pass.
@@ -14,7 +14,7 @@ Push an interface past conventional limits. This isn't just about visual effects
This command has the highest potential to misfire. Do NOT jump straight into implementation. You MUST:
1. **Think through 2-3 different directions**: consider different techniques, levels of ambition, and aesthetic approaches. For each direction, briefly describe what the result would look and feel like.
2. **ask the user directly to clarify what you cannot infer.** to present these directions and get the user's pick before writing any code. Explain trade-offs (browser support, performance cost, complexity).
2. **STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer.** to present these directions and get the user's pick before writing any code. Explain trade-offs (browser support, performance cost, complexity).
3. Only proceed with the direction the user confirms.
Skipping this step risks building something embarrassing that needs to be thrown away.
@@ -30,16 +30,16 @@ Technically ambitious effects almost never work on the first try. You MUST activ
The right kind of technical ambition depends entirely on what you're working with. Before choosing a technique, ask: **what would make a user of THIS specific interface say "wow, that's nice"?**
### For visual/marketing surfaces
Pages, hero sections, landing pages, portfolios the "wow" is often sensory: a scroll-driven reveal, a shader background, a cinematic page transition, generative art that responds to the cursor.
Pages, hero sections, landing pages, portfolios: the "wow" is often sensory: a scroll-driven reveal, a shader background, a cinematic page transition, generative art that responds to the cursor.
### For functional UI
Tables, forms, dialogs, navigation the "wow" is in how it FEELS: a dialog that morphs from the button that triggered it via View Transitions, a data table that renders 100k rows at 60fps via virtual scrolling, a form with streaming validation that feels instant, drag-and-drop with spring physics.
Tables, forms, dialogs, navigation: the "wow" is in how it FEELS: a dialog that morphs from the button that triggered it via View Transitions, a data table that renders 100k rows at 60fps via virtual scrolling, a form with streaming validation that feels instant, drag-and-drop with spring physics.
### For performance-critical UI
The "wow" is invisible but felt: a search that filters 50k items without a flicker, a complex form that never blocks the main thread, an image editor that processes in near-real-time. The interface just never hesitates.
### For data-heavy interfaces
Charts and dashboards the "wow" is in fluidity: GPU-accelerated rendering via Canvas/WebGL for massive datasets, animated transitions between data states, force-directed graph layouts that settle naturally.
Charts and dashboards: the "wow" is in fluidity: GPU-accelerated rendering via Canvas/WebGL for massive datasets, animated transitions between data states, force-directed graph layouts that settle naturally.
**The common thread**: something about the implementation goes beyond what users expect from a web interface. The technique serves the experience, not the other way around.
@@ -48,36 +48,36 @@ Charts and dashboards — the "wow" is in fluidity: GPU-accelerated rendering vi
Organized by what you're trying to achieve, not by technology name.
### Make transitions feel cinematic
- **View Transitions API** (same-document: all browsers; cross-document: no Firefox) shared element morphing between states. A list item expanding into a detail page. A button morphing into a dialog. This is the closest thing to native FLIP animations.
- **`@starting-style`** (all browsers) animate elements from `display: none` to visible with CSS only, including entry keyframes
- **Spring physics** natural motion with mass, tension, and damping instead of cubic-bezier. Libraries: motion (formerly Framer Motion), GSAP, or roll your own spring solver.
- **View Transitions API** (same-document: all browsers; cross-document: no Firefox): shared element morphing between states. A list item expanding into a detail page. A button morphing into a dialog. This is the closest thing to native FLIP animations.
- **`@starting-style`** (all browsers): animate elements from `display: none` to visible with CSS only, including entry keyframes
- **Spring physics**: natural motion with mass, tension, and damping instead of cubic-bezier. Libraries: motion (formerly Framer Motion), GSAP, or roll your own spring solver.
### Tie animation to scroll position
- **Scroll-driven animations** (`animation-timeline: scroll()`) CSS-only, no JS. Parallax, progress bars, reveal sequences all driven by scroll position. (Chrome/Edge/Safari; Firefox: flag only always provide a static fallback)
- **Scroll-driven animations** (`animation-timeline: scroll()`): CSS-only, no JS. Parallax, progress bars, reveal sequences all driven by scroll position. (Chrome/Edge/Safari; Firefox: flag only; always provide a static fallback)
### Render beyond CSS
- **WebGL** (all browsers) shader effects, post-processing, particle systems. Libraries: Three.js, OGL (lightweight), regl. Use for effects CSS can't express.
- **WebGPU** (Chrome/Edge; Safari partial; Firefox: flag only) next-gen GPU compute. More powerful than WebGL but limited browser support. Always fall back to WebGL2.
- **Canvas 2D / OffscreenCanvas** custom rendering, pixel manipulation, or moving heavy rendering off the main thread entirely via Web Workers + OffscreenCanvas.
- **SVG filter chains** displacement maps, turbulence, morphology for organic distortion effects. CSS-animatable.
- **WebGL** (all browsers): shader effects, post-processing, particle systems. Libraries: Three.js, OGL (lightweight), regl. Use for effects CSS can't express.
- **WebGPU** (Chrome/Edge; Safari partial; Firefox: flag only): next-gen GPU compute. More powerful than WebGL but limited browser support. Always fall back to WebGL2.
- **Canvas 2D / OffscreenCanvas**: custom rendering, pixel manipulation, or moving heavy rendering off the main thread entirely via Web Workers + OffscreenCanvas.
- **SVG filter chains**: displacement maps, turbulence, morphology for organic distortion effects. CSS-animatable.
### Make data feel alive
- **Virtual scrolling** render only visible rows for tables/lists with tens of thousands of items. No library required for simple cases; TanStack Virtual for complex ones.
- **GPU-accelerated charts** Canvas or WebGL-rendered data visualization for datasets too large for SVG/DOM. Libraries: deck.gl, regl-based custom renderers.
- **Animated data transitions** morph between chart states rather than replacing. D3's `transition()` or View Transitions for DOM-based charts.
- **Virtual scrolling**: render only visible rows for tables/lists with tens of thousands of items. No library required for simple cases; TanStack Virtual for complex ones.
- **GPU-accelerated charts**: Canvas or WebGL-rendered data visualization for datasets too large for SVG/DOM. Libraries: deck.gl, regl-based custom renderers.
- **Animated data transitions**: morph between chart states rather than replacing. D3's `transition()` or View Transitions for DOM-based charts.
### Animate complex properties
- **`@property`** (all browsers) register custom CSS properties with types, enabling animation of gradients, colors, and complex values that CSS can't normally interpolate.
- **Web Animations API** (all browsers) JavaScript-driven animations with the performance of CSS. Composable, cancellable, reversible. The foundation for complex choreography.
- **`@property`** (all browsers): register custom CSS properties with types, enabling animation of gradients, colors, and complex values that CSS can't normally interpolate.
- **Web Animations API** (all browsers): JavaScript-driven animations with the performance of CSS. Composable, cancellable, reversible. The foundation for complex choreography.
### Push performance boundaries
- **Web Workers** move computation off the main thread. Heavy data processing, image manipulation, search indexing anything that would cause jank.
- **OffscreenCanvas** render in a Worker thread. The main thread stays free while complex visuals render in the background.
- **WASM** near-native performance for computation-heavy features. Image processing, physics simulations, codecs.
- **Web Workers**: move computation off the main thread. Heavy data processing, image manipulation, search indexing: anything that would cause jank.
- **OffscreenCanvas**: render in a Worker thread. The main thread stays free while complex visuals render in the background.
- **WASM**: near-native performance for computation-heavy features. Image processing, physics simulations, codecs.
### Interact with the device
- **Web Audio API** spatial audio, audio-reactive visualizations, sonic feedback. Requires user gesture to start.
- **Device APIs** orientation, ambient light, geolocation. Use sparingly and always with user permission.
- **Web Audio API**: spatial audio, audio-reactive visualizations, sonic feedback. Requires user gesture to start.
- **Device APIs**: orientation, ambient light, geolocation. Use sparingly and always with user permission.
**NOTE**: This command is about enhancing how an interface FEELS, not changing what a product DOES. Adding real-time collaboration, offline support, or new backend capabilities are product decisions, not UI enhancements. Focus on making existing features feel extraordinary.
@@ -102,22 +102,22 @@ else if (canvas.getContext('webgl2')) { /* WebGL2 fallback */ }
### Performance rules
- Target 60fps. If dropping below 50, simplify.
- Respect `prefers-reduced-motion` always. Provide a beautiful static alternative.
- Respect `prefers-reduced-motion`, always. Provide a beautiful static alternative.
- Lazy-initialize heavy resources (WebGL contexts, WASM modules) only when near viewport.
- Pause off-screen rendering. Kill what you can't see.
- Test on real mid-range devices, not just your development machine.
### Polish is the difference
The gap between "cool" and "extraordinary" is in the last 20% of refinement: the easing curve on a spring animation, the timing offset in a staggered reveal, the subtle secondary motion that makes a transition feel physical. Don't ship the first version that works ship the version that feels inevitable.
The gap between "cool" and "extraordinary" is in the last 20% of refinement: the easing curve on a spring animation, the timing offset in a staggered reveal, the subtle secondary motion that makes a transition feel physical. Don't ship the first version that works; ship the version that feels inevitable.
**NEVER**:
- Ignore `prefers-reduced-motion` — this is an accessibility requirement, not a suggestion
- Ignore `prefers-reduced-motion`. This is an accessibility requirement, not a suggestion
- Ship effects that cause jank on mid-range devices
- Use bleeding-edge APIs without a functional fallback
- Add sound without explicit user opt-in
- Use technical ambition to mask weak design fundamentals; fix those first with other commands
- Layer multiple competing extraordinary moments — focus creates impact, excess creates noise
- Layer multiple competing extraordinary moments. Focus creates impact, excess creates noise
## Verify the Result
@@ -127,4 +127,4 @@ The gap between "cool" and "extraordinary" is in the last 20% of refinement: the
- **The accessibility test**: Enable reduced motion. Still beautiful?
- **The context test**: Does this make sense for THIS brand and audience?
Remember: "Technically extraordinary" isn't about using the newest API. It's about making an interface do something users didn't think a website could do.
"Technically extraordinary" isn't about using the newest API. It's about making an interface do something users didn't think a website could do.
@@ -1,178 +0,0 @@
# Persona-Based Design Testing
Test the interface through the eyes of 5 distinct user archetypes. Each persona exposes different failure modes that a single "design director" perspective would miss.
**How to use**: Select 23 personas most relevant to the interface being critiqued. Walk through the primary user action as each persona. Report specific red flags — not generic concerns.
---
## 1. Impatient Power User — "Alex"
**Profile**: Expert with similar products. Expects efficiency, hates hand-holding. Will find shortcuts or leave.
**Behaviors**:
- Skips all onboarding and instructions
- Looks for keyboard shortcuts immediately
- Tries to bulk-select, batch-edit, and automate
- Gets frustrated by required steps that feel unnecessary
- Abandons if anything feels slow or patronizing
**Test Questions**:
- Can Alex complete the core task in under 60 seconds?
- Are there keyboard shortcuts for common actions?
- Can onboarding be skipped entirely?
- Do modals have keyboard dismiss (Esc)?
- Is there a "power user" path (shortcuts, bulk actions)?
**Red Flags** (report these specifically):
- Forced tutorials or unskippable onboarding
- No keyboard navigation for primary actions
- Slow animations that can't be skipped
- One-item-at-a-time workflows where batch would be natural
- Redundant confirmation steps for low-risk actions
---
## 2. Confused First-Timer — "Jordan"
**Profile**: Never used this type of product. Needs guidance at every step. Will abandon rather than figure it out.
**Behaviors**:
- Reads all instructions carefully
- Hesitates before clicking anything unfamiliar
- Looks for help or support constantly
- Misunderstands jargon and abbreviations
- Takes the most literal interpretation of any label
**Test Questions**:
- Is the first action obviously clear within 5 seconds?
- Are all icons labeled with text?
- Is there contextual help at decision points?
- Does terminology assume prior knowledge?
- Is there a clear "back" or "undo" at every step?
**Red Flags** (report these specifically):
- Icon-only navigation with no labels
- Technical jargon without explanation
- No visible help option or guidance
- Ambiguous next steps after completing an action
- No confirmation that an action succeeded
---
## 3. Accessibility-Dependent User — "Sam"
**Profile**: Uses screen reader (VoiceOver/NVDA), keyboard-only navigation. May have low vision, motor impairment, or cognitive differences.
**Behaviors**:
- Tabs through the interface linearly
- Relies on ARIA labels and heading structure
- Cannot see hover states or visual-only indicators
- Needs adequate color contrast (4.5:1 minimum)
- May use browser zoom up to 200%
**Test Questions**:
- Can the entire primary flow be completed keyboard-only?
- Are all interactive elements focusable with visible focus indicators?
- Do images have meaningful alt text?
- Is color contrast WCAG AA compliant (4.5:1 for text)?
- Does the screen reader announce state changes (loading, success, errors)?
**Red Flags** (report these specifically):
- Click-only interactions with no keyboard alternative
- Missing or invisible focus indicators
- Meaning conveyed by color alone (red = error, green = success)
- Unlabeled form fields or buttons
- Time-limited actions without extension option
- Custom components that break screen reader flow
---
## 4. Deliberate Stress Tester — "Riley"
**Profile**: Methodical user who pushes interfaces beyond the happy path. Tests edge cases, tries unexpected inputs, and probes for gaps in the experience.
**Behaviors**:
- Tests edge cases intentionally (empty states, long strings, special characters)
- Submits forms with unexpected data (emoji, RTL text, very long values)
- Tries to break workflows by navigating backwards, refreshing mid-flow, or opening in multiple tabs
- Looks for inconsistencies between what the UI promises and what actually happens
- Documents problems methodically
**Test Questions**:
- What happens at the edges (0 items, 1000 items, very long text)?
- Do error states recover gracefully or leave the UI in a broken state?
- What happens on refresh mid-workflow? Is state preserved?
- Are there features that appear to work but produce broken results?
- How does the UI handle unexpected input (emoji, special chars, paste from Excel)?
**Red Flags** (report these specifically):
- Features that appear to work but silently fail or produce wrong results
- Error handling that exposes technical details or leaves UI in a broken state
- Empty states that show nothing useful ("No results" with no guidance)
- Workflows that lose user data on refresh or navigation
- Inconsistent behavior between similar interactions in different parts of the UI
---
## 5. Distracted Mobile User — "Casey"
**Profile**: Using phone one-handed on the go. Frequently interrupted. Possibly on a slow connection.
**Behaviors**:
- Uses thumb only — prefers bottom-of-screen actions
- Gets interrupted mid-flow and returns later
- Switches between apps frequently
- Has limited attention span and low patience
- Types as little as possible, prefers taps and selections
**Test Questions**:
- Are primary actions in the thumb zone (bottom half of screen)?
- Is state preserved if the user leaves and returns?
- Does it work on slow connections (3G)?
- Can forms leverage autocomplete and smart defaults?
- Are touch targets at least 44×44pt?
**Red Flags** (report these specifically):
- Important actions positioned at the top of the screen (unreachable by thumb)
- No state persistence — progress lost on tab switch or interruption
- Large text inputs required where selection would work
- Heavy assets loading on every page (no lazy loading)
- Tiny tap targets or targets too close together
---
## Selecting Personas
Choose personas based on the interface type:
| Interface Type | Primary Personas | Why |
|---------------|-----------------|-----|
| Landing page / marketing | Jordan, Riley, Casey | First impressions, trust, mobile |
| Dashboard / admin | Alex, Sam | Power users, accessibility |
| E-commerce / checkout | Casey, Riley, Jordan | Mobile, edge cases, clarity |
| Onboarding flow | Jordan, Casey | Confusion, interruption |
| Data-heavy / analytics | Alex, Sam | Efficiency, keyboard nav |
| Form-heavy / wizard | Jordan, Sam, Casey | Clarity, accessibility, mobile |
---
## Project-Specific Personas
If `AGENTS.md` contains a `## Design Context` section (generated by `impeccable teach`), derive 12 additional personas from the audience and brand information:
1. Read the target audience description
2. Identify the primary user archetype not covered by the 5 predefined personas
3. Create a persona following this template:
```
### [Role] — "[Name]"
**Profile**: [2-3 key characteristics derived from Design Context]
**Behaviors**: [3-4 specific behaviors based on the described audience]
**Red Flags**: [3-4 things that would alienate this specific user type]
```
Only generate project-specific personas when real Design Context data is available. Don't invent audience details — use the 5 predefined personas when no context exists.
+48 -19
View File
@@ -2,19 +2,21 @@
Perform a meticulous final pass to catch all the small details that separate good work from great work. The difference between shipped and polished.
Detector and automated QA output are defect evidence only. A clean script result is never proof that the design is strong; gather browser evidence and inspect the real interaction path.
## Design System Discovery
Before polishing, understand the system you are polishing toward:
Aligning the feature to the design system is **not optional**. Polish without alignment is decoration on top of drift, and it makes the next person's job harder. Discovery comes before any other polish work.
1. **Find the design system**: Search for design system documentation, component libraries, style guides, or token definitions. Study the core patterns: color tokens, spacing scale, typography styles, component API.
2. **Note the conventions**: How are shared components imported? What spacing scale is used? Which colors come from tokens vs hard-coded values? What motion and interaction patterns are established?
3. **Identify drift**: Where does the target feature deviate from the system? Hard-coded values that should be tokens, custom components that duplicate shared ones, spacing that doesn't match the scale.
1. **Find the design system**: Search for design system documentation, component libraries, style guides, or token definitions. Study the core patterns: design principles, target audience, color tokens, spacing scale, typography styles, component API, motion conventions.
2. **Note the conventions**: How are shared components imported? What spacing scale is used? Which colors come from tokens vs hard-coded values? What motion and interaction patterns are established? What flow shapes are used for comparable actions (modal vs full-page, inline vs route, save-on-blur vs explicit submit)?
3. **Identify drift, then name the root cause**: For every deviation, classify it as a **missing token** (the value should exist in the system but doesn't), a **one-off implementation** (a shared component already exists but wasn't used), or a **conceptual misalignment** (the feature's flow, IA, or hierarchy doesn't match neighboring features). The fix differs by category: patch the value, swap to the shared component, or rework the flow. Fixing the symptom without naming the cause is how drift compounds.
If a design system exists, polish should align the feature with it. If none exists, polish against the conventions visible in the codebase.
If a design system exists, polish **must** align the feature with it. If none exists, polish against the conventions visible in the codebase. **If anything about the system is ambiguous, ask. Never guess at design system principles.**
## Pre-Polish Assessment
Understand the current state and goals:
Understand the current state and goals before touching anything:
1. **Review completeness**:
- Is it functionally complete?
@@ -22,13 +24,25 @@ Understand the current state and goals:
- What's the quality bar? (MVP vs flagship feature?)
- When does it ship? (How much time for polish?)
2. **Identify polish areas**:
2. **Think experience-first**: Who actually uses this, and what's the best possible experience for them? Effective design beats decorative polish; a feature that looks beautiful but fights the user's flow is not polished. Walk the path from their perspective before opening DevTools.
3. **Identify polish areas**:
- Visual inconsistencies
- Spacing and alignment issues
- Interaction state gaps
- Copy inconsistencies
- Edge cases and error states
- Loading and transition smoothness
- Information architecture and flow drift (does this feature reveal complexity the way neighboring features do?)
4. **Pull in any prior critique** (optional signal): If `$impeccable critique` has been run on the same target, its priority issues are a useful prior for what to address first. Resolve the target to a file path or URL, then:
```bash
slug=$(node .agents/skills/impeccable/scripts/critique-storage.mjs slug "<resolved>")
node .agents/skills/impeccable/scripts/critique-storage.mjs latest "$slug"
```
Exit 0 with body = found; fold the P0/P1 items into your polish list and mention the snapshot path so the user sees what you read. Exit 2 = no snapshot, continue without it. The critique is one input among many. Do your own pass either way.
5. **Triage cosmetic vs functional**: Classify each issue as **cosmetic** (looks off, doesn't impede the user) or **functional** (breaks, blocks, or confuses the experience). When polish time is tight, functional issues ship first; cosmetic ones can land in a follow-up. Quality should be consistent; never perfect one corner while leaving another rough.
**CRITICAL**: Polish is the last step, not the first. Don't polish work that's not functionally complete.
@@ -50,6 +64,16 @@ Work through these dimensions methodically:
- Test at multiple viewport sizes
- Look for elements that "feel" off
### Information Architecture & Flow
Visual polish on a misshapen flow is wasted work. Match the *shape* of the experience to the system, not just the surface.
- **Progressive disclosure**: Match how much is revealed when, compared to neighboring features. A settings page exposing 40 fields when the rest of the app reveals 5 at a time is drift, even if every field is perfectly styled.
- **Established user flows**: Multi-step actions follow the same shape as comparable flows elsewhere: modal vs full-page, inline edit vs separate route, save-on-blur vs explicit submit, optimistic vs pessimistic updates.
- **Hierarchy & complexity**: The same conceptual weight gets the same visual weight throughout. Primary actions don't become tertiary in one corner of the product, and tertiary actions don't shout.
- **Empty, loading, and arrival transitions**: How content arrives, updates, and leaves matches how it does in adjacent features.
- **Naming and mental model**: The feature uses the same nouns and verbs as the rest of the system. A "Workspace" here shouldn't be a "Project" three screens away.
### Typography Refinement
- **Hierarchy consistency**: Same elements use same sizes/weights throughout
@@ -67,8 +91,7 @@ Work through these dimensions methodically:
- **Theme consistency**: Works in all theme variants
- **Color meaning**: Same colors mean same things throughout
- **Accessible focus**: Focus indicators visible with sufficient contrast
- **Tinted neutrals**: No pure gray or pure black—add subtle color tint (0.01 chroma)
- **Gray on color**: Never put gray text on colored backgrounds—use a shade of that color or transparency
- **Gray on color**: Never put gray text on colored backgrounds; use a shade of that color or transparency
### Interaction States
@@ -88,8 +111,8 @@ Every interactive element needs all states:
### Micro-interactions & Transitions
- **Smooth transitions**: All state changes animated appropriately (150-300ms)
- **Consistent easing**: Use ease-out-quart/quint/expo for natural deceleration. Never bounce or elasticthey feel dated.
- **No jank**: 60fps animations, only animate transform and opacity
- **Consistent easing**: Use ease-out-quart/quint/expo for natural deceleration. Never bounce or elastic; they feel dated.
- **No jank**: Smooth animations; use atmospheric blur/filter/mask/shadow effects when they add polish, but bound expensive paint areas and avoid casual layout-property animation
- **Appropriate motion**: Motion serves purpose, not decoration
- **Reduced motion**: Respects `prefers-reduced-motion`
@@ -158,6 +181,8 @@ Every interactive element needs all states:
Go through systematically:
- [ ] Aligned to the design system (drift named and resolved by root cause)
- [ ] Information architecture and flow shape match neighboring features
- [ ] Visual alignment perfect at all breakpoints
- [ ] Spacing uses design tokens consistently
- [ ] Typography hierarchy consistent
@@ -181,24 +206,30 @@ Go through systematically:
**IMPORTANT**: Polish is about details. Zoom in. Squint at it. Use it yourself. The little things add up.
Sweat the details. Zoom in until the alignment is right and the spacing reads as deliberate. Then ship.
**NEVER**:
- Polish before it's functionally complete
- Polish without aligning to the design system; that's decoration on drift
- Guess at design system principles instead of asking when something is ambiguous
- Spend hours on polish if it ships in 30 minutes (triage)
- Introduce bugs while polishing (test thoroughly)
- Ignore systematic issues (if spacing is off everywhere, fix the system)
- Ignore systematic issues (if spacing is off everywhere, fix the system, not just one screen)
- Perfect one thing while leaving others rough (consistent quality level)
- Create new one-off components when design system equivalents exist
- Hard-code values that should use design tokens
- Introduce new patterns or flows that diverge from established ones
## Final Verification
Before marking as done:
- **Use it yourself**: Actually interact with the feature
- **Test on real devices**: Not just browser DevTools
- **Ask someone else to review**: Fresh eyes catch things
- **Compare to design**: Match intended design
- **Check all states**: Don't just test happy path
- **Use it yourself**: Actually interact with the feature.
- **Test on real devices**: Not just browser DevTools.
- **Ask someone else to review**: Fresh eyes catch things.
- **Compare to design**: Match intended design.
- **Check all states**: Don't just test happy path.
- **Treat automation carefully**: Run detector or QA commands when they are available and relevant, fix their defects, but never cite a clean result as proof that the work is polished.
## Clean Up
@@ -208,5 +239,3 @@ After polishing, ensure code quality:
- **Remove orphaned code**: Delete unused styles, components, or files made obsolete by polish.
- **Consolidate tokens**: If you introduced new values, check whether they should be tokens.
- **Verify DRYness**: Look for duplication introduced during polishing and consolidate.
Remember: You have impeccable attention to detail and exquisite taste. Polish until it feels effortless, looks intentional, and works flawlessly. Sweat the details - they matter.
@@ -4,30 +4,27 @@ When design SERVES the product: app UIs, admin dashboards, settings panels, data
## The product slop test
Not "would someone say AI made this" — familiarity is often a feature here. The test is: would a user fluent in the category's best tools (Linear, Figma, Notion, Raycast, Stripe come to mind) sit down and trust this interface, or pause at every subtly-off component?
Not "would someone say AI made this." Familiarity is often a feature here. The test is: would a user fluent in the category's best tools (Linear, Figma, Notion, Raycast, Stripe come to mind) sit down and trust this interface, or pause at every subtly-off component?
Product UI's failure mode isn't flatness, it's strangeness without purpose: over-decorated buttons, mismatched form controls, gratuitous motion, display fonts where labels should be, invented affordances for standard tasks. The bar is earned familiarity. The tool should disappear into the task.
## Typography
- **System fonts are legitimate.** `-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif` gives you native feel on every platform. Inter is the common cross-platform default for a reason.
- **One family is often right.** Product UIs don't need display/body pairing. A well-tuned sans carries headings, buttons, labels, body, data.
- **Fixed rem scale, not fluid.** Clamp-sized headings don't serve product UI. Users view at consistent DPI, and a fluid h1 that shrinks in a sidebar looks worse, not better.
- **Tighter scale ratio.** 1.1251.2 between steps is typical. More type elements here than on brand surfaces; exaggerated contrast creates noise.
- **Line length still applies for prose** (6575ch). Data and compact UI can run denser tables at 120ch+ are fine.
- **Line length still applies for prose** (6575ch). Data and compact UI can run denser; tables at 120ch+ are fine.
## Color
Product defaults to Restrained. A single surface can earn Committed a dashboard where one category color carries a report, an onboarding flow with a drenched welcome screen but Restrained is the floor.
Product defaults to Restrained. A single surface can earn Committed (a dashboard where one category color carries a report, an onboarding flow with a drenched welcome screen), but Restrained is the floor.
- State-rich semantic vocabulary: hover, focus, active, disabled, selected, loading, error, warning, success, info. Standardize these.
- Accent color used for primary actions, current selection, and state indicators only not decoration.
- Accent color used for primary actions, current selection, and state indicators only, not decoration.
- A second neutral layer for sidebars, toolbars, and panels (slightly cooler or warmer than the content surface).
## Layout
- Predictable grids. Consistency IS an affordance — users navigate faster when the structure is expected.
- Familiar patterns are features. Standard navigation (top bar, side nav), breadcrumbs, tabs, and form layouts have established user expectations. Don't reinvent for flavor.
- Responsive behavior is structural (collapse sidebar, responsive table, breakpoint-driven columns), not fluid typography.
## Components
@@ -40,8 +37,8 @@ Every interactive component has: default, hover, focus, active, disabled, loadin
## Motion
- 150250 ms on most transitions. Users are in flow don't make them wait for choreography.
- Motion conveys state, not decoration. State change, feedback, loading, reveal nothing else.
- 150250 ms on most transitions. Users are in flow; don't make them wait for choreography.
- Motion conveys state, not decoration. State change, feedback, loading, reveal: nothing else.
- No orchestrated page-load sequences. Product loads into a task; users don't want to watch it load.
## Product bans (on top of the shared absolute bans)
@@ -51,6 +48,7 @@ Every interactive component has: default, hover, focus, active, disabled, loadin
- Display fonts in UI labels, buttons, data.
- Reinventing standard affordances for flavor (custom scrollbars, weird form controls, non-standard modals).
- Heavy color or full-saturation accents on inactive states.
- Modal as first thought. Modals are usually laziness. Exhaust inline / progressive alternatives first.
## Product permissions
+12 -12
View File
@@ -1,12 +1,12 @@
Reduce visual intensity in designs that are too bold, aggressive, or overstimulating, creating a more refined and approachable aesthetic without losing effectiveness.
Quiet design is harder than bold design. Subtlety needs precision. Reduce visual intensity in designs that are too loud, aggressive, or overstimulating without losing personality or making the result generic.
---
## Register
Brand: "quieter" means more restrained palette, more whitespace, more typographic air. Drama is reduced, not eliminated the POV stays intact.
Brand: "quieter" means more restrained palette, more whitespace, more typographic air. Drama is reduced, not eliminated; the POV stays intact.
Product: "quieter" means reducing visual noise — fewer background accents, flatter cards, less color, less motion. The tool should disappear more completely into the task.
Product: "quieter" means reducing visual noise. Fewer background accents, flatter cards, less color, less motion. The tool should disappear more completely into the task.
---
@@ -28,20 +28,20 @@ Analyze what makes the design feel too intense:
- What's working? (Don't throw away good ideas)
- What's the core message? (Preserve what matters)
If any of these are unclear from the codebase, ask the user directly to clarify what you cannot infer.
If any of these are unclear from the codebase, STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer.
**CRITICAL**: "Quieter" doesn't mean boring or generic. It means refined, sophisticated, and easier on the eyes. Think luxury, not laziness.
**CRITICAL**: "Quieter" doesn't mean boring or generic. It means refined and easier on the eyes. Think luxury, not laziness.
## Plan Refinement
Create a strategy to reduce intensity while maintaining impact:
- **Color approach**: Desaturate or shift to more sophisticated tones?
- **Color approach**: Desaturate or shift to more restrained tones?
- **Hierarchy approach**: Which elements should stay bold (very few), which should recede?
- **Simplification approach**: What can be removed entirely?
- **Sophistication approach**: How can we signal quality through restraint?
**IMPORTANT**: Great quiet design is harder than great bold design. Subtlety requires precision.
**IMPORTANT**: Subtlety requires precision. Quiet without intent collapses to generic.
## Refine the Design
@@ -49,11 +49,11 @@ Systematically reduce intensity across these dimensions:
### Color Refinement
- **Reduce saturation**: Shift from fully saturated to 70-85% saturation
- **Soften palette**: Replace bright colors with muted, sophisticated tones
- **Soften palette**: Replace bright colors with muted tones
- **Reduce color variety**: Use fewer colors more thoughtfully
- **Neutral dominance**: Let neutrals do more work, use color as accent (10% rule)
- **Gentler contrasts**: High contrast only where it matters most
- **Tinted grays**: Use warm or cool tinted grays instead of pure gray—adds sophistication without loudness
- **Tinted grays**: Use warm or cool tinted grays instead of pure gray. Adds depth without loudness
- **Never gray on color**: If you have gray text on a colored background, use a darker shade of that color or transparency instead
### Visual Weight Reduction
@@ -72,7 +72,7 @@ Systematically reduce intensity across these dimensions:
- **Reduce animation intensity**: Shorter distances (10-20px instead of 40px), gentler easing
- **Remove decorative animations**: Keep functional motion, remove flourishes
- **Subtle micro-interactions**: Replace dramatic effects with gentle feedback
- **Refined easing**: Use ease-out-quart for smooth, understated motion—never bounce or elastic
- **Refined easing**: Use ease-out-quart for smooth, understated motion. Never bounce or elastic
- **Remove animations entirely** if they're not serving a clear purpose
### Composition Refinement
@@ -94,6 +94,6 @@ Ensure refinement maintains quality:
- **Still functional**: Can users still accomplish tasks easily?
- **Still distinctive**: Does it have character, or is it generic now?
- **Better reading**: Is text easier to read for extended periods?
- **Sophistication**: Does it feel more refined and premium?
- **Restrained, not absent**: Does the POV survive the cuts?
Remember: Quiet design is confident design. It doesn't need to shout. Less is more, but less is also harder. Refine with precision and maintain intentionality.
When the result feels right, hand off to `$impeccable polish` for the final pass.
@@ -1,114 +0,0 @@
# Responsive Design
## Mobile-First: Write It Right
Start with base styles for mobile, use `min-width` queries to layer complexity. Desktop-first (`max-width`) means mobile loads unnecessary styles first.
## Breakpoints: Content-Driven
Don't chase device sizes—let content tell you where to break. Start narrow, stretch until design breaks, add breakpoint there. Three breakpoints usually suffice (640, 768, 1024px). Use `clamp()` for fluid values without breakpoints.
## Detect Input Method, Not Just Screen Size
**Screen size doesn't tell you input method.** A laptop with touchscreen, a tablet with keyboard—use pointer and hover queries:
```css
/* Fine pointer (mouse, trackpad) */
@media (pointer: fine) {
.button { padding: 8px 16px; }
}
/* Coarse pointer (touch, stylus) */
@media (pointer: coarse) {
.button { padding: 12px 20px; } /* Larger touch target */
}
/* Device supports hover */
@media (hover: hover) {
.card:hover { transform: translateY(-2px); }
}
/* Device doesn't support hover (touch) */
@media (hover: none) {
.card { /* No hover state - use active instead */ }
}
```
**Critical**: Don't rely on hover for functionality. Touch users can't hover.
## Safe Areas: Handle the Notch
Modern phones have notches, rounded corners, and home indicators. Use `env()`:
```css
body {
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
/* With fallback */
.footer {
padding-bottom: max(1rem, env(safe-area-inset-bottom));
}
```
**Enable viewport-fit** in your meta tag:
```html
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
```
## Responsive Images: Get It Right
### srcset with Width Descriptors
```html
<img
src="hero-800.jpg"
srcset="
hero-400.jpg 400w,
hero-800.jpg 800w,
hero-1200.jpg 1200w
"
sizes="(max-width: 768px) 100vw, 50vw"
alt="Hero image"
>
```
**How it works**:
- `srcset` lists available images with their actual widths (`w` descriptors)
- `sizes` tells the browser how wide the image will display
- Browser picks the best file based on viewport width AND device pixel ratio
### Picture Element for Art Direction
When you need different crops/compositions (not just resolutions):
```html
<picture>
<source media="(min-width: 768px)" srcset="wide.jpg">
<source media="(max-width: 767px)" srcset="tall.jpg">
<img src="fallback.jpg" alt="...">
</picture>
```
## Layout Adaptation Patterns
**Navigation**: Three stages—hamburger + drawer on mobile, horizontal compact on tablet, full with labels on desktop. **Tables**: Transform to cards on mobile using `display: block` and `data-label` attributes. **Progressive disclosure**: Use `<details>/<summary>` for content that can collapse on mobile.
## Testing: Don't Trust DevTools Alone
DevTools device emulation is useful for layout but misses:
- Actual touch interactions
- Real CPU/memory constraints
- Network latency patterns
- Font rendering differences
- Browser chrome/keyboard appearances
**Test on at least**: One real iPhone, one real Android, a tablet if relevant. Cheap Android phones reveal performance issues you'll never see on simulators.
---
**Avoid**: Desktop-first design. Device detection instead of feature detection. Separate mobile/desktop codebases. Ignoring tablet and landscape. Assuming all mobile devices are powerful.
+42 -13
View File
@@ -12,7 +12,20 @@ Most AI-generated UIs fail not because of bad code, but because of skipped think
**Do NOT write any code or make any design decisions during this phase.** Your only job is to understand the feature deeply enough to make excellent design decisions later.
Ask these questions in conversation, adapting based on answers. Don't dump them all at once; have a natural dialogue. ask the user directly to clarify what you cannot infer.
This is a required interaction, not optional guidance. Ask these questions in conversation, adapting based on answers. Don't dump them all at once; have a natural dialogue. STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer.
### Interview cadence
Discovery includes at least one user-answer round unless PRODUCT.md, DESIGN.md, or an already-confirmed brief directly answers the needed inputs. With a sparse prompt, do **not** synthesize a complete brief for confirmation on the first response.
- Use the harness's structured question tool when one exists. Otherwise, ask directly in chat and stop.
- Ask **2-3 questions per round**, then wait for answers.
- Treat PRODUCT.md and DESIGN.md as anchors; they reduce repeated questions but do **not** replace shape for craft. Shape is task-specific.
- One round is the default. Add a second only if the first answers leave material gaps. Don't run a second round just to feel thorough.
- Round 1 should clarify purpose, audience/context, content/scope, and (for brand) visual direction.
- Round 2, when needed, fills in whatever's still genuinely missing.
**Assert-then-confirm, not menu-with-escape.** When PRODUCT.md and the user's prompt make one option obvious, name it and ask the user to confirm or override. Don't enumerate "Restrained / Committed / Or something else?" as a real choice; "This reads as Restrained, confirm?" beats a four-option menu when the answer is already clear.
### Purpose & Context
- What is this feature for? What problem does it solve?
@@ -25,14 +38,15 @@ Ask these questions in conversation, adapting based on answers. Don't dump them
- What are the realistic ranges? (Minimum, typical, maximum, e.g., 0 items, 5 items, 500 items)
- What are the edge cases? (Empty state, error state, first-time use, power user)
- Is any content dynamic? What changes and how often?
- What visual assets are real content here? Note required images, product shots, illustrations, maps, textures, diagrams, generated objects, or existing project assets.
### Design Direction
Force a visual decision on three fronts. Skip anything PRODUCT.md or DESIGN.md already answers; ask only what's missing.
- **Color strategy for this surface.** Pick one: Restrained / Committed / Full palette / Drenched. Can override the project default if the surface earns it (e.g. a drenched hero inside an otherwise Restrained product).
- **Theme via scene sentence.** Write one sentence of physical context for this surface who uses it, where, under what ambient light, in what mood. The sentence forces dark vs light. If it doesn't, add detail until it does.
- **Two or three named anchor references.** Specific products, brands, objects — not adjectives like "modern" or "clean."
- **Theme via scene sentence.** Write one sentence of physical context for this surface: who uses it, where, under what ambient light, in what mood. The sentence forces dark vs light. If it doesn't, add detail until it does.
- **Two or three named anchor references.** Specific products, brands, objects. Not adjectives like "modern" or "clean."
### Scope
@@ -43,7 +57,7 @@ Always ask. Sketch quality and shipped quality are different outputs; don't gues
- **Interactivity.** Static visual / interactive prototype / shipped-quality component?
- **Time intent.** Quick exploration, or polish until it ships?
Scope answers are task-scoped. Don't write them to PRODUCT.md or DESIGN.md carry them through the design brief only.
Scope answers are task-scoped. Don't write them to PRODUCT.md or DESIGN.md; carry them through the design brief only.
### Constraints
- Are there technical constraints? (Framework, performance budget, browser support)
@@ -61,9 +75,13 @@ After the discovery interview, generate a small set of visual direction probes *
- The work is **net-new** or directionally ambiguous enough that visual exploration will clarify the brief.
- The requested fidelity is **mid-fi, high-fi, or production-ready**. Skip for sketch-only planning.
- The current harness has **built-in image generation capability** (for example, Codex with a native image tool). Do **not** ask the user to set up external APIs, shell scripts, or one-off tooling just to do this.
- The current harness gives you native image generation (Codex's `image_gen`, an equivalent MCP tool, or similar). Don't ask the user to install APIs or tooling.
When those conditions are met, this step is the default. Use it to explore visual lanes, not to replace the brief.
When those conditions are met, this step is mandatory. If image generation isn't natively available, do not ask the user to install APIs or tooling. State in one line that the image step is skipped because the harness lacks native image generation, then proceed. The one-line announcement is required, not optional; it forces a conscious decision instead of letting the step quietly evaporate.
Use probes to explore visual lanes, not to replace the brief.
Do not skip probes because the final UI will be semantic, editable, code-native, responsive, or accessible. Those are implementation requirements, not reasons to avoid visual exploration.
### What to generate
@@ -89,11 +107,20 @@ The probes should differ in primary visual direction (hierarchy, topology, densi
- Do **not** treat generated imagery as final UX specification, final copy, or final accessibility behavior.
- Do **not** use this step for minor refinements of existing work. It's for shaping a new surface or clarifying a big directional choice.
If image generation is unavailable, or the task doesn't benefit from it, skip this phase and proceed directly to the design brief.
If image generation isn't natively available, announce the skip in one line and proceed to the design brief.
## Phase 2: Design Brief
After the interview, synthesize everything into a structured design brief. Present it to the user for confirmation before considering this command complete.
After the interview and any required probes, present a brief and **end your response**. The user must confirm before any implementation runs. Do not present a brief and then continue to code in the same response, even if the brief feels obvious to you. The user's confirmation is the gate.
**Choose the brief shape based on how clear the answers are:**
- **Compact form (3-5 bullets)** when discovery was crisp and the original prompt + PRODUCT.md already pinned scope, content, and direction. State what you're building, the visual lane, and end with one or two specific questions or a clear "confirm or override?" prompt. This is the default for typical craft requests with a clear prompt.
- **Full structured form (sections below)** when the task is genuinely ambiguous, multi-screen, or when the user asked for shape as a standalone step. Use this when the discipline of structure earns its weight.
Don't pad a clear brief into a long one to look thorough. A 70-line brief restating answers the user just gave is noise, not rigor. Equally, don't skip the confirmation pause to look efficient: the pause is the point.
Present the brief, then **stop and wait for explicit confirmation**. You are not the judge of whether the user already approved. Even when the brief feels obviously right, ask once and wait. The pause is what separates shape from premature implementation.
### Brief Structure
@@ -109,7 +136,7 @@ Color strategy (Restrained / Committed / Full palette / Drenched) + the theme sc
If you ran the Visual Direction Probe step, name which probe direction won and what changed in the brief because of it.
**4. Scope**
Fidelity, breadth, interactivity, and time intent from the Scope section of the interview. Task-scoped these don't persist beyond the brief.
Fidelity, breadth, interactivity, and time intent from the Scope section of the interview. Task-scoped; these don't persist beyond the brief.
**5. Layout Strategy**
High-level spatial approach: what gets emphasis, what's secondary, how information flows. Describe the visual hierarchy and rhythm, not specific CSS.
@@ -121,16 +148,18 @@ List every state the feature needs: default, empty, loading, error, success, edg
How users interact with this feature. What happens on click, hover, scroll? What feedback do they get? What's the flow from entry to completion?
**8. Content Requirements**
What copy, labels, empty state messages, error messages, and microcopy are needed. Note any dynamic content and its realistic ranges.
What copy, labels, empty state messages, error messages, and microcopy are needed. Note any dynamic content and its realistic ranges. For image-led surfaces, also list the required image/media roles and their likely source (project asset, generated raster, semantic SVG/CSS, canvas/WebGL, icon library, or accepted omission).
**9. Recommended References**
Based on the brief, list which impeccable reference files would be most valuable during implementation (e.g., spatial-design.md for complex layouts, motion-design.md for animated features, interaction-design.md for form-heavy features).
Based on the brief, list which impeccable reference files would be most valuable during implementation (e.g., layout.md for complex layouts, animate.md for animated features, interaction-design.md for form-heavy features, typeset.md for typography-driven pages, colorize.md for color-led brands).
**10. Open Questions**
Anything unresolved that the implementer should resolve during build.
Anything genuinely unresolved. Don't list "open questions" you've already recommended a default for; assert the default and move on. If you'd write `Recommend: X` next to a question, just decide X.
---
ask the user directly to clarify what you cannot infer. Get explicit confirmation of the brief before finishing. If the user disagrees with any part, revisit the relevant discovery questions.
STOP and use Codex's structured user-input/question tool when available; if unavailable, ask directly in chat to clarify what you cannot infer. Ask for explicit confirmation of the brief before finishing.
If the user disagrees with any part, revisit the relevant discovery questions. A shape run is incomplete until the user confirms direction.
Once confirmed, the brief is complete. The user can now hand it to $impeccable, or use it to guide any other implementation approach. (If the user wants the full discovery-then-build flow in one step, they should use $impeccable craft instead, which runs this command internally.)
@@ -1,100 +0,0 @@
# Spatial Design
## Spacing Systems
### Use 4pt Base, Not 8pt
8pt systems are too coarse—you'll frequently need 12px (between 8 and 16). Use 4pt for granularity: 4, 8, 12, 16, 24, 32, 48, 64, 96px.
### Name Tokens Semantically
Name by relationship (`--space-sm`, `--space-lg`), not value (`--spacing-8`). Use `gap` instead of margins for sibling spacing—it eliminates margin collapse and cleanup hacks.
## Grid Systems
### The Self-Adjusting Grid
Use `repeat(auto-fit, minmax(280px, 1fr))` for responsive grids without breakpoints. Columns are at least 280px, as many as fit per row, leftovers stretch. For complex layouts, use named grid areas (`grid-template-areas`) and redefine them at breakpoints.
## Visual Hierarchy
### The Squint Test
Blur your eyes (or screenshot and blur). Can you still identify:
- The most important element?
- The second most important?
- Clear groupings?
If everything looks the same weight blurred, you have a hierarchy problem.
### Hierarchy Through Multiple Dimensions
Don't rely on size alone. Combine:
| Tool | Strong Hierarchy | Weak Hierarchy |
|------|------------------|----------------|
| **Size** | 3:1 ratio or more | <2:1 ratio |
| **Weight** | Bold vs Regular | Medium vs Regular |
| **Color** | High contrast | Similar tones |
| **Position** | Top/left (primary) | Bottom/right |
| **Space** | Surrounded by white space | Crowded |
**The best hierarchy uses 2-3 dimensions at once**: A heading that's larger, bolder, AND has more space above it.
### Cards Are Not Required
Cards are overused. Spacing and alignment create visual grouping naturally. Use cards only when content is truly distinct and actionable, items need visual comparison in a grid, or content needs clear interaction boundaries. **Never nest cards inside cards**—use spacing, typography, and subtle dividers for hierarchy within a card.
## Container Queries
Viewport queries are for page layouts. **Container queries are for components**:
```css
.card-container {
container-type: inline-size;
}
.card {
display: grid;
gap: var(--space-md);
}
/* Card layout changes based on its container, not viewport */
@container (min-width: 400px) {
.card {
grid-template-columns: 120px 1fr;
}
}
```
**Why this matters**: A card in a narrow sidebar stays compact, while the same card in a main content area expands—automatically, without viewport hacks.
## Optical Adjustments
Text at `margin-left: 0` looks indented due to letterform whitespace—use negative margin (`-0.05em`) to optically align. Geometrically centered icons often look off-center; play icons need to shift right, arrows shift toward their direction.
### Touch Targets vs Visual Size
Buttons can look small but need large touch targets (44px minimum). Use padding or pseudo-elements:
```css
.icon-button {
width: 24px; /* Visual size */
height: 24px;
position: relative;
}
.icon-button::before {
content: '';
position: absolute;
inset: -10px; /* Expand tap target to 44px */
}
```
## Depth & Elevation
Create semantic z-index scales (dropdown → sticky → modal-backdrop → modal → toast → tooltip) instead of arbitrary numbers. For shadows, create a consistent elevation scale (sm → md → lg → xl). **Key insight**: Shadows should be subtle—if you can clearly see it, it's probably too strong.
---
**Avoid**: Arbitrary spacing values outside your scale. Making all spacing equal (variety creates hierarchy). Creating hierarchy through size alone - combine size, weight, color, and space.
@@ -1,137 +0,0 @@
# Teach Flow
Gathers design context for a project and writes two complementary files at the project root:
- **PRODUCT.md** (strategic): register, target users, product purpose, brand personality, anti-references, strategic design principles. Answers "who/what/why".
- **DESIGN.md** (visual): visual theme, color palette, typography, components, layout. Follows the [Google Stitch DESIGN.md format](https://stitch.withgoogle.com/docs/design-md/format/). Answers "how it looks".
Every other impeccable command reads these files before doing any work.
## Step 1: Load current state
Run the shared loader first so you know what already exists:
```bash
node .agents/skills/impeccable/scripts/load-context.mjs
```
The output tells you whether PRODUCT.md and/or DESIGN.md already exist. If `migrated: true`, legacy `.impeccable.md` was auto-renamed to `PRODUCT.md`. Mention this once to the user.
Decision tree:
- **Neither file exists (empty project or no context yet)**: do Steps 2-4 (write PRODUCT.md), then decide on DESIGN.md based on whether there's code to analyze.
- **PRODUCT.md exists, DESIGN.md missing**: skip to Step 5 — offer to run `$impeccable document` for DESIGN.md.
- **PRODUCT.md exists but has no `## Register` section (legacy)**: add it. Infer a hypothesis from the codebase (see Step 2), confirm with the user, write the field.
- **Both exist**: ask the user directly to clarify what you cannot infer. which to refresh. Skip the one the user doesn't want changed.
- **Just DESIGN.md exists (unusual)**: do Steps 2-4 to produce PRODUCT.md.
Never silently overwrite an existing file. Always confirm first.
## Step 2: Explore the codebase
Before asking questions, thoroughly scan the project to discover what you can:
- **README and docs**: Project purpose, target audience, any stated goals
- **Package.json / config files**: Tech stack, dependencies, existing design libraries
- **Existing components**: Current design patterns, spacing, typography in use
- **Brand assets**: Logos, favicons, color values already defined
- **Design tokens / CSS variables**: Existing color palettes, font stacks, spacing scales
- **Any style guides or brand documentation**
Also form a **register hypothesis** from what you find:
- Brand signals: `/`, `/about`, `/pricing`, `/blog/*`, `/docs/*`, hero sections, big typography, scroll-driven sections, landing-page-shaped content.
- Product signals: `/app/*`, `/dashboard`, `/settings`, `/(auth)`, forms, data tables, side/top nav, app-shell components.
Register is a hypothesis at this point, not a decision — Step 3 confirms it.
Note what you've learned and what remains unclear. This exploration feeds both PRODUCT.md and DESIGN.md.
## Step 3: Ask strategic questions (for PRODUCT.md)
ask the user directly to clarify what you cannot infer. Focus only on what you couldn't infer from the codebase.
### Register (ask first — it shapes everything below)
Every design task is either **brand** (marketing, landing, campaign, long-form content, portfolio — design IS the product) or **product** (app UI, admin, dashboards, tools — design SERVES the product).
If Step 2 produced a clear hypothesis, lead with it: *"From the codebase, this looks like a [brand / product] surface — does that match your intent, or should we treat it differently?"*
If the signal is genuinely split (e.g. a product with a big marketing landing), ask the user directly to clarify what you cannot infer. which register describes the **primary** surface. The register can be overridden per task later, but PRODUCT.md carries one default.
### Users & Purpose
- Who uses this? What's their context when using it?
- What job are they trying to get done?
- For brand: what emotions should the interface evoke? (confidence, delight, calm, urgency)
- For product: what workflow are they in? What's the primary task on any given screen?
### Brand & Personality
- How would you describe the brand personality in 3 words?
- Reference sites or apps that capture the right feel? What specifically about them?
- For brand, push for real-world references in the right lane (tech-minimal, editorial-magazine, consumer-warm, brutalist-grid, etc.) — not generic "modern" adjectives.
- For product, push for category best-tool references (Linear, Figma, Notion, Raycast, Stripe).
- What should this explicitly NOT look like? Any anti-references?
### Accessibility & Inclusion
- Specific accessibility requirements? (WCAG level, known user needs)
- Considerations for reduced motion, color blindness, or other accommodations?
Skip questions where the answer is already clear. **Do NOT ask about colors, fonts, radii, or visual styling here** — those belong in DESIGN.md, not PRODUCT.md.
## Step 4: Write PRODUCT.md
Synthesize into a strategic document:
```markdown
# Product
## Register
product
## Users
[Who they are, their context, the job to be done]
## Product Purpose
[What this product does, why it exists, what success looks like]
## Brand Personality
[Voice, tone, 3-word personality, emotional goals]
## Anti-references
[What this should NOT look like. Specific bad-example sites or patterns to avoid.]
## Design Principles
[3-5 strategic principles derived from the conversation. Principles like "practice what you preach", "show, don't tell", "expert confidence" — NOT visual rules like "use OKLCH" or "magenta accent".]
## Accessibility & Inclusion
[WCAG level, known user needs, considerations]
```
Register is either `brand` or `product` as a bare value. No prose, no commentary.
Write to `PROJECT_ROOT/PRODUCT.md`. If `.impeccable.md` existed, the loader already renamed it — merge into that content rather than starting from scratch.
## Step 5: Decide on DESIGN.md
Offer `$impeccable document` either way. Two paths:
- **Code exists** (CSS tokens, components, a running site): "I can generate a DESIGN.md that captures your visual system (colors, typography, components) so variants stay on-brand. Want to do that now?"
- **Pre-implementation** (empty project): "I can seed a starter DESIGN.md from five quick questions about color strategy, type direction, motion energy, and references. You can re-run once there's code, to capture the real tokens. Want to do that now?"
If the user agrees, delegate to `$impeccable document` (it auto-detects scan vs seed). Load its reference and follow that flow.
If the user prefers to skip, mention they can run `$impeccable document` any time later.
## Step 6: Confirm and wrap up
Summarize:
- Register captured (brand / product)
- What was written (PRODUCT.md, DESIGN.md, or both)
- The 3-5 strategic principles from PRODUCT.md that will guide future work
- If DESIGN.md is pending, remind the user how to generate it later
**Critical: re-run the loader to refresh session context.** After writing PRODUCT.md, run `node .agents/skills/impeccable/scripts/load-context.mjs` one final time and let its full JSON output land in conversation. This ensures subsequent commands in this session use the freshly-written PRODUCT.md, not a stale earlier version.
If teach was invoked as a blocker by another impeccable command (e.g. the user ran `$impeccable polish` with no PRODUCT.md), resume that original task now with the fresh context.
Optionally ask the user directly to clarify what you cannot infer. whether they'd like a brief summary of PRODUCT.md appended to AGENTS.md for easier agent reference. If yes, append a short **Design Context** pointer section there.
+165 -10
View File
@@ -1,10 +1,10 @@
Assess and improve typography that feels generic, inconsistent, or poorly structured — turning default-looking text into intentional, well-crafted type.
Typography carries most of the information on the page. Replace generic defaults (Inter, Roboto, system fallback at flat scale) with type that reflects the brand and scales with intentional contrast.
---
## Register
Brand: run the font selection procedure in [brand.md](brand.md). Pairing follows the brand's lane (display serif + sans body for editorial/luxury, one committed sans for tech, etc.). Fluid `clamp()` scale, ≥1.25 ratio between steps.
Brand: run the font selection procedure in [brand.md](brand.md). Fluid `clamp()` scale, ≥1.25 ratio between steps.
Product: system fonts and familiar sans stacks are legitimate here. One well-tuned family typically carries the whole UI. Fixed `rem` scale, 1.1251.2 ratio between more closely-spaced steps.
@@ -39,17 +39,17 @@ Analyze what's weak or generic about the current type:
- Are font weights used consistently? (Not bold in one section, semibold in another for the same role)
- Is letter-spacing intentional or default everywhere?
**CRITICAL**: The goal isn't to make text "fancier" — it's to make it clearer, more readable, and more intentional. Good typography is invisible; bad typography is distracting.
**CRITICAL**: The goal isn't to make text "fancier." It's to make it clearer, more readable, and more intentional. Good typography is invisible; bad typography is distracting.
## Plan Typography Improvements
Consult the [typography reference](typography.md) for detailed guidance on scales, pairing, and loading strategies.
Consult the [Reference Material](#reference-material) section below for detailed guidance on scales, pairing, and loading strategies.
Create a systematic plan:
- **Font selection**: Do fonts need replacing? What fits the brand/context?
- **Type scale**: Establish a modular scale (e.g., 1.25 ratio) with clear hierarchy
- **Weight strategy**: Which weights serve which roles? (Regular for body, Semibold for labels, Bold for headings or whatever fits)
- **Weight strategy**: Which weights serve which roles? (Regular for body, Semibold for labels, Bold for headings, or whatever fits)
- **Spacing**: Line-heights, letter-spacing, and margins between typographic elements
## Improve Typography Systematically
@@ -58,7 +58,7 @@ Create a systematic plan:
If fonts need replacing:
- Choose fonts that reflect the brand personality
- Pair with genuine contrast (serif + sans, geometric + humanist) or use a single family in multiple weights
- Pair with genuine contrast (serif + sans, geometric + humanist), or use a single family in multiple weights
- Ensure web font loading doesn't cause layout shift (`font-display: swap`, metric-matched fallbacks)
### Establish Hierarchy
@@ -66,7 +66,7 @@ If fonts need replacing:
Build a clear type scale:
- **5 sizes cover most needs**: caption, secondary, body, subheading, heading
- **Use a consistent ratio** between levels (1.25, 1.333, or 1.5)
- **Combine dimensions**: Size + weight + color + space for strong hierarchy — don't rely on size alone
- **Combine dimensions**: Size + weight + color + space for strong hierarchy. Don't rely on size alone
- **App UIs**: Use a fixed `rem`-based type scale, optionally adjusted at 1-2 breakpoints. Fluid sizing undermines the spatial predictability that dense, container-based layouts need
- **Marketing / content pages**: Use fluid sizing via `clamp(min, preferred, max)` for headings and display text. Keep body text fixed
@@ -92,11 +92,11 @@ Build a clear type scale:
**NEVER**:
- Use more than 2-3 font families
- Pick sizes arbitrarily commit to a scale
- Pick sizes arbitrarily; commit to a scale
- Set body text below 16px
- Use decorative/display fonts for body text
- Disable browser zoom (`user-scalable=no`)
- Use `px` for font sizes use `rem` to respect user settings
- Use `px` for font sizes; use `rem` to respect user settings
- Default to Inter/Roboto/Open Sans when personality matters
- Pair fonts that are similar but not identical (two geometric sans-serifs)
@@ -109,7 +109,7 @@ Build a clear type scale:
- **Performance**: Are web fonts loading efficiently without layout shift?
- **Accessibility**: Does text meet WCAG contrast ratios? Is it zoomable to 200%?
Remember: Typography is the foundation of interface design — it carries the majority of information. Getting it right is the highest-leverage improvement you can make.
When the type carries the hierarchy on its own, hand off to `$impeccable polish` for the final pass.
## Live-mode signature params
@@ -122,3 +122,158 @@ Each variant MUST declare a `scale` param controlling the hierarchy ratio. Expre
Where the variant riffs on a specific pairing, expose the pairing choice as a `steps` param (e.g. "serif display + sans body" vs. "mono display + sans body" vs. "all-sans"). Each branch routes through `:scope[data-p-pairing="X"]` selectors in scoped CSS.
See `reference/live.md` for the full params contract.
---
## Reference Material
The sections below were previously `typography.md` and live inline now so the typeset flow has its deep typography reference in one place. `bolder.md` also references this section.
### Typography
#### Classic Typography Principles
##### Vertical Rhythm
Your line-height should be the base unit for ALL vertical spacing. If body text has `line-height: 1.5` on `16px` type (= 24px), spacing values should be multiples of 24px. This creates subconscious harmony; text and space share a mathematical foundation.
##### Modular Scale & Hierarchy
The common mistake: too many font sizes that are too close together (14px, 15px, 16px, 18px...). This creates muddy hierarchy.
**Use fewer sizes with more contrast.** A 5-size system covers most needs:
| Role | Typical Ratio | Use Case |
|------|---------------|----------|
| xs | 0.75rem | Captions, legal |
| sm | 0.875rem | Secondary UI, metadata |
| base | 1rem | Body text |
| lg | 1.25-1.5rem | Subheadings, lead text |
| xl+ | 2-4rem | Headlines, hero text |
Popular ratios: 1.25 (major third), 1.333 (perfect fourth), 1.5 (perfect fifth). Pick one and commit.
##### Readability & Measure
Use `ch` units for character-based measure (`max-width: 65ch`). Line-height scales inversely with line length: narrow columns need tighter leading, wide columns need more.
**Non-obvious**: Light text on dark backgrounds needs compensation on three axes, not just one. Bump line-height by 0.050.1, add a touch of letter-spacing (0.010.02em), and optionally step the body weight up one notch (regular → medium). The perceived weight drops across all three; fix all three.
**Paragraph rhythm**: Pick either space between paragraphs OR first-line indentation. Never both. Digital usually wants space; editorial/long-form can justify indent-only.
#### Font Selection & Pairing
The tactical selection procedure and the reflex-reject list live in [reference/brand.md](brand.md) under **Font selection procedure** and **Reflex-reject list** (loaded for brand-register tasks). The rest of this section covers the adjacent knowledge: anti-reflex corrections, system font use, and pairing rules.
##### Anti-reflexes worth defending against
- A technical/utilitarian brief does NOT need a serif "for warmth." Most tech tools should look like tech tools.
- An editorial/premium brief does NOT need the same expressive serif everyone is using right now. Premium can be Swiss-modern, can be neo-grotesque, can be a literal monospace, can be a quiet humanist sans.
- A children's product does NOT need a rounded display font. Kids' books use real type.
- A "modern" brief does NOT need a geometric sans. The most modern thing you can do is not use the font everyone else is using.
**System fonts are underrated**: `-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui` looks native, loads instantly, and is highly readable. Consider this for apps where performance > personality.
##### Pairing Principles
**The non-obvious truth**: You often don't need a second font. One well-chosen font family in multiple weights creates cleaner hierarchy than two competing typefaces. Only add a second font when you need genuine contrast (e.g., display headlines + body serif).
When pairing, contrast on multiple axes:
- Serif + Sans (structure contrast)
- Geometric + Humanist (personality contrast)
- Condensed display + Wide body (proportion contrast)
##### Web Font Loading
The layout shift problem: fonts load late, text reflows, and users see content jump. Here's the fix:
```css
/* 1. Use font-display: swap for visibility */
@font-face {
font-family: 'CustomFont';
src: url('font.woff2') format('woff2');
font-display: swap;
}
/* 2. Match fallback metrics to minimize shift */
@font-face {
font-family: 'CustomFont-Fallback';
src: local('Arial');
size-adjust: 105%; /* Scale to match x-height */
ascent-override: 90%; /* Match ascender height */
descent-override: 20%; /* Match descender depth */
line-gap-override: 10%; /* Match line spacing */
}
body {
font-family: 'CustomFont', 'CustomFont-Fallback', sans-serif;
}
```
Tools like [Fontaine](https://github.com/unjs/fontaine) calculate these overrides automatically.
**`swap` vs `optional`**: `swap` shows fallback text immediately and FOUT-swaps when the web font arrives. `optional` uses the fallback if the web font misses a small load budget (~100ms) and avoids the shift entirely. Pick `optional` when zero layout shift matters more than seeing the branded font on slow networks.
**Preload the critical weight only**: typically the regular-weight body font used above the fold. Preloading every weight costs more bandwidth than it saves.
**Variable fonts for 3+ weights or styles**: a single variable font file is usually smaller than three static weight files, gives fractional weight control, and pairs well with `font-optical-sizing: auto`. For 12 weights, static is fine.
#### Modern Web Typography
##### Fluid Type
Fluid typography via `clamp(min, preferred, max)` scales text smoothly with the viewport. The middle value (e.g., `5vw + 1rem`) controls scaling rate (higher vw = faster scaling). Add a rem offset so it doesn't collapse to 0 on small screens.
**Use fluid type for**: Headings and display text on marketing/content pages where text dominates the layout and needs to breathe across viewport sizes.
**Use fixed `rem` scales for**: App UIs, dashboards, and data-dense interfaces. No major app design system (Material, Polaris, Primer, Carbon) uses fluid type in product UI; fixed scales with optional breakpoint adjustments give the spatial predictability that container-based layouts need. Body text should also be fixed even on marketing pages, since the size difference across viewports is too small to warrant it.
**Bound your clamp()**: keep `max-size ≤ ~2.5 × min-size`. Wider ratios break the browser's zoom and reflow behaviour and make large viewports feel like the page is shouting.
**Scale container width and font-size together** so effective character measure stays in the 4575ch band at every viewport. A heading that widens faster than its container drifts out of the comfortable measure at the top end.
##### OpenType Features
Most developers don't know these exist. Use them for polish:
```css
/* Proper fractions */
.recipe-amount { font-variant-numeric: diagonal-fractions; }
/* Small caps for abbreviations */
abbr { font-variant-caps: all-small-caps; }
/* Disable ligatures in code */
code { font-variant-ligatures: none; }
/* Enable kerning (usually on by default, but be explicit) */
body { font-kerning: normal; }
```
Check what features your font supports at [Wakamai Fondue](https://wakamaifondue.com/).
##### Rendering polish
```css
/* Variable fonts: pick the right optical-size master automatically */
body { font-optical-sizing: auto; }
```
**ALL-CAPS tracking**: capitals sit too close at default spacing. Add 512% letter-spacing (`letter-spacing: 0.05em` to `0.12em`) to short all-caps labels, eyebrows, and small headings. Real small caps (via `font-variant-caps`) need the same treatment, slightly gentler.
#### Typography System Architecture
Name tokens semantically (`--text-body`, `--text-heading`), not by value (`--font-size-16`). Include font stacks, size scale, weights, line-heights, and letter-spacing in your token system.
#### Accessibility Considerations
Beyond contrast ratios (which are well-documented), consider:
- **Never disable zoom**: `user-scalable=no` breaks accessibility. If your layout breaks at 200% zoom, fix the layout.
- **Use rem/em for font sizes**: This respects user browser settings. Never `px` for body text.
- **Minimum 16px body text**: Smaller than this strains eyes and fails WCAG on mobile.
- **Adequate touch targets**: Text links need padding or line-height that creates 44px+ tap targets.
---
**Avoid**: More than 2-3 font families per project. Skipping fallback font definitions. Ignoring font loading performance (FOUT/FOIT). Using decorative fonts for body text.
@@ -1,159 +0,0 @@
# Typography
## Classic Typography Principles
### Vertical Rhythm
Your line-height should be the base unit for ALL vertical spacing. If body text has `line-height: 1.5` on `16px` type (= 24px), spacing values should be multiples of 24px. This creates subconscious harmony—text and space share a mathematical foundation.
### Modular Scale & Hierarchy
The common mistake: too many font sizes that are too close together (14px, 15px, 16px, 18px...). This creates muddy hierarchy.
**Use fewer sizes with more contrast.** A 5-size system covers most needs:
| Role | Typical Ratio | Use Case |
|------|---------------|----------|
| xs | 0.75rem | Captions, legal |
| sm | 0.875rem | Secondary UI, metadata |
| base | 1rem | Body text |
| lg | 1.25-1.5rem | Subheadings, lead text |
| xl+ | 2-4rem | Headlines, hero text |
Popular ratios: 1.25 (major third), 1.333 (perfect fourth), 1.5 (perfect fifth). Pick one and commit.
### Readability & Measure
Use `ch` units for character-based measure (`max-width: 65ch`). Line-height scales inversely with line length—narrow columns need tighter leading, wide columns need more.
**Non-obvious**: Light text on dark backgrounds needs compensation on three axes, not just one. Bump line-height by 0.050.1, add a touch of letter-spacing (0.010.02em), and optionally step the body weight up one notch (regular → medium). The perceived weight drops across all three; fix all three.
**Paragraph rhythm**: Pick either space between paragraphs OR first-line indentation. Never both. Digital usually wants space; editorial/long-form can justify indent-only.
## Font Selection & Pairing
The tactical selection procedure and the reflex-reject list live in [reference/brand.md](brand.md) under **Font selection procedure** and **Reflex-reject list** (loaded for brand-register tasks). The rest of this section covers the adjacent knowledge: anti-reflex corrections, system font use, and pairing rules.
### Anti-reflexes worth defending against
- A technical/utilitarian brief does NOT need a serif "for warmth." Most tech tools should look like tech tools.
- An editorial/premium brief does NOT need the same expressive serif everyone is using right now. Premium can be Swiss-modern, can be neo-grotesque, can be a literal monospace, can be a quiet humanist sans.
- A children's product does NOT need a rounded display font. Kids' books use real type.
- A "modern" brief does NOT need a geometric sans. The most modern thing you can do is not use the font everyone else is using.
**System fonts are underrated**: `-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui` looks native, loads instantly, and is highly readable. Consider this for apps where performance > personality.
### Pairing Principles
**The non-obvious truth**: You often don't need a second font. One well-chosen font family in multiple weights creates cleaner hierarchy than two competing typefaces. Only add a second font when you need genuine contrast (e.g., display headlines + body serif).
When pairing, contrast on multiple axes:
- Serif + Sans (structure contrast)
- Geometric + Humanist (personality contrast)
- Condensed display + Wide body (proportion contrast)
**Never pair fonts that are similar but not identical** (e.g., two geometric sans-serifs). They create visual tension without clear hierarchy.
### Web Font Loading
The layout shift problem: fonts load late, text reflows, and users see content jump. Here's the fix:
```css
/* 1. Use font-display: swap for visibility */
@font-face {
font-family: 'CustomFont';
src: url('font.woff2') format('woff2');
font-display: swap;
}
/* 2. Match fallback metrics to minimize shift */
@font-face {
font-family: 'CustomFont-Fallback';
src: local('Arial');
size-adjust: 105%; /* Scale to match x-height */
ascent-override: 90%; /* Match ascender height */
descent-override: 20%; /* Match descender depth */
line-gap-override: 10%; /* Match line spacing */
}
body {
font-family: 'CustomFont', 'CustomFont-Fallback', sans-serif;
}
```
Tools like [Fontaine](https://github.com/unjs/fontaine) calculate these overrides automatically.
**`swap` vs `optional`**: `swap` shows fallback text immediately and FOUT-swaps when the web font arrives. `optional` uses the fallback if the web font misses a small load budget (~100ms) and avoids the shift entirely. Pick `optional` when zero layout shift matters more than seeing the branded font on slow networks.
**Preload the critical weight only**: typically the regular-weight body font used above the fold. Preloading every weight costs more bandwidth than it saves.
**Variable fonts for 3+ weights or styles**: a single variable font file is usually smaller than three static weight files, gives fractional weight control, and pairs well with `font-optical-sizing: auto`. For 12 weights, static is fine.
## Modern Web Typography
### Fluid Type
Fluid typography via `clamp(min, preferred, max)` scales text smoothly with the viewport. The middle value (e.g., `5vw + 1rem`) controls scaling rate—higher vw = faster scaling. Add a rem offset so it doesn't collapse to 0 on small screens.
**Use fluid type for**: Headings and display text on marketing/content pages where text dominates the layout and needs to breathe across viewport sizes.
**Use fixed `rem` scales for**: App UIs, dashboards, and data-dense interfaces. No major app design system (Material, Polaris, Primer, Carbon) uses fluid type in product UI — fixed scales with optional breakpoint adjustments give the spatial predictability that container-based layouts need. Body text should also be fixed even on marketing pages, since the size difference across viewports is too small to warrant it.
**Bound your clamp()**: keep `max-size ≤ ~2.5 × min-size`. Wider ratios break the browser's zoom and reflow behaviour and make large viewports feel like the page is shouting.
**Scale container width and font-size together** so effective character measure stays in the 4575ch band at every viewport. A heading that widens faster than its container drifts out of the comfortable measure at the top end.
### OpenType Features
Most developers don't know these exist. Use them for polish:
```css
/* Tabular numbers for data alignment */
.data-table { font-variant-numeric: tabular-nums; }
/* Proper fractions */
.recipe-amount { font-variant-numeric: diagonal-fractions; }
/* Small caps for abbreviations */
abbr { font-variant-caps: all-small-caps; }
/* Disable ligatures in code */
code { font-variant-ligatures: none; }
/* Enable kerning (usually on by default, but be explicit) */
body { font-kerning: normal; }
```
Check what features your font supports at [Wakamai Fondue](https://wakamaifondue.com/).
### Rendering polish
```css
/* Even out heading line lengths (browser picks better break points) */
h1, h2, h3 { text-wrap: balance; }
/* Reduce orphans and ragged endings in long prose */
article p { text-wrap: pretty; }
/* Variable fonts: pick the right optical-size master automatically */
body { font-optical-sizing: auto; }
```
**ALL-CAPS tracking**: capitals sit too close at default spacing. Add 512% letter-spacing (`letter-spacing: 0.05em` to `0.12em`) to short all-caps labels, eyebrows, and small headings. Real small caps (via `font-variant-caps`) need the same treatment, slightly gentler.
## Typography System Architecture
Name tokens semantically (`--text-body`, `--text-heading`), not by value (`--font-size-16`). Include font stacks, size scale, weights, line-heights, and letter-spacing in your token system.
## Accessibility Considerations
Beyond contrast ratios (which are well-documented), consider:
- **Never disable zoom**: `user-scalable=no` breaks accessibility. If your layout breaks at 200% zoom, fix the layout.
- **Use rem/em for font sizes**: This respects user browser settings. Never `px` for body text.
- **Minimum 16px body text**: Smaller than this strains eyes and fails WCAG on mobile.
- **Adequate touch targets**: Text links need padding or line-height that creates 44px+ tap targets.
---
**Avoid**: More than 2-3 font families per project. Skipping fallback font definitions. Ignoring font loading performance (FOUT/FOIT). Using decorative fonts for body text.
@@ -1,107 +0,0 @@
# UX Writing
## The Button Label Problem
**Never use "OK", "Submit", or "Yes/No".** These are lazy and ambiguous. Use specific verb + object patterns:
| Bad | Good | Why |
|-----|------|-----|
| OK | Save changes | Says what will happen |
| Submit | Create account | Outcome-focused |
| Yes | Delete message | Confirms the action |
| Cancel | Keep editing | Clarifies what "cancel" means |
| Click here | Download PDF | Describes the destination |
**For destructive actions**, name the destruction:
- "Delete" not "Remove" (delete is permanent, remove implies recoverable)
- "Delete 5 items" not "Delete selected" (show the count)
## Error Messages: The Formula
Every error message should answer: (1) What happened? (2) Why? (3) How to fix it? Example: "Email address isn't valid. Please include an @ symbol." not "Invalid input".
### Error Message Templates
| Situation | Template |
|-----------|----------|
| **Format error** | "[Field] needs to be [format]. Example: [example]" |
| **Missing required** | "Please enter [what's missing]" |
| **Permission denied** | "You don't have access to [thing]. [What to do instead]" |
| **Network error** | "We couldn't reach [thing]. Check your connection and [action]." |
| **Server error** | "Something went wrong on our end. We're looking into it. [Alternative action]" |
### Don't Blame the User
Reframe errors: "Please enter a date in MM/DD/YYYY format" not "You entered an invalid date".
## Empty States Are Opportunities
Empty states are onboarding moments: (1) Acknowledge briefly, (2) Explain the value of filling it, (3) Provide a clear action. "No projects yet. Create your first one to get started." not just "No items".
## Voice vs Tone
**Voice** is your brand's personality—consistent everywhere.
**Tone** adapts to the moment.
| Moment | Tone Shift |
|--------|------------|
| Success | Celebratory, brief: "Done! Your changes are live." |
| Error | Empathetic, helpful: "That didn't work. Here's what to try..." |
| Loading | Reassuring: "Saving your work..." |
| Destructive confirm | Serious, clear: "Delete this project? This can't be undone." |
**Never use humor for errors.** Users are already frustrated. Be helpful, not cute.
## Writing for Accessibility
**Link text** must have standalone meaning—"View pricing plans" not "Click here". **Alt text** describes information, not the image—"Revenue increased 40% in Q4" not "Chart". Use `alt=""` for decorative images. **Icon buttons** need `aria-label` for screen reader context.
## Writing for Translation
### Plan for Expansion
German text is ~30% longer than English. Allocate space:
| Language | Expansion |
|----------|-----------|
| German | +30% |
| French | +20% |
| Finnish | +30-40% |
| Chinese | -30% (fewer chars, but same width) |
### Translation-Friendly Patterns
Keep numbers separate ("New messages: 3" not "You have 3 new messages"). Use full sentences as single strings (word order varies by language). Avoid abbreviations ("5 minutes ago" not "5 mins ago"). Give translators context about where strings appear.
## Consistency: The Terminology Problem
Pick one term and stick with it:
| Inconsistent | Consistent |
|--------------|------------|
| Delete / Remove / Trash | Delete |
| Settings / Preferences / Options | Settings |
| Sign in / Log in / Enter | Sign in |
| Create / Add / New | Create |
Build a terminology glossary and enforce it. Variety creates confusion.
## Avoid Redundant Copy
If the heading explains it, the intro is redundant. If the button is clear, don't explain it again. Say it once, say it well.
## Loading States
Be specific: "Saving your draft..." not "Loading...". For long waits, set expectations ("This usually takes 30 seconds") or show progress.
## Confirmation Dialogs: Use Sparingly
Most confirmation dialogs are design failures—consider undo instead. When you must confirm: name the action, explain consequences, use specific button labels ("Delete project" / "Keep project", not "Yes" / "No").
## Form Instructions
Show format with placeholders, not instructions. For non-obvious fields, explain why you're asking.
---
**Avoid**: Jargon without explanation. Blaming users ("You made an error" → "This field is required"). Vague errors ("Something went wrong"). Varying terminology for variety. Humor for errors.
@@ -25,7 +25,7 @@ import { join, resolve } from 'node:path';
const DEPRECATED_NAMES = [
// v2.0 renames
'frontend-design', // renamed to impeccable
'teach-impeccable', // folded into /impeccable teach
'teach-impeccable', // folded into /impeccable init
// v2.1 merges
'arrange', // renamed to layout
'normalize', // merged into polish
@@ -1,10 +1,10 @@
{
"craft": {
"description": "Full shape-then-build flow with visual iteration. Plans the UX with /impeccable shape, loads the right reference files, then builds and iterates visually until the result is delightful. Use when building a new feature end-to-end.",
"description": "Full confirmed-brief-then-build flow. Runs multi-round shape discovery first, resolves visual probe and north-star mock gates when available, then builds and visually iterates. Use when building a new feature end-to-end.",
"argumentHint": "[feature description]"
},
"teach": {
"description": "Gathers design context for a project. Runs a short discovery interview and writes PRODUCT.md (strategic: users, brand, principles) and, when code exists to analyze, DESIGN.md (visual: colors, typography, components). Every other command reads these files before doing work. Use once per project.",
"init": {
"description": "Sets up a project for impeccable. Runs a multi-round discovery interview when context is missing and writes PRODUCT.md (strategic: users, brand, principles); offers DESIGN.md (visual: colors, typography, components) when code exists; pre-configures live mode; then recommends the best commands to run next. Every other command reads these files before doing work. Use once per project.",
"argumentHint": ""
},
"document": {
@@ -84,7 +84,7 @@
"argumentHint": "[target]"
},
"shape": {
"description": "Plan the UX and UI for a feature before writing code. Runs a structured discovery interview, then produces a design brief that guides implementation. Use during the planning phase to establish design direction, constraints, and strategy before any code is written.",
"description": "Plan UX and UI before code. Runs a required multi-round discovery interview, uses visual probes when available, and produces a user-confirmed design brief for implementation.",
"argumentHint": "[feature to shape]"
},
"typeset": {
@@ -0,0 +1,253 @@
/**
* Context loader: prints PRODUCT.md (and DESIGN.md if present) as one
* markdown block on stdout, or exits with empty stdout when no PRODUCT.md
* is found anywhere. The skill keys off "empty stdout" to branch into the
* init flow.
*
* Path resolution (first match wins):
* 1. cwd, if PRODUCT.md or DESIGN.md is there
* 2. .agents/context/ then docs/
* 3. $IMPECCABLE_CONTEXT_DIR (absolute or cwd-relative) power-user
* escape hatch, only consulted when defaults are empty
* 4. cwd as a "nothing found" default
*
* `resolveContextDir()` and `loadContext()` are also exported for the
* server-side scripts (live.mjs, live-server.mjs) that need the structured
* shape rather than the markdown block.
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
const FALLBACK_DIRS = ['.agents/context', 'docs'];
// ─── Update check ──────────────────────────────────────────────────────────
// Piggyback a lightweight skill-version check on the once-per-session boot.
// When a newer skill ships, append an UPDATE_AVAILABLE directive so the agent
// can offer `npx impeccable skills update`. Everything here is best-effort and
// silent on failure: a network problem, sandbox, or missing cache must never
// block context output or print an error.
const UPDATE_HOST = (process.env.IMPECCABLE_UPDATE_HOST || 'https://impeccable.style').replace(/\/$/, '');
const UPDATE_CACHE_PATH =
process.env.IMPECCABLE_UPDATE_CACHE || path.join(os.homedir(), '.impeccable', 'update-check.json');
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // throttle the network poll to once a day
const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000; // don't re-surface the same version for a week
const FETCH_TIMEOUT_MS = 1200;
export function resolveContextDir(cwd = process.cwd()) {
if (firstExisting(cwd, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
return cwd;
}
for (const rel of FALLBACK_DIRS) {
const candidate = path.resolve(cwd, rel);
if (firstExisting(candidate, [...PRODUCT_NAMES, ...DESIGN_NAMES])) {
return candidate;
}
}
const envDir = process.env.IMPECCABLE_CONTEXT_DIR;
if (envDir && envDir.trim()) {
const trimmed = envDir.trim();
return path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
}
return cwd;
}
export function loadContext(cwd = process.cwd()) {
const contextDir = resolveContextDir(cwd);
const productPath = firstExisting(contextDir, PRODUCT_NAMES);
const designPath = firstExisting(contextDir, DESIGN_NAMES);
const product = productPath ? safeRead(productPath) : null;
const design = designPath ? safeRead(designPath) : null;
return {
hasProduct: !!product,
product,
productPath: productPath ? path.relative(cwd, productPath) : null,
hasDesign: !!design,
design,
designPath: designPath ? path.relative(cwd, designPath) : null,
contextDir,
};
}
function firstExisting(dir, names) {
for (const name of names) {
const abs = path.join(dir, name);
if (fs.existsSync(abs)) return abs;
}
return null;
}
function safeRead(p) {
try {
return fs.readFileSync(p, 'utf-8');
} catch {
return null;
}
}
/**
* Pull the register (`brand` or `product`) out of PRODUCT.md by looking
* for a `## Register` section and reading the first non-empty line that
* follows it. Returns null when the file is legacy / register-less.
*/
function extractRegister(product) {
if (!product) return null;
const lines = product.split('\n');
for (let i = 0; i < lines.length; i++) {
if (/^##\s+Register\b/i.test(lines[i].trim())) {
for (let j = i + 1; j < lines.length; j++) {
const next = lines[j].trim();
if (!next) continue;
const word = next.toLowerCase();
if (word === 'brand' || word === 'product') return word;
return null;
}
}
}
return null;
}
/**
* Read the installed skill's own version from the sibling SKILL.md frontmatter
* (this file lives at `<skill>/scripts/context.mjs`). Returns null when the
* frontmatter is missing or unreadable.
*/
function readLocalSkillVersion() {
try {
const here = path.dirname(fileURLToPath(import.meta.url));
const skillMd = path.join(here, '..', 'SKILL.md');
const content = fs.readFileSync(skillMd, 'utf-8');
const match = content.match(/^version:\s*(.+)$/m);
return match ? match[1].trim().replace(/^["']|["']$/g, '') : null;
} catch {
return null;
}
}
function readUpdateCache() {
try {
return JSON.parse(fs.readFileSync(UPDATE_CACHE_PATH, 'utf-8'));
} catch {
return {};
}
}
function writeUpdateCache(cache) {
try {
fs.mkdirSync(path.dirname(UPDATE_CACHE_PATH), { recursive: true });
fs.writeFileSync(UPDATE_CACHE_PATH, JSON.stringify(cache));
} catch {
// Best-effort: a read-only home dir just means we re-poll next session.
}
}
/** Compare dotted numeric versions. Returns >0 when a is newer than b. */
function compareSemver(a, b) {
const pa = String(a).split('.').map(n => parseInt(n, 10) || 0);
const pb = String(b).split('.').map(n => parseInt(n, 10) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const diff = (pa[i] || 0) - (pb[i] || 0);
if (diff !== 0) return diff;
}
return 0;
}
async function fetchLatestSkillVersion() {
try {
const res = await fetch(`${UPDATE_HOST}/api/version`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
if (!res.ok) return null;
const data = await res.json();
return typeof data?.skills === 'string' ? data.skills : null;
} catch {
return null; // offline, sandboxed, timed out, or bad JSON: all non-fatal
}
}
function buildUpdateDirective(localVersion, latestVersion) {
return (
`UPDATE_AVAILABLE: A newer Impeccable skill is available ` +
`(installed v${localVersion}, latest v${latestVersion}). ` +
`Before continuing, ask the user once: "A newer Impeccable (v${latestVersion}) is available. ` +
`Update now? It runs \`npx impeccable skills update\`." ` +
`If they agree, run \`npx impeccable skills update\` (the update applies to the next session, not this one). ` +
`Either way, continue the current task without waiting, and do not raise this again.`
);
}
/**
* Best-effort update directive for the boot output. Returns a string to append
* or null. Polls the version endpoint at most once per day (cached globally in
* the user's home dir) and re-surfaces a given version at most once per week so
* the agent never nags. Opt out entirely with IMPECCABLE_NO_UPDATE_CHECK=1.
*/
async function computeUpdateDirective(now = Date.now()) {
try {
if (process.env.IMPECCABLE_NO_UPDATE_CHECK) return null;
const localVersion = readLocalSkillVersion();
if (!localVersion) return null;
const cache = readUpdateCache();
// Poll the network only when the throttle window has elapsed. Stamp
// lastCheck even on failure so an offline machine doesn't poll every boot.
if (!cache.lastCheck || now - cache.lastCheck > CHECK_INTERVAL_MS) {
const latest = await fetchLatestSkillVersion();
cache.lastCheck = now;
if (latest) cache.latestVersion = latest;
writeUpdateCache(cache);
}
const latest = cache.latestVersion;
if (!latest || compareSemver(latest, localVersion) <= 0) return null;
// Anti-nag: surface a given version at most once per RENOTIFY window.
if (cache.notifiedVersion === latest && cache.notifiedAt && now - cache.notifiedAt < RENOTIFY_INTERVAL_MS) {
return null;
}
cache.notifiedVersion = latest;
cache.notifiedAt = now;
writeUpdateCache(cache);
return buildUpdateDirective(localVersion, latest);
} catch {
return null;
}
}
async function cli() {
const ctx = loadContext(process.cwd());
const updateDirective = await computeUpdateDirective();
if (!ctx.hasProduct) {
// Direct stdout message instead of relying on empty output as a signal
// — cheap models miss the empty case more often than the explicit one.
const parts = [
'NO_PRODUCT_MD: This project has no PRODUCT.md yet. ' +
'Stop the current task, load reference/init.md, and follow its ' +
'instructions to write PRODUCT.md before resuming.',
];
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
process.exit(0);
}
const parts = [`# PRODUCT.md\n\n${ctx.product.trim()}`];
if (ctx.hasDesign) {
parts.push(`# DESIGN.md\n\n${ctx.design.trim()}`);
}
const register = extractRegister(ctx.product);
const next = register
? `NEXT STEP: This project's register is \`${register}\`. You MUST now read \`reference/${register}.md\` before producing any design output.`
: `NEXT STEP: You MUST now read the matching register reference (\`reference/brand.md\` or \`reference/product.md\`) before producing any design output. Pick based on PRODUCT.md above.`;
parts.push(next);
if (updateDirective) parts.push(updateDirective);
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
}
const _running = process.argv[1];
if (_running?.endsWith('context.mjs') || _running?.endsWith('context.mjs/')) {
cli();
}
@@ -0,0 +1,242 @@
#!/usr/bin/env node
/**
* Critique persistence helper.
*
* Each run of /impeccable critique writes a per-target snapshot to
* .impeccable/critique/<timestamp>__<slug>.md
* with a small YAML frontmatter carrying the score + P0/P1 counts.
*
* /impeccable polish reads the latest matching snapshot at start as its
* fix backlog. No other skill auto-reads critique output.
*
* The slug is derived mechanically from the *resolved* primary artifact
* (file path or URL), never from the user's natural-language phrasing.
* Slug stability across runs is what lets the trend display work.
*
* CLI entry points (called from skill instructions):
* node critique-storage.mjs slug <resolved-target>
* node critique-storage.mjs write <slug> <snapshot-body-file>
* node critique-storage.mjs latest <slug>
* node critique-storage.mjs trend <slug> [limit]
*
* Note: there is intentionally no `ignore` subcommand. ignore.md is a plain
* markdown file; the model reads it directly with its file-read tool. This
* helper only exists for operations the model can't trivially do inline
* (normalizing paths, generating filenames, globbing + parsing frontmatter).
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { getCritiqueDir } from './impeccable-paths.mjs';
const SLUG_MAX = 50;
/**
* Mechanically derive a slug from a resolved target. Returns null if the
* input doesn't look like a stable identifier (empty, project root, etc).
*
* Accepts file paths and URLs. The model resolves "the homepage" to a
* concrete artifact before calling this we never slug a natural-language
* phrase.
*/
export function slugFromTarget(resolved, { cwd = process.cwd() } = {}) {
if (!resolved || typeof resolved !== 'string') return null;
const trimmed = resolved.trim();
if (!trimmed) return null;
// URL
if (/^https?:\/\//i.test(trimmed)) {
let url;
try { url = new URL(trimmed); } catch { return null; }
const hostPath = `${url.hostname}${url.pathname}`;
return kebab(hostPath);
}
// File path. Make it project-relative so two devs critiquing the same
// checkout get the same slug regardless of where their repo is cloned.
const abs = path.isAbsolute(trimmed) ? trimmed : path.resolve(cwd, trimmed);
let rel = path.relative(cwd, abs);
// If the target is outside cwd, fall back to the basename so we still
// produce a stable slug (vs the absolute path, which would include
// home dirs / usernames).
if (rel.startsWith('..') || path.isAbsolute(rel)) {
rel = path.basename(abs);
}
if (!rel || rel === '.' || rel === '') return null;
return kebab(rel);
}
function kebab(s) {
const slug = s
.toLowerCase()
.replace(/[/\\.]+/g, '-')
.replace(/[^a-z0-9-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
if (!slug) return null;
// Cap from the tail — the tail (filename) is more identifying than the
// top-level directory.
return slug.length <= SLUG_MAX ? slug : slug.slice(slug.length - SLUG_MAX).replace(/^-/, '');
}
/**
* Filename-safe UTC ISO timestamp: hyphens for separators, trailing Z.
* Plain colons aren't allowed on Windows filesystems.
*/
export function nowFilenameStamp(date = new Date()) {
const iso = date.toISOString(); // 2026-05-12T18:30:00.123Z
return iso.replace(/[:.]/g, '-').replace(/-\d+Z$/, 'Z');
}
/**
* Write a snapshot for `slug`. `meta` carries the small structured frontmatter
* keys read back by readTrend(). `body` is the human-readable critique
* report (everything below the frontmatter).
*
* Returns the absolute path written.
*/
export function writeSnapshot({ slug, meta, body, cwd = process.cwd(), now = new Date() }) {
if (!slug) throw new Error('writeSnapshot requires a slug');
const dir = getCritiqueDir(cwd);
fs.mkdirSync(dir, { recursive: true });
const timestamp = nowFilenameStamp(now);
const filePath = path.join(dir, `${timestamp}__${slug}.md`);
// Spread `meta` first so internally computed `timestamp` and `slug`
// always win. Otherwise a caller-supplied meta blob (parsed from the
// IMPECCABLE_CRITIQUE_META env var) could clobber them, leaving the
// filename in disagreement with its frontmatter and corrupting trends.
const front = serializeFrontmatter({ ...meta, timestamp, slug });
fs.writeFileSync(filePath, `${front}\n${body.trim()}\n`, 'utf-8');
return filePath;
}
function serializeFrontmatter(obj) {
const lines = ['---'];
for (const [key, value] of Object.entries(obj)) {
if (value === undefined || value === null) continue;
const str = typeof value === 'string' ? value : String(value);
// Quote strings that contain : or # to keep parsing simple.
const needsQuotes = typeof value === 'string' && /[:#]/.test(str);
lines.push(`${key}: ${needsQuotes ? JSON.stringify(str) : str}`);
}
lines.push('---');
return lines.join('\n');
}
function parseFrontmatter(text) {
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!match) return {};
const out = {};
for (const line of match[1].split(/\r?\n/)) {
const colon = line.indexOf(':');
if (colon < 0) continue;
const key = line.slice(0, colon).trim();
let value = line.slice(colon + 1).trim();
if (/^".*"$/.test(value)) {
try { value = JSON.parse(value); } catch { /* leave as-is */ }
} else if (/^-?\d+$/.test(value)) {
value = Number(value);
}
out[key] = value;
}
return out;
}
/**
* Return all snapshot files for `slug`, sorted oldest newest.
*/
function listSnapshotsForSlug(slug, cwd) {
const dir = getCritiqueDir(cwd);
if (!fs.existsSync(dir)) return [];
const suffix = `__${slug}.md`;
return fs.readdirSync(dir)
.filter((f) => f.endsWith(suffix))
.sort()
.map((f) => path.join(dir, f));
}
/**
* 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 the last `limit` snapshots' frontmatter, oldest newest.
* 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 slice = all.slice(-limit);
return slice.map((file) => parseFrontmatter(fs.readFileSync(file, 'utf-8')));
}
// ---- CLI ---------------------------------------------------------------
function main(argv) {
const [cmd, ...args] = argv;
switch (cmd) {
case 'slug': {
const slug = slugFromTarget(args[0]);
if (!slug) { process.stderr.write('no stable slug for input\n'); process.exit(1); }
process.stdout.write(`${slug}\n`);
return;
}
case 'write': {
const [slug, bodyFile] = args;
if (!slug || !bodyFile) { process.stderr.write('usage: write <slug> <body-file>\n'); process.exit(1); }
const raw = fs.readFileSync(bodyFile, 'utf-8');
// The body file may be a full report. The caller passes the meta as
// a JSON object on stdin if it wants structured frontmatter; otherwise
// we write with minimal metadata.
let meta = {};
const metaArg = process.env.IMPECCABLE_CRITIQUE_META;
if (metaArg) {
try { meta = JSON.parse(metaArg); } catch { /* ignore */ }
}
const out = writeSnapshot({ slug, meta, body: raw });
process.stdout.write(`${out}\n`);
return;
}
case 'latest': {
const latest = readLatestSnapshot(args[0]);
if (!latest) { process.exit(2); }
process.stdout.write(latest.body);
return;
}
case 'trend': {
const rows = readTrend(args[0], { limit: args[1] ? Number(args[1]) : 5 });
process.stdout.write(JSON.stringify(rows, null, 2) + '\n');
return;
}
default:
process.stderr.write('usage: critique-storage.mjs <slug|write|latest|trend> [args]\n');
process.exit(1);
}
}
function isMainModule() {
if (!process.argv[1]) return false;
try {
return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(process.argv[1]);
} catch {
// pathToFileURL normalizes Windows paths; keep it as a fallback for any
// environment where realpath is unavailable.
return import.meta.url === pathToFileURL(process.argv[1]).href;
}
}
// Why the realpath check: generated skills are often reached through symlinked
// harness directories (for example a demo repo's `.agents` -> source `.agents`).
// Node resolves import.meta.url to the real file, while process.argv[1] keeps
// the symlink path. Comparing canonical paths prevents a silent exit-0 no-op.
if (isMainModule()) {
main(process.argv.slice(2));
}
@@ -63,7 +63,7 @@ function parseYamlSubset(yaml) {
}
const key = content.slice(0, colonIdx).trim();
const rest = content.slice(colonIdx + 1).trim();
const rest = stripInlineYamlComment(content.slice(colonIdx + 1).trim());
const parent = stack[stack.length - 1].obj;
if (rest === '') {
@@ -93,6 +93,21 @@ function findTopLevelColon(s) {
return -1;
}
function stripInlineYamlComment(s) {
let inQuote = null;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (inQuote) {
if (ch === inQuote && s[i - 1] !== '\\') inQuote = null;
} else if (ch === '"' || ch === "'") {
inQuote = ch;
} else if (ch === '#' && i > 0 && /\s/.test(s[i - 1])) {
return s.slice(0, i).trimEnd();
}
}
return s;
}
function parseScalar(raw) {
const s = raw.trim();
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
@@ -0,0 +1,21 @@
#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import { pathToFileURL, fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const candidates = [
path.join(__dirname, 'detector', 'detect-antipatterns.mjs'),
path.join(__dirname, '..', '..', 'cli', 'engine', 'detect-antipatterns.mjs'),
];
const detectorPath = candidates.find(p => fs.existsSync(p));
if (!detectorPath) {
process.stderr.write('Error: bundled detector not found.\n');
process.exit(1);
}
const { detectCli } = await import(pathToFileURL(detectorPath));
await detectCli();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,238 @@
import fs from 'node:fs';
import path from 'node:path';
import { createBrowserDetector, detectUrl } from '../engines/browser/detect-url.mjs';
import { detectHtml } from '../engines/static-html/detect-html.mjs';
import { detectText } from '../engines/regex/detect-text.mjs';
import {
HTML_EXTENSIONS,
buildImportGraph,
detectFrameworkConfig,
isPortListening,
walkDir,
} from '../node/file-system.mjs';
// ---------------------------------------------------------------------------
// Output formatting
// ---------------------------------------------------------------------------
function formatFindings(findings, jsonMode) {
if (jsonMode) return JSON.stringify(findings, null, 2);
const grouped = {};
for (const f of findings) {
if (!grouped[f.file]) grouped[f.file] = [];
grouped[f.file].push(f);
}
const out = [];
for (const [file, items] of Object.entries(grouped)) {
const importNote = items[0]?.importedBy?.length ? ` (imported by ${items[0].importedBy.join(', ')})` : '';
out.push(`\n${file}${importNote}`);
for (const item of items) {
out.push(` ${item.line ? `line ${item.line}: ` : ''}[${item.antipattern}] ${item.snippet}`);
out.push(`${item.description}`);
}
}
out.push(`\n${findings.length} anti-pattern${findings.length === 1 ? '' : 's'} found.`);
return out.join('\n');
}
// ---------------------------------------------------------------------------
// Stdin handling
// ---------------------------------------------------------------------------
async function handleStdin(options = {}) {
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
const input = Buffer.concat(chunks).toString('utf-8');
try {
const parsed = JSON.parse(input);
const fp = parsed?.tool_input?.file_path;
if (fp && fs.existsSync(fp)) {
return HTML_EXTENSIONS.has(path.extname(fp).toLowerCase())
? detectHtml(fp, options) : detectText(fs.readFileSync(fp, 'utf-8'), fp, options);
}
} catch { /* not JSON */ }
return detectText(input, '<stdin>', options);
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
async function confirm(question) {
const rl = (await import('node:readline')).default.createInterface({
input: process.stdin, output: process.stderr,
});
return new Promise((resolve) => {
rl.question(`${question} [Y/n] `, (answer) => {
rl.close();
resolve(!answer || /^y(es)?$/i.test(answer.trim()));
});
});
}
function printUsage() {
console.log(`Usage: impeccable detect [options] [file-or-dir-or-url...]
Scan files or URLs for UI anti-patterns and design quality issues.
Options:
--fast Regex-only mode (skip static HTML/CSS analysis, faster but misses linked stylesheets)
--json Output results as JSON
--gpt Also report GPT-specific provider tells (off by default)
--gemini Also report Gemini-specific provider tells (off by default)
--help Show this help message
Detection modes:
HTML files Static HTML/CSS analysis (default, catches linked CSS)
Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.)
URLs Puppeteer full browser rendering (auto-detected)
--fast Forces regex for all files
Examples:
impeccable detect src/
impeccable detect index.html
impeccable detect https://example.com
impeccable detect --fast --json .`);
}
async function detectCli() {
let args = process.argv.slice(2).map(arg => {
if (arg === '-json') return '--json';
if (arg === '-fast') return '--fast';
return arg;
});
if (args[0] === 'detect') args = args.slice(1);
const jsonMode = args.includes('--json');
const helpMode = args.includes('--help');
const fastMode = args.includes('--fast');
const providers = [];
if (args.includes('--gpt')) providers.push('gpt');
if (args.includes('--gemini')) providers.push('gemini');
const scanOptions = { providers };
const targets = args.filter(a => !a.startsWith('--'));
if (helpMode) { printUsage(); process.exit(0); }
let allFindings = [];
if (!process.stdin.isTTY && targets.length === 0) {
allFindings = await handleStdin(scanOptions);
} else {
const paths = targets.length > 0 ? targets : [process.cwd()];
const urlTargetCount = paths.filter(target => /^https?:\/\//i.test(target)).length;
const browserDetector = urlTargetCount > 1 ? await createBrowserDetector() : null;
try {
for (const target of paths) {
if (/^https?:\/\//i.test(target)) {
try {
const scanner = browserDetector
? (url) => browserDetector.detectUrl(url, scanOptions)
: (url) => detectUrl(url, scanOptions);
allFindings.push(...await scanner(target));
} catch (e) { process.stderr.write(`Error: ${e.message}\n`); }
continue;
}
const resolved = path.resolve(target);
let stat;
try { stat = fs.statSync(resolved); }
catch { process.stderr.write(`Warning: cannot access ${target}\n`); continue; }
if (stat.isDirectory()) {
// Check for framework dev server config (skip in JSON mode to avoid polluting output)
if (!jsonMode) {
const fwConfig = detectFrameworkConfig(resolved);
if (fwConfig) {
const probe = await isPortListening(fwConfig.port, fwConfig.fingerprint);
if (probe.listening && probe.matched) {
process.stderr.write(
`\n${fwConfig.name} dev server detected on localhost:${fwConfig.port}.\n` +
`For more accurate results, scan the running site:\n` +
` npx impeccable detect http://localhost:${fwConfig.port}\n\n`
);
} else if (probe.listening && !probe.matched) {
process.stderr.write(
`\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` +
`Port ${fwConfig.port} is in use by another service. Start the ${fwConfig.name} dev server and scan via URL for best results.\n\n`
);
} else {
process.stderr.write(
`\n${fwConfig.name} project detected (${path.basename(fwConfig.configPath)}).\n` +
`Start the dev server and scan via URL for best results:\n` +
` npx impeccable detect http://localhost:${fwConfig.port}\n\n`
);
}
}
}
const files = walkDir(resolved);
const htmlCount = files.filter(f => HTML_EXTENSIONS.has(path.extname(f).toLowerCase())).length;
// Warn and confirm if scanning many files (static HTML/CSS processes each HTML file)
if (files.length > 50 && process.stdin.isTTY && !jsonMode) {
process.stderr.write(
`\nFound ${files.length} files (${htmlCount} HTML) in ${target}.\n` +
`Scanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\n` +
`Use --fast to skip static HTML/CSS analysis, or target a specific subdirectory.\n`
);
const ok = await confirm('Continue?');
if (!ok) { process.stderr.write('Aborted.\n'); process.exit(0); }
}
// Build import graph for multi-file awareness
const graph = buildImportGraph(files);
// Build reverse map: file -> set of files that import it
const importedByMap = new Map();
for (const [importer, imports] of graph) {
for (const imported of imports) {
if (!importedByMap.has(imported)) importedByMap.set(imported, new Set());
importedByMap.get(imported).add(importer);
}
}
for (const file of files) {
const ext = path.extname(file).toLowerCase();
let fileFindings;
if (!fastMode && HTML_EXTENSIONS.has(ext)) {
fileFindings = await detectHtml(file, scanOptions);
} else {
fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file, scanOptions);
}
// Annotate findings with import context
const importers = importedByMap.get(file);
if (importers && importers.size > 0) {
const importerNames = [...importers].map(f => path.basename(f));
for (const f of fileFindings) {
f.importedBy = importerNames;
}
}
allFindings.push(...fileFindings);
}
} else if (stat.isFile()) {
const ext = path.extname(resolved).toLowerCase();
if (!fastMode && HTML_EXTENSIONS.has(ext)) {
allFindings.push(...await detectHtml(resolved, scanOptions));
} else {
allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved, scanOptions));
}
}
}
} finally {
if (browserDetector) await browserDetector.close();
}
}
if (allFindings.length > 0) {
if (jsonMode) process.stdout.write(formatFindings(allFindings, true) + '\n');
else process.stderr.write(formatFindings(allFindings, false) + '\n');
process.exit(2);
}
if (jsonMode) process.stdout.write('[]\n');
process.exit(0);
}
export { formatFindings, handleStdin, confirm, printUsage, detectCli };
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,43 @@
#!/usr/bin/env node
/**
* Anti-Pattern Detector for Impeccable
* Copyright (c) 2026 Paul Bakaus
* SPDX-License-Identifier: Apache-2.0
*
* Public API facade. Runtime engines live under cli/engine/engines/.
*/
import { detectCli } from './cli/main.mjs';
export { ANTIPATTERNS, RULE_ENGINE_SUPPORT, getAntipattern, getRulesForCategory, getRuleEngineSupport } from './registry/antipatterns.mjs';
export { SAFE_TAGS, BORDER_SAFE_TAGS, OVERUSED_FONTS, GENERIC_FONTS, KNOWN_SERIF_FONTS } from './shared/constants.mjs';
export { isNeutralColor, parseRgb, relativeLuminance, contrastRatio, parseGradientColors, hasChroma, getHue, colorToHex } from './shared/color.mjs';
export { isFullPage } from './shared/page.mjs';
export {
checkElementBorders,
checkElementMotion,
checkElementGlow,
checkPageTypography,
checkPageLayout,
checkHtmlPatterns,
} from './rules/checks.mjs';
export { createDetectorProfile, summarizeDetectorProfile } from './profile/profiler.mjs';
export { detectHtml } from './engines/static-html/detect-html.mjs';
export { detectUrl, createBrowserDetector } from './engines/browser/detect-url.mjs';
export { detectText, extractStyleBlocks, extractCSSinJS } from './engines/regex/detect-text.mjs';
export {
walkDir,
SCANNABLE_EXTENSIONS,
SKIP_DIRS,
buildImportGraph,
resolveImport,
detectFrameworkConfig,
isPortListening,
FRAMEWORK_CONFIGS,
} from './node/file-system.mjs';
export { formatFindings, detectCli } from './cli/main.mjs';
const isMainModule = process.argv[1]?.endsWith('detect-antipatterns.mjs') ||
process.argv[1]?.endsWith('detect-antipatterns.mjs/');
if (isMainModule) detectCli();
@@ -0,0 +1,252 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { finding } from '../../findings.mjs';
import { filterByProviders } from '../../registry/antipatterns.mjs';
import { profileFindingsAsync, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import { captureVisualContrastCandidate } from '../visual/screenshot-contrast.mjs';
async function runVisualContrastFallback(page, serializedGroups, options, profile, target) {
if (options?.visualContrast === false) return [];
const maxCandidates = Number.isFinite(options?.visualContrastMaxCandidates)
? options.visualContrastMaxCandidates
: 12;
const scrollOffscreen = options?.visualContrastScrollOffscreen !== false;
const existingLowContrastSelectors = new Set(
serializedGroups
.filter(group => group.findings?.some(f => f.type === 'low-contrast'))
.map(group => group.selector)
.filter(Boolean)
);
let browserAnalyses = [];
const findings = [];
if (options?.visualContrastBrowser !== false) {
const browserFindings = await profileFindingsAsync(profile, {
engine: 'browser',
phase: 'visual-contrast',
ruleId: 'browser-fallback',
target,
}, async () => {
browserAnalyses = await page.evaluate(async ({ maxCandidates, scrollOffscreen }) => {
if (typeof window.impeccableAnalyzeVisualContrast !== 'function') return [];
return window.impeccableAnalyzeVisualContrast({ maxCandidates, scrollOffscreen });
}, { maxCandidates, scrollOffscreen });
return browserAnalyses
.filter(result => result.finding && !existingLowContrastSelectors.has(result.selector))
.map(result => result.finding);
});
findings.push(...browserFindings);
}
let candidates = browserAnalyses.length > 0 ? browserAnalyses : [];
if (candidates.length === 0) {
candidates = await profileStepAsync(profile, {
engine: 'browser',
phase: 'visual-contrast',
ruleId: 'collect-candidates',
target,
}, () => page.evaluate(({ maxCandidates }) => {
if (typeof window.impeccableCollectVisualContrastCandidates !== 'function') return [];
return window.impeccableCollectVisualContrastCandidates({ maxCandidates });
}, { maxCandidates }));
}
const viewport = options?.viewport || { width: 1280, height: 800 };
const browserResolvedSelectors = new Set(
browserAnalyses
.filter(result => result.status === 'fail' || result.status === 'pass')
.map(result => result.selector)
.filter(Boolean)
);
const filtered = candidates.filter(candidate =>
!existingLowContrastSelectors.has(candidate.selector) &&
!browserResolvedSelectors.has(candidate.selector)
);
if (options?.visualContrastPixel === false) return findings;
for (const candidate of filtered) {
const result = await profileFindingsAsync(profile, {
engine: 'browser',
phase: 'visual-contrast',
ruleId: 'pixel-diff',
target,
}, async () => {
const finding = await captureVisualContrastCandidate(page, candidate, viewport);
return finding ? [finding] : [];
});
findings.push(...result);
}
return findings;
}
// ---------------------------------------------------------------------------
// Puppeteer detection (for URLs)
// ---------------------------------------------------------------------------
async function detectUrl(url, options = {}) {
const profile = options?.profile;
const waitUntil = options?.waitUntil || 'networkidle0';
const settleMs = Number.isFinite(options?.settleMs) ? options.settleMs : 0;
const viewport = options?.viewport || { width: 1280, height: 800 };
const externalBrowser = options?.browser || null;
let puppeteer;
if (!externalBrowser) {
try {
puppeteer = await profileStepAsync(profile, {
engine: 'browser',
phase: 'setup',
ruleId: 'import-puppeteer',
target: url,
}, () => import('puppeteer'));
} catch {
throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer');
}
}
// Read the browser detection script — reuse it instead of reimplementing
const browserScriptPath = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'..',
'detect-antipatterns-browser.js'
);
let browserScript;
try {
browserScript = profileStep(profile, {
engine: 'browser',
phase: 'setup',
ruleId: 'read-browser-script',
target: url,
}, () => fs.readFileSync(browserScriptPath, 'utf-8'));
} catch {
throw new Error(`Browser script not found at ${browserScriptPath}`);
}
// CI runners (GitHub Actions Ubuntu) block unprivileged user namespaces, so
// Chrome can't initialize its sandbox there. Disable the sandbox only when
// running in CI; local users keep the default hardened launch.
const launchArgs = process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : [];
const browser = externalBrowser || await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
ruleId: 'launch-browser',
target: url,
}, () => puppeteer.default.launch({ headless: true, args: launchArgs }));
const page = await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
ruleId: 'new-page',
target: url,
}, () => browser.newPage());
let results = [];
try {
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
ruleId: 'set-viewport',
target: url,
}, () => page.setViewport(viewport));
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
ruleId: `goto:${waitUntil}`,
target: url,
}, () => page.goto(url, { waitUntil, timeout: 30000 }));
if (settleMs > 0) {
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
ruleId: 'settle',
target: url,
}, () => new Promise(resolve => setTimeout(resolve, settleMs)));
}
// Inject the browser detection script and collect results
await profileStepAsync(profile, {
engine: 'browser',
phase: 'scan',
ruleId: 'configure-pure-detect',
target: url,
}, () => page.evaluate(() => {
window.__IMPECCABLE_CONFIG__ = {
...(window.__IMPECCABLE_CONFIG__ || {}),
autoScan: false,
};
}));
await profileStepAsync(profile, {
engine: 'browser',
phase: 'scan',
ruleId: 'inject-browser-script',
target: url,
}, () => page.evaluate(browserScript));
let serializedGroups = [];
results = await profileFindingsAsync(profile, {
engine: 'browser',
phase: 'scan',
ruleId: 'browser-scan',
target: url,
}, async () => {
serializedGroups = await page.evaluate(() => {
if (!window.impeccableDetect) return [];
return window.impeccableDetect({ decorate: false, serialize: true });
});
return serializedGroups.flatMap(({ findings }) =>
findings.map(f => ({ id: f.type, snippet: f.detail }))
);
});
const visualFindings = await runVisualContrastFallback(page, serializedGroups, options, profile, url);
results.push(...visualFindings);
} finally {
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
ruleId: 'close-page',
target: url,
}, () => page.close().catch(() => {}));
if (!externalBrowser) {
await profileStepAsync(profile, {
engine: 'browser',
phase: 'load',
ruleId: 'close-browser',
target: url,
}, () => browser.close());
}
}
return filterByProviders(results.map(f => finding(f.id, url, f.snippet)), options.providers);
}
async function createBrowserDetector(options = {}) {
let puppeteer;
try {
puppeteer = await import('puppeteer');
} catch {
throw new Error('puppeteer is required for URL scanning. Install: npm install puppeteer');
}
const launchArgs = options.launchArgs || (process.env.CI ? ['--no-sandbox', '--disable-setuid-sandbox'] : []);
const browser = options.browser || await puppeteer.default.launch({
headless: options.headless ?? true,
args: launchArgs,
});
const ownsBrowser = !options.browser;
const defaults = {
waitUntil: options.waitUntil || 'load',
settleMs: Number.isFinite(options.settleMs) ? options.settleMs : 100,
viewport: options.viewport || { width: 1280, height: 800 },
};
return {
browser,
async detectUrl(url, scanOptions = {}) {
return detectUrl(url, {
...defaults,
...scanOptions,
browser,
});
},
async close() {
if (ownsBrowser) await browser.close().catch(() => {});
},
};
}
export { runVisualContrastFallback, detectUrl, createBrowserDetector };
@@ -0,0 +1,535 @@
import { GENERIC_FONTS } from '../../shared/constants.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { finding } from '../../findings.mjs';
import { filterByProviders } from '../../registry/antipatterns.mjs';
import { profileFindings, profileStep } from '../../profile/profiler.mjs';
// ---------------------------------------------------------------------------
// Regex fallback (non-HTML files: CSS, JSX, TSX, etc.)
// ---------------------------------------------------------------------------
const hasRounded = (line) => /\brounded(?:-\w+)?\b/.test(line);
const hasBorderRadius = (line) => /border-radius/i.test(line);
const isSafeElement = (line) => /<(?:blockquote|nav[\s>]|pre[\s>]|code[\s>]|a\s|input[\s>]|span[\s>])/i.test(line);
/** Strip HTML to plain text drops script/style/comments/tags so
* content-text analyzers don't false-positive on code or CSS. */
function stripHtmlToText(html) {
return html
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ')
.replace(/<!--[\s\S]*?-->/g, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ');
}
function isNeutralBorderColor(str) {
const m = str.match(/solid\s+(#[0-9a-f]{3,8}|rgba?\([^)]+\)|\w+)/i);
if (!m) return false;
const c = m[1].toLowerCase();
if (['gray', 'grey', 'silver', 'white', 'black', 'transparent', 'currentcolor'].includes(c)) return true;
const hex = c.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/);
if (hex) {
const [r, g, b] = [parseInt(hex[1], 16), parseInt(hex[2], 16), parseInt(hex[3], 16)];
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
}
const shex = c.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/);
if (shex) {
const [r, g, b] = [parseInt(shex[1] + shex[1], 16), parseInt(shex[2] + shex[2], 16), parseInt(shex[3] + shex[3], 16)];
return (Math.max(r, g, b) - Math.min(r, g, b)) < 30;
}
return false;
}
const REGEX_MATCHERS = [
// --- Side-tab ---
{ id: 'side-tab', regex: /\bborder-[lrse]-(\d+)\b/g,
test: (m, line) => { const n = +m[1]; return hasRounded(line) ? n >= 1 : n >= 4; },
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-(?:left|right)\s*:\s*(\d+)px\s+solid[^;]*/gi,
test: (m, line) => { if (isSafeElement(line)) return false; if (isNeutralBorderColor(m[0])) return false; const n = +m[1]; return hasBorderRadius(line) ? n >= 1 : n >= 3; },
fmt: (m) => m[0].replace(/\s*;?\s*$/, '') },
{ id: 'side-tab', regex: /border-(?:left|right)-width\s*:\s*(\d+)px/gi,
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-inline-(?:start|end)\s*:\s*(\d+)px\s+solid/gi,
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border-inline-(?:start|end)-width\s*:\s*(\d+)px/gi,
test: (m, line) => !isSafeElement(line) && +m[1] >= 3,
fmt: (m) => m[0] },
{ id: 'side-tab', regex: /border(?:Left|Right)\s*[:=]\s*["'`](\d+)px\s+solid/g,
test: (m) => +m[1] >= 3,
fmt: (m) => m[0] },
// --- Border accent on rounded ---
{ id: 'border-accent-on-rounded', regex: /\bborder-[tb]-(\d+)\b/g,
test: (m, line) => hasRounded(line) && +m[1] >= 1,
fmt: (m) => m[0] },
{ id: 'border-accent-on-rounded', regex: /border-(?:top|bottom)\s*:\s*(\d+)px\s+solid/gi,
test: (m, line) => +m[1] >= 3 && hasBorderRadius(line),
fmt: (m) => m[0] },
// --- Overused font ---
{ id: 'overused-font', regex: /font-family\s*:\s*['"]?(Inter|Roboto|Open Sans|Lato|Montserrat|Arial|Helvetica|Fraunces|Geist Sans|Geist Mono|Geist|Mona Sans|Plus Jakarta Sans|Space Grotesk|Recoleta|Instrument Sans|Instrument Serif)\b/gi,
test: () => true,
fmt: (m) => m[0] },
{ id: 'overused-font', regex: /fonts\.googleapis\.com\/css2?\?family=(Inter|Roboto|Open\+Sans|Lato|Montserrat|Fraunces|Plus\+Jakarta\+Sans|Space\+Grotesk|Instrument\+Sans|Instrument\+Serif|Mona\+Sans|Geist)\b/gi,
test: () => true,
fmt: (m) => `Google Fonts: ${m[1].replace(/\+/g, ' ')}` },
// --- Gradient text ---
{ id: 'gradient-text', regex: /background-clip\s*:\s*text|-webkit-background-clip\s*:\s*text/gi,
test: (m, line) => /gradient/i.test(line),
fmt: () => 'background-clip: text + gradient' },
// --- Gradient text (Tailwind) ---
{ id: 'gradient-text', regex: /\bbg-clip-text\b/g,
test: (m, line) => /\bbg-gradient-to-/i.test(line),
fmt: () => 'bg-clip-text + bg-gradient' },
// --- Tailwind gray on colored bg ---
{ id: 'gray-on-color', regex: /\btext-(?:gray|slate|zinc|neutral|stone)-(\d+)\b/g,
test: (m, line) => /\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/.test(line),
fmt: (m, line) => { const bg = line.match(/\bbg-(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+\b/); return `${m[0]} on ${bg?.[0] || '?'}`; } },
// --- Tailwind AI palette ---
{ id: 'ai-color-palette', regex: /\btext-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\btext-(?:[2-9]xl|[3-9]xl)\b|<h[1-3]/i.test(line),
fmt: (m) => `${m[0]} on heading` },
{ id: 'ai-color-palette', regex: /\bfrom-(?:purple|violet|indigo)-(\d+)\b/g,
test: (m, line) => /\bto-(?:purple|violet|indigo|blue|cyan|pink|fuchsia)-\d+\b/.test(line),
fmt: (m) => `${m[0]} gradient` },
// --- Bounce/elastic easing ---
{ id: 'bounce-easing', regex: /\banimate-bounce\b/g,
test: () => true,
fmt: () => 'animate-bounce (Tailwind)' },
{ id: 'bounce-easing', regex: /animation(?:-name)?\s*:\s*[^;]*\b(bounce|elastic|wobble|jiggle|spring)\b/gi,
test: () => true,
fmt: (m) => m[0] },
{ id: 'bounce-easing', regex: /cubic-bezier\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/g,
test: (m) => {
const y1 = parseFloat(m[2]), y2 = parseFloat(m[4]);
return y1 < -0.1 || y1 > 1.1 || y2 < -0.1 || y2 > 1.1;
},
fmt: (m) => `cubic-bezier(${m[1]}, ${m[2]}, ${m[3]}, ${m[4]})` },
// --- Layout property transition ---
{ id: 'layout-transition', regex: /transition\s*:\s*([^;{}]+)/gi,
test: (m) => {
const val = m[1].toLowerCase();
if (/\ball\b/.test(val)) return false;
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
},
fmt: (m) => {
const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
return `transition: ${found ? found.join(', ') : m[1].trim()}`;
} },
{ id: 'layout-transition', regex: /transition-property\s*:\s*([^;{}]+)/gi,
test: (m) => {
const val = m[1].toLowerCase();
if (/\ball\b/.test(val)) return false;
return /\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding\b|\bmargin\b/.test(val);
},
fmt: (m) => {
const found = m[1].match(/\b(?:(?:max|min)-)?(?:width|height)\b|\bpadding(?:-(?:top|right|bottom|left))?\b|\bmargin(?:-(?:top|right|bottom|left))?\b/gi);
return `transition-property: ${found ? found.join(', ') : m[1].trim()}`;
} },
// --- Broken image: src="" or src="#" or src=" " ---
{ id: 'broken-image', regex: /<img\b[^>]*?\bsrc\s*=\s*(?:""|''|"\s+"|'\s+'|"#"|'#')/gi,
test: () => true,
fmt: (m) => m[0].slice(0, 100) },
// --- Broken image: <img> with no src attribute at all ---
{ id: 'broken-image', regex: /<img\b(?:(?!\bsrc\s*=)[^>])*>/gi,
test: (m) => !/\bsrc\s*=/i.test(m[0]),
fmt: (m) => m[0].slice(0, 100) },
];
const REGEX_ANALYZERS = [
// Single font
(content, filePath) => {
const fontFamilyRe = /font-family\s*:\s*([^;}]+)/gi;
const fonts = new Set();
let m;
while ((m = fontFamilyRe.exec(content)) !== null) {
for (const f of m[1].split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase())) {
if (f && !GENERIC_FONTS.has(f)) fonts.add(f);
}
}
const gfRe = /fonts\.googleapis\.com\/css2?\?family=([^&"'\s]+)/gi;
while ((m = gfRe.exec(content)) !== null) {
for (const f of m[1].split('|').map(f => f.split(':')[0].replace(/\+/g, ' ').toLowerCase())) fonts.add(f);
}
if (fonts.size !== 1 || content.split('\n').length < 20) return [];
const name = [...fonts][0];
const lines = content.split('\n');
let line = 1;
for (let i = 0; i < lines.length; i++) { if (lines[i].toLowerCase().includes(name)) { line = i + 1; break; } }
return [finding('single-font', filePath, `only font used is ${name}`, line)];
},
// Flat type hierarchy
(content, filePath) => {
const sizes = new Set();
const REM = 16;
let m;
const sizeRe = /font-size\s*:\s*([\d.]+)(px|rem|em)\b/gi;
while ((m = sizeRe.exec(content)) !== null) {
const px = m[2] === 'px' ? +m[1] : +m[1] * REM;
if (px > 0 && px < 200) sizes.add(Math.round(px * 10) / 10);
}
const clampRe = /font-size\s*:\s*clamp\(\s*([\d.]+)(px|rem|em)\s*,\s*[^,]+,\s*([\d.]+)(px|rem|em)\s*\)/gi;
while ((m = clampRe.exec(content)) !== null) {
sizes.add(Math.round((m[2] === 'px' ? +m[1] : +m[1] * REM) * 10) / 10);
sizes.add(Math.round((m[4] === 'px' ? +m[3] : +m[3] * REM) * 10) / 10);
}
const TW = { 'text-xs': 12, 'text-sm': 14, 'text-base': 16, 'text-lg': 18, 'text-xl': 20, 'text-2xl': 24, 'text-3xl': 30, 'text-4xl': 36, 'text-5xl': 48, 'text-6xl': 60, 'text-7xl': 72, 'text-8xl': 96, 'text-9xl': 128 };
for (const [cls, px] of Object.entries(TW)) { if (new RegExp(`\\b${cls}\\b`).test(content)) sizes.add(px); }
if (sizes.size < 3) return [];
const sorted = [...sizes].sort((a, b) => a - b);
const ratio = sorted[sorted.length - 1] / sorted[0];
if (ratio >= 2.0) return [];
const lines = content.split('\n');
let line = 1;
for (let i = 0; i < lines.length; i++) { if (/font-size/i.test(lines[i]) || /\btext-(?:xs|sm|base|lg|xl|\d)/i.test(lines[i])) { line = i + 1; break; } }
return [finding('flat-type-hierarchy', filePath, `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)`, line)];
},
// Monotonous spacing (regex)
(content, filePath) => {
const vals = [];
let m;
const pxRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*(\d+)px/gi;
while ((m = pxRe.exec(content)) !== null) { const v = +m[1]; if (v > 0 && v < 200) vals.push(v); }
const remRe = /(?:padding|margin)(?:-(?:top|right|bottom|left))?\s*:\s*([\d.]+)rem/gi;
while ((m = remRe.exec(content)) !== null) { const v = Math.round(parseFloat(m[1]) * 16); if (v > 0 && v < 200) vals.push(v); }
const gapRe = /gap\s*:\s*(\d+)px/gi;
while ((m = gapRe.exec(content)) !== null) vals.push(+m[1]);
const twRe = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap)-(\d+)\b/g;
while ((m = twRe.exec(content)) !== null) vals.push(+m[1] * 4);
const rounded = vals.map(v => Math.round(v / 4) * 4);
if (rounded.length < 10) return [];
const counts = {};
for (const v of rounded) counts[v] = (counts[v] || 0) + 1;
const maxCount = Math.max(...Object.values(counts));
const pct = maxCount / rounded.length;
const unique = [...new Set(rounded)].filter(v => v > 0);
if (pct <= 0.6 || unique.length > 3) return [];
const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0];
return [finding('monotonous-spacing', filePath, `~${dominant}px used ${maxCount}/${rounded.length} times (${Math.round(pct * 100)}%)`)];
},
// Em-dash overuse: 5+ em-dashes or "--" in body text content
// (occasional em-dash use in prose is fine; the pattern fires only
// when count crosses into AI-cadence territory).
(content, filePath) => {
const text = stripHtmlToText(content);
let count = 0;
const re = /[—]|--(?=\S)/g;
while (re.exec(text) !== null) count++;
if (count < 5) return [];
return [finding('em-dash-overuse', filePath, `${count} em-dashes in body text`)];
},
// Marketing buzzwords: SaaS phrase list
(content, filePath) => {
const text = stripHtmlToText(content);
const lower = text.toLowerCase();
const BUZZWORDS = [
'streamline your', 'empower your', 'supercharge your',
'unleash your', 'unleash the power', 'leverage the power',
'built for the modern', 'trusted by leading', 'trusted by the world',
'best-in-class', 'industry-leading', 'world-class', 'enterprise-grade',
'next-generation', 'cutting-edge', 'transform your business',
'revolutionize', 'game-changer', 'game changing',
'mission-critical', 'best of breed', 'future-proof', 'future proof',
'seamless experience', 'seamlessly integrate',
'drive engagement', 'drive growth', 'drive results',
'harness the power',
];
let count = 0;
let firstSample = '';
for (const phrase of BUZZWORDS) {
let from = 0;
while (true) {
const idx = lower.indexOf(phrase, from);
if (idx === -1) break;
count++;
if (!firstSample) {
firstSample = text.slice(Math.max(0, idx - 12), Math.min(text.length, idx + phrase.length + 12)).trim();
}
from = idx + phrase.length;
}
}
if (count === 0) return [];
return [finding('marketing-buzzword', filePath, `${count} buzzword phrase${count === 1 ? '' : 's'}: "${firstSample}"`)];
},
// Numbered section markers (01 / 02 / 03 ...)
(content, filePath) => {
const text = stripHtmlToText(content);
const re = /\b(0[1-9]|1[0-2])\b/g;
const seen = new Set();
let m;
while ((m = re.exec(text)) !== null) seen.add(m[1]);
if (seen.size < 3) return [];
const sorted = [...seen].sort();
let sequential = 0;
for (let i = 1; i < sorted.length; i++) {
if (parseInt(sorted[i], 10) === parseInt(sorted[i - 1], 10) + 1) sequential++;
}
if (sequential < 2) return [];
return [finding('numbered-section-markers', filePath, `Sequence: ${sorted.slice(0, 6).join(', ')}`)];
},
// Aphoristic cadence: manufactured-contrast + short-rebuttal
(content, filePath) => {
const text = stripHtmlToText(content);
const NOT_A_RE = /\bNot an? [a-z][^.!?]{1,40}[.!]\s+[A-Z][^.!?]{1,60}[.!]/g;
const SHORT_REBUTTAL_RE = /\b[A-Z][^.!?]{4,80}[.!]\s+(No|Just)\s+[a-z][^.!?]{2,60}[.!]/g;
let count = 0;
let firstSample = '';
let m;
NOT_A_RE.lastIndex = 0;
while ((m = NOT_A_RE.exec(text)) !== null) {
count++;
if (!firstSample) firstSample = m[0].trim().slice(0, 80);
}
SHORT_REBUTTAL_RE.lastIndex = 0;
while ((m = SHORT_REBUTTAL_RE.exec(text)) !== null) {
count++;
if (!firstSample) firstSample = m[0].trim().slice(0, 80);
}
if (count < 3) return [];
return [finding('aphoristic-cadence', filePath, `${count} aphoristic constructions: "${firstSample}"`)];
},
// Dark glow (page-level: dark bg + colored box-shadow with blur)
(content, filePath) => {
// Check if page has a dark background
const darkBgRe = /background(?:-color)?\s*:\s*(?:#(?:0[0-9a-f]|1[0-9a-f]|2[0-3])[0-9a-f]{4}\b|#(?:0|1)[0-9a-f]{2}\b|rgb\(\s*(\d{1,2})\s*,\s*(\d{1,2})\s*,\s*(\d{1,2})\s*\))/gi;
const twDarkBg = /\bbg-(?:gray|slate|zinc|neutral|stone)-(?:9\d{2}|800)\b/;
const hasDarkBg = darkBgRe.test(content) || twDarkBg.test(content);
if (!hasDarkBg) return [];
// Check for colored box-shadow with blur > 4px
const shadowRe = /box-shadow\s*:\s*([^;{}]+)/gi;
let m;
while ((m = shadowRe.exec(content)) !== null) {
const val = m[1];
const colorMatch = val.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (!colorMatch) continue;
const [r, g, b] = [+colorMatch[1], +colorMatch[2], +colorMatch[3]];
if ((Math.max(r, g, b) - Math.min(r, g, b)) < 30) continue; // skip gray
// Check blur: look for pattern like "0 0 20px" (third number > 4)
const pxVals = [...val.matchAll(/(\d+)px|(?<![.\d])\b(0)\b(?![.\d])/g)].map(p => +(p[1] || p[2]));
if (pxVals.length >= 3 && pxVals[2] > 4) {
const lines = content.substring(0, m.index).split('\n');
return [finding('dark-glow', filePath, `Colored glow (rgb(${r},${g},${b})) on dark page`, lines.length)];
}
}
return [];
},
];
// ---------------------------------------------------------------------------
// Style block extraction (Vue/Svelte <style> blocks)
// ---------------------------------------------------------------------------
function extractStyleBlocks(content, ext) {
ext = ext.toLowerCase();
if (ext !== '.vue' && ext !== '.svelte') return [];
const blocks = [];
const re = /<style[^>]*>([\s\S]*?)<\/style>/gi;
let m;
while ((m = re.exec(content)) !== null) {
const before = content.substring(0, m.index);
const startLine = before.split('\n').length + 1;
blocks.push({ content: m[1], startLine });
}
return blocks;
}
// ---------------------------------------------------------------------------
// CSS-in-JS extraction (styled-components, emotion)
// ---------------------------------------------------------------------------
const CSS_IN_JS_EXTENSIONS = new Set(['.js', '.ts', '.jsx', '.tsx']);
function extractCSSinJS(content, ext) {
ext = ext.toLowerCase();
if (!CSS_IN_JS_EXTENSIONS.has(ext)) return [];
const blocks = [];
const re = /(?:styled(?:\.\w+|\([^)]+\))|css)\s*`([\s\S]*?)`/g;
let m;
while ((m = re.exec(content)) !== null) {
const before = content.substring(0, m.index);
const startLine = before.split('\n').length;
blocks.push({ content: m[1], startLine });
}
return blocks;
}
function runRegexMatchers(lines, filePath, lineOffset = 0, blockContext = null, options = {}) {
const { profile, phase = 'regex-matchers' } = options || {};
const findings = [];
if (!profile) {
for (const matcher of REGEX_MATCHERS) {
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
matcher.regex.lastIndex = 0;
let m;
while ((m = matcher.regex.exec(line)) !== null) {
// For extracted blocks, use nearby lines as context for multi-line CSS patterns
const context = blockContext
? lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 4)).join(' ')
: line;
if (matcher.test(m, context)) {
findings.push(finding(matcher.id, filePath, matcher.fmt(m, context), i + 1 + lineOffset));
}
}
}
}
return findings;
}
for (const matcher of REGEX_MATCHERS) {
const matcherFindings = profileFindings(profile, {
engine: 'regex',
phase,
ruleId: matcher.id,
target: filePath,
}, () => {
const matches = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
matcher.regex.lastIndex = 0;
let m;
while ((m = matcher.regex.exec(line)) !== null) {
// For extracted blocks, use nearby lines as context for multi-line CSS patterns
const context = blockContext
? lines.slice(Math.max(0, i - 3), Math.min(lines.length, i + 4)).join(' ')
: line;
if (matcher.test(m, context)) {
matches.push(finding(matcher.id, filePath, matcher.fmt(m, context), i + 1 + lineOffset));
}
}
}
return matches;
});
findings.push(...matcherFindings);
}
return findings;
}
/** Page-level analyzers that scan rendered text content (em-dash use,
* buzzword phrases, numbered section markers, aphoristic cadence).
* These are detector-agnostic they work on any HTML/text source
* and don't need a parsed DOM. Exported so detectHtml can call them
* for `.html` files (which otherwise skip the regex engine). */
const TEXT_CONTENT_ANALYZER_IDS = [
'em-dash-overuse',
'marketing-buzzword',
'numbered-section-markers',
'aphoristic-cadence',
];
function runTextContentAnalyzers(content, filePath, options = {}) {
const profile = options?.profile;
if (!isFullPage(content)) return [];
// The 4 text-content analyzers are at indices 3-6 in REGEX_ANALYZERS.
const findings = [];
for (let i = 0; i < TEXT_CONTENT_ANALYZER_IDS.length; i++) {
const analyzer = REGEX_ANALYZERS[3 + i];
const ruleId = TEXT_CONTENT_ANALYZER_IDS[i];
findings.push(...profileFindings(profile, {
engine: 'regex',
phase: 'text-content',
ruleId,
target: filePath,
}, () => analyzer(content, filePath)));
}
return findings;
}
function detectText(content, filePath, options = {}) {
const profile = options?.profile;
const findings = [];
const lines = content.split('\n');
const ext = filePath ? (filePath.match(/\.\w+$/)?.[0] || '').toLowerCase() : '';
// Run regex matchers on the full file content (catches Tailwind classes, inline styles)
// Enable block context for CSS files where related properties span multiple lines
const cssLike = new Set(['.css', '.scss', '.less']);
findings.push(...runRegexMatchers(lines, filePath, 0, cssLike.has(ext) || null, {
profile,
phase: 'source',
}));
// Extract and scan <style> blocks from Vue/Svelte SFCs
const styleBlocks = profile
? profileStep(profile, {
engine: 'regex',
phase: 'extract',
ruleId: 'style-blocks',
target: filePath,
}, () => extractStyleBlocks(content, ext))
: extractStyleBlocks(content, ext);
for (const block of styleBlocks) {
const blockLines = block.content.split('\n');
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
profile,
phase: 'style-block',
}));
}
// Extract and scan CSS-in-JS template literals
const cssJsBlocks = profile
? profileStep(profile, {
engine: 'regex',
phase: 'extract',
ruleId: 'css-in-js',
target: filePath,
}, () => extractCSSinJS(content, ext))
: extractCSSinJS(content, ext);
for (const block of cssJsBlocks) {
const blockLines = block.content.split('\n');
findings.push(...runRegexMatchers(blockLines, filePath, block.startLine - 1, true, {
profile,
phase: 'css-in-js',
}));
}
// Deduplicate findings (same antipattern + similar snippet, within 2 lines)
const deduped = [];
for (const f of findings) {
const isDupe = deduped.some(d =>
d.antipattern === f.antipattern &&
d.snippet === f.snippet &&
Math.abs(d.line - f.line) <= 2
);
if (!isDupe) deduped.push(f);
}
// Page-level analyzers only run on full pages
if (isFullPage(content)) {
const analyzerIds = [
'single-font',
'flat-type-hierarchy',
'monotonous-spacing',
'em-dash-overuse',
'marketing-buzzword',
'numbered-section-markers',
'aphoristic-cadence',
'dark-glow',
];
for (let i = 0; i < REGEX_ANALYZERS.length; i++) {
const analyzer = REGEX_ANALYZERS[i];
deduped.push(...profileFindings(profile, {
engine: 'regex',
phase: 'page-analyzer',
ruleId: analyzerIds[i] || `analyzer-${i + 1}`,
target: filePath,
}, () => analyzer(content, filePath)));
}
}
return filterByProviders(deduped, options?.providers);
}
export {
REGEX_MATCHERS,
REGEX_ANALYZERS,
TEXT_CONTENT_ANALYZER_IDS,
extractStyleBlocks,
extractCSSinJS,
runRegexMatchers,
runTextContentAnalyzers,
detectText,
};
@@ -0,0 +1,986 @@
import fs from 'node:fs';
import path from 'node:path';
import { profileStep, recordProfileEvent } from '../../profile/profiler.mjs';
import { parseAnyColor, resolveLengthPx, resolveVarRefs } from '../../rules/checks.mjs';
// ---------------------------------------------------------------------------
// jsdom CSS-variable border override map
// ---------------------------------------------------------------------------
//
// jsdom's CSSOM silently drops any border shorthand that contains a var()
// reference — the computed style for the element then shows empty width,
// empty style, and a default black color. That's enough to hide the most
// common real-world side-tab pattern in AI-generated pages:
//
// :root { --brand: #87a8ff; }
// .card { border-left: 5px solid var(--brand); border-radius: 4px; }
//
// Real browsers (and therefore the browser detector path) resolve var()
// natively, so this only affects the Node jsdom path.
//
// This pre-pass walks the stylesheets, finds any rule whose per-side or
// all-sides border property contains var(), resolves the var() against
// :root-level custom properties (read from the documentElement's computed
// style, which jsdom DOES handle correctly), and attaches the resolved
// width+color to every element that matches the rule's selector. The
// Node-side `checkElementBorders` adapter consumes that map as a fallback
// whenever jsdom's computed style came back empty.
//
// Limitations (intentional, to keep the pass simple):
// * Only :root-level custom properties are resolved. Scoped overrides on
// descendants are not tracked — uncommon in practice and would require
// a per-element cascade walk.
// * @media / @supports wrapped rules are ignored (jsdom often mishandles
// these anyway).
// * The fallback only fills sides that jsdom left empty, so any rule
// whose border parses normally still wins via the computed style.
const BORDER_SHORTHAND_RE = /^(\d+(?:\.\d+)?)px\s+(solid|dashed|dotted|double|groove|ridge|inset|outset)\s+(.+)$/i;
// isNeutralColor only understands rgba()/oklch()/lch()/lab()/hsl()/hwb().
// CSS variables typically hold hex or named colors, so normalize those to
// rgb() before handing the value off to the shared check. Anything we don't
// recognise is passed through unchanged — isNeutralColor then treats it as
// non-neutral, which is the safer default (matches the oklch-era bugfix).
const NAMED_COLORS = {
white: [255, 255, 255], black: [0, 0, 0], gray: [128, 128, 128],
grey: [128, 128, 128], silver: [192, 192, 192], red: [255, 0, 0],
green: [0, 128, 0], blue: [0, 0, 255], yellow: [255, 255, 0],
};
function normalizeColorForCheck(value) {
if (!value) return value;
const v = value.trim();
const hex6 = v.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
if (hex6) {
const [r, g, b] = [parseInt(hex6[1], 16), parseInt(hex6[2], 16), parseInt(hex6[3], 16)];
return `rgb(${r}, ${g}, ${b})`;
}
const hex3 = v.match(/^#([0-9a-f])([0-9a-f])([0-9a-f])$/i);
if (hex3) {
const [r, g, b] = [
parseInt(hex3[1] + hex3[1], 16),
parseInt(hex3[2] + hex3[2], 16),
parseInt(hex3[3] + hex3[3], 16),
];
return `rgb(${r}, ${g}, ${b})`;
}
const named = NAMED_COLORS[v.toLowerCase()];
if (named) return `rgb(${named[0]}, ${named[1]}, ${named[2]})`;
return v;
}
function buildBorderOverrideMap(document, window) {
const map = new Map();
const rootStyle = window.getComputedStyle(document.documentElement);
function resolveVar(value, depth = 0) {
if (!value || depth > 10 || !value.includes('var(')) return value;
return value.replace(
/var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\s*\)/g,
(_, name, fallback) => {
const v = rootStyle.getPropertyValue(name).trim();
if (v) return resolveVar(v, depth + 1);
if (fallback) return resolveVar(fallback.trim(), depth + 1);
return '';
}
);
}
function parseShorthand(text) {
const m = text.trim().match(BORDER_SHORTHAND_RE);
if (!m) return null;
return { width: parseFloat(m[1]), color: normalizeColorForCheck(m[3]) };
}
// Read from the per-property accessors on rule.style. jsdom preserves
// each border-* shorthand it parsed, even when the overall cssText has
// been truncated (e.g. a `border: 1px solid var(...)` followed by a
// `border-left: ...` loses the first declaration but keeps the second).
const SIDE_PROPS = [
['borderLeft', 'Left'],
['borderRight', 'Right'],
['borderTop', 'Top'],
['borderBottom', 'Bottom'],
['borderInlineStart', 'Left'],
['borderInlineEnd', 'Right'],
];
for (const sheet of document.styleSheets) {
let rules;
try { rules = sheet.cssRules || []; } catch { continue; }
for (const rule of rules) {
// CSSStyleRule only; skip @media / @keyframes / @supports wrappers.
if (rule.type !== 1 || !rule.style || !rule.selectorText) continue;
const perSide = {};
for (const [prop, side] of SIDE_PROPS) {
const val = rule.style[prop];
if (!val || !val.includes('var(')) continue;
const parsed = parseShorthand(resolveVar(val));
if (parsed && parsed.color) perSide[side] = parsed;
}
// Uniform `border: <w> <style> var(...)` applies to every side the
// per-side map didn't already claim.
const borderAll = rule.style.border;
if (borderAll && borderAll.includes('var(')) {
const parsed = parseShorthand(resolveVar(borderAll));
if (parsed && parsed.color) {
for (const s of ['Top', 'Right', 'Bottom', 'Left']) {
if (!perSide[s]) perSide[s] = parsed;
}
}
}
// Longhand `border-*-color: var(...)` with width/style in separate
// declarations. Rare in AI-generated pages, but cheap to cover.
for (const [prop, side] of [
['borderLeftColor', 'Left'],
['borderRightColor', 'Right'],
['borderTopColor', 'Top'],
['borderBottomColor', 'Bottom'],
]) {
const val = rule.style[prop];
if (!val || !val.includes('var(')) continue;
const resolved = resolveVar(val).trim();
if (!resolved) continue;
// Width may or may not come from this rule — that's fine; the
// adapter only substitutes the color when jsdom left it as a
// literal var() string.
if (!perSide[side]) perSide[side] = { width: 0, color: normalizeColorForCheck(resolved) };
}
if (Object.keys(perSide).length === 0) continue;
let matched;
try { matched = document.querySelectorAll(rule.selectorText); }
catch { continue; }
for (const el of matched) {
const existing = map.get(el);
if (existing) {
// Later rules overwrite earlier ones — approximates source-order
// cascade for equal-specificity rules and is good enough for the
// uncontested var()-dropped sides we're trying to recover.
Object.assign(existing, perSide);
} else {
map.set(el, { ...perSide });
}
}
}
}
return map;
}
// Strip `@layer NAME { … }` wrappers from a CSS / HTML source, leaving
// the inner rules as flat CSS. jsdom doesn't implement CSS @layer, so
// any rule inside a layer block becomes invisible to getComputedStyle.
// Tailwind v4 makes this ubiquitous: every utility class lives in
// `@layer utilities`, and Preflight lives in `@layer base`. Without
// unwrapping, every Tailwind-styled element returns empty computed
// styles. We walk the source character-by-character, balancing braces
// so we correctly handle nested style rules inside the layer block.
function unwrapCssAtLayer(source) {
if (!source || !source.includes('@layer')) return source;
// Find `@layer <name>? {` openers. The match starts at the @, and
// we then balance braces from the opening { onward.
const re = /@layer\b[^{;]*\{/g;
let out = '';
let lastIdx = 0;
let m;
while ((m = re.exec(source)) !== null) {
const openStart = m.index;
const openEnd = m.index + m[0].length; // position right after `{`
let depth = 1;
let i = openEnd;
while (i < source.length && depth > 0) {
const c = source.charCodeAt(i);
if (c === 0x7b /* { */) depth++;
else if (c === 0x7d /* } */) depth--;
i++;
}
if (depth !== 0) {
// Unbalanced — bail and return source unchanged.
return source;
}
// Emit everything before the @layer, then the inner contents
// (between the opening { and the matched closing }), then advance.
out += source.slice(lastIdx, openStart);
out += source.slice(openEnd, i - 1); // i-1 = position of the closing }
lastIdx = i;
re.lastIndex = i;
}
out += source.slice(lastIdx);
return out;
}
// ---------------------------------------------------------------------------
// Static HTML/CSS detection (default for local HTML files)
// ---------------------------------------------------------------------------
const STATIC_INHERITED_PROPS = new Set([
'color', 'fontFamily', 'fontSize', 'fontStyle', 'fontWeight',
'lineHeight', 'letterSpacing', 'textTransform', 'textAlign', 'hyphens',
'webkitHyphens',
]);
const STATIC_DEFAULT_STYLE = {
color: 'rgb(0, 0, 0)',
backgroundColor: 'rgba(0, 0, 0, 0)',
backgroundImage: 'none',
borderTopWidth: '0px',
borderRightWidth: '0px',
borderBottomWidth: '0px',
borderLeftWidth: '0px',
borderTopColor: 'rgb(0, 0, 0)',
borderRightColor: 'rgb(0, 0, 0)',
borderBottomColor: 'rgb(0, 0, 0)',
borderLeftColor: 'rgb(0, 0, 0)',
borderRadius: '0px',
outlineWidth: '0px',
outlineColor: 'rgb(0, 0, 0)',
outlineStyle: 'none',
boxShadow: 'none',
fontFamily: '',
fontSize: '16px',
fontStyle: 'normal',
fontWeight: '400',
lineHeight: 'normal',
letterSpacing: 'normal',
textTransform: 'none',
textAlign: 'start',
hyphens: 'manual',
webkitHyphens: 'manual',
transitionProperty: '',
transitionTimingFunction: '',
animationName: '',
animationTimingFunction: '',
webkitBackgroundClip: '',
backgroundClip: '',
width: '',
height: '',
paddingTop: '0px',
paddingRight: '0px',
paddingBottom: '0px',
paddingLeft: '0px',
position: 'static',
display: '',
overflow: 'visible',
overflowX: 'visible',
overflowY: 'visible',
};
const STATIC_PROP_MAP = {
'background-color': 'backgroundColor',
'background-image': 'backgroundImage',
'background-clip': 'backgroundClip',
'-webkit-background-clip': 'webkitBackgroundClip',
'border-radius': 'borderRadius',
'border-top-width': 'borderTopWidth',
'border-right-width': 'borderRightWidth',
'border-bottom-width': 'borderBottomWidth',
'border-left-width': 'borderLeftWidth',
'border-top-color': 'borderTopColor',
'border-right-color': 'borderRightColor',
'border-bottom-color': 'borderBottomColor',
'border-left-color': 'borderLeftColor',
'outline-width': 'outlineWidth',
'outline-color': 'outlineColor',
'outline-style': 'outlineStyle',
'box-shadow': 'boxShadow',
'font-family': 'fontFamily',
'font-size': 'fontSize',
'font-style': 'fontStyle',
'font-weight': 'fontWeight',
'line-height': 'lineHeight',
'letter-spacing': 'letterSpacing',
'text-transform': 'textTransform',
'text-align': 'textAlign',
'hyphens': 'hyphens',
'-webkit-hyphens': 'webkitHyphens',
'transition-property': 'transitionProperty',
'transition-timing-function': 'transitionTimingFunction',
'animation-name': 'animationName',
'animation-timing-function': 'animationTimingFunction',
'width': 'width',
'height': 'height',
'padding-top': 'paddingTop',
'padding-right': 'paddingRight',
'padding-bottom': 'paddingBottom',
'padding-left': 'paddingLeft',
'position': 'position',
'display': 'display',
'overflow': 'overflow',
'overflow-x': 'overflowX',
'overflow-y': 'overflowY',
};
const STATIC_NAMED_COLORS = {
black: { r: 0, g: 0, b: 0, a: 1 },
white: { r: 255, g: 255, b: 255, a: 1 },
transparent: { r: 0, g: 0, b: 0, a: 0 },
gray: { r: 128, g: 128, b: 128, a: 1 },
grey: { r: 128, g: 128, b: 128, a: 1 },
silver: { r: 192, g: 192, b: 192, a: 1 },
red: { r: 255, g: 0, b: 0, a: 1 },
green: { r: 0, g: 128, b: 0, a: 1 },
blue: { r: 0, g: 0, b: 255, a: 1 },
};
function splitCssList(value) {
const parts = [];
let depth = 0, quote = '', start = 0;
for (let i = 0; i < value.length; i++) {
const ch = value[i];
if (quote) {
if (ch === quote && value[i - 1] !== '\\') quote = '';
continue;
}
if (ch === '"' || ch === "'") { quote = ch; continue; }
if (ch === '(' || ch === '[') depth++;
else if (ch === ')' || ch === ']') depth = Math.max(0, depth - 1);
else if (ch === ',' && depth === 0) {
parts.push(value.slice(start, i).trim());
start = i + 1;
}
}
const tail = value.slice(start).trim();
if (tail) parts.push(tail);
return parts;
}
function splitCssTokens(value) {
const tokens = [];
let depth = 0, quote = '', current = '';
for (let i = 0; i < value.length; i++) {
const ch = value[i];
if (quote) {
current += ch;
if (ch === quote && value[i - 1] !== '\\') quote = '';
continue;
}
if (ch === '"' || ch === "'") { quote = ch; current += ch; continue; }
if (ch === '(') { depth++; current += ch; continue; }
if (ch === ')') { depth = Math.max(0, depth - 1); current += ch; continue; }
if (/\s/.test(ch) && depth === 0) {
if (current) { tokens.push(current); current = ''; }
continue;
}
current += ch;
}
if (current) tokens.push(current);
return tokens;
}
function cssPropToCamel(prop) {
if (!prop) return prop;
const mapped = STATIC_PROP_MAP[prop];
if (mapped) return mapped;
return prop.replace(/-([a-z])/g, (_m, ch) => ch.toUpperCase());
}
function staticColorToCss(c) {
if (!c) return '';
if (c.a != null && c.a < 1) return `rgba(${c.r}, ${c.g}, ${c.b}, ${Number(c.a.toFixed(3))})`;
return `rgb(${c.r}, ${c.g}, ${c.b})`;
}
function parseStaticColor(value) {
const parsed = parseAnyColor(value);
if (parsed) return parsed;
const named = STATIC_NAMED_COLORS[String(value || '').trim().toLowerCase()];
return named ? { ...named } : null;
}
function extractStaticColor(value) {
if (!value) return '';
const raw = String(value).trim();
if (/^var\(/i.test(raw)) return raw;
const colorLike = raw.match(/(?:rgba?\([^)]+\)|oklch\([^)]+\)|oklab\([^)]+\)|lch\([^)]+\)|lab\([^)]+\)|hsla?\([^)]+\)|hwb\([^)]+\)|#[0-9a-f]{3,8}\b|\b(?:black|white|gray|grey|silver|red|green|blue|transparent)\b)/i);
if (!colorLike) return '';
return colorLike[0];
}
function normalizeStaticCssValue(prop, value, customProps, parentStyle, currentStyle = null) {
let resolved = resolveVarRefs(String(value || '').trim(), customProps);
if (resolved === 'inherit') return parentStyle?.[prop] || STATIC_DEFAULT_STYLE[prop] || '';
const isModernBorderColor = /^border[A-Z][a-z]+Color$/.test(prop) && /^(?:oklch|oklab|lch|lab|hsl|hwb)\(/i.test(resolved);
if (!isModernBorderColor && (/color$/i.test(prop) || prop === 'color' || prop === 'backgroundColor')) {
const parsed = parseStaticColor(resolved);
if (parsed) resolved = staticColorToCss(parsed);
}
if (prop === 'fontSize') {
const base = parseFloat(parentStyle?.fontSize) || 16;
const px = resolveLengthPx(resolved, base);
if (px != null) resolved = `${px}px`;
}
if (prop === 'letterSpacing') {
const base = parseFloat(currentStyle?.fontSize || parentStyle?.fontSize) || 16;
const px = resolveLengthPx(resolved, base);
if (px != null) resolved = `${px}px`;
}
if (prop === 'lineHeight' && resolved !== 'normal') {
const base = parseFloat(currentStyle?.fontSize || parentStyle?.fontSize) || 16;
const px = resolveLengthPx(resolved, base);
if (px != null) resolved = `${px}px`;
}
return resolved;
}
function expandStaticBoxValues(tokens) {
if (tokens.length === 0) return ['0px', '0px', '0px', '0px'];
if (tokens.length === 1) return [tokens[0], tokens[0], tokens[0], tokens[0]];
if (tokens.length === 2) return [tokens[0], tokens[1], tokens[0], tokens[1]];
if (tokens.length === 3) return [tokens[0], tokens[1], tokens[2], tokens[1]];
return [tokens[0], tokens[1], tokens[2], tokens[3]];
}
function parseStaticBorder(value) {
const tokens = splitCssTokens(value);
let width = '', color = '';
for (const token of tokens) {
if (!width && /^-?[\d.]+(?:px|rem|em|%)$/.test(token)) width = token;
if (!color) color = extractStaticColor(token);
}
return { width, color };
}
function parseStaticFont(value) {
const out = [];
const slashParts = value.match(/(?:^|\s)([\d.]+(?:px|rem|em|%))(?:\/([^\s]+))?/);
if (/\bitalic\b/i.test(value)) out.push(['fontStyle', 'italic']);
const weight = value.match(/\b([1-9]00|bold|normal|lighter|bolder)\b/i);
if (weight) out.push(['fontWeight', weight[1]]);
if (slashParts) {
out.push(['fontSize', slashParts[1]]);
if (slashParts[2]) out.push(['lineHeight', slashParts[2]]);
const familyStart = value.indexOf(slashParts[0]) + slashParts[0].length;
const family = value.slice(familyStart).trim();
if (family) out.push(['fontFamily', family]);
}
return out;
}
function parseStaticTransition(value) {
const props = [];
const timings = [];
for (const item of splitCssList(value)) {
const tokens = splitCssTokens(item);
const timing = tokens.find(token => /^(?:ease|linear|step-|cubic-bezier\()/i.test(token));
if (timing) timings.push(timing);
const prop = tokens.find(token => /^[a-z-]+$/i.test(token) && !/^(?:ease|linear|infinite|alternate|forwards|backwards|both|normal|none)$/.test(token) && !/s$/.test(token));
if (prop) props.push(prop);
}
return {
property: props.join(', '),
timing: timings.join(', '),
};
}
function parseStaticAnimation(value) {
const names = [];
const timings = [];
for (const item of splitCssList(value)) {
const tokens = splitCssTokens(item);
const timing = tokens.find(token => /^(?:ease|linear|step-|cubic-bezier\()/i.test(token));
if (timing) timings.push(timing);
const name = tokens.find(token =>
/^[a-z_-][\w-]*$/i.test(token) &&
!/^(?:ease|linear|infinite|alternate|forwards|backwards|both|normal|none|running|paused)$/.test(token)
);
if (name) names.push(name);
}
return {
name: names.join(', '),
timing: timings.join(', '),
};
}
function expandStaticDeclaration(prop, value) {
const p = prop.toLowerCase();
const v = String(value || '').trim();
if (!v) return [];
if (p.startsWith('--')) return [[p, v]];
if (p === 'background') {
const out = [];
const hasImage = /gradient|url\(/i.test(v);
if (hasImage) out.push(['backgroundImage', v]);
const beforeImage = hasImage ? v.split(/(?:repeating-)?(?:linear|radial|conic)-gradient\(|url\(/i)[0] : v;
const color = extractStaticColor(hasImage ? beforeImage : v);
if (color) out.push(['backgroundColor', color]);
return out;
}
if (p === 'border') {
const parsed = parseStaticBorder(v);
const out = [];
for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
if (parsed.width) out.push([`border${side}Width`, parsed.width]);
if (parsed.color) out.push([`border${side}Color`, parsed.color]);
}
return out;
}
if (p === 'outline') {
// `outline` shorthand: width | style | color, in any order. Reuse the
// border parser for width + color, then sniff a style keyword from the
// tokens (solid|dashed|...). `outline: 0` (single-token zero) zeros
// the width and effectively hides the outline.
const tokens = splitCssTokens(v);
const parsed = parseStaticBorder(v);
const styleToken = tokens.find(t =>
/^(none|hidden|solid|dashed|dotted|double|groove|ridge|inset|outset)$/i.test(t)
);
const out = [];
if (parsed.width) out.push(['outlineWidth', parsed.width]);
if (parsed.color) out.push(['outlineColor', parsed.color]);
if (styleToken) out.push(['outlineStyle', styleToken.toLowerCase()]);
// `outline: 0` with no other tokens: explicit zero width.
if (!parsed.width && /^0(?:px|rem|em|%)?$/.test(v.trim())) {
out.push(['outlineWidth', '0px']);
}
return out;
}
const sideMatch = p.match(/^border-(top|right|bottom|left)$/);
if (sideMatch) {
const parsed = parseStaticBorder(v);
const side = sideMatch[1][0].toUpperCase() + sideMatch[1].slice(1);
return [
...(parsed.width ? [[`border${side}Width`, parsed.width]] : []),
...(parsed.color ? [[`border${side}Color`, parsed.color]] : []),
];
}
if (p === 'border-width') {
const vals = expandStaticBoxValues(splitCssTokens(v));
return [
['borderTopWidth', vals[0]],
['borderRightWidth', vals[1]],
['borderBottomWidth', vals[2]],
['borderLeftWidth', vals[3]],
];
}
if (p === 'border-color') {
const vals = expandStaticBoxValues(splitCssTokens(v));
return [
['borderTopColor', vals[0]],
['borderRightColor', vals[1]],
['borderBottomColor', vals[2]],
['borderLeftColor', vals[3]],
];
}
if (p === 'padding') {
const vals = expandStaticBoxValues(splitCssTokens(v));
return [
['paddingTop', vals[0]],
['paddingRight', vals[1]],
['paddingBottom', vals[2]],
['paddingLeft', vals[3]],
];
}
if (p === 'font') return parseStaticFont(v);
if (p === 'transition') {
const parsed = parseStaticTransition(v);
return [
...(parsed.property ? [['transitionProperty', parsed.property]] : []),
...(parsed.timing ? [['transitionTimingFunction', parsed.timing]] : []),
];
}
if (p === 'animation') {
const parsed = parseStaticAnimation(v);
return [
...(parsed.name ? [['animationName', parsed.name]] : []),
...(parsed.timing ? [['animationTimingFunction', parsed.timing]] : []),
];
}
const mapped = cssPropToCamel(p);
if (STATIC_DEFAULT_STYLE[mapped] != null || STATIC_INHERITED_PROPS.has(mapped)) {
return [[mapped, v]];
}
return [];
}
function compareStaticPriority(a, b) {
if (!a) return true;
if (!!b.important !== !!a.important) return !!b.important;
if (!!b.inline !== !!a.inline) return !!b.inline;
for (let i = 0; i < 3; i++) {
if ((b.specificity[i] || 0) !== (a.specificity[i] || 0)) {
return (b.specificity[i] || 0) > (a.specificity[i] || 0);
}
}
return b.order >= a.order;
}
function staticSpecificity(selector) {
const noWhere = selector.replace(/:where\([^)]*\)/g, '');
const ids = (noWhere.match(/#[\w-]+/g) || []).length;
const classes = (noWhere.match(/\.[\w-]+|\[[^\]]+\]|:(?!:)[\w-]+(?:\([^)]*\))?/g) || []).length;
const stripped = noWhere
.replace(/#[\w-]+/g, ' ')
.replace(/\.[\w-]+|\[[^\]]+\]|:{1,2}[\w-]+(?:\([^)]*\))?/g, ' ')
.replace(/[*>+~(),]/g, ' ');
const types = (stripped.match(/\b[a-zA-Z][\w-]*\b/g) || []).length;
return [ids, classes, types];
}
function applyStaticDeclaration(specified, node, prop, value, meta) {
let map = specified.get(node);
if (!map) { map = new Map(); specified.set(node, map); }
for (const [expandedProp, expandedValue] of expandStaticDeclaration(prop, value)) {
const existing = map.get(expandedProp);
const next = { ...meta, prop: expandedProp, value: expandedValue };
if (compareStaticPriority(existing, next)) map.set(expandedProp, next);
}
}
function parseStaticStyleAttribute(styleText, orderBase = 0) {
const decls = [];
for (const part of String(styleText || '').split(';')) {
const idx = part.indexOf(':');
if (idx <= 0) continue;
const prop = part.slice(0, idx).trim();
let value = part.slice(idx + 1).trim();
const important = /!important\s*$/i.test(value);
value = value.replace(/\s*!important\s*$/i, '').trim();
decls.push({ prop, value, important, order: orderBase + decls.length });
}
return decls;
}
function collectStaticCssRules(cssText, csstree) {
const rules = [];
let ast;
try {
ast = csstree.parse(cssText, { positions: false, parseValue: true, parseCustomProperty: false });
} catch {
return rules;
}
let order = 0;
const walkList = (list, atRuleStack = []) => {
list?.forEach?.(node => {
if (node.type === 'Rule' && node.block) {
if (atRuleStack.some(name => /keyframes$/i.test(name))) return;
const selectorText = csstree.generate(node.prelude).trim();
const declarations = [];
node.block.children?.forEach?.(child => {
if (child.type !== 'Declaration') return;
declarations.push({
prop: child.property,
value: csstree.generate(child.value).trim(),
important: !!child.important,
});
});
for (const selector of splitCssList(selectorText)) {
if (selector) rules.push({ selector, declarations, specificity: staticSpecificity(selector), order: order++ });
}
return;
}
if (node.type === 'Atrule' && node.block) {
const name = String(node.name || '').toLowerCase();
if (name === 'media' || name === 'supports' || name === 'layer') {
walkList(node.block.children, [...atRuleStack, name]);
}
}
});
};
walkList(ast.children);
return rules;
}
class StaticElement {
constructor(node, doc) {
this.node = node;
this._doc = doc;
this.nodeType = 1;
this.tagName = String(node.name || '').toUpperCase();
this.nodeName = this.tagName;
}
get parentElement() {
let cur = this.node.parent;
while (cur && cur.type !== 'tag') cur = cur.parent;
return cur ? this._doc.wrap(cur) : null;
}
get previousElementSibling() {
let cur = this.node.prev;
while (cur && cur.type !== 'tag') cur = cur.prev;
return cur ? this._doc.wrap(cur) : null;
}
get children() {
return (this.node.children || []).filter(child => child.type === 'tag').map(child => this._doc.wrap(child));
}
get childNodes() {
return (this.node.children || []).map(child => {
if (child.type === 'text') return { nodeType: 3, textContent: child.data || '' };
if (child.type === 'tag') return this._doc.wrap(child);
return { nodeType: 8, textContent: child.data || '' };
});
}
get textContent() {
return this._doc.domutils.textContent(this.node);
}
get className() {
return this.getAttribute('class') || '';
}
get id() {
return this.getAttribute('id') || '';
}
getAttribute(name) {
return this.node.attribs?.[name] ?? null;
}
querySelector(selector) {
try {
const found = this._doc.selectOne(selector, this.node.children || []);
return found ? this._doc.wrap(found) : null;
} catch {
return null;
}
}
querySelectorAll(selector) {
try {
return this._doc.selectAll(selector, this.node.children || []).map(node => this._doc.wrap(node));
} catch {
return [];
}
}
closest(selector) {
let cur = this.node;
while (cur && cur.type === 'tag') {
try {
if (this._doc.is(cur, selector)) return this._doc.wrap(cur);
} catch {
return null;
}
cur = cur.parent;
while (cur && cur.type !== 'tag') cur = cur.parent;
}
return null;
}
contains(other) {
let cur = other?.node || null;
while (cur) {
if (cur === this.node) return true;
cur = cur.parent;
}
return false;
}
}
class StaticDocument {
constructor(root, modules) {
this.root = root;
this.selectAll = modules.selectAll;
this.selectOne = modules.selectOne;
this.is = modules.is;
this.domutils = modules.domutils;
this._wrappers = new WeakMap();
this._styleMap = new WeakMap();
}
wrap(node) {
let wrapped = this._wrappers.get(node);
if (!wrapped) {
wrapped = new StaticElement(node, this);
this._wrappers.set(node, wrapped);
}
return wrapped;
}
querySelectorAll(selector) {
try {
return this.selectAll(selector, this.root.children || []).map(node => this.wrap(node));
} catch {
return [];
}
}
querySelector(selector) {
try {
const found = this.selectOne(selector, this.root.children || []);
return found ? this.wrap(found) : null;
} catch {
return null;
}
}
get documentElement() {
return this.querySelector('html');
}
get body() {
return this.querySelector('body');
}
setStyle(node, style) {
this._styleMap.set(node, style);
}
getStyle(el) {
return this._styleMap.get(el.node) || makeStaticStyle();
}
}
function makeStaticStyle(values = {}) {
const style = { ...STATIC_DEFAULT_STYLE, ...values };
style.getPropertyValue = (prop) => {
const key = cssPropToCamel(prop);
return style[key] || style[prop] || '';
};
return style;
}
function buildStaticWindow(staticDoc) {
return {
document: staticDoc,
getComputedStyle: (el) => staticDoc.getStyle(el),
};
}
function collectStaticCssText(root, fileDir, profile, filePath, modules) {
const styleTexts = [];
for (const styleEl of modules.selectAll('style', root.children || [])) {
styleTexts.push(modules.domutils.textContent(styleEl));
}
const links = modules.selectAll('link', root.children || []);
for (const link of links) {
const rel = link.attribs?.rel || '';
const href = link.attribs?.href || '';
if (!/\bstylesheet\b/i.test(rel) || !href || /^(https?:)?\/\//i.test(href)) continue;
const cssPath = path.resolve(fileDir, href);
try {
const css = profileStep(profile, {
engine: 'static-html',
phase: 'preprocess',
ruleId: 'inline-linked-stylesheet',
target: filePath,
detail: href,
}, () => fs.readFileSync(cssPath, 'utf-8'));
styleTexts.push(css);
} catch { /* skip unreadable */ }
}
return styleTexts.join('\n');
}
function buildStaticStyleMap(root, staticDoc, cssText, modules, profile, filePath) {
const specified = new Map();
const allNodes = modules.selectAll('*', root.children || []);
const rules = profileStep(profile, {
engine: 'static-html',
phase: 'parse-css',
ruleId: 'css-rules',
target: filePath,
}, () => collectStaticCssRules(cssText, modules.csstree));
profileStep(profile, {
engine: 'static-html',
phase: 'selector-match',
ruleId: 'css-selectors',
target: filePath,
}, () => {
for (const rule of rules) {
let matched;
try {
matched = modules.selectAll(rule.selector, root.children || []);
} catch {
recordProfileEvent(profile, {
engine: 'static-html',
phase: 'selector-match',
ruleId: 'unsupported-selector',
target: filePath,
ms: 0,
findings: 0,
detail: rule.selector,
});
continue;
}
for (const node of matched) {
for (const decl of rule.declarations) {
applyStaticDeclaration(specified, node, decl.prop, decl.value, {
important: decl.important,
specificity: rule.specificity,
order: rule.order,
inline: false,
});
}
}
}
let inlineOrder = rules.length + 1;
for (const node of allNodes) {
const styleText = node.attribs?.style;
if (!styleText) continue;
for (const decl of parseStaticStyleAttribute(styleText, inlineOrder)) {
applyStaticDeclaration(specified, node, decl.prop, decl.value, {
important: decl.important,
specificity: [1, 0, 0],
order: decl.order,
inline: true,
});
}
inlineOrder += 1000;
}
});
const computeNode = (node, parentStyle = null, parentCustom = new Map()) => {
const specifiedMap = specified.get(node) || new Map();
const customProps = new Map(parentCustom);
for (const [prop, decl] of specifiedMap) {
if (prop.startsWith('--')) customProps.set(prop, resolveVarRefs(decl.value, customProps));
}
const values = {};
for (const prop of Object.keys(STATIC_DEFAULT_STYLE)) {
if (STATIC_INHERITED_PROPS.has(prop) && parentStyle?.[prop] != null) values[prop] = parentStyle[prop];
else values[prop] = STATIC_DEFAULT_STYLE[prop];
}
for (const [prop, decl] of specifiedMap) {
if (prop.startsWith('--')) continue;
values[prop] = normalizeStaticCssValue(prop, decl.value, customProps, parentStyle, values);
}
const style = makeStaticStyle(values);
staticDoc.setStyle(node, style);
for (const child of node.children || []) {
if (child.type === 'tag') computeNode(child, style, customProps);
}
};
profileStep(profile, {
engine: 'static-html',
phase: 'cascade',
ruleId: 'compute-styles',
target: filePath,
}, () => {
for (const child of root.children || []) {
if (child.type === 'tag') computeNode(child);
}
});
}
export {
BORDER_SHORTHAND_RE,
NAMED_COLORS,
normalizeColorForCheck,
buildBorderOverrideMap,
unwrapCssAtLayer,
STATIC_INHERITED_PROPS,
STATIC_DEFAULT_STYLE,
STATIC_PROP_MAP,
STATIC_NAMED_COLORS,
splitCssList,
splitCssTokens,
cssPropToCamel,
staticColorToCss,
parseStaticColor,
extractStaticColor,
normalizeStaticCssValue,
expandStaticBoxValues,
parseStaticBorder,
parseStaticFont,
parseStaticTransition,
parseStaticAnimation,
expandStaticDeclaration,
compareStaticPriority,
staticSpecificity,
applyStaticDeclaration,
parseStaticStyleAttribute,
collectStaticCssRules,
StaticElement,
StaticDocument,
makeStaticStyle,
buildStaticWindow,
collectStaticCssText,
buildStaticStyleMap,
};
@@ -0,0 +1,208 @@
import fs from 'node:fs';
import path from 'node:path';
import { GENERIC_FONTS, OVERUSED_FONTS } from '../../shared/constants.mjs';
import { isFullPage } from '../../shared/page.mjs';
import { finding } from '../../findings.mjs';
import { profileFindings, profileStep, profileStepAsync } from '../../profile/profiler.mjs';
import {
checkElementBorders,
checkElementClippedOverflow,
checkElementColors,
checkElementGlow,
checkElementGptBorderShadow,
checkElementHeroEyebrow,
checkElementIconTile,
checkElementItalicSerif,
checkElementMotion,
checkElementOversizedH1,
checkElementQuality,
checkCreamPalette,
checkHtmlPatterns,
checkPageLayout,
checkPageQualityFromDoc,
checkRepeatedSectionKickersFromDoc,
resolveBackground,
resolveBorderRadiusPx,
} from '../../rules/checks.mjs';
import { filterByProviders } from '../../registry/antipatterns.mjs';
import { detectText, runTextContentAnalyzers } from '../regex/detect-text.mjs';
import {
StaticDocument,
buildStaticStyleMap,
buildStaticWindow,
collectStaticCssText,
} from './css-cascade.mjs';
function checkStaticPageTypography(document, window) {
const findings = [];
const fonts = new Set();
const overusedFound = new Set();
for (const el of document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, td, th, dd, blockquote, figcaption, a, button, label, span, div')) {
const hasText = el.childNodes.some(n => n.nodeType === 3 && n.textContent.trim().length > 0);
if (!hasText) continue;
const ff = window.getComputedStyle(el).fontFamily || '';
const stack = ff.split(',').map(f => f.trim().replace(/^['"]|['"]$/g, '').toLowerCase());
const primary = stack.find(f => f && !GENERIC_FONTS.has(f));
if (!primary) continue;
fonts.add(primary);
if (OVERUSED_FONTS.has(primary)) overusedFound.add(primary);
}
for (const font of overusedFound) {
findings.push({ id: 'overused-font', snippet: `Primary font: ${font}` });
}
if (fonts.size === 1 && document.querySelectorAll('*').length >= 20) {
findings.push({ id: 'single-font', snippet: `only font used is ${[...fonts][0]}` });
}
const sizes = new Set();
for (const el of document.querySelectorAll('h1, h2, h3, h4, h5, h6, p, span, a, li, td, th, label, button, div')) {
const fontSize = parseFloat(window.getComputedStyle(el).fontSize);
if (fontSize >= 8 && fontSize < 200) sizes.add(Math.round(fontSize * 10) / 10);
}
if (sizes.size >= 3) {
const sorted = [...sizes].sort((a, b) => a - b);
const ratio = sorted[sorted.length - 1] / sorted[0];
if (ratio < 2.0) {
findings.push({ id: 'flat-type-hierarchy', snippet: `Sizes: ${sorted.map(s => s + 'px').join(', ')} (ratio ${ratio.toFixed(1)}:1)` });
}
}
return findings;
}
function checkElementBrokenImage(el) {
const src = (el.getAttribute && el.getAttribute('src')) ?? el.attribs?.src;
// Missing src attribute entirely
if (src === undefined || src === null) {
return [{ id: 'broken-image', snippet: '<img> with no src attribute' }];
}
const trimmed = String(src).trim();
// Empty or placeholder-only src values
if (trimmed === '' || trimmed === '#') {
return [{ id: 'broken-image', snippet: `<img src="${src}">` }];
}
return [];
}
const STATIC_ELEMENT_RULES = [
{ id: 'border-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementBorders(tag, style, null, resolveBorderRadiusPx(el, style, parseFloat(style.width) || 0, window)) },
{ id: 'color-rules', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementColors(el, style, tag, window, customPropMap, false) },
{ id: 'dark-glow', selector: '*', run: (el, tag, style, window, customPropMap) => checkElementGlow(tag, style, resolveBackground(el.parentElement || el, window, customPropMap)) },
{ id: 'motion-rules', selector: '*', run: (el, tag, style) => checkElementMotion(tag, style) },
{ id: 'icon-tile-stack', selector: 'h1,h2,h3,h4,h5,h6', run: (el, tag, _style, window) => checkElementIconTile(el, tag, window) },
{ id: 'italic-serif-display', selector: 'h1,h2', run: (el, tag, style) => checkElementItalicSerif(el, style, tag) },
{ id: 'hero-eyebrow-chip', selector: 'h1', run: (el, tag, style, window, customPropMap) => checkElementHeroEyebrow(el, style, tag, window, customPropMap) },
{ id: 'broken-image', selector: 'img', run: (el) => checkElementBrokenImage(el) },
{ id: 'quality-rules', selector: '*', run: (el, tag, style, window) => checkElementQuality(el, style, tag, window) },
{ id: 'oversized-h1', selector: 'h1', run: (el, tag, style, window) => checkElementOversizedH1(el, style, tag, window) },
{ id: 'clipped-overflow-container', selector: '*', run: (el, tag, style, window) => checkElementClippedOverflow(el, style, tag, window) },
{ id: 'gpt-thin-border-wide-shadow', selector: '*', run: (el, tag, style) => checkElementGptBorderShadow(el, style) },
];
async function detectHtml(filePath, options = {}) {
const profile = options?.profile;
const html = profileStep(profile, {
engine: 'static-html',
phase: 'setup',
ruleId: 'read-html',
target: filePath,
}, () => fs.readFileSync(filePath, 'utf-8'));
let modules;
try {
modules = await profileStepAsync(profile, {
engine: 'static-html',
phase: 'setup',
ruleId: 'import-static-parser',
target: filePath,
}, async () => {
const [htmlparser2, cssSelect, csstree, domutils] = await Promise.all([
import('htmlparser2'),
import('css-select'),
import('css-tree'),
import('domutils'),
]);
return {
parseDocument: htmlparser2.parseDocument,
selectAll: cssSelect.selectAll,
selectOne: cssSelect.selectOne,
is: cssSelect.is,
csstree,
domutils,
};
});
} catch {
return detectText(html, filePath, options);
}
const resolvedPath = path.resolve(filePath);
const fileDir = path.dirname(resolvedPath);
const root = profileStep(profile, {
engine: 'static-html',
phase: 'parse-html',
ruleId: 'parse-document',
target: filePath,
}, () => modules.parseDocument(html, { lowerCaseAttributeNames: false, lowerCaseTags: true }));
const cssText = collectStaticCssText(root, fileDir, profile, filePath, modules);
const document = new StaticDocument(root, modules);
buildStaticStyleMap(root, document, cssText, modules, profile, filePath);
const window = buildStaticWindow(document);
const customPropMap = null;
const findings = [];
const runElementCheck = (ruleId, callback) => profile
? profileFindings(profile, { engine: 'static-html', phase: 'element', ruleId, target: filePath }, callback)
: callback();
const visitedByRule = new Map();
for (const rule of STATIC_ELEMENT_RULES) {
const elements = document.querySelectorAll(rule.selector);
visitedByRule.set(rule.id, elements.length);
for (const el of elements) {
const tag = el.tagName.toLowerCase();
const style = window.getComputedStyle(el);
for (const f of runElementCheck(rule.id, () => rule.run(el, tag, style, window, customPropMap))) {
findings.push(finding(f.id, filePath, f.snippet));
}
}
}
if (isFullPage(html)) {
const runPageCheck = (ruleId, callback) => profile
? profileFindings(profile, { engine: 'static-html', phase: 'page', ruleId, target: filePath }, callback)
: callback();
for (const f of runPageCheck('typography-rules', () => checkStaticPageTypography(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('repeated-section-kickers', () => checkRepeatedSectionKickersFromDoc(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('layout-rules', () => checkPageLayout(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('cream-palette', () => checkCreamPalette(document, window))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('skipped-heading', () => checkPageQualityFromDoc(document))) {
findings.push(finding(f.id, filePath, f.snippet));
}
for (const f of runPageCheck('html-patterns', () => checkHtmlPatterns(html).filter(item =>
item.id !== 'bounce-easing' && item.id !== 'layout-transition'
))) {
findings.push(finding(f.id, filePath, f.snippet));
}
// Text-content analyzers (em-dash overuse, marketing buzzwords,
// numbered section markers, aphoristic cadence) live in the regex
// engine. Call them from here so .html files get the same coverage
// as .css/.tsx files. These are scoped to text content only and
// don't overlap with static-html's element/page rules.
for (const f of runPageCheck('text-content', () => runTextContentAnalyzers(html, filePath, options))) {
findings.push(finding(f.antipattern, filePath, f.snippet));
}
}
return filterByProviders(findings, options.providers);
}
export { checkStaticPageTypography, STATIC_ELEMENT_RULES, detectHtml };
@@ -0,0 +1,189 @@
function sanitizeScreenshotClip(clip, viewport) {
if (!clip) return null;
const x = Math.max(0, Math.floor(clip.x || 0));
const y = Math.max(0, Math.floor(clip.y || 0));
const width = Math.min(
Math.max(1, Math.ceil(clip.width || 0)),
Math.max(1, viewport?.width || 1600),
);
const height = Math.min(
Math.max(1, Math.ceil(clip.height || 0)),
320,
);
if (width < 1 || height < 1) return null;
return { x, y, width, height };
}
async function compareScreenshotContrast(page, beforeBase64, afterBase64, candidate) {
return page.evaluate(async ({ beforeBase64, afterBase64, candidate }) => {
const loadImage = (base64) => new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Could not decode contrast screenshot'));
img.src = `data:image/png;base64,${base64}`;
});
const [before, after] = await Promise.all([loadImage(beforeBase64), loadImage(afterBase64)]);
const width = Math.min(before.width, after.width);
const height = Math.min(before.height, after.height);
if (width < 1 || height < 1) return null;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) return null;
ctx.drawImage(before, 0, 0, width, height);
const beforePixels = ctx.getImageData(0, 0, width, height).data;
ctx.clearRect(0, 0, width, height);
ctx.drawImage(after, 0, 0, width, height);
const afterPixels = ctx.getImageData(0, 0, width, height).data;
const luminance = ({ r, g, b }) => {
const convert = c => {
const v = c / 255;
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * convert(r) + 0.7152 * convert(g) + 0.0722 * convert(b);
};
const ratio = (a, b) => {
const l1 = luminance(a);
const l2 = luminance(b);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
};
const cssTextColor = candidate.textColor && !candidate.preferRenderedForeground
? {
r: candidate.textColor.r,
g: candidate.textColor.g,
b: candidate.textColor.b,
}
: null;
const ratios = [];
let glyphPixels = 0;
let strongestDelta = 0;
for (let i = 0; i < beforePixels.length; i += 4) {
const delta = Math.abs(beforePixels[i] - afterPixels[i])
+ Math.abs(beforePixels[i + 1] - afterPixels[i + 1])
+ Math.abs(beforePixels[i + 2] - afterPixels[i + 2])
+ Math.abs(beforePixels[i + 3] - afterPixels[i + 3]);
strongestDelta = Math.max(strongestDelta, delta);
if (delta < 10) continue;
glyphPixels++;
const fg = cssTextColor || {
r: beforePixels[i],
g: beforePixels[i + 1],
b: beforePixels[i + 2],
};
const bg = {
r: afterPixels[i],
g: afterPixels[i + 1],
b: afterPixels[i + 2],
};
ratios.push(ratio(fg, bg));
}
if (ratios.length < 8) {
return {
glyphPixels,
strongestDelta,
worstRatio: null,
p10Ratio: null,
medianRatio: null,
};
}
ratios.sort((a, b) => a - b);
const pick = pct => ratios[Math.min(ratios.length - 1, Math.max(0, Math.floor((pct / 100) * ratios.length)))];
return {
glyphPixels,
strongestDelta,
worstRatio: ratios[0],
p10Ratio: pick(10),
medianRatio: pick(50),
};
}, { beforeBase64, afterBase64, candidate });
}
async function captureVisualContrastCandidate(page, candidate, viewport) {
const clip = sanitizeScreenshotClip(candidate.clip, viewport);
if (!clip) return null;
const beforeBase64 = await page.screenshot({
encoding: 'base64',
clip,
captureBeyondViewport: true,
});
const token = `impeccable-contrast-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const applied = await page.evaluate(({ selector, token, backgroundClipText }) => {
let el;
try {
el = document.querySelector(selector);
} catch {
return false;
}
if (!el) return false;
let style = document.getElementById('impeccable-visual-contrast-hide-style');
if (!style) {
style = document.createElement('style');
style.id = 'impeccable-visual-contrast-hide-style';
style.textContent = [
'[data-impeccable-visual-contrast-target] {',
' color: transparent !important;',
' -webkit-text-fill-color: transparent !important;',
' text-shadow: none !important;',
'}',
'[data-impeccable-visual-contrast-target][data-impeccable-bgclip-text="true"] {',
' background-image: none !important;',
'}',
].join('\n');
document.head.appendChild(style);
}
el.setAttribute('data-impeccable-visual-contrast-target', token);
if (backgroundClipText) el.setAttribute('data-impeccable-bgclip-text', 'true');
return true;
}, {
selector: candidate.selector,
token,
backgroundClipText: candidate.backgroundClipText,
});
if (!applied) return null;
let afterBase64;
try {
afterBase64 = await page.screenshot({
encoding: 'base64',
clip,
captureBeyondViewport: true,
});
} finally {
await page.evaluate(({ selector }) => {
try {
const el = document.querySelector(selector);
if (el) {
el.removeAttribute('data-impeccable-visual-contrast-target');
el.removeAttribute('data-impeccable-bgclip-text');
}
} catch {
// Ignore invalid or stale selectors during cleanup.
}
}, { selector: candidate.selector }).catch(() => {});
}
const metrics = await compareScreenshotContrast(page, beforeBase64, afterBase64, candidate);
if (!metrics || !Number.isFinite(metrics.p10Ratio) || metrics.glyphPixels < 8) return null;
const measuredRatio = metrics.p10Ratio;
if (measuredRatio >= candidate.threshold) return null;
const textLabel = candidate.text ? ` "${candidate.text}"` : '';
const reasonLabel = (candidate.reasons || []).slice(0, 3).join(', ') || 'visual background';
return {
id: 'low-contrast',
snippet: `pixel contrast ${measuredRatio.toFixed(1)}:1 median ${metrics.medianRatio.toFixed(1)}:1 (need ${candidate.threshold}:1) on ${reasonLabel}${textLabel}`,
};
}
export {
sanitizeScreenshotClip,
compareScreenshotContrast,
captureVisualContrastCandidate,
};
@@ -0,0 +1,12 @@
import { getAntipattern } from './registry/antipatterns.mjs';
function getAP(id) {
return getAntipattern(id);
}
function finding(id, filePath, snippet, line = 0) {
const ap = getAP(id);
return { antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', file: filePath, line, snippet };
}
export { getAP, finding };
@@ -0,0 +1,198 @@
import fs from 'node:fs';
import path from 'node:path';
// ---------------------------------------------------------------------------
// File walker
// ---------------------------------------------------------------------------
const SKIP_DIRS = new Set([
'node_modules', '.git', 'dist', 'build', '.next', '.nuxt', '.output',
'.svelte-kit', '__pycache__', '.turbo', '.vercel',
]);
const SCANNABLE_EXTENSIONS = new Set([
'.html', '.htm', '.css', '.scss', '.less',
'.jsx', '.tsx', '.js', '.ts',
'.vue', '.svelte', '.astro',
]);
const HTML_EXTENSIONS = new Set(['.html', '.htm']);
function walkDir(dir) {
const files = [];
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
for (const entry of entries) {
if (SKIP_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);
}
return files;
}
// ---------------------------------------------------------------------------
// Import graph (multi-file awareness)
// ---------------------------------------------------------------------------
function resolveImport(specifier, fromDir, fileSet) {
if (!/^[./]/.test(specifier)) return null; // skip bare specifiers
const base = path.resolve(fromDir, specifier);
if (fileSet.has(base)) return base;
for (const ext of SCANNABLE_EXTENSIONS) {
const withExt = base + ext;
if (fileSet.has(withExt)) return withExt;
}
// index file convention
for (const ext of SCANNABLE_EXTENSIONS) {
const indexFile = path.join(base, 'index' + ext);
if (fileSet.has(indexFile)) return indexFile;
}
return null;
}
function buildImportGraph(files) {
const fileSet = new Set(files);
const graph = new Map();
for (const file of files) {
const content = fs.readFileSync(file, 'utf-8');
const dir = path.dirname(file);
const imports = new Set();
// ES imports: import ... from '...' and import '...'
const esRe = /import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g;
let m;
while ((m = esRe.exec(content)) !== null) {
const resolved = resolveImport(m[1], dir, fileSet);
if (resolved) imports.add(resolved);
}
// CSS @import
const cssRe = /@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g;
while ((m = cssRe.exec(content)) !== null) {
const resolved = resolveImport(m[1], dir, fileSet);
if (resolved) imports.add(resolved);
}
// SCSS @use / @forward
const scssRe = /@(?:use|forward)\s+['"]([^'"]+)['"]/g;
while ((m = scssRe.exec(content)) !== null) {
const resolved = resolveImport(m[1], dir, fileSet);
if (resolved) imports.add(resolved);
}
graph.set(file, imports);
}
return graph;
}
// ---------------------------------------------------------------------------
// Framework dev server detection
// ---------------------------------------------------------------------------
const FRAMEWORK_CONFIGS = [
{ name: 'Next.js', files: ['next.config.js', 'next.config.mjs', 'next.config.ts'], defaultPort: 3000,
portRe: /port\s*[:=]\s*(\d+)/,
fingerprint: { header: 'x-powered-by', value: /next/i } },
{ name: 'SvelteKit', files: ['svelte.config.js', 'svelte.config.ts'], defaultPort: 5173,
portRe: /port\s*[:=]\s*(\d+)/,
fingerprint: { header: 'x-sveltekit-page', value: null } },
{ name: 'Nuxt', files: ['nuxt.config.js', 'nuxt.config.ts'], defaultPort: 3000,
portRe: /port\s*[:=]\s*(\d+)/,
fingerprint: { header: 'x-powered-by', value: /nuxt/i } },
{ name: 'Vite', files: ['vite.config.js', 'vite.config.ts', 'vite.config.mjs'], defaultPort: 5173,
portRe: /port\s*[:=]\s*(\d+)/,
fingerprint: { body: /@vite\/client/ } },
{ name: 'Astro', files: ['astro.config.js', 'astro.config.ts', 'astro.config.mjs'], defaultPort: 4321,
portRe: /port\s*[:=]\s*(\d+)/,
fingerprint: { body: /astro/i } },
{ name: 'Angular', files: ['angular.json'], defaultPort: 4200,
portRe: /"port"\s*:\s*(\d+)/,
fingerprint: { body: /ng-version/i } },
{ name: 'Remix', files: ['remix.config.js', 'remix.config.ts'], defaultPort: 3000,
portRe: /port\s*[:=]\s*(\d+)/,
fingerprint: { header: 'x-powered-by', value: /remix/i } },
];
function detectFrameworkConfig(dir) {
let entries;
try { entries = fs.readdirSync(dir); } catch { return null; }
const entrySet = new Set(entries);
for (const cfg of FRAMEWORK_CONFIGS) {
const match = cfg.files.find(f => entrySet.has(f));
if (!match) continue;
const configPath = path.join(dir, match);
let port = cfg.defaultPort;
try {
const content = fs.readFileSync(configPath, 'utf-8');
const portMatch = content.match(cfg.portRe);
if (portMatch) port = parseInt(portMatch[1], 10);
} catch { /* use default */ }
return { name: cfg.name, port, configPath, fingerprint: cfg.fingerprint };
}
return null;
}
/**
* Check if a port is listening and optionally verify it matches the expected framework.
* Returns { listening: true, matched: true/false } or { listening: false }.
*/
async function isPortListening(port, fingerprint = null) {
if (!fingerprint) {
// Simple TCP probe fallback
const net = await import('node:net');
return new Promise((resolve) => {
const sock = net.default.createConnection({ port, host: '127.0.0.1' });
sock.setTimeout(500);
sock.on('connect', () => { sock.destroy(); resolve({ listening: true, matched: true }); });
sock.on('error', () => resolve({ listening: false }));
sock.on('timeout', () => { sock.destroy(); resolve({ listening: false }); });
});
}
// HTTP probe with fingerprint matching
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 2000);
const res = await fetch(`http://localhost:${port}/`, { signal: controller.signal, redirect: 'follow' });
clearTimeout(timeout);
// Check header fingerprint
if (fingerprint.header) {
const val = res.headers.get(fingerprint.header);
if (val && (!fingerprint.value || fingerprint.value.test(val))) {
return { listening: true, matched: true };
}
}
// Check body fingerprint
if (fingerprint.body) {
const body = await res.text();
if (fingerprint.body.test(body)) {
return { listening: true, matched: true };
}
}
// Port is listening but doesn't match the expected framework
return { listening: true, matched: false };
} catch {
return { listening: false };
}
}
export {
SKIP_DIRS,
SCANNABLE_EXTENSIONS,
HTML_EXTENSIONS,
walkDir,
resolveImport,
buildImportGraph,
FRAMEWORK_CONFIGS,
detectFrameworkConfig,
isPortListening,
};
@@ -0,0 +1,166 @@
function profileNow() {
return typeof performance !== 'undefined' && performance.now
? performance.now()
: Date.now();
}
function createDetectorProfile() {
return { events: [] };
}
function recordProfileEvent(profile, event) {
if (!profile) return;
const normalized = {
engine: event.engine || 'unknown',
phase: event.phase || 'unknown',
ruleId: event.ruleId || 'unknown',
target: event.target || '',
ms: Number.isFinite(event.ms) ? event.ms : 0,
findings: Number.isFinite(event.findings) ? event.findings : 0,
};
if (event.detail) normalized.detail = event.detail;
if (Array.isArray(event.findingIds) && event.findingIds.length) {
normalized.findingIds = event.findingIds;
}
if (typeof profile === 'function') {
profile(normalized);
} else if (typeof profile.record === 'function') {
profile.record(normalized);
} else if (Array.isArray(profile.events)) {
profile.events.push(normalized);
} else if (Array.isArray(profile)) {
profile.push(normalized);
}
}
function extractFindingIds(findings) {
if (!Array.isArray(findings) || findings.length === 0) return [];
return [...new Set(findings.map(f => f?.id || f?.type || f?.antipattern).filter(Boolean))];
}
function profileFindings(profile, meta, callback) {
if (!profile) return callback();
const started = profileNow();
const findings = callback();
recordProfileEvent(profile, {
...meta,
ms: profileNow() - started,
findings: Array.isArray(findings) ? findings.length : 0,
findingIds: extractFindingIds(findings),
});
return findings;
}
function profileStep(profile, meta, callback) {
if (!profile) return callback();
const started = profileNow();
try {
return callback();
} finally {
recordProfileEvent(profile, {
...meta,
ms: profileNow() - started,
findings: 0,
});
}
}
async function profileFindingsAsync(profile, meta, callback) {
if (!profile) return callback();
const started = profileNow();
const findings = await callback();
recordProfileEvent(profile, {
...meta,
ms: profileNow() - started,
findings: Array.isArray(findings) ? findings.length : 0,
findingIds: extractFindingIds(findings),
});
return findings;
}
async function profileStepAsync(profile, meta, callback) {
if (!profile) return callback();
const started = profileNow();
try {
return await callback();
} finally {
recordProfileEvent(profile, {
...meta,
ms: profileNow() - started,
findings: 0,
});
}
}
function percentile(sortedValues, pct) {
if (!sortedValues.length) return 0;
const idx = Math.min(
sortedValues.length - 1,
Math.max(0, Math.ceil((pct / 100) * sortedValues.length) - 1),
);
return sortedValues[idx];
}
function summarizeDetectorProfile(profile) {
const events = Array.isArray(profile)
? profile
: (Array.isArray(profile?.events) ? profile.events : []);
const groups = new Map();
for (const event of events) {
const key = [
event.engine || 'unknown',
event.phase || 'unknown',
event.ruleId || 'unknown',
event.target || '',
].join('\u0000');
let group = groups.get(key);
if (!group) {
group = {
engine: event.engine || 'unknown',
phase: event.phase || 'unknown',
ruleId: event.ruleId || 'unknown',
target: event.target || '',
calls: 0,
totalMs: 0,
findings: 0,
samples: [],
};
groups.set(key, group);
}
const ms = Number.isFinite(event.ms) ? event.ms : 0;
group.calls += 1;
group.totalMs += ms;
group.findings += Number.isFinite(event.findings) ? event.findings : 0;
group.samples.push(ms);
}
return [...groups.values()]
.map(group => {
const samples = group.samples.sort((a, b) => a - b);
return {
engine: group.engine,
phase: group.phase,
ruleId: group.ruleId,
target: group.target,
calls: group.calls,
totalMs: Number(group.totalMs.toFixed(3)),
avgMs: Number((group.totalMs / group.calls).toFixed(3)),
p50: Number(percentile(samples, 50).toFixed(3)),
p95: Number(percentile(samples, 95).toFixed(3)),
findings: group.findings,
};
})
.sort((a, b) => b.totalMs - a.totalMs);
}
export {
profileNow,
createDetectorProfile,
recordProfileEvent,
extractFindingIds,
profileFindings,
profileStep,
profileFindingsAsync,
profileStepAsync,
percentile,
summarizeDetectorProfile,
};
@@ -0,0 +1,419 @@
const ANTIPATTERNS = [
// ── AI slop: tells that something was AI-generated ──
{
id: 'side-tab',
category: 'slop',
name: 'Side-tab accent border',
description:
'Thick colored border on one side of a card — the most recognizable tell of AI-generated UIs. Use a subtler accent or remove it entirely.',
skillSection: 'Visual Details',
skillGuideline: 'colored accent stripe',
},
{
id: 'border-accent-on-rounded',
category: 'slop',
name: 'Border accent on rounded element',
description:
'Thick accent border on a rounded card — the border clashes with the rounded corners. Remove the border or the border-radius.',
skillSection: 'Visual Details',
skillGuideline: 'colored accent stripe',
},
{
id: 'overused-font',
category: 'slop',
name: 'Overused font',
description:
'Inter, Roboto, Fraunces, Geist, Plus Jakarta Sans, and Space Grotesk are used on so many sites they no longer feel distinctive. Each new wave of AI-generated UIs converges on the same handful of faces. Choose a face that gives your interface personality.',
skillSection: 'Typography',
skillGuideline: 'overused fonts like Inter',
},
{
id: 'single-font',
category: 'slop',
name: 'Single font for everything',
description:
'Only one font family is used for the entire page. Pair a distinctive display font with a refined body font to create typographic hierarchy.',
skillSection: 'Typography',
skillGuideline: 'only one font family for the entire page',
},
{
id: 'flat-type-hierarchy',
category: 'slop',
name: 'Flat type hierarchy',
description:
'Font sizes are too close together — no clear visual hierarchy. Use fewer sizes with more contrast (aim for at least a 1.25 ratio between steps).',
skillSection: 'Typography',
skillGuideline: 'flat type hierarchy',
},
{
id: 'gradient-text',
category: 'slop',
name: 'Gradient text',
description:
'Gradient text is decorative rather than meaningful — a common AI tell, especially on headings and metrics. Use solid colors for text.',
skillSection: 'Color & Contrast',
skillGuideline: 'gradient text for',
},
{
id: 'ai-color-palette',
category: 'slop',
name: 'AI color palette',
description:
'Purple/violet gradients and cyan-on-dark are the most recognizable tells of AI-generated UIs. Choose a distinctive, intentional palette.',
skillSection: 'Color & Contrast',
skillGuideline: 'AI color palette',
},
{
id: 'cream-palette',
category: 'slop',
name: 'Cream / beige palette',
description:
'A warm cream or beige page background has become the default "tasteful" AI surface, reached for by reflex. Choose a background that comes from a deliberate palette, not the safe warm off-white.',
skillSection: 'Color & Contrast',
skillGuideline: 'cream and beige as the default surface',
},
{
id: 'nested-cards',
category: 'slop',
name: 'Nested cards',
description:
'Cards inside cards create visual noise and excessive depth. Flatten the hierarchy — use spacing, typography, and dividers instead of nesting containers.',
skillSection: 'Layout & Space',
skillGuideline: 'Nest cards inside cards',
},
{
id: 'monotonous-spacing',
category: 'slop',
name: 'Monotonous spacing',
description:
'The same spacing value used everywhere — no rhythm, no variation. Use tight groupings for related items and generous separations between sections.',
skillSection: 'Layout & Space',
skillGuideline: 'same spacing everywhere',
},
{
id: 'bounce-easing',
category: 'slop',
name: 'Bounce or elastic easing',
description:
'Bounce and elastic easing feel dated and tacky. Real objects decelerate smoothly — use exponential easing (ease-out-quart/quint/expo) instead.',
skillSection: 'Motion',
skillGuideline: 'bounce or elastic easing',
},
{
id: 'dark-glow',
category: 'slop',
name: 'Dark mode with glowing accents',
description:
'Dark backgrounds with colored box-shadow glows are the default "cool" look of AI-generated UIs. Use subtle, purposeful lighting instead — or skip the dark theme entirely.',
skillSection: 'Color & Contrast',
skillGuideline: 'dark mode with glowing accents',
},
{
id: 'icon-tile-stack',
category: 'slop',
name: 'Icon tile stacked above heading',
description:
'A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.',
skillSection: 'Typography',
skillGuideline: 'large icons with rounded corners above every heading',
},
{
id: 'italic-serif-display',
category: 'slop',
name: 'Italic serif display headline',
description:
'Oversized italic serif (Fraunces, Recoleta, Playfair, Newsreader-italic) as the primary hero headline reads as taste in isolation but has become the universal AI-startup landing page hero. Set roman, or move to a non-serif display face. Editorial / magazine register may legitimately want this — judge by context.',
skillSection: 'Typography',
skillGuideline: 'oversized italic serif as the hero headline',
},
{
id: 'hero-eyebrow-chip',
category: 'slop',
name: 'Hero eyebrow / pill chip',
description:
'A tiny uppercase letter-spaced label sitting immediately above an oversized hero headline — or the same shape rendered as a pill chip — is now the default AI SaaS hero. Drop the eyebrow, integrate the kicker into the headline, or run it as a navigation breadcrumb instead.',
skillSection: 'Typography',
skillGuideline: 'tiny uppercase tracked label above the hero headline',
},
{
id: 'repeated-section-kickers',
category: 'slop',
severity: 'advisory',
name: 'Repeated section kicker labels',
description:
'Repeating tiny uppercase tracked labels above section headings turns a brand page into AI editorial scaffolding. Replace them with stronger structure, artifacts, imagery, or a deliberate brand system.',
skillSection: 'Typography',
skillGuideline: 'repeated eyebrow or kicker labels as section scaffolding',
},
{
id: 'numbered-section-markers',
category: 'slop',
severity: 'advisory',
name: 'Numbered section markers (01 / 02 / 03)',
description:
'Numbered display markers as section labels (01, 02, 03) are the AI editorial scaffold one tier deeper than tracked eyebrow chips. If you find yourself reaching for them, choose a different section cadence.',
skillSection: 'Layout & Space',
skillGuideline: 'numbered section markers',
},
{
id: 'em-dash-overuse',
category: 'slop',
name: 'Em-dash overuse',
description:
'More than two em-dashes (— or --) in body copy is an AI cadence tell. Use commas, colons, periods, or parentheses instead.',
skillSection: 'Copy',
skillGuideline: 'no em dashes',
},
{
id: 'marketing-buzzword',
category: 'slop',
name: 'Marketing buzzword',
description:
'Generic SaaS phrases (streamline / empower / supercharge / world-class / enterprise-grade / next-generation / cutting-edge / etc) are instant AI tells. Pick a specific verb and noun that says what the product literally does.',
skillSection: 'Copy',
skillGuideline: 'marketing buzzwords',
},
{
id: 'aphoristic-cadence',
category: 'slop',
name: 'Aphoristic-cadence copy',
description:
'Three or more sections landing on a short rebuttal sentence ("X. No Y." / "X. Just Y.") or a manufactured-contrast aphorism ("Not a feature. A platform.") reads as AI cadence, not voice. Once is fine; the pattern is the tell.',
skillSection: 'Copy',
skillGuideline: 'aphoristic cadence',
},
{
id: 'oversized-h1',
category: 'slop',
name: 'Oversized hero headline',
description:
'A full-sentence headline set at display size ends up dominating the viewport, leaving no room for anything else above the fold. A punchy one- or two-word headline at that size is fine — the problem is a long headline blown up too large. Set long headlines smaller, or tighten the copy.',
skillSection: 'Typography',
skillGuideline: 'long headline set at display size',
},
{
id: 'extreme-negative-tracking',
category: 'slop',
name: 'Crushed letter spacing',
description:
'Letter-spacing pulled tighter than the point where characters keep their own shapes costs legibility. Tighten display type optically, not destructively.',
skillSection: 'Typography',
skillGuideline: 'letter spacing crushed past legibility',
},
{
id: 'broken-image',
category: 'quality',
name: 'Broken or placeholder image',
description:
'<img> tags with empty src, missing src, or placeholder values ship as broken-image boxes. Use real images, generated assets, or remove the tag.',
skillSection: 'Imagery',
skillGuideline: 'broken image references',
},
// ── Quality: general design and accessibility issues ──
{
id: 'gray-on-color',
category: 'quality',
name: 'Gray text on colored background',
description:
'Gray text looks washed out on colored backgrounds. Use a darker shade of the background color instead, or white/near-white for contrast.',
skillSection: 'Color & Contrast',
skillGuideline: 'gray text on colored backgrounds',
},
{
id: 'low-contrast',
category: 'quality',
name: 'Low contrast text',
description:
'Text does not meet WCAG AA contrast requirements (4.5:1 for body, 3:1 for large text). Increase the contrast between text and background.',
},
{
id: 'layout-transition',
category: 'quality',
name: 'Layout property animation',
description:
'Animating width, height, padding, or margin causes layout thrash and janky performance. Use transform and opacity instead, or grid-template-rows for height animations.',
skillSection: 'Motion',
skillGuideline: 'Animate layout properties',
},
{
id: 'line-length',
category: 'quality',
name: 'Line length too long',
description:
'Text lines wider than ~80 characters are hard to read. The eye loses its place tracking back to the start of the next line. Add a max-width (65ch to 75ch) to text containers.',
skillSection: 'Layout & Space',
skillGuideline: 'wrap beyond ~80 characters',
},
{
id: 'cramped-padding',
category: 'quality',
name: 'Cramped padding',
description:
'Text is too close to the edge of its container. Two shapes: (1) an element with its own text where the padding is too low for the font size, and (2) a wrapper with text-bearing children and near-zero padding against a visible boundary (border, outline, or non-transparent background) — children land flush against the boundary line. Add at least 8px (ideally 1216px) of padding inside bordered, outlined, or colored containers.',
skillSection: 'Layout & Space',
skillGuideline: 'inside bordered or colored containers',
},
{
id: 'body-text-viewport-edge',
category: 'quality',
name: 'Body text touching viewport edge',
description:
'Body paragraphs render flush against the left or right viewport edge with no container providing horizontal padding. Wrap content in a container with at least 16px (ideally 24-32px) of horizontal padding, or apply max-width with mx-auto.',
},
{
id: 'tight-leading',
category: 'quality',
name: 'Tight line height',
description:
'Line height below 1.3x the font size makes multi-line text hard to read. Use 1.5 to 1.7 for body text so lines have room to breathe.',
},
{
id: 'skipped-heading',
category: 'quality',
name: 'Skipped heading level',
description:
'Heading levels should not skip (e.g. h1 then h3 with no h2). Screen readers use heading hierarchy for navigation. Skipping levels breaks the document outline.',
},
{
id: 'justified-text',
category: 'quality',
name: 'Justified text',
description:
'Justified text without hyphenation creates uneven word spacing ("rivers of white"). Use text-align: left for body text, or enable hyphens: auto if you must justify.',
},
{
id: 'tiny-text',
category: 'quality',
name: 'Tiny body text',
description:
'Body text below 12px is hard to read, especially on high-DPI screens. Use at least 14px for body content, 16px is ideal.',
},
{
id: 'all-caps-body',
category: 'quality',
name: 'All-caps body text',
description:
'Long passages in uppercase are hard to read. We recognize words by shape (ascenders and descenders), which all-caps removes. Reserve uppercase for short labels and headings.',
skillSection: 'Typography',
skillGuideline: 'long body passages in uppercase',
},
{
id: 'wide-tracking',
category: 'quality',
name: 'Wide letter spacing on body text',
description:
'Letter spacing above 0.05em on body text disrupts natural character groupings and slows reading. Reserve wide tracking for short uppercase labels only.',
},
{
id: 'text-overflow',
category: 'quality',
name: 'Content overflowing its container',
description:
'Content renders wider than its container, spilling out or forcing a horizontal scrollbar. Let text wrap, constrain widths, or give the region a deliberate scroll affordance.',
skillSection: 'Layout & Space',
skillGuideline: 'content wider than its container',
},
{
id: 'clipped-overflow-container',
category: 'quality',
name: 'Positioned child clipped by overflow container',
description:
'A clipping container (overflow hidden or clip) wrapping an absolutely-positioned child cuts off tooltips, menus, and popovers that need to escape. Let the overflow be visible, or move the positioned layer out of the clip.',
skillSection: 'Layout & Space',
skillGuideline: 'overflow container clipping positioned children',
},
// ── Provider tells: opt-in via --gpt / --gemini (gated off by default) ──
{
id: 'gpt-thin-border-wide-shadow',
category: 'slop',
severity: 'advisory',
gated: 'gpt',
name: 'Hairline border with wide shadow',
description:
'A hairline border paired with a wide, diffuse shadow is a recurring generated-UI signature. Commit to one — a defined edge or a soft elevation — rather than both at once.',
skillSection: 'Visual Details',
skillGuideline: 'hairline border plus wide diffuse shadow',
},
{
id: 'repeating-stripes-gradient',
category: 'slop',
severity: 'advisory',
gated: 'gpt',
name: 'Repeating-gradient stripes',
description:
'Repeating-gradient stripes used as surface decoration are a recurring generated-UI signature. Reach for a deliberate texture or leave the surface plain.',
skillSection: 'Visual Details',
skillGuideline: 'repeating-gradient decorative stripes',
},
{
id: 'theater-slop-phrase',
category: 'slop',
severity: 'advisory',
gated: 'gpt',
name: 'Theater framing copy',
description:
'Dismissing something as "theater" is a recurring generated-copy tic. Say plainly what the thing does or does not do.',
skillSection: 'Copy',
skillGuideline: 'theater framing copy',
},
{
id: 'image-hover-transform',
category: 'slop',
severity: 'advisory',
gated: 'gemini',
name: 'Image hover transform',
description:
'Scaling or rotating an image on hover is a recurring generated-UI signature. Let imagery sit still, or use a subtler, purposeful interaction.',
skillSection: 'Motion',
skillGuideline: 'image scale or rotate on hover',
},
];
const RULE_ENGINE_SUPPORT = {
regex: new Set(['source', 'page-analyzer']),
'static-html': new Set(['element', 'page']),
browser: new Set(['element', 'page', 'layout']),
visual: new Set(['visual-contrast']),
};
function getAntipattern(id) {
return ANTIPATTERNS.find(rule => rule.id === id);
}
function getRulesForCategory(category) {
return ANTIPATTERNS.filter(rule => rule.category === category);
}
function getRuleEngineSupport(engine) {
return RULE_ENGINE_SUPPORT[engine] || new Set();
}
// Set of provider tags that gate rules off by default (e.g. 'gpt', 'gemini').
const GATED_PROVIDERS = new Set(
ANTIPATTERNS.map(rule => rule.gated).filter(Boolean),
);
// Drop findings for rules gated behind a provider tag unless that provider
// was explicitly enabled (CLI --gpt / --gemini). Non-gated findings always
// pass through. `findings` carry the rule id on `.antipattern`.
function filterByProviders(findings, providers = []) {
const enabled = new Set(providers || []);
if (!GATED_PROVIDERS.size) return findings;
return findings.filter(f => {
const rule = getAntipattern(f.antipattern);
if (!rule || !rule.gated) return true;
return enabled.has(rule.gated);
});
}
export {
ANTIPATTERNS,
RULE_ENGINE_SUPPORT,
GATED_PROVIDERS,
getAntipattern,
getRulesForCategory,
getRuleEngineSupport,
filterByProviders,
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,124 @@
// ─── Section 2: Color Utilities ─────────────────────────────────────────────
function isNeutralColor(color) {
if (!color || color === 'transparent') return true;
// rgb/rgba — use channel spread. Threshold 30 ≈ 11.7% of the 0255 range.
const rgb = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (rgb) {
return (Math.max(+rgb[1], +rgb[2], +rgb[3]) - Math.min(+rgb[1], +rgb[2], +rgb[3])) < 30;
}
// oklch()/lch() — chroma is the second numeric component.
// oklch chroma is ~00.4 in sRGB gamut; >= 0.02 reads as tinted, not gray.
// lch chroma is ~0150; >= 3 reads as tinted. jsdom emits both formats
// literally (it does NOT convert them to rgb).
const oklch = color.match(/oklch\(\s*[\d.]+%?\s*([\d.-]+)/i);
if (oklch) return parseFloat(oklch[1]) < 0.02;
const lch = color.match(/lch\(\s*[\d.]+%?\s*([\d.-]+)/i);
if (lch) return parseFloat(lch[1]) < 3;
// oklab()/lab() — a and b are signed axes; chroma = sqrt(a² + b²).
// oklab a/b are ~-0.4..0.4, threshold 0.02. lab a/b are ~-128..127, threshold 3.
const oklab = color.match(/oklab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i);
if (oklab) {
const a = parseFloat(oklab[1]), b = parseFloat(oklab[2]);
return Math.hypot(a, b) < 0.02;
}
const lab = color.match(/lab\(\s*[\d.]+%?\s*([\d.-]+)\s+([\d.-]+)/i);
if (lab) {
const a = parseFloat(lab[1]), b = parseFloat(lab[2]);
return Math.hypot(a, b) < 3;
}
// hsl/hsla — saturation is the second numeric component (percent).
// Modern jsdom usually converts hsl() to rgb, but handle it directly for
// safety across versions and for any engine that preserves the format.
const hsl = color.match(/hsla?\(\s*[\d.-]+\s*,?\s*([\d.]+)%/i);
if (hsl) return parseFloat(hsl[1]) < 10;
// hwb(hue whiteness% blackness%) — a pixel is fully gray when
// whiteness + blackness >= 100; chroma-like saturation = 1 - (w+b)/100.
const hwb = color.match(/hwb\(\s*[\d.-]+\s+([\d.]+)%\s+([\d.]+)%/i);
if (hwb) {
const w = parseFloat(hwb[1]), b = parseFloat(hwb[2]);
return (1 - Math.min(100, w + b) / 100) < 0.1;
}
// Unknown / unrecognized format — err on the side of DETECTING rather
// than silently skipping. This is the opposite of the previous default,
// which was the root cause of the oklch bug.
return false;
}
function parseRgb(color) {
if (!color || color === 'transparent') return null;
const m = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/);
if (!m) return null;
return { r: +m[1], g: +m[2], b: +m[3], a: m[4] !== undefined ? +m[4] : 1 };
}
function relativeLuminance({ r, g, b }) {
const [rs, gs, bs] = [r / 255, g / 255, b / 255].map(c =>
c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
);
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
function contrastRatio(c1, c2) {
const l1 = relativeLuminance(c1);
const l2 = relativeLuminance(c2);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
function parseGradientColors(bgImage) {
if (!bgImage || !bgImage.includes('gradient')) return [];
const colors = [];
for (const m of bgImage.matchAll(/rgba?\([^)]+\)/g)) {
const c = parseRgb(m[0]);
if (c) colors.push(c);
}
for (const m of bgImage.matchAll(/#([0-9a-f]{6}|[0-9a-f]{3})\b/gi)) {
const h = m[1];
if (h.length === 6) {
colors.push({ r: parseInt(h.slice(0,2),16), g: parseInt(h.slice(2,4),16), b: parseInt(h.slice(4,6),16), a: 1 });
} else {
colors.push({ r: parseInt(h[0]+h[0],16), g: parseInt(h[1]+h[1],16), b: parseInt(h[2]+h[2],16), a: 1 });
}
}
return colors;
}
function hasChroma(c, threshold = 30) {
if (!c) return false;
return (Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b)) >= threshold;
}
function getHue(c) {
if (!c) return 0;
const r = c.r / 255, g = c.g / 255, b = c.b / 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
if (max === min) return 0;
const d = max - min;
let h;
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
else if (max === g) h = ((b - r) / d + 2) / 6;
else h = ((r - g) / d + 4) / 6;
return Math.round(h * 360);
}
function colorToHex(c) {
if (!c) return '?';
return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join('');
}
export {
isNeutralColor,
parseRgb,
relativeLuminance,
contrastRatio,
parseGradientColors,
hasChroma,
getHue,
colorToHex,
};
@@ -0,0 +1,101 @@
// ─── Section 1: Constants ───────────────────────────────────────────────────
const SAFE_TAGS = new Set([
'blockquote', 'nav', 'a', 'input', 'textarea', 'select',
'pre', 'code', 'span', 'th', 'td', 'tr', 'li', 'label',
'button', 'hr', 'html', 'head', 'body', 'script', 'style',
'link', 'meta', 'title', 'br', 'img', 'svg', 'path', 'circle',
'rect', 'line', 'polyline', 'polygon', 'g', 'defs', 'use',
]);
// Per-check safe-tags override for the border (side-tab / border-accent)
// rule. We intentionally re-allow <label> here because card-shaped clickable
// labels (e.g. .checklist-item wrapping a checkbox + content) are one of the
// canonical side-tab anti-pattern shapes and must be detected. The rule's
// other preconditions (non-neutral color, width >= 2px on a single side,
// radius > 0 or width >= 3, element size >= 20x20 in the browser path)
// already filter out plain inline form labels so this does not introduce
// false positives. See modern-color-borders.html for the test matrix.
const BORDER_SAFE_TAGS = new Set(
[...SAFE_TAGS].filter(t => t !== 'label')
);
const OVERUSED_FONTS = new Set([
// Older monoculture (still ubiquitous):
'inter', 'roboto', 'open sans', 'lato', 'montserrat', 'arial', 'helvetica',
// Newer monoculture (the Anthropic-skill / Vercel / GitHub default wave):
'fraunces', 'instrument sans', 'instrument serif',
'geist', 'geist sans', 'geist mono',
'mona sans',
'plus jakarta sans', 'space grotesk', 'recoleta',
]);
// Brand-associated fonts: don't flag these as "overused" on the brand's own domains.
// Keys are font names, values are arrays of hostname suffixes where the font is allowed.
const GOOGLE_DOMAINS = [
'google.com', 'youtube.com', 'android.com', 'chromium.org',
'chrome.com', 'web.dev', 'gstatic.com', 'firebase.google.com',
];
const VERCEL_DOMAINS = ['vercel.com', 'nextjs.org', 'v0.app'];
const GITHUB_DOMAINS = ['github.com', 'githubnext.com'];
const BRAND_FONT_DOMAINS = {
'roboto': GOOGLE_DOMAINS,
'google sans': GOOGLE_DOMAINS,
'product sans': GOOGLE_DOMAINS,
'geist': VERCEL_DOMAINS,
'geist sans': VERCEL_DOMAINS,
'geist mono': VERCEL_DOMAINS,
'mona sans': GITHUB_DOMAINS,
};
function isBrandFontOnOwnDomain(font) {
if (typeof location === 'undefined') return false;
const allowed = BRAND_FONT_DOMAINS[font];
if (!allowed) return false;
const host = location.hostname.toLowerCase();
return allowed.some(suffix => host === suffix || host.endsWith('.' + suffix));
}
const GENERIC_FONTS = new Set([
'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
'-apple-system', 'blinkmacsystemfont', 'segoe ui',
'inherit', 'initial', 'unset', 'revert',
]);
// WCAG large text thresholds are defined in points: 18pt normal text and
// 14pt bold text. Browsers expose font-size in CSS pixels at 96px per inch.
const WCAG_LARGE_TEXT_PX = 18 * (96 / 72);
const WCAG_LARGE_BOLD_TEXT_PX = 14 * (96 / 72);
// Serif faces that show up in italic-display heroes. The rule also fires when
// the primary face is unknown but the stack ends in the generic `serif` token,
// which catches custom/private faces with a serif fallback.
const KNOWN_SERIF_FONTS = new Set([
'fraunces', 'recoleta', 'newsreader', 'playfair display', 'playfair',
'cormorant', 'cormorant garamond', 'garamond', 'eb garamond',
'tiempos', 'tiempos headline', 'tiempos text',
'lora', 'vollkorn', 'spectral',
'source serif pro', 'source serif 4', 'source serif',
'ibm plex serif', 'merriweather',
'libre caslon', 'libre baskerville', 'baskerville',
'georgia', 'times new roman', 'times',
'dm serif display', 'dm serif text',
'instrument serif', 'gt sectra', 'ogg', 'canela',
'freight display', 'freight text',
]);
export {
SAFE_TAGS,
BORDER_SAFE_TAGS,
OVERUSED_FONTS,
GOOGLE_DOMAINS,
VERCEL_DOMAINS,
GITHUB_DOMAINS,
BRAND_FONT_DOMAINS,
isBrandFontOnOwnDomain,
GENERIC_FONTS,
WCAG_LARGE_TEXT_PX,
WCAG_LARGE_BOLD_TEXT_PX,
KNOWN_SERIF_FONTS,
};
@@ -0,0 +1,7 @@
/** Check if content looks like a full page (not a component/partial) */
function isFullPage(content) {
const stripped = content.replace(/<!--[\s\S]*?-->/g, '');
return /<!doctype\s|<html[\s>]|<head[\s>]/i.test(stripped);
}
export { isFullPage };
@@ -0,0 +1,110 @@
import fs from 'node:fs';
import path from 'node:path';
export const IMPECCABLE_DIR = '.impeccable';
export const LIVE_DIR = 'live';
export const CRITIQUE_DIR = 'critique';
export function getImpeccableDir(cwd = process.cwd()) {
return path.join(cwd, IMPECCABLE_DIR);
}
export function getDesignSidecarPath(cwd = process.cwd()) {
return path.join(getImpeccableDir(cwd), 'design.json');
}
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd) {
const candidates = [
getDesignSidecarPath(cwd),
path.join(cwd, 'DESIGN.json'),
];
const contextLegacy = path.join(contextDir, 'DESIGN.json');
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
return candidates;
}
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd) {
return firstExisting(getDesignSidecarCandidates(cwd, contextDir));
}
export function getLiveDir(cwd = process.cwd()) {
return path.join(getImpeccableDir(cwd), LIVE_DIR);
}
export function getLiveConfigPath(cwd = process.cwd()) {
return path.join(getLiveDir(cwd), 'config.json');
}
export function getLegacyLiveConfigPath(scriptsDir) {
return path.join(scriptsDir, 'config.json');
}
export function resolveLiveConfigPath({ cwd = process.cwd(), scriptsDir, env = process.env } = {}) {
if (env.IMPECCABLE_LIVE_CONFIG && env.IMPECCABLE_LIVE_CONFIG.trim()) {
const configured = env.IMPECCABLE_LIVE_CONFIG.trim();
return path.isAbsolute(configured) ? configured : path.resolve(cwd, configured);
}
const primary = getLiveConfigPath(cwd);
if (fs.existsSync(primary)) return primary;
if (scriptsDir) {
const legacy = getLegacyLiveConfigPath(scriptsDir);
if (fs.existsSync(legacy)) return legacy;
}
return primary;
}
export function getLiveServerPath(cwd = process.cwd()) {
return path.join(getLiveDir(cwd), 'server.json');
}
export function getLegacyLiveServerPath(cwd = process.cwd()) {
return path.join(cwd, '.impeccable-live.json');
}
export function readLiveServerInfo(cwd = process.cwd()) {
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
try {
return { info: JSON.parse(fs.readFileSync(filePath, 'utf-8')), path: filePath };
} catch {
/* try next */
}
}
return null;
}
export function writeLiveServerInfo(cwd = process.cwd(), info) {
const filePath = getLiveServerPath(cwd);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(info));
return filePath;
}
export function removeLiveServerInfo(cwd = process.cwd()) {
for (const filePath of [getLiveServerPath(cwd), getLegacyLiveServerPath(cwd)]) {
try { fs.unlinkSync(filePath); } catch {}
}
}
export function getLiveSessionsDir(cwd = process.cwd()) {
return path.join(getLiveDir(cwd), 'sessions');
}
export function getLegacyLiveSessionsDir(cwd = process.cwd()) {
return path.join(cwd, '.impeccable-live', 'sessions');
}
export function getLiveAnnotationsDir(cwd = process.cwd()) {
return path.join(getLiveDir(cwd), 'annotations');
}
export function getCritiqueDir(cwd = process.cwd()) {
return path.join(getImpeccableDir(cwd), CRITIQUE_DIR);
}
export function getLegacyLiveAnnotationsDir(cwd = process.cwd()) {
return path.join(cwd, '.impeccable-live', 'annotations');
}
function firstExisting(paths) {
return paths.find((filePath) => fs.existsSync(filePath)) || null;
}
+156 -13
View File
@@ -105,15 +105,22 @@ function handleDiscard(id, lines, targetFile) {
if (!block) return { handled: false, error: 'Markers not found' };
const original = extractOriginal(lines, block);
const indent = lines[block.start].match(/^(\s*)/)[1];
const isJsx = detectCommentSyntax(targetFile).open === '{/*';
const replaceRange = expandReplaceRange(block, lines, isJsx);
// De-indent the original content back to the marker's indentation level
// Restore at the line we're actually replacing FROM, not the marker line.
// For JSX wrappers the marker comments live INSIDE the outer `<div>`, so
// `block.start` sits 2 spaces deeper than the original element. Using that
// as the deindent base would push the restored content 2 spaces too far
// right on every JSX/TSX session. `replaceRange.start` is the outer wrapper
// line, which is at the original element's indent for both HTML and JSX.
const indent = lines[replaceRange.start].match(/^(\s*)/)[1];
const restored = deindentContent(original, indent);
const newLines = [
...lines.slice(0, block.start),
...lines.slice(0, replaceRange.start),
...restored,
...lines.slice(block.end + 1),
...lines.slice(replaceRange.end + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
return {};
@@ -127,8 +134,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
const indent = lines[block.start].match(/^(\s*)/)[1];
const commentSyntax = detectCommentSyntax(targetFile);
const isJsx = commentSyntax.open === '{/*';
// Anchor indent on the line we're replacing FROM (the outer wrapper),
// not on `block.start` — for JSX that's the marker comment 2 spaces
// deeper than the original element. See handleDiscard for the full
// rationale.
const replaceRange = expandReplaceRange(block, lines, isJsx);
const indent = lines[replaceRange.start].match(/^(\s*)/)[1];
// Extract the chosen variant's inner content
const variantContent = extractVariant(lines, block, variantNum);
@@ -150,12 +163,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
if (cssContent) {
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
replacement.push(indent + '<style data-impeccable-css="' + id + '">');
// JSX targets need the CSS body wrapped in a template literal so that the
// `{` and `}` in CSS rules don't get parsed as JSX expressions.
replacement.push(indent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : ''));
// Re-indent CSS content to match
for (const cssLine of cssContent) {
replacement.push(indent + cssLine.trimStart());
}
replacement.push(indent + '</style>');
replacement.push(indent + (isJsx ? '`}</style>' : '</style>'));
if (paramValues && Object.keys(paramValues).length > 0) {
// Preserve the user's knob positions for the carbonize-cleanup agent
// to bake into the final CSS when it collapses scoped rules.
@@ -169,8 +184,13 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
// in a data-impeccable-variant="N" div with `display: contents` (so layout
// isn't affected). The carbonize agent strips this attribute + wrapper when
// it moves the CSS to a proper stylesheet.
//
// Style attribute syntax has to follow the host file's flavor — JSX files
// need the object form, otherwise React 19 throws "Failed to set indexed
// property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
if (cssContent) {
replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" style="display: contents">');
const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
replacement.push(...restored);
replacement.push(indent + '</div>');
} else {
@@ -178,9 +198,9 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
}
const newLines = [
...lines.slice(0, block.start),
...lines.slice(0, replaceRange.start),
...replacement,
...lines.slice(block.end + 1),
...lines.slice(replaceRange.end + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
@@ -209,6 +229,72 @@ function findMarkerBlock(id, lines) {
return (start !== -1 && end !== -1) ? { start, end } : null;
}
/**
* Compute the line range to REPLACE (vs. just the marker range to extract
* from). For JSX/TSX wrappers, live-wrap places the marker comments INSIDE
* the `<div data-impeccable-variants="ID">` outer wrapper so the picked
* element's JSX slot keeps a single child a Fragment `<></>` would have
* solved the multi-sibling case but failed inside `asChild` / cloneElement
* parents with "Invalid prop supplied to React.Fragment".
*
* That means the marker block is enclosed by the wrapper `<div>` opener
* (with `data-impeccable-variants="ID"`) and its matching `</div>`. We
* walk back to the opener and forward to the closer so accept/discard
* remove the entire scaffold, not just the inner markers.
*
* Marker lines themselves stay where they were so extractOriginal /
* extractVariant / extractCss continue to walk the same range.
*/
function expandReplaceRange(block, lines, isJsx) {
if (!isJsx) return { start: block.start, end: block.end };
let { start, end } = block;
// Walk back for the wrapper `<div data-impeccable-variants="..."` opener.
// The attr may sit on a continuation line of a multi-line opening tag, so
// also walk to the line that actually contains `<div`.
for (let i = start - 1; i >= Math.max(0, start - 12); i--) {
if (/data-impeccable-variants=/.test(lines[i])) {
let opener = i;
while (opener > 0 && !/<div\b/.test(lines[opener])) opener--;
start = opener;
break;
}
}
// Walk forward to the matching `</div>` by div-depth tracking from the
// wrapper opener. Operate on JOINED text instead of per-line: a
// multi-line self-closing JSX `<div\n className="spacer"\n/>` would
// fool per-line regex tracking (the `<div` line matches openRe but the
// `/>` line never matches selfCloseRe since it needs `<div` on the same
// line). That left depth permanently over-counted and the wrapper's
// outer `</div>` orphaned after accept/discard. Single regex with
// `[^>]*?` (which spans newlines in JS) handles either form correctly.
const joined = lines.slice(start).join('\n');
// Match either `<div … />` (self-close, group 1 is `/`), `<div … >`
// (open, group 1 is empty), or `</div>`.
const tagRe = /<div\b[^>]*?(\/?)>|<\/div\s*>/g;
let depth = 0;
let m;
while ((m = tagRe.exec(joined)) !== null) {
const isClose = m[0].startsWith('</');
const isSelfClose = !isClose && m[1] === '/';
if (isClose) depth--;
else if (!isSelfClose) depth++;
if (depth <= 0) {
// m.index is offset within `joined`; convert back to a file line.
const linesBefore = joined.slice(0, m.index + m[0].length).split('\n').length - 1;
const candidateEnd = start + linesBefore;
if (candidateEnd >= end) {
end = candidateEnd;
break;
}
}
}
return { start, end };
}
/**
* Join wrapper lines into a single string with `<style>` elements removed so
* marker matching and div-depth tracking aren't confused by:
@@ -336,7 +422,7 @@ function extractCss(lines, block, id) {
// Same-line open + close: extract inner text.
const sameLine = line.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/);
if (sameLine) {
const inner = sameLine[1];
const inner = stripJsxTemplateWrap(sameLine[1]);
return inner.length > 0 ? inner.split('\n') : null;
}
inStyle = true;
@@ -344,12 +430,69 @@ function extractCss(lines, block, id) {
}
if (inStyle) {
if (line.trimStart().startsWith('</style>')) break;
// Detect </style> anywhere on the line — JSX template-literal closes
// (`}</style>`) put the close mid-line, and we don't want to absorb the
// template-literal punctuation as CSS content.
const closeIdx = line.indexOf('</style>');
if (closeIdx !== -1) break;
content.push(line);
}
}
return content.length > 0 ? content : null;
if (content.length === 0) return null;
return stripJsxTemplateLines(content);
}
/**
* Strip a JSX template-literal wrap (`{` `}`) from CSS extracted out of a
* `<style>` element in a JSX/TSX file. The agent may write the wrap with
* `{` and `}` directly attached to the `<style>` tags, on their own lines,
* or attached to the first/last CSS lines all three are JSX-legal.
*
* Stripping is required because handleAccept re-wraps the CSS itself when
* carbonizing. Without this, two consecutive accepts (or a previously-
* accepted variants block being carbonized) would produce nested
* `{` `{` `}` `}`, which oxc rejects with "Expected `}` but found `@`".
*/
function stripJsxTemplateLines(content) {
const out = content.slice();
// Drop any leading blank lines so we don't miss a `{` line buried below
// them; same for trailing.
while (out.length > 0 && out[0].trim() === '') out.shift();
while (out.length > 0 && out[out.length - 1].trim() === '') out.pop();
if (out.length === 0) return null;
// Leading `{`: own line, or attached to the first CSS line.
const firstTrim = out[0].trimStart();
if (firstTrim === '{`') {
out.shift();
} else if (firstTrim.startsWith('{`')) {
const idx = out[0].indexOf('{`');
out[0] = out[0].slice(0, idx) + out[0].slice(idx + 2);
if (out[0].trim() === '') out.shift();
}
if (out.length === 0) return null;
// Trailing `` ` `` `}`: own line, or attached to the last CSS line.
const lastIdx = out.length - 1;
const lastTrim = out[lastIdx].trimEnd();
if (lastTrim === '`}') {
out.pop();
} else if (lastTrim.endsWith('`}')) {
const text = out[lastIdx];
const idx = text.lastIndexOf('`}');
out[lastIdx] = text.slice(0, idx) + text.slice(idx + 2);
if (out[lastIdx].trim() === '') out.pop();
}
return out.length > 0 ? out : null;
}
function stripJsxTemplateWrap(text) {
const lines = text.split('\n');
const stripped = stripJsxTemplateLines(lines);
return stripped ? stripped.join('\n') : '';
}
/**
@@ -0,0 +1,123 @@
/**
* Browser-side durable session helpers for Impeccable live mode.
*
* Kept separate from live-browser.js so recovery state can be tested without
* booting the full overlay UI. Served before live-browser.js and attached to
* window.__IMPECCABLE_LIVE_SESSION__.
*/
(function (root) {
'use strict';
function createLiveBrowserSessionState({ prefix, storage, idFactory }) {
if (!prefix) throw new Error('prefix required');
const store = storage || root.localStorage;
const makeId = idFactory || function () { return Math.random().toString(16).slice(2, 10); };
const sessionKey = prefix + '-session';
const handledKey = sessionKey + '-handled';
const scrollKey = sessionKey + '-scroll';
let checkpointRevision = 0;
const owner = makeId();
function safeRead(key) {
try { return store.getItem(key); } catch { return null; }
}
function safeWrite(key, value) {
try { store.setItem(key, value); } catch { /* quota exceeded or private mode */ }
}
function safeRemove(key) {
try { store.removeItem(key); } catch { /* unavailable storage */ }
}
function loadSession() {
try {
const raw = safeRead(sessionKey);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (Number.isInteger(parsed.checkpointRevision)) {
checkpointRevision = Math.max(checkpointRevision, parsed.checkpointRevision);
}
return parsed;
} catch { return null; }
}
function saveSession(session) {
if (!session || !session.id) return;
const payload = {
...session,
checkpointRevision,
};
safeWrite(sessionKey, JSON.stringify(payload));
}
function clearSession() {
safeRemove(sessionKey);
}
function nextCheckpointRevision() {
checkpointRevision += 1;
const existing = loadSession();
if (existing?.id) saveSession(existing);
return checkpointRevision;
}
function seedCheckpointRevision(value) {
if (Number.isInteger(value)) checkpointRevision = Math.max(checkpointRevision, value);
return checkpointRevision;
}
function currentCheckpointRevision() {
return checkpointRevision;
}
function markHandled(id) {
if (!id) return;
safeWrite(handledKey, id);
}
function isHandled(id) {
return !!id && safeRead(handledKey) === id;
}
function clearHandled() {
safeRemove(handledKey);
}
function writeScrollY(y) {
safeWrite(scrollKey, String(y));
}
function readScrollY() {
const raw = safeRead(scrollKey);
if (raw == null) return null;
const n = parseFloat(raw);
return isFinite(n) ? n : null;
}
function clearScrollY() {
safeRemove(scrollKey);
}
return {
owner,
sessionKey,
handledKey,
scrollKey,
saveSession,
loadSession,
clearSession,
nextCheckpointRevision,
seedCheckpointRevision,
currentCheckpointRevision,
markHandled,
isHandled,
clearHandled,
writeScrollY,
readScrollY,
clearScrollY,
};
}
root.__IMPECCABLE_LIVE_SESSION__ = { createLiveBrowserSessionState };
})(typeof window !== 'undefined' ? window : globalThis);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,75 @@
#!/usr/bin/env node
/**
* Canonical durable completion acknowledgement for Impeccable live sessions.
*/
import { createLiveSessionStore } from './live-session-store.mjs';
import { readLiveServerInfo } from './impeccable-paths.mjs';
function parseArgs(argv) {
const out = { status: 'complete' };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--id') out.id = argv[++i];
else if (arg.startsWith('--id=')) out.id = arg.slice('--id='.length);
else if (arg === '--discarded' || arg === '--discard') out.status = 'discarded';
else if (arg === '--error') { out.status = 'agent_error'; out.message = argv[++i] || 'unknown error'; }
else if (arg.startsWith('--error=')) { out.status = 'agent_error'; out.message = arg.slice('--error='.length); }
else if (arg === '--help' || arg === '-h') out.help = true;
}
return out;
}
export async function completeCli() {
const args = parseArgs(process.argv.slice(2));
if (args.help || !args.id) {
console.log(`Usage: node live-complete.mjs --id SESSION_ID [--discarded|--error MESSAGE]\n\nAppend the final durable session acknowledgement. Use after accept/discard cleanup is verified.`);
process.exit(args.help ? 0 : 1);
}
const serverInfo = readServerInfo();
const serverResult = serverInfo ? await completeThroughServer(serverInfo, args) : null;
if (serverResult?.ok) {
const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id });
const snapshot = store.getSnapshot(args.id, { includeCompleted: true });
console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot?.phase || args.status, snapshot }, null, 2));
return;
}
const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id });
const event = args.status === 'discarded'
? { type: 'discarded', id: args.id }
: args.status === 'agent_error'
? { type: 'agent_error', id: args.id, message: args.message || 'unknown error' }
: { type: 'complete', id: args.id };
const snapshot = store.appendEvent(event);
console.log(JSON.stringify({ ok: true, id: args.id, phase: snapshot.phase, snapshot }, null, 2));
}
function readServerInfo() {
return readLiveServerInfo(process.cwd())?.info || null;
}
async function completeThroughServer(info, args) {
const type = args.status === 'discarded'
? 'discarded'
: args.status === 'agent_error'
? 'error'
: 'complete';
try {
const res = await fetch(`http://localhost:${info.port}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: info.token, id: args.id, type, message: args.message }),
});
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
const _running = process.argv[1];
if (_running?.endsWith('live-complete.mjs') || _running?.endsWith('live-complete.mjs/')) {
completeCli();
}
@@ -0,0 +1,18 @@
export function completionTypeForAcceptResult(eventType, acceptResult) {
if (eventType === 'discard') return acceptResult?.handled === true ? 'discarded' : 'error';
if (acceptResult?.handled === true && acceptResult?.carbonize === true) return 'agent_done';
if (acceptResult?.handled === true) return 'complete';
if (acceptResult?.mode === 'error') return 'error';
return 'agent_done';
}
export function completionAckForAcceptResult(eventId, completionType, acceptResult) {
const ack = { ok: true, type: completionType };
if (acceptResult?.handled === true && acceptResult?.carbonize === true) {
ack.final = false;
ack.requiresComplete = true;
ack.nextCommand = `live-complete.mjs --id ${eventId}`;
ack.message = 'Carbonize cleanup must be verified, then the session must be completed explicitly before polling again.';
}
return ack;
}
@@ -0,0 +1,101 @@
/**
* Shared event validation for the live helper server.
* Extracted for unit testing (insert mode rules).
*/
import { canCreateInsert } from './live-insert-ui.mjs';
export const VISUAL_ACTIONS = [
'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset',
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
const ID_PATTERN = /^[0-9a-f]{8}$/;
const VARIANT_ID_PATTERN = /^[0-9]{1,3}$/;
const INSERT_POSITIONS = new Set(['before', 'after']);
function isValidId(v) { return typeof v === 'string' && ID_PATTERN.test(v); }
function isValidVariantId(v) { return typeof v === 'string' && VARIANT_ID_PATTERN.test(v); }
function validateAnnotationFields(msg) {
if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') {
return 'generate: screenshotPath must be string';
}
if (msg.comments !== undefined && !Array.isArray(msg.comments)) {
return 'generate: comments must be array';
}
if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) {
return 'generate: strokes must be array';
}
return null;
}
function validateInsertGenerate(msg) {
if (!msg.insert || typeof msg.insert !== 'object') return 'generate: insert mode requires insert object';
if (!INSERT_POSITIONS.has(msg.insert.position)) return 'generate: insert.position must be before or after';
const anchor = msg.insert.anchor;
if (!anchor || typeof anchor !== 'object') return 'generate: insert.anchor required';
if (!anchor.tagName && !anchor.outerHTML && !(Array.isArray(anchor.classes) && anchor.classes.length)) {
return 'generate: insert.anchor needs tagName, classes, or outerHTML';
}
if (!msg.placeholder || typeof msg.placeholder !== 'object') return 'generate: insert mode requires placeholder dimensions';
if (!Number.isFinite(msg.placeholder.width) || !Number.isFinite(msg.placeholder.height)) {
return 'generate: placeholder width and height must be numbers';
}
if (!canCreateInsert({
prompt: msg.freeformPrompt,
comments: msg.comments,
strokes: msg.strokes,
})) {
return 'generate: insert requires freeformPrompt or annotations';
}
return validateAnnotationFields(msg);
}
function validateReplaceGenerate(msg) {
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
return validateAnnotationFields(msg);
}
export function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!isValidId(msg.id)) return 'generate: missing or malformed id';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (msg.mode === 'insert') return validateInsertGenerate(msg);
return validateReplaceGenerate(msg);
case 'accept':
if (!isValidId(msg.id)) return 'accept: missing or malformed id';
if (!isValidVariantId(msg.variantId)) return 'accept: missing or malformed variantId';
if (msg.paramValues !== undefined) {
if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
return 'accept: paramValues must be an object';
}
}
return null;
case 'discard':
return isValidId(msg.id) ? null : 'discard: missing or malformed id';
case 'checkpoint':
if (!isValidId(msg.id)) return 'checkpoint: missing or malformed id';
if (!Number.isInteger(msg.revision) || msg.revision < 0) return 'checkpoint: revision must be a non-negative integer';
if (msg.paramValues !== undefined && (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues))) {
return 'checkpoint: paramValues must be an object';
}
return null;
case 'exit':
return null;
case 'prefetch':
if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl';
return null;
case 'steer':
if (!isValidId(msg.id)) return 'steer: missing or malformed id';
if (typeof msg.message !== 'string' || !msg.message.trim()) return 'steer: message required';
if (msg.message.length > 4000) return 'steer: message too long';
if (msg.pageUrl !== undefined && typeof msg.pageUrl !== 'string') return 'steer: pageUrl must be string';
return null;
default:
return 'Unknown event type: ' + msg.type;
}
}
+150 -12
View File
@@ -2,23 +2,24 @@
* CLI helper: insert/remove the live variant mode script tag in the project's
* main HTML entry point.
*
* On first live run, the agent generates `config.json` in this script's
* directory with the project's insertion target (framework-specific). On
* On first live run, the agent generates `.impeccable/live/config.json`
* with the project's insertion target (framework-specific). On
* every subsequent run, this script handles insert/remove deterministically
* with zero LLM involvement.
*
* Usage:
* node live-inject.mjs --port PORT # Insert the live script tag
* node live-inject.mjs --remove # Remove the live script tag
* node live-inject.mjs --check # Check whether config.json exists
* node live-inject.mjs --check # Check whether live config exists
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolveLiveConfigPath } from './impeccable-paths.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, 'config.json');
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
const MARKER_OPEN_TEXT = 'impeccable-live-start';
const MARKER_CLOSE_TEXT = 'impeccable-live-end';
@@ -39,12 +40,12 @@ export async function injectCli() {
console.log(`Usage: node live-inject.mjs [options]
Insert or remove the live mode script tag in the project's HTML entry point.
Reads configuration from config.json (in this same directory).
Reads configuration from .impeccable/live/config.json.
Modes:
--port PORT Insert script tag pointing at http://localhost:PORT/live.js
--remove Remove the script tag (if present)
--check Print whether config.json exists and its content
--check Print whether .impeccable/live/config.json exists and its content
Output (JSON):
{ ok, file, inserted|removed, config? }`);
@@ -88,10 +89,15 @@ Output (JSON):
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
const updated = removeTag(content, config.commentSyntax);
const detagged = removeTag(content, config.commentSyntax);
const updated = revertCspMeta(detagged);
if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };
fs.writeFileSync(absFile, updated, 'utf-8');
return { file: relFile, removed: true };
return {
file: relFile,
removed: detagged !== content,
cspReverted: updated !== detagged,
};
});
console.log(JSON.stringify({ ok: true, results }));
return;
@@ -109,11 +115,18 @@ Output (JSON):
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
const withoutOld = removeTag(content, config.commentSyntax);
const updated = insertTag(withoutOld, config, port);
if (updated === withoutOld) return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax));
const withTag = insertTag(withoutOld, config, port);
if (withTag === withoutOld) {
return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
}
const updated = patchCspMeta(withTag, port);
fs.writeFileSync(absFile, updated, 'utf-8');
return { file: relFile, inserted: true };
return {
file: relFile,
inserted: true,
cspPatched: updated !== withTag,
};
});
const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results }));
@@ -296,6 +309,130 @@ function removeTag(content, _syntax) {
return content;
}
// ---------------------------------------------------------------------------
// Content-Security-Policy meta-tag patcher
//
// When the user's HTML carries `<meta http-equiv="Content-Security-Policy">`,
// the cross-origin load of /live.js (and the SSE/POST connection back to
// localhost:PORT) is blocked unless the CSP explicitly allows that origin.
//
// On insert: append `http://localhost:PORT` to `script-src` and `connect-src`,
// and stash the original `content` value in a `data-impeccable-csp-original`
// attribute (base64) so revert is exact.
//
// On remove: detect the marker attribute, decode it, restore the original
// content value verbatim, drop the marker.
//
// Header-based CSP (Next.js headers, Nuxt routeRules, SvelteKit kit.csp,
// shared helpers) is NOT patched here — those need framework-specific config
// edits and are handled via the existing detect-csp.mjs reference output.
// Only the in-source meta-tag form gets the auto-patch.
// ---------------------------------------------------------------------------
const CSP_MARKER_ATTR = 'data-impeccable-csp-original';
function findCspMetaTags(content) {
const out = [];
const tagRe = /<meta\s+([^>]*?)\/?>/gis;
let m;
while ((m = tagRe.exec(content)) !== null) {
const attrs = m[1];
if (!/(http-equiv|httpEquiv)\s*=\s*(['"])Content-Security-Policy\2/i.test(attrs)) continue;
out.push({ start: m.index, end: m.index + m[0].length, full: m[0], attrs });
}
return out;
}
function getAttr(attrs, name) {
const re = new RegExp(`\\b${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`, 'i');
const m = attrs.match(re);
return m ? { quote: m[1], value: m[2], full: m[0] } : null;
}
function appendOriginToDirective(csp, directive, origin) {
const re = new RegExp(`(^|;)(\\s*)(${directive})\\s+([^;]*)`, 'i');
const m = csp.match(re);
if (m) {
const tokens = m[4].trim().split(/\s+/);
if (tokens.includes(origin)) return csp;
return csp.replace(re, `${m[1]}${m[2]}${m[3]} ${[...tokens, origin].join(' ')}`);
}
// Directive missing — add it. Use 'self' + origin so we don't inadvertently
// narrow the policy compared to the default-src fallback (most users with
// an explicit CSP have 'self' there).
return csp.trim().replace(/;?\s*$/, '') + `; ${directive} 'self' ${origin}`;
}
export function patchCspMeta(content, port) {
const tags = findCspMetaTags(content);
if (tags.length === 0) return content;
const origin = `http://localhost:${port}`;
// Walk last-to-first so prior splices don't invalidate later indices.
let result = content;
for (let i = tags.length - 1; i >= 0; i--) {
const tag = tags[i];
const attrs = tag.attrs;
if (getAttr(attrs, CSP_MARKER_ATTR)) continue; // already patched
const contentAttr = getAttr(attrs, 'content');
if (!contentAttr) continue;
const original = contentAttr.value;
let patched = original;
patched = appendOriginToDirective(patched, 'script-src', origin);
patched = appendOriginToDirective(patched, 'connect-src', origin);
// The shader overlay during 'generating' creates a screenshot via
// URL.createObjectURL, producing a `blob:` URL — img-src 'self' rejects
// those. Add `blob:` so the overlay doesn't throw a CSP violation.
patched = appendOriginToDirective(patched, 'img-src', 'blob:');
if (patched === original) continue;
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
// The tagRe captures any whitespace between the last attribute and the
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
// a replace would land it BEFORE that trailing space, leaving a double
// space inside attrs and clobbering the space before `/>`. Split off
// the trailing whitespace, splice the marker into the attribute body,
// and re-append the original trailing whitespace so a self-closing
// `<meta … />` round-trips byte-for-byte.
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
const newTag = tag.full.replace(attrs, newAttrs);
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
}
return result;
}
export function revertCspMeta(content) {
const tags = findCspMetaTags(content);
if (tags.length === 0) return content;
let result = content;
for (let i = tags.length - 1; i >= 0; i--) {
const tag = tags[i];
const origAttr = getAttr(tag.attrs, CSP_MARKER_ATTR);
if (!origAttr) continue;
const contentAttr = getAttr(tag.attrs, 'content');
if (!contentAttr) continue;
let originalValue;
try { originalValue = Buffer.from(origAttr.value, 'base64').toString('utf-8'); }
catch { continue; }
const newContentAttr = `content=${contentAttr.quote}${originalValue}${contentAttr.quote}`;
let newAttrs = tag.attrs.replace(contentAttr.full, newContentAttr);
// Drop the marker attribute and any single space immediately preceding it.
newAttrs = newAttrs.replace(new RegExp(`\\s*${origAttr.full}`), '');
const newTag = tag.full.replace(tag.attrs, newAttrs);
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
}
return result;
}
// ---------------------------------------------------------------------------
// Auto-execute
// ---------------------------------------------------------------------------
@@ -306,3 +443,4 @@ if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs
}
export { insertTag, removeTag, validateConfig, buildTagBlock };
// patchCspMeta + revertCspMeta are exported above where they're defined.
@@ -0,0 +1,458 @@
/**
* Pure helpers for live-mode insert UI (browser + tests).
* Kept separate from live-browser.js so insert logic is unit-testable.
*/
export const PLACEHOLDER_DEFAULT_HEIGHT = 80;
export const PLACEHOLDER_MIN_HEIGHT = 48;
export const PLACEHOLDER_MIN_WIDTH = 120;
/** @typedef {'before' | 'after'} InsertPosition */
/** @typedef {'row' | 'column'} InsertAxis */
/**
* Infer sibling flow axis from a container's computed layout styles.
* @param {{ display?: string, flexDirection?: string, gridTemplateColumns?: string, gridAutoFlow?: string }} style
* @returns {InsertAxis}
*/
export function detectInsertAxisFromStyle(style) {
const display = style?.display || 'block';
if (display.includes('flex')) {
const dir = style.flexDirection || 'row';
return dir.startsWith('row') ? 'row' : 'column';
}
if (display === 'grid' || display === 'inline-grid') {
const flow = style.gridAutoFlow || 'row';
if (flow.includes('column')) return 'column';
const cols = (style.gridTemplateColumns || '').trim();
if (cols && cols !== 'none') {
const colCount = cols.split(/\s+/).filter(Boolean).length;
if (colCount > 1) return 'row';
}
return 'row';
}
return 'column';
}
/**
* Pick insertion side from pointer position against an anchor element box.
* @param {number} clientX
* @param {number} clientY
* @param {{ top: number, left: number, width: number, height: number, bottom?: number, right?: number }} rect
* @param {InsertAxis} [axis]
* @returns {InsertPosition}
*/
export function computeInsertPosition(clientX, clientY, rect, axis = 'column') {
if (!rect) return 'after';
if (axis === 'row') {
if (!Number.isFinite(rect.left) || !Number.isFinite(rect.width) || rect.width <= 0) return 'after';
const mid = rect.left + rect.width / 2;
return clientX < mid ? 'before' : 'after';
}
if (!Number.isFinite(rect.top) || !Number.isFinite(rect.height) || rect.height <= 0) return 'after';
const mid = rect.top + rect.height / 2;
return clientY < mid ? 'before' : 'after';
}
/**
* Whether Create is allowed for an insert session.
* Requires a non-empty prompt OR at least one annotation.
*/
export function canCreateInsert({ prompt, comments, strokes }) {
const hasPrompt = typeof prompt === 'string' && prompt.trim().length > 0;
const hasComments = Array.isArray(comments) && comments.length > 0;
const hasStrokes = Array.isArray(strokes) && strokes.some(
(s) => Array.isArray(s?.points) && s.points.length >= 2,
);
return hasPrompt || hasComments || hasStrokes;
}
/** Tooltip/title when Create is disabled. */
export function insertCreateDisabledReason({ prompt, comments, strokes }) {
if (canCreateInsert({ prompt, comments, strokes })) return null;
return 'Add a prompt or annotate the placeholder to create';
}
/**
* Fixed-position insert line coordinates (viewport px).
* @param {{ top: number, left: number, width: number, height: number, bottom?: number, right?: number }} rect
* @param {InsertPosition} position
* @param {InsertAxis} [axis]
*/
export function insertLineCoords(rect, position, axis = 'column') {
if (axis === 'row') {
const right = rect.right ?? rect.left + rect.width;
const x = position === 'before' ? rect.left - 2 : right + 2;
return { axis: 'row', top: rect.top, left: x, width: 0, height: rect.height };
}
const bottom = rect.bottom ?? rect.top + rect.height;
const y = position === 'before' ? rect.top - 2 : bottom + 2;
return { axis: 'column', top: y, left: rect.left, width: rect.width, height: 0 };
}
/** Cursor while hovering an insert boundary. */
export function cursorForInsertAxis(axis) {
return axis === 'row' ? 'ew-resize' : 'ns-resize';
}
function groupSiblingRows(siblings, rowThreshold = 8) {
const sorted = [...siblings].sort((a, b) => a.rect.top - b.rect.top || a.rect.left - b.rect.left);
const rows = [];
for (const entry of sorted) {
let placed = false;
for (const row of rows) {
if (Math.abs(entry.rect.top - row[0].rect.top) <= rowThreshold) {
row.push(entry);
placed = true;
break;
}
}
if (!placed) rows.push([entry]);
}
return rows;
}
function horizontalOverlap(a, b) {
const left = Math.max(a.left, b.left);
const right = Math.min(a.right ?? a.left + a.width, b.right ?? b.left + b.width);
return Math.max(0, right - left);
}
/**
* Hit-test the gap between adjacent siblings (flex rows, grid columns, stacked blocks).
* @param {number} clientX
* @param {number} clientY
* @param {Array<{ el: unknown, rect: { top: number, left: number, width: number, height: number, bottom?: number, right?: number } }>} siblings
* @param {{ slop?: number, minOverlap?: number }} [opts]
*/
export function hitSiblingInsertGap(clientX, clientY, siblings, opts = {}) {
if (!Array.isArray(siblings) || siblings.length < 2) return null;
const slop = opts.slop ?? 12;
const minOverlap = opts.minOverlap ?? 0.25;
for (const row of groupSiblingRows(siblings)) {
if (row.length < 2) continue;
const sorted = [...row].sort((a, b) => a.rect.left - b.rect.left);
for (let i = 0; i < sorted.length - 1; i++) {
const a = sorted[i];
const b = sorted[i + 1];
const aRight = a.rect.right ?? a.rect.left + a.rect.width;
const bLeft = b.rect.left;
if (bLeft <= aRight) continue;
const top = Math.max(a.rect.top, b.rect.top);
const aBottom = a.rect.bottom ?? a.rect.top + a.rect.height;
const bBottom = b.rect.bottom ?? b.rect.top + b.rect.height;
const bottom = Math.min(aBottom, bBottom);
const span = bottom - top;
const minH = Math.min(a.rect.height, b.rect.height);
if (span < minH * minOverlap) continue;
const inX = clientX >= aRight - slop && clientX <= bLeft + slop;
const inY = clientY >= top - slop && clientY <= bottom + slop;
if (!inX || !inY) continue;
const midX = (aRight + bLeft) / 2;
return {
anchor: b.el,
position: 'before',
axis: 'row',
line: { axis: 'row', left: midX, top, width: 0, height: span },
};
}
}
const sortedCol = [...siblings].sort((a, b) => a.rect.top - b.rect.top || a.rect.left - b.rect.left);
for (let i = 0; i < sortedCol.length - 1; i++) {
const a = sortedCol[i];
const b = sortedCol[i + 1];
const overlap = horizontalOverlap(a.rect, b.rect);
const minW = Math.min(a.rect.width, b.rect.width);
if (overlap < minW * minOverlap) continue;
const aBottom = a.rect.bottom ?? a.rect.top + a.rect.height;
const gapTop = aBottom;
const gapBottom = b.rect.top;
if (gapBottom <= gapTop) continue;
const overlapLeft = Math.max(a.rect.left, b.rect.left);
const overlapRight = Math.min(
a.rect.right ?? a.rect.left + a.rect.width,
b.rect.right ?? b.rect.left + b.rect.width,
);
const inY = clientY >= gapTop - slop && clientY <= gapBottom + slop;
const inX = clientX >= overlapLeft - slop && clientX <= overlapRight + slop;
if (!inY || !inX) continue;
const midY = (gapTop + gapBottom) / 2;
return {
anchor: b.el,
position: 'before',
axis: 'column',
line: { axis: 'column', top: midY, left: overlapLeft, width: overlap, height: 0 },
};
}
return null;
}
/**
* Resolve insert hover target, side, axis, and indicator line for the pointer.
*/
export function resolveInsertHover({ clientX, clientY, target, rect, axis, siblings }) {
const gap = hitSiblingInsertGap(clientX, clientY, siblings);
if (gap) return gap;
const position = computeInsertPosition(clientX, clientY, rect, axis);
const line = insertLineCoords(rect, position, axis);
return { anchor: target, position, axis, line };
}
/**
* How the in-flow placeholder should participate in layout.
* Prefer implicit sizing (flex / %) so row inserts don't inherit the full parent width in px.
* @returns {{ kind: 'flex', flex: string, minWidth: number } | { kind: 'percent' } | { kind: 'auto' } | { kind: 'explicit', width: number }}
*/
export function placeholderSizing({ axis, parentDisplay, parentWidth, anchorFlex }) {
const display = parentDisplay || 'block';
const w = Number.isFinite(parentWidth) ? parentWidth : 0;
if (axis === 'row') {
if (display.includes('flex')) {
const flex = anchorFlex && anchorFlex !== 'none' && anchorFlex !== '0 1 auto'
? anchorFlex
: '1 1 0';
return { kind: 'flex', flex, minWidth: 0 };
}
if (display === 'grid' || display === 'inline-grid') {
return { kind: 'auto' };
}
}
if (w >= PLACEHOLDER_MIN_WIDTH) {
return { kind: 'percent' };
}
return {
kind: 'explicit',
width: Math.max(PLACEHOLDER_MIN_WIDTH, w || PLACEHOLDER_MIN_WIDTH),
};
}
/** Width kinds that need materializing to px before edge-resize. */
export function placeholderWidthIsImplicit(kind) {
return kind === 'flex' || kind === 'percent' || kind === 'auto';
}
/**
* Clamp user-resized placeholder dimensions.
*/
export function clampPlaceholderSize(width, height, parentWidth, opts = {}) {
const minW = opts.minWidth ?? PLACEHOLDER_MIN_WIDTH;
const minH = opts.minHeight ?? PLACEHOLDER_MIN_HEIGHT;
const maxW = opts.maxWidth ?? Math.max(minW, parentWidth || minW);
return {
width: Math.min(maxW, Math.max(minW, Math.round(width))),
height: Math.max(minH, Math.round(height)),
};
}
/** CSS cursor for a placeholder edge resize handle. */
export function cursorForPlaceholderEdge(edge) {
if (edge === 'n' || edge === 's') return 'ns-resize';
if (edge === 'e' || edge === 'w') return 'ew-resize';
return 'default';
}
/**
* Compute placeholder box after dragging one edge (in-flow margins shift for n/w).
* @param {{ width: number, height: number, marginLeft?: number, marginTop?: number }} start
* @param {'n'|'e'|'s'|'w'} edge
* @param {number} dx pointer delta X since drag start
* @param {number} dy pointer delta Y since drag start
* @param {number} parentWidth
*/
export function resizePlaceholderFromEdge(start, edge, dx, dy, parentWidth, opts = {}) {
const base = {
width: start.width,
height: start.height,
marginLeft: start.marginLeft ?? 0,
marginTop: start.marginTop ?? 0,
};
if (edge === 'e') base.width = start.width + dx;
else if (edge === 'w') {
base.width = start.width - dx;
base.marginLeft = start.marginLeft + dx;
} else if (edge === 's') base.height = start.height + dy;
else if (edge === 'n') {
base.height = start.height - dy;
base.marginTop = start.marginTop + dy;
}
const clamped = clampPlaceholderSize(base.width, base.height, parentWidth, opts);
if (edge === 'w') {
base.marginLeft = start.marginLeft + start.width - clamped.width;
} else if (edge === 'n') {
base.marginTop = start.marginTop + start.height - clamped.height;
}
return {
width: clamped.width,
height: clamped.height,
marginLeft: Math.round(base.marginLeft),
marginTop: Math.round(base.marginTop),
};
}
/** Pick and insert toggles are independent but turning one ON turns the other OFF. */
export function applyPickToggle(pickActive, insertActive) {
const nextPick = !pickActive;
return {
pickActive: nextPick,
insertActive: nextPick ? false : insertActive,
};
}
export function applyInsertToggle(pickActive, insertActive) {
const nextInsert = !insertActive;
return {
pickActive: nextInsert ? false : pickActive,
insertActive: nextInsert,
};
}
/**
* Build the browser generate payload for insert mode.
*/
export function buildInsertGeneratePayload({
id,
count,
pageUrl,
anchorContext,
position,
placeholder,
freeformPrompt,
comments,
strokes,
screenshotPath,
}) {
const payload = {
type: 'generate',
mode: 'insert',
id,
count,
pageUrl,
insert: {
position,
anchor: anchorContext,
},
placeholder,
freeformPrompt: freeformPrompt?.trim() || undefined,
};
if (comments?.length) payload.comments = comments;
if (strokes?.length) payload.strokes = strokes;
if (screenshotPath) payload.screenshotPath = screenshotPath;
return payload;
}
/**
* Whether a variant wrapper is currently shown (handles `hidden` and display:none).
* @param {{ hidden?: boolean, style?: { display?: string } } | null | undefined} el
*/
export function isVariantShown(el) {
if (!el) return false;
if (el.hidden) return false;
if (el.style?.display === 'none') return false;
return true;
}
/**
* Show or hide a variant wrapper for cycling.
* @param {{ hidden?: boolean, style?: { display?: string }, removeAttribute?: (name: string) => void, setAttribute?: (name: string, value?: string) => void } | null | undefined} el
* @param {boolean} shown
*/
export function setVariantShown(el, shown) {
if (!el) return;
if (shown) {
el.removeAttribute?.('hidden');
if (el.style) el.style.display = '';
} else {
el.setAttribute?.('hidden', '');
if (el.style) el.style.display = 'none';
}
}
/**
* Pick the best live anchor during an insert session (placeholder until variants land).
* @param {{
* wrapper?: unknown,
* variantCount?: number,
* visibleVariant?: number,
* placeholder?: unknown,
* insertAnchor?: unknown,
* pickVariantContent?: (wrapper: unknown, index: number) => unknown,
* }} opts
*/
export function resolveInsertSessionAnchor(opts) {
const {
wrapper,
variantCount = 0,
visibleVariant = 0,
placeholder,
insertAnchor,
pickVariantContent,
} = opts || {};
if (wrapper && variantCount > 0 && visibleVariant > 0 && pickVariantContent) {
const vis = pickVariantContent(wrapper, visibleVariant);
if (vis) return vis;
}
return placeholder || insertAnchor || null;
}
/**
* Snapshot placeholder geometry + anchor fingerprint so HMR can recreate the box.
* @param {{
* tagName?: string,
* className?: string,
* textContent?: string,
* }} anchor
* @param {{
* offsetWidth?: number,
* offsetHeight?: number,
* style?: { marginLeft?: string, marginTop?: string },
* }} placeholder
* @param {{ position: 'before' | 'after', layoutAxis?: 'row' | 'column' }} meta
*/
export function buildInsertPlaceholderSnapshot(anchor, placeholder, { position, layoutAxis }) {
return {
width: Math.round(placeholder.offsetWidth || 0),
height: Math.round(placeholder.offsetHeight || PLACEHOLDER_DEFAULT_HEIGHT),
marginLeft: parseFloat(placeholder.style?.marginLeft || '') || 0,
marginTop: parseFloat(placeholder.style?.marginTop || '') || 0,
position,
layoutAxis: layoutAxis || 'column',
anchorTag: anchor.tagName || 'DIV',
anchorClasses: anchor.className || '',
anchorText: (anchor.textContent || '').trim().slice(0, 120),
};
}
/**
* Re-find an insert anchor after framework HMR replaced the live DOM node.
* @param {Pick<Document, 'body' | 'querySelectorAll'>} doc
* @param {ReturnType<typeof buildInsertPlaceholderSnapshot> | null | undefined} snapshot
* @param {Element | null | undefined} liveAnchor
*/
export function findInsertAnchorInDom(doc, snapshot, liveAnchor = null) {
if (liveAnchor && doc.body.contains(liveAnchor)) return liveAnchor;
if (!snapshot) return null;
const tag = (snapshot.anchorTag || 'div').toLowerCase();
const cls = (snapshot.anchorClasses || '').split(/\s+/).filter(Boolean)[0];
const needle = snapshot.anchorText || '';
const sel = cls ? `${tag}.${cls}` : tag;
const candidates = doc.querySelectorAll(sel);
for (const candidate of candidates) {
if (needle && !(candidate.textContent || '').includes(needle.slice(0, 40))) continue;
return candidate;
}
return null;
}
@@ -0,0 +1,232 @@
/**
* CLI helper: find an anchor element in source and splice an insert-variant
* wrapper before or after it (no original variant net-new content).
*
* Usage:
* node live-insert.mjs --id SESSION_ID --count N --position after \
* --classes "hero" --tag section [--file path]
*/
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
import {
buildSearchQueries,
findElement,
findAllElements,
filterByText,
findFileWithQuery,
detectCommentSyntax,
detectStyleMode,
buildCssAuthoring,
buildCssSelectorPrefixExamples,
} from './live-wrap.mjs';
const INSERT_POSITIONS = new Set(['before', 'after']);
export function isInsertPosition(value) {
return INSERT_POSITIONS.has(value);
}
export function computeInsertLine(startLine, endLine, position) {
return position === 'before' ? startLine : endLine + 1;
}
export function buildInsertWrapperLines({ id, count, indent, commentSyntax, isJsx }) {
const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
const attrs =
'data-impeccable-variants="' + id + '" ' +
'data-impeccable-mode="insert" ' +
'data-impeccable-variant-count="' + count + '" ' +
styleContents;
if (isJsx) {
return [
indent + '<div ' + attrs + '>',
indent + ' ' + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
indent + '</div>',
];
}
return [
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div ' + attrs + '>',
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
indent + '</div>',
indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
];
}
function argVal(args, flag) {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
}
function resolveElementMatch({ lines, queries, tag, text }) {
if (text) {
const candidates = [];
for (const q of queries) {
const all = findAllElements(lines, q, tag);
for (const c of all) {
if (!candidates.some((x) => x.startLine === c.startLine)) candidates.push(c);
}
if (candidates.length === 1) break;
}
if (candidates.length === 0) return { error: 'element_not_found' };
if (candidates.length === 1) return { match: candidates[0] };
const filtered = filterByText(candidates, lines, text);
if (filtered.length === 1) return { match: filtered[0] };
if (filtered.length === 0) return { match: candidates[0] };
return { error: 'element_ambiguous', candidates: filtered };
}
for (const q of queries) {
const match = findElement(lines, q, tag);
if (match) return { match };
}
return { error: 'element_not_found' };
}
export async function insertCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node live-insert.mjs [options]
Find an anchor element in source and splice an insert-variant wrapper.
Required:
--id ID Session ID for the variant wrapper
--count N Number of expected variants (1-8)
--position POS before | after (relative to the anchor element)
Element identification (at least one required):
--element-id ID HTML id attribute of the anchor element
--classes A,B,C Comma-separated CSS class names
--tag TAG Tag name (div, section, etc.)
--query TEXT Fallback: raw text to search for
Optional:
--file PATH Source file to search in (skips auto-detection)
--text TEXT Anchor textContent for disambiguation (~80 chars)
Output (JSON):
{ mode: "insert", file, position, insertLine, commentSyntax, styleMode, styleTag, cssAuthoring }`);
process.exit(0);
}
const id = argVal(args, '--id');
const count = parseInt(argVal(args, '--count') || '3', 10);
const position = argVal(args, '--position');
const elementId = argVal(args, '--element-id');
const classes = argVal(args, '--classes');
const tag = argVal(args, '--tag');
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!position) { console.error('Missing --position (before | after)'); process.exit(1); }
if (!isInsertPosition(position)) { console.error('Invalid --position: ' + position); process.exit(1); }
if (!elementId && !classes && !query) {
console.error('Need at least one of: --element-id, --classes, --query');
process.exit(1);
}
const queries = buildSearchQueries(elementId, classes, tag, query);
const genOpts = { cwd: process.cwd() };
let targetFile = filePath;
if (!targetFile) {
for (const q of queries) {
targetFile = findFileWithQuery(q, process.cwd(), genOpts);
if (targetFile) break;
}
if (!targetFile) {
let generatedHit = null;
for (const q of queries) {
generatedHit = findFileWithQuery(q, process.cwd(), { ...genOpts, includeGenerated: true });
if (generatedHit) break;
}
console.error(JSON.stringify({
error: generatedHit ? 'element_not_in_source' : 'element_not_found',
fallback: 'agent-driven',
hint: 'See "Handle fallback" in live.md.',
}));
process.exit(1);
}
} else if (isGeneratedFile(targetFile, genOpts)) {
console.error(JSON.stringify({
error: 'file_is_generated',
fallback: 'agent-driven',
file: path.relative(process.cwd(), path.resolve(process.cwd(), targetFile)),
}));
process.exit(1);
}
const content = fs.readFileSync(targetFile, 'utf-8');
const lines = content.split('\n');
const resolved = resolveElementMatch({ lines, queries, tag, text });
if (resolved.error === 'element_ambiguous') {
console.error(JSON.stringify({
error: 'element_ambiguous',
fallback: 'agent-driven',
file: path.relative(process.cwd(), targetFile),
candidates: resolved.candidates.map((c) => ({
startLine: c.startLine + 1,
endLine: c.endLine + 1,
})),
}));
process.exit(1);
}
if (!resolved.match) {
console.error(JSON.stringify({ error: 'element_not_found', fallback: 'agent-driven' }));
process.exit(1);
}
const { startLine, endLine } = resolved.match;
const commentSyntax = detectCommentSyntax(targetFile);
const styleMode = detectStyleMode(targetFile);
const isJsx = commentSyntax.open === '{/*';
const spliceIndex = computeInsertLine(startLine, endLine, position);
const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1]
?? lines[startLine]?.match(/^(\s*)/)?.[1]
?? '';
const wrapperLines = buildInsertWrapperLines({
id,
count,
indent,
commentSyntax,
isJsx,
});
const newLines = [
...lines.slice(0, spliceIndex),
...wrapperLines,
...lines.slice(spliceIndex),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
const insertLine = spliceIndex + 3;
console.log(JSON.stringify({
mode: 'insert',
position,
file: path.relative(process.cwd(), targetFile),
insertLine: insertLine + 1,
commentSyntax,
styleMode: styleMode.mode,
styleTag: styleMode.styleTag,
cssSelectorPrefixExamples: buildCssSelectorPrefixExamples(styleMode.mode, count),
cssAuthoring: buildCssAuthoring(styleMode, count),
}));
}
const _running = process.argv[1];
if (_running?.endsWith('live-insert.mjs') || _running?.endsWith('live-insert.mjs/')) {
insertCli();
}
+223 -105
View File
@@ -3,32 +3,225 @@
*
* Usage:
* npx impeccable poll # Block until browser event, print JSON
* npx impeccable poll --stream # Experimental: keep polling; one JSON line per event
* npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly
* npx impeccable poll --reply <id> done # Reply "done" to event <id>
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live-completion.mjs';
import { readLiveServerInfo } from './impeccable-paths.mjs';
// Node's built-in fetch (undici under the hood) enforces a 300s headers
// timeout that can't be lowered per-request. We cap each request below
// that ceiling and loop in `pollOnce` to synthesize a long poll without
// depending on the standalone undici package.
const PER_REQUEST_TIMEOUT_MS = 270_000;
export const PER_REQUEST_TIMEOUT_MS = 270_000;
const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer']);
function readServerInfo() {
try {
return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
} catch {
const record = readLiveServerInfo(process.cwd());
if (!record) {
console.error('No running live server found. Start one with: npx impeccable live');
process.exit(1);
}
return record.info;
}
export function buildPollReplyPayload(token, { id, type, message, file, data }) {
return { token, id, type, message, file, data };
}
export function requiresAgentReply(event) {
return EVENT_TYPES_NEEDING_AGENT_REPLY.has(event?.type);
}
export async function postReply(base, token, reply) {
const res = await fetch(`${base}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildPollReplyPayload(token, reply)),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || res.statusText);
}
}
export async function fetchServerStatus(base, token) {
const res = await fetch(`${base}/status?token=${token}`);
if (res.status === 401) {
const err = new Error('Authentication failed. The server token may have changed.');
err.code = 'AUTH_FAILED';
throw err;
}
if (!res.ok) {
throw new Error(`Status failed: ${res.status} ${res.statusText}`);
}
return res.json();
}
export function isEventPending(status, eventId) {
return (status.pendingEvents || []).some((entry) => entry.id === eventId);
}
export async function waitForEventAck(base, token, eventId, {
pollIntervalMs = 400,
maxWaitMs = 600_000,
} = {}) {
const deadline = Date.now() + maxWaitMs;
while (Date.now() < deadline) {
const status = await fetchServerStatus(base, token);
if (!isEventPending(status, eventId)) return true;
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
return false;
}
export async function fetchNextEvent(base, token, { totalDeadline } = {}) {
while (true) {
if (totalDeadline && Date.now() >= totalDeadline) {
return { type: 'timeout' };
}
const remaining = totalDeadline
? totalDeadline - Date.now()
: PER_REQUEST_TIMEOUT_MS;
const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS);
const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}`);
if (res.status === 401) {
const err = new Error('Authentication failed. The server token may have changed.');
err.code = 'AUTH_FAILED';
throw err;
}
if (!res.ok) {
throw new Error(`Poll failed: ${res.status} ${res.statusText}`);
}
const next = await res.json();
if (next?.type === 'timeout') {
if (totalDeadline && Date.now() < totalDeadline) continue;
if (!totalDeadline) continue;
return next;
}
return next;
}
}
export async function augmentEventWithAcceptHandling(event, base, token) {
if (event.type !== 'accept' && event.type !== 'discard') return event;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const acceptScript = path.join(__dirname, 'live-accept.mjs');
const scriptArgs = event.type === 'discard'
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
scriptArgs.push('--param-values', JSON.stringify(event.paramValues));
}
try {
const out = execFileSync(
'node',
[acceptScript, ...scriptArgs],
{ encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 },
);
event._acceptResult = JSON.parse(out.trim());
} catch (err) {
event._acceptResult = { handled: false, mode: 'error', error: err.message };
}
const completionType = completionTypeForAcceptResult(event.type, event._acceptResult);
try {
await postReply(base, token, {
id: event.id,
type: completionType,
message: event._acceptResult?.error,
file: event._acceptResult?.file,
data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined,
});
} catch (err) {
event._completionAck = { ok: false, error: err.message };
}
if (!event._completionAck) {
event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
}
return event;
}
export function writeCarbonizeBanner(event) {
if (event._acceptResult?.carbonize === true) {
process.stderr.write('\n⚠ Carbonize cleanup REQUIRED before next poll. After cleanup, run live-complete.mjs --id ' + event.id + '. See reference/live.md "Required after accept".\n\n');
}
}
export function printPollEvent(event) {
console.log(JSON.stringify(event));
}
export async function runPollOnce(base, token, { totalTimeout = 600_000 } = {}) {
const deadline = Date.now() + totalTimeout;
const event = await fetchNextEvent(base, token, { totalDeadline: deadline });
await augmentEventWithAcceptHandling(event, base, token);
writeCarbonizeBanner(event);
printPollEvent(event);
return event;
}
export async function runPollStream(base, token, {
ackTimeoutMs = 600_000,
ackPollIntervalMs = 400,
shouldContinue = () => true,
} = {}) {
process.stderr.write('[impeccable-poll] stream mode: one JSON object per line on stdout; use --reply while this process stays running\n');
while (shouldContinue()) {
const event = await fetchNextEvent(base, token);
await augmentEventWithAcceptHandling(event, base, token);
writeCarbonizeBanner(event);
printPollEvent(event);
if (event.type === 'exit') return event;
if (requiresAgentReply(event)) {
const acked = await waitForEventAck(base, token, event.id, {
pollIntervalMs: ackPollIntervalMs,
maxWaitMs: ackTimeoutMs,
});
if (!acked) {
const err = new Error(`Timed out waiting for --reply on event ${event.id}`);
err.code = 'ACK_TIMEOUT';
throw err;
}
}
}
return null;
}
function handlePollError(err) {
if (err.code === 'AUTH_FAILED') {
console.error(err.message);
console.error('Try restarting: npx impeccable live stop && npx impeccable live');
process.exit(1);
}
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
process.exit(1);
}
if (err.code === 'ACK_TIMEOUT') {
console.error(err.message);
process.exit(1);
}
console.error('Poll failed:', err.message);
process.exit(1);
}
export async function pollCli() {
@@ -40,13 +233,20 @@ export async function pollCli() {
Wait for a browser event from the live variant server, or reply to one.
Modes:
poll Block until a browser event arrives, print JSON
poll --reply <id> done Reply "done" to event <id>
poll Block until a browser event arrives, print JSON, exit
poll --stream Keep polling; print one JSON line per event (see live.md)
poll --reply <id> done Reply "done" to event <id> (replace or insert generate)
poll --reply <id> steer_done Reply after handling a steer event (unlocks Steer bar)
poll --reply <id> error "msg" Reply with an error message
Options:
--timeout=MS Long-poll timeout in ms (default: 600000). Use the default unless the user asked to pause live; never use a short timeout to end the chat turn
--help Show this help message`);
--timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode
--ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000)
--help Show this help message
Harness note:
Default one-shot mode is the portable contract for Claude Code, Codex, and Cursor.
--stream is experimental for harnesses with fast incremental stdout; do not use on Cursor.`);
process.exit(0);
}
@@ -60,7 +260,6 @@ Options:
const status = args[replyIdx + 2] || 'done';
const fileIdx = args.indexOf('--file');
const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined;
// Message is any remaining positional arg that isn't a flag
const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined;
if (!id) {
@@ -69,25 +268,7 @@ Options:
}
try {
const res = await fetch(`${base}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: info.token,
id,
type: status,
message,
file: filePath,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
console.error(`Reply failed (${res.status}):`, body.error || res.statusText);
process.exit(1);
}
// Success — silent exit (agent doesn't need output for replies)
await postReply(base, info.token, { id, type: status, message, file: filePath });
} catch (err) {
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
@@ -99,84 +280,21 @@ Options:
return;
}
// Poll mode: block until browser event. Default 10 min. Node's built-in
// fetch enforces a 300s headers timeout, so we loop in slices under that
// ceiling and keep re-polling until we get a real event or the user's
// total timeout runs out.
const timeoutArg = args.find(a => a.startsWith('--timeout='));
const totalTimeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600000;
const streamMode = args.includes('--stream');
const ackTimeoutArg = args.find((a) => a.startsWith('--ack-timeout='));
const ackTimeoutMs = ackTimeoutArg ? parseInt(ackTimeoutArg.split('=')[1], 10) : 600_000;
const deadline = Date.now() + totalTimeout;
let event;
try {
while (true) {
const remaining = deadline - Date.now();
if (remaining <= 0) {
event = { type: 'timeout' };
break;
}
const slice = Math.min(remaining, PER_REQUEST_TIMEOUT_MS);
const res = await fetch(`${base}/poll?token=${info.token}&timeout=${slice}`);
if (res.status === 401) {
console.error('Authentication failed. The server token may have changed.');
console.error('Try restarting: npx impeccable live stop && npx impeccable live');
process.exit(1);
}
if (!res.ok) {
console.error(`Poll failed: ${res.status} ${res.statusText}`);
process.exit(1);
}
const next = await res.json();
// Server-side timeout means no browser event arrived in this slice.
// Loop and re-poll until we get a real event or we hit the user's
// total deadline.
if (next?.type === 'timeout' && Date.now() < deadline) continue;
event = next;
break;
if (streamMode) {
await runPollStream(base, info.token, { ackTimeoutMs });
return;
}
// Auto-handle accept/discard via deterministic script
if (event.type === 'accept' || event.type === 'discard') {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const acceptScript = path.join(__dirname, 'live-accept.mjs');
const scriptArgs = event.type === 'discard'
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
// Pass through a JSON blob; the shell-safe wrap uses single quotes because
// values are finite {id, number|string|boolean} pairs from a validated payload.
scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`);
}
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
{ encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
);
event._acceptResult = JSON.parse(out.trim());
} catch (err) {
event._acceptResult = { handled: false, error: err.message };
}
}
// Second signal path: stderr banner in case the agent parses stdout
// JSON but skips nested fields. One line is enough — the full checklist
// is in reference/live.md.
if (event._acceptResult?.carbonize === true) {
process.stderr.write('\n⚠ Carbonize cleanup REQUIRED before next poll. See reference/live.md "Required after accept".\n\n');
}
// Print the event as JSON — the agent reads this from stdout
console.log(JSON.stringify(event));
const timeoutArg = args.find((a) => a.startsWith('--timeout='));
const totalTimeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600_000;
await runPollOnce(base, info.token, { totalTimeout });
} catch (err) {
if (err.cause?.code === 'ECONNREFUSED') {
console.error('Live server not running. Start one with: npx impeccable live');
} else {
console.error('Poll failed:', err.message);
}
process.exit(1);
handlePollError(err);
}
}
@@ -0,0 +1,48 @@
#!/usr/bin/env node
/**
* Recover the next agent action from the durable live-session journal.
*/
import { createLiveSessionStore } from './live-session-store.mjs';
function parseArgs(argv) {
const out = { id: null };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--id') out.id = argv[++i];
else if (arg.startsWith('--id=')) out.id = arg.slice('--id='.length);
else if (arg === '--help' || arg === '-h') out.help = true;
}
return out;
}
export async function resumeCli() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
console.log(`Usage: node live-resume.mjs [--id SESSION_ID]\n\nPrint the active durable session checkpoint and the next safe agent action.`);
return;
}
const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id || undefined });
const snapshot = args.id ? store.getSnapshot(args.id) : store.listActiveSessions()[0] || null;
if (!snapshot) {
console.log(JSON.stringify({ active: false, nextAction: 'No active durable live session found.' }, null, 2));
return;
}
const pending = snapshot.pendingEvent || null;
const nextAction = pending
? `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.`
: snapshot.phase === 'carbonize_required'
? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.`
: snapshot.phase === 'accept_requested'
? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.`
: `Inspect ${snapshot.id}; no pending agent event is currently queued.`;
console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, nextAction }, null, 2));
}
const _running = process.argv[1];
if (_running?.endsWith('live-resume.mjs') || _running?.endsWith('live-resume.mjs/')) {
resumeCli();
}
+210 -88
View File
@@ -21,11 +21,23 @@ import path from 'node:path';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
import { parseDesignMd } from './design-parser.mjs';
import { resolveContextDir } from './context.mjs';
import { createLiveSessionStore } from './live-session-store.mjs';
import { validateEvent } from './live-event-validation.mjs';
import {
getDesignSidecarPath,
getLiveAnnotationsDir,
readLiveServerInfo,
removeLiveServerInfo,
resolveDesignSidecarPath,
writeLiveServerInfo,
} from './impeccable-paths.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// PID file in the project root so both the server and agent can find it
// predictably (os.tmpdir() varies across platforms).
const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
// DESIGN.json fallback for existing projects.
const CONTEXT_DIR = resolveContextDir(process.cwd());
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
@@ -52,10 +64,13 @@ const state = {
token: null,
port: null,
sseClients: new Set(), // SSE response objects (server→browser push)
pendingEvents: [], // browser events waiting for agent poll
pendingEvents: [], // browser events waiting for agent ack ({ event, leaseUntil })
pendingPolls: [], // agent poll callbacks waiting for browser events
lastAgentPollingBroadcast: null,
exitTimer: null,
sessionDir: null, // per-session tmp dir for annotation screenshots
sessionStore: null,
leaseTimer: null,
};
// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB;
@@ -63,13 +78,87 @@ const state = {
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
function enqueueEvent(event) {
if (state.pendingPolls.length > 0) {
state.pendingPolls.shift()(event);
} else {
state.pendingEvents.push(event);
if (!event || (event.id && state.pendingEvents.some((entry) => entry.event?.id === event.id && entry.event?.type === event.type))) return;
state.pendingEvents.push({ event, leaseUntil: 0 });
flushPendingPolls();
}
function restorePendingEventsFromStore() {
if (!state.sessionStore) return;
for (const snapshot of state.sessionStore.listActiveSessions()) {
if (snapshot.pendingEvent) enqueueEvent(snapshot.pendingEvent);
}
}
function findAvailablePendingEvent(now = Date.now()) {
return state.pendingEvents.find((entry) => !entry.leaseUntil || entry.leaseUntil <= now);
}
function leaseEvent(entry, leaseMs) {
if (!entry.event?.id) {
const idx = state.pendingEvents.indexOf(entry);
if (idx !== -1) state.pendingEvents.splice(idx, 1);
return entry.event;
}
entry.leaseUntil = Date.now() + leaseMs;
return entry.event;
}
function acknowledgePendingEvent(id) {
if (!id) return false;
const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id);
if (idx === -1) return false;
state.pendingEvents.splice(idx, 1);
scheduleLeaseFlush();
return true;
}
function scheduleLeaseFlush() {
if (state.leaseTimer) {
clearTimeout(state.leaseTimer);
state.leaseTimer = null;
}
if (state.pendingPolls.length === 0) return;
const now = Date.now();
const nextLeaseUntil = state.pendingEvents
.map((entry) => entry.leaseUntil || 0)
.filter((leaseUntil) => leaseUntil > now)
.sort((a, b) => a - b)[0];
if (!nextLeaseUntil) return;
state.leaseTimer = setTimeout(() => {
state.leaseTimer = null;
flushPendingPolls();
}, Math.max(0, nextLeaseUntil - now));
}
function flushPendingPolls() {
let changed = false;
while (state.pendingPolls.length > 0) {
const entry = findAvailablePendingEvent();
if (!entry) {
scheduleLeaseFlush();
broadcastAgentPollingIfChanged();
return;
}
const poll = state.pendingPolls.shift();
poll.resolve(leaseEvent(entry, poll.leaseMs));
changed = true;
}
scheduleLeaseFlush();
if (changed) broadcastAgentPollingIfChanged();
}
function agentPollingConnected() {
return state.pendingPolls.length > 0;
}
function broadcastAgentPollingIfChanged() {
const connected = agentPollingConnected();
if (state.lastAgentPollingBroadcast === connected) return;
state.lastAgentPollingBroadcast = connected;
broadcast({ type: 'agent_polling', connected });
}
/** Push a message to all connected SSE clients. */
function broadcast(msg) {
const data = 'data: ' + JSON.stringify(msg) + '\n\n';
@@ -83,12 +172,14 @@ function broadcast(msg) {
// ---------------------------------------------------------------------------
function loadBrowserScripts() {
// Detection script: look relative to the skill scripts dir, then fall back
// to the npm package location (src/detect-antipatterns-browser.js).
// Detection script: prefer the skill-bundled detector, then fall back to
// source/npm package locations for local development and older installs.
// This one IS cached — detect.js rarely changes during a session.
const detectPaths = [
path.join(__dirname, '..', '..', '..', '..', 'src', 'detect-antipatterns-browser.js'),
path.join(process.cwd(), 'node_modules', 'impeccable', 'src', 'detect-antipatterns-browser.js'),
path.join(__dirname, 'detector', 'detect-antipatterns-browser.js'),
path.join(__dirname, '..', '..', 'cli', 'engine', 'detect-antipatterns-browser.js'),
path.join(__dirname, '..', '..', '..', '..', 'cli', 'engine', 'detect-antipatterns-browser.js'),
path.join(process.cwd(), 'node_modules', 'impeccable', 'cli', 'engine', 'detect-antipatterns-browser.js'),
];
let detectScript = '';
for (const p of detectPaths) {
@@ -98,22 +189,24 @@ function loadBrowserScripts() {
// live-browser.js: DO NOT cache. Return the path so the /live.js handler
// can re-read on every request. Editing the browser script during iteration
// should land on the next tab reload, not require a server restart.
const sessionPath = path.join(__dirname, 'live-browser-session.js');
const livePath = path.join(__dirname, 'live-browser.js');
if (!fs.existsSync(livePath)) {
process.stderr.write('Error: live-browser.js not found at ' + livePath + '\n');
process.exit(1);
for (const p of [sessionPath, livePath]) {
if (!fs.existsSync(p)) {
process.stderr.write('Error: live browser script not found at ' + p + '\n');
process.exit(1);
}
}
return { detectScript, livePath };
return { detectScript, sessionPath, livePath };
}
function hasProjectContext() {
// PRODUCT.md carries brand voice / anti-references — that's what determines
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
// concern, surfaced by the design panel's own empty state. Legacy
// .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs.
// concern, surfaced by the design panel's own empty state.
try {
fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK);
fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
return true;
} catch { return false; }
}
@@ -122,54 +215,11 @@ function statOrNull(filePath) {
try { return fs.statSync(filePath); } catch { return null; }
}
// ---------------------------------------------------------------------------
// Validation (inline — no external import needed for self-contained script)
// ---------------------------------------------------------------------------
const VISUAL_ACTIONS = [
'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset',
'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive',
];
function validateEvent(msg) {
if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message';
switch (msg.type) {
case 'generate':
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action';
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context';
// Optional annotation fields (all-or-nothing: if any present, all must be well-formed).
if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string';
if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array';
if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array';
return null;
case 'accept':
if (!msg.id) return 'accept: missing id';
if (!msg.variantId) return 'accept: missing variantId';
if (msg.paramValues !== undefined) {
if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) {
return 'accept: paramValues must be an object';
}
}
return null;
case 'discard':
return msg.id ? null : 'discard: missing id';
case 'exit':
return null;
case 'prefetch':
if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl';
return null;
default:
return 'Unknown event type: ' + msg.type;
}
}
// ---------------------------------------------------------------------------
// HTTP request handler
// ---------------------------------------------------------------------------
function createRequestHandler({ detectScript, livePath }) {
function createRequestHandler({ detectScript, sessionPath, livePath }) {
return (req, res) => {
const url = new URL(req.url, `http://localhost:${state.port}`);
res.setHeader('Access-Control-Allow-Origin', '*');
@@ -185,17 +235,20 @@ function createRequestHandler({ detectScript, livePath }) {
// the next tab reload. No-store headers prevent browser caching across
// sessions — during iteration, a cached old script silently breaks
// every subsequent session.
let sessionScript;
let liveScript;
try {
sessionScript = fs.readFileSync(sessionPath, 'utf-8');
liveScript = fs.readFileSync(livePath, 'utf-8');
} catch (err) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Error reading live-browser.js: ' + err.message);
res.end('Error reading live browser scripts: ' + err.message);
return;
}
const body =
`window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` +
`window.__IMPECCABLE_PORT__ = ${state.port};\n` +
sessionScript + '\n' +
liveScript;
res.writeHead(200, {
'Content-Type': 'application/javascript',
@@ -290,6 +343,27 @@ function createRequestHandler({ detectScript, livePath }) {
}
// --- Health ---
if (p === '/status') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; }
const sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : [];
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: 'ok',
port: state.port,
connectedClients: state.sseClients.size,
agentPolling: agentPollingConnected(),
pendingEvents: state.pendingEvents.map((entry) => ({
id: entry.event?.id,
type: entry.event?.type,
leased: !!(entry.leaseUntil && entry.leaseUntil > Date.now()),
leaseUntil: entry.leaseUntil || null,
})),
activeSessions: sessions,
}));
return;
}
if (p === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
@@ -301,13 +375,13 @@ function createRequestHandler({ detectScript, livePath }) {
}
// --- Design system (unified v2 response) + raw ---
// /design-system.json returns both parsed DESIGN.md and DESIGN.json
// /design-system.json returns both parsed DESIGN.md and .impeccable/design.json
// sidecar when present. Panel merges them:
// { present, parsed, sidecar, hasMd, hasSidecar,
// mdNewerThanJson, parseError?, sidecarError? }
// - parsed: output of parseDesignMd (frontmatter
// + six canonical sections) when DESIGN.md exists.
// - sidecar: DESIGN.json contents when present.
// - sidecar: .impeccable/design.json contents when present.
// Expected shape: schemaVersion 2, carrying
// extensions + components + narrative.
// /design-system/raw returns DESIGN.md markdown verbatim
@@ -315,8 +389,8 @@ function createRequestHandler({ detectScript, livePath }) {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
const mdPath = path.join(process.cwd(), 'DESIGN.md');
const jsonPath = path.join(process.cwd(), 'DESIGN.json');
const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
const mdStat = statOrNull(mdPath);
const jsonStat = statOrNull(jsonPath);
@@ -352,7 +426,7 @@ function createRequestHandler({ detectScript, livePath }) {
try {
response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
} catch (err) {
response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message;
response.sidecarError = 'Failed to parse .impeccable/design.json: ' + err.message;
}
}
@@ -389,6 +463,7 @@ function createRequestHandler({ detectScript, livePath }) {
res.write('data: ' + JSON.stringify({
type: 'connected',
hasProjectContext: hasProjectContext(),
agentPolling: agentPollingConnected(),
}) + '\n\n');
state.sseClients.add(res);
@@ -434,7 +509,16 @@ function createRequestHandler({ detectScript, livePath }) {
res.end(JSON.stringify({ error }));
return;
}
enqueueEvent(msg);
if (state.sessionStore && msg.id) {
try {
state.sessionStore.appendEvent(msg);
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'session_store_append_failed', message: err.message }));
return;
}
}
if (msg.type !== 'checkpoint') enqueueEvent(msg);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
@@ -477,14 +561,18 @@ function handlePollGet(req, res, url) {
return;
}
const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
if (state.pendingEvents.length > 0) {
const leaseMs = parseInt(url.searchParams.get('leaseMs') || '30000', 10);
const available = findAvailablePendingEvent();
if (available) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(state.pendingEvents.shift()));
res.end(JSON.stringify(leaseEvent(available, leaseMs)));
return;
}
const poll = { resolve, leaseMs };
const timer = setTimeout(() => {
const idx = state.pendingPolls.indexOf(resolve);
const idx = state.pendingPolls.indexOf(poll);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
broadcastAgentPollingIfChanged();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ type: 'timeout' }));
}, timeout);
@@ -493,11 +581,14 @@ function handlePollGet(req, res, url) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(event));
}
state.pendingPolls.push(resolve);
state.pendingPolls.push(poll);
broadcastAgentPollingIfChanged();
scheduleLeaseFlush();
req.on('close', () => {
clearTimeout(timer);
const idx = state.pendingPolls.indexOf(resolve);
const idx = state.pendingPolls.indexOf(poll);
if (idx !== -1) state.pendingPolls.splice(idx, 1);
broadcastAgentPollingIfChanged();
});
}
@@ -516,6 +607,28 @@ function handlePollPost(req, res) {
res.end(JSON.stringify({ error: 'Unauthorized' }));
return;
}
acknowledgePendingEvent(msg.id);
if (state.sessionStore && msg.id) {
try {
const eventType = msg.type === 'steer_done'
? 'steer_done'
: msg.type === 'discard' || msg.type === 'discarded'
? 'discarded'
: msg.type === 'complete'
? 'complete'
: msg.type === 'error'
? 'agent_error'
: 'agent_done';
state.sessionStore.appendEvent({
type: eventType,
id: msg.id,
file: msg.file,
message: msg.message,
carbonize: msg.data?.carbonize === true,
});
} catch { /* keep reply path best-effort; browser still needs SSE */ }
}
flushPendingPolls();
// Forward the reply to the browser via SSE
broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data });
res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -530,13 +643,15 @@ function handlePollPost(req, res) {
let httpServer = null;
function shutdown() {
try { fs.unlinkSync(LIVE_PID_FILE); } catch {}
removeLiveServerInfo(process.cwd());
if (state.leaseTimer) clearTimeout(state.leaseTimer);
state.leaseTimer = null;
if (state.sessionDir) {
try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {}
}
for (const res of state.sseClients) { try { res.end(); } catch {} }
state.sseClients.clear();
for (const resolve of state.pendingPolls) resolve({ type: 'exit' });
for (const poll of state.pendingPolls) poll.resolve({ type: 'exit' });
state.pendingPolls.length = 0;
if (httpServer) httpServer.close();
process.exit(0);
@@ -572,6 +687,7 @@ Endpoints:
/events SSE stream (serverbrowser) + POST (browserserver)
/poll Long-poll for agent CLI
/source Raw source file reader (no-HMR fallback)
/status Durable recovery status (token-protected)
/health Health check`);
process.exit(0);
}
@@ -579,7 +695,7 @@ Endpoints:
if (args.includes('stop')) {
const keepInject = args.includes('--keep-inject');
try {
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
const { info } = readLiveServerInfo(process.cwd()) || {};
const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`);
if (res.ok) console.log(`Stopped live server on port ${info.port}.`);
} catch {
@@ -630,7 +746,7 @@ if (args.includes('--background')) {
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
try {
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
const { info } = readLiveServerInfo(process.cwd()) || {};
if (info.pid !== process.pid) {
// Output JSON so the agent can read port + token from stdout.
console.log(JSON.stringify(info));
@@ -644,30 +760,36 @@ if (args.includes('--background')) {
}
// Check for existing session
try {
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
try { process.kill(existing.pid, 0);
const existingRecord = readLiveServerInfo(process.cwd());
if (existingRecord?.info) {
const existing = existingRecord.info;
try {
process.kill(existing.pid, 0);
console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`);
console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop');
process.exit(1);
} catch { fs.unlinkSync(LIVE_PID_FILE); }
} catch {}
} catch {
try { fs.unlinkSync(existingRecord.path); } catch {}
}
}
state.token = randomUUID();
state.sessionStore = createLiveSessionStore({ cwd: process.cwd() });
restorePendingEventsFromStore();
const portArg = args.find(a => a.startsWith('--port='));
state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort();
// Annotation screenshots live in the project root so the agent's Read tool
// doesn't trip a per-file permission prompt. Sessioned by token so concurrent
// projects (or quick restarts) don't collide.
const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations');
const annotRoot = getLiveAnnotationsDir(process.cwd());
fs.mkdirSync(annotRoot, { recursive: true });
state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-'));
const { detectScript, livePath } = loadBrowserScripts();
httpServer = http.createServer(createRequestHandler({ detectScript, livePath }));
const { detectScript, sessionPath, livePath } = loadBrowserScripts();
httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath }));
httpServer.listen(state.port, '127.0.0.1', () => {
fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token }));
writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token });
const url = `http://localhost:${state.port}`;
console.log(`\nImpeccable live server running on ${url}`);
console.log(`Token: ${state.token}\n`);
@@ -0,0 +1,265 @@
import fs from 'node:fs';
import path from 'node:path';
import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs';
const COMPLETED_PHASES = new Set(['completed', 'discarded']);
export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
const rootDir = getLiveSessionsDir(cwd);
const legacyRootDir = getLegacyLiveSessionsDir(cwd);
fs.mkdirSync(rootDir, { recursive: true });
const snapshotCache = new Map();
function loadCachedOrRebuild(id) {
const cached = snapshotCache.get(id);
if (cached) return cached;
const journalPath = getReadableJournalPath(id);
const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
snapshotCache.set(id, rebuilt);
return rebuilt;
}
function getReadableJournalPath(id) {
const primary = getJournalPath(rootDir, id);
if (fs.existsSync(primary)) return primary;
const legacy = getJournalPath(legacyRootDir, id);
if (fs.existsSync(legacy)) return legacy;
return primary;
}
return {
rootDir,
legacyRootDir,
appendEvent(event) {
const normalized = normalizeEvent(event, sessionId);
const journalPath = getJournalPath(rootDir, normalized.id);
const snapshotPath = getSnapshotPath(rootDir, normalized.id);
const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id);
if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
fs.copyFileSync(legacyJournalPath, journalPath);
}
const prior = loadCachedOrRebuild(normalized.id);
const seq = prior.nextSeq;
const entry = {
seq,
id: normalized.id,
type: normalized.type,
ts: new Date().toISOString(),
event: normalized,
};
fs.appendFileSync(journalPath, JSON.stringify(entry) + '\n');
const next = applyEvent(prior.snapshot, entry, prior.diagnostics);
snapshotCache.set(normalized.id, { snapshot: next, diagnostics: next.diagnostics || [], nextSeq: seq + 1 });
writeSnapshot(snapshotPath, next);
return next;
},
getSnapshot(id = sessionId, opts = {}) {
if (!id) throw new Error('session id required');
const journalPath = getReadableJournalPath(id);
const snapshotPath = getSnapshotPath(rootDir, id);
const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
snapshotCache.set(id, rebuilt);
writeSnapshot(snapshotPath, rebuilt.snapshot);
if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
return rebuilt.snapshot;
},
listActiveSessions() {
const ids = new Set();
for (const dir of [legacyRootDir, rootDir]) {
if (!fs.existsSync(dir)) continue;
for (const name of fs.readdirSync(dir)) {
if (name.endsWith('.jsonl')) ids.add(name.slice(0, -'.jsonl'.length));
}
}
return [...ids]
.sort()
.map((id) => this.getSnapshot(id))
.filter(Boolean);
},
};
}
function normalizeEvent(event, fallbackId) {
if (!event || typeof event !== 'object') throw new Error('event object required');
const id = event.id || fallbackId;
if (!id || typeof id !== 'string') throw new Error('event id required');
if (!event.type || typeof event.type !== 'string') throw new Error('event type required');
return { ...event, id };
}
function getJournalPath(rootDir, id) {
return path.join(rootDir, safeSessionId(id) + '.jsonl');
}
function getSnapshotPath(rootDir, id) {
return path.join(rootDir, safeSessionId(id) + '.snapshot.json');
}
function safeSessionId(id) {
if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error('invalid session id: ' + id);
return id;
}
function baseSnapshot(id) {
return {
id,
phase: 'new',
pageUrl: null,
sourceFile: null,
expectedVariants: 0,
arrivedVariants: 0,
visibleVariant: null,
paramValues: {},
pendingEventSeq: null,
pendingEvent: null,
deliveryLease: null,
checkpointRevision: 0,
activeOwner: null,
sourceMarkers: {},
fallbackMode: null,
annotationArtifacts: [],
diagnostics: [],
updatedAt: null,
};
}
function rebuildSnapshotFromJournal(journalPath, id) {
let snapshot = baseSnapshot(id);
const diagnostics = [];
let nextSeq = 1;
if (!fs.existsSync(journalPath)) return { snapshot, diagnostics, nextSeq };
const lines = fs.readFileSync(journalPath, 'utf-8').split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.trim()) continue;
try {
const entry = JSON.parse(line);
if (!entry || typeof entry !== 'object') throw new Error('entry is not object');
if (Number.isInteger(entry.seq)) nextSeq = Math.max(nextSeq, entry.seq + 1);
snapshot = applyEvent(snapshot, entry);
} catch (err) {
diagnostics.push({
error: 'journal_parse_failed',
line: i + 1,
message: err.message,
});
}
}
snapshot.diagnostics = [...snapshot.diagnostics, ...diagnostics];
return { snapshot, diagnostics, nextSeq };
}
function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
const event = entry.event || entry;
const next = {
...snapshot,
paramValues: { ...(snapshot.paramValues || {}) },
sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
diagnostics: [...(snapshot.diagnostics || [])],
updatedAt: entry.ts || new Date().toISOString(),
};
if (inheritedDiagnostics.length && next.diagnostics.length === 0) {
next.diagnostics = [...inheritedDiagnostics];
}
switch (event.type) {
case 'generate':
next.phase = 'generate_requested';
next.pageUrl = event.pageUrl ?? next.pageUrl;
next.expectedVariants = event.count ?? next.expectedVariants;
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
next.pendingEvent = toPendingEvent(event);
if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
break;
case 'variants_ready':
case 'agent_done':
next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
next.sourceFile = event.file ?? next.sourceFile;
next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants);
next.pendingEventSeq = null;
next.pendingEvent = null;
if (event.carbonize === true) {
next.diagnostics.push({
error: 'carbonize_cleanup_required',
file: event.file || null,
message: 'Accepted variant still has carbonize markers that must be folded into source CSS.',
});
}
break;
case 'checkpoint':
if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
next.phase = event.phase ?? next.phase;
next.checkpointRevision = event.revision ?? next.checkpointRevision;
next.activeOwner = event.owner ?? next.activeOwner;
next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
if (event.paramValues) next.paramValues = { ...event.paramValues };
} else {
next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
}
break;
case 'accept':
case 'accept_intent':
next.phase = 'accept_requested';
next.visibleVariant = Number(event.variantId ?? next.visibleVariant);
if (event.paramValues) next.paramValues = { ...event.paramValues };
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
next.pendingEvent = toPendingEvent(event);
break;
case 'steer':
next.phase = 'steer_requested';
next.pageUrl = event.pageUrl ?? next.pageUrl;
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
next.pendingEvent = toPendingEvent(event);
break;
case 'steer_done':
next.phase = 'steer_done';
next.pendingEventSeq = null;
next.pendingEvent = null;
break;
case 'discard':
next.phase = 'discard_requested';
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
next.pendingEvent = toPendingEvent(event);
break;
case 'discarded':
next.phase = 'discarded';
next.pendingEventSeq = null;
next.pendingEvent = null;
break;
case 'complete':
next.phase = 'completed';
next.pendingEventSeq = null;
next.pendingEvent = null;
break;
case 'agent_error':
next.phase = 'agent_error';
next.pendingEventSeq = null;
next.pendingEvent = null;
next.diagnostics.push({ error: 'agent_error', message: event.message || 'unknown agent error' });
break;
default:
next.diagnostics.push({ error: 'unknown_event_type', type: event.type });
break;
}
return next;
}
function toPendingEvent(event) {
const pending = { ...event };
delete pending.token;
return pending;
}
function upsertArtifact(artifacts, artifact) {
if (!artifacts.some((existing) => existing.path === artifact.path && existing.type === artifact.type)) {
artifacts.push(artifact);
}
}
function writeSnapshot(snapshotPath, snapshot) {
fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2) + '\n');
}
@@ -0,0 +1,48 @@
#!/usr/bin/env node
/**
* Print durable recovery status for Impeccable live sessions.
*/
import { createLiveSessionStore } from './live-session-store.mjs';
import { readLiveServerInfo } from './impeccable-paths.mjs';
function readServerInfo() {
return readLiveServerInfo(process.cwd())?.info || null;
}
async function fetchServerStatus(info) {
if (!info) return null;
try {
const res = await fetch(`http://localhost:${info.port}/status?token=${info.token}`);
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
export async function statusCli() {
const info = readServerInfo();
const server = await fetchServerStatus(info);
const store = createLiveSessionStore({ cwd: process.cwd() });
const activeSessions = store.listActiveSessions();
const payload = {
liveServer: server ? {
status: server.status,
port: server.port,
connectedClients: server.connectedClients,
agentPolling: server.agentPolling,
pendingEvents: server.pendingEvents,
} : null,
activeSessions: server?.activeSessions || activeSessions,
recoveryHint: server
? 'Run live-poll.mjs to continue pending work, or live-complete.mjs --id <session> after manual cleanup.'
: 'Start live-server.mjs to requeue pending durable events, then run live-poll.mjs.',
};
console.log(JSON.stringify(payload, null, 2));
}
const _running = process.argv[1];
if (_running?.endsWith('live-status.mjs') || _running?.endsWith('live-status.mjs/')) {
statusCli();
}
+269 -18
View File
@@ -37,6 +37,10 @@ Element identification (at least one required):
Optional:
--file PATH Source file to search in (skips auto-detection)
--text TEXT Picked element's textContent. Used to disambiguate when
classes/tag match multiple sibling elements (e.g. a list
of <Card>s with the same className). Pass the first ~80
chars of event.element.textContent.
--help Show this help message
Output (JSON):
@@ -53,6 +57,7 @@ The agent should insert variant HTML at insertLine.`);
const tag = argVal(args, '--tag');
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
@@ -115,35 +120,117 @@ The agent should insert variant HTML at insertLine.`);
const content = fs.readFileSync(targetFile, 'utf-8');
const lines = content.split('\n');
// Find the element, trying each query in priority order.
// Pass tag hint so findElement can reject matches inside wrong element types
// and walk backward to the real opener on multi-line JSX tags.
// Find the element, trying each query in priority order. When `--text` is
// supplied, collect every candidate the queries surface and disambiguate
// by the picked element's textContent. Without `--text`, fall back to the
// legacy first-match behavior so unmodified callers keep working.
let match = null;
for (const q of queries) {
match = findElement(lines, q, tag);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
if (text) {
const candidates = [];
for (const q of queries) {
const all = findAllElements(lines, q, tag);
for (const c of all) {
if (!candidates.some((x) => x.startLine === c.startLine)) {
candidates.push(c);
}
}
// Once a more-specific query (ID, full className combo) yielded a unique
// result, stop — falling through to the loose tag+single-class query
// would readmit the siblings we just disambiguated past.
if (candidates.length === 1) break;
}
if (candidates.length === 0) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
if (candidates.length === 1) {
match = candidates[0];
} else {
const filtered = filterByText(candidates, lines, text);
if (filtered.length === 1) {
match = filtered[0];
} else if (filtered.length === 0) {
// Source uses dynamic content (`<h1>{title}</h1>` etc.) so the
// browser-side textContent doesn't appear literally in source. Fall
// back to first-match rather than refusing — this is the same
// behavior unmodified callers see, just preserved.
match = candidates[0];
} else {
// Multiple candidates ALSO match the text. Truly ambiguous — refuse
// rather than pick wrong, and hand the agent the candidate locations
// so it can disambiguate by reading the file.
console.error(JSON.stringify({
error: 'element_ambiguous',
fallback: 'agent-driven',
file: path.relative(process.cwd(), targetFile),
candidates: filtered.map((c) => ({
startLine: c.startLine + 1,
endLine: c.endLine + 1,
})),
hint: 'Multiple source elements match both classes/tag and textContent. Pass --element-id, a more specific --text, or write the wrapper manually. See "Handle fallback" in live.md.',
}));
process.exit(1);
}
}
} else {
for (const q of queries) {
match = findElement(lines, q, tag);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
}
const { startLine, endLine } = match;
const commentSyntax = detectCommentSyntax(targetFile);
const styleMode = detectStyleMode(targetFile);
const isJsx = commentSyntax.open === '{/*';
const indent = lines[startLine].match(/^(\s*)/)[1];
// Extract the original element
// Extract the original element. Reindent under the wrapper while preserving
// the relative depth between lines — `l.trimStart()` would strip ALL leading
// whitespace and collapse e.g. `<aside>`/` <h1>`/`</aside>` (6/8/6 spaces)
// to a single uniform indent, so on accept/discard the round-trip restores
// the inner element at its parent's depth instead of nested inside it.
// Strip only the COMMON minimum leading whitespace across the picked lines;
// `deindentContent` on the accept side already mirrors this convention.
const originalLines = lines.slice(startLine, endLine + 1);
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
const originalBaseIndent = minLeadingSpaces(originalLines);
const reindentOriginal = (extra) => originalLines
.map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
.join('\n');
const originalIndented = reindentOriginal(' ');
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
// JSX requires object-literal style and parses string attrs as HTML (which
// either type-errors or renders a literal CSS string).
const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
// Build the wrapper
const wrapperLines = [
// JSX/TSX guard: the picked element occupies a single JSX child slot
// (inside `return (...)`, an array `.map(...)`, an `asChild` branch, or
// any other expression position). Replacing it with `comment + <div> +
// comment` yields three adjacent siblings — invalid JSX. We can't use a
// Fragment `<></>` either: parents that clone children (Radix `asChild`,
// Headless UI, etc.) hit "Invalid prop supplied to React.Fragment" when
// they try to pass an `id` through.
//
// Solution: keep the wrapper `<div>` as the single JSX-slot child and
// tuck both marker comments INSIDE it. accept/discard then expands its
// replacement range to include the wrapper's `<div>` open / close lines
// so the entire scaffold gets removed cleanly.
const wrapperLines = isJsx ? [
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original">',
reindentOriginal(' '),
indent + ' </div>',
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
indent + '</div>',
] : [
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
@@ -163,15 +250,30 @@ The agent should insert variant HTML at insertLine.`);
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment)
const insertLine = startLine + 6; // 0-indexed in the new file
// Calculate insert line (the "insert below this line" comment).
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
// the insert marker (HTML: start-comment + outer-div + Original-comment +
// original-div + content + close-original-div; JSX: outer-div +
// start-comment + Original-comment + original-div + content +
// close-original-div). Multi-line originals push the marker by their
// extra line count.
const insertLine = startLine + 6 + (originalLines.length - 1);
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that
// spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
styleMode: styleMode.mode,
styleTag: styleMode.styleTag,
cssSelectorPrefixExamples: buildCssSelectorPrefixExamples(styleMode.mode, count),
cssAuthoring: buildCssAuthoring(styleMode, count),
originalLineCount: originalLines.length,
}));
}
@@ -238,6 +340,65 @@ function detectCommentSyntax(filePath) {
return { open: '<!--', close: '-->' };
}
function detectStyleMode(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.astro') {
return {
mode: 'astro-global-prefixed',
styleTag: '<style is:inline data-impeccable-css="SESSION_ID">',
};
}
return {
mode: 'scoped',
styleTag: '<style data-impeccable-css="SESSION_ID">',
};
}
function buildCssSelectorPrefixExamples(styleMode, count) {
if (styleMode !== 'astro-global-prefixed') return [];
return Array.from({ length: count }, (_, i) => `[data-impeccable-variant="${i + 1}"]`);
}
function buildCssAuthoring(styleMode, count) {
const variantNumbers = Array.from({ length: count }, (_, i) => i + 1);
if (styleMode.mode === 'astro-global-prefixed') {
return {
mode: styleMode.mode,
styleTag: styleMode.styleTag,
strategy: 'global-prefixed',
rulePattern: '[data-impeccable-variant="N"] > .variant-class { ... }',
selectorExamples: variantNumbers.map((n) => `[data-impeccable-variant="${n}"] > .variant-class`),
requirements: [
'Use the styleTag exactly; the is:inline attribute is required for this file.',
'Put raw CSS directly between the styleTag opening and a plain </style> close.',
'Prefix every preview selector with the matching [data-impeccable-variant="N"] selector.',
'Keep selectors anchored to the generated variant wrapper; do not rely on component CSS scoping for preview rules.',
],
forbidden: [
'Do not use @scope for this styleMode.',
'Do not wrap style content in a JSX/TSX template literal ({` ... `}); that syntax is for .tsx/.jsx only.',
'Do not put { immediately after the style opening tag; Astro parses { as expression syntax.',
],
};
}
return {
mode: styleMode.mode,
styleTag: styleMode.styleTag,
strategy: 'scope-rule',
rulePattern: '@scope ([data-impeccable-variant="N"]) { :scope > .variant-class { ... } }',
selectorExamples: variantNumbers.map((n) => `@scope ([data-impeccable-variant="${n}"]) { :scope > .variant-class { ... } }`),
requirements: [
'Use @scope blocks keyed to each [data-impeccable-variant="N"] wrapper.',
'Inside each @scope block, make :scope rules step into the replacement element with a descendant combinator.',
'Use the styleTag exactly; do not add framework-specific style attributes unless this object says to.',
],
forbidden: [
'Do not use global [data-impeccable-variant="N"] selector prefixes for this styleMode.',
'Do not add is:inline to the style tag for this styleMode.',
],
};
}
/**
* Search project files for the query string (class name, ID, etc.)
* Returns the first matching file path, or null.
@@ -310,6 +471,22 @@ const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
* line to find the actual tag opener. When `tag` is provided, opener candidates
* must match that tag name.
*/
/**
* Return the smallest leading-whitespace count across a set of lines,
* ignoring blank lines (whose indent isn't load-bearing). Used to compute
* the common base indent of a multi-line picked element so reindenting
* under the wrapper preserves the relative depth between lines.
*/
function minLeadingSpaces(lines) {
let min = Infinity;
for (const l of lines) {
if (l.trim() === '') continue;
const m = l.match(/^(\s*)/);
if (m && m[1].length < min) min = m[1].length;
}
return min === Infinity ? 0 : min;
}
function findElement(lines, query, tag = null) {
// Iterate all matches — the first substring hit isn't always the right one.
for (let i = 0; i < lines.length; i++) {
@@ -330,6 +507,69 @@ function findElement(lines, query, tag = null) {
return null;
}
/**
* Like findElement, but returns every match. Used for ambiguity detection
* when the agent passes --text: when the same className appears on multiple
* sibling elements (a list of cards, repeated section variants, etc.),
* first-match silently lands on the wrong branch. Returning all matches lets
* the caller narrow by textContent or fail with a structured ambiguity error.
*/
function findAllElements(lines, query, tag = null) {
const out = [];
const seen = new Set();
for (let i = 0; i < lines.length; i++) {
if (!lines[i].includes(query)) continue;
const stripped = lines[i].trim();
if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
if (lines[i].includes('data-impeccable-variant')) continue;
const openerLine = findOpenerLine(lines, i, tag);
if (openerLine === -1) continue;
if (seen.has(openerLine)) continue; // multiple matches inside the same element
seen.add(openerLine);
const endLine = findClosingLine(lines, openerLine);
out.push({ startLine: openerLine, endLine });
}
return out;
}
/**
* Narrow a candidate set to those whose source body matches a meaningful
* prefix of the picked element's textContent. The compare strips tags and
* JSX expressions, then checks two whitespace normalizations side-by-side:
*
* - single-space ("hero two second card body")
* - no-whitespace ("herotwosecondcardbody")
*
* Both are needed because `el.textContent` concatenates sibling text without
* inserting whitespace (e.g. `<h1>Hero Two</h1><p>Second…</p>` reads as
* `"Hero TwoSecond…"`), while the source has whitespace between tags. If
* EITHER normalization matches, the candidate keeps. A snippet shorter than
* 8 chars after stripping is too weak to disambiguate the caller falls
* back to first-match.
*/
function filterByText(candidates, lines, text) {
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
// Too short to disambiguate. Return [] so the caller's `filtered.length
// === 0` branch fires (fall back to first-match) — the previous
// `candidates.slice()` return forced `filtered.length > 1` and surfaced
// a spurious `element_ambiguous` error on every short-text picker event
// with multiple candidates.
if (trimmed.length < 8) return [];
const targetSpaced = trimmed;
const targetCompact = trimmed.replace(/\s+/g, '');
return candidates.filter((c) => {
const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
const inner = body
.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
.toLowerCase();
const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
const sourceCompact = inner.replace(/\s+/g, '');
return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
});
}
/**
* Resolve a match line to the real tag opener. If the match line itself opens
* a tag, return it. Otherwise walk up to 10 lines backward looking for the
@@ -392,4 +632,15 @@ if (_running?.endsWith('live-wrap.mjs') || _running?.endsWith('live-wrap.mjs/'))
}
// Test exports (used by tests/live-wrap.test.mjs)
export { buildSearchQueries, findElement, findClosingLine, detectCommentSyntax };
export {
buildSearchQueries,
findElement,
findClosingLine,
detectCommentSyntax,
findAllElements,
filterByText,
findFileWithQuery,
detectStyleMode,
buildCssAuthoring,
buildCssSelectorPrefixExamples,
};
+8 -9
View File
@@ -2,10 +2,10 @@
* CLI entry point: prepare everything needed to enter the live variant poll loop.
*
* Does (all in one command):
* 1. Check config.json (returns config_missing if first-ever run)
* 1. Check .impeccable/live/config.json (returns config_missing if first-ever run)
* 2. Start the live server in the background (or reuse a running one)
* 3. Inject the browser script tag into the project's entry file
* 4. Read .impeccable.md for design context (if present)
* 4. Read PRODUCT.md / DESIGN.md for project context
* 5. Print a single JSON blob with everything the agent needs
*
* After this, the agent's only remaining steps are:
@@ -21,11 +21,11 @@ import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadContext } from './load-context.mjs';
import { loadContext } from './context.mjs';
import { resolveFiles } from './live-inject.mjs';
import { readLiveServerInfo } from './impeccable-paths.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
async function liveCli() {
const args = process.argv.slice(2);
@@ -34,10 +34,10 @@ async function liveCli() {
console.log(`Usage: node live.mjs
Prepare everything for live variant mode in a single command:
- Checks scripts/config.json (required, created once per project)
- Checks .impeccable/live/config.json (required, created once per project)
- Starts (or reuses) the live server in the background
- Injects the browser script tag
- Reads .impeccable.md for design context
- Reads PRODUCT.md / DESIGN.md for project context
On success, prints a JSON blob with:
{ ok, serverPort, serverToken, pageFile, hasContext, context }
@@ -80,7 +80,7 @@ The agent should then:
process.exit(1);
}
// 4. Load PRODUCT.md + DESIGN.md context (auto-migrates legacy .impeccable.md)
// 4. Load PRODUCT.md + DESIGN.md context.
const ctx = loadContext(process.cwd());
// 5. Compute drift-heal: compare resolved inject targets against the
@@ -102,7 +102,6 @@ The agent should then:
hasDesign: ctx.hasDesign,
design: ctx.design,
designPath: ctx.designPath,
migrated: ctx.migrated,
}, null, 2));
}
@@ -223,7 +222,7 @@ function safeParse(out) {
function ensureServerRunning() {
// Try to reuse an existing server
try {
const existing = JSON.parse(fs.readFileSync(PID_FILE, 'utf-8'));
const existing = readLiveServerInfo(process.cwd())?.info;
if (existing && existing.pid) {
try {
process.kill(existing.pid, 0); // throws if dead
@@ -1,93 +0,0 @@
/**
* Shared context loader for every impeccable command that needs to know
* "who is this for" and "what does this look like".
*
* Input: project root (process.cwd()).
*
* Output (JSON to stdout):
* {
* hasProduct: boolean, // PRODUCT.md found (or auto-migrated)
* product: string | null, // PRODUCT.md contents
* productPath: string | null, // relative path
* hasDesign: boolean, // DESIGN.md found
* design: string | null, // DESIGN.md contents
* designPath: string | null,
* migrated: boolean, // true if we auto-renamed .impeccable.md -> PRODUCT.md
* }
*
* Filename matching is case-insensitive for PRODUCT.md and DESIGN.md. The
* Google DESIGN.md convention is uppercase at repo root; Kiro-style and
* lowercase variants are also matched so users don't get punished for case.
*/
import fs from 'node:fs';
import path from 'node:path';
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
const LEGACY_NAMES = ['.impeccable.md'];
export function loadContext(cwd = process.cwd()) {
let migrated = false;
// 1. Look for PRODUCT.md (case-insensitive)
let productPath = firstExisting(cwd, PRODUCT_NAMES);
// 2. Legacy: if no PRODUCT.md but .impeccable.md exists, rename in place
if (!productPath) {
const legacyPath = firstExisting(cwd, LEGACY_NAMES);
if (legacyPath) {
const newPath = path.join(cwd, 'PRODUCT.md');
try {
fs.renameSync(legacyPath, newPath);
productPath = newPath;
migrated = true;
} catch {
// Rename failed (permissions, etc.) — fall back to reading legacy in place
productPath = legacyPath;
}
}
}
// 3. DESIGN.md (case-insensitive)
const designPath = firstExisting(cwd, DESIGN_NAMES);
const product = productPath ? safeRead(productPath) : null;
const design = designPath ? safeRead(designPath) : null;
return {
hasProduct: !!product,
product,
productPath: productPath ? path.relative(cwd, productPath) : null,
hasDesign: !!design,
design,
designPath: designPath ? path.relative(cwd, designPath) : null,
migrated,
};
}
function firstExisting(cwd, names) {
for (const name of names) {
const abs = path.join(cwd, name);
if (fs.existsSync(abs)) return abs;
}
return null;
}
function safeRead(p) {
try { return fs.readFileSync(p, 'utf-8'); } catch { return null; }
}
// ---------------------------------------------------------------------------
// CLI mode — print the context as JSON
// ---------------------------------------------------------------------------
function cli() {
const result = loadContext(process.cwd());
console.log(JSON.stringify(result, null, 2));
}
const _running = process.argv[1];
if (_running?.endsWith('load-context.mjs') || _running?.endsWith('load-context.mjs/')) {
cli();
}
@@ -0,0 +1,633 @@
#!/usr/bin/env node
/**
* Brand-seed picker. Returns one OKLCH seed color + the mood it most
* naturally evokes, and teaches the model how to compose a full palette
* around it.
*
* The seed is the brand's anchor color. The 5-role palette (bg, surface,
* ink, accent, muted) is composed by the caller at runtime using their
* judgment + the brief (PRODUCT.md / DESIGN.md / user prompt), NOT picked
* from a frozen 4-color preset.
*
* Why: 4-color frozen palettes drift toward safe defaults (warm-cream bg,
* complementary accent on near-white) regardless of brief. A single seed +
* the model's own composition lets the same seed produce a dark-mode jazz
* club or a light-mode hospitality brand depending on what the brief calls
* for. Tested empirically against curated 4-color palettes; seed approach
* wins on mood-fit in 3 of 5 cases and ties on the rest.
*
* Usage:
* node scripts/palette.mjs # pick at random
* node scripts/palette.mjs --id seed-021 # pick a specific seed
* node scripts/palette.mjs --from <key> # hash <key> to a seed (deterministic)
*
* Env vars:
* IMPECCABLE_PALETTE_SEED same as --from; useful for the eval harness
* to make runs reproducible.
*/
import crypto from 'node:crypto';
// Seeds are inlined (129 entries, hand-curated via a tinder review of
// ~400 candidates from ColorHunt + synthesis + Radix/brand/Pantone anchors).
// Each carries a mood + strategy the judging model produced — surfaced as
// hints, not commands; the brief still drives composition.
const SEEDS = [
{ id: "seed-200", oklch: [0.360, 0.137, 0.0],
mood: "Aesop apothecary shelf — oxblood bottle glass against linen, considered and unhurried",
strategy: "Seed is a deep desaturated red-brown that reads as brand ink itself; I push primary darker toward bottle-glass oxblood, pair with a pure white surface so the red does the work, and use a clear pale-blush accent that can carry dark text in pills." },
{ id: "seed-000", oklch: [0.400, 0.130, 0.0],
mood: "oxblood leather banquette in a 1940s steakhouse — low lamplight on dark wood and burgundy",
strategy: "Near-black bg with the faintest red undertone lets the oxblood primary glow like lamplit leather; warm cream ink and a brass accent complete the chophouse register." },
{ id: "seed-002", oklch: [0.450, 0.150, 0.0],
mood: "darkroom red light — analog photography, blood-warm safelight glow on chemical trays",
strategy: "Near-black surface with a deep oxblood primary lets the seed function like a safelight in a darkroom — the bg disappears so the red becomes the only emotional signal." },
{ id: "seed-003", oklch: [0.500, 0.194, 0.0],
mood: "darkroom safelight — the deep oxblood glow of analog photography, chemical and contemplative",
strategy: "Anchored the seed as primary against pure near-black so the red reads like a single illuminated bulb in a developing room, with cool desaturated ink to evoke silver gelatin print tones." },
{ id: "seed-004", oklch: [0.546, 0.204, 3.4],
mood: "midnight boudoir — velvet rose under low lamplight, perfumed and intimate",
strategy: "Near-black surface lets the rose seed glow like silk in shadow; a warm champagne accent provides the candle-flame counterpoint without breaking the hush." },
{ id: "seed-005", oklch: [0.550, 0.180, 0.0],
mood: "smoldering vermillion at dusk — the last red ember in a blacksmith's forge, iron-rich and quietly violent",
strategy: "Near-black gallery surround lets the seed read as glowing forged metal; ink stays warm-off-white, accent shifts to a hotter ember orange so the primary feels like cooling steel against a fresh strike." },
{ id: "seed-201", oklch: [0.647, 0.262, 0.3],
mood: "Figma plugin marketplace red — confident product-brand crimson, the kind a modern dev tool uses for a 'live' indicator or a primary CTA on a pristine docs page",
strategy: "Pure white surface lets a high-chroma crimson primary do all the brand work, paired with a hue-shifted warm coral accent for hierarchy without competing saturation" },
{ id: "seed-006", oklch: [0.650, 0.160, 0.0],
mood: "1960s Italian cinema — Technicolor lipstick red against a darkened theater",
strategy: "Pure near-black surface lets a saturated cinematic red and its warm peach accent perform like film light projected in a dark room — the brand colors carry the drama, the bg disappears." },
{ id: "seed-008", oklch: [0.520, 0.200, 10.4],
mood: "Negroni hour at a Milanese bar — bittersweet crimson, vermouth and amaro under low tungsten",
strategy: "Seed is a saturated red-crimson with cinematic weight, so I sit it on near-black to let the primary glow like backlit liquor, with a warmer amber accent acting as the citrus twist against the bitter red." },
{ id: "seed-010", oklch: [0.563, 0.223, 11.0],
mood: "Negroni hour on a Milan rooftop — bittersweet crimson, aperitivo light, polished restraint",
strategy: "Seed is a vivid carmine-red with strong chroma, so the surface gets out of the way (pure white) and lets the primary do the aperitivo work, with a cooled garnet accent for tension." },
{ id: "seed-202", oklch: [0.643, 0.247, 7.0],
mood: "Glossier brand pink — modern beauty editorial, confident and current",
strategy: "Pure white bg lets a saturated rose-red primary do all the brand work, paired with a deeper crimson accent for hierarchy — the Stripe/Glossier move where the color carries the mood." },
{ id: "seed-013", oklch: [0.400, 0.130, 20.0],
mood: "Tuscan cellar at dusk — aged terracotta, oxidized iron, the deep red of decanted Sangiovese",
strategy: "Black surface lets the oxblood seed and copper accent glow like firelight on cellar stone; brand colors carry all the warmth while the room recedes." },
{ id: "seed-014", oklch: [0.450, 0.150, 20.0],
mood: "smoldering tannery — oxblood leather, cured under low workshop light",
strategy: "Anchor the deep oxblood seed as primary against a near-black architectural ground, then lift with a single warm ember accent so the leather reads burnished rather than bloody." },
{ id: "seed-016", oklch: [0.550, 0.180, 20.0],
mood: "Negroni hour on a Roman terrace — bitter campari red, vermouth, late golden light spilling on white linen",
strategy: "Pure white surface lets the campari-red primary do all the emotional work, paired with a deeper oxblood accent for bittersweet depth — Italian aperitivo restraint, not warmth-washed." },
{ id: "seed-205", oklch: [0.634, 0.254, 17.6],
mood: "Aesop apothecary bottle — considered red-coral on a clinical white surface, the kind of brand restraint where one saturated object does all the work",
strategy: "Default A pure white surface lets a single coral-red primary carry the entire brand voice; accent shifts to a deeper oxblood for hierarchy without competing chroma." },
{ id: "seed-011", oklch: [0.639, 0.207, 13.5],
mood: "Aperitivo hour in Milan — Campari glow on a white marble bar, crisp and effervescent",
strategy: "Pure white gallery backdrop lets the Campari-red primary ring like a single bitter note; ink is near-black with a whisper of warmth, accent shifts to a deeper oxblood for hierarchy without competing hues." },
{ id: "seed-015", oklch: [0.527, 0.202, 22.7],
mood: "Negroni hour on a Milanese terrace — bittersweet vermillion, aperitivo glassware catching low sun",
strategy: "Seed becomes a saturated aperitivo-red primary against pure white so the color carries the bittersweet warmth alone, paired with a deep oxblood accent for typographic gravitas." },
{ id: "seed-023", oklch: [0.427, 0.175, 29.2],
mood: "blacksmith's forge at dusk — iron heated to ember red, the deep glow of oxidized metal and quenching oil",
strategy: "Pure black bg lets the seed's ember-red glow radiate like hot iron in a dark forge; accent shifts to a copper-amber to suggest scaling metal and sparks, while ink stays near-white for tool-precise legibility." },
{ id: "seed-206", oklch: [0.614, 0.234, 28.2],
mood: "Aesop apothecary bottle — considered red-orange on lab-white, calm utility with a single confident pigment",
strategy: "Pure white surface lets a saturated vermilion primary do all the brand work, paired with a deep oxblood accent for hierarchy without introducing a second hue family" },
{ id: "seed-029", oklch: [0.665, 0.222, 25.7],
mood: "Negroni hour at a Milanese bar — bittersweet orange-red liqueur catching late afternoon light on polished marble",
strategy: "Pure white surface lets the seed's vermilion read like Campari in a glass; a deeper oxblood accent provides the bitter depth, with neutral graphite ink keeping the editorial restraint of Italian design." },
{ id: "seed-022", oklch: [0.418, 0.155, 27.2],
mood: "Pompeiian red fresco — oxidized cinnabar on a museum wall, archaeological gravity",
strategy: "Pure black gallery surface lets the seed's iron-oxide red read as a lit artifact; accent shifts to an aged terracotta amber, so primary and accent form a fired-clay duet against neutral void." },
{ id: "seed-024", oklch: [0.464, 0.169, 26.9],
mood: "Mid-century darkroom under the safelight — developer trays, oxblood leather, the quiet patience of a print emerging",
strategy: "Seed becomes a deep oxblood primary; surface stays pure black so the red glows like a safelight, with a warmer ember accent for hierarchy" },
{ id: "seed-026", oklch: [0.489, 0.190, 28.3],
mood: "smoldering ember in a blacksmith's forge — iron-hot rust, soot, and controlled fire",
strategy: "Near-black soot background lets the seed's red-orange glow like heated metal; ink is bone-white, accent is a cooler tempered-steel orange that creates internal heat gradient with the primary." },
{ id: "seed-027", oklch: [0.568, 0.208, 27.1],
mood: "Sicilian blood orange at golden hour — citrus rind, terracotta, sun on stucco",
strategy: "Seed reads as vivid blood-orange — picked pure white surface so the citrus-red primary and a deep oxblood accent do all the emotional work, like a Loro Piana editorial spread." },
{ id: "seed-028", oklch: [0.591, 0.172, 24.0],
mood: "Sienna-fired ceramic studio at dusk — terracotta cooling on a wheel, hands still dusted with slip",
strategy: "Pure black stage lets the fired-clay primary glow like a kiln ember, with a deeper oxblood accent providing tonal weight rather than hue contrast — a monochrome warm-axis play." },
{ id: "seed-033", oklch: [0.544, 0.169, 31.3],
mood: "1960s Italian terracotta workshop — fired clay, espresso, late-afternoon Mediterranean dust",
strategy: "Pure black ground lets the seed's burnt-sienna primary glow like a lit kiln, with a deeper oxblood accent for restrained warmth tension — the brand carries the heat, the surface stays out." },
{ id: "seed-207", oklch: [0.564, 0.231, 29.1],
mood: "Aesop apothecary bottle — considered red oxide, the calm authority of a well-made object on a white shelf",
strategy: "Seed becomes the singular brand voice against pure white, with a deeper oxblood accent for hierarchy — the surface disappears so the red does all the speaking." },
{ id: "seed-035", oklch: [0.663, 0.153, 32.1],
mood: "Aesop apothecary bottle — clay-fired warmth, considered retail",
strategy: "Pure white surface lets the terracotta primary do the brand work, paired with a deep umber ink and a cooler clay accent for editorial tension." },
{ id: "seed-037", oklch: [0.590, 0.188, 35.8],
mood: "Aesop apothecary bottle — considered terracotta, herbalist restraint, the warmth comes from the glass not the room",
strategy: "Seed becomes a muted terracotta primary against pure white so the brand's warmth carries entirely through the color itself; accent shifts to a deeper umber for quiet hierarchy." },
{ id: "seed-038", oklch: [0.652, 0.229, 34.8],
mood: "blown-glass furnace at dusk — molten orange iron pulled from the kiln, a craftsman's signature heat",
strategy: "Pure black stage so the seed reads as live ember; primary holds the seed's heat, accent shifts to a brass-amber a hue-step away for a 1.7+ contrast pairing without leaving the fire." },
{ id: "seed-039", oklch: [0.653, 0.185, 33.5],
mood: "Aesop apothecary bottle — considered terracotta, quiet retail craft",
strategy: "Seed becomes a grounded clay primary against pure white, paired with a deeper umber accent so the warmth lives entirely in the brand marks, not the surface." },
{ id: "seed-167", oklch: [0.495, 0.134, 36.0],
mood: "Aesop apothecary shelf — burnished terracotta on clinical white, considered craft pharmacy",
strategy: "Treat the seed as a brand-carrying burnt-sienna against a pure paper-white surface so the warmth lives entirely in the primary, with a deep umber accent pulled along the same warm axis for typographic gravity." },
{ id: "seed-147", oklch: [0.500, 0.151, 40.0],
mood: "Aesop apothecary shelf — considered terracotta, pharmacy restraint, the brand color does the work against clinical white",
strategy: "Anchor the seed's burnt-sienna primary against a pure white surface so the rust speaks alone, with a deep umber ink and a cooler clay accent to give the palette product-brand discipline rather than environmental warmth." },
{ id: "seed-040", oklch: [0.660, 0.201, 40.0],
mood: "Aesop apothecary bottle — amber glass on a clean dispensary shelf, considered and clinical-warm",
strategy: "Seed becomes a burnt-amber primary against pure white so the bottle-glass color does the emotional work; accent shifts to a deep olive-bronze for the apothecary-label pairing." },
{ id: "seed-041", oklch: [0.673, 0.217, 38.6],
mood: "Aesop apothecary shelf — considered orange glass, clinical retail restraint",
strategy: "Pure white surface lets the burnt-orange primary do all the brand work, with a deep ink-brown for editorial gravity and a muted clay accent that reads as a sibling, not a contrast." },
{ id: "seed-042", oklch: [0.688, 0.133, 35.8],
mood: "Aesop apothecary shelf — terracotta glass, considered retail",
strategy: "Seed becomes a warm clay primary against pure white so the bottle-on-marble retail feel comes from the brand color alone; a deeper umber accent gives the label-print contrast." },
{ id: "seed-043", oklch: [0.781, 0.119, 38.1],
mood: "Aesop apothecary catalogue — considered terracotta, dermatological restraint, the warm color doing all the work against clinical white",
strategy: "Pure white surface lets the seed's warm clay tone read as the entire brand voice, paired with a deeper umber accent for hierarchy without competing with the primary's warmth." },
{ id: "seed-168", oklch: [0.400, 0.103, 50.0],
mood: "Aesop apothecary bottle — amber glass on a clinical white shelf, considered and pharmaceutical",
strategy: "Pure white surface lets the deep amber primary act like tinted glass against a clean shelf; accent is a muted clay that complements without competing, keeping the brand quiet and product-led." },
{ id: "seed-044", oklch: [0.568, 0.149, 45.9],
mood: "1970s desert highway at golden hour — sun-faded terracotta, denim dust, the warmth of a Polaroid pulled from a glovebox",
strategy: "Seed becomes a burnt-sienna primary against pure white so the terracotta does all the emotional work; a deep indigo accent acts as the denim shadow opposing the sun, creating the era's signature warm/cool tension without tinting the page." },
{ id: "seed-045", oklch: [0.607, 0.163, 47.7],
mood: "Aesop apothecary shelf — considered amber glass, clinical restraint, craft pharmacy",
strategy: "Pure white bg lets the burnt-amber primary do the apothecary work alone, paired with a deeper umber accent and graphite ink for editorial calm." },
{ id: "seed-046", oklch: [0.653, 0.175, 45.0],
mood: "Aesop apothecary shelf — considered amber glass, quiet luxury, restrained craft",
strategy: "Pure black backdrop lets the warm amber primary glow like backlit apothecary glass, with a deeper rust accent providing tonal depth in the same hue family — monochromatic warm against neutral void." },
{ id: "seed-047", oklch: [0.695, 0.205, 43.2],
mood: "Aesop apothecary label — sun-warmed amber glass on a clinical countertop, restrained botanical pharmacy",
strategy: "Pure white surface lets the burnt-amber primary and a deeper sienna accent do all the brand work, like an apothecary bottle photographed under daylight." },
{ id: "seed-051", oklch: [0.704, 0.189, 49.0],
mood: "blacksmith's forge at dusk — glowing iron, hammered copper, ember light against cooling steel",
strategy: "Pure near-black surface lets the seed's molten orange burn like heated metal; accent shifts to a deeper amber-red to suggest the cooling end of the same iron, while ink stays a clean off-white so type reads like chalk on slate." },
{ id: "seed-171", oklch: [0.550, 0.124, 60.0],
mood: "Klim Type Foundry specimen page — considered ochre on paper, design-school-honest",
strategy: "Seed becomes a muted ochre primary on pure white; accent is a deep ink-navy pulled across the wheel for editorial contrast without warmth-pooling in the bg" },
{ id: "seed-148", oklch: [0.650, 0.146, 60.0],
mood: "Klim-style editorial gold — late-afternoon paper light on a serif specimen sheet, considered and dry",
strategy: "Hold the seed's amber as primary on a pure white page so the gold reads as ink rather than atmosphere, and pair with a deep aubergine accent for typographic contrast." },
{ id: "seed-052", oklch: [0.700, 0.130, 60.0],
mood: "late-afternoon terracotta studio — sun-warmed clay, hands-on craft, the hour before dusk",
strategy: "Seed is a saturated amber-ochre with strong environmental association (ceramics, adobe, sunlit plaster), so I lean into Exception (a) with a faintly warm bone surface that reads as lime-washed wall, then deepen the seed slightly for primary and pair it with a fired-clay rust accent for hand-thrown warmth." },
{ id: "seed-053", oklch: [0.773, 0.157, 56.6],
mood: "late-summer apricot orchard at golden hour — sun-warmed fruit, considered Californian craft",
strategy: "Seed is a juicy mid-warm orange at daylight luminance — leaning optimistic/editorial, so pure white surface lets the apricot primary glow without muddying it; a deep wine accent provides the bite." },
{ id: "seed-149", oklch: [0.600, 0.124, 70.0],
mood: "1970s desert highway — late-afternoon amber light on chrome and asphalt",
strategy: "Anchor the amber seed as primary against pure black so the warm hue reads as headlight glow against night; a cooler dusk-mauve accent provides the complementary tension of horizon vs. sun." },
{ id: "seed-054", oklch: [0.740, 0.162, 68.1],
mood: "late-afternoon honey on terracotta — Mediterranean stucco at golden hour, sun-baked amber",
strategy: "Seed is a saturated honey-amber at high lightness; pairing it with pure black lets the warmth read as luminous gold against gravity, like lamplight in a dark room." },
{ id: "seed-055", oklch: [0.774, 0.174, 65.1],
mood: "late-summer honey hour — amber light slanting through a west-facing window, optimistic and golden",
strategy: "Anchor a saturated honey-amber primary on pure white so the warmth radiates from the brand itself, then pair with a deep teak accent for grounded contrast rather than tinting the canvas." },
{ id: "seed-056", oklch: [0.691, 0.146, 74.6],
mood: "Klim-style modern publishing house — late-afternoon paper warmth, considered editorial gold",
strategy: "Pure white surface so the amber seed becomes the brand voice; ink stays near-black neutral and accent shifts to a deep ink-blue to give the gold something structural to lean on." },
{ id: "seed-150", oklch: [0.750, 0.148, 80.0],
mood: "Klim Type Foundry specimen page — late-summer editorial gold, considered and grown-up",
strategy: "Pure white surface lets a single restrained ochre primary do all the brand work, paired with a deep ink-blue accent for typographic contrast in the Klim/Commercial Type tradition." },
{ id: "seed-058", oklch: [0.764, 0.120, 77.1],
mood: "Klim Type Foundry specimen page — late-afternoon ochre, considered editorial typography",
strategy: "Pure white surface lets the ochre primary do the brand work, paired with a deep ink-blue accent for editorial contrast — the type-foundry move where one warm hue carries the whole feeling against neutral paper." },
{ id: "seed-059", oklch: [0.784, 0.144, 79.8],
mood: "late afternoon in a Tuscan limonaia — sun-cured amber on whitewashed plaster",
strategy: "Pure white surface lets the saffron-amber primary and a deep olive accent carry the Mediterranean warmth, with split-complementary tension between gold and a quiet evergreen." },
{ id: "seed-061", oklch: [0.817, 0.161, 75.1],
mood: "late-afternoon honey on Tuscan limestone — golden hour, slow and luminous",
strategy: "Pure white surface lets the amber primary glow like sunlight on a wall, paired with a deep terracotta accent for warm tonal contrast within the same hue family." },
{ id: "seed-063", oklch: [0.842, 0.165, 91.3],
mood: "late-afternoon Tuscan sun on limestone — golden hour, considered, optimistic",
strategy: "Pure white surface lets the amber-gold primary radiate as the mood-carrier, with a deep aubergine accent providing the long shadow that golden light needs to feel three-dimensional." },
{ id: "seed-174", oklch: [0.350, 0.075, 110.0],
mood: "olive grove at late afternoon — sun-cured leaves, dust, and quiet Mediterranean weight",
strategy: "Pure white surface lets a deep, sun-cured olive primary do the emotional work, with a burnt-terracotta accent providing the warm-earth counterpoint olive groves are known for." },
{ id: "seed-117", oklch: [0.650, 0.100, 110.0],
mood: "Klim-style editorial sage — late-summer foundry catalogue, considered olive-yellow on paper",
strategy: "Seed sits at olive-chartreuse; treating it as a quiet typographic primary on pure paper, with a deeper bronze-olive accent for hierarchy — the color does the work, the page disappears." },
{ id: "seed-118", oklch: [0.750, 0.090, 110.0],
mood: "Klim Type Foundry specimen page — late-summer olive light on a working specimen, the honesty of a type designer showing their work",
strategy: "Pure white bg lets a desaturated olive-yellow primary do the editorial work, with a deeper olive-bronze accent providing typographic emphasis the way a specimen uses one heavy weight against the body roman." },
{ id: "seed-065", oklch: [0.797, 0.166, 113.1],
mood: "late-summer olive grove at noon — sun-bleached leaves, dry stone, Mediterranean glare",
strategy: "Hold the seed as a luminous chartreuse-olive primary against pure white so the color reads as sunlit foliage, pairing it with a deep umber accent for the dry-stone contrast." },
{ id: "seed-176", oklch: [0.300, 0.071, 120.0],
mood: "moss-darkened apothecary jar — herbal, shadowed, mid-19th-century botanical study",
strategy: "Seed is a deep desaturated olive-green that reads as preserved botanical pigment; I anchor it on pure white so the dim moss-green primary feels like ink on a herbarium page, with a warm ochre accent supplying the aged-paper counterpoint." },
{ id: "seed-155", oklch: [0.550, 0.142, 130.0],
mood: "moss-bed forest floor at noon — chlorophyll, lichen, sunlit fern",
strategy: "Seed is a confident mid-olive green with strong chroma; mood is daylight botanical, so I let the brand greens do the work on a pure paper-white bg and pair with a warm umber accent for fern-against-bark contrast." },
{ id: "seed-119", oklch: [0.600, 0.154, 130.0],
mood: "moss garden at Saihō-ji — damp stone, filtered green light through old cedar",
strategy: "Pure near-black bg lets the seed's mossy green glow like wet lichen under low light; accent shifts to a pale ochre-gold like sun catching through canopy." },
{ id: "seed-179", oklch: [0.300, 0.096, 140.0],
mood: "moss on wet stone — forest floor at dusk, deep botanical hush",
strategy: "Kept the seed's deep moss green as primary against a near-black surface so the green reads as living shadow, with a pale lichen accent providing the single point of light." },
{ id: "seed-180", oklch: [0.350, 0.110, 140.0],
mood: "moss-darkened apothecary — herbal tinctures in amber glass, pressed botanicals, the deep green of a conservatory at dusk",
strategy: "Near-black bg with a whisper of green undertone lets the seed's deep moss read as luminous foliage; a warm parchment accent provides the apothecary-label counterpoint without breaking the herbal register." },
{ id: "seed-120", oklch: [0.650, 0.100, 140.0],
mood: "moss on weathered stone — quiet botanical garden conservatory at midday",
strategy: "Pure white bg lets the muted sage-green primary read as a considered botanical mark, with a deeper terracotta accent providing earthen counterpoint without breaking the gallery-like restraint." },
{ id: "seed-121", oklch: [0.750, 0.090, 140.0],
mood: "moss garden at Saihō-ji — diffuse green light filtered through wet stone and lichen",
strategy: "Pure near-black bg lets the muted sage-green primary glow like lichen under low light; a warm pale-bone accent acts as the single ray of sun cutting through canopy." },
{ id: "seed-182", oklch: [0.400, 0.106, 150.0],
mood: "moss garden at Saiho-ji — deep cultivated green under wet stone shadow, contemplative and damp",
strategy: "Near-black bg with the faintest cool-green undertone evokes shaded stone; primary holds the seed's moss tone while accent shifts to a lichen-yellow for organic counterpoint without breaking the hush." },
{ id: "seed-157", oklch: [0.550, 0.145, 150.0],
mood: "moss garden at Saiho-ji — damp stone, filtered green light through cedar canopy",
strategy: "Near-black bg with a faint green undertone evokes deep forest shadow; primary holds the seed's verdant register while accent shifts to a pale lichen-cream to mimic light catching moss." },
{ id: "seed-122", oklch: [0.600, 0.158, 150.0],
mood: "forest floor at first light — moss, lichen, and clean morning air",
strategy: "Seed reads as a living, daylight green; surface stays pure white so the green carries the freshness, with a cool teal accent pulling it toward dew rather than earth." },
{ id: "seed-195", oklch: [0.650, 0.150, 145.0],
mood: "Considered horticulture brand — botanical research lab, the green of a healthy stem photographed in clean daylight",
strategy: "Pure white surface lets the seed's vegetal green carry the entire brand voice, paired with a deep forest ink and a warm clay accent for editorial contrast." },
{ id: "seed-183", oklch: [0.350, 0.077, 160.0],
mood: "moss-stained apothecary — deep forest glass, herbal tinctures shelved in low candlelight",
strategy: "Anchored the seed as primary and built a near-black dark surface with whisper-tinted green to evoke aged apothecary glass, letting the green glow rather than shout." },
{ id: "seed-184", oklch: [0.400, 0.087, 160.0],
mood: "deep forest apothecary — moss, bottle glass, and herbal tincture under afternoon light",
strategy: "Seed becomes a botanical-bottle-green primary on pure white, paired with a warm clove-amber accent to evoke herbal pharmacy contrast without tinting the surface." },
{ id: "seed-158", oklch: [0.550, 0.119, 160.0],
mood: "moss on wet stone — forest floor after rain, mineral and quiet",
strategy: "Pure white surface lets the deep mossy green carry the entire mood; accent shifts to a damp slate-teal to sit beside primary like lichen on stone without competing." },
{ id: "seed-159", oklch: [0.600, 0.130, 160.0],
mood: "moss-covered forest apothecary — herbal tinctures in amber glass, eucalyptus shadow",
strategy: "Anchored the green seed in a near-black backdrop so it reads like botanical glassware lit from within, with a warm amber accent pulled across the wheel to evoke tincture bottles against dark wood." },
{ id: "seed-185", oklch: [0.450, 0.086, 170.0],
mood: "weathered copper patina on a Pacific Northwest greenhouse — oxidized teal, glass light, botanical hush",
strategy: "Seed sits as a deep oxidized-teal primary against pure white so the patina reads as pigment, not atmosphere; a rust-copper accent completes the verdigris/oxidation story across the warm-cool axis." },
{ id: "seed-124", oklch: [0.750, 0.080, 170.0],
mood: "sea-glass on a foggy Pacific shoreline — weathered, mineral, quietly oxidized",
strategy: "Seed is a soft desaturated teal-green; pairing it on pure white lets the mineral primary read as patinated copper-glass, with a deeper kelp-toned primary and a rusted coral accent to spark the muted teal against its complement." },
{ id: "seed-160", oklch: [0.550, 0.095, 180.0],
mood: "weathered copper patina on a museum bronze — oxidized teal, conservatorial quiet",
strategy: "Pure near-black gallery surround lets the patina-teal primary glow like a lit artifact, with a warm verdigris-adjacent accent providing the oxidation contrast against the cool seed." },
{ id: "seed-161", oklch: [0.720, 0.100, 188.0],
mood: "climate-tech dashboard — calm verdigris on plain paper, the quiet confidence of an instrument that just works",
strategy: "Seed teal carries the entire mood as a single considered brand color on pure white, with a desaturated copper accent providing warm signal against the cool primary without competing for attention." },
{ id: "seed-186", oklch: [0.450, 0.074, 200.0],
mood: "deep hydrothermal vent — mineral teal under pressure, the cold blue-green of oxidized copper in submerged light",
strategy: "Near-black surface lets the mineral teal glow as if lit from within; accent shifts toward verdigris-copper to suggest patina on submerged metal, while ink stays cool-neutral to keep the register austere rather than aquatic-cute." },
{ id: "seed-125", oklch: [0.650, 0.100, 200.0],
mood: "climate-tech dashboard — calm operational teal, the color of clean water data and atmospheric sensors",
strategy: "Pure white surface lets a single muted-teal primary do all the brand work, with a deeper marine accent providing hierarchy without competing chroma." },
{ id: "seed-126", oklch: [0.750, 0.080, 200.0],
mood: "climate-tech product brand — quiet competence, dashboards for hard infrastructure problems",
strategy: "Hold the seed's muted teal as primary, pair with a sharper cyan-leaning accent for interactive lift, and let a pure white surface do the disappearing act so the brand reads as a tool, not an atmosphere." },
{ id: "seed-162", oklch: [0.550, 0.091, 210.0],
mood: "weathered nautical instrument — patinated brass on oxidized steel, the cool blue-grey of a ship's chronometer at dawn",
strategy: "Pure white surface lets the muted teal-steel primary read as a precise instrument mark, with a warm brass accent providing the single point of patina against clinical white." },
{ id: "seed-163", oklch: [0.450, 0.086, 230.0],
mood: "deep harbor at dusk — weathered nautical instruments, brass dials on oxidized steel",
strategy: "Near-black background with subtle cool tint evokes the marine dusk; primary holds the seed's teal-blue while a warm brass accent creates the instrument-on-steel tension." },
{ id: "seed-164", oklch: [0.550, 0.105, 230.0],
mood: "deep harbor at dawn — cold steel water, fog-muted light, the quiet before the boats leave",
strategy: "Pure near-black bg lets the seed's cold marine blue read as a luminous beacon, while a pale frost-cyan accent evokes diffused dawn light cutting through fog." },
{ id: "seed-127", oklch: [0.650, 0.100, 230.0],
mood: "climate-tech dashboard — atmospheric sensor blue, calm operational clarity",
strategy: "Anchor the seed as a confident mid-blue primary on pure white so the brand color carries all the atmospheric feeling, with a deep navy accent for hierarchy and a soft slate muted for body text." },
{ id: "seed-128", oklch: [0.750, 0.080, 230.0],
mood: "climate-tech dashboard — calm atmospheric data, considered sky-blue",
strategy: "Pure white surface lets the muted sky-blue primary carry the meteorological calm, with a deep-navy accent providing readable weight against the soft primary." },
{ id: "seed-187", oklch: [0.350, 0.078, 240.0],
mood: "deep harbor at blue hour — wet stone, cold steel, the quiet before night fully lands",
strategy: "Near-black architectural bg with a hint of marine chroma lets the seed read as ambient atmosphere rather than UI chrome; a cooler steel accent sits opposite the warmer-shifted primary for navigational clarity." },
{ id: "seed-077", oklch: [0.578, 0.130, 241.7],
mood: "pre-dawn signal tower — cold blue solitude, instruments glowing against the dark",
strategy: "Pure near-black bg lets the seed's cold tower-light blue glow as the sole emotional source, with a frost-cyan accent acting as a secondary indicator light." },
{ id: "seed-188", oklch: [0.400, 0.110, 250.0],
mood: "Linear's considered indigo — the calm authority of a well-built developer tool, blueprint ink on a clean page",
strategy: "Held the seed as a deep indigo primary against pure white so the brand color carries all the gravity; accent shifts to a cooler, brighter cyan-blue to create a crisp hierarchy pair without warming the surface." },
{ id: "seed-165", oklch: [0.450, 0.123, 250.0],
mood: "blueprint room at dusk — drafting table, graphite, civic-engineering blue",
strategy: "Seed is a mid-deep architectural blue with real chroma and no environmental cue, so I stay out of the way with a pure white surface and let the primary do all the talking, pairing it with a burnt-ochre accent for drafting-pencil contrast." },
{ id: "seed-079", oklch: [0.478, 0.136, 251.8],
mood: "twilight cartography — the blue of deep dusk over open water, precise and navigational",
strategy: "Pure white surface lets the seed's oceanic blue act as a single navigational anchor, with a warm amber accent struck across it like a lighthouse beam at dusk." },
{ id: "seed-080", oklch: [0.541, 0.122, 248.2],
mood: "Linear-style considered tool blue — the calm, exact register of a modern engineering app where every pixel is intentional",
strategy: "Pure white surface lets the considered indigo-blue primary carry the entire brand; a deeper navy accent provides hierarchy without warmth, keeping the palette in a single cool family for that focused-software feel" },
{ id: "seed-166", oklch: [0.550, 0.149, 250.0],
mood: "pre-dawn flight deck — instrument glow against deep cobalt sky, precise and quietly intense",
strategy: "Near-black bg with the faintest cool tint reads like a darkened cockpit; the seed becomes a luminous instrument-blue primary, paired with a warm amber accent that mimics avionics readouts for unmistakable signal contrast." },
{ id: "seed-081", oklch: [0.650, 0.160, 250.0],
mood: "deep-sea research vessel at dawn — instrument glow against cold steel light",
strategy: "Pure near-white bg keeps the palette technical and instrument-like; the seed blue holds as primary while a desaturated steel-cyan accent reads like signal readouts on glass." },
{ id: "seed-082", oklch: [0.742, 0.140, 247.4],
mood: "high-altitude flight deck at dawn — cold cabin instruments glowing against a sky still holding night",
strategy: "Near-black cockpit ground with a faint blue cast lets the seed read as an illuminated instrument; primary holds the seed, accent shifts to cyan for signal/indicator contrast." },
{ id: "seed-210", oklch: [0.360, 0.140, 260.0],
mood: "Linear-style considered tool indigo — late-night focused work, the deep blue of a code editor at 2am where everything else falls away",
strategy: "Pure black bg lets the indigo primary carry all the cognitive-focus weight, with a slightly brighter periwinkle accent for interactive lift — the surface disappears so the tool feels weightless." },
{ id: "seed-189", oklch: [0.400, 0.130, 260.0],
mood: "pre-dawn observatory — cold instrument blue, star-chart precision",
strategy: "Seed becomes the primary on pure black so the deep instrument-blue glows like a calibration light, with a faint cyan accent reading as starlight against the void." },
{ id: "seed-211", oklch: [0.420, 0.161, 260.0],
mood: "Linear's considered indigo — the tool-for-thought blue of focused product work, calm authority without coldness",
strategy: "Hold the seed as a deep indigo primary against pure white, then pair with a slightly warmer, lighter periwinkle accent to create gentle hue separation without breaking the disciplined tool-brand register." },
{ id: "seed-129", oklch: [0.450, 0.150, 260.0],
mood: "pre-dawn observatory — deep cobalt sky just before astronomical twilight, instruments cool to the touch",
strategy: "Near-black surface lets the cobalt seed read as luminous starlight; a single warm amber accent acts as the calibration lamp against the cold blue field." },
{ id: "seed-084", oklch: [0.476, 0.207, 261.2],
mood: "pre-dawn flight deck — instrument glow against deep cobalt sky, precise and awake",
strategy: "Default B black bg lets the cobalt primary read as a luminous instrument signal, with a cyan accent striking the analogous 'cockpit display' relationship." },
{ id: "seed-085", oklch: [0.681, 0.132, 258.4],
mood: "pre-dawn flight deck — instrument glow against deep cobalt sky",
strategy: "Anchored the seed as a luminous primary against a near-black architectural ground, with a warm amber accent acting as the single instrument light cutting through cold blue." },
{ id: "seed-086", oklch: [0.767, 0.106, 255.9],
mood: "Scandinavian winter morning — quiet light through frost, pale sky over snow",
strategy: "Anchored a pure white editorial stage so the seed's cool sky-blue reads as crisp polar light, with a deeper navy primary providing the only saturated weight — like a single dark pine against snow." },
{ id: "seed-083", oklch: [0.340, 0.159, 262.4],
mood: "deep cobalt twilight — the moment after sunset when the sky goes electric blue and city windows start to glow",
strategy: "Pure black stage lets the cobalt seed act as a luminous neon-window glow, with a warm amber accent across the wheel for the lit-window contrast." },
{ id: "seed-212", oklch: [0.360, 0.219, 270.0],
mood: "Linear-grade tooling indigo — considered software for people who care about craft",
strategy: "Anchored the deep indigo seed as primary on a pure white surface so the brand color carries all the weight, with a slightly cooler violet-blue accent for hierarchy without competing chroma." },
{ id: "seed-130", oklch: [0.400, 0.150, 270.0],
mood: "Linear-grade indigo — considered productivity tool, ink on paper, no theatrics",
strategy: "Pure white surface lets a deep cool indigo carry all the brand weight, paired with a slightly warmer violet-blue accent for hierarchy without acid." },
{ id: "seed-213", oklch: [0.411, 0.241, 267.9],
mood: "Linear-style indigo — considered tool surface, the kind of blue-violet that sits behind a developer's keyboard at 11pm without shouting",
strategy: "Pure black canvas lets a saturated indigo primary do all the brand work, with a cooler cyan-violet accent providing UI signal without competing." },
{ id: "seed-131", oklch: [0.450, 0.180, 270.0],
mood: "monastic indigo dusk — vespers light through stained glass, contemplative and severe",
strategy: "Seed becomes a deep indigo primary against pure near-black so the violet reads as luminous stained-glass against architectural shadow, with a cooler iris accent for tonal lift." },
{ id: "seed-088", oklch: [0.476, 0.158, 268.5],
mood: "pre-dawn astronomer's notebook — deep indigo sky just before the stars fade, ink and graphite",
strategy: "Near-black bg with the faintest cool tint to evoke night sky without theatrics; primary holds the seed's indigo, accent shifts to a paler periwinkle for stellar contrast, keeping the palette monochromatic-cool and observational." },
{ id: "seed-196", oklch: [0.530, 0.130, 268.0],
mood: "Linear-style considered tool indigo — the deep-focus blue-violet of a thoughtfully built productivity surface, the color of a well-typeset keyboard shortcut",
strategy: "Pure white bg lets the indigo seed do all the brand work as primary, with a slightly darker, more saturated violet-shifted accent for hierarchy and interactive states — the surface disappears so the brand color reads as the entire identity." },
{ id: "seed-132", oklch: [0.700, 0.120, 270.0],
mood: "Linear-style considered tool indigo — the quiet violet of a focused product workspace, late-afternoon thinking",
strategy: "Pure white surface lets a muted indigo-violet primary and a slightly cooler accent do all the brand work, keeping the register calm and software-like rather than theatrical." },
{ id: "seed-090", oklch: [0.445, 0.206, 279.1],
mood: "Linear-style considered tool indigo — the violet of a focused product surface, not a nightclub",
strategy: "Anchor the seed as a confident product primary on pure white, with a cooler indigo-shift accent that reads as a sibling tool color, so the brand violet does all the emotional work." },
{ id: "seed-133", oklch: [0.500, 0.160, 280.0],
mood: "Linear-adjacent indigo — considered productivity tool, the violet of a thinking workspace",
strategy: "Seed becomes a measured indigo primary on pure white; accent shifts to a cooler blue-violet to create hierarchy without nightclub saturation, letting the brand color do all the emotional work." },
{ id: "seed-094", oklch: [0.533, 0.125, 294.3],
mood: "Linear-style considered tool indigo — the violet of a focused product surface, calm authority for a creative workspace",
strategy: "Pure white canvas lets the indigo-violet primary carry the entire brand voice; accent shifts hue slightly toward blue for a cool, tool-like duotone rather than warm decorative pairing." },
{ id: "seed-137", oklch: [0.700, 0.120, 290.0],
mood: "Linear-adjacent indigo — the considered tool, late-evening focus mode, software made for people who care about craft",
strategy: "Pure black surface lets a single restrained indigo-violet carry the brand, with a cooler periwinkle accent providing UI hierarchy without competing — Vercel/Linear dark-mode discipline." },
{ id: "seed-100", oklch: [0.450, 0.150, 330.0],
mood: "velvet boudoir at last call — bruised orchid and lipstick traces under low lamplight",
strategy: "Pure near-black surface lets a deep magenta-rose primary smolder while a warm peach accent acts like skin-lit lamplight — drama lives in the brand pair, not the room." },
{ id: "seed-103", oklch: [0.650, 0.160, 330.0],
mood: "1980s Memphis boudoir — powder-pink neon humming against lacquered black, lipstick and lacquer",
strategy: "Near-black gallery surface lets the magenta-pink seed read as lit neon; accent shifts to warm coral to create cinematic dichromatic tension without competing chroma." },
{ id: "seed-228", oklch: [0.360, 0.147, 340.0],
mood: "Figma-era creative tool plum — considered productivity software for designers, the inky violet of a serif wordmark on a marketing site",
strategy: "Held the seed as a deep plum primary against pure white so the brand color does the emotional work; paired with a muted rose accent for warmth without breaking the productivity-tool restraint." },
{ id: "seed-107", oklch: [0.500, 0.200, 340.0],
mood: "Figma plum — creative-tool confidence, considered magenta for a modern design product",
strategy: "Pure white surface lets a saturated magenta-plum primary carry all the brand voice, paired with a cooler violet-leaning accent for hierarchy without competing." },
{ id: "seed-198", oklch: [0.600, 0.210, 340.0],
mood: "Figma-era creative tool plum — confident, considered, made for makers",
strategy: "Anchor a saturated plum primary against pure white so the brand color does all the emotional work, with a deeper magenta-rose accent for hierarchy." },
{ id: "seed-112", oklch: [0.754, 0.193, 343.4],
mood: "Figma-era creative tool — confident pink primary doing the brand work on a clean canvas, the way Linear uses indigo or Stripe uses violet",
strategy: "Anchor the seed pink as a saturated brand primary on pure white so the color carries all the personality; pair with a cooler plum accent to give the pink something to push against without competing." },
{ id: "seed-229", oklch: [0.420, 0.163, 350.0],
mood: "considered fintech rose — the deep magenta of a modern product brand (think Stripe-adjacent, but rotated toward berry), confident and current",
strategy: "pure white surface lets a single deep berry-rose primary do all the brand work, paired with a cooler indigo accent for the contrast move you see in modern product marketing" },
{ id: "seed-113", oklch: [0.470, 0.173, 354.8],
mood: "1960s velvet rope nightclub — crushed magenta, low light, cigarette smoke catching a spotlight",
strategy: "Pure black stage so the seed's smoky magenta reads as a single hot spotlight, paired with a cooler violet accent for the second light cue." },
{ id: "seed-114", oklch: [0.570, 0.158, 353.3],
mood: "fin-de-siècle Parisian rose — velvet curtain, theatre program, lipstick blotted on linen",
strategy: "Drop bg to true black so the dusty-rose primary reads as stage-lit silk; accent shifts to a warmer coral-mauve at higher lightness to create gentle hue rotation without breaking the romance." },
{ id: "seed-199", oklch: [0.650, 0.180, 350.0],
mood: "modern fintech rose — the considered pink of a Series B brand mark, confident and current without nostalgia",
strategy: "Pure white surface lets a saturated rose primary do the brand work, paired with a deep plum accent for hierarchy — the Stripe move applied to a pink hue." },
{ id: "seed-115", oklch: [0.636, 0.218, 355.3],
mood: "backstage at a cabaret — velvet rope, lipstick mark on a champagne glass",
strategy: "Seed reads as a saturated stage-light magenta-red; I push it into pure black so the primary glows like a neon sign and the accent (a cold pearl-pink) acts as the spotlight rim — the room is dark, the color does the singing." },
{ id: "seed-230", oklch: [0.650, 0.249, 354.5],
mood: "Modern fintech rose — the considered pink of a contemporary payments brand: confident, alive, and clear-headed",
strategy: "Pure white bg lets a saturated rose-magenta primary carry all the brand energy, paired with a cooler indigo accent for trustworthy contrast — the Stripe move applied to a pink hue." },
{ id: "seed-231", oklch: [0.682, 0.241, 353.2],
mood: "Figma-era creative tool — a confident pink-magenta product brand, the kind a modern design platform uses to feel alive without shouting",
strategy: "Default A pure white bg lets the saturated pink-magenta primary do all the brand work, with a near-complementary cool teal accent for tool-like clarity and a neutral ink for editorial calm" },
{ id: "seed-116", oklch: [0.734, 0.183, 356.8],
mood: "modern beauty brand DTC — Glossier-adjacent pink, confident and current without being saccharine",
strategy: "Pure white surface so the rose-pink primary carries all the brand warmth, paired with a near-black ink and a desaturated mauve accent for editorial restraint." },
];
function parseArgs(argv) {
const args = { id: null, from: null };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--id' && argv[i + 1]) { args.id = argv[++i]; }
else if (a === '--from' && argv[i + 1]) { args.from = argv[++i]; }
}
return args;
}
// Hash a key into a stable float in [0, 1) for deterministic weighted picks.
function hashUnit(key) {
const h = crypto.createHash('sha256').update(key).digest();
return h.readUInt32BE(0) / 0x100000000;
}
// The curated library is hue-skewed (more reds/oranges than teals/magentas)
// because that's where the source material + taste landed. Left uniform, a
// random pick would land on red ~1/3 of the time. Inverse-frequency weighting
// gives each seed a weight of 1/(count in its 30° hue bucket), so each hue
// ZONE is roughly equally likely to be chosen regardless of how many seeds it
// holds — fair rainbow exposure across runs without pruning the library.
function buildWeights(seeds) {
const bucketCount = {};
const bucketOf = (s) => Math.floor(((s.oklch[2] % 360) + 360) % 360 / 30);
for (const s of seeds) { const b = bucketOf(s); bucketCount[b] = (bucketCount[b] || 0) + 1; }
const weights = seeds.map((s) => 1 / bucketCount[bucketOf(s)]);
const total = weights.reduce((a, b) => a + b, 0);
return { weights, total };
}
function weightedPick(seeds, unit) {
const { weights, total } = buildWeights(seeds);
let target = unit * total;
for (let i = 0; i < seeds.length; i++) {
target -= weights[i];
if (target < 0) return seeds[i];
}
return seeds[seeds.length - 1];
}
function pickSeed(seeds, { id, from }) {
if (id) {
const found = seeds.find(s => s.id === id);
if (!found) { console.error(`no seed with id "${id}"`); process.exit(2); }
return found;
}
const envFrom = process.env.IMPECCABLE_PALETTE_SEED;
const key = from || envFrom;
const unit = key ? hashUnit(key) : Math.random();
return weightedPick(seeds, unit);
}
function fmtOklch([L, C, H]) {
return `oklch(${L.toFixed(3)} ${C.toFixed(3)} ${H.toFixed(1)})`;
}
function hueWord(H) {
if (H < 15 || H >= 345) return 'pure red';
if (H < 35) return 'warm red / crimson';
if (H < 55) return 'warm coral / burnt orange';
if (H < 80) return 'orange / honey';
if (H < 105) return 'warm amber / honey-gold';
if (H < 135) return 'yellow-green / olive';
if (H < 170) return 'green';
if (H < 200) return 'teal';
if (H < 230) return 'sky blue';
if (H < 265) return 'cobalt / indigo';
if (H < 295) return 'violet / purple';
if (H < 330) return 'magenta / pink';
return 'deep pink / rose';
}
// ---------------------------------------------------------------
const args = parseArgs(process.argv.slice(2));
const seed = pickSeed(SEEDS, args);
const [L, C, H] = seed.oklch;
// The mood + strategy on each seed were derived by the model that
// originally judged it. We surface them as *hints*, not commands —
// the brief should still drive what the seed becomes.
const moodHint = seed.mood ? ` (one read: "${seed.mood}")` : '';
const strategyHint = seed.strategy ? `\n - one example strategy: ${seed.strategy}` : '';
// ---------------------------------------------------------------
// Fat tool-exit response — what the model sees on stdout.
// ---------------------------------------------------------------
process.stdout.write(`BRAND SEED · ${seed.id}
Seed color (anchor for your primary brand color):
${fmtOklch(seed.oklch)} ${hueWord(H)}${moodHint}
This is the brand's anchor a single beautiful color. Compose the rest of
the palette around it using YOUR judgment, the brief (PRODUCT.md /
DESIGN.md / the user's prompt), and the color-strategy guidance already in
SKILL.md.
How to use:
1. Read the brief. Write one specific phrase describing the mood this
product calls for. Be granular. Good: "1970s travel poster sun-baked
warmth, considered", "midnight jazz club smoky brass, saxophone
light", "Scandinavian winter morning quiet light through frost". Bad:
"modern and clean", "warm and inviting". The first lets you compose; the
second is generic and will produce generic palettes.
2. The seed's hue (${H.toFixed(0)}°) anchors your primary brand color. You
choose L and C to match the mood. The same hue can be deep-and-velvet,
bright-and-confident, or pale-and-faded pick the one the mood demands.
Primary's hue should stay within ±10° of the seed.${strategyHint}
3. Now compose the full palette in OKLCH (5 more roles):
bg the most important architectural choice.
CORE PRINCIPLE: the mood lives in the BRAND COLORS
(primary + accent) and typography, NOT in the surface.
Stripe is warm its purple does that, bg is pure
white. Linear is cool its blue does that, bg is
pure. Notion is warm its accents do that, bg is
near-pure-white. Putting warmth in BOTH primary AND
bg is the AI cliché.
DEFAULT A PURE white: exactly oklch(1.000 0.000 0).
Not 0.99, not chroma 0.002. Stripe / Notion / Apple
use literal #ffffff. Don't add hidden warmth.
Refs: Stripe, Notion, Linear (light), Apple.com,
Vercel docs, Figma marketing, Loom, Substack.
DEFAULT B PURE black/near-black: L 0.04-0.12,
chroma exactly 0.000. No hue tint. Vercel is
roughly oklch(0.08 0 0). Pick L for mood; C is 0.
Refs: Vercel, A24, Acne, Apple dark, MUBI.
ALT 2 TINTED: chroma 0.015-0.05.
Use ONLY when:
(a) the mood is EXPLICITLY environmental the surface
IS part of the brand (1920s lacquered interior,
leather library, ceramic studio, hotel lobby), or
(b) the seed itself is desaturated (chroma < 0.10) and
needs a tinted surface to read as a brand.
NOT for "feels warm" / "modern + warm" / "moody". If
your mood says "warm" but doesn't name a specific
environment, use PURE white and let primary carry
the warmth.
HEURISTIC: if seed chroma > 0.10 AND mood is product-
focused (not environment-focused), it's almost always
PURE white. Target distribution across many palettes:
~50% pure white, ~25% pure black, ~25% tinted.
surface bg pulled slightly toward ink (10-15% mix). Same hue
family as bg. Used for cards, panels, sections.
ink body text color. Must reach 7:1 contrast vs bg.
Can carry the brand hue at low chroma in light mode
(slight warmth or coolness toward the brand).
accent a SECOND brand color, distinct from primary in BOTH
hue AND lightness. Picked to complement the mood (not
default-complementary across the wheel). Used for
badges, status pills, links, accent rules.
muted secondary text. Ink pulled 40% toward bg, keeping ink's
hue. Must reach 3.5:1 contrast vs bg.
4. Pick a color STRATEGY (the four steps from SKILL.md):
Restrained: tinted neutrals + accent 10% product default
Committed: one saturated color carries 30-60% identity-driven
Full palette: 3-4 named roles each used deliberately brand work
Drenched: the surface IS the color campaign, hero, statement
The brief picks the strategy. A startup dashboard a perfume brand.
Hard rules (already in SKILL.md, recapped because the seed step is where
they actually bite):
- OKLCH only never hex. Never #RRGGBB.
- ink-vs-bg WCAG contrast 7 (body text must be readable)
- primary chroma 0.23 (above this, primary glows perceptually and
no text on it is readable acid-bright is a UI failure)
- if primary L > 0.78, primary chroma 0.18 (the fluorescent zone)
- primary-vs-accent contrast 1.7 (they must be visually distinct,
not two variants of the same hue at similar lightness)
- accent must carry readable text on a filled badge/pill: EITHER
saturated (chroma 0.10) OR clearly light (L 0.85) OR clearly
dark (L 0.30). Never a muddy mid-tone (L 0.45-0.72 + chroma < 0.10)
taupe/mushroom/dusty-grey accents read as weak and can't hold text
either way. Saturate it or push its lightness to a clear light/dark.
- avoid the saturated AI attractor zones: claude-beige (warm-cream bg
+ dusty brown primary), forest-green-on-cream, AI-purple-on-white,
navy-cream-with-orange-accent
TEXT-ON-COLOR FILLS pick by perceptual contrast, not just WCAG. The
rule applies to ANY element where text sits on a saturated color fill:
primary buttons, accent buttons, badges, status pills, tag highlights,
filled callouts. Don't only think "primary button" apply consistently.
For any saturated mid-luminance color (L between 0.42 and 0.78, chroma
0.08), use WHITE text (or near-white from your bg), not dark text even
if WCAG says dark technically passes. The Helmholtz-Kohlrausch effect
makes saturated colors appear brighter than their luminance suggests,
and dark text on a warm-or-cool-saturated fill reads as muddy.
Convention: Stripe orange CTAs, McDonald's red, every fintech orange
button, Vercel's filled badges, Linear's status pills all use white
text on saturated bg fills.
Dark text is correct only on PALE fills (L > 0.85) or PURE-NEUTRAL fills
(chroma near 0). Everything else: white text.
Return your composed palette in CSS custom properties using OKLCH, then
build with it. The seed is the start, not the recipe.
`);
+1 -1
View File
@@ -27,7 +27,7 @@ const HARNESS_DIRS = [
// Valid sub-command names
const VALID_COMMANDS = [
'craft', 'teach', 'extract', 'document', 'shape',
'craft', 'init', 'extract', 'document', 'shape',
'critique', 'audit',
'polish', 'bolder', 'quieter', 'distill', 'harden', 'onboard', 'live',
'animate', 'colorize', 'typeset', 'layout', 'delight', 'overdrive',
+2 -2
View File
@@ -12,12 +12,12 @@
{
"name": "impeccable",
"description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
"version": "3.0.0",
"version": "3.5.0",
"author": {
"name": "Paul Bakaus",
"email": "paul@paulbakaus.com"
},
"source": "./",
"source": "./plugin",
"category": "design",
"homepage": "https://impeccable.style",
"tags": ["design", "frontend", "ui", "ux", "skills", "commands"]
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "impeccable",
"description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
"version": "3.0.0",
"version": "3.5.0",
"author": {
"name": "Paul Bakaus",
"email": "paul@paulbakaus.com"
},
"homepage": "https://impeccable.style",
"repository": "https://github.com/pbakaus/impeccable",
"skills": "./.claude/skills"
"skills": "./.claude/skills/"
}
-257
View File
@@ -1,257 +0,0 @@
---
name: anti-patterns
description: Use when adding, modifying, or debugging an anti-pattern detection rule in this repo. Walks through the TDD recipe, the rule schema, all five plug-in points, jsdom constraints, and the post-implementation checklist. Trigger this for any work touching src/detect-antipatterns.mjs, tests/fixtures/antipatterns/, or extension/detector/.
tools: Read, Edit, Write, Glob, Grep, Bash, mcp__claude-in-chrome__navigate, mcp__claude-in-chrome__javascript_tool, mcp__claude-in-chrome__tabs_context_mcp, mcp__claude-in-chrome__tabs_create_mcp
---
# Anti-Pattern Engine Maintenance
This agent handles every step of adding or modifying an anti-pattern detection rule in the impeccable repo. The rule engine is wired into many places — tests, browser bundle, extension detector, extension panel JSON, homepage count, and the skill content — and missing a step causes silent drift between them.
## The five things that need to stay in sync
When you add a rule, all of these update or get regenerated:
| Where | What | How it stays in sync |
|---|---|---|
| `src/detect-antipatterns.mjs` `ANTIPATTERNS` | Rule metadata (id, category, name, description, skillSection, skillGuideline) and the detection logic (`checkXxx`) | **Hand-edited.** Source of truth. |
| `src/detect-antipatterns-browser.js` | Browser-bundled engine for the public site overlay | Generated by `bun run build:browser` |
| `extension/detector/detect.js` | Browser-bundled engine for the Chrome extension | Generated by `bun run build:extension` |
| `extension/detector/antipatterns.json` | Rule list (id, name, category, description) for the extension's devtools panel — drives rule toggles UI | Generated by `bun run build:extension` |
| `public/js/generated/counts.js` | `DETECTION_COUNT` integer for homepage display | Generated by `bun run build` |
| `source/skills/impeccable/SKILL.md` and `reference/*.md` | Design guidance that a human or LLM reads. Can reference anti-patterns in its own voice. | **Hand-edited**, alongside the rule. Drift is a code-review concern, not a programmatic one. |
The CLI (`bin/cli.js`) imports `ANTIPATTERNS` directly from `src/detect-antipatterns.mjs` — no separate sync needed.
## Rule schema
Each entry in the `ANTIPATTERNS` array (around src/detect-antipatterns.mjs:77) looks like this:
```js
{
id: 'icon-tile-stack', // kebab-case, unique, stable
category: 'slop', // 'slop' or 'quality' (see below)
name: 'Icon tile stacked above heading', // human-readable, used in extension UI
description: // 12 sentences. Used in CLI output, extension tooltips, web overlay labels
'A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.',
skillSection: 'Typography', // OPTIONAL. The logical skill section this rule maps to; used for /docs/impeccable deep-links.
skillGuideline: 'large icons with rounded corners above every heading', // OPTIONAL. Canonical short phrasing for the rule; used in CLI output and as a linkable fragment.
}
```
### Categories
- **`slop`** = "AI tells". Patterns that scream *AI generated this*. Things like purple gradients, gradient text, dark glow accents, thick side borders, icon-tile-stacks. Flagging these is about taste and freshness, not correctness.
- **`quality`** = real design or accessibility issues regardless of who wrote the code. WCAG contrast, line length, padding, line height, justified text, skipped headings, etc.
If you're not sure, ask: *"would a human designer who's careful and tasteful still ship this?"* If no, it's `quality`. If they would (because it works fine, it just looks templated), it's `slop`.
### `skillSection` values
The value is used by `scripts/build-sub-pages.js` to build deep links into the impeccable docs page. Use one of the logical sections the skill groups rules under (e.g. `Typography`, `Color`, `Layout`, `Motion`, `Visual Details`). If the section you pick matches an `### Heading` somewhere in the skill body, the deep link will land precisely; otherwise it falls back to the section's top. Omit entirely for rules that don't have a natural home in the skill.
### `skillGuideline` phrasing
Canonical short phrasing for the rule (36 words). Used as the CLI output label when `npx impeccable detect` reports a violation, and as human-readable text in the extension's devtools panel. The skill's prose may or may not echo this phrasing verbatim — the skill is the design-guidance document, not a rule manifest.
Examples: `'AI color palette'`, `'large icons with rounded corners above every heading'`, `'WCAG AA contrast'`.
Omit if the rule doesn't need a short label (rare — only niche a11y-only rules).
## The TDD recipe (always do it in this order)
This order is non-negotiable. Fixture and failing test before implementation. The full suite must run between the rule going in and you committing.
### 1. Write the fixture (two-column convention)
A single HTML file at `tests/fixtures/antipatterns/{rule-id}.html` with two columns: left = should-flag, right = should-pass. Each test case carries a unique heading text so the test can match snippets back to expectations.
Convention skeleton:
```html
<!DOCTYPE html>
<html>
<head>
<style>
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; max-width: 960px; margin: 0 auto; padding: 24px; }
.col h2 { font-size: 14px; text-transform: uppercase; }
/* ... per-case styles with EXPLICIT pixel dimensions (jsdom can't lay out) ... */
</style>
</head>
<body>
<div class="grid">
<div class="col" data-col="flag">
<h2>Should flag</h2>
<!-- 46 cases that should be flagged, each with a unique <h3> text -->
</div>
<div class="col" data-col="pass">
<h2>Should pass</h2>
<!-- 58 cases that should NOT be flagged: cover every false-positive shape you can think of -->
</div>
</div>
<script src="/js/detect-antipatterns-browser.js"></script>
</body>
</html>
```
The script tag at the bottom is critical — it lets you load the fixture in the browser via `http://localhost:3000/fixtures/antipatterns/{rule-id}.html` (served by `server/index.js:62` route for `/fixtures/*`).
**Should-pass cases must cover the false-positive shapes you can think of in advance.** A good fixture has 5+ pass cases. The icon-tile-stack fixture covers: round avatar, wide thumbnail, side-by-side, no-icon, too-tiny, too-huge.
### 2. Write the failing test
Add to `tests/detect-antipatterns-fixtures.test.mjs` in its own `describe` block. Use the snippet-substring matching pattern — the test parses heading text out of each finding's snippet and asserts membership against expected lists:
```js
describe('detectHtml — {rule-id}', () => {
const SHOULD_FLAG = ['Heading One', 'Heading Two', /* ... */];
const SHOULD_PASS = ['Pass Heading One', /* ... */];
it('{rule-id}: flags only the should-flag column', async () => {
const f = await detectHtml(path.join(FIXTURES, '{rule-id}.html'));
const flagged = new Set();
for (const r of f) {
if (r.antipattern !== '{rule-id}') continue;
const m = (r.snippet || '').match(/"([^"]+)"/);
if (m) flagged.add(m[1]);
}
for (const text of SHOULD_FLAG) {
assert.ok(flagged.has(text), `expected "${text}" to be flagged`);
}
for (const text of SHOULD_PASS) {
assert.ok(!flagged.has(text), `"${text}" should NOT be flagged`);
}
});
});
```
For this to work, the rule's snippet **must include the heading text in quotes**. See "Snippet conventions" below.
Run `node --test tests/detect-antipatterns-fixtures.test.mjs` and **watch it fail**. If it doesn't fail, your test is wrong.
### 3. Add the rule definition
Add a new entry to the `ANTIPATTERNS` array in `src/detect-antipatterns.mjs`. Place it in the right category section (slop or quality). Fill in all fields including `skillSection` and `skillGuideline`.
### 4. Implement the pure check function
Add a `checkXxx(opts)` function alongside the others (`checkColors`, `checkBorders`, `checkMotion`, `checkGlow`, `checkIconTile`, etc.). The pure function takes a plain options object — no DOM access — and returns an array of `{ id, snippet }`. This makes it testable and reusable across the browser/Node adapters.
Example shape (see `checkIconTile` in src/detect-antipatterns.mjs for a real one):
```js
function checkXxx(opts) {
const { tag, /* whatever fields the rule needs */ } = opts;
if (SAFE_TAGS.has(tag)) return [];
// ... your detection logic ...
if (matches) {
return [{ id: 'rule-id', snippet: `... "${headingText}"` }];
}
return [];
}
```
### 5. Add the two adapters
Two adapters wrap the pure function with environment-specific input gathering:
- **`checkElementXxxDOM(el)`** — for the browser. Uses `getComputedStyle(el)` and `el.getBoundingClientRect()`.
- **`checkElementXxx(el, tag, window)`** — for jsdom (Node). Uses `window.getComputedStyle(el)` and **must read explicit pixel dimensions from `parseFloat(style.width)`** instead of bounding rects, because **jsdom does not lay out**`getBoundingClientRect()` returns 0×0 for everything.
If your rule needs vertical positioning info (e.g. "icon must be above heading"), that check is browser-only — gate it behind `if (headingTop && siblingBottom)` so the Node path skips it. The structural checks alone (sizes, sibling identity, classes) are enough for the fixture.
### 6. Wire into both element-iteration loops
Two loops iterate every element on the page. You need to add your DOM-adapter call to **both**:
- **Browser loop** at src/detect-antipatterns.mjs:1837 (`for (const el of document.querySelectorAll('*'))` with the `findings` spread). Add a line like:
```js
...checkElementXxxDOM(el).map(f => ({ type: f.id, detail: f.snippet })),
```
- **Node (jsdom) loop** at src/detect-antipatterns.mjs:2058 (in `detectHtml`). Add a block like:
```js
for (const f of checkElementXxx(el, tag, window)) {
findings.push(finding(f.id, filePath, f.snippet));
}
```
Forgetting one of these is the most common mistake — the test passes but the live page doesn't show anything (or vice versa).
### 7. Decide whether the skill needs an update
If the rule introduces a new design concept not already covered by the impeccable skill, update `source/skills/impeccable/SKILL.md` (or the appropriate register file in `reference/editorial.md` / `reference/product.md`) to teach the concept. The skill is a design-guidance document — it doesn't need to echo every rule verbatim, and one skill line can cover multiple engine rules. Only add prose if there's a real gap in the guidance.
### 8. Run the build (regenerates everything)
```bash
bun run build && bun run build:browser && bun run build:extension
```
This regenerates:
- `src/detect-antipatterns-browser.js` (public-site detector)
- `extension/detector/detect.js` (extension detector)
- `extension/detector/antipatterns.json` (extension rule list, includes description)
- `public/js/generated/counts.js` (DETECTION_COUNT)
### 9. Run the test suite
```bash
bun run test
```
166 unit tests + N fixture tests, including your new one. All should be green.
### 10. Verify on a live page in the browser
Don't skip this. The jsdom path uses `parseFloat(style.width)` and the browser path uses `getBoundingClientRect()` — they can disagree. The fixture test catches one path; manual browser verification catches the other.
```
http://localhost:3000/fixtures/antipatterns/{rule-id}.html
http://localhost:3000/antipattern-examples/{your-example}.html (if relevant)
http://localhost:3000/ (no false positives on real pages)
```
Use the chrome MCP tools (`mcp__claude-in-chrome__navigate` + `mcp__claude-in-chrome__javascript_tool`) to inject `window.impeccableScan()` and read `.impeccable-overlay` / `.impeccable-label` from the DOM to verify. Don't try to screenshot — the overlays are decorative; read them programmatically.
## Snippet conventions
The fixture-test convention extracts the heading text from a finding's snippet using regex `/"([^"]+)"/` — so **wrap the identifying heading text in straight double quotes** in your snippet. Examples:
- `'80x80px icon tile above h3 "Lightning Fast"'`
- `'4.5:1 (need 4.5:1) — text #808080 on #3b82f6'` ← uses element identifiers instead, since this rule isn't anchored to a heading
If your rule isn't naturally anchored to a heading, pick another stable identifier (a class name, the parent element's text, etc.) and document the test pattern in the test itself.
## jsdom constraints (the most common gotcha)
- **No layout.** `getBoundingClientRect()` returns `0×0` always. Read `parseFloat(style.width)` and `parseFloat(style.height)` instead — jsdom does honor explicit pixel widths in `<style>` and inline styles.
- **`background:` shorthand isn't decomposed.** `style.backgroundColor` and `style.backgroundImage` may be empty even when `style="background: ..."` is set. The existing `resolveBackground()` and `resolveGradientStops()` helpers (src/detect-antipatterns.mjs:631 and src/detect-antipatterns.mjs:670) handle this — use them.
- **Computed colors are normalized in real browsers, not in jsdom.** A browser returns `rgb(59, 130, 246)`; jsdom may return the original hex. The `parseGradientColors()` helper handles both.
- **No SAFE_TAGS skipping for parent walks.** When walking ancestors, you don't get the `SAFE_TAGS` filter the main loop applies — be explicit.
## Where to find concrete example rules to learn from
- Simplest border check: **`side-tab`** — `checkBorders()` at src/detect-antipatterns.mjs:312
- Color/contrast with gradient handling: **`low-contrast`** — `checkColors()` at src/detect-antipatterns.mjs:339
- Element-relationship check (siblings): **`icon-tile-stack`** — `checkIconTile()` at src/detect-antipatterns.mjs:425
- Page-level / cross-element: **`flat-type-hierarchy`** — `checkPageTypography()` at src/detect-antipatterns.mjs:1080
- Motion/animation: **`bounce-easing`** — `checkMotion()` at src/detect-antipatterns.mjs:425
## Pre-commit checklist
Before you commit a new rule:
- [ ] Test passes: `bun run test` is green
- [ ] Build passes: `bun run build && bun run build:browser && bun run build:extension` is green
- [ ] Live verification: rule fires on a real page and produces zero false positives on the homepage `http://localhost:3000/`
- [ ] Both element loops were updated (browser DOM at line ~1846 + Node jsdom at line ~2058)
- [ ] Snippet format matches the test's extraction regex
- [ ] Fixture covers ≥4 should-flag and ≥5 should-pass cases
- [ ] Skill reviewed: if the rule introduces a new design concept, the relevant skill file teaches it
- [ ] Commit only the relevant files — `git status` will show many unrelated stale skill builds; do not stage them
## Things that have bitten previous sessions
- **Forgot to run `bun run build:extension`** — extension JSON went stale, missing the new rule. Symptom: extension panel doesn't show toggle for new rule. Fix: always run all three build commands.
- **Forgot to update both loops** — test passed in jsdom but live browser was silent (or vice versa). Fix: grep for an existing rule's adapter call and copy its placement.
- **Wrote the fixture without explicit pixel dimensions** — jsdom returned 0×0 and the rule never matched. Fix: always set `width: Npx; height: Npx` in CSS for fixture elements, or use inline style attributes.
+81 -75
View File
@@ -1,91 +1,92 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 3.0.0
version: 3.5.0
user-invocable: true
argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · teach|document|extract|live] [target]"
license: Apache 2.0. Based on Anthropic's frontend-design skill. See NOTICE.md for attribution.
argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]"
license: Apache 2.0
allowed-tools:
- Bash(npx impeccable *)
---
Designs and iterates production-grade frontend interfaces. Real working code, committed design choices, exceptional craft.
## Setup (non-optional)
## Setup
Two steps before any design work. Both are required. Skipping either produces generic output that ignores the project.
You MUST do these steps before proceeding:
### 1. Context gathering
1. Run `node .claude/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/<command>.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
5. **If the project is brand-new (no existing CSS tokens / theme / committed brand colors found in step 3)**, run `node .claude/skills/impeccable/scripts/palette.mjs` to receive a brand seed color and composition guidance. This is the anchor for your primary brand color. Compose the rest of the palette (bg, surface, ink, accent, muted) around it per the script's instructions. Use OKLCH throughout. **Skip this step only if step 3 found committed brand colors in existing tokens; in that case identity-preservation wins.**
Two files at the project root, case-insensitive:
## Design guidance
- **PRODUCT.md** — required. Users, brand, tone, anti-references, strategic principles.
- **DESIGN.md** — optional, strongly recommended. Colors, typography, elevation, components.
Produce ready-to-ship, production-grade code, not prototypes or starting points. Take no shortcuts unless the user asks for them (when in doubt, ask). Don't stop until arriving at a complete implementation (beautiful, responsive, fast, precise, bug-free, on brand). You take attention to detail seriously: every page, section or component crafted is battle tested using the tools available to you (browser screenshotting, computer use, etc). Claude is capable of extraordinary work. Don't hold back.
Load both in one call:
### General rules
```bash
node .claude/skills/impeccable/scripts/load-context.mjs
```
#### Color
Consume the full JSON output. Never pipe through `head`, `tail`, `grep`, or `jq`.
- **Verify contrast.** Body text must hit ≥4.5:1 against its background; large text (≥18px or bold ≥14px) needs ≥3:1. Placeholder text needs the same 4.5:1, not the muted-gray default. The most common failure: muted gray body text on a tinted near-white. If the contrast is even close, bump the body color toward the ink end of the ramp; light gray "for elegance" is the single biggest reason AI designs feel hard to read.
- Gray text on a colored background looks washed out. Use a darker shade of the background's own hue, or a transparency of the text color.
If the output is already in this session's conversation history, don't re-run. Exceptions requiring a fresh load: you just ran `/impeccable teach` or `/impeccable document` (they rewrite the files), or the user manually edited one.
`/impeccable live` already warms context via `live.mjs` — if you've run `live.mjs`, don't also run `load-context.mjs` this session.
If PRODUCT.md is missing, empty, or placeholder (`[TODO]` markers, <200 chars): run `/impeccable teach`, then resume the user's original task with the fresh context.
If DESIGN.md is missing: nudge once per session (*"Run `/impeccable document` for more on-brand output"*), then proceed.
### 2. Register
Every design task is **brand** (marketing, landing, campaign, long-form content, portfolio — design IS the product) or **product** (app UI, admin, dashboard, tool — design SERVES the product).
Identify before designing. Priority: (1) cue in the task itself ("landing page" vs "dashboard"); (2) the surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md. First match wins.
If PRODUCT.md lacks the `register` field (legacy), infer it once from its "Users" and "Product Purpose" sections, then cache the inferred value for the session. Suggest the user run `/impeccable teach` to add the field explicitly.
Load the matching reference: [reference/brand.md](reference/brand.md) or [reference/product.md](reference/product.md). The shared design laws below apply to both.
## Shared design laws
Apply to every design, both registers. Match implementation complexity to the aesthetic vision — maximalism needs elaborate code, minimalism needs precision. Interpret creatively. Vary across projects; never converge on the same choices. Claude is capable of extraordinary work — don't hold back.
### Color
- Use OKLCH. Reduce chroma as lightness approaches 0 or 100 — high chroma at extremes looks garish.
- Never use `#000` or `#fff`. Tint every neutral toward the brand hue (chroma 0.0050.01 is enough).
- Pick a **color strategy** before picking colors. Four steps on the commitment axis:
- **Restrained** — tinted neutrals + one accent ≤10%. Product default; brand minimalism.
- **Committed** — one saturated color carries 3060% of the surface. Brand default for identity-driven pages.
- **Full palette** — 34 named roles, each used deliberately. Brand campaigns; product data viz.
- **Drenched** — the surface IS the color. Brand heroes, campaign pages.
- The "one accent ≤10%" rule is Restrained only. Committed / Full palette / Drenched exceed it on purpose. Don't collapse every design to Restrained by reflex.
### Theme
Dark vs. light is never a default. Not dark "because tools look cool dark." Not light "to be safe."
Before choosing, write one sentence of physical scene: who uses this, where, under what ambient light, in what mood. If the sentence doesn't force the answer, it's not concrete enough — add detail until it does.
"Observability dashboard" does not force an answer. "SRE glancing at incident severity on a 27-inch monitor at 2am in a dim room" does. Run the sentence, not the category.
### Typography
#### Typography
- Cap body line length at 6575ch.
- Hierarchy through scale + weight contrast (≥1.25 ratio between steps). Avoid flat scales.
- Cap font-family count at 3 (display + body + optional mono). More than 3 reads as indecision, not richness. One well-tuned family with weight contrast usually beats three competing typefaces.
- Don't pair fonts that are similar but not identical (two geometric sans-serifs, two humanist sans-serifs). Pair on a contrast axis (serif + sans, geometric + humanist) or use one family in multiple weights.
- No all-caps body copy. Reserve uppercase for short labels (≤4 words), section eyebrows (used sparingly per the Absolute bans), and badges. Sentences in ALL CAPS are unreadable at body sizes.
- Hero / display heading ceiling: clamp() max ≤ 6rem (~96px). Above that the page is shouting, not designing.
- Display heading letter-spacing floor: ≥ -0.04em. Anything tighter and letters touch; cramped, not "designed".
- Use `text-wrap: balance` on h1h3 for even line lengths; `text-wrap: pretty` on long prose to reduce orphans.
### Layout
#### Layout
- Vary spacing for rhythm. Same padding everywhere is monotony.
- Vary spacing for rhythm.
- Cards are the lazy answer. Use them only when they're truly the best affordance. Nested cards are always wrong.
- Don't wrap everything in a container. Most things don't need one.
- Flexbox for 1D, Grid for 2D. Don't default to Grid when `flex-wrap` would be simpler.
- For responsive grids without breakpoints: `repeat(auto-fit, minmax(280px, 1fr))`.
- Build a semantic z-index scale (dropdown → sticky → modal-backdrop → modal → toast → tooltip). Never arbitrary values like 999 or 9999.
### Motion
- Don't animate CSS layout properties.
#### Motion
- Motion should be intentional, and not be an afterthought. consider it as part of the build.
- Don't animate CSS layout properties unless truly needed.
- Ease out with exponential curves (ease-out-quart / quint / expo). No bounce, no elastic.
- Use libraries for more advanced motion needs (e.g. motion, gsap, anime.js, lenis etc)
- Reduced motion is not optional. Every animation needs a `@media (prefers-reduced-motion: reduce)` alternative: typically a crossfade or instant transition.
- Staggering the items within one list is legitimate. The tell is the uniform reflex (one identical entrance applied to every section), not motion itself; each reveal should fit what it reveals. Suppressing the reflex is never a reason to ship a page with no motion at all.
- Reveal animations must enhance an already-visible default. Don't gate content visibility on a class-triggered transition; transitions pause on hidden tabs and headless renderers, so the reveal never fires and the section ships blank.
- Premium motion materials are not just transform/opacity. Blur, backdrop-filter, clip-path, mask, and shadow/glow are part of the palette when they materially improve the effect and stay smooth.
#### Interaction
- Dropdowns rendered with `position: absolute` inside an `overflow: hidden` or `overflow: auto` container will be clipped. Use the native `<dialog>` / popover API, `position: fixed`, or a portal to escape the stacking context.
### Copy
- Every word earns its place. No restated headings, no intros that repeat the title.
- **No em dashes.** Use commas, colons, semicolons, periods, or parentheses. Also not `--`.
- **No aphoristic-cadence body copy as a default voice.** Don't fall into the rhythm of "serious statement, then punchy short negation" as the page's recurring voice. If three or more section copy blocks on the page land on a short rebuttal-shaped sentence, rewrite. Specific, not aphoristic.
- **No marketing buzzwords.** The streamline / empower / supercharge / leverage / unleash / transform / seamless / world-class / enterprise-grade / next-generation / cutting-edge / game-changer / mission-critical family of phrases. Pick a specific noun and a verb that describes what the product literally does.
- Button labels: verb + object. "Save changes" beats "OK"; "Delete project" beats "Yes". The label should say what will happen.
- Link text needs standalone meaning. "View pricing plans" beats "Click here"; screen readers announce links out of context.
### New projects only (when no prior work exists)
#### Color & Theme
- Use OKLCH.
- **The cream / sand / beige body bg is the saturated AI default of 2026.** The whole warm-neutral band (OKLCH L 0.84-0.97, C < 0.06, hue 40-100) reads as cream/sand/paper/parchment regardless of what you call it. Token names like `--paper`, `--cream`, `--sand`, `--bone`, `--flour`, `--linen`, `--parchment`, `--wheat`, `--biscuit`, `--ivory` are tells in themselves. If the brief is "warm, traditional, family-coastal-Italian" or "magazine-warm" or "editorial-restraint", DO NOT translate that into a near-white warm-tinted bg; that's the AI move. Pick: (a) a saturated brand color as the body (terracotta, oxblood, deep ochre, near-black), (b) a true off-white at chroma 0 (or chroma toward the brand's own hue, not toward warmth-by-default), or (c) a darker mid-tone tinted neutral that's clearly the brand's own. "Warmth" in the brand is carried by accent + typography + imagery, not by body bg.
- Tinted neutrals: add 0.0050.015 chroma toward the brand's hue. Don't default-tint toward warm or cool "because the brand feels that way"; that's the cross-project monoculture move.
- When picking a theme: Dark vs. light is never a default. Not dark "because tools look cool dark." Not light "to be safe.".Before choosing, write one sentence of physical scene: who uses this, where, under what ambient light, in what mood. If the sentence doesn't force the answer, it's not concrete enough. Add detail until it does.
- Pick a **color strategy** before picking colors. Four steps on the commitment axis:
- **Restrained**: tinted neutrals + one accent ≤10%. Product default; brand minimalism.
- **Committed**: one saturated color carries 3060% of the surface. Brand default for identity-driven pages.
- **Full palette**: 34 named roles, each used deliberately. Brand campaigns; product data viz.
- **Drenched**: the surface IS the color. Brand heroes, campaign pages.
### Absolute bans
@@ -96,18 +97,18 @@ Match-and-refuse. If you're about to write any of these, rewrite the element wit
- **Glassmorphism as default.** Blurs and glass cards used decoratively. Rare and purposeful, or nothing.
- **The hero-metric template.** Big number, small label, supporting stats, gradient accent. SaaS cliché.
- **Identical card grids.** Same-sized cards with icon + heading + text, repeated endlessly.
- **Modal as first thought.** Modals are usually laziness. Exhaust inline / progressive alternatives first.
### Copy
- Every word earns its place. No restated headings, no intros that repeat the title.
- **No em dashes.** Use commas, colons, semicolons, periods, or parentheses. Also not `--`.
- **Tiny uppercase tracked eyebrow above every section.** The 2023-era kicker (small all-caps text with wide tracking, "ABOUT" "PROCESS" "PRICING" above each heading) is now the saturated AI scaffold; it appears on 55-95% of generations regardless of brief, which is the definition of a tell. One named kicker as a deliberate brand system is voice; an eyebrow on every section is AI grammar. Choose a different cadence.
- **Numbered section markers as default scaffolding (01 / 02 / 03).** Putting `01 · About / 02 · Process / 03 · Pricing` above every section is the eyebrow trope one tier deeper: reach for it because "landing pages do this" and you're scaffolding by reflex. Numbers earn their place when the section actually IS a sequence (a real 3-step process, an ordered flow, a typed timeline) and the order carries information the reader needs. One deliberate numbered sequence on one page is voice; numbered eyebrows on every section across the site is AI grammar.
- **Text that overflows its container.** Long heading words plus large clamp scales plus narrow grids cause headline overflow on tablet/mobile. Test the heading copy at every breakpoint; if it overflows, reduce the clamp max or rewrite the copy. The viewport is part of the design.
### The AI slop test
If someone could look at this interface and say "AI made that" without doubt, it's failed. Cross-register failures are the absolute bans above. Register-specific failures live in each reference.
**Category-reflex check.** If someone could guess the theme and palette from the category name alone — "observability → dark blue", "healthcare → white + teal", "finance → navy + gold", "crypto → neon on black" — it's the training-data reflex. Rework the scene sentence and color strategy until the answer is no longer obvious from the domain.
**Category-reflex check.** Run at two altitudes; the second one catches what the first one misses.
- **First-order:** if someone could guess the theme + palette from the category alone, it's the first training-data reflex. Rework the scene sentence and color strategy until the answer isn't obvious from the domain.
- **Second-order:** if someone could guess the aesthetic family from category-plus-anti-references ("AI workflow tool that's not SaaS-cream → editorial-typographic", "fintech that's not navy-and-gold → terminal-native dark mode"), it's the trap one tier deeper. The first reflex was avoided; the second wasn't. Rework until both answers are not obvious. The brand register's [reflex-reject aesthetic lanes](reference/brand.md) list catches the currently-saturated families.
## Commands
@@ -115,7 +116,7 @@ If someone could look at this interface and say "AI made that" without doubt, it
|---|---|---|---|
| `craft [feature]` | Build | Shape, then build a feature end-to-end | [reference/craft.md](reference/craft.md) |
| `shape [feature]` | Build | Plan UX/UI before writing code | [reference/shape.md](reference/shape.md) |
| `teach` | Build | Set up PRODUCT.md and DESIGN.md context | [reference/teach.md](reference/teach.md) |
| `init` | Build | Set up project context: PRODUCT.md, DESIGN.md, live config, next steps | [reference/init.md](reference/init.md) |
| `document` | Build | Generate DESIGN.md from existing project code | [reference/document.md](reference/document.md) |
| `extract [target]` | Build | Pull reusable tokens and components into design system | [reference/extract.md](reference/extract.md) |
| `critique [target]` | Evaluate | UX design review with heuristic scoring | [reference/critique.md](reference/critique.md) |
@@ -137,16 +138,21 @@ If someone could look at this interface and say "AI made that" without doubt, it
| `optimize [target]` | Fix | Diagnose and fix UI performance | [reference/optimize.md](reference/optimize.md) |
| `live` | Iterate | Visual variant mode: pick elements in the browser, generate alternatives | [reference/live.md](reference/live.md) |
Plus two management commands `pin <command>` and `unpin <command>`, detailed below.
Plus two management commands: `pin <command>` and `unpin <command>`, detailed below.
### Routing rules
1. **No argument** render the table above as the user-facing command menu, grouped by category. Ask what they'd like to do.
2. **First word matches a command** load its reference file and follow its instructions. Everything after the command name is the target.
3. **First word doesn't match** — general design invocation. Apply the setup steps, shared design laws, and the loaded register reference, using the full argument as context.
1. **No argument**: render the table above as the user-facing command menu, grouped by category. Ask what they'd like to do.
2. **First word matches a command**: load its reference file and follow its instructions. Everything after the command name is the target.
3. **First word doesn't match, but the intent clearly maps to one command** (e.g. "fix the spacing" → `layout`, "rewrite this error message" → `clarify`, "the colors feel flat" → `colorize`): load that command's reference and proceed as if invoked. If two commands could fit, ask once which.
4. **No clear command match**: general design invocation. Apply the setup steps, the General rules, and the loaded register reference, using the full argument as context.
Setup (context gathering, register) is already loaded by then; sub-commands don't re-invoke `/impeccable`.
If the first word is `craft`, setup still runs first, but [reference/craft.md](reference/craft.md) owns the rest of the flow. If setup invokes `init` as a blocker, finish init, refresh context, then resume the original command and target.
`teach` is a deprecated alias for `init`: if the user types it, load [reference/init.md](reference/init.md) and proceed as if they ran `init`.
## Pin / Unpin
**Pin** creates a standalone shortcut so `/<command>` invokes `/impeccable <command>` directly. **Unpin** removes it. The script writes to every harness directory present in the project.
@@ -155,4 +161,4 @@ Setup (context gathering, register) is already loaded by then; sub-commands don'
node .claude/skills/impeccable/scripts/pin.mjs <pin|unpin> <command>
```
Valid `<command>` is any command from the table above. Report the script's result concisely — confirm the new shortcut on success, relay stderr verbatim on error.
Valid `<command>` is any command from the table above. Report the script's result concisely. Confirm the new shortcut on success, relay stderr verbatim on error.
+125 -4
View File
@@ -1,6 +1,6 @@
> **Additional context needed**: target platforms/devices and usage contexts.
Adapt existing designs to work effectively across different contexts - different screen sizes, devices, platforms, or use cases.
Adapt an existing design to a different context: another screen size, device, platform, or use case. The trap is treating adaptation as scaling. The job is rethinking the experience for the new context.
---
@@ -27,7 +27,7 @@ Understand what needs adaptation and why:
- What won't work? (Hover states on touch, tiny touch targets)
- What's inappropriate? (Desktop patterns on mobile, mobile patterns on desktop)
**CRITICAL**: Adaptation is not just scaling - it's rethinking the experience for the new context.
**CRITICAL**: Adaptation is rethinking the experience for the new context, not scaling pixels.
## Plan Adaptation Strategy
@@ -164,7 +164,7 @@ Choose appropriate breakpoints:
- Persistent side navigation on desktop
- Breadcrumbs on smaller screens for context
**IMPORTANT**: Test on real devices, not just browser DevTools. Device emulation is helpful but not perfect.
**IMPORTANT**: Test on real devices. Device emulation in DevTools is helpful but not perfect.
**NEVER**:
- Hide core functionality on mobile (if it matters, make it work)
@@ -187,4 +187,125 @@ Test thoroughly across contexts:
- **Edge cases**: Very small screens (320px), very large screens (4K)
- **Slow connections**: Test on throttled network
Remember: You're a cross-platform design expert. Make experiences that feel native to each context while maintaining brand and functionality consistency. Adapt intentionally, test thoroughly.
When the adaptation feels native to each context, hand off to `/impeccable polish` for the final pass.
---
## Reference Material
The sections below were previously `responsive-design.md` and live inline now so the adapt flow has its deep responsive reference in one place.
### Responsive Design
#### Mobile-First: Write It Right
Start with base styles for mobile, use `min-width` queries to layer complexity. Desktop-first (`max-width`) means mobile loads unnecessary styles first.
#### Breakpoints: Content-Driven
Don't chase device sizes; let content tell you where to break. Start narrow, stretch until design breaks, add breakpoint there. Three breakpoints usually suffice (640, 768, 1024px). Use `clamp()` for fluid values without breakpoints.
#### Detect Input Method, Not Just Screen Size
**Screen size doesn't tell you input method.** A laptop with touchscreen, a tablet with keyboard. Use pointer and hover queries:
```css
/* Fine pointer (mouse, trackpad) */
@media (pointer: fine) {
.button { padding: 8px 16px; }
}
/* Coarse pointer (touch, stylus) */
@media (pointer: coarse) {
.button { padding: 12px 20px; } /* Larger touch target */
}
/* Device supports hover */
@media (hover: hover) {
.card:hover { transform: translateY(-2px); }
}
/* Device doesn't support hover (touch) */
@media (hover: none) {
.card { /* No hover state - use active instead */ }
}
```
**Critical**: Don't rely on hover for functionality. Touch users can't hover.
#### Safe Areas: Handle the Notch
Modern phones have notches, rounded corners, and home indicators. Use `env()`:
```css
body {
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
/* With fallback */
.footer {
padding-bottom: max(1rem, env(safe-area-inset-bottom));
}
```
**Enable viewport-fit** in your meta tag:
```html
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
```
#### Responsive Images: Get It Right
##### srcset with Width Descriptors
```html
<img
src="hero-800.jpg"
srcset="
hero-400.jpg 400w,
hero-800.jpg 800w,
hero-1200.jpg 1200w
"
sizes="(max-width: 768px) 100vw, 50vw"
alt="Hero image"
>
```
**How it works**:
- `srcset` lists available images with their actual widths (`w` descriptors)
- `sizes` tells the browser how wide the image will display
- Browser picks the best file based on viewport width AND device pixel ratio
##### Picture Element for Art Direction
When you need different crops/compositions (not just resolutions):
```html
<picture>
<source media="(min-width: 768px)" srcset="wide.jpg">
<source media="(max-width: 767px)" srcset="tall.jpg">
<img src="fallback.jpg" alt="...">
</picture>
```
#### Layout Adaptation Patterns
**Navigation**: Three stages: hamburger + drawer on mobile, horizontal compact on tablet, full with labels on desktop. **Tables**: Transform to cards on mobile using `display: block` and `data-label` attributes. **Progressive disclosure**: Use `<details>/<summary>` for content that can collapse on mobile.
#### Testing: Don't Trust DevTools Alone
DevTools device emulation is useful for layout but misses:
- Actual touch interactions
- Real CPU/memory constraints
- Network latency patterns
- Font rendering differences
- Browser chrome/keyboard appearances
**Test on at least**: One real iPhone, one real Android, a tablet if relevant. Cheap Android phones reveal performance issues you'll never see on simulators.
---
**Avoid**: Desktop-first design. Device detection instead of feature detection. Separate mobile/desktop codebases. Ignoring tablet and landscape. Assuming all mobile devices are powerful.
+52 -24
View File
@@ -1,14 +1,14 @@
> **Additional context needed**: performance constraints.
Analyze a feature and strategically add animations and micro-interactions that enhance understanding, provide feedback, and create delight.
Add motion that conveys state, gives feedback, and clarifies hierarchy. Cut motion that exists only for decoration. Animation fatigue is a real cost; spend the budget on the moments that need it.
---
## Register
Brand: orchestrated page-load sequences, staggered reveals, scroll-driven animation. Motion is part of the voice; one well-rehearsed entrance beats scattered micro-interactions.
Brand: motion is part of the voice; one well-rehearsed entrance beats scattered micro-interactions. The saturated AI default is fade-and-rise reveals on every scrolled section; that's a tell, not a choreography. Reserve scroll-triggered motion for moments that earn it.
Product: 150250 ms on most transitions. Motion conveys state feedback, reveal, loading, transitions between views. No page-load choreography; users are in a task and won't wait for it.
Product: 150250 ms on most transitions. Motion conveys state: feedback, reveal, loading, transitions between views. No page-load choreography; users are in a task and won't wait for it.
---
@@ -49,10 +49,11 @@ Create a purposeful animation plan:
Add motion systematically across these categories:
### Entrance Animations
- **Page load choreography**: Stagger element reveals (100-150ms delays), fade + slide combinations
- **Hero section**: Dramatic entrance for primary content (scale, parallax, or creative effects)
- **Content reveals**: Scroll-triggered animations using intersection observer
- **Modal/drawer entry**: Smooth slide + fade, backdrop fade, focus management
- **List rhythm**: Sibling stagger is legitimate for cards-in-a-grid or list-items-appearing. Whole-section fade-on-scroll is not a list and is not legitimate. Cap total stagger time: 10 items at 50ms each = 500ms total. For more items, reduce per-item delay or cap the staggered count.
Use CSS custom properties for clean stagger: `animation-delay: calc(var(--i, 0) * 50ms)` with `style="--i: 0"`, `style="--i: 1"`, etc. on each item.
### Micro-interactions
- **Button feedback**:
@@ -97,20 +98,23 @@ Use appropriate techniques for each animation:
### Timing & Easing
**Durations by purpose:**
- **100-150ms**: Instant feedback (button press, toggle)
- **200-300ms**: State changes (hover, menu open)
- **300-500ms**: Layout changes (accordion, modal)
- **500-800ms**: Entrance animations (page load)
**Duration: the 100/300/500 rule.** Timing matters more than easing for "feels right":
| Duration | Use Case | Examples |
|----------|----------|----------|
| **100150ms** | Instant feedback | Button press, toggle, color change |
| **200300ms** | State changes | Menu open, tooltip, hover state |
| **300500ms** | Layout changes | Accordion, modal, drawer |
| **500800ms** | Entrance animations | Page load, hero reveal |
**Easing curves (use these, not CSS defaults):**
```css
/* Recommended - natural deceleration */
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1); /* Smooth, refined */
/* Recommended: natural deceleration */
--ease-out-quart: cubic-bezier(0.25, 1, 0.5, 1); /* Smooth */
--ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1); /* Slightly snappier */
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); /* Confident, decisive */
/* AVOID - feel dated and tacky */
/* AVOID: feel dated and tacky */
/* bounce: cubic-bezier(0.34, 1.56, 0.64, 1); */
/* elastic: cubic-bezier(0.68, -0.6, 0.32, 1.6); */
```
@@ -122,7 +126,8 @@ Use appropriate techniques for each animation:
/* Prefer for simple, declarative animations */
- transitions for state changes
- @keyframes for complex sequences
- transform + opacity only (GPU-accelerated)
- transform and opacity for reliable movement
- blur, filters, masks, clip paths, shadows, and color shifts for premium atmospheric effects when verified smooth
```
### JavaScript Animation
@@ -133,12 +138,35 @@ Use appropriate techniques for each animation:
- GSAP for complex sequences
```
### Motion Materials
Transform and opacity are reliable defaults, not the whole palette. Premium interfaces often need atmospheric properties. Match material to effect:
- **Transform / opacity**: movement, press feedback, simple reveals, list choreography
- **Blur / filter / backdrop-filter**: focus pulls, depth, glass or lens effects, softened entrances
- **Clip-path / masks**: wipes, reveals, editorial cropping, product-like transitions
- **Shadow / glow / color filters**: energy, affordance, focus, warmth, active state
- **Grid-template-rows or FLIP-style transforms**: expanding and reflowing layout without animating `height` directly
The hard rule isn't "transform and opacity only." It's: avoid animating layout-driving properties casually (`width`, `height`, `top`, `left`, margins), keep expensive effects bounded to small or isolated areas, and verify smoothness in-browser on target viewports.
### Performance
- **GPU acceleration**: Use `transform` and `opacity`, avoid layout properties
- **will-change**: Add sparingly for known expensive animations
- **Reduce paint**: Minimize repaints, use `contain` where appropriate
- **Layout safety**: Avoid casual animation of layout-driving properties (`width`, `height`, `top`, `left`, margins)
- **will-change**: Add sparingly for known expensive animations only (e.g. on `:hover` or an `.animating` class), never preemptively across the whole page
- **Scroll triggers**: Use Intersection Observer instead of scroll event listeners; unobserve after the animation fires once
- **Bound expensive effects**: Keep blur/filter/shadow areas small or isolated, use `contain` where appropriate
- **Monitor FPS**: Ensure 60fps on target devices
### Perceived Performance
Nobody cares how fast your site *is*, only how fast it feels. The 80ms threshold: anything under ~80ms feels instant because our brains buffer sensory input for that long to synchronize perception. Target this for micro-interactions.
- **Preemptive start**: Begin transitions immediately while loading (iOS app zoom, skeleton UI). Users perceive work happening.
- **Early completion**: Show content progressively, don't wait for everything (progressive images, streaming HTML, skeleton fade-ins).
- **Optimistic UI**: Update the interface immediately, handle failures gracefully. Use for low-stakes actions (likes, follows). Avoid for payments or destructive operations.
- **Easing affects perceived duration**: Ease-in (accelerating toward completion) makes tasks feel shorter because the peak-end effect weights final moments heavily. Ease-out feels satisfying for entrances.
- **Caution**: Too-fast responses can decrease perceived value for complex operations (search, analysis). Sometimes a brief delay signals "real work" is happening.
### Accessibility
```css
@media (prefers-reduced-motion: reduce) {
@@ -151,12 +179,12 @@ Use appropriate techniques for each animation:
```
**NEVER**:
- Use bounce or elastic easing curvesthey feel dated and draw attention to the animation itself
- Animate layout properties (width, height, top, left)—use transform instead
- Use durations over 500ms for feedbackit feels laggy
- Animate without purposeevery animation needs a reason
- Ignore `prefers-reduced-motion`this is an accessibility violation
- Animate everythinganimation fatigue makes interfaces feel exhausting
- Use bounce or elastic easing curves; they feel dated and draw attention to the animation itself
- Animate layout properties casually (`width`, `height`, `top`, `left`, margins) when transform, FLIP, or grid-based techniques would work
- Use durations over 500ms for feedback (it feels laggy)
- Animate without purpose (every animation needs a reason)
- Ignore `prefers-reduced-motion` (this is an accessibility violation)
- Animate everything (animation fatigue makes interfaces feel exhausting)
- Block interaction during animations unless intentional
## Verify Quality
@@ -170,4 +198,4 @@ Test animations thoroughly:
- **Doesn't block**: Users can interact during/after animations
- **Adds value**: Makes interface clearer or more delightful
Remember: Motion should enhance understanding and provide feedback, not just add decoration. Animate with purpose, respect performance constraints, and always consider accessibility. Great animation is invisible - it just makes everything feel right.
When the motion clarifies state instead of decorating it, hand off to `/impeccable polish` for the final pass.
+9 -10
View File
@@ -1,4 +1,4 @@
Run systematic **technical** quality checks and generate a comprehensive report. Don't fix issues document them for other commands to address.
Run systematic **technical** quality checks and generate a comprehensive report. Don't fix issues; document them for other commands to address.
This is a code-level audit, not a design critique. Check what's measurable and verifiable in the implementation.
@@ -22,7 +22,7 @@ Run comprehensive checks across 5 dimensions. Score each dimension 0-4 using the
**Check for**:
- **Layout thrashing**: Reading/writing layout properties in loops
- **Expensive animations**: Animating layout properties (width, height, top, left) instead of transform/opacity
- **Expensive animations**: Casual layout-property animation, unbounded blur/filter/shadow effects, or effects that visibly drop frames
- **Missing optimization**: Images without lazy loading, unoptimized assets, missing will-change
- **Bundle size**: Unnecessary imports, unused dependencies
- **Render performance**: Unnecessary re-renders, missing memoization
@@ -83,10 +83,10 @@ Check against ALL the **DON'T** guidelines from the parent impeccable skill (alr
### Detailed Findings by Severity
Tag every issue with **P0-P3 severity**:
- **P0 Blocking**: Prevents task completion — fix immediately
- **P1 Major**: Significant difficulty or WCAG AA violation — fix before release
- **P2 Minor**: Annoyance, workaround exists — fix in next pass
- **P3 Polish**: Nice-to-fix, no real user impact — fix if time permits
- **P0 Blocking**: Prevents task completion. Fix immediately
- **P1 Major**: Significant difficulty or WCAG AA violation. Fix before release
- **P2 Minor**: Annoyance, workaround exists. Fix in next pass
- **P3 Polish**: Nice-to-fix, no real user impact. Fix if time permits
For each issue, document:
- **[P?] Issue name**
@@ -105,14 +105,14 @@ Identify recurring problems that indicate systemic gaps rather than one-off mist
### Positive Findings
Note what's working well good practices to maintain and replicate.
Note what's working well: good practices to maintain and replicate.
## Recommended Actions
List recommended commands in priority order (P0 first, then P1, then P2):
1. **[P?] `/command-name`** Brief description (specific context from audit findings)
2. **[P?] `/command-name`** Brief description (specific context)
1. **[P?] `/command-name`**: Brief description (specific context from audit findings)
2. **[P?] `/command-name`**: Brief description (specific context)
**Rules**: Only recommend commands from: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable delight, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset. Map findings to the most appropriate command. End with `/impeccable polish` as the final step if any fixes were recommended.
@@ -131,4 +131,3 @@ After presenting the summary, tell the user:
- Forget to prioritize (everything can't be P0)
- Report false positives without verification
Remember: You're a technical quality auditor. Document systematically, prioritize ruthlessly, cite specific code locations, and provide clear paths to improvement.
+12 -12
View File
@@ -1,4 +1,4 @@
Increase visual impact and personality in designs that are too safe, generic, or visually underwhelming, creating more engaging and memorable experiences.
When asked for "bolder," AI defaults to the same tired tricks: cyan/purple gradients, glassmorphism, neon accents on dark backgrounds, gradient text on metrics. These are the opposite of bold. Reject them first, then increase visual impact and personality through stronger hierarchy, committed scale, and decisive type.
---
@@ -6,7 +6,7 @@ Increase visual impact and personality in designs that are too safe, generic, or
Brand: "bolder" means distinctive. Extreme scale, unexpected color, typographic risk, committed POV.
Product: "bolder" rarely means theatrics those undermine trust. It means stronger hierarchy, clearer weight contrast, one sharper accent, more committed density. The amplification is in clarity, not drama.
Product: "bolder" rarely means theatrics; those undermine trust. It means stronger hierarchy, clearer weight contrast, one sharper accent, more committed density. The amplification is in clarity, not drama.
---
@@ -32,7 +32,7 @@ If any of these are unclear from the codebase, STOP and call the AskUserQuestion
**CRITICAL**: "Bolder" doesn't mean chaotic or garish. It means distinctive, memorable, and confident. Think intentional drama, not random chaos.
**WARNING - AI SLOP TRAP**: When making things "bolder," AI defaults to the same tired tricks: cyan/purple gradients, glassmorphism, neon accents on dark backgrounds, gradient text on metrics. These are the OPPOSITE of bold. They're generic. Review ALL the DON'T guidelines from the parent impeccable skill (already loaded in this context) before proceeding. Bold means distinctive, not "more effects."
**WARNING - AI SLOP TRAP**: Review ALL the DON'T guidelines from the parent impeccable skill (already loaded in this context) before proceeding. Bold means distinctive, not "more effects."
## Plan Amplification
@@ -50,14 +50,14 @@ Create a strategy to increase impact while maintaining coherence:
Systematically increase impact across these dimensions:
### Typography Amplification
- **Replace generic fonts**: Swap system fonts for distinctive choices (see the parent skill's typography guidelines and [typography.md](typography.md) for inspiration)
- **Replace generic fonts**: Swap system fonts for distinctive choices (see the parent skill's typography guidelines and the [Reference Material section of typeset.md](typeset.md#reference-material) for inspiration)
- **Extreme scale**: Create dramatic size jumps (3x-5x differences, not 1.5x)
- **Weight contrast**: Pair 900 weights with 200 weights, not 600 with 400
- **Unexpected choices**: Variable fonts, display fonts for headlines, condensed/extended widths, monospace as intentional accent (not as lazy "dev tool" default)
### Color Intensification
- **Increase saturation**: Shift to more vibrant, energetic colors (but not neon)
- **Bold palette**: Introduce unexpected color combinations—avoid the purple-blue gradient AI slop
- **Bold palette**: Introduce unexpected color combinations. Avoid the purple-blue gradient AI slop
- **Dominant color strategy**: Let one bold color own 60% of the design
- **Sharp accents**: High-contrast accent colors that pop
- **Tinted neutrals**: Replace pure grays with tinted grays that harmonize with your palette
@@ -73,15 +73,15 @@ Systematically increase impact across these dimensions:
### Visual Effects
- **Dramatic shadows**: Large, soft shadows for elevation (but not generic drop shadows on rounded rectangles)
- **Background treatments**: Mesh patterns, noise textures, geometric patterns, intentional gradients (not purple-to-blue)
- **Texture & depth**: Grain, halftone, duotone, layered elementsNOT glassmorphism (it's overused AI slop)
- **Texture & depth**: Grain, halftone, duotone, layered elements. NOT glassmorphism (it's overused AI slop)
- **Borders & frames**: Thick borders, decorative frames, custom shapes (not rounded rectangles with colored border on one side)
- **Custom elements**: Illustrative elements, custom icons, decorative details that reinforce brand
### Motion & Animation
- **Entrance choreography**: Staggered, dramatic page load animations with 50-100ms delays
- **Scroll effects**: Parallax, reveal animations, scroll-triggered sequences
- **Micro-interactions**: Satisfying hover effects, click feedback, state changes
- **Transitions**: Smooth, noticeable transitions using ease-out-quart/quint/expo (not bounce or elastic—they cheapen the effect)
- **Hero moment**: One signature entrance, once. Not on every visit and not on every section.
- **Micro-interactions**: Satisfying hover effects, click feedback, state changes.
- **Transitions**: Smooth, noticeable transitions using ease-out-quart/quint/expo (not bounce or elastic, which cheapen the effect).
- **Bolder ≠ scroll-fade-rise on every section.** That's the saturated AI default, the opposite of bold.
### Composition Boldness
- **Hero moments**: Create clear focal points with dramatic treatment
@@ -92,7 +92,7 @@ Systematically increase impact across these dimensions:
**NEVER**:
- Add effects randomly without purpose (chaos ≠ bold)
- Sacrifice readability for aesthetics (body text must be readable)
- Make everything bold (then nothing is bold - need contrast)
- Make everything bold (then nothing is bold; you need contrast)
- Ignore accessibility (bold design must still meet WCAG standards)
- Overwhelm with motion (animation fatigue is real)
- Copy trendy aesthetics blindly (bold means distinctive, not derivative)
@@ -110,4 +110,4 @@ Ensure amplification maintains usability and coherence:
**The test**: If you showed this to someone and said "AI made this bolder," would they believe you immediately? If yes, you've failed. Bold means distinctive, not "more AI effects."
Remember: Bold design is confident design. It takes risks, makes statements, and creates memorable experiences. But bold without strategy is just loud. Be intentional, be dramatic, be unforgettable.
When the result feels right, hand off to `/impeccable polish` for the final pass.
+33 -29
View File
@@ -1,47 +1,52 @@
# Brand register
When design IS the product: brand sites, landing pages, marketing surfaces, campaign pages, portfolios, long-form content, about pages. The deliverable is the design itself a visitor's impression is the thing being made.
When design IS the product: brand sites, landing pages, marketing surfaces, campaign pages, portfolios, long-form content, about pages. The deliverable is the design itself; a visitor's impression is the thing being made.
The register spans every genre. A tech brand (Stripe, Linear, Vercel). A luxury brand (a hotel, a fashion house). A consumer product (a restaurant, a travel site, a CPG packaging page). A creative studio, an agency portfolio, a band's album page. They all share the stance *communicate, not transact* and diverge wildly in aesthetic. Don't collapse them into a single look.
The register spans every genre. A tech brand (Stripe, Linear, Vercel). A luxury brand (a hotel, a fashion house). A consumer product (a restaurant, a travel site, a CPG packaging page). A creative studio, an agency portfolio, a band's album page. They all share the stance (*communicate, not transact*) and diverge wildly in aesthetic. Don't collapse them into a single look.
## The brand slop test
If someone could look at this and say "AI made that" without hesitation, it's failed. The bar is distinctiveness a visitor should ask "how was this made?", not "which AI made this?"
If someone could look at this and say "AI made that" without hesitation, it's failed. The bar is distinctiveness; a visitor should ask "how was this made?", not "which AI made this?"
Brand isn't a neutral register. AI-generated landing pages have flooded the internet, and average is no longer findable. Restraint without intent now reads as mediocre, not refined. Brand surfaces need a POV, a specific audience, a willingness to risk strangeness. Go big or go home.
**The second slop test: aesthetic lane.** Before committing to moves, name the reference. A Klim-style specimen page is one lane; Stripe-minimal is another; Liquid-Death-acid-maximalism is another. Don't drift into editorial-magazine aesthetics on a brief that isn't editorial. A hiking brand with Cormorant italic drop caps has the wrong register within the register.
Then the inverse test: in one sentence, describe what you're about to build the way a competitor would describe theirs. If that sentence fits the modal landing page in the category, restart.
## Typography
### Font selection procedure
Every project. Never skip.
1. Read the brief. Write three concrete brand-voice words — not "modern" or "elegant," but "warm and mechanical and opinionated" or "calm and clinical and careful." Physical-object words.
2. List the three fonts you'd reach for by reflex. If any appear in the reflex-reject list below, reject them they are training-data defaults and they create monoculture.
3. Browse a real catalog (Google Fonts, Pangram Pangram, Future Fonts, Adobe Fonts, ABC Dinamo, Klim, Velvetyne) with the three words in mind. Find the font for the brand as a *physical object* a museum caption, a 1970s terminal manual, a fabric label, a cheap-newsprint children's book, a concert poster, a receipt from a mid-century diner. Reject the first thing that "looks designy."
1. Read the brief. Write three concrete brand-voice words. Not "modern" or "elegant," but "warm and mechanical and opinionated" or "calm and clinical and careful." Physical-object words.
2. List the three fonts you'd reach for by reflex. If any appear in the reflex-reject list below, reject them; they are training-data defaults and they create monoculture.
3. Browse a real catalog (Google Fonts, Pangram Pangram, Future Fonts, Adobe Fonts, ABC Dinamo, Klim, Velvetyne) with the three words in mind. Find the font for the brand as a *physical object*: a museum caption, a 1970s terminal manual, a fabric label, a cheap-newsprint children's book, a concert poster, a receipt from a mid-century diner. Reject the first thing that "looks designy."
4. Cross-check. "Elegant" is not necessarily serif. "Technical" is not necessarily sans. "Warm" is not Fraunces. If the final pick lines up with the original reflex, start over.
### Reflex-reject list
Training-data defaults. Ban list — look further:
Training-data defaults. Ban list. Look further:
Fraunces · Newsreader · Lora · Crimson · Crimson Pro · Crimson Text · Playfair Display · Cormorant · Cormorant Garamond · Syne · IBM Plex Mono · IBM Plex Sans · IBM Plex Serif · Space Mono · Space Grotesk · Inter · DM Sans · DM Serif Display · DM Serif Text · Outfit · Plus Jakarta Sans · Instrument Sans · Instrument Serif
### Reflex-reject aesthetic lanes
Parallel to the font list. Currently saturated aesthetic families that have flooded brand surfaces. If a brief lands in one of these lanes without a register reason that *requires* it (a literal magazine, a literal terminal, a literal industrial signage system), it's the second-order training reflex: the trap one tier deeper than picking a Fraunces font. Look further.
- **Editorial-typographic.** Display serif (often italic) + small mono labels + ruled separators + monochromatic restraint. Klim-influenced, magazine-cover affectation. By 2026, every Stripe-adjacent and Notion-adjacent brand has landed here. The fingerprint: three rule-separated columns, an italic Fraunces / Recoleta / Newsreader headline, lowercase track-spaced metadata, no imagery.
(More entries land here on the same cadence the font list updates. Brutalist-utility and acid-maximalism may join when they saturate. Removing entries when they fall back below saturation is also fine.)
The reflex-reject lists apply to **new design choices**. When the existing brand has already committed to a font or a lane as part of its identity, identity-preservation wins; variants on an existing surface don't second-guess what's already shipping. The reflex-reject lists are for greenfield decisions and for departure-mode variants in [live.md](live.md).
### Pairing and voice
Distinctive + refined is the goal — the specific shape depends on the brand:
- **Editorial / long-form / luxury**: display serif + sans body (a magazine shape).
- **Tech / dev tools / fintech**: one committed sans, usually; custom-tight tracking, strong weight contrast inside a single family.
- **Consumer / food / travel**: warmer pairings, often a humanist sans plus a script or display serif.
- **Creative studios / agencies**: rule-breaking welcome — mono-only, or display-only, or custom-drawn type as voice.
Distinctive + refined is the goal. The specific shape depends on the brand, not on the brand's category. A category ("restaurant", "dev tool", "magazine", "fintech") is not a recipe; treating it as one is the first-order reflex SKILL.md warns against.
Two families minimum is the rule *only* when the voice needs it. A single well-chosen family with committed weight/size contrast is stronger than a timid display+body pair.
Vary across projects. If the last brief was a serif-display landing page, this one isn't.
### Scale
Modular scale, fluid `clamp()` for headings, ≥1.25 ratio between steps. Flat scales (1.1× apart) read as uncommitted.
@@ -50,38 +55,37 @@ Light text on dark backgrounds: add 0.050.1 to line-height. Light type reads
## Color
Brand surfaces have permission for Committed, Full palette, and Drenched strategies. Use them. A single saturated color spread across a hero is not excess it's voice. A beige-and-muted-slate landing page ignores the register.
Brand surfaces have permission for Committed, Full palette, and Drenched strategies. Use them. A single saturated color spread across a hero is not excess; it's voice. A beige-and-muted-slate landing page ignores the register.
- Name a real reference before picking a strategy. "Klim Type Foundry #ff4500 orange drench", "Stripe purple-on-white restraint", "Liquid Death acid-green full palette", "Mailchimp yellow full palette", "Condé Nast Traveler muted navy restraint", "Vercel pure black monochrome". Unnamed ambition becomes beige.
- Palette IS voice. A calm brand and a restless brand should not share palette mechanics.
- When the strategy is Committed or Drenched, the color is load-bearing. Don't hedge with neutrals around the edges — commit.
- When the strategy is Committed or Drenched, color carries the brand. Don't hedge with neutrals around the edges. Commit.
- Don't converge across projects. If the last brand surface was restrained-on-cream, this one is not.
- When a cultural-symbol palette is the obvious pull, reach past it. Let the cultural reading come from typography, imagery, and copy, not the palette.
## Layout
- Asymmetric compositions are one option. Break the grid intentionally for emphasis.
- Fluid spacing with `clamp()` that breathes on larger viewports. Vary for rhythm generous separations, tight groupings.
- Alternative: a strict, visible grid as the voice (brutalist / Swiss / tech-spec aesthetics). Either asymmetric or rigorously-gridded can be "designed" — the failure mode is splitting the difference into a generic centered stack.
- Don't default to centering everything. Left-aligned with asymmetric layouts feels more designed; a strict grid reads as confident structure. A centered-stack hero with icon-title-subtitle cards reads as template.
- When cards ARE the right affordance, use `grid-template-columns: repeat(auto-fit, minmax(280px, 1fr))` — breakpoint-free responsiveness.
- Fluid spacing with `clamp()` that breathes on larger viewports. Vary for rhythm: generous separations, tight groupings.
- For image-led briefs (hotels, restaurants, magazines, photography), full-bleed hero imagery with overlaid menu and centered headline is a canonical move; let the photograph be the design.
- When cards ARE the right affordance, use `grid-template-columns: repeat(auto-fit, minmax(280px, 1fr))` for breakpoint-free responsiveness.
## Imagery
Brand surfaces lean on imagery. A restaurant, hotel, magazine, or product landing page without any imagery reads as incomplete, not as restrained. A solid-color rectangle where a hero image should go is worse than a representative stock photo.
**When the brief implies imagery (restaurants, hotels, magazines, photography, hobbyist communities, food, travel, fashion, product), you must ship imagery.** Zero images is a bug, not a design choice. "Restraint" is not an excuse.
**When the brief implies imagery (restaurants, hotels, magazines, photography, hobbyist communities, food, travel, fashion, product), you must ship imagery.** Zero images is a bug, not a design choice. "Restraint" is not an excuse. If the approved comp or brief is image-led, ship real project assets, generated raster assets, or a credible canvas/SVG/WebGL scene. Do not replace photographic, architectural, product, or place imagery with generic CSS panels, decorative diagrams, cards, bullets, or copy.
- **For greenfield work without local assets, use stock imagery** Unsplash is the default. The URL shape is `https://images.unsplash.com/photo-{id}?auto=format&fit=crop&w=1600&q=80`. Pick real Unsplash photo IDs you're confident exist (`photo-1559339352-11d035aa65de`, `photo-1590490360182-c33d57733427`, etc.); if unsure, pick fewer photos but don't substitute colored `<div>` placeholders.
- **For greenfield work without local assets, use stock imagery.** Unsplash is the default. The URL shape is `https://images.unsplash.com/photo-{id}?auto=format&fit=crop&w=1600&q=80`. **Verify the URLs before referencing them.** If you have an image-search MCP, web-fetch tool, or browser access, use it to find real photo IDs and confirm they resolve. Guessed IDs (even ones that look real) often 404 and ship as broken-image placeholders. Without a verification path, pick fewer photos you're confident exist over more that you guessed; never substitute colored `<div>` placeholders.
- **Search for the brand's physical object**, not the generic category: "handmade pasta on a scratched wooden table" beats "Italian food"; "cypress trees above a limestone hotel facade at dusk" beats "luxury hotel".
- **One decisive photo beats five mediocre ones.** Hero imagery should commit to a mood; padding with more stock doesn't rescue an indecisive one.
- **Alt text is part of the voice.** "Coastal fettuccine, hand-cut, served on the terrace" beats "pasta dish".
Tech / dev-tool brands are the exception where zero imagery can be correct — a developer landing page often carries its voice through typography, code samples, diagrams. Know which kind of brand you're working on.
"Imagery" here is broader than stock photography: product screenshots, custom data visualizations, generated SVG, and canvas/WebGL scenes are all imagery. Text-only pages where typography alone carries the entire visual weight are the failure mode.
## Motion
- One well-orchestrated page-load with staggered reveals beats scattered micro-interactions when the brand invites it. Tech-minimal brands often skip entrance motion entirely; the restraint is the voice.
- For collapsing/expanding sections, transition `grid-template-rows` rather than `height`.
- One well-orchestrated page-load beats scattered micro-interactions, when the brand invites it. Some brands skip entrance motion entirely; the restraint is the voice.
## Brand bans (on top of the shared absolute bans)
@@ -92,13 +96,13 @@ Tech / dev-tool brands are the exception where zero imagery can be correct — a
- Timid palettes and average layouts. Safe = invisible.
- Zero imagery on a brief that implies imagery (restaurant, hotel, food, travel, fashion, photography, hobbyist). Colored blocks where a hero photo belongs.
- Defaulting to editorial-magazine aesthetics (display serif + italic + drop caps + broadsheet grid) on briefs that aren't magazine-shaped. Editorial is ONE aesthetic lane, not the default brand aesthetic.
- Repeated tiny uppercase tracked labels above every section heading. A single strong kicker can be voice; repeating it as section grammar is AI scaffolding unless it's a deliberate, named brand system.
## Brand permissions
Brand can afford things product can't. Take them.
- Ambitious first-load motion. Reveals, scroll-triggered transitions, typographic choreography.
- Ambitious first-load motion. Reveals and typographic choreography that earn their place; not fade-on-scroll for every section.
- Single-purpose viewports. One dominant idea per fold, long scroll, deliberate pacing.
- Typographic risk. Enormous display type, unexpected italic cuts, mixed cases, hand-drawn headlines, a single oversize word as a hero.
- Unexpected color strategies. Palette IS voice — a calm brand and a restless brand should not share palette mechanics.
- Unexpected color strategies. Palette IS voice; a calm brand and a restless brand should not share palette mechanics.
- Art direction per section. Different sections can have different visual worlds if the narrative demands it. Consistency of voice beats consistency of treatment.
+117 -3
View File
@@ -1,6 +1,6 @@
> **Additional context needed**: audience technical level and users' mental state in context.
Identify and improve unclear, confusing, or poorly written interface text to make the product easier to understand and use.
Find the unclear, confusing, or poorly written interface text and rewrite it. Vague copy creates support tickets and abandonment; specific copy gets users through the task.
---
@@ -146,7 +146,7 @@ Every piece of copy should follow these rules:
2. **Be concise**: Cut unnecessary words (but don't sacrifice clarity)
3. **Be active**: "Save changes" not "Changes will be saved"
4. **Be human**: "Oops, something went wrong" not "System error encountered"
5. **Be helpful**: Tell users what to do, not just what happened
5. **Tell users what to do**, not just what happened
6. **Be consistent**: Use same terms throughout (don't vary for variety)
**NEVER**:
@@ -171,4 +171,118 @@ Test that copy improvements work:
- **Consistency**: Does it match terminology elsewhere?
- **Tone**: Is it appropriate for the situation?
Remember: You're a clarity expert with excellent communication skills. Write like you're explaining to a smart friend who's unfamiliar with the product. Be clear, be helpful, be human.
When the copy reads cleanly, hand off to `/impeccable polish` for the final pass.
---
## Reference Material
The sections below were previously `ux-writing.md` and live inline now so the clarify flow has its deep UX-writing reference in one place.
### UX Writing
#### The Button Label Problem
**Never use "OK", "Submit", or "Yes/No".** These are lazy and ambiguous. Use specific verb + object patterns:
| Bad | Good | Why |
|-----|------|-----|
| OK | Save changes | Says what will happen |
| Submit | Create account | Outcome-focused |
| Yes | Delete message | Confirms the action |
| Cancel | Keep editing | Clarifies what "cancel" means |
| Click here | Download PDF | Describes the destination |
**For destructive actions**, name the destruction:
- "Delete" not "Remove" (delete is permanent, remove implies recoverable)
- "Delete 5 items" not "Delete selected" (show the count)
#### Error Messages: The Formula
Every error message should answer: (1) What happened? (2) Why? (3) How to fix it? Example: "Email address isn't valid. Please include an @ symbol." not "Invalid input".
##### Error Message Templates
| Situation | Template |
|-----------|----------|
| **Format error** | "[Field] needs to be [format]. Example: [example]" |
| **Missing required** | "Please enter [what's missing]" |
| **Permission denied** | "You don't have access to [thing]. [What to do instead]" |
| **Network error** | "We couldn't reach [thing]. Check your connection and [action]." |
| **Server error** | "Something went wrong on our end. We're looking into it. [Alternative action]" |
##### Don't Blame the User
Reframe errors: "Please enter a date in MM/DD/YYYY format" not "You entered an invalid date".
#### Empty States Are Opportunities
Empty states are onboarding moments: (1) Acknowledge briefly, (2) Explain the value of filling it, (3) Provide a clear action. "No projects yet. Create your first one to get started." not just "No items".
#### Voice vs Tone
**Voice** is your brand's personality, consistent everywhere.
**Tone** adapts to the moment.
| Moment | Tone Shift |
|--------|------------|
| Success | Celebratory, brief: "Done! Your changes are live." |
| Error | Empathetic, helpful: "That didn't work. Here's what to try..." |
| Loading | Reassuring: "Saving your work..." |
| Destructive confirm | Serious, clear: "Delete this project? This can't be undone." |
**Never use humor for errors.** Users are already frustrated. Be helpful, not cute.
#### Writing for Accessibility
**Link text** must have standalone meaning: "View pricing plans" not "Click here". **Alt text** describes information, not the image: "Revenue increased 40% in Q4" not "Chart". Use `alt=""` for decorative images. **Icon buttons** need `aria-label` for screen reader context.
#### Writing for Translation
##### Plan for Expansion
German text is ~30% longer than English. Allocate space:
| Language | Expansion |
|----------|-----------|
| German | +30% |
| French | +20% |
| Finnish | +30-40% |
| Chinese | -30% (fewer chars, but same width) |
##### Translation-Friendly Patterns
Keep numbers separate ("New messages: 3" not "You have 3 new messages"). Use full sentences as single strings (word order varies by language). Avoid abbreviations ("5 minutes ago" not "5 mins ago"). Give translators context about where strings appear.
#### Consistency: The Terminology Problem
Pick one term and stick with it:
| Inconsistent | Consistent |
|--------------|------------|
| Delete / Remove / Trash | Delete |
| Settings / Preferences / Options | Settings |
| Sign in / Log in / Enter | Sign in |
| Create / Add / New | Create |
Build a terminology glossary and enforce it. Variety creates confusion.
#### Avoid Redundant Copy
If the heading explains it, the intro is redundant. If the button is clear, don't explain it again. Say it once, say it well.
#### Loading States
Be specific: "Saving your draft..." not "Loading...". For long waits, set expectations ("This usually takes 30 seconds") or show progress.
#### Confirmation Dialogs: Use Sparingly
Most confirmation dialogs are design failures; consider undo instead. When you must confirm: name the action, explain consequences, use specific button labels ("Delete project" / "Keep project", not "Yes" / "No").
#### Form Instructions
Show format with placeholders, not instructions. For non-obvious fields, explain why you're asking.
---
**Avoid**: Jargon without explanation. Blaming users ("You made an error" → "This field is required"). Vague errors ("Something went wrong"). Varying terminology for variety. Humor for errors.
@@ -0,0 +1,105 @@
# Codex: Visual Direction & Asset Production
This file is loaded by `/impeccable craft` when the harness has native image generation (currently Codex via `image_gen`). Other harnesses skip it. It covers the two craft steps that depend on real image generation: landing the visual direction, and producing the raster assets the implementation will compose.
Read this *before* generating any images. The order matters, and the per-step user pauses are what keep generated imagery from drifting away from the brief.
### Four stop points before code
Steps A through D each end with the user. Do not advance past any of them on your own read of the situation.
1. **STOP after Step A questions.** Wait for answers.
2. **STOP after Step B palette generation.** Wait for "confirm palette."
3. **STOP after Step C mocks.** Wait for direction approval or delegation.
4. **Only after Step D approves a direction** do you return to craft.md Step 4 and write code.
Prior shape approval does **not** satisfy any of these. Shape's "confirm or override" advances you into Step A; it is not a substitute for it.
## Step A: Explore Directions with the User
Before generating anything, run a brief direction conversation grounded in the shape brief.
**Step A is required even when shape just produced a confirmed brief.** The shape questions and Step A questions cover different ground: shape pins purpose, content, scope; Step A pins palette, atmosphere, and named visual references for the comps you're about to generate. The only time you can skip Step A is when the user has already answered these exact palette/atmosphere/reference questions in the same session.
Ask **2-3 targeted questions** about visual lane, color strategy, atmosphere, and named anchor references. Don't enumerate generic menus; tie each question to the shape brief's answers. Example shape-grounded questions:
- "Brief says 'specimen-page restraint.' Are we closer to a quiet typographic page or a wider editorial spread with hero imagery?"
- "Palette strategy from shape was 'Committed.' Which one color carries the surface (a brand-driven pick rather than a default warm-or-cool framing)? (And no, the answer isn't a cream/sand body bg; that's the saturated AI default.)"
**STOP and wait for answers.** These pin the palette before any pixel gets generated. Do not proceed to Step B until the user has responded.
## Step B: Generate the Brand Palette First
Generate **one** palette artifact before any mocks. This is a small, focused image: typography pairing on the chosen background, primary + accent color swatches, one signature ornament or motif. Single image, single pass.
Why palette first: mocks generated against a vague color sense produce noise that drowns out the structural decisions. A confirmed palette is the first concrete contract for everything downstream.
Show the palette to the user. Ask one question: "This is the palette I'm locking in for the mocks. Confirm, or call out what to shift?"
**STOP and wait for confirmation.** Do not generate mocks against an unconfirmed palette. "Probably good enough" is the wrong call here; the palette is the contract for everything downstream.
## Step C: Generate 1-3 Visual Mocks Against the Palette
Once the palette is confirmed, generate **1 to 3** high-fidelity north-star comps. Each mock must use the confirmed palette and typography. Mocks differ in *structural* direction (hierarchy, topology, density, composition), not in color or motif.
- Brand work: push visual identity, composition, mood, and signature motifs.
- Product work: push hierarchy, topology, density, tone, grounded in realistic product structure.
- Landing pages and long-form brand surfaces: show enough of the second fold to establish the system beyond the hero.
Use the `image_gen` tool directly (or via the imagegen skill when available). Don't ask the user to install anything.
## Step D: Approval Loop
Show the comps. Ask what carries forward. Iterate until **one direction is approved** or the user explicitly delegates.
**STOP and wait for the approval or the delegation.** Do not begin Step E or return to craft.md Step 4 until a single direction is named. If the user delegates, pick the strongest direction and explain it from the brief, not personal taste.
Before moving to assets, summarize what to carry into code and what *not* to literalize from the mock. This is the handoff between visual exploration and semantic implementation.
## Step E: Mock Fidelity Inventory
Inventory the approved mock's major visible ingredients. For each, decide implementation: semantic HTML/CSS/SVG, generated raster, sourced raster, icon library, canvas/WebGL, or accepted omission.
Common ingredients to inventory:
- Hero silhouette and dominant composition
- Signature motifs (planets, devices, portraits, charts, route lines, insets, badges, etc.)
- Nav and primary CTA treatment
- Section sequence, especially the second fold
- Image-native content the concept depends on
- Typography, density, color/material treatment, motion cues
Treat the mock as a north star, not a screenshot to trace. Don't rasterize core UI text. But if the live result lacks the mock's major ingredients, the implementation is wrong.
If a photographic, architectural, product, or place-led mock becomes generic CSS scenery, decorative diagrams, bullets, or copy, stop and fix it. That's a broken implementation, not a harmless interpretation.
Don't substitute a different hero composition or visual driver post-approval without user sign-off.
## Step F: Asset Slicing via the Asset Producer
Raster ingredients identified in Step E need clean production assets. Use the bundled `impeccable_asset_producer` subagent rather than producing inline.
Spawn it as a scoped subagent. If you do not have explicit permission to use agents, stop and ask:
```text
Asset production will work better as a scoped subagent job. Should I spawn the Impeccable asset producer subagent for this step?
```
Pass to the agent:
- Approved mock path or screenshot reference
- Crop paths or a contact sheet with crop ids
- Output directory
- Required dimensions, format, transparency needs
- Avoid list
- Notes on what should remain semantic HTML/CSS/SVG instead of raster
Attach image generation capability to the spawned agent when the harness supports it. Do **not** load image-generation reference material into the parent thread.
Inline asset production is allowed only if the user declines subagents, the harness cannot spawn the authorized agent, or the user explicitly asks for single-thread mode.
Prefer HTML/CSS/SVG/canvas when they can credibly reproduce an ingredient; reach for real, generated, or stock imagery when the mock or subject matter calls for actual visual content.
## After This File
Once Steps A through F are complete, return to `craft.md` Step 5 (Build to Production Quality). The implementation builds against the confirmed palette, approved mock, and the assets the producer wrote.
@@ -1,106 +0,0 @@
# Cognitive Load Assessment
Cognitive load is the total mental effort required to use an interface. Overloaded users make mistakes, get frustrated, and leave. This reference helps identify and fix cognitive overload.
---
## Three Types of Cognitive Load
### Intrinsic Load — The Task Itself
Complexity inherent to what the user is trying to do. You can't eliminate this, but you can structure it.
**Manage it by**:
- Breaking complex tasks into discrete steps
- Providing scaffolding (templates, defaults, examples)
- Progressive disclosure — show what's needed now, hide the rest
- Grouping related decisions together
### Extraneous Load — Bad Design
Mental effort caused by poor design choices. **Eliminate this ruthlessly** — it's pure waste.
**Common sources**:
- Confusing navigation that requires mental mapping
- Unclear labels that force users to guess meaning
- Visual clutter competing for attention
- Inconsistent patterns that prevent learning
- Unnecessary steps between user intent and result
### Germane Load — Learning Effort
Mental effort spent building understanding. This is *good* cognitive load — it leads to mastery.
**Support it by**:
- Progressive disclosure that reveals complexity gradually
- Consistent patterns that reward learning
- Feedback that confirms correct understanding
- Onboarding that teaches through action, not walls of text
---
## Cognitive Load Checklist
Evaluate the interface against these 8 items:
- [ ] **Single focus**: Can the user complete their primary task without distraction from competing elements?
- [ ] **Chunking**: Is information presented in digestible groups (≤4 items per group)?
- [ ] **Grouping**: Are related items visually grouped together (proximity, borders, shared background)?
- [ ] **Visual hierarchy**: Is it immediately clear what's most important on the screen?
- [ ] **One thing at a time**: Can the user focus on a single decision before moving to the next?
- [ ] **Minimal choices**: Are decisions simplified (≤4 visible options at any decision point)?
- [ ] **Working memory**: Does the user need to remember information from a previous screen to act on the current one?
- [ ] **Progressive disclosure**: Is complexity revealed only when the user needs it?
**Scoring**: Count the failed items. 01 failures = low cognitive load (good). 23 = moderate (address soon). 4+ = high cognitive load (critical fix needed).
---
## The Working Memory Rule
**Humans can hold ≤4 items in working memory at once** (Miller's Law revised by Cowan, 2001).
At any decision point, count the number of distinct options, actions, or pieces of information a user must simultaneously consider:
- **≤4 items**: Within working memory limits — manageable
- **57 items**: Pushing the boundary — consider grouping or progressive disclosure
- **8+ items**: Overloaded — users will skip, misclick, or abandon
**Practical applications**:
- Navigation menus: ≤5 top-level items (group the rest under clear categories)
- Form sections: ≤4 fields visible per group before a visual break
- Action buttons: 1 primary, 12 secondary, group the rest in a menu
- Dashboard widgets: ≤4 key metrics visible without scrolling
- Pricing tiers: ≤3 options (more causes analysis paralysis)
---
## Common Cognitive Load Violations
### 1. The Wall of Options
**Problem**: Presenting 10+ choices at once with no hierarchy.
**Fix**: Group into categories, highlight recommended, use progressive disclosure.
### 2. The Memory Bridge
**Problem**: User must remember info from step 1 to complete step 3.
**Fix**: Keep relevant context visible, or repeat it where it's needed.
### 3. The Hidden Navigation
**Problem**: User must build a mental map of where things are.
**Fix**: Always show current location (breadcrumbs, active states, progress indicators).
### 4. The Jargon Barrier
**Problem**: Technical or domain language forces translation effort.
**Fix**: Use plain language. If domain terms are unavoidable, define them inline.
### 5. The Visual Noise Floor
**Problem**: Every element has the same visual weight — nothing stands out.
**Fix**: Establish clear hierarchy: one primary element, 23 secondary, everything else muted.
### 6. The Inconsistent Pattern
**Problem**: Similar actions work differently in different places.
**Fix**: Standardize interaction patterns. Same type of action = same type of UI.
### 7. The Multi-Task Demand
**Problem**: Interface requires processing multiple simultaneous inputs (reading + deciding + navigating).
**Fix**: Sequence the steps. Let the user do one thing at a time.
### 8. The Context Switch
**Problem**: User must jump between screens/tabs/modals to gather info for a single decision.
**Fix**: Co-locate the information needed for each decision. Reduce back-and-forth.
@@ -1,105 +0,0 @@
# Color & Contrast
## Color Spaces: Use OKLCH
**Stop using HSL.** Use OKLCH (or LCH) instead. It's perceptually uniform, meaning equal steps in lightness *look* equal—unlike HSL where 50% lightness in yellow looks bright while 50% in blue looks dark.
The OKLCH function takes three components: `oklch(lightness chroma hue)` where lightness is 0-100%, chroma is roughly 0-0.4, and hue is 0-360. To build a primary color and its lighter / darker variants, hold the chroma+hue roughly constant and vary the lightness — but **reduce chroma as you approach white or black**, because high chroma at extreme lightness looks garish.
The hue you pick is a brand decision and should not come from a default. Do not reach for blue (hue 250) or warm orange (hue 60) by reflex — those are the dominant AI-design defaults, not the right answer for any specific brand.
## Building Functional Palettes
### Tinted Neutrals
**Pure gray is dead.** A neutral with zero chroma feels lifeless next to a colored brand. Add a tiny chroma value (0.005-0.015) to all your neutrals, hued toward whatever your brand color is. The chroma is small enough not to read as "tinted" consciously, but it creates subconscious cohesion between brand color and UI surfaces.
The hue you tint toward should come from THIS project's brand, not from a "warm = friendly, cool = tech" formula. If your brand color is teal, your neutrals lean toward teal. If your brand color is amber, they lean toward amber. The point is cohesion with the SPECIFIC brand, not a stock palette.
**Avoid** the trap of always tinting toward warm orange or always tinting toward cool blue. Those are the two laziest defaults and they create their own monoculture across projects.
### Palette Structure
A complete system needs:
| Role | Purpose | Example |
|------|---------|---------|
| **Primary** | Brand, CTAs, key actions | 1 color, 3-5 shades |
| **Neutral** | Text, backgrounds, borders | 9-11 shade scale |
| **Semantic** | Success, error, warning, info | 4 colors, 2-3 shades each |
| **Surface** | Cards, modals, overlays | 2-3 elevation levels |
**Skip secondary/tertiary unless you need them.** Most apps work fine with one accent color. Adding more creates decision fatigue and visual noise.
### The 60-30-10 Rule (Applied Correctly)
This rule is about **visual weight**, not pixel count:
- **60%**: Neutral backgrounds, white space, base surfaces
- **30%**: Secondary colors—text, borders, inactive states
- **10%**: Accent—CTAs, highlights, focus states
The common mistake: using the accent color everywhere because it's "the brand color." Accent colors work *because* they're rare. Overuse kills their power.
## Contrast & Accessibility
### WCAG Requirements
| Content Type | AA Minimum | AAA Target |
|--------------|------------|------------|
| Body text | 4.5:1 | 7:1 |
| Large text (18px+ or 14px bold) | 3:1 | 4.5:1 |
| UI components, icons | 3:1 | 4.5:1 |
| Non-essential decorations | None | None |
**The gotcha**: Placeholder text still needs 4.5:1. That light gray placeholder you see everywhere? Usually fails WCAG.
### Dangerous Color Combinations
These commonly fail contrast or cause readability issues:
- Light gray text on white (the #1 accessibility fail)
- **Gray text on any colored background**—gray looks washed out and dead on color. Use a darker shade of the background color, or transparency
- Red text on green background (or vice versa)—8% of men can't distinguish these
- Blue text on red background (vibrates visually)
- Yellow text on white (almost always fails)
- Thin light text on images (unpredictable contrast)
### Never Use Pure Gray or Pure Black
Pure gray (`oklch(50% 0 0)`) and pure black (`#000`) don't exist in nature—real shadows and surfaces always have a color cast. Even a chroma of 0.005-0.01 is enough to feel natural without being obviously tinted. (See tinted neutrals example above.)
### Testing
Don't trust your eyes. Use tools:
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
- Browser DevTools → Rendering → Emulate vision deficiencies
- [Polypane](https://polypane.app/) for real-time testing
## Theming: Light & Dark Mode
### Dark Mode Is Not Inverted Light Mode
You can't just swap colors. Dark mode requires different design decisions:
| Light Mode | Dark Mode |
|------------|-----------|
| Shadows for depth | Lighter surfaces for depth (no shadows) |
| Dark text on light | Light text on dark (reduce font weight) |
| Vibrant accents | Desaturate accents slightly |
| White backgrounds | Never pure black—use dark gray (oklch 12-18%) |
In dark mode, depth comes from surface lightness, not shadow. Build a 3-step surface scale where higher elevations are lighter (e.g. 15% / 20% / 25% lightness). Use the SAME hue and chroma as your brand color (whatever it is for THIS project — do not reach for blue) and only vary the lightness. Reduce body text weight slightly (e.g. 350 instead of 400) because light text on dark reads as heavier than dark text on light.
### Token Hierarchy
Use two layers: primitive tokens (`--blue-500`) and semantic tokens (`--color-primary: var(--blue-500)`). For dark mode, only redefine the semantic layer—primitives stay the same.
## Alpha Is A Design Smell
Heavy use of transparency (rgba, hsla) usually means an incomplete palette. Alpha creates unpredictable contrast, performance overhead, and inconsistency. Define explicit overlay colors for each context instead. Exception: focus rings and interactive states where see-through is needed.
---
**Avoid**: Relying on color alone to convey information. Creating palettes without clear roles for each color. Using pure black (#000) for large areas. Skipping color blindness testing (8% of men affected).
+115 -12
View File
@@ -1,14 +1,14 @@
> **Additional context needed**: existing brand colors.
Strategically introduce color to designs that are too monochromatic, gray, or lacking in visual warmth and personality.
Replace timid grayscale or single-accent designs with a strategic palette: pick a color strategy, choose a hue family that fits the brand, then apply color with intent. More color ≠ better. Strategic color beats rainbow vomit.
---
## Register
Brand: palette IS voice. Pick a color strategy first per SKILL.md (Restrained / Committed / Full palette / Drenched) and follow its dosage. Committed, Full palette, and Drenched deliberately exceed the ≤10% rule that rule is Restrained only. Unexpected combinations are allowed; a dominant color can own the page when the chosen strategy calls for it.
Brand: palette IS voice. Pick a color strategy first per SKILL.md (Restrained / Committed / Full palette / Drenched) and follow its dosage. Committed, Full palette, and Drenched deliberately exceed the ≤10% rule; that rule is Restrained only. Unexpected combinations are allowed; a dominant color can own the page when the chosen strategy calls for it.
Product: semantic-first and almost always Restrained. Accent color is reserved for primary action, current selection, and state indicators — not decoration. Every color has a consistent meaning across every screen.
Product: semantic-first and almost always Restrained. Accent color is reserved for primary action, current selection, and state indicators. Not decoration. Every color has a consistent meaning across every screen.
---
@@ -68,10 +68,10 @@ Add color systematically across these dimensions:
- **Hover states**: Introduce color on interaction
### Background & Surfaces
- **Tinted backgrounds**: Replace pure gray (`#f5f5f5`) with warm neutrals (`oklch(97% 0.01 60)`) or cool tints (`oklch(97% 0.01 250)`)
- **Tinted backgrounds**: If you replace pure gray, tint toward the brand hue, not toward a generic-warm-or-cool pair. The default-warm-tint (`oklch(97% 0.01 60)` and its neighbors) is now the AI cream/sand giveaway. Be specific to the brand or stay neutral.
- **Colored sections**: Use subtle background colors to separate areas
- **Gradient backgrounds**: Add depth with subtle, intentional gradients (not generic purple-blue)
- **Cards & surfaces**: Tint cards or surfaces slightly for warmth
- **Cards & surfaces**: Tint cards or surfaces toward the brand, not "for warmth" by reflex
**Use OKLCH for color**: It's perceptually uniform, meaning equal steps in lightness *look* equal. Great for generating harmonious scales.
@@ -81,13 +81,13 @@ Add color systematically across these dimensions:
- **Comparison**: Color coding for different datasets or timeframes
### Borders & Accents
- **Hairline borders**: 1px colored borders on full perimeter (not side-stripes see the absolute ban on `border-left/right > 1px`)
- **Hairline borders**: 1px colored borders on full perimeter (not side-stripes; see the absolute ban on `border-left/right > 1px`)
- **Underlines**: Color underlines for emphasis or active states
- **Dividers**: Subtle colored dividers instead of gray lines
- **Focus rings**: Colored focus indicators matching brand
- **Surface tints**: A 4-8% background wash of the accent color instead of a stripe
**NEVER**: `border-left` or `border-right` greater than 1px as a colored accent stripe. This is one of the three absolute bans in the parent skill. If you want to mark a card as "active" or "warning", use a full hairline border, a background tint, a leading glyph, or a numbered prefix — not a side stripe.
**NEVER**: `border-left` or `border-right` greater than 1px as a colored accent stripe. This is one of the three absolute bans in the parent skill. If you want to mark a card as "active" or "warning", use a full hairline border, a background tint, a leading glyph, or a numbered prefix. Not a side stripe.
### Typography Color
- **Colored headings**: Use brand colors for section headings (maintain contrast)
@@ -123,9 +123,7 @@ Ensure color addition improves rather than overwhelms:
**NEVER**:
- Use every color in the rainbow (choose 2-4 colors beyond neutrals)
- Apply color randomly without semantic meaning
- Put gray text on colored backgrounds—it looks washed out; use a darker shade of the background color or transparency instead
- Use pure gray for neutrals—add subtle color tint (warm or cool) for sophistication
- Use pure black (`#000`) or pure white (`#fff`) for large areas
- Put gray text on colored backgrounds. It looks washed out; use a darker shade of the background color or transparency instead
- Violate WCAG contrast requirements
- Use color as the only indicator (accessibility issue)
- Make everything colorful (defeats the purpose)
@@ -141,14 +139,119 @@ Test that colorization improves the experience:
- **Still accessible**: Do all color combinations meet WCAG standards?
- **Not overwhelming**: Is color balanced and purposeful?
Remember: Color is emotional and powerful. Use it to create warmth, guide attention, communicate meaning, and express personality. But restraint and strategy matter more than saturation and variety. Be colorful, but be intentional.
When the palette earns its place, hand off to `/impeccable polish` for the final pass.
## Live-mode signature params
When invoked from live mode, each variant MUST declare a `color-amount` param so the user can dial between a restrained accent and a drenched surface without regeneration. Author the variant's CSS against `var(--p-color-amount, 0.5)` typically as the alpha multiplier on backgrounds, or as a scaling factor on the chroma axis in an OKLCH expression. 0 = neutral/monochrome, 1 = full saturation / dominant coverage.
When invoked from live mode, each variant MUST declare a `color-amount` param so the user can dial between a restrained accent and a drenched surface without regeneration. Author the variant's CSS against `var(--p-color-amount, 0.5)`, typically as the alpha multiplier on backgrounds, or as a scaling factor on the chroma axis in an OKLCH expression. 0 = neutral/monochrome, 1 = full saturation / dominant coverage.
```json
{"id":"color-amount","kind":"range","min":0,"max":1,"step":0.05,"default":0.5,"label":"Color amount"}
```
Layer 1-2 variant-specific params on top: palette selection (`steps` with named options), temperature warmth, or tint vs. true color. See `reference/live.md` for the full params contract.
---
## Reference Material
The sections below were previously `color-and-contrast.md` and live inline now so the colorize flow has its deep color reference in one place.
### Color & Contrast
#### Color Spaces: Use OKLCH
**Stop using HSL.** Use OKLCH (or LCH) instead. It's perceptually uniform, meaning equal steps in lightness *look* equal, unlike HSL where 50% lightness in yellow looks bright while 50% in blue looks dark.
The OKLCH function takes three components: `oklch(lightness chroma hue)` where lightness is 0-100%, chroma is roughly 0-0.4, and hue is 0-360. To build a primary color and its lighter / darker variants, hold the chroma+hue roughly constant and vary the lightness, but **reduce chroma as you approach white or black**, because high chroma at extreme lightness looks garish.
The hue you pick is a brand decision and should not come from a default. Do not reach for blue (hue 250) or warm orange (hue 60) by reflex; those are the dominant AI-design defaults, not the right answer for any specific brand.
#### Building Functional Palettes
##### Tinted Neutrals
**Pure gray is dead.** A neutral with zero chroma feels lifeless next to a colored brand. Add a tiny chroma value (0.005-0.015) to all your neutrals, hued toward whatever your brand color is. The chroma is small enough not to read as "tinted" consciously, but it creates subconscious cohesion between brand color and UI surfaces.
The hue you tint toward should come from THIS project's brand, not from a "warm = friendly, cool = tech" formula. If your brand color is teal, your neutrals lean toward teal. If your brand color is amber, they lean toward amber. The point is cohesion with the SPECIFIC brand, not a stock palette.
**Avoid** the trap of always tinting toward warm orange or always tinting toward cool blue. Those are the two laziest defaults and they create their own monoculture across projects.
##### Palette Structure
A complete system needs:
| Role | Purpose | Example |
|------|---------|---------|
| **Primary** | Brand, CTAs, key actions | 1 color, 3-5 shades |
| **Neutral** | Text, backgrounds, borders | 9-11 shade scale |
| **Semantic** | Success, error, warning, info | 4 colors, 2-3 shades each |
| **Surface** | Cards, modals, overlays | 2-3 elevation levels |
**Skip secondary/tertiary unless you need them.** Most apps work fine with one accent color. Adding more creates decision fatigue and visual noise.
##### The 60-30-10 Rule (Applied Correctly)
This rule is about **visual weight**, not pixel count:
- **60%**: Neutral backgrounds, white space, base surfaces
- **30%**: Secondary colors: text, borders, inactive states
- **10%**: Accent: CTAs, highlights, focus states
The common mistake: using the accent color everywhere because it's "the brand color." Accent colors work *because* they're rare. Overuse kills their power.
#### Contrast & Accessibility
##### WCAG Requirements
| Content Type | AA Minimum | AAA Target |
|--------------|------------|------------|
| Body text | 4.5:1 | 7:1 |
| Large text (18px+ or 14px bold) | 3:1 | 4.5:1 |
| UI components, icons | 3:1 | 4.5:1 |
| Non-essential decorations | None | None |
##### Dangerous Color Combinations
These commonly fail contrast or cause readability issues:
- Light gray text on white (the #1 accessibility fail)
- Red text on green background (or vice versa): 8% of men can't distinguish these
- Blue text on red background (vibrates visually)
- Yellow text on white (almost always fails)
- Thin light text on images (unpredictable contrast)
##### Testing
Don't trust your eyes. Use tools:
- [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/)
- Browser DevTools → Rendering → Emulate vision deficiencies
- [Polypane](https://polypane.app/) for real-time testing
#### Theming: Light & Dark Mode
##### Dark Mode Is Not Inverted Light Mode
You can't just swap colors. Dark mode requires different design decisions:
| Light Mode | Dark Mode |
|------------|-----------|
| Shadows for depth | Lighter surfaces for depth (no shadows) |
| Dark text on light | Light text on dark (reduce font weight) |
| Vibrant accents | Desaturate accents slightly |
| White backgrounds | Either pure black or a deep surface that fits the brand (a brand-tinted near-black at oklch 12-18% works too) |
In dark mode, depth comes from surface lightness, not shadow. Build a 3-step surface scale where higher elevations are lighter (e.g. 15% / 20% / 25% lightness). Use the SAME hue and chroma as your brand color (whatever it is for THIS project; do not reach for blue) and only vary the lightness. Reduce body text weight slightly (e.g. 350 instead of 400) because light text on dark reads as heavier than dark text on light.
##### Token Hierarchy
Use two layers: primitive tokens (`--blue-500`) and semantic tokens (`--color-primary: var(--blue-500)`). For dark mode, only redefine the semantic layer; primitives stay the same.
#### Alpha Is A Design Smell
Heavy use of transparency (rgba, hsla) usually means an incomplete palette. Alpha creates unpredictable contrast, performance overhead, and inconsistency. Define explicit overlay colors for each context instead. Exception: focus rings and interactive states where see-through is needed.
---
**Avoid**: Relying on color alone to convey information. Creating palettes without clear roles for each color. Skipping color blindness testing (8% of men affected).
+86 -89
View File
@@ -1,126 +1,123 @@
# Craft Flow
Build a feature with impeccable UX and UI quality through a structured process: shape the design, load the right references, then build and iterate visually until the result is delightful.
Build a feature with impeccable UX and UI quality: shape the design, land the visual direction, build real production code, inspect and improve in-browser until it meets a high-end studio bar.
Before writing code, you need: PRODUCT.md loaded, register identified and the matching reference loaded, and a confirmed design direction for this task (either from `shape` or supplied by the user). PRODUCT.md is project context, not a task-specific brief.
Treat any approved visual direction (generated mock or stated reference) as a concrete contract for composition, hierarchy, density, atmosphere, signature motifs, and distinctive visual moves. Don't let mocks replace structure, copy, accessibility, or state design. But if the live result lacks the approved direction's major ingredients, the implementation is wrong.
### Gates: do not compress
Craft has **multiple user gates**, not one. When the harness has native image generation (Codex via `image_gen`), the gate sequence before code is:
1. **Shape brief confirmed** (Step 1)
2. **Direction questions answered** (codex.md Step A)
3. **Palette confirmed** (codex.md Step B)
4. **One mock direction approved or delegated** (codex.md Step D)
You must stop at every gate. **Shape confirmation alone is NOT a green light to start coding.** It is the green light to begin codex.md Step A. Compressing gates 2 through 4 because the shape brief felt complete is the dominant failure mode of this flow.
When the harness lacks native image generation, gates 2-4 collapse into the brief itself, and shape confirmation does advance straight to code.
## Step 0: Project Foundation
Before shape, before code: figure out what kind of project you're working in.
Look at the working directory. Run `ls`. Check for:
- An existing framework: `astro.config.mjs/ts`, `next.config.js/ts`, `nuxt.config.ts`, `svelte.config.js`, `vite.config.js/ts`, `package.json` with framework deps, `Cargo.toml` + Leptos/Yew, `Gemfile` + Rails. **If found, use it.** Do not start a parallel build, do not introduce a second framework, do not write to `dist/` or `build/` directly. Whatever pipeline the project has, respect it.
- An existing component library or design system: `src/components/`, `app/components/`, a `tokens.css` / `theme.ts`, an `astro.config` `integrations`. Read what's there before adding to it.
- An existing icon set: `lucide-react`, `@phosphor-icons/react`, `@iconify/*`, hand-rolled SVG sprites in `assets/icons/`. **Use what's already in the project**; don't introduce a second set.
If the directory is empty (greenfield), don't pick a framework silently. Ask the user via the AskUserQuestion tool, with sensible defaults framed by the brief:
```text
What should this be built on?
- Astro (default for content-led brand sites, landing pages, marketing surfaces)
- SvelteKit / Next.js / Nuxt (when the brief implies an app surface or significant interactivity)
- Single index.html (one-shot demo, prototype, or a deliberately framework-free experiment)
```
Default: Astro for brand briefs, the project's existing framework for product briefs. Ask once; don't re-ask mid-task.
## Step 1: Shape the Design
Run /impeccable shape, passing along whatever feature description the user provided.
Run /impeccable shape, passing along whatever feature description the user provided. Shape is **required** for craft; it is what produces a confirmed direction.
Wait for the design brief to be fully confirmed before proceeding. The brief is your blueprint, and every implementation decision should trace back to it.
Present the shape output and stop. Wait for the user to confirm, override, or course-correct before writing code.
If the user has already run /impeccable shape and has a confirmed design brief, skip this step and use the existing brief.
If the user already supplied a confirmed brief or ran shape separately, use it and skip this step.
When the original prompt + PRODUCT.md already answer scope, content, and visual direction with no real ambiguity, the shape output can be **compact** (3-5 bullets stating what you're building and the visual lane, ending with one or two specific questions or "confirm or override"). The full 10-section structured brief is reserved for genuinely ambiguous, multi-screen, or stakeholder-heavy tasks. Don't pad a clear brief into a long one to look thorough; equally, don't skip the pause to look efficient.
If the harness has native image generation (Codex), a compact shape's "confirm or override" advances to **Step 3 and the codex.md flow**, not to Step 4. Phrase the closing line accordingly: "Confirm or override; once we lock direction, I'll run a couple of palette and reference questions before generating any mocks." This stops the model from reading shape confirmation as code-green.
## Step 2: Load References
Based on the design brief's "Recommended References" section, consult the relevant impeccable reference files. At minimum, always consult:
- [spatial-design.md](spatial-design.md) for layout and spacing
- [typography.md](typography.md) for type hierarchy
- [layout.md](layout.md) for layout, spacing, grid, container queries, optical adjustments
- [typeset.md](typeset.md) for type hierarchy, font selection, web font loading, OpenType features (Reference Material section)
Then add references based on the brief's needs:
- Complex interactions or forms? Consult [interaction-design.md](interaction-design.md)
- Animation or transitions? Consult [motion-design.md](motion-design.md)
- Color-heavy or themed? Consult [color-and-contrast.md](color-and-contrast.md)
- Responsive requirements? Consult [responsive-design.md](responsive-design.md)
- Heavy on copy, labels, or errors? Consult [ux-writing.md](ux-writing.md)
- Animation or transitions? Consult [animate.md](animate.md) (Reference Material covers motion materials, durations, easing, perceived performance)
- Color-heavy or themed? Consult [colorize.md](colorize.md) (Reference Material covers OKLCH, palette structure, dark mode, contrast)
- Responsive requirements? Consult [adapt.md](adapt.md) (Reference Material covers breakpoints, input methods, safe areas, responsive images)
- Heavy on copy, labels, or errors? Consult [clarify.md](clarify.md) (Reference Material covers button labels, error formula, voice/tone, translation)
## Step 3: North Star Mock (Capability-Gated)
## Step 3: Visual Direction & Assets (Harness-Gated)
Before implementation, generate a small set of high-fidelity visual comps when all of these are true:
If the harness has **native image generation** (currently Codex via `image_gen`), this step is mandatory. **Stop and load [codex.md](codex.md)**. It covers palette generation, mock exploration, the approval loop, mock-fidelity inventory, and asset slicing via the `impeccable_asset_producer` subagent. Follow Steps A-F in that file, then return here for Step 4.
- The work is **net-new** or visually open-ended enough that composition exploration will improve the build.
- The brief's scope is **mid-fi, high-fi, or production-ready**.
- The current harness has **built-in image generation capability** (for example, Codex with a native image tool). Do **not** ask the user to set up external APIs, shell scripts, or one-off tooling just to do this.
If the harness lacks native image generation, **state in one line that the visual-direction-by-generation step is being skipped because the harness lacks native image generation, then proceed**. The one-line announcement is required; it forces a conscious decision instead of letting the step quietly evaporate. The brief is your only visual reference. Implement directly from it, treating any named anchor references and the brief's "Design Direction" as the contract.
When those conditions are met, this step is the default for **both brand and product work**.
Whether you generated mocks or not: don't replace required imagery with generic cards, bullets, emoji, fake metrics, decorative CSS panels, or filler copy. Image-led briefs (restaurants, hotels, magazines, photography, hobbyist communities, food, travel, fashion, product) need real or sourced imagery in the build, not CSS scenery.
### Purpose
## Step 4: Build to Production Quality
Use the mock step to find a stronger visual lane than code-first generation would reliably discover on its own. The brief remains authoritative on user, purpose, content, constraints, states, and anti-goals. The mock clarifies composition, hierarchy, density, typography, and visual tone.
**Precondition.** If Step 3 routed you to codex.md (native image generation available), Steps A through D in that file must be complete before any code: questions answered, palette confirmed, mocks generated, one direction approved or delegated. **Do not mention implementation, file paths, or patch plans until that's done.** A confirmed shape brief is not enough; the model that compressed those gates is the model that already failed this flow.
### What to generate
Implement the feature following the design brief. Build in passes so structure, visual system, states, motion/media, and responsive behavior each get deliberate attention. The list below is the definition of done, not inspiration.
Generate **1 to 3** high-fidelity north-star comps based on the confirmed brief.
### Production bar
- For brand work, push visual identity, composition, and mood aggressively.
- For product work, still push hierarchy, topology, density, and tone, but keep the comps grounded in realistic product structure and states.
- **Real content.** No placeholder copy, placeholder images, dead links, fake controls, or unused scaffold at presentation time.
- **Preserve the approved mock's major ingredients.** Missing hero objects, world/product imagery, section structure, CTA/nav treatment, or distinctive motifs are blocking defects unless the user accepted the change.
- **Semantic first.** Real headings, landmarks, labels, form associations, button/link semantics, accessible names, state announcements where needed.
- **Deliberate spacing and alignment.** No default gaps, arbitrary margins, unbalanced whitespace, or accidental optical misalignment.
- **Intentional typography.** Chosen loading strategy, clear hierarchy, readable measure, stable line breaks, no overflow at any width.
- **Realistic state coverage.** Default, hover, focus-visible, active, disabled, loading, error, success, empty, overflow, long/short text, first-run.
- **Finished interaction quality.** Keyboard paths, touch targets, feedback timing, scroll behavior, state transitions, no hover-only functionality.
- **Coherent icon set.** Use the project's established set; otherwise pick one library or use accessible text. Don't mix.
- **Respect the build pipeline.** Edit source files and run the project's build (`npm run build` or equivalent). Don't write to `build/` / `dist/` / `.next/` with `cat`, heredoc, or Bash redirects; that skips asset hashing, image optimization, code splitting, and CSS extraction, and produces output the dev server won't serve.
- **Verify image URLs before referencing them.** Use image-search MCP or web-fetch when available; guessed photo IDs ship as broken-image placeholders. Without verification, prefer fewer images you're confident about.
- **Optimized imagery and media.** Correct dimensions, useful alt text, lazy loading below the fold, modern formats when practical, responsive `srcset`/`picture` for raster, no project-referenced asset left outside the workspace.
- **Premium motion.** Use atmospheric blur, filter, mask, shadow, reveal when they improve the experience. Avoid casual layout-property animation, bound expensive effects, verify smoothness in-browser, respect reduced motion, and avoid choreography that blocks task completion.
- **Maintainable.** Reusable local patterns, clear component boundaries, project conventions. No rasterized UI text or one-off hacks when a local pattern exists.
- **Technically clean.** Production build passes, no console errors, no avoidable layout shift, no needless dependencies, no broken asset paths.
- **Ask when uncertain.** If a discovery materially changes the brief or approved direction, stop and ask. Don't guess.
The comps must be genuinely different in primary visual direction, not just color variants.
## Step 5: Iterate Visually
### After generation
Look at what you built like a designer would. Your eyes are whatever the harness gives you: a connected browser, a screenshotting tool, Playwright, or asking the user. Use them for responsive testing (mobile, tablet, desktop minimum) and general visual validation.
Choose a direction with the user, or if the user delegated the decision, pick the strongest one and explain why.
If your tool returns a file path, read the PNG back into the conversation. A screenshot you didn't read doesn't count.
Before moving to implementation, summarize:
For long-form brand surfaces, inspect major sections individually. Thumbnails hide spacing, clipping, and cascade defects.
- What to carry into code
- What **not** to literalize from the mock
After the first pass, write an honest critique against the brief, the approved mock's major ingredients (hero silhouette, motifs, imagery, nav/CTA, density), and impeccable's DON'Ts. Patch material defects and re-inspect. **Don't invent defects to demonstrate iteration.** A confident "first pass clean, shipping" beats a fake fix.
Treat the mock as a **north star**, not a screenshot to trace. Do **not** let it override the confirmed brief.
Actively check: responsive behavior (composes, not shrinks), every state (empty / error / loading / edge), craft details (spacing, alignment, hierarchy, contrast, motion timing, focus), performance basics. The exit bar: defensible in a high-end studio review.
## Step 4: Asset Extraction (Optional)
Detector or QA output is defect evidence only; never proof the work is finished.
If the chosen direction includes image-native visual ingredients that would materially improve the implementation, generate them as separate assets before building.
Good candidates:
- stickers
- badges
- seals
- tickets
- graphic labels
- textures
- abstract objects
- decorative marks
- non-semantic scene elements
Do **not** export assets for core UI text, navigation, body copy, or any structure that should stay semantic and editable in code.
Usually **1 to 5** extracted assets is enough. If the design can be built cleanly in HTML/CSS/SVG, prefer that over raster assets.
## Step 5: Build
Implement the feature following the design brief. Work in this order:
1. **Structure first**: HTML/semantic structure for the primary state. No styling yet.
2. **Layout and spacing**: Establish the spatial rhythm and visual hierarchy.
3. **Typography and color**: Apply the type scale and color system.
4. **Interactive states**: Hover, focus, active, disabled.
5. **Edge case states**: Empty, loading, error, overflow, first-run.
6. **Motion**: Purposeful transitions and animations (if appropriate).
7. **Responsive**: Adapt for different viewports. Don't just shrink; redesign for the context.
### During Build
- Test with real (or realistic) data at every step, not placeholder text
- Check each state as you build it, not all at the end
- If you discover a design question, stop and ask rather than guessing
- Every visual choice should trace back to something in the design brief or the chosen north-star direction
- Keep text semantic, layout real, and interactions accessible. Do not turn the mock into a pile of rasterized UI
- If assets were extracted, use them intentionally. They support the build; they do not replace interface structure
## Step 6: Visual Iteration
**This step is critical.** Do not stop after the first implementation pass.
Open the result in a browser window. If browser automation tools are available, use them to navigate to the page and visually inspect the result. If not, ask the user to open it and provide feedback.
Iterate through these checks visually:
1. **Does it match the brief?** Compare the live result against every section of the design brief. Fix discrepancies.
2. **Does it pass the AI slop test?** If someone saw this and said "AI made this," would they believe it immediately? If yes, it needs more design intention.
3. **Check against impeccable's DON'T guidelines.** Fix any anti-pattern violations.
4. **Check every state.** Navigate through empty, error, loading, and edge case states. Each one should feel intentional, not like an afterthought.
5. **Check responsive.** Resize the viewport. Does it adapt well or just shrink?
6. **Check the details.** Spacing consistency, type hierarchy clarity, color contrast, interactive feedback, motion timing.
After each round of fixes, visually verify again. **Repeat until you would be proud to show this to the user.** The bar is not "it works"; the bar is "this delights."
## Step 7: Present
## Step 6: Present
Present the result to the user:
- Show the feature in its primary state
- Summarize the browser/viewports checked and the most important fixes made after inspection
- Walk through the key states (empty, error, responsive)
- Explain design decisions that connect back to the design brief and, when used, the chosen north-star mock
- Explain design decisions that connect back to the design brief and, when used, the chosen north-star mock. Include any accepted deviations from the mock; do not hide unimplemented mock ingredients.
- Note any remaining limitations or follow-up risks honestly
- Ask: "What's working? What isn't?"
Iterate based on feedback. Good design is rarely right on the first pass.
+621 -67
View File
@@ -1,95 +1,86 @@
> **Additional context needed**: what the interface is trying to accomplish.
### Purpose
### Gather Assessments
Resolve one stable target, run two independent assessments, synthesize a design critique, persist a snapshot, and ask the user what to improve next. The chat response is the primary deliverable; the snapshot is an archive/backlog for future commands.
Launch two independent assessments. **Neither may see the other's output** — this isolation is what makes the combined score honest. Running both in one head silently anchors them to each other; do not shortcut it for cost, speed, or context-size reasons.
### Hard Invariants
Delegate each assessment to a separate sub-agent (Claude Code's `Agent` tool, Codex's subagent spawning, etc.). Each returns structured findings as text. Do NOT output findings to the user yet.
- Assessment A (design review) and Assessment B (detector/browser evidence) are both required.
- Assessment A must finish before detector findings enter the parent synthesis context. Detector output is deterministic, but it still anchors judgment.
- If sub-agents are unavailable, fall back sequentially: finish and record Assessment A first, then run Assessment B, then synthesize.
- A skipped detector is a failed critique run unless `detect.mjs` is missing or crashes after a real attempt.
- Viewable targets require browser inspection when available.
- Any local server started only for critique visualization must run in the background, have a recorded stop method, and be stopped before final reporting unless the user asks to keep it.
- Do not claim a user-visible overlay exists unless script injection succeeded and the detector ran in the page.
Fall back to sequential in-head work only if the environment genuinely cannot spawn sub-agents.
### Setup
**Tab isolation**: When browser automation is available, each assessment MUST create its own new tab. Never reuse an existing tab, even if one is already open at the correct URL. This prevents the two assessments from interfering with each other's page state.
1. **Resolve the target** to a concrete file path or URL. Prefer a source path over a dev-server URL when both identify the same surface; ports drift, paths do not.
- "the homepage" -> `site/pages/index.astro` or `index.html`
- "the settings modal" -> the primary component file
- "this page" -> the current URL or source file
2. **Compute the slug**:
```bash
node .claude/skills/impeccable/scripts/critique-storage.mjs slug "<resolved-path-or-url>"
```
Keep it. If the command exits non-zero, skip persistence and trend for this run, but continue the critique.
3. **Read `.impeccable/critique/ignore.md`** if it exists. Drop matching findings silently; it is the only prior-run input critique consumes.
#### Assessment A: LLM Design Review
### Assessment Orchestration
Read the relevant source files (HTML, CSS, JS/TS) and, if browser automation is available, visually inspect the live page. **Create a new tab** for this; do not reuse existing tabs. After navigation, label the tab by setting the document title:
```javascript
document.title = '[LLM] ' + document.title;
```
Think like a design director. Evaluate:
Delegate Assessment A and Assessment B to separate sub-agents when possible. They must not see each other's output. Do not show findings to the user until synthesis.
**AI Slop Detection (CRITICAL)**: Does this look like every other AI-generated interface? Review against ALL **DON'T** guidelines from the parent impeccable skill (already loaded in this context). Check for AI color palette, gradient text, dark glows, glassmorphism, hero metric layouts, identical card grids, generic fonts, and all other tells. **The test**: If someone said "AI made this," would you believe them immediately?
If browser automation is available, each assessment creates its own new tab. Never reuse an existing tab, even if it is already at the right URL.
**Holistic Design Review**: visual hierarchy (eye flow, primary action clarity), information architecture (structure, grouping, cognitive load), emotional resonance (does it match brand and audience?), discoverability (are interactive elements obvious?), composition (balance, whitespace, rhythm), typography (hierarchy, readability, font choices), color (purposeful use, cohesion, accessibility), states & edge cases (empty, loading, error, success), microcopy (clarity, tone, helpfulness).
### Assessment A: Design Review
**Cognitive Load** (consult [cognitive-load](cognitive-load.md)):
- Run the 8-item cognitive load checklist. Report failure count: 0-1 = low (good), 2-3 = moderate, 4+ = critical.
- Count visible options at each decision point. If >4, flag it.
- Check for progressive disclosure: is complexity revealed only when needed?
Read relevant source files and visually inspect the live page when browser automation is available. Think like a design director.
**Emotional Journey**:
- What emotion does this interface evoke? Is that intentional?
- **Peak-end rule**: Is the most intense moment positive? Does the experience end well?
- **Emotional valleys**: Check for anxiety spikes at high-stakes moments (payment, delete, commit). Are there design interventions (progress indicators, reassurance copy, undo options)?
Evaluate:
- **AI slop**: Would someone believe "AI made this" immediately? Check all DON'T guidance from the parent Impeccable skill.
- **Holistic design**: hierarchy, IA, emotional fit, discoverability, composition, typography, color, accessibility, states, copy, and edge cases.
- **Cognitive load**: consult the [Cognitive Load Assessment](#cognitive-load-assessment) section below; report checklist failures and decision points with >4 visible options.
- **Emotional journey**: peak-end rule, emotional valleys, reassurance at high-stakes moments.
- **Nielsen heuristics**: consult the [Heuristics Scoring Guide](#heuristics-scoring-guide) section below; score all 10 heuristics 0-4.
**Nielsen's Heuristics** (consult [heuristics-scoring](heuristics-scoring.md)):
Score each of the 10 heuristics 0-4. This scoring will be presented in the report.
Return: AI slop verdict, heuristic scores, cognitive load, emotional journey, 2-3 strengths, 3-5 priority issues, persona red flags, minor observations, and provocative questions.
Return structured findings covering: AI slop verdict, heuristic scores, cognitive load assessment, what's working (2-3 items), priority issues (3-5 with what/why/fix), minor observations, and provocative questions.
### Assessment B: Detector + Browser Evidence
#### Assessment B: Automated Detection
Run the bundled detector and browser visualization evidence. Assessment B is mandatory and must remain isolated from Assessment A until both are complete.
Run the bundled deterministic detector, which flags 25 specific patterns (AI slop tells + general design quality).
**CLI scan**:
CLI scan:
```bash
npx impeccable --json [--fast] [target]
node .claude/skills/impeccable/scripts/detect.mjs --json [--fast] [target]
```
- Pass HTML/JSX/TSX/Vue/Svelte files or directories as `[target]` (anything with markup). Do not pass CSS-only files.
- For URLs, skip the CLI scan (it requires Puppeteer). Use browser visualization instead.
- For large directories (200+ scannable files), use `--fast` (regex-only, skips jsdom)
- For 500+ files, narrow scope or ask the user
- Exit code 0 = clean, 2 = findings
- Pass markup files/directories as `[target]`; do not pass CSS-only files.
- For URLs, skip CLI scan and use browser visualization.
- For 200+ scannable files, use `--fast`; for 500+, narrow scope or ask.
- Exit code 0 = clean; 2 = findings.
- If the detector entrypoint is missing or fails to load, report deterministic scan unavailable and continue with browser/manual review.
**Browser visualization** — **required** when browser automation tools are available AND the target is a viewable page. The `[Human]` overlay tab is the user-facing deliverable; the critique is incomplete without it. Skip only if the target is not a viewable page (CSS-only file, non-browser target).
Browser visualization is required for a viewable target when browser automation is available. Use a localhost dev/static URL for local files; avoid `file://` unless the available browser explicitly supports this workflow. Overlay flow:
The overlay is a **visual aid for the user**. It highlights issues directly in their browser. Do NOT scroll through the page to screenshot overlays. Instead, read the console output to get the results programmatically.
1. Create a fresh tab and navigate.
2. Preflight mutable injection by setting `document.title` and appending a `<script>` tag. Read-only evaluate APIs do not count.
3. If mutation is unavailable, skip live server, browser presentation, and injection; report fallback signal.
4. If mutation is available, start `node .claude/skills/impeccable/scripts/live-server.mjs --background`, present the browser if supported, label `[Human]`, scroll top, inject `http://localhost:PORT/detect.js`, wait 2-3 seconds, read `impeccable` console messages, then stop the live server.
5. For multi-view targets, inject on 3-5 representative pages.
1. **Start the live detection server**:
```bash
npx impeccable live &
```
Note the port printed to stdout (auto-assigned). Use `--port=PORT` to fix it.
2. **Create a new tab** and navigate to the page (use dev server URL for local files, or direct URL). Do not reuse existing tabs.
3. **Label the tab** via `javascript_tool` so the user can distinguish it:
```javascript
document.title = '[Human] ' + document.title;
```
4. **Scroll to top** to ensure the page is scrolled to the very top before injection
5. **Inject** via `javascript_tool` (replace PORT with the port from step 1):
```javascript
const s = document.createElement('script'); s.src = 'http://localhost:PORT/detect.js'; document.head.appendChild(s);
```
6. Wait 2-3 seconds for the detector to render overlays
7. **Read results from console** using `read_console_messages` with pattern `impeccable`. The detector logs all findings with the `[impeccable]` prefix. Do NOT scroll through the page to take screenshots of the overlays.
8. **Cleanup**: Stop the live server when done:
```bash
npx impeccable live stop
```
Return: CLI findings JSON/counts, browser console findings if applicable, false positives, and skipped/failed browser steps with concrete reasons.
For multi-view targets, inject on 3-5 representative pages. If injection fails, continue with CLI results only.
Return: CLI findings (JSON), browser console findings (if applicable), and any false positives noted.
After Assessment B returns usable CLI findings, reuse them. Do not rerun `detect.mjs` in the parent unless Assessment B failed, was truncated, or omitted count, rule names, or file locations.
### Generate Combined Critique Report
Synthesize both assessments into a single report. Do NOT simply concatenate. Weave the findings together, noting where the LLM review and detector agree, where the detector caught issues the LLM missed, and where detector findings are false positives.
The chat response is the primary user-facing deliverable. Present the full structured critique below in chat; do not replace it with a summary and a link. The persisted snapshot is only an archive/backlog for later commands.
Structure your feedback as a design director would:
#### Design Health Score
> *Consult [heuristics-scoring](heuristics-scoring.md)*
> *Consult the [Heuristics Scoring Guide](#heuristics-scoring-guide) section below.*
Present the Nielsen's 10 heuristics scores as a table:
@@ -117,7 +108,7 @@ Be honest with scores. A 4 means genuinely excellent. Most real interfaces score
**Deterministic scan**: Summarize what the automated detector found, with counts and file locations. Note any additional issues the detector caught that you missed, and flag any false positives.
**Visual overlays** (if browser was used): Tell the user that overlays are now visible in the **[Human]** tab in their browser, highlighting the detected issues. Summarize what the console output reported.
**Visual overlays** (if injection succeeded): Tell the user that overlays are now visible in the **[Human]** tab in their browser, highlighting the detected issues. Summarize what the console output reported. If browser visualization was attempted but injection failed, say that no reliable user-visible overlay is available and report the fallback signal instead.
#### Overall Impression
A brief gut reaction: what works, what doesn't, and the single biggest opportunity.
@@ -128,16 +119,16 @@ Highlight 2-3 things done well. Be specific about why they work.
#### Priority Issues
The 3-5 most impactful design problems, ordered by importance.
For each issue, tag with **P0-P3 severity** (consult [heuristics-scoring](heuristics-scoring.md) for severity definitions):
For each issue, tag with **P0-P3 severity** (see [Issue Severity below](#issue-severity-p0p3) for definitions):
- **[P?] What**: Name the problem clearly
- **Why it matters**: How this hurts users or undermines goals
- **Fix**: What to do about it (be concrete)
- **Suggested command**: Which command could address this (from: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable delight, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset)
#### Persona Red Flags
> *Consult [personas](personas.md)*
> *Consult the [Personas reference](#persona-based-design-testing) below.*
Auto-select 2-3 personas most relevant to this interface type (use the selection table in the reference). If `CLAUDE.md` contains a `## Design Context` section from `impeccable teach`, also generate 1-2 project-specific personas from the audience/brand info.
Auto-select 2-3 personas most relevant to this interface type (use the selection table in the reference). If `CLAUDE.md` contains a `## Design Context` section from `impeccable init`, also generate 1-2 project-specific personas from the audience/brand info.
For each selected persona, walk through the primary user action and list specific red flags found:
@@ -160,10 +151,42 @@ Provocative questions that might unlock better solutions:
- Be direct. Vague feedback wastes everyone's time.
- Be specific. "The submit button," not "some elements."
- Say what's wrong AND why it matters to users.
- Give concrete suggestions, not just "consider exploring..."
- Give concrete suggestions. Cut "consider exploring..." entirely.
- Prioritize ruthlessly. If everything is important, nothing is.
- Don't soften criticism. Developers need honest feedback to ship great design.
### Persist the Snapshot
Once the report above is finalized, write it to `.impeccable/critique/` so the user can refer back, and so `/impeccable polish` can pick up the priority issues without a copy-paste.
Skip this step if the Setup slug was null (vague or root-level target).
1. **Write the body to a temp file** so you can pipe it to the helper. Use the full critique report (heuristic table, anti-patterns verdict, priority issues, persona red flags, minor observations, and questions), but stop before the "Ask the User" / "Recommended Actions" sections that come later.
2. **Pass the structured metadata** through `IMPECCABLE_CRITIQUE_META` (JSON), then run the write command:
```bash
IMPECCABLE_CRITIQUE_META='{"target":"<user phrasing>","total_score":<n>,"p0_count":<n>,"p1_count":<n>}' \
node .claude/skills/impeccable/scripts/critique-storage.mjs write <slug> <body-file>
```
The helper prints the absolute path it wrote.
3. **Delete the temp body file** after the write attempt completes, whether the write succeeded or failed. If deletion fails, mention `temp-file cleanup failed: <reason>` briefly in the final output, but do not block the critique.
4. **Read the trend** for context:
```bash
node .claude/skills/impeccable/scripts/critique-storage.mjs trend <slug> 5
```
This returns a JSON array of the last 5 frontmatter entries (including the one you just wrote).
5. **Append a single line to the user-visible output**, after the report and before the questions:
> **Trend for `<slug>` (last 5 runs): 24 → 28 → 32 → 29 → 32**
> Wrote `.impeccable/critique/<filename>`.
If this is the first run for the slug, the trend is just one score; say so: "First run for this target, no trend yet."
This is fire-and-forget. Do not show the user the helper's JSON output; only the human-readable trend line and the written path. Failures here should not block the rest of the flow; print the error and move on.
### Ask the User
**After presenting findings**, use targeted questions based on what was actually found. STOP and call the AskUserQuestion tool to clarify. These answers will shape the action plan.
@@ -211,3 +234,534 @@ After presenting the summary, tell the user:
> You can ask me to run these one at a time, all at once, or in any order you prefer.
>
> Re-run `/impeccable critique` after fixes to see your score improve.
---
## Reference Material
The sections below were previously separate reference files (`cognitive-load.md`, `heuristics-scoring.md`, `personas.md`). They live inline now so the critique flow has all its deep context in one place.
### Cognitive Load Assessment
Cognitive load is the total mental effort required to use an interface. Overloaded users make mistakes, get frustrated, and leave. This reference helps identify and fix cognitive overload.
---
#### Three Types of Cognitive Load
##### Intrinsic Load: The Task Itself
Complexity inherent to what the user is trying to do. You can't eliminate this, but you can structure it.
**Manage it by**:
- Breaking complex tasks into discrete steps
- Providing scaffolding (templates, defaults, examples)
- Progressive disclosure: show what's needed now, hide the rest
- Grouping related decisions together
##### Extraneous Load: Bad Design
Mental effort caused by poor design choices. **Eliminate this ruthlessly.** It's pure waste.
**Common sources**:
- Confusing navigation that requires mental mapping
- Unclear labels that force users to guess meaning
- Visual clutter competing for attention
- Inconsistent patterns that prevent learning
- Unnecessary steps between user intent and result
##### Germane Load: Learning Effort
Mental effort spent building understanding. This is *good* cognitive load; it leads to mastery.
**Support it by**:
- Progressive disclosure that reveals complexity gradually
- Consistent patterns that reward learning
- Feedback that confirms correct understanding
- Onboarding that teaches through action, not walls of text
---
#### Cognitive Load Checklist
Evaluate the interface against these 8 items:
- [ ] **Single focus**: Can the user complete their primary task without distraction from competing elements?
- [ ] **Chunking**: Is information presented in digestible groups (≤4 items per group)?
- [ ] **Grouping**: Are related items visually grouped together (proximity, borders, shared background)?
- [ ] **Visual hierarchy**: Is it immediately clear what's most important on the screen?
- [ ] **One thing at a time**: Can the user focus on a single decision before moving to the next?
- [ ] **Minimal choices**: Are decisions simplified (≤4 visible options at any decision point)?
- [ ] **Working memory**: Does the user need to remember information from a previous screen to act on the current one?
- [ ] **Progressive disclosure**: Is complexity revealed only when the user needs it?
**Scoring**: Count the failed items. 01 failures = low cognitive load (good). 23 = moderate (address soon). 4+ = high cognitive load (critical fix needed).
---
#### The Working Memory Rule
**Humans can hold ≤4 items in working memory at once** (Miller's Law revised by Cowan, 2001).
At any decision point, count the number of distinct options, actions, or pieces of information a user must simultaneously consider:
- **≤4 items**: Within working memory limits, manageable
- **57 items**: Pushing the boundary; consider grouping or progressive disclosure
- **8+ items**: Overloaded; users will skip, misclick, or abandon
**Practical applications**:
- Navigation menus: ≤5 top-level items (group the rest under clear categories)
- Form sections: ≤4 fields visible per group before a visual break
- Action buttons: 1 primary, 12 secondary, group the rest in a menu
- Dashboard widgets: ≤4 key metrics visible without scrolling
- Pricing tiers: ≤3 options (more causes analysis paralysis)
---
#### Common Cognitive Load Violations
##### 1. The Wall of Options
**Problem**: Presenting 10+ choices at once with no hierarchy.
**Fix**: Group into categories, highlight recommended, use progressive disclosure.
##### 2. The Memory Bridge
**Problem**: User must remember info from step 1 to complete step 3.
**Fix**: Keep relevant context visible, or repeat it where it's needed.
##### 3. The Hidden Navigation
**Problem**: User must build a mental map of where things are.
**Fix**: Always show current location (breadcrumbs, active states, progress indicators).
##### 4. The Jargon Barrier
**Problem**: Technical or domain language forces translation effort.
**Fix**: Use plain language. If domain terms are unavoidable, define them inline.
##### 5. The Visual Noise Floor
**Problem**: Every element has the same visual weight; nothing stands out.
**Fix**: Establish clear hierarchy: one primary element, 23 secondary, everything else muted.
##### 6. The Inconsistent Pattern
**Problem**: Similar actions work differently in different places.
**Fix**: Standardize interaction patterns. Same type of action = same type of UI.
##### 7. The Multi-Task Demand
**Problem**: Interface requires processing multiple simultaneous inputs (reading + deciding + navigating).
**Fix**: Sequence the steps. Let the user do one thing at a time.
##### 8. The Context Switch
**Problem**: User must jump between screens/tabs/modals to gather info for a single decision.
**Fix**: Co-locate the information needed for each decision. Reduce back-and-forth.
---
### Heuristics Scoring Guide
Score each of Nielsen's 10 Usability Heuristics on a 04 scale. Be honest: a 4 means genuinely excellent, not "good enough."
#### Nielsen's 10 Heuristics
##### 1. Visibility of System Status
Keep users informed about what's happening through timely, appropriate feedback.
**Check for**:
- Loading indicators during async operations
- Confirmation of user actions (save, submit, delete)
- Progress indicators for multi-step processes
- Current location in navigation (breadcrumbs, active states)
- Form validation feedback (inline, not just on submit)
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | No feedback; user is guessing what happened |
| 1 | Rare feedback; most actions produce no visible response |
| 2 | Partial; some states communicated, major gaps remain |
| 3 | Good; most operations give clear feedback, minor gaps |
| 4 | Excellent; every action confirms, progress is always visible |
##### 2. Match Between System and Real World
Speak the user's language. Follow real-world conventions. Information appears in natural, logical order.
**Check for**:
- Familiar terminology (no unexplained jargon)
- Logical information order matching user expectations
- Recognizable icons and metaphors
- Domain-appropriate language for the target audience
- Natural reading flow (left-to-right, top-to-bottom priority)
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | Pure tech jargon, alien to users |
| 1 | Mostly confusing; requires domain expertise to navigate |
| 2 | Mixed; some plain language, some jargon leaks through |
| 3 | Mostly natural; occasional term needs context |
| 4 | Speaks the user's language fluently throughout |
##### 3. User Control and Freedom
Users need a clear "emergency exit" from unwanted states without extended dialogue.
**Check for**:
- Undo/redo functionality
- Cancel buttons on forms and modals
- Clear navigation back to safety (home, previous)
- Easy way to clear filters, search, selections
- Escape from long or multi-step processes
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | Users get trapped; no way out without refreshing |
| 1 | Difficult exits; must find obscure paths to escape |
| 2 | Some exits; main flows have escape, edge cases don't |
| 3 | Good control; users can exit and undo most actions |
| 4 | Full control; undo, cancel, back, and escape everywhere |
##### 4. Consistency and Standards
Users shouldn't wonder whether different words, situations, or actions mean the same thing.
**Check for**:
- Consistent terminology throughout the interface
- Same actions produce same results everywhere
- Platform conventions followed (standard UI patterns)
- Visual consistency (colors, typography, spacing, components)
- Consistent interaction patterns (same gesture = same behavior)
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | Inconsistent everywhere; feels like different products stitched together |
| 1 | Many inconsistencies; similar things look/behave differently |
| 2 | Partially consistent; main flows match, details diverge |
| 3 | Mostly consistent; occasional deviation, nothing confusing |
| 4 | Fully consistent; cohesive system, predictable behavior |
##### 5. Error Prevention
Better than good error messages is a design that prevents problems in the first place.
**Check for**:
- Confirmation before destructive actions (delete, overwrite)
- Constraints preventing invalid input (date pickers, dropdowns)
- Smart defaults that reduce errors
- Clear labels that prevent misunderstanding
- Autosave and draft recovery
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | Errors easy to make; no guardrails anywhere |
| 1 | Few safeguards; some inputs validated, most aren't |
| 2 | Partial prevention; common errors caught, edge cases slip |
| 3 | Good prevention; most error paths blocked proactively |
| 4 | Excellent; errors nearly impossible through smart constraints |
##### 6. Recognition Rather Than Recall
Minimize memory load. Make objects, actions, and options visible or easily retrievable.
**Check for**:
- Visible options (not buried in hidden menus)
- Contextual help when needed (tooltips, inline hints)
- Recent items and history
- Autocomplete and suggestions
- Labels on icons (not icon-only navigation)
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | Heavy memorization; users must remember paths and commands |
| 1 | Mostly recall; many hidden features, few visible cues |
| 2 | Some aids; main actions visible, secondary features hidden |
| 3 | Good recognition; most things discoverable, few memory demands |
| 4 | Everything discoverable; users never need to memorize |
##### 7. Flexibility and Efficiency of Use
Accelerators, invisible to novices, speed up expert interaction.
**Check for**:
- Keyboard shortcuts for common actions
- Customizable interface elements
- Recent items and favorites
- Bulk/batch actions
- Power user features that don't complicate the basics
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | One rigid path; no shortcuts or alternatives |
| 1 | Limited flexibility; few alternatives to the main path |
| 2 | Some shortcuts; basic keyboard support, limited bulk actions |
| 3 | Good accelerators; keyboard nav, some customization |
| 4 | Highly flexible; multiple paths, power features, customizable |
##### 8. Aesthetic and Minimalist Design
Interfaces should not contain irrelevant or rarely needed information. Every element should serve a purpose.
**Check for**:
- Only necessary information visible at each step
- Clear visual hierarchy directing attention
- Purposeful use of color and emphasis
- No decorative clutter competing for attention
- Focused, uncluttered layouts
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | Overwhelming; everything competes for attention equally |
| 1 | Cluttered; too much noise, hard to find what matters |
| 2 | Some clutter; main content clear, periphery noisy |
| 3 | Mostly clean; focused design, minor visual noise |
| 4 | Perfectly minimal; every element earns its pixel |
##### 9. Help Users Recognize, Diagnose, and Recover from Errors
Error messages should use plain language, precisely indicate the problem, and constructively suggest a solution.
**Check for**:
- Plain language error messages (no error codes for users)
- Specific problem identification ("Email is missing @" not "Invalid input")
- Actionable recovery suggestions
- Errors displayed near the source of the problem
- Non-blocking error handling (don't wipe the form)
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | Cryptic errors; codes, jargon, or no message at all |
| 1 | Vague errors; "Something went wrong" with no guidance |
| 2 | Clear but unhelpful; names the problem but not the fix |
| 3 | Clear with suggestions; identifies problem and offers next steps |
| 4 | Perfect recovery; pinpoints issue, suggests fix, preserves user work |
##### 10. Help and Documentation
Even if the system is usable without docs, help should be easy to find, task-focused, and concise.
**Check for**:
- Searchable help or documentation
- Contextual help (tooltips, inline hints, guided tours)
- Task-focused organization (not feature-organized)
- Concise, scannable content
- Easy access without leaving current context
**Scoring**:
| Score | Criteria |
|-------|----------|
| 0 | No help available anywhere |
| 1 | Help exists but hard to find or irrelevant |
| 2 | Basic help; FAQ or docs exist, not contextual |
| 3 | Good documentation; searchable, mostly task-focused |
| 4 | Excellent contextual help; right info at the right moment |
---
#### Score Summary
**Total possible**: 40 points (10 heuristics × 4 max)
| Score Range | Rating | What It Means |
|-------------|--------|---------------|
| 3640 | Excellent | Minor polish only; ship it |
| 2835 | Good | Address weak areas, solid foundation |
| 2027 | Acceptable | Significant improvements needed before users are happy |
| 1219 | Poor | Major UX overhaul required; core experience broken |
| 011 | Critical | Redesign needed; unusable in current state |
---
#### Issue Severity (P0P3)
Tag each individual issue found during scoring with a priority level:
| Priority | Name | Description | Action |
|----------|------|-------------|--------|
| **P0** | Blocking | Prevents task completion entirely | Fix immediately; this is a showstopper |
| **P1** | Major | Causes significant difficulty or confusion | Fix before release |
| **P2** | Minor | Annoyance, but workaround exists | Fix in next pass |
| **P3** | Polish | Nice-to-fix, no real user impact | Fix if time permits |
**Tip**: If you're unsure between two levels, ask: "Would a user contact support about this?" If yes, it's at least P1.
---
### Persona-Based Design Testing
Test the interface through the eyes of 5 distinct user archetypes. Each persona exposes different failure modes that a single "design director" perspective would miss.
**How to use**: Select 23 personas most relevant to the interface being critiqued. Walk through the primary user action as each persona. Report specific red flags, not generic concerns.
---
#### 1. Impatient Power User: "Alex"
**Profile**: Expert with similar products. Expects efficiency, hates hand-holding. Will find shortcuts or leave.
**Behaviors**:
- Skips all onboarding and instructions
- Looks for keyboard shortcuts immediately
- Tries to bulk-select, batch-edit, and automate
- Gets frustrated by required steps that feel unnecessary
- Abandons if anything feels slow or patronizing
**Test Questions**:
- Can Alex complete the core task in under 60 seconds?
- Are there keyboard shortcuts for common actions?
- Can onboarding be skipped entirely?
- Do modals have keyboard dismiss (Esc)?
- Is there a "power user" path (shortcuts, bulk actions)?
**Red Flags** (report these specifically):
- Forced tutorials or unskippable onboarding
- No keyboard navigation for primary actions
- Slow animations that can't be skipped
- One-item-at-a-time workflows where batch would be natural
- Redundant confirmation steps for low-risk actions
---
#### 2. Confused First-Timer: "Jordan"
**Profile**: Never used this type of product. Needs guidance at every step. Will abandon rather than figure it out.
**Behaviors**:
- Reads all instructions carefully
- Hesitates before clicking anything unfamiliar
- Looks for help or support constantly
- Misunderstands jargon and abbreviations
- Takes the most literal interpretation of any label
**Test Questions**:
- Is the first action obviously clear within 5 seconds?
- Are all icons labeled with text?
- Is there contextual help at decision points?
- Does terminology assume prior knowledge?
- Is there a clear "back" or "undo" at every step?
**Red Flags** (report these specifically):
- Icon-only navigation with no labels
- Technical jargon without explanation
- No visible help option or guidance
- Ambiguous next steps after completing an action
- No confirmation that an action succeeded
---
#### 3. Accessibility-Dependent User: "Sam"
**Profile**: Uses screen reader (VoiceOver/NVDA), keyboard-only navigation. May have low vision, motor impairment, or cognitive differences.
**Behaviors**:
- Tabs through the interface linearly
- Relies on ARIA labels and heading structure
- Cannot see hover states or visual-only indicators
- Needs adequate color contrast (4.5:1 minimum)
- May use browser zoom up to 200%
**Test Questions**:
- Can the entire primary flow be completed keyboard-only?
- Are all interactive elements focusable with visible focus indicators?
- Do images have meaningful alt text?
- Is color contrast WCAG AA compliant (4.5:1 for text)?
- Does the screen reader announce state changes (loading, success, errors)?
**Red Flags** (report these specifically):
- Click-only interactions with no keyboard alternative
- Missing or invisible focus indicators
- Meaning conveyed by color alone (red = error, green = success)
- Unlabeled form fields or buttons
- Time-limited actions without extension option
- Custom components that break screen reader flow
---
#### 4. Deliberate Stress Tester: "Riley"
**Profile**: Methodical user who pushes interfaces beyond the happy path. Tests edge cases, tries unexpected inputs, and probes for gaps in the experience.
**Behaviors**:
- Tests edge cases intentionally (empty states, long strings, special characters)
- Submits forms with unexpected data (emoji, RTL text, very long values)
- Tries to break workflows by navigating backwards, refreshing mid-flow, or opening in multiple tabs
- Looks for inconsistencies between what the UI promises and what actually happens
- Documents problems methodically
**Test Questions**:
- What happens at the edges (0 items, 1000 items, very long text)?
- Do error states recover gracefully or leave the UI in a broken state?
- What happens on refresh mid-workflow? Is state preserved?
- Are there features that appear to work but produce broken results?
- How does the UI handle unexpected input (emoji, special chars, paste from Excel)?
**Red Flags** (report these specifically):
- Features that appear to work but silently fail or produce wrong results
- Error handling that exposes technical details or leaves UI in a broken state
- Empty states that show nothing useful ("No results" with no guidance)
- Workflows that lose user data on refresh or navigation
- Inconsistent behavior between similar interactions in different parts of the UI
---
#### 5. Distracted Mobile User: "Casey"
**Profile**: Using phone one-handed on the go. Frequently interrupted. Possibly on a slow connection.
**Behaviors**:
- Uses thumb only; prefers bottom-of-screen actions
- Gets interrupted mid-flow and returns later
- Switches between apps frequently
- Has limited attention span and low patience
- Types as little as possible, prefers taps and selections
**Test Questions**:
- Are primary actions in the thumb zone (bottom half of screen)?
- Is state preserved if the user leaves and returns?
- Does it work on slow connections (3G)?
- Can forms use autocomplete and smart defaults?
- Are touch targets at least 44×44pt?
**Red Flags** (report these specifically):
- Important actions positioned at the top of the screen (unreachable by thumb)
- No state persistence; progress lost on tab switch or interruption
- Large text inputs required where selection would work
- Heavy assets loading on every page (no lazy loading)
- Tiny tap targets or targets too close together
---
#### Selecting Personas
Choose personas based on the interface type:
| Interface Type | Primary Personas | Why |
|---------------|-----------------|-----|
| Landing page / marketing | Jordan, Riley, Casey | First impressions, trust, mobile |
| Dashboard / admin | Alex, Sam | Power users, accessibility |
| E-commerce / checkout | Casey, Riley, Jordan | Mobile, edge cases, clarity |
| Onboarding flow | Jordan, Casey | Confusion, interruption |
| Data-heavy / analytics | Alex, Sam | Efficiency, keyboard nav |
| Form-heavy / wizard | Jordan, Sam, Casey | Clarity, accessibility, mobile |
---
#### Project-Specific Personas
If `CLAUDE.md` contains a `## Design Context` section (generated by `impeccable init`), derive 12 additional personas from the audience and brand information:
1. Read the target audience description
2. Identify the primary user archetype not covered by the 5 predefined personas
3. Create a persona following this template:
```
##### [Role]: "[Name]"
**Profile**: [2-3 key characteristics derived from Design Context]
**Behaviors**: [3-4 specific behaviors based on the described audience]
**Red Flags**: [3-4 things that would alienate this specific user type]
```
Only generate project-specific personas when real Design Context data is available. Don't invent audience details; use the 5 predefined personas when no context exists.
@@ -1,12 +1,12 @@
> **Additional context needed**: what's appropriate for the domain (playful vs professional vs quirky vs elegant).
Identify opportunities to add moments of joy, personality, and unexpected polish that transform functional interfaces into delightful experiences.
Find the moments where personality and unexpected polish would turn a functional interface into one users remember and tell other people about. Add only where the moment earns it; delight everywhere reads as noise.
---
## Register
Brand: delight can be distributed copy voice, section transitions, discovery rewards, seasonal touches, personality across the whole surface.
Brand: delight can be distributed across copy voice, section transitions, discovery rewards, seasonal touches, personality across the whole surface.
Product: delight at specific moments, not pages. Completion, first-time actions, error recovery, milestone crossings. Reliability and consistency carry the rest of the experience; delight pushed everywhere reads as noise.
@@ -236,14 +236,14 @@ Add personality and joy through these methods:
- Countdown with encouraging messages
```
Loading messages write ones specific to your product, not generic AI filler:
Loading messages: write ones specific to your product, not generic AI filler:
- "Crunching your latest numbers..."
- "Syncing with your team's changes..."
- "Preparing your dashboard..."
- "Checking for updates since yesterday..."
```
**WARNING**: Avoid cliched loading messages like "Herding pixels", "Teaching robots to dance", "Consulting the magic 8-ball", "Counting backwards from infinity". These are AI-slop copy instantly recognizable as machine-generated. Write messages that are specific to what your product actually does.
**WARNING**: Avoid cliched loading messages like "Herding pixels", "Teaching robots to dance", "Consulting the magic 8-ball", "Counting backwards from infinity". These are AI-slop copy, instantly recognizable as machine-generated. Write messages that are specific to what your product actually does.
### Celebration Moments
@@ -299,4 +299,4 @@ Test that delight actually delights:
- **Appropriate**: Matches brand and context
- **Accessible**: Works with reduced motion, screen readers
Remember: Delight is the difference between a tool and an experience. Add personality, surprise users positively, and create moments worth sharing. But always respect usability - delight should enhance, never obstruct.
When the moments feel earned, hand off to `/impeccable polish` for the final pass.
@@ -1,4 +1,4 @@
Remove unnecessary complexity from designs, revealing the essential elements and creating clarity through ruthless simplification.
Strip a design to its essence. Remove anything that doesn't earn its place: redundant elements, repeated information, decorative noise, cosmetic complexity.
---
@@ -23,7 +23,7 @@ Analyze what makes the design feel complex or cluttered:
If any of these are unclear from the codebase, STOP and call the AskUserQuestion tool to clarify.
**CRITICAL**: Simplicity is not about removing features - it's about removing obstacles between users and their goals. Every element should justify its existence.
**CRITICAL**: Simplicity is not about removing features. It's about removing obstacles between users and their goals. Every element should justify its existence.
## Plan Simplification
@@ -51,7 +51,7 @@ Systematically remove complexity across these dimensions:
- **Reduce color palette**: Use 1-2 colors plus neutrals, not 5-7 colors
- **Limit typography**: One font family, 3-4 sizes maximum, 2-3 weights
- **Remove decorations**: Eliminate borders, shadows, backgrounds that don't serve hierarchy or function
- **Flatten structure**: Reduce nesting, remove unnecessary containersnever nest cards inside cards
- **Flatten structure**: Reduce nesting, remove unnecessary containers; never nest cards inside cards
- **Remove unnecessary cards**: Cards aren't needed for basic layout; use spacing and alignment instead
- **Consistent spacing**: Use one spacing scale, remove arbitrary gaps
@@ -108,4 +108,4 @@ If you removed features or options:
- Consider if they need alternative access points
- Note any user feedback to monitor
Remember: You have great taste and judgment. Simplification is an act of confidence - knowing what to keep and courage to remove the rest. As Antoine de Saint-Exupéry said: "Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away."
When the cuts feel right, hand off to `/impeccable polish` for the final pass. As Antoine de Saint-Exupéry put it: "Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away."
+59 -57
View File
@@ -44,8 +44,8 @@ Rules that matter:
- **Token refs** use `{path.to.token}` (e.g. `{colors.primary}`, `{rounded.md}`). Components may reference primitives; primitives may not reference each other.
- **Stitch validates colors as hex sRGB only** (`#RGB` / `#RGBA` / `#RRGGBB` / `#RRGGBBAA`); OKLCH/HSL/P3 trigger a linter warning, not a hard error. YAML accepts the string either way and our own parser is format-agnostic. Choose based on project posture: (a) if the project has an "OKLCH-only" doctrine or uses Display-P3 values that don't round-trip through sRGB, put OKLCH directly in the frontmatter and accept the Stitch linter warning; (b) if the project wants strict Stitch compliance or plans to use their Tailwind/DTCG export pipeline, put hex in the frontmatter and keep OKLCH in prose as the canonical reference. Never split the source of truth without explicit reason.
- **Component sub-tokens** are limited to 8 props: `backgroundColor`, `textColor`, `typography`, `rounded`, `padding`, `size`, `height`, `width`. Shadows, motion, focus rings, backdrop-filter none of those fit. Carry them in the sidecar (Step 4b).
- **Scale keys are open-ended.** Use whatever names the project already uses (`warm-ash-cream`, `surface-container-low`). Don't rename to Material defaults.
- **Component sub-tokens** are limited to 8 props: `backgroundColor`, `textColor`, `typography`, `rounded`, `padding`, `size`, `height`, `width`. Shadows, motion, focus rings, backdrop-filter: none of those fit. Carry them in the sidecar (Step 4b).
- **Scale keys are open-ended.** Use whatever names the project already uses (`oxblood-deep`, `surface-container-low`). Don't rename to Material defaults.
- **Variants are naming convention, not schema.** `button-primary` / `button-primary-hover` / `button-primary-active` as sibling keys.
## The markdown body: six sections (exact order)
@@ -57,11 +57,11 @@ Rules that matter:
5. `## Components`
6. `## Do's and Don'ts`
Optional evocative subtitles are allowed in the form `## 2. Colors: The [Name] Palette` Stitch's own outputs do this but the literal word in each header (Overview, Colors, Typography, Elevation, Components, Do's and Don'ts) must be present. Do NOT add extra top-level sections (Layout Principles, Responsive Behavior, Motion, Agent Prompt Guide). Fold that content into the six spec sections where it naturally belongs.
Optional evocative subtitles are allowed in the form `## 2. Colors: The [Name] Palette` (Stitch's own outputs do this), but the literal word in each header (Overview, Colors, Typography, Elevation, Components, Do's and Don'ts) must be present. Do NOT add extra top-level sections (Layout Principles, Responsive Behavior, Motion, Agent Prompt Guide). Fold that content into the six spec sections where it naturally belongs.
## When to run
- The user just ran `/impeccable teach` and needs the visual side documented.
- The user just ran `/impeccable init` and needs the visual side documented.
- The skill noticed no `DESIGN.md` exists and nudged the user to create one.
- An existing `DESIGN.md` is stale (the design has drifted).
- Before a large redesign, to capture the current state as a reference.
@@ -71,9 +71,9 @@ If a `DESIGN.md` already exists, **do not silently overwrite it**. Show the user
## Two paths
- **Scan mode** (default): the project has design tokens, components, or rendered output. Extract, then confirm descriptive language. Use when there's code to analyze.
- **Seed mode**: the project is pre-implementation (fresh teach, nothing built yet). Interview for five high-level answers, write a minimal DESIGN.md marked `<!-- SEED -->`. Re-run in scan mode once there's code.
- **Seed mode**: the project is pre-implementation (fresh init, nothing built yet). Interview for five high-level answers, write a minimal DESIGN.md marked `<!-- SEED -->`. Re-run in scan mode once there's code.
Decide by scanning first (Scan mode Step 1). If the scan finds no tokens, no component files, and no rendered site, offer seed mode don't silently switch. `/impeccable document --seed` forces seed mode regardless of code presence.
Decide by scanning first (Scan mode Step 1). If the scan finds no tokens, no component files, and no rendered site, offer seed mode; don't silently switch. `/impeccable document --seed` forces seed mode regardless of code presence.
## Scan mode (approach C: auto-extract, then confirm descriptive language)
@@ -81,29 +81,29 @@ Decide by scanning first (Scan mode Step 1). If the scan finds no tokens, no com
Search the codebase in priority order:
1. **CSS custom properties** grep for `--color-`, `--font-`, `--spacing-`, `--radius-`, `--shadow-`, `--ease-`, `--duration-` declarations in CSS files (usually `src/styles/`, `public/css/`, `app/globals.css`, etc.). Record name, value, and the file it's defined in.
2. **Tailwind config** if `tailwind.config.{js,ts,mjs}` exists, read the `theme.extend` block for colors, fontFamily, spacing, borderRadius, boxShadow.
3. **CSS-in-JS theme files** styled-components, emotion, vanilla-extract, stitches: look for `theme.ts`, `tokens.ts`, or equivalent.
4. **Design token files** `tokens.json`, `design-tokens.json`, Style Dictionary output, W3C token community group format.
5. **Component library** scan the main button, card, input, navigation, dialog components. Note their variant APIs and default styles.
6. **Global stylesheet** the root CSS file usually has the base typography and color assignments.
7. **Visible rendered output** if browser automation tools are available, load the live site and sample computed styles from key elements (body, h1, a, button, .card). This catches values that tokens miss.
1. **CSS custom properties**: grep for `--color-`, `--font-`, `--spacing-`, `--radius-`, `--shadow-`, `--ease-`, `--duration-` declarations in CSS files (usually `src/styles/`, `public/css/`, `app/globals.css`, etc.). Record name, value, and the file it's defined in.
2. **Tailwind config**: if `tailwind.config.{js,ts,mjs}` exists, read the `theme.extend` block for colors, fontFamily, spacing, borderRadius, boxShadow.
3. **CSS-in-JS theme files**: styled-components, emotion, vanilla-extract, stitches; look for `theme.ts`, `tokens.ts`, or equivalent.
4. **Design token files**: `tokens.json`, `design-tokens.json`, Style Dictionary output, W3C token community group format.
5. **Component library**: scan the main button, card, input, navigation, dialog components. Note their variant APIs and default styles.
6. **Global stylesheet**: the root CSS file usually has the base typography and color assignments.
7. **Visible rendered output**: if browser automation tools are available, load the live site and sample computed styles from key elements (body, h1, a, button, .card). This catches values that tokens miss.
### Step 2: Auto-extract what can be auto-extracted
Build a structured draft from the discovered tokens. For each token class:
- **Colors**: Group into Primary / Secondary / Tertiary / Neutral (the Material-derived roles Stitch uses). If the project only has one accent, express it as Primary + Neutral omit Secondary and Tertiary rather than inventing them.
- **Colors**: Group into Primary / Secondary / Tertiary / Neutral (the Material-derived roles Stitch uses). If the project only has one accent, express it as Primary + Neutral; omit Secondary and Tertiary rather than inventing them.
- **Typography**: Map observed sizes and weights to the Material hierarchy (display / headline / title / body / label). Note font-family stacks and the scale ratio.
- **Elevation**: Catalogue the shadow vocabulary. If the project is flat and uses tonal layering instead, that's a valid answer state it explicitly.
- **Elevation**: Catalogue the shadow vocabulary. If the project is flat and uses tonal layering instead, that's a valid answer; state it explicitly.
- **Components**: For each common component (button, card, input, chip, list item, tooltip, nav), extract shape (radius), color assignment, hover/focus treatment, internal padding.
- **Spacing + layout**: Fold into Overview or relevant Components. The spec does NOT have a Layout section.
### Step 2b: Stage the frontmatter
From the auto-extracted tokens, draft the YAML frontmatter now (you'll write it at the top of DESIGN.md in Step 4). This is the machine-readable layer what the live panel and Stitch's linter consume.
From the auto-extracted tokens, draft the YAML frontmatter now (you'll write it at the top of DESIGN.md in Step 4). This is the machine-readable layer: what the live panel and Stitch's linter consume.
- **Colors**: one entry per extracted color. Key = descriptive slug (`warm-ash-cream`, `editorial-magenta`, not `blue-800`). Value = whichever format the project treats as canonical (OKLCH or hex see the frontmatter rules above). Don't split the source of truth: one format in the frontmatter, don't redefine the same token in prose with a different value.
- **Colors**: one entry per extracted color. Key = descriptive slug (`oxblood-deep`, `editorial-magenta`, not `blue-800`). Value = whichever format the project treats as canonical (OKLCH or hex; see the frontmatter rules above). Don't split the source of truth: one format in the frontmatter, don't redefine the same token in prose with a different value.
- **Typography**: one entry per role (`display`, `headline`, `title`, `body`, `label`). Typography is an object; include only the props that are real for the project (`fontFamily`, `fontSize`, `fontWeight`, `lineHeight`, `letterSpacing`, `fontFeature`, `fontVariation`).
- **Rounded / Spacing**: whatever scale steps the project actually uses, keyed by whatever scale name the project uses (`sm` / `md` / `lg`, or `surface-sm`, or numeric steps).
- **Components**: one entry per variant (`button-primary`, `button-primary-hover`, `button-ghost`). Reference primitives via `{colors.X}`, `{rounded.Y}`. If a variant needs a property Stitch's 8-prop set doesn't cover (shadow, focus ring, backdrop-filter), carry the full snippet in the sidecar instead.
@@ -149,7 +149,7 @@ colors:
### Primary
- **[Descriptive Name]** (#HEX / oklch(...)): [Where and why this color is used. Be specific about context, not just role.]
### Secondary (optional omit if the project has only one accent)
### Secondary (optional; omit if the project has only one accent)
- **[Descriptive Name]** (#HEX): [Role.]
### Tertiary (optional)
@@ -160,7 +160,7 @@ colors:
- [...]
### Named Rules (optional, powerful)
**The [Rule Name] Rule.** [Short, forceful prohibition or doctrine e.g. "The One Voice Rule. The primary accent is used on ≤10% of any given screen. Its rarity is the point."]
**The [Rule Name] Rule.** [Short, forceful prohibition or doctrine, e.g. "The One Voice Rule. The primary accent is used on ≤10% of any given screen. Its rarity is the point."]
## 3. Typography
@@ -171,7 +171,7 @@ colors:
**Character:** [1-2 sentence personality description of the pairing.]
### Hierarchy
- **Display** ([weight], [size/clamp], [line-height]): [Purpose where it appears.]
- **Display** ([weight], [size/clamp], [line-height]): [Purpose; where it appears.]
- **Headline** ([weight], [size], [line-height]): [Purpose.]
- **Title** ([weight], [size], [line-height]): [Purpose.]
- **Body** ([weight], [size], [line-height]): [Purpose. Include max line length like 6575ch if relevant.]
@@ -214,34 +214,34 @@ For each component, lead with a short character line, then specify shape, color
### Inputs / Fields
- **Style:** [stroke, background, radius]
- **Focus:** [treatment glow, border shift, etc.]
- **Focus:** [treatment, e.g. glow, border shift, etc.]
- **Error / Disabled:** [if applicable]
### Navigation
- **Style, typography, default/hover/active states, mobile treatment.**
### [Signature Component] (optional if the project has a distinctive custom component worth documenting)
### [Signature Component] (optional; if the project has a distinctive custom component worth documenting)
[Description.]
## 6. Do's and Don'ts
Concrete, forceful guardrails. Lead each with "Do" or "Don't". Be specific include exact colors, pixel values, and named anti-patterns the user mentioned in PRODUCT.md. **Every anti-reference in PRODUCT.md should show up here as a "Don't" with the same language**, so the visual spec carries the strategic line through. Quote PRODUCT.md directly where possible: if PRODUCT.md says *"avoid dark mode with purple gradients, neon accents, glassmorphism"*, the Don'ts here should repeat that by name.
Concrete, forceful guardrails. Lead each with "Do" or "Don't". Be specific: include exact colors, pixel values, and named anti-patterns the user mentioned in PRODUCT.md. **Every anti-reference in PRODUCT.md should show up here as a "Don't" with the same language**, so the visual spec carries the strategic line through. Quote PRODUCT.md directly where possible: if PRODUCT.md says *"avoid dark mode with purple gradients, neon accents, glassmorphism"*, the Don'ts here should repeat that by name.
### Do:
- **Do** [specific prescription with exact values / named rule].
- **Do** [...]
### Don't:
- **Don't** [specific prohibition e.g. "use border-left greater than 1px as a colored stripe"].
- **Don't** [specific prohibition, e.g. "use border-left greater than 1px as a colored stripe"].
- **Don't** [...]
- **Don't** [...]
```
### Step 4b: Write DESIGN.json sidecar (extensions only)
### Step 4b: Write .impeccable/design.json sidecar (extensions only)
The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `DESIGN.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it.
The frontmatter owns token primitives (colors, typography, rounded, spacing, components). The sidecar at `.impeccable/design.json` carries **what Stitch's schema can't hold**: tonal ramps per color, shadow/elevation tokens, motion tokens, breakpoints, full component HTML/CSS snippets (the panel renders these into a shadow DOM), and narrative (north star, rules, do's/don'ts). It extends the frontmatter, it doesn't duplicate it.
Regenerate the sidecar whenever you regenerate DESIGN.md. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve DESIGN.md and write only DESIGN.json.
Regenerate the sidecar whenever you regenerate root `DESIGN.md`. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve `DESIGN.md` and write only `.impeccable/design.json`.
#### Schema
@@ -253,7 +253,7 @@ Regenerate the sidecar whenever you regenerate DESIGN.md. If the user only asks
"extensions": {
"colorMeta": {
"primary": { "role": "primary", "displayName": "Editorial Magenta", "canonical": "oklch(60% 0.25 350)", "tonalRamp": ["...", "...", "..."] },
"warm-ash-cream": { "role": "neutral", "displayName": "Warm Ash Cream", "canonical": "oklch(96% 0.005 350)", "tonalRamp": ["...", "...", "..."] }
"cool-paper": { "role": "neutral", "displayName": "Cool Paper", "canonical": "oklch(96% 0.005 230)", "tonalRamp": ["...", "...", "..."] }
},
"typographyMeta": {
"display": { "displayName": "Display", "purpose": "Hero headlines only." }
@@ -280,7 +280,7 @@ Regenerate the sidecar whenever you regenerate DESIGN.md. If the user only asks
],
"narrative": {
"northStar": "The Editorial Sanctuary",
"overview": "2-3 paragraphs of the philosophy pulled from DESIGN.md Overview section.",
"overview": "2-3 paragraphs of the philosophy, pulled from DESIGN.md Overview section.",
"keyCharacteristics": ["...", "..."],
"rules": [{ "name": "The One Voice Rule", "body": "...", "section": "colors|typography|elevation" }],
"dos": ["Do use ..."],
@@ -289,14 +289,14 @@ Regenerate the sidecar whenever you regenerate DESIGN.md. If the user only asks
}
```
**What changed from schemaVersion 1.** The old sidecar carried token primitive arrays (`tokens.colors[]`, `tokens.typography[]`, etc.). Those values now live in the frontmatter. The sidecar only carries metadata that can't live in the frontmatter tonal ramps, canonical OKLCH when the hex is an approximation, display names, role hints keyed by the frontmatter token name (`colorMeta.<token-name>`, `typographyMeta.<token-name>`). Components still carry full HTML/CSS because Stitch's 8-prop set can't hold them.
**What changed from schemaVersion 1.** The old sidecar carried token primitive arrays (`tokens.colors[]`, `tokens.typography[]`, etc.). Those values now live in the frontmatter. The sidecar only carries metadata that can't live in the frontmatter (tonal ramps, canonical OKLCH when the hex is an approximation, display names, role hints), keyed by the frontmatter token name (`colorMeta.<token-name>`, `typographyMeta.<token-name>`). Components still carry full HTML/CSS because Stitch's 8-prop set can't hold them.
#### Component translation rules
The `html` and `css` fields must be **self-contained, drop-in snippets** that render correctly when injected into a shadow DOM. The panel applies them directly no post-processing, no framework runtime.
The `html` and `css` fields must be **self-contained, drop-in snippets** that render correctly when injected into a shadow DOM. The panel applies them directly: no post-processing, no framework runtime.
1. **Tailwind expansion.** If the source uses Tailwind (className="bg-primary text-white rounded-lg px-6 py-3"), expand every utility to literal CSS properties in the `css` string. Do **not** reference Tailwind classes; do **not** assume a Tailwind CSS bundle is loaded. Each component is self-contained.
2. **Token resolution.** If the project exposes tokens as CSS custom properties on `:root` (e.g. `--color-primary`, `--radius-md`), reference them via `var(--color-primary)` they inherit through the shadow DOM and stay live-bound. If tokens live only in JS theme objects (styled-components, CSS-in-JS), resolve to literal values at generation time.
2. **Token resolution.** If the project exposes tokens as CSS custom properties on `:root` (e.g. `--color-primary`, `--radius-md`), reference them via `var(--color-primary)`; they inherit through the shadow DOM and stay live-bound. If tokens live only in JS theme objects (styled-components, CSS-in-JS), resolve to literal values at generation time.
3. **Icons.** Inline as SVG. Do not reference Lucide/Heroicons packages, icon fonts, or `<img src="...">`. A typical icon is 16-24px; copy the SVG path data directly.
4. **States.** Include `:hover`, `:focus-visible`, and (if meaningful) `:active` rules inline. A static default-only snapshot makes the panel feel dead. Hover + focus rules in the CSS make it feel alive.
5. **Reset bloat.** Extract only the component's *distinctive* CSS (background, color, padding, border-radius, typography, transition). Skip universal resets (`box-sizing: border-box`, `line-height: inherit`, `-webkit-font-smoothing`). The panel already has a neutral canvas; don't re-ship resets.
@@ -308,13 +308,13 @@ Aim for a tight set of **5-10 components** that best represent the visual system
- **Canonical primitives (always include if the project has them):** button (each variant as a separate component entry), input/text field, navigation, chip/tag, card.
- **Signature components (include if distinctive):** hero CTA, featured card, filter pill, any custom pattern the user mentioned as important in PRODUCT.md.
- **Skip the rest.** Utility components, form building blocks, wrapper layouts not worth documenting unless visually distinctive.
- **Skip the rest.** Utility components, form building blocks, wrapper layouts: not worth documenting unless visually distinctive.
If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every DESIGN.json has *something* to render, even on day zero.
If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every `.impeccable/design.json` has *something* to render, even on day zero.
#### Tonal ramps
For each color token, generate an 8-step `tonalRamp` array dark to light, same hue and chroma, stepped lightness from ~15% to ~95%. The panel renders this as a strip under the swatch. If the project already defines a tonal scale (Material `surface-container-low` family, Tailwind-style `blue-50..blue-900`), use those values. Otherwise synthesize in OKLCH.
For each color token, generate an 8-step `tonalRamp` array: dark to light, same hue and chroma, stepped lightness from ~15% to ~95%. The panel renders this as a strip under the swatch. If the project already defines a tonal scale (Material `surface-container-low` family, Tailwind-style `blue-50..blue-900`), use those values. Otherwise synthesize in OKLCH.
#### Narrative mapping
@@ -328,12 +328,13 @@ Pull directly from the DESIGN.md you just wrote:
Do not reword. The panel shows these as secondary collapsible context; the same voice that's in the Markdown carries through.
### Step 5: Confirm, refine, and refresh session cache
### Step 5: Confirm and refine
1. Show the user the full DESIGN.md you wrote. Briefly highlight the non-obvious creative choices (descriptive color names, atmosphere language, named rules).
2. Mention that `DESIGN.json` was also written alongside the live panel will now render this project's actual button/input/nav primitives instead of generic approximations.
2. Mention that `.impeccable/design.json` was also written alongside; the live panel will now render this project's actual button/input/nav primitives instead of generic approximations.
3. Offer to refine any section: "Want me to revise a section, add component patterns I missed, or adjust the atmosphere language?"
4. **Refresh the session cache.** Run `node .claude/skills/impeccable/scripts/load-context.mjs` one final time so the newly-written DESIGN.md lands in conversation. Subsequent commands in this session will use the fresh version automatically without re-reading.
Your own write is the freshest source; subsequent commands in this session don't need a reload.
## Seed mode
@@ -350,24 +351,24 @@ If the user prefers to skip, stop. No file.
Group into one `AskUserQuestion` interaction. Options must be concrete.
1. **Color strategy.** Pick one:
- Restrained tinted neutrals + one accent ≤10%
- Committed one saturated color carries 3060% of the surface
- Full palette 34 named color roles, each deliberate
- Drenched the surface IS the color
- Restrained: tinted neutrals + one accent ≤10%
- Committed: one saturated color carries 3060% of the surface
- Full palette: 34 named color roles, each deliberate
- Drenched: the surface IS the color
Then: one hue family or anchor reference ("deep teal", "mustard", "Klim #ff4500 orange").
2. **Typography direction.** Pick one (specific fonts come later):
- Serif display + sans body
- Single sans (warm / technical / geometric / humanist pick a feel)
- Single sans (warm / technical / geometric / humanist; pick a feel)
- Display + mono
- Mono-forward
- Editorial script + sans
3. **Motion energy.** Pick one:
- Restrained state changes only
- Responsive feedback + transitions, no choreography
- Choreographed orchestrated entrances, scroll-driven sequences
- Restrained: state changes only
- Responsive: feedback + transitions, no choreography
- Choreographed: orchestrated entrances, scroll-driven sequences
4. **Three named references.** Brands, products, printed objects. Not adjectives.
@@ -380,36 +381,37 @@ Use the six-section spec from Scan mode. Populate what the interview answers; le
Lead the file with:
```markdown
<!-- SEED re-run /impeccable document once there's code to capture the actual tokens and components. -->
<!-- SEED: re-run /impeccable document once there's code to capture the actual tokens and components. -->
```
Per-section guidance in seed mode:
- **Overview**: Creative North Star and philosophy phrased from the answers (color strategy + motion energy + references). Reference the user's anti-reference directly.
- **Colors**: Color strategy as a Named Rule (e.g. *"The Drenched Rule. The surface IS the color."*). Hue family or anchor reference. No hex values mark as `[to be resolved during implementation]`.
- **Typography**: the direction the user picked (e.g. "Serif display + sans body"). No font names yet `[font pairing to be chosen at implementation]`.
- **Colors**: Color strategy as a Named Rule (e.g. *"The Drenched Rule. The surface IS the color."*). Hue family or anchor reference. No hex values; mark as `[to be resolved during implementation]`.
- **Typography**: the direction the user picked (e.g. "Serif display + sans body"). No font names yet: `[font pairing to be chosen at implementation]`.
- **Elevation**: inferred from motion energy. Restrained/Responsive → flat by default; Choreographed → layered. One sentence.
- **Components**: omit entirely no components exist yet.
- **Components**: omit entirely; no components exist yet.
- **Do's and Don'ts**: carry PRODUCT.md's anti-references directly plus the anti-reference named in Q5.
Seed mode writes a minimal frontmatter with `name` and `description` only no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `DESIGN.json` sidecar in seed mode for the same reason: nothing to render.
Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `.impeccable/design.json` sidecar in seed mode for the same reason: nothing to render.
### Step 4: Confirm and refresh session cache
### Step 4: Confirm
1. Show the seed DESIGN.md. Call out that it is a seed (the marker is the literal commitment).
2. Tell the user: "Re-run `/impeccable document` once you have some code. That pass will extract real tokens and generate the sidecar."
3. Run `node .claude/skills/impeccable/scripts/load-context.mjs` once so the seed lands in conversation for the rest of the session.
Your own write is the freshest source; no reload needed.
## Style guidelines
- **Frontmatter first, prose second.** Tokens go in the YAML frontmatter; prose contextualizes them. Don't redefine a token value in two places the frontmatter is normative.
- **Frontmatter first, prose second.** Tokens go in the YAML frontmatter; prose contextualizes them. Don't redefine a token value in two places; the frontmatter is normative.
- **Cite PRODUCT.md anti-references by name** in the Do's and Don'ts section. If PRODUCT.md lists "SaaS landing-page clichés" or "generic AI tool marketing" as anti-references, the DESIGN.md Don'ts should repeat those phrases verbatim so the visual spec enforces the strategic line.
- **Match the spec, don't invent new sections.** The six section names are fixed. If you have Layout/Motion/Responsive content to document, fold it into Overview (philosophy-level rules) or Components (per-component behavior).
- **Descriptive > technical**: "Gently curved edges (8px radius)" > "rounded-lg". Include the technical value in parens, lead with the description.
- **Functional > decorative**: for each token, explain WHERE and WHY it's used, not just WHAT it is.
- **Exact values in parens**: hex codes, px/rem values, font weights always the number in parens alongside the description.
- **Exact values in parens**: hex codes, px/rem values, font weights; always the number in parens alongside the description.
- **Use Named Rules**: `**The [Name] Rule.** [short doctrine]`. These are memorable, citable, and much stickier for AI consumers than bullet lists. Stitch's own outputs use them heavily ("The No-Line Rule", "The Ghost Border Fallback"). Aim for 1-3 per section.
- **Be forceful**. The voice of a design director. "Prohibited", "forbidden", "never", "always" not "consider", "might", "prefer". Match PRODUCT.md's tone.
- **Be forceful**. The voice of a design director. "Prohibited", "forbidden", "never", "always", not "consider", "might", "prefer". Match PRODUCT.md's tone.
- **Concrete anti-pattern tests**. Stitch writes things like *"If it looks like a 2014 app, the shadow is too dark and the blur is too small."* A one-sentence audit test beats a paragraph of principle.
- **Reference PRODUCT.md**. The anti-references section of PRODUCT.md should directly inform the Do's and Don'ts section here. Quote or paraphrase.
- **Group colors by role**, not by hex-order or hue-order. Primary / Secondary / Tertiary / Neutral is the spec ordering.
@@ -417,7 +419,7 @@ Seed mode writes a minimal frontmatter with `name` and `description` only — no
## Pitfalls
- Don't paste raw CSS class names. Translate to descriptive language.
- Don't extract every token. Stop at what's actually reused one-offs pollute the system.
- Don't extract every token. Stop at what's actually reused; one-offs pollute the system.
- Don't invent components that don't exist. If the project only has buttons and cards, only document those.
- Don't overwrite an existing DESIGN.md without asking.
- Don't duplicate content from PRODUCT.md. DESIGN.md is strictly visual.

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