This commit is contained in:
Abdul Wahab
2026-09-01 10:07:07 +05:00
parent a5cf9266e6
commit 89c842e5f4
1113 changed files with 109101 additions and 1330 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"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.",
"description": "Design fluency for frontend development. 1 skill with 24 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
"version": "4.1.2",
"author": {
"name": "Paul Bakaus",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "impeccable",
"version": "4.1.2",
"description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
"description": "Design fluency for frontend development. 1 skill with 24 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
"author": {
"name": "Paul Bakaus",
"email": "paul@paulbakaus.com"
+5 -5
View File
@@ -16,16 +16,16 @@ A hard turn ceiling ends the run without warning; a run that ends before its con
## Input Contract
Expect: the original request; the confirmed user answers; the artifact path(s); the screenshots the parent captured, in `.impeccable/review/` (web: `desktop.png` and `mobile.png`; native: device-class names such as `phone.png` and `tablet.png`, suffixed per OS on adaptive). A screenshot path the calling brief names is authoritative when the file exists; `.impeccable/review/` is where to look when the brief names none or a named path is missing, never a filename you invent. Also expect: the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); the PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths; on a comp-led build the approved comp path (a code-led build has none; it passes the chosen decision comp as a separate critique-reference input, labeled as such, and nothing here that binds "the approved comp" binds it); on a comp-led build the build state (`.impeccable/build/state.json`), the measured spec (`.impeccable/build/spec.json`), and the diff directories `.impeccable/review/diff/hero/` and `.impeccable/review/diff/final/` (each holds `side-by-side.png`, `heatmap.png`, `regions/<id>.png` paired crops, and `report.json` with per-region scores and verdicts from `comp-diff.mjs`); and the skill's `reference/craft-floor.md` path. On a native (`ios` / `android` / `adaptive`) build the packet adds the platform reference path(s) (`reference/ios.md` / `reference/android.md`) and a line saying no detector ran: read the platform reference alongside the craft floor, judge every check in the platform's own conventions, treat the screenshots as device captures, and know your floor check is the build's only slop gate. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped.
Expect: the original request; the confirmed user answers; the artifact path(s); the screenshots the parent captured, in `.impeccable/review/` (web: `desktop.png` and `mobile.png`; native: device-class names such as `phone.png` and `tablet.png`, suffixed per OS on adaptive). A screenshot path the calling brief names is authoritative when the file exists; `.impeccable/review/` is where to look when the brief names none or a named path is missing, never a filename you invent. Also expect: the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); the PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths; on a comp-led build the approved comp path (a code-led build has none; it passes the chosen decision comp as a separate critique-reference input, labeled as such, and nothing here that binds "the approved comp" binds it); and the skill's `reference/craft-floor.md` path. On a native (`ios` / `android` / `adaptive`) build the packet adds the platform reference path(s) (`reference/ios.md` / `reference/android.md`) and a line saying no detector ran: read the platform reference alongside the craft floor, judge every check in the platform's own conventions, treat the screenshots as device captures, and know your floor check is the build's only slop gate. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped.
## Checks, in order
0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-<width>.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round.
1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all.
2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition.
1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/review/hero-repro.png` exists: the hero reproduction checkpoint's capture at the comp's own dimensions; its absence means the reproduction phase ran unproven, a material finding. When a seed or prior DESIGN.md predates this build, it matches the built world, and "matches" is evidence you grep, not an impression: custom properties DESIGN.md defines that no rule consumes, literals sitting a step from a defined token, geometry a named rule bans (a 999px pill against a Slightly Soft rule). Each hit is a material finding under the craft floor's token rule, and an approved comp excuses none of them: the comp rules composition; the world rules material. On a new world with no seed, DESIGN.md is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all.
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. A committed motion energy shows up as eased state changes in the shipped code; a recorded energy with zero transitions is a finding. The card governs commitment and finish, never composition.
4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport.
5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every raster region of the spec shipped as its plate (the spec names the file; the page references it; the region's diff row is not `missing`), not a gradient, an inline SVG, or a many-vertex `clip-path` standing in for it, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind a wash is a compliance token, not a shipped material, and the detector's `buried-raster` and `organic-clip-path` findings in the packet are material fixes.
5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every image-native region of the approved comp shipped as a real asset, not a gradient standing in for one, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind other paint is a compliance token, not a shipped material.
6. **Floor.** Read the craft floor's Refuse list and hold the screenshots against it: kickers and eyebrows, hard offset shadows outside a neobrutalist world, glyph icons, system display faces, gradient text, side stripes, and the rest. A banned element is a material fix even when it matches nothing in the comp: the builder loaded the same ban before writing it, and fidelity to a comp cannot authorize what the floor refuses. The parent's hook findings cover this mechanically where hooks run; this check exists because hookless harnesses reach you with none, and the last two live sessions shipped five kickers past a reviewer that never looked.
Do not run a second detector pass; mechanical findings belong to the parent's hooks.
+2 -1
View File
@@ -3,7 +3,7 @@ name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 4.1.2
user-invocable: true
argument-hint: "[shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]"
argument-hint: "[shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live|design-context] [target]"
license: Apache 2.0
allowed-tools:
- Bash(npx impeccable *)
@@ -48,6 +48,7 @@ Choose the mode from the requested surface, not the product, and persist it only
| `init` | Build | Capture durable product context in PRODUCT.md | [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) |
| `design-context [open/edit/export/import]` | Build | Reopen, revise, export, or import the design interview and its document | [reference/design-context.md](reference/design-context.md) |
| `critique [target]` | Evaluate | UX design review with heuristic scoring | [reference/critique.md](reference/critique.md) |
| `audit [target]` | Evaluate | Technical quality checks (a11y, perf, responsive) | [reference/audit.md](reference/audit.md) · native: [reference/audit.native.md](reference/audit.native.md) |
| `polish [target]` | Refine | Final quality pass before shipping | [reference/polish.md](reference/polish.md) |
+2 -2
View File
@@ -99,7 +99,7 @@ For each issue, document:
- **Impact**: How it affects users
- **WCAG/Standard**: Which standard it violates (if applicable)
- **Recommendation**: How to fix it
- **Suggested command**: Which command to use (prefer: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable delight, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset)
- **Suggested command**: Which command to use (prefer: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable design-context, /impeccable delight, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset)
### Patterns & Systemic Issues
@@ -118,7 +118,7 @@ List recommended commands in priority order (P0 first, then P1, then P2):
1. **[P?] `/command-name`**: Brief description (specific context from audit findings)
2. **[P?] `/command-name`**: Brief description (specific context)
**Rules**: Only recommend commands from: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable delight, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset. Map findings to the most appropriate command. End with `/impeccable polish` as the final step if any fixes were recommended.
**Rules**: Only recommend commands from: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable design-context, /impeccable delight, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset. Map findings to the most appropriate command. End with `/impeccable polish` as the final step if any fixes were recommended.
After presenting the summary, tell the user:
@@ -102,7 +102,7 @@ For each issue, document:
- **Impact**: How it affects users
- **Guideline**: The HIG / Material rule it violates (if applicable)
- **Recommendation**: How to fix it
- **Suggested command**: Which command to use (prefer: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable delight, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset)
- **Suggested command**: Which command to use (prefer: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable design-context, /impeccable delight, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset)
### Patterns & Systemic Issues
@@ -121,7 +121,7 @@ List recommended commands in priority order (P0 first, then P1, then P2):
1. **[P?] `/command-name`**: Brief description (specific context from audit findings)
2. **[P?] `/command-name`**: Brief description (specific context)
**Rules**: Only recommend commands from: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable delight, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset. Map findings to the most appropriate command. End with `/impeccable polish` as the final step if any fixes were recommended.
**Rules**: Only recommend commands from: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable design-context, /impeccable delight, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset. Map findings to the most appropriate command. End with `/impeccable polish` as the final step if any fixes were recommended.
After presenting the summary, tell the user:
@@ -2,6 +2,8 @@
Load this after the direction is settled, and build without announcing the checklist. A pinned brief or the committed visual world overrides anything here; your own habit does not. When the design hook is active it already enforces the mechanical checks below as you edit: act on its findings instead of re-auditing each rule.
A wholesale file rewrite, after a hook block or an error recovery, starts by re-opening DESIGN.md and the token sheet, so the new file restates the recorded system rather than your memory of it; memory is where a display token becomes a hand-tuned clamp. The ranking lives in [new-work.md](new-work.md): the comp rules composition; the world rules material, and a rewrite changes neither.
## Verify
Each of these is a check on the built result, not an intention. Run them together in the batched inspection rounds, not as separate screenshot trips; the checks share one render.
@@ -11,6 +13,7 @@ Each of these is a check on the built result, not an intention. Run them togethe
- **Spacing:** tight groups, generous separation, more space above a heading than below it. Read the computed values.
- **Type:** body measure 6575ch, display max 6rem, tracking floor -0.04em, balanced headings, obvious scale and weight steps. Run the real copy at every breakpoint and fix what overflows.
- **Motion:** one authored moment, not scattered effects and not one identical entrance on every section. Exponential ease-out from an already-visible default. Reach past transform and opacity: blur, backdrop-filter, clip-path, mask, and shadow belong to the palette when they stay smooth.
- **Tokens:** a size or color a step from a defined token takes the token: `0.82rem` beside a `0.8rem` token is the token, not a new size, and inline SVG or JS-drawn strokes take `var(--role)` or `currentColor`. Wire or remove what nothing consumes before finish; done means every defined custom property is consumed by at least one rule.
- **States:** hover, disabled, loading, error, empty. Plus real content, working controls, responsive composition, keyboard focus.
- **Browser surfaces:** the parts you did not draw still carry the design. Text selection, the caret, custom scrollbars, focus rings, underline offset, and the numerals in tabular data all ship with browser defaults that belong to no design system. Theme them from the palette. This is the cheapest signal that a page was built rather than assembled, and the one models skip most reliably.
- **Copy:** the product's own language. Controls name their action; errors name the problem and the recovery.
@@ -37,7 +40,7 @@ Surface habits:
- Sparklines, progress rings, and soft-shadowed rounded rectangles standing in for content.
- Monospace as a costume for "technical" rather than for code, data, or measurement.
- A system display face (Impact, Arial Black, the platform sans) as the display voice of an own-world page. Source and self-host a face whose character matches the approved lettering; the closest installed font is a failure, not a fallback.
- Unicode glyphs or emoji standing in for an icon system. Icons are drawn, from a real library or authored SVG, in one consistent stroke and weight.
- Unicode glyphs or emoji standing in for an icon system. Icons are drawn, from a real library or authored SVG, in one consistent stroke and weight. When a pack icon fails to fetch, take the nearest icon from the same pack, jsDelivr as the fallback CDN; drawing a replacement from scratch is an exception the user signs off on, so the one-pack promise survives a failed fetch.
- Geometric masks standing in for organic contours. A circle, polygon, or radial-gradient cutout approximating a photographic subject's edge is the cheap version of the effect and reads worse than omitting it. Derive an alpha matte from the actual image, or produce a cut-out asset.
- Light or dark picked by category. Pick it from the use scene: who, where, under what ambient light.
@@ -142,7 +142,7 @@ For each issue, tag with **P0-P3 severity** (see [Issue Severity below](#issue-s
- **[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)
- **Suggested command**: Which command could address this (from: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable design-context, /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 the [Personas reference](#persona-based-design-testing) below.*
@@ -257,7 +257,7 @@ List recommended commands in priority order, based on the user's answers:
...
**Rules for recommendations**:
- 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
- Only recommend commands from: /impeccable adapt, /impeccable animate, /impeccable audit, /impeccable bolder, /impeccable clarify, /impeccable colorize, /impeccable critique, /impeccable design-context, /impeccable delight, /impeccable distill, /impeccable document, /impeccable harden, /impeccable layout, /impeccable onboard, /impeccable optimize, /impeccable overdrive, /impeccable polish, /impeccable quieter, /impeccable shape, /impeccable typeset
- Order by the user's stated priorities first, then by impact
- Each item's description should carry enough context that the command knows what to focus on
- Map each Priority Issue to the appropriate command
@@ -11,16 +11,16 @@ A hard turn ceiling ends the run without warning; a run that ends before its con
## Input Contract
Expect: the original request; the confirmed user answers; the artifact path(s); the screenshots the parent captured, in `.impeccable/review/` (web: `desktop.png` and `mobile.png`; native: device-class names such as `phone.png` and `tablet.png`, suffixed per OS on adaptive). A screenshot path the calling brief names is authoritative when the file exists; `.impeccable/review/` is where to look when the brief names none or a named path is missing, never a filename you invent. Also expect: the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); the PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths; on a comp-led build the approved comp path (a code-led build has none; it passes the chosen decision comp as a separate critique-reference input, labeled as such, and nothing here that binds "the approved comp" binds it); on a comp-led build the build state (`.impeccable/build/state.json`), the measured spec (`.impeccable/build/spec.json`), and the diff directories `.impeccable/review/diff/hero/` and `.impeccable/review/diff/final/` (each holds `side-by-side.png`, `heatmap.png`, `regions/<id>.png` paired crops, and `report.json` with per-region scores and verdicts from `comp-diff.mjs`); and the skill's `reference/craft-floor.md` path. On a native (`ios` / `android` / `adaptive`) build the packet adds the platform reference path(s) (`reference/ios.md` / `reference/android.md`) and a line saying no detector ran: read the platform reference alongside the craft floor, judge every check in the platform's own conventions, treat the screenshots as device captures, and know your floor check is the build's only slop gate. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped.
Expect: the original request; the confirmed user answers; the artifact path(s); the screenshots the parent captured, in `.impeccable/review/` (web: `desktop.png` and `mobile.png`; native: device-class names such as `phone.png` and `tablet.png`, suffixed per OS on adaptive). A screenshot path the calling brief names is authoritative when the file exists; `.impeccable/review/` is where to look when the brief names none or a named path is missing, never a filename you invent. Also expect: the direction contract (THESIS, OWN-WORLD, STORY, FIRST VIEWPORT, FORM); the PRODUCT.md path; existing hook or detector findings; the chosen world's QUALITY BAR card paths; on a comp-led build the approved comp path (a code-led build has none; it passes the chosen decision comp as a separate critique-reference input, labeled as such, and nothing here that binds "the approved comp" binds it); and the skill's `reference/craft-floor.md` path. On a native (`ios` / `android` / `adaptive`) build the packet adds the platform reference path(s) (`reference/ios.md` / `reference/android.md`) and a line saying no detector ran: read the platform reference alongside the craft floor, judge every check in the platform's own conventions, treat the screenshots as device captures, and know your floor check is the build's only slop gate. When the harness can view images, open the screenshots, the comp, and the card first, and inventory the comp's salient elements in your own words before reading the direction contract or any builder-authored summary: a review anchored on the contract inherits whatever the builder's abstraction dropped.
## Checks, in order
0. **Evidence.** Before any other check, verify the required captures exist and every capture is valid. Required: the platform's full viewport set (web: `desktop.png` and `mobile.png`; native: one capture per shipped device class), plus every capture the calling brief names as required, a reported user viewport (`user-<width>.png`) included. Valid: no black or blank regions, content matching what the filename claims (a visit capture showing the About section is invalid), the document top visible where the file claims a full page, dimensions that make sense for the named viewport. A required capture that is absent fails exactly like one that is malformed: a viewport nobody captured is a viewport nobody inspected, and it cannot ship. When any capture fails, the whole review changes shape: return `disposition: recapture` as the first line, then one section, `recapture`, listing each missing or invalid file and what a valid capture of it shows, and stop. Never build a matrix on malformed evidence; a verdict derived from a broken capture launders the breakage into an approval, and the parent owes you a full re-review on valid captures, not a scoring round.
1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/build/state.json` exists and its `comps` (or `skipped` when a surface round locked the comp), `spec`, `plates`, and `hero` phases are `closed`; a comp-led config with no state file, or a state whose `comps` phase never closed, means the comp round was skipped and the build ran from a world description alone, a material finding that outranks craft; a phase closed with a `forced` record is disclosed as a material finding unless the user downgraded the comp in words the packet quotes; a state file whose `hero.gate.score` sits under 0.72, or a missing state file, means the reproduction ran unproven, a material finding, and `.impeccable/review/hero-repro.png` must exist either way. When DESIGN.md predates this build (an extension or redesign), it matches the built world; on a new world it is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all.
2. **Fidelity.** Start from the measurement, then judge what it cannot: read `.impeccable/review/diff/final/report.json` (and hero) first; every region scored `missing` or `contradicted` is a matrix row in that state unless the paired crop under `regions/` shows the score is wrong, and you say why; a region scored `match` still gets your eye for lettering character and material, which the numbers do not measure. Then, against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. The card governs commitment and finish, never composition.
1. **Persistence.** PRODUCT.md exists. On a comp-led build, `.impeccable/review/hero-repro.png` exists: the hero reproduction checkpoint's capture at the comp's own dimensions; its absence means the reproduction phase ran unproven, a material finding. When a seed or prior DESIGN.md predates this build, it matches the built world, and "matches" is evidence you grep, not an impression: custom properties DESIGN.md defines that no rule consumes, literals sitting a step from a defined token, geometry a named rule bans (a 999px pill against a Slightly Soft rule). Each hit is a material finding under the craft floor's token rule, and an approved comp excuses none of them: the comp rules composition; the world rules material. On a new world with no seed, DESIGN.md is written after this review by the documenter, so its absence here is not a finding. When comp-round comps exist under `.impeccable/mocks/`, an approval record exists too: the surface brief naming the approved comp, or an `approved` flag in its sidecar. Comp-round comps with no recorded pick mean the approval point was skipped, a material finding. Files under `.impeccable/mocks/decision/` are exempt: they are the direction round's dealt hand, produced before any comp round, and imply no approval whatever the build path; a code-led build has no comp round at all.
2. **Fidelity.** Against your own element inventory of the approved comp, never against the contract's summary of it: topology, reading order, focal scale, overlaps and z-order, density, signature geometry, the primary action's treatment (a CTA the comp physically works, dissolves, or stamps is a signature element; its plain-rectangle rendition is contradicted), navigation items and icons, headline levels and scale relationships. Classify every salient element: match, acceptable adaptation, missing, contradicted, or added without approval. Three rows are mandatory in every matrix. TYPE: the display lettering's character, compression, width, weight, contrast, terminals, against the comp's; a face of a different character is contradicted however the layout matches. MATERIAL: an element rendered as flat CSS or clean vector where the comp shows painted, textured, dimensional, or photographic material is contradicted regardless of placement; medium is part of the promise. GROUND: the page field's value and temperature against the comp's, sampled from pixels on both sides when tooling allows rather than judged from memory, and read as the net on-screen result where a texture or tile paints over the base color; a ground warmer or cooler than the comp's is contradicted however faithfully the layout matches, and drift toward the rendition prior (warm cream on light grounds, blue-black slate on dark) is the direction to hunt. With no approved comp, TYPE and MATERIAL do not lapse: judge them against the contract's OWN-WORLD and the world's real materials, and treat faked physicality (CSS bevels, embossing, stamped-metal or chalk effects imitating a material the page never renders) as contradicted on its face; imitation material is the single most reliable mark of machine-made design. GROUND narrows rather than lapses: with no comp to sample, a color OWN-WORLD names is the target and the same warmer-or-cooler judgment applies; when OWN-WORLD names none, there is no GROUND authority, and the review says so in place of a verdict, because a target the reviewer invents turns the check into taste. A critique-reference comp on such a build is provocation, not spec: no element matrix, no adaptation citations, no asset obligations; its one contribution is what the image dared that the build did not, and dares worth adopting enter material_fixes as ordinary ordered fixes. An adaptation counts as intentional only when it cites the user answer, surface brief, accessibility need, or product truth that forced it; an uncited deviation is a defect. A missing signature element, a changed topology, or content added without approval fails fidelity and outranks every craft point in material_fixes. When MATERIAL is contradicted on the focal element, or contradiction is the page rather than the exception, stop ordering repairs: make the first material fix a rebuild directive naming the comp regions to re-derive and the assets to produce; a list of patches against a rejected page launders the rejection into an approval. A fix that requires producing an asset says so explicitly ("produce: <region> as a raster asset"), never phrased as a style adjustment the parent will answer with CSS. The comp is the spec for composition, topology, element inventory, density, lettering character, and material; it is not a pixel spec for semantics, accessibility, or responsive reflow, and that allowance covers translation, never replacement.
3. **Ceiling.** Against the QUALITY BAR card: name the world's native devices the build left unused, frame, depth, lettering treatment, ornament density, motion. A committed motion energy shows up as eased state changes in the shipped code; a recorded energy with zero transitions is a finding. The card governs commitment and finish, never composition.
4. **Contract, promise by promise.** First verify FORM carries the seed key the concept roll printed; a contract with no seed key, or one the parent cannot corroborate, means the roll was skipped, a material fix ahead of any craft point. Then, for each of the five blocks: does the render keep the promise? Apply the memory test to the first viewport.
5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every raster region of the spec shipped as its plate (the spec names the file; the page references it; the region's diff row is not `missing`), not a gradient, an inline SVG, or a many-vertex `clip-path` standing in for it, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind a wash is a compliance token, not a shipped material, and the detector's `buried-raster` and `organic-clip-path` findings in the packet are material fixes.
5. **Truth.** Demonstration data authored and labeled synthetic; no invented commercial claims; unanswered claims present as marked placeholders, not omissions. Every image-native region of the approved comp shipped as a real asset, not a gradient standing in for one, and every produced asset visibly present in the screenshots; an asset applied at near-zero opacity or buried behind other paint is a compliance token, not a shipped material.
6. **Floor.** Read the craft floor's Refuse list and hold the screenshots against it: kickers and eyebrows, hard offset shadows outside a neobrutalist world, glyph icons, system display faces, gradient text, side stripes, and the rest. A banned element is a material fix even when it matches nothing in the comp: the builder loaded the same ban before writing it, and fidelity to a comp cannot authorize what the floor refuses. The parent's hook findings cover this mechanically where hooks run; this check exists because hookless harnesses reach you with none, and the last two live sessions shipped five kickers past a reviewer that never looked.
Do not run a second detector pass; mechanical findings belong to the parent's hooks.
@@ -0,0 +1,93 @@
# Design Context
Loaded by `/impeccable design-context`. Owns the design interview record, the document built from it, and its portable form. The interview itself is created by `/impeccable document` seed mode; this command is everything afterwards.
## Where it lives
One store, under the project root:
```text
.impeccable/design-context/
context.json the chat half of the interview: product, audience, brand, interview
answers.json the questionnaire's decisions
assets/ brand files the user supplied
fonts/ font faces the user uploaded
cue.png the chosen cue image, copied at submit
runtime/ session.json, journal.jsonl, draft.json (local, gitignored)
exports/ the written-out forms (local, gitignored)
```
`.impeccable/visual-cues/` is separate on purpose: it is the generation workspace, regenerable and gitignored, and the document no longer depends on it. The store is the user's own record and is theirs to commit.
## No argument
Report status in two lines, then act:
- Whether `answers.json` exists, and when it was last written.
- Whether a draft is waiting (`runtime/draft.json`), whether DESIGN.md is seeded, and whether a session is live (`runtime/session.json` naming a running process).
With answers on disk, do `open`. Without them, say the design context is created by the questionnaire and offer `/impeccable document`. Never start the questionnaire unasked.
## open
Reopen the document, live for edits.
Run `node .claude/skills/impeccable/scripts/picker-server.mjs --doc` from the project root as a foreground command and parse its `PICKER_URL` line. Open it and wait exactly as [visual-cues.md](visual-cues.md)'s launch paragraph does: its harness-browser ladder (in-IDE browser first, then another browser tool, then the system opener, then telling the user the URL) and its wait-on-the-foreground-process rule. Skip everything earlier in its Step 7: the cue announcement and the `modes` and `context` writes belong to a run that is generating cues, and this one is not.
Then enter the document edit loop below. The process exiting is the signal:
- `DOC_SESSION_ENDED` and exit 0: the document was closed. Say so in one line; the loop is over.
- Exit 2: it timed out or was never opened. Say it can be reopened with the same command, and never relaunch unprompted.
- Exit 1: no interview exists. Route to `/impeccable document`.
## edit
Re-run the questionnaire over the previous answers.
Say in one line what it will do before launching, and settle DESIGN.md in the same breath, because a new run replaces the seed the last one produced: *"This re-runs the questionnaire with your previous answers filled in. When you finish, I will refresh DESIGN.md from the new answers. Refresh it, overwrite it, or merge by hand?"* That is the whole consent for this run; do not ask again afterwards.
Then run `node .claude/skills/impeccable/scripts/picker-server.mjs`, using the same launch ladder and wait rule as `open`. Prefill happens on its own: an unfinished run resumes from its draft, a finished one loads its answers, and `--fresh` starts blank. Cues and `context.json` already exist from the previous run, so do not regenerate cues and do not repeat Step 7's pre-launch writes.
On exit 0, go to [document.md](document.md) Steps 5-6 and write the seed from the new `answers.json`, honoring the choice made before launch. On exit 2, nothing was answered and nothing changed.
If `.impeccable/visual-cues/cues.json` is missing, the questionnaire cannot run: its palette screen loads the dealt cues and the built-in seeds together and neither arrives without that file. Say so and offer a full `/impeccable document --seed` run instead.
## export
```text
node .claude/skills/impeccable/scripts/design-context-export.mjs [--out DIR] [--no-assets]
```
Writes two files and prints an `EXPORTED` line for each. Tell the user what each is for, in one line each:
- `design-context.md` is the design context as one readable document. It is what to hand another tool, another agent, or a collaborator who needs to follow this design.
- `design-context.bundle.json` is the same context in a form `/impeccable design-context import` reads, including the files the user supplied.
Do not read the export back into the conversation; the user asked for a file, not a recitation.
## import
```text
node .claude/skills/impeccable/scripts/design-context-import.mjs <bundle.json> [--design skip|write] [--force]
```
It refuses a project that already has a design context unless `--force`, and refuses while a document is open either way. Report what it prints:
- `DESIGN_MD carried` with a DESIGN.md already here: ask whether to refresh it from the imported context, overwrite it, or merge by hand, then act.
- `DESIGN_MD carried` with none here: offer to write it (`--design write`) or to re-seed from the imported answers through [document.md](document.md) Steps 5-6.
- `DESIGN_MD absent`: say the bundle carried decisions but no design document, and offer to seed one.
Then offer `open`.
## The document edit loop
The document is a working surface. Follow [visual-cues.md](visual-cues.md)'s "The document edit loop" section; it is the canonical contract for polling, the event kinds, and the reply commands. Two things to hold on to while you are in it:
- **The session is the only writer of the store.** Never edit `answers.json` or `context.json` yourself while a session runs. Values you settle travel on your reply, through `--answers` or `--context`. DESIGN.md and PRODUCT.md are yours to write directly.
- **A `save_batch` is already applied.** The user's values are in the store before you hear about them. Your work is the prose those values leave stale, in whichever document the event's `downstream` names.
## Pitfalls
- Never poll `answers.json` while a server runs. The process exiting is the signal.
- Never drive the questionnaire yourself. The answers are the user's, and a run you filled in is a run they did not make.
- Editing in the document changes values that are already there. A field the interview never captured is added by asking through the document's own request control, not by this command.
+114 -21
View File
@@ -64,6 +64,7 @@ Omit irrelevant sections rather than filling them with invented rules. Put respo
## When to run
- New-work found a coherent incumbent visual system but no `DESIGN.md`.
- New-work paused before its direction roll on a project with no `DESIGN.md` and the user accepted the seed questionnaire recommendation; run seed mode.
- The first implementation of a new world is complete and its provisional decisions need to be carbonized.
- An existing `DESIGN.md` is stale (the design has drifted).
- Before a large redesign, to capture the current state as a reference.
@@ -73,9 +74,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. Ensure PRODUCT.md exists, then reuse new-work's visual-world workshop and write its directional DESIGN.md seed. Re-run in scan mode once there's code.
- **Seed mode**: the project is pre-implementation (fresh init, nothing built yet). Decide first whether the browser questionnaire can run, gather any existing brand assets, interview in chat (three named references and one anti-reference when the questionnaire will run; five high-level answers when it will not), then write a seed DESIGN.md marked `<!-- SEED -->` that carries every decision the interview and the questionnaire made. 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` requests new-work's world workshop, but it does not authorize replacing coherent code: when an incumbent system exists, offer scan mode or route an explicit identity-replacement request through new-work.
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 on a pre-implementation project, but it does not authorize replacing coherent code: when an incumbent system exists, offer scan mode or route an explicit identity-replacement request through new-work.
## Scan mode (approach C: auto-extract, then confirm descriptive language)
@@ -308,7 +309,7 @@ The `html` and `css` fields must be **self-contained, drop-in snippets** that re
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.
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.
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. This is about how a snippet ships, not about which family the project draws from: when the design names an icon set, keep using that set's glyphs and paste their path data in.
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.
6. **Scoped class names.** Prefix every class with `ds-` (e.g. `ds-btn-primary`, `ds-input-search`) so component CSS doesn't collide with other components' CSS in the same shadow DOM.
@@ -349,46 +350,138 @@ Your own write is the freshest source; subsequent commands in this session don't
## Seed mode
For projects with no visual system to extract yet. Produces a user-chosen visual-world scaffold, not a fabricated token spec.
### Step 1: Route through new-work's workshop
For projects with no visual system to extract yet. Produces a minimal, user-chosen scaffold, not a fabricated token spec.
PRODUCT.md is the prerequisite. If it is missing, load [init.md](init.md) and complete its product interview first. Do not create a visual identity without durable product context.
If PRODUCT.md exists, load [new-work.md](new-work.md) and resolve visual authority. Seed mode requires a concrete first surface: use the target the user named, or ask what they want to make first. Run new-work's **Create or replace the visual world** flow, then **Commit the world**, so the visual world and its first expression are chosen together. Stop after the directional DESIGN.md seed and surface brief; do not implement. A structured simulated user counts as the user and must get the same choice.
### Step 1: Decide the path, confirm seed mode, and ask for assets
If new-work already completed the workshop in this session, use its chosen direction directly. Do not ask again.
The browser questionnaire asks color strategy and motion per surface and picks concrete typefaces and a type scale by eye, so whether it will run decides what the chat interview may ask. Decide the path **before the first question**, never after the interview:
### Step 2: Write seed DESIGN.md
- **The harness has native image generation** (Codex's `image_gen`, an equivalent MCP tool, or similar): the questionnaire path; the cues are generated directly at Step 4, no setup needed. This branch wins even when `.impeccable/.env` already holds an `IMAGE_GEN_API_KEY` or an earlier run in another harness left a wrapper script behind; those are fallbacks for keyless harnesses, not the preferred path. A native tool that **cannot generate** (zero credits, failed auth) counts as absent: fall through to the next branch without asking, and mention the swap in the final report.
- **No usable native path, key already in `.impeccable/.env`**: the questionnaire path, with no pause and no questions. Load [image-api.md](image-api.md) and use its shipped wrapper; it pre-answers everything this path has ever stopped to ask, including which provider the key belongs to.
- **No usable native path, no key**: pause. STOP and call the AskUserQuestion tool to clarify. Ask whether the user wants generated visual cues to pick a palette by eye. *"I can generate a few small palette-and-mood images so you choose a direction visually instead of from descriptions. That needs an image-generation API key (FLUX and Google Nano Banana are supported out of the box; other providers work too), stored as `IMAGE_GEN_API_KEY` in `.impeccable/.env`. Add one, or skip straight to the chat interview?"* If a key arrives, write it to `.impeccable/.env` together with `IMAGE_GEN_PROVIDER` (`bfl` for FLUX, `gemini` for Nano Banana, the provider's own name for anything else; when the user does not say, let the wrapper infer it from the key). Confirm that file is listed in the project's `.gitignore` (add it if missing; a committed key is a leak), then load [image-api.md](image-api.md). Its shipped wrapper is the whole integration for the built-in providers; only a provider it does not know earns the project-local wrapper that file specifies. A key arriving makes this the questionnaire path.
- **The user opts out, or no key arrives**: the interview-only path. The assets ask below, the five questions in Step 3, then Steps 5-6 from the interview alone.
Use the canonical section order from Scan mode. Populate the selected workshop direction and leave unresolved implementation facts as honest placeholders. The seed commits a world and its invariants; it does not pretend implementation tokens already exist.
Then confirm seed mode and ask for assets, framed for the path:
Lead the file with:
- **Questionnaire path**: *"There's no existing visual system to scan. You'll pick the visual direction by eye in a browser questionnaire; before I generate its options, three quick things. First: if you have any visual assets (a logo, reference or product images, moodboards), drop them in or point me at the files. They're extra context that makes the first DESIGN.md seed more accurate. You can re-run `/impeccable document` once there's code, to capture the real tokens and components. OK?"*
- **Interview-only path**: *"There's no existing visual system to scan. I'll ask five quick questions to seed a starter DESIGN.md. First: if you have any visual assets (a logo, reference or product images, moodboards), drop them in or point me at the files. They'll ground the questions in what you already have. You can re-run `/impeccable document` once there's code, to capture the real tokens and components. OK?"*
Also glance for assets already in the project (`assets/`, `public/`, `brand/`, image files at the root); name anything found so the user can confirm it's relevant. Assets are optional: one ask, then proceed with whatever arrived.
If the user prefers to skip entirely, stop. No file.
### Step 2: Read the assets
Look at every asset provided (attached in chat or a file path) and record what it tells you, before writing the questions:
- **Logo**: sample the exact colors, note letterform character (geometric / humanist / serif) and temperature.
- **Reference / product images**: density, palette, type feel; what the user is drawn to.
- **Moodboards**: recurring hues, textures, era, register cues.
On the questionnaire path, the files themselves also feed the design context document the picker shows after the last question. When the user provided actual files (a logo, a mood board, a reference image), copy each one into `.impeccable/design-context/assets/`, keeping its filename. Record every staged file for Step 4's context write: it becomes an object entry in `context.json` `context.assets`, `{ "file": "<filename>", "kind": "logo" | "moodboard" | "reference", "note": "<one-line observation>" }`, where the note is what this step read off it. An observation with no file behind it stays a plain string entry, as before. On the interview-only path, stage nothing; the observations feed the questions and the seed alone.
These observations exist to sharpen Step 3. **No assets: skip straight to Step 3** with generic options.
### Step 3: The interview
Group each path's questions into one `AskUserQuestion` interaction. Options must be concrete. Keep skill vocabulary (seed, register, anti-reference) out of question text; ask for the thing in words the user would use. Ask like a magazine editor profiling the brand: curious and narrative, drawing out the feel the surface should carry.
**Questionnaire path: two questions, nothing more.** With Step 1's assets ask these are the whole chat interview; the questionnaire asks everything else by eye.
1. **Three named references.** Brands, products, printed objects. Not adjectives. When Step 2 produced observations, ground candidate names in them (references drawn from the moodboard's era).
2. **One anti-reference.** What the product should NOT feel like. Also named.
**Do not ask about color, typography, or motion here; the questionnaire owns them.** It asks color strategy and motion per surface and picks concrete typefaces and a type scale, so a chat answer would be asked again by eye and one of the two would be thrown away. Both answered, go straight to Step 4.
**Interview-only path: five questions.** When Step 2 produced observations, ground the options in them: offer the logo's sampled color as a hue anchor in Q1, a type direction that matches the letterforms in Q2, candidate named references drawn from the moodboard's era in Q4. The user should recognize their own material in the choices.
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
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)
- 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
4. **Three named references.** Brands, products, printed objects. Not adjectives.
5. **One anti-reference.** What it should NOT feel like. Also named.
### Step 4: Launch the questionnaire (questionnaire path only)
**Interview-only path: skip this step.** Go to Step 5 and seed from the answers alone. Step 1 already settled the capability question; do not re-open it here.
On the questionnaire path, **stop and load [visual-cues.md](visual-cues.md)** and follow its pipeline; it owns everything from the one-line user announcement and the persona palette studio through generation, `cues.json`, and the picker pause. Do not restate its mechanics here or in chat. The picker's exit is the handoff: when the server exits 0 and `.impeccable/design-context/answers.json` lands, come back here and run Steps 5-6 with that file in hand.
### Step 5: Write seed DESIGN.md
Use the canonical section order from Scan mode. Populate what the interview, the assets, and the questionnaire answer; leave the rest as honest placeholders. The seed is a scaffold, not a fabricated spec, but a decision the user actually made in the picker is real and belongs in the file at full strength.
Mark the file as a seed with this comment as the first line of the markdown body, immediately after the frontmatter's closing `---` (the frontmatter must open the file or token parsers will not see it):
```markdown
<!-- SEED: established with the user before implementation; re-run /impeccable document once there's code to capture the actual tokens and components. -->
```
Per-section guidance in seed mode:
**Two seeds exist**, and which one you write depends on whether Step 4's picker ran:
- **Overview**: the chosen design thesis, layout behavior, material character, imagery stance, motion grammar, and reusable signature. Keep the selected first-surface expression in its surface brief; do not promote its composition into the global world.
- **Colors**: the selected palette strategy and roles. Include values only when the user, an existing asset, or new-work's exploration established them; otherwise mark them `[to be resolved during implementation]`.
- **Typography**: the selected type character and role relationship. Include font names only when established; otherwise mark the pairing `[to be resolved during implementation]`.
- **Layout**: the selected spatial grammar and responsive behavior, without pretending exact measurements are settled.
- **Elevation & Depth**: the selected material and depth behavior, stated as an invariant rather than inferred from a generic preset.
- **Shapes**: the selected form and corner language.
**Interview-only seed** (the user opted out of generation, or no key arrived). Per-section guidance:
- **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. Colors sampled from a provided logo are real; include them with exact values and note the source. Everything else stays `[to be resolved during implementation]`; those sampled anchors are the only hex this seed may carry.
- **Typography**: the direction the user picked (e.g. "Serif display + sans body"). No font names yet: `[font pairing to be chosen at implementation]`.
- **Layout** and **Shapes**: omit unless an asset or answer established a spatial or form preference; do not invent grids or corner language pre-implementation.
- **Elevation & Depth**: inferred from motion energy. Restrained/Responsive → flat by default; Choreographed → layered. One sentence.
- **Components**: omit entirely; no components exist yet.
- **Do's and Don'ts**: record the durable guardrails confirmed during the world choice, not task-local refusals.
- **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 `.impeccable/design.json` sidecar in seed mode for the same reason: nothing to render.
This seed writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet.
### Step 3: Confirm
**Questionnaire seed** (`.impeccable/design-context/answers.json` exists from this run). The user answered every screen by eye, so the seed carries their answers as decisions, not directions. Read the answers file plus the picked cue's palette entry in `.impeccable/visual-cues/cues.json` (`palette-source` names it), and map:
- **Frontmatter**: `name` and `description`, plus real `colors` (the four `palette-*` hex values under descriptive slugs; these are picked, not sampled) and real `typography` (`font-heading` and `font-body` are exact family names; give each role its family and weight intent, leave sizes for implementation). Derive the two text inks and record them under `colors` too: one near-black and one near-white, the pair the picker's previews already set their text in over these exact surfaces, each holding 4.5:1 against the grounds it will carry copy on, so a builder needing body-text contrast finds ink in the system instead of inventing a fifth color. Still no `rounded`, `spacing`, or `components`: the corner and spacing answers are qualitative, and nothing is built.
- **Overview**: Creative North Star and philosophy phrased from the questionnaire's color-strategy and motion answers plus the chat references; reference the user's anti-reference directly. Name the chosen surfaces (`surface-modes`) and what each is for. Movement stays here, after the North Star, but the questionnaire asks it of a landing page and a portfolio only, so write what the keys support:
- `motion-energy-<mode>` keys present, all agreeing: one philosophy sentence for the product, as before.
- Keys present and disagreeing: one sentence per surface, named (*"The landing page moves on state change only; the portfolio stages entrances and drives sequences on scroll."*). The bare `motion-energy` is the leading one of the two.
- **No `motion-energy` key at all**: the run has neither of those surfaces, so movement was never asked. Say nothing about it, and do not fill the gap from the register; this path's chat interview never asked about motion, so there is nothing to borrow. The next Scan-mode run reads the real transitions out of the code.
- **Colors**: the four roles with their picked hex, noting the cue they came from. Name the chosen cue by its slug, and note that the unpicked cue images stay in `.impeccable/visual-cues/` for later art direction. `color-strategy` becomes the Named Rule. When surfaces differ (`color-strategy-<mode>` keys), state each surface's strategy and which surface leads (the bare key's owner).
- **Typography**: the real pair by name, the pairing's character, and the type scale as a rule: `type-scale` names it, `type-scale-ratio` is the ratio (e.g. *"Major third: each heading step is 1.25x the last"*). Base size and exact steps stay `[resolved at implementation]`. A `font-heading-source` / `font-body-source` value means a user-provided font file; record where it lives.
- **Layout**: `boundary-style` (how sections separate) per surface when the `-<mode>` keys differ, plus `layout-structure` (how pages are composed), which the questionnaire asks of a landing page and a portfolio only. No invented grids beyond what the answers state.
- `layout-structure` present: one bare key and no `-<mode>` keys, so state it as a rule for the whole product rather than per surface.
- **No `layout-structure` key at all**: the run has neither of those surfaces, so composition was never asked. Say nothing about how strict the grid is, and let `boundary-style` carry the section.
- **Elevation & Depth**: `depth-style` per surface, stated directly; the questionnaire answered this, so do not re-infer it from motion energy.
- **Shapes**: `corner-style` per surface.
- **Components**: still omit; nothing exists yet.
- **Do's and Don'ts**: the interview-only guidance, plus a Do fixing the icon source: every icon comes from the chosen pack (`icon-pack-name`, license, URL), no mixed sets.
Per-surface answers come back for every chosen surface, defaults included, and a difference between surfaces is a decision the picker enforced, not an inconsistency to smooth over (the option lists differ per surface). Where all surfaces agree, state the answer once for the product. `motion-energy` and `layout-structure` are the two keys that can be missing entirely, since movement and composition are asked of a landing page and a portfolio only; [visual-cues.md](visual-cues.md) has the full contract.
Both seeds skip the `.impeccable/design.json` sidecar: nothing to render yet. Real tokens for sizes, spacing, and components land on the next Scan-mode run.
### Step 6: 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. On the questionnaire path, add one line: the interview is kept, and `/impeccable design-context` reopens the document, re-runs the questionnaire over these answers, or writes the context out for another tool. See [design-context.md](design-context.md).
Your own write is the freshest source; no reload needed.
When the questionnaire ran, the confirm is not the end of the turn: the design context document in the user's tab is live for edits through the session the picker forked. Follow the document edit loop in [visual-cues.md](visual-cues.md): poll, apply `edit_request`s to this same DESIGN.md, reply. A color the user changed in the tab before your seed write is already in `answers.json`; one changed after arrives as a `save_batch` event, its value already in the store and its description in DESIGN.md yours to bring in line.
## 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.
@@ -0,0 +1,54 @@
# Image API Path (keyless harnesses)
Loaded when a pipeline needs image generation and the harness has **no usable native tool**. It answers, upfront, every question an agent has historically stopped to ask on this path; with a funded key in place, a run through this file asks the user nothing and debugs nothing.
**This file never overrides a working native tool.** A harness with native image generation skips this path entirely; the precedence rule lives where the path is picked ([visual-cues.md](visual-cues.md) Step 3, [document.md](document.md) seed Step 1), not here. One refinement to that rule: a native tool that **cannot generate** (zero credits, failed auth, disabled account) counts as absent. Fall through to this path silently and mention the swap in the final report; do not stop to ask which path to use. A stopped question costs hours when the user is away; the swap costs nothing.
## The setup, already answered
- **Key**: `IMAGE_GEN_API_KEY` in `.impeccable/.env` at the project root. The wrapper reads that file itself; never `source` it, never export the key by hand, never rename the variable. Never delete or truncate that file either, cleanup included: it is the user's stored credential, not run output, and a wiped key turns the next run's silent keyless path into a stalled question.
- **Provider**: `IMAGE_GEN_PROVIDER` in the same file: `bfl` (FLUX / Black Forest Labs) or `gemini` (Google Nano Banana), both built into the wrapper; any other value routes to a custom wrapper (below). Loose spellings from earlier runs (`flux`, `google`, `nano-banana`) normalize to the built-ins, and a missing provider line is inferred from the key's shape (Google keys start with `AIza` or `AQ.`; anything else runs as `bfl`), so a misworded or absent line is never a reason to stop and ask.
- **Wrapper**: `.claude/skills/impeccable/scripts/image-gen.mjs`, shipped with the skill. Do **not** write a new wrapper for a built-in provider, edit this one, or fall back to raw `curl`/`fetch` calls; every known failure mode below is already handled inside it. Wrappers left by earlier runs under other names (`flux-gen.mjs`, project-local copies) are superseded by the shipped one.
- **No smoke test.** A funded key plus the shipped wrapper is a working path; the first real generation is the test, and the wrapper turns transient failures into internal retries rather than failed calls.
## The command
One command regardless of provider; the provider switch happens inside the wrapper, so calling pipelines never branch on it:
```text
node .claude/skills/impeccable/scripts/image-gen.mjs --prompt "..." --out /abs/path.png \
[--ref /abs/reference.png] [--width 1408] [--height 1408]
```
Run it from the project root (that is where it finds `.impeccable/.env`). It prints the absolute output path on success and exits non-zero with the error on stderr. `--ref` switches text-to-image to image-to-image where the provider supports it.
## Provider facts, so no one re-derives them
**bfl** (FLUX):
- **Models**: `flux-pro-1.1` text-to-image; with `--ref`, `flux-kontext-max` image-to-image (reference sent as base64, aspect ratio pinned 1:1).
- **Size**: BFL accepts 256-1440 px in multiples of 32. The default `1408x1408` is the largest clean square; passing `--width 1500` fails validation locally, before any credit is spent. Output is always square unless you pass unequal values.
- **Concurrency**: BFL allows 24 active tasks (`flux-kontext-max`: 6). A six-spawn wave fits both caps; do not throttle it.
- **Protocol**: submit returns a `polling_url`; the wrapper polls exactly that URL (the global endpoint requires it) and downloads the signed result URL immediately, inside its 10-minute expiry. None of this is the caller's concern.
**gemini** (Nano Banana):
- **Model**: `gemini-3.1-flash-image` by default; a `IMAGE_GEN_MODEL` line in `.impeccable/.env` overrides it, and the wrapper retries the `-preview` sibling once when Google's model naming drifts.
- **Size**: the wrapper pins aspect ratio 1:1, so output is always square; Gemini picks the pixel size for its tier (1024 by default) and ignores `--width`/`--height`. A 1024 square passes the pipelines' square gate as a "nearest supported square"; do not upscale it.
- **Format**: Gemini frequently returns JPEG bytes regardless of the `--out` filename; the wrapper converts them, so the written file is always a real PNG. Do not re-check or re-convert it.
- **Protocol**: synchronous; one call returns the image inline, no polling. Moderation arrives as an imageless response, which the wrapper turns into a clear error, not as an HTTP failure.
- **Text rendering**: Gemini paints text well and eagerly, so a prompt that mentions codes, numbers, or labels tends to get them rendered onto the image (hex codes come back as a printed swatch strip). The calling pipeline's prompt rules ([visual-cues.md](visual-cues.md)'s HERO PROMPT skeleton) keep those out of prompts; follow them, not looser habits from other models.
**Any other provider**: the user names it, so the integration cannot be pre-shipped. Write `.impeccable/image-gen.mjs` implementing the same CLI (same flags, print the absolute output path on success, non-zero exit with the error on stderr, transient retries handled inside), set `IMAGE_GEN_PROVIDER` to the provider's name, and the shipped wrapper delegates to it automatically; calling pipelines keep using the shipped command unchanged. Build it from the provider's API docs, and give it square output; do **not** modify the shipped wrapper to add the provider inline.
## Failures and what they mean
The wrapper retries transient failures internally (DNS, network blips, 429 back-pressure, poll hiccups, expired-download re-fetches), so an error that reaches the caller is real and carries its own explanation:
- **"out of credits"** (bfl, HTTP 402): a human must top up at dashboard.bfl.ai. Report it and stop this path; retrying is pointless, and so is asking the user to choose an alternative that does not exist.
- **"quota or rate limit exhausted"** (gemini, HTTP 429 after the wrapper's own retries): the key's plan is out of headroom. Report it and stop this path; the fix is billing, not retries.
- **"rejected the key"** (either provider): the key in `.impeccable/.env` is wrong or revoked. Report it; do not mint debugging sessions around a dead key.
- **Moderation** ("Content Moderated" / "Request Moderated" / "Prompt was moderated"): the prompt tripped the provider's filter; rewording the prompt is the fix, within the caller's normal generation budget.
- **"cannot resolve"**: the wrapper already tried the system resolver, `dig`, Google, and Cloudflare. **Never debug DNS beyond this**: no `/etc/hosts` edits, no new resolvers, no rewriting the wrapper to use `fetch()` (sandboxed harnesses block the default resolver for these hosts; the wrapper pins IPs via `curl --resolve` for exactly that reason). Report the failure and let the parent decide.
Subagents on this path inherit the generation-failure budget from their own pipeline ([visual-cues.md](visual-cues.md)'s three-call budget, or the calling pipeline's equivalent); the wrapper's internal retries do not count against it, only whole failed invocations do.
+26 -47
View File
@@ -34,19 +34,21 @@ Inherit its world and composition. Resolve only the new purpose, content, hierar
Keep the visual system fixed. Derive five to seven materially different structures from the content, task, and user behavior, ordered by resonance. For a genuinely open whole page, screen, or flow, run:
`node "<skill-base-dir>/scripts/concept-seed.mjs" --scope surface --mode <mode>`
`node .claude/skills/impeccable/scripts/concept-seed.mjs --scope surface --mode <mode>`
The script deals three of your structures; the dice pick which three reach the user, breaking the ranking rut while the user keeps a real choice. Present them on the decision page as full cards of equal salience, the dealt lead under kicker THE ROLL, with steer and re-roll; the user locks one. No canon card and no pick card at surface scope: the world is settled, so every card visualizes composition, not identity. With image generation and a comp-led default (`.impeccable/config.json`; the build-path paragraph below), each card declares a `comp` under `.impeccable/mocks/decision/`, generated after serving, in reading order, under [visualize.md](visualize.md)'s comp discipline. Anchor each comp on the established identity: pass a screenshot of a representative existing page as a reference image (the harness image tool's input image, or `generate-image.mjs --ref`) with a prompt that leads with the new surface's structure and names DESIGN.md's palette, type, and component character; prose paraphrases of a design system drift, pixel references do not. Without image generation, or under a code-led default, each card carries a `wireframe` schematic (`serve-question.mjs --schema`) the page draws itself. Locking a card is the approval and sets the build path: a locked comp builds comp-led with that comp as the approved comp, discharging [visualize.md](visualize.md)'s three-option round with no second approval point; a locked wireframe builds code-led, its ambition carried by the direction contract. Never run the script for a local extension or a precisely specified narrow request; shape those directly.
### Create or replace the visual world
1. Name the product's unique mechanism in one sentence, the audience's real scene, its cultural home, and what this first surface must prove. Note the page this category always ships and its predictable opposite; both are the rut, kept out of the seven-candidate list. A brief that paints its own picture, a product name, a titled artifact, a governing metaphor, adds its literal reading to the rut: spend at most one candidate on it and derive the rest from elsewhere in the audience's world.
2. From that cultural world, list seven concrete visual systems, artifacts, places, or rituals the audience knows by heart, each with one line on why it resonates and can carry the mechanism, ordered by resonance. The audience's world includes its graphic and screen traditions, not only its physical objects: the notation, publications, identity programs, data graphics, and interfaces it reads daily. A nameable abstract system (a school of poster, a documentation standard) is as concrete a candidate as any artifact. What would this thing look like as a physical object; what did its world look like before the web? Near-duplicates count once. When more than three of the seven share one material family, the derivation stopped at the subject's most obvious artifact; dig until the list spans at least three families.
3. Turn that material into complete directions: each joins a reusable visual world to a concrete first-surface experience.
4. Run `node "<skill-base-dir>/scripts/concept-seed.mjs" --scope direction --mode <mode>` and follow what it prints. No substitute, no skip: on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure; the roll is what keeps every run from converging on the category default. The script assigns the direction to build and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity. Losing to strong grounded material is a valid outcome; beating a thin or tool-monoculture list is the point. Close with a verdict per challenger, decided before any borrowing: wins (beats the assigned direction on both axes; becomes the build candidate), competitive (holds one axis; stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a lifted motif is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen.
5. Present one direction, fully committed and already raised by the hand it beat, raises visible as named lines: world, first viewport, visitor path, signature interaction, cross-surface reach, honest risk. Route each challenger by verdict: winning and competitive challengers are full alternates with their QUALITY BAR cards and one-line case; declined challengers render demoted, compact and quiet, each carrying its verdict and what the direction kept from it, never full-size, never silently dropped, still adoptable on request. The verdict informs the user's choice, never pre-empts it; the demoted row is the hand's proof of judgment. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLES PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often where most runs in this category land, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: a lineup of your candidates hands selection back to a taste function and invites the safest card. The pick never takes the lead position; when the dice assign your top candidate there is no pick card, and the assigned card notes it topped your list. Add re-roll with an optional one-line steer, in three registers: plain (a fresh hand, same spread), safer (your remaining conventional grounded candidates plus the canon against named competitors), bolder (foreign forms only, at full commitment). The register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool, whose option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit last; declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel.
**When DESIGN.md is missing**, pause before any of the work below. STOP and call the AskUserQuestion tool to clarify. Ask once: recommend `/impeccable document --seed`, the guided interview plus browser questionnaire, because a world established from the user's own choices beats one assigned to them; offer the skip in the same breath. *"There's no design system on record yet. Before I invent directions for [the requested surface], I can run a short interview and browser questionnaire so the visual world is built from your choices; I recommend it. Or skip it and I'll roll a direction now."* The pause is enforced by the script, not by your discipline: with no DESIGN.md on record, the step 4 roll refuses to deal and prints this same offer until the questionnaire has been put to the user. Do not roll first and offer the questionnaire after, the assignment anchors the conversation; do not run document on the user's behalf without their yes. **Accepted:** seed mode in [document.md](document.md) owns the interview and the picker; follow it. When the seed DESIGN.md is written, resume at section 1: the seed world now reads as an established world, so inherit it; the direction roll below no longer applies, and the optional surface roll remains. Through every phase that follows, comp-led included, the comp rules composition; the world rules material, and this seed is that world. **Skipped:** re-run the step 4 command with `--seed-declined="<the user's verbatim skip answer>"`; the flag carries the user's own words as evidence of the skip, and the roll deals with nothing in step 4 softened. The build request itself is never a skip answer, a bare flag refuses again, and fabricating or paraphrasing the quote is a contract violation; only words the user typed after being asked qualify. When DESIGN.md exists, there is no pause and no flag.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Resolve collisions field by field: preserve every user- or brief-pinned constraint. In dimensions the brief leaves open, the assignment still binds through its topology, controls, state vocabulary, and ritual; when only its materials conflict with a pinned visual direction or PRODUCT.md brand commitment, translate that material expression and name the translation in the presented direction. A look mismatch is not grounds to re-roll. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node "<skill-base-dir>/scripts/serve-question.mjs" --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
1. Name the product's unique mechanism in one sentence, the audience's real scene, its cultural home, and what this first surface must prove. Note the page this category always ships and its predictable opposite; name both as the rut and keep them out of the seven-candidate list. A brief that paints its own picture, a product name, a titled artifact, a governing metaphor, adds its literal reading to the rut: spend at most one candidate on it and derive the rest from elsewhere in the audience's world.
2. From that cultural world, list seven concrete visual systems, artifacts, places, or rituals the audience knows by heart, each with one line on why it resonates and can carry the mechanism, ordered by resonance. The audience's world includes its graphic and screen traditions, not only its physical objects: the notation, publications, identity programs, data graphics, and interfaces it reads daily; a nameable abstract system (a school of poster, a documentation standard) is as concrete a candidate as any artifact. What would this thing look like as a physical object; what did its world look like before the web? Near-duplicates count once. When more than three of the seven share one material family, the derivation stopped at the subject's most obvious artifact; dig until the list spans at least three families.
3. Turn that material into complete directions: each joins a reusable visual world to a concrete first-surface experience.
4. Run `node .claude/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. With no DESIGN.md the script refuses to deal until the seed pause above has run; after the user's explicit skip, and only then, re-run it with `--seed-declined="<their verbatim skip answer>"` carrying the user's own words. This step has no substitute and no skip condition (the seed pause above exits this subsection before any direction work starts; it is not a skip of the roll): on a new or replacement world, writing artifact code before this script has run and its assignment is acknowledged is a contract violation, whatever the harness, the model, or the time pressure, because the roll is the mechanism that keeps every run from converging on the category default. The script assigns which direction gets built and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, and clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity; losing to strong grounded material is a valid outcome, and beating a thin or tool-monoculture list is the point. The weighing closes with a verdict per challenger, decided before any borrowing is considered: wins (beats the assigned direction on both axes; it becomes the build candidate), competitive (holds one axis; it stays a full alternate), or declined (loses both). A declined challenger is not spent: name the one discipline of its system the assigned direction lacks, and raise the assigned direction to match before presenting it. A donation transfers ambition and system discipline (a palette's total commitment, a grid's density courage, a form's structural honesty), never the challenger's clothes; a motif lifted from a declined world is a costume note, not a raise, and one world owns the page. Write each raise into the presented direction as its own line, named for its donor; a raise nobody can read did not happen.
5. Present one direction, fully committed and already raised by the hand it beat, its raises visible as named lines: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Alongside it, route each dealt challenger by its verdict: winning and competitive challengers are full alternates carrying their QUALITY BAR cards and one-line case, while declined challengers render demoted, compact and quiet, each carrying its verdict plus what the direction kept from it, never full-size and never silently dropped, each still adoptable on request. The verdict informs the user's choice, it never pre-empts it; the demoted row is the hand's proof of judgment, showing why the dealt worlds made the presented direction better. A hand holds at most three full-card challengers: when the roll deals more, the three strongest join the hand and the rest wait in the re-roll pool, noted in one line; dropping a challenger from the hand itself takes a named product-truth failure, disclosed. Add one card for your own top-ranked grounded candidate when it is not the assigned direction, kicker IMPECCABLES PICK, same anatomy as every card, with an honest risk line naming its familiarity when true: the strongest grounded direction is often the one most runs in this category land on, and the user deciding that trade is the point of showing it. Familiar and effective is a legitimate destination, not a failure of nerve; the pick card and the standing exit serve it at two depths. One pick card, never two, never a ranked list: the rest of your grounded candidates stay yours, because a lineup of them hands selection back to a taste function and invites the safest card. The pick never takes the lead position, and when the dice assign your top candidate there is no pick card; the assigned card notes it also topped your list. Add re-roll with an optional one-line steer, offered in three registers: plain (a fresh hand, same spread), safer (the familiar register: your remaining conventional grounded candidates plus the canon against named competitors), and bolder (foreign forms only, at full commitment). A register is the user's steering on the familiar-to-bold axis, never yours to pre-select; when the answer carries one, re-run the seed with `--register <value>` and the next `--reroll` round, and follow what it prints. A user saying "bolder" or "safer" while a direction round is open means these registers, never the bolder or harden commands. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool; the structured tool's option list carries the assigned direction, the pick, the winning and competitive challengers, and the standing exit as its last option, while declined challengers fold into the assigned option's description as their kept lines, so the raise survives the text channel too.
The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never let it soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it (the canon action, a safer-steer, or plain words asking for the familiar or competitor-like path), convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the bar, and execute the canon at full fidelity, without irony or smuggled quirk. Record a standing preference as a brand commitment in PRODUCT.md. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. Re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Present the decision visually: write an options payload with the assigned direction leading, its raised lines included; the pick card when one exists; the dealt challengers as alternates with their QUALITY BAR cards, verdicts, and kept lines; re-roll with its safer and bolder registers; steer; canon enabled; and `buildPath` carrying the recorded default with `toggle: true` whenever image generation exists (details in the build-path paragraph below). A degraded roll with no challengers still uses the page, as a single text-only card with re-roll. Give every card the same anatomy: thesis, palette, materials, first viewport, honest risk, and the challengers' case lines (`--schema` prints the exact shape); the page renders identity from these fields, demotes declined challengers to their row on its own, and a challenger's catalog image rides as labeled inspiration, never the promise of the build. Author `canonCard` too: the category standard as one honest card, same anatomy; the page keeps it subordinate, and the counterweights still bind you. Run `node .claude/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (`--schema` first for the payload shape). It daemonizes, prints the page URL and a key, and exits; open that URL for the user, in-app browser first, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. An ANSWER of `{"optionId":"reroll"}` keeps the server alive and the page open on a loading hand: rerun concept-seed with the same `--scope` and `--mode` plus `--from <seed-key> --reroll <n>` (1 on the first re-roll, counting up), build the next payload, deliver it with `--update --key <same key> --payload <file>`, then return to `--wait` on that key. Never `--start` a second server or fall back to chat here: either strands the open tab on a hand that never arrives. Exit 4 means the page closed unanswered: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may run the script without `--start` and let it auto-open and block. Never predict the fallback: run the script, and only exit code 2 from starting it routes the decision to the structured tool; that exit is the fallback, never an error to retry.
When image generation exists, every card also declares a `comp` path under `.impeccable/mocks/decision/`, the canon card included. Where the harness sandboxes its shell, start the page through the least-sandboxed command path it offers: a sandboxed shell cannot bind the board's port, and the first-attempt failure costs a retry every session. Serve the page first, then produce the comps; the page shimmer-waits per slot and the user may answer before they land. Each card's image is that direction's north-star comp at full fidelity under [visualize.md](visualize.md)'s comp discipline: the requested surface's first viewport, structure-led prompt, real product name and real content, no invented commercial claims, in that card's own palette, type character, and material world, committed all the way. Generation takes the same time at any fidelity, so an unfinished draft pays comp cost for draft quality; fairness between cards is equal fidelity in each card's own grammar, one surface, one aspect, never shared unfinishedness. The frame's aspect is the surface's own: portrait at device viewport for a native app or mobile-first surface, landscape for desktop web; the decision page adapts to either, and a phone screen comped landscape is a broken frame, not a neutral default. Produce in reading order, the assigned card, then the pick, then the full-card hand, then canon, each file written with its prompt sidecar the moment it is done, so a re-roll's spend front-loads onto the cards read first; declined challengers get no comp, their catalog thumb is their face. With parallel subagents, fan out one agent per card: each spawn is the shipped asset producer with a single-comp packet, that card's fields, PRODUCT.md, the shared frame, and the card's declared path, up to four in flight. Regenerate inline any slot still empty when its agent returns; drop without ceremony any slot still empty when the user answers. No other supervision is owed. Without parallel subagents, generate in the main thread after serving, same order, and let the harness's own generation display carry the progress; the wait for the answer follows the last file. The chosen card's comp is not spent by the choice: comp-led, it enters the comp round as compositional option one; code-led, it returns at the finish review as the critique reference, what the image dared that the build did not. Unchosen comps stay in `.impeccable/mocks/decision/` as the round's spent hand; they carry no approval and imply none. With no image generation, cards carry their identity in palette chips and facts, and that page is complete, not a lesser version; the page then also demotes every challenger's catalog art to a labeled thumbnail on its own, because salience must encode the verdict, never the accident of which cards have images.
@@ -70,19 +72,15 @@ Your measured rendition prior: warm, bookish, family, and child-facing subjects
## 5. Record the decision
Before code, record the chosen direction as a development-only contract under `## Direction contract` in the relevant surface brief. A direction contract is durable route or artifact strategy, so create or update the brief even when no other surface strategy needs persistence. Keep the contract to six short blocks and 150 words at most. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, and the seed key the script printed. Close with one more line, FINISH: the run's exit condition, verbatim "unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance". The surface brief is the reminder later agents reload across edits and sessions: a page that looks complete with the FINISH line undischarged is not done, it is abandoned at the finish line. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
Never copy the direction contract into implementation source or any browser-delivered artifact. This includes HTML or framework comments, hidden DOM, `<template>` elements, `data-*` attributes, rendered JSX or TSX output, serialized props or state, React Server Component payloads, client bundles, metadata or JSON-LD, accessibility-only text, and files served beside the artifact. A compiler or optimizer removing development metadata is not a safety boundary. Reviewers and documenters receive the contract from the surface brief.
Before code, state the chosen direction as a contract in the artifact's opening comment, five short blocks, 150 words at most, in a form that survives the production build: an HTML comment in the emitted markup, never only a templating-frontmatter comment, placed as the first child of the document's body in the root layout, never inside a slotted or child component (some compilers, Astro among them, strip a slot's leading comment while keeping deeper ones). After the first production build, grep the built output for the seed key; a contract the build erased is a contract nobody can audit. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, and the seed key the script printed. Close with one more line, FINISH: the run's exit condition, verbatim "unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance". The comment tops the artifact you re-open on every edit, the one reminder that survives a long build: a page that looks complete with the FINISH line undischarged is not done, it is abandoned at the finish line. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
On a new or replacement world, DESIGN.md is written at finish, from the built world, by the shipped documenter (section 7); a rulebook written before the build gets defended against reality instead of describing it, and hands the design-system detector an unstable target. A new world shipped with no DESIGN.md is still an incomplete run. An ordinary extension does not rewrite DESIGN.md.
Read the existing surface brief before updating it:
If the work establishes durable strategy for a route or artifact, read its existing surface brief, then update it:
`node "<skill-base-dir>/scripts/surface-brief.mjs" read <primary-target>`
`node .claude/skills/impeccable/scripts/surface-brief.mjs read <primary-target>`
`node "<skill-base-dir>/scripts/surface-brief.mjs" write <primary-target> <body-file> [related-target ...]`
After writing, read the brief once more and verify that all six contract blocks and the seed key are present before building.
`node .claude/skills/impeccable/scripts/surface-brief.mjs write <primary-target> <body-file> [related-target ...]`
Keep the brief small: scope and visitor mode; audience, job, action/task, proof/content, and constraints; chosen direction and memorable moment; unresolved decisions. Do not copy global product truth or DESIGN.md tokens into it.
@@ -92,55 +90,36 @@ For `shape`, return the selected direction to [shape.md](shape.md) and stop befo
## 6. Build with full commitment
Build the assigned direction, not a safer interpretation of it. The form supplies structure, reading order, component conventions, and native motion; the product supplies every fact. Commit every atom: nav, buttons, inputs, and links are rebuilt in the form's vocabulary, and a stock component inside a committed form is a lapse. Land the first build fully committed; the passes that follow exist to make the committed thing clear and effective, never to dilute it. In unattended work, the safe rendition is the known risk.
**The comp rules composition; the world rules material.** Composition is the comp's dominion: topology, element inventory, density, region assignment. The recorded world's system stays binding through every phase: palette roles at their recorded scale of use, type tiers, corner language, motion register. Product truth in **PRODUCT.md** outranks both, so a comp never deletes a core product answer. A genuine conflict between the comp and a DESIGN.md named rule is resolved consciously and rides to the documenter's re-record at finish: adopt the deviation into the world's record, or conform the build.
### Comp-led: the comp is a measured contract
When an approved comp exists, the comp is king, and the crown is scoped by the rule above: the comp rules composition; the world rules material. The build happens in phases. The comp is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words, and difficulty never infers a downgrade. Phase one is reproduction: rebuild the comp at its own breakpoint until a screenshot at the comp's width and height overlaps it near pixel-perfectly, materials, components, elevation, assets, and implied design language included. Exactly three concessions exist: fonts (the closest obtainable face), icons (exact match unless the user already chose an icon library), and genuine defects in the generated comp such as spelling errors. Everything else must match, and models systematically believe their HTML, CSS, and SVG recreation succeeded when it did not, so the overlap comparison is the authority, never your conviction: set the screenshot beside the freshly reopened comp image at identical dimensions after every region, never beside your memory of it, and when a region keeps losing that comparison, stop recreating it in code and produce it as a rendered asset composited into the page. The comp also outranks every written record of it: when the recorded brief or inventory commits to less than the comp shows, a softer texture, a sparser field, a sculpted plate reduced to flat CSS, correct the record upward to the comp; qualifiers like subtle, restrained, and low-contrast, and counts rounded down to a comfortable fraction, are how approved materials die between approval and build. A produced material must then survive to the screen: a texture buried under a nearly opaque color wash ships the wash, not the material, so judge every material by the screenshot beside the comp, never by the stylesheet. Every color the brief records gets that comparison by number, not by eye: sample the build screenshot's ground, dominant fields, and accents the same way each record was taken (an interior patch average where the record is an average, both end colors where the record is a gradient) and set each value against its recorded counterpart (sampled from the comp itself when the brief lacks one), and when a texture or tile paints over a base token, measure the net on-screen value, because the eye files a drifted color under the same color word and the number is what catches it. Judge the gap like a colorist, not a diff tool: a difference with a color name (warmer, grayer, darker than the record) is drift to fix, while a few digits of render and compression noise are the same color. Only when reproduction holds does phase two begin: static regions that should live become animated or interactive, reveals and motion are added, then responsiveness across the surface's devices. Where the comp does not cover the whole surface, continue building the remainder inside the comp's recorded world and design language; a component the comp never shows inherits the recorded system's corner language, line weights, and materials, and may not introduce container styles, border weights, or chrome the comp never uses.
When an approved comp exists, it is a spatial contract, not a mood board: only the user can downgrade its authority, in explicit words. Models systematically believe their HTML, CSS, and SVG recreation of an image succeeded when it did not, so the build runs as a state machine on disk whose gates measure the screen against the comp instead of asking you to remember it. Start it once, and let it tell you what is next:
`node "<skill-base-dir>/scripts/build-phase.mjs" start --direction <seed key> --kind <assigned|pick|challenger|canon>` right after the direction choice (this is also the choice ping; the roll's output names the exact command), or `start --comp <approved comp>` when a surface round already locked one.
Then, in order, each closed by `node "<skill-base-dir>/scripts/build-phase.mjs" advance` (every script below lives under `<skill-base-dir>/scripts/` and runs with `node`; exit 2 means the gate failed and printed why; fix that and advance again; write nothing for a later phase while an earlier gate is open):
0. **comps.** The comp round from [visualize.md](visualize.md): three compositional comps of the requested surface at its own viewport under `.impeccable/mocks/`, each with a prompt sidecar, put in front of the user; the chosen one's sidecar gets `"approved": true`. The gate counts them and reads the approval; a `start --comp` skips this phase because it already happened.
The comp-led path is a frontier-tier job: it asks the builder to hold a measured layout, place plates at their boxes, and act on numeric readings across a dozen attempts. Smaller or faster models produce a recognisable page and stall under the hero gate; if the model in hand is one of those, say so before the direction round and take the code-led path, or expect the run to end at the hero with its readings unmet.
1. **spec.** Measure the comp: `comp-spec.mjs --comp <comp> --grid` writes a coordinate grid over the comp; open it, name every salient region by grid span in a regions file (text and control regions snap to the largest ink mass inside their span, so a headline named B1:E4 measures as the headline and not the column beside it; `snap: false` keeps the span, and an explicit `box` is taken as drawn) (kind `plate` / `image` / `texture` for anything painted: every illustration, photograph, figure, product object, and material texture; `text` / `control` / `chrome` for what code draws; every region carries a `note` saying what the comp shows there, which the plate prompt and the gate messages read), and run `comp-spec.mjs --comp <comp> --regions <file>`. The spec carries each region's box, sampled palette, and medium; `comp-spec.mjs --print` is the build's reference from here on. Type is measured, not guessed: `font-match.mjs --measure <text region>` reads the comp's cap height, width class, and weight off the pixels, and `font-match.mjs --rank <region> --text "..."` takes its candidates from a fingerprint index of the Google Fonts catalog (the nearest faces to the crop's shape) plus any names you pass with `--candidates`, renders them at that cap height with the region's words, and ranks them by fingerprint distance (its `USE` line is the CSS; its proof sheet shows the comp over the top three); with no browser resolvable it records the catalog's nearest face and says the size is estimated, which is still the choice to build on. Do not install a browser to rank, and never write a `chosen` face into the spec by hand: the gate accepts only what font-match wrote. The spec gate refuses to close until the lead text region is measured and ranked. A region note that describes painted material (a diagram, drawing, photograph, texture) under a code kind is refused at the spec: reclassify it as a plate, or reword the note if code really draws it. The script refuses a regions file that leaves comp ink unnamed (callouts, a parts table, a notes block): what is never named can never be missing, so everything the comp shows gets a region. It also refuses a `text` / `control` / `chrome` region larger than a quarter of the comp: that is a column, not an element, and a column scored as one region hides the plates, tables, and notes inside it. Name each element inside it (`container: true` only when it truly is one undivided element). Anything drawn is a plate: an inline SVG past an icon's budget (a diagram, notation, leader lines with arrows, a "quick approximation" of the artwork) is refused at the hero; icon-sized SVG (under 64px, a few paths) is fine, and a chart the page draws from data at runtime is a chart, not an illustration. Callout lines and arrows that annotate a drawing belong to that drawing's plate, with only their labels set as text. A crop of the comp is never a plate (the plates gate refuses a file that is a resample of the comp region: the comp's grain, its neighbours' edges, and its resolution would ship as the artwork); the crop is the reference the plate is generated from. A plate region's box has to hold its whole artwork with a margin: the spec measures the artwork's contact with the box edges and refuses a box that cuts through it (`bleed: true` only when the page really crops it there), because a plate placed with `object-fit: cover` on such a box shows the artwork minus the side the box lost. Anything not in the spec does not exist on the page: no borders, rules, containers, or chrome the comp does not show. Only three concessions exist: fonts (the closest obtainable face), icon glyphs (close enough, exact if the user chose an icon library; this covers the pictogram only, never a control's chrome, so a chevron, an arrow, a dropdown's border and fill, a button's shape are the comp's), and genuine defects in the comp such as spelling errors.
2. **plates.** Every raster region ships as a plate: an illustration, photo, or figure regenerated at asset resolution from its comp crop, UI text removed, at its `plate` path (ink on flat ground is generated on a chroma key and keyed to alpha, so it sits on the page's own ground rather than a second paper); a texture (paper, cloth, grain) is a clean patch of the comp region mirror-tiled to size, generated only when no clean patch exists. `generate-image.mjs --plate <id>` does one region end to end and scores it against the crop; a harness-native image tool takes the crop (`comp-spec.mjs --crop <id>`) as its input image and `comp-spec.mjs --plate-prompt <id>` as its prompt, then `embed-prompt.mjs`. With parallel subagents, spawn the shipped asset producer (`impeccable-asset-producer`; `impeccable_asset_producer` in codex; `/impeccable-asset-producer` in Cursor; on GitHub Copilot say "Use the impeccable-asset-producer agent") with the spec path and let it produce them all; without subagents, produce them here. A crop of the comp is a reference, never a shipping pixel. The gate checks every plate exists, is at least 1.5x the region's size, and reads as the region. Page code waits for this gate: a page written before its plates exist is a page that draws its material in CSS. A single-file deliverable changes nothing here: the plate is produced the same way and inlined as a data URI. `--force` exists for one case only, the user downgrading the comp's authority in words you quote in `--reason`; the script refuses every other reason.
3. **hero.** `build-phase.mjs scaffold` first: it writes the measured layout as CSS custom properties (`.impeccable/build/scaffold/layout.css`: `--r-<id>-x/y/w/h` in % of the comp, plus cap height, font-size, family, and weight where measured) and a reference page (`hero-reference.html`) with every region at its box and every plate placed. Bind the numbers to your own semantic structure, an element per region; the reference is a check on positions, never the page, and overlapping boxes are overlapping boxes. Then build only the first viewport, at the comp's own dimensions, the comp's words copied verbatim (the user approved that comp with those words; rewording is a stated decision after the hero passes, never a silent one inside it), every text region sized from its measured cap height and set in its ranked face, plates first: place every plate at its spec box (`object-fit: cover`, an `<img>`, a background image, or an inlined data URI named for it) before any text or control, capture into `.impeccable/review/hero-repro.png`, run `build-phase.mjs record hero` once so you see the plate regions read as match before any text exists, then lay the semantic layer over the plates from the spec's palette and boxes and advance. The gate first refuses while any plate is unreferenced by the source, then runs `comp-diff.mjs`, writes `.impeccable/review/diff/hero/` (side-by-side, heatmap, one paired crop per region, `report.json`), and passes at 72% overall with no hard veto outstanding (a missing region, a contradicted plate or text block, an SVG illustration, a clipped plate, invented ink block at any score); above the bar, the numeric readings become advisories printed with the pass, and the polish pass before responsive is where they get fixed: the gate also reads each text region's cap height, line count, weight, ink colour, and position against the comp, each chrome strip's height off its rule, and the frame for ink where the comp is calm (a kicker, an extra nav item, a divider), and says each miss as a number ("cap height 78px in the build, 103px in the comp"); those numbers are the edit. When it fails, open the region crops it lists, in order, before editing: a region scored `missing` needs its material, `contradicted` needs its structure re-derived from the spec box, `drift` is where size and spacing edits belong; the gate refuses a third attempt that only nudges values on the same region. This is where the run's ambition is won or lost, and a retry here costs minutes where a rebuild verdict at the finish costs the run.
4. **sections.** Build the rest of the surface inside the spec's system: the same corner language, line weights, and palette, and nothing the comp never shows. Where the comp does not cover a region, it inherits the recorded system.
5. **motion.** The signature interaction, reveals, and motion, orchestrated once rather than scattered.
6. **responsive.** The other viewports, and the first viewport at common desktop widths (1280 to 1600), not only at the comp's exact size: fluid columns, no fixed-pixel grid that wraps a hundred pixels narrower. Capture `desktop.png` (1440 wide, full page) and `mobile.png` (390 wide) into `.impeccable/review/`; the gate diffs the desktop capture against the comp and refuses a first viewport that only held at the comp's width. A comp'd surface that is mobile-first was comped portrait; the plates were produced for that frame.
### Code-led
No comp and no apology for it: the ambition lives in the direction contract's FIRST VIEWPORT block and the named signature interaction, and the finish reviewer audits those promises in behavior. The chosen decision comp rides to the finish review as the critique reference.
### Both paths
Build the assigned direction, not a safer interpretation of it. The form supplies structure, reading order, component conventions, and native motion; the product supplies every fact. Commit every atom: nav, buttons, inputs, and links are rebuilt in the form's vocabulary, and a stock component inside a committed form is a lapse. Land the first build fully committed; committing is the hard part, and the passes that follow exist to make the committed thing clear and effective, never to dilute it. In unattended work, the safe rendition is the known risk.
- **The first viewport is a thesis, not a header.** Demonstrate the mechanism immediately, at the scale the form has in life; do not trap the concept inside a standard hero or card shell. The memory test: if someone left after one viewport, what would they describe an hour later? If the honest answer is a mood, the concept has not committed yet.
- **Prove, don't claim.** Show the subject doing its job: the interface at work, the mechanism dramatized, specifics a competitor could not copy-paste. Demonstration data is design material: author it at full fidelity and label it synthetic; claims stay uninventable.
- **Author the assets; never substitute chrome.** Great surfaces live on carefully made content: names, entries, copy, covers, thumbnails, textures. In greenfield work every blank the ask round left open is yours to author at production fidelity; content is authorable, claims are labelable, no section is omittable. Gradients, glass, generic icon tiles, and many-vertex `clip-path` polygons where an authored asset belongs are the gap wearing chrome; the detector flags the last two.
- **Build the form's web leverage.** When the chosen world names a technique (canvas, WebGL, view transitions, generative motion), build the technique itself, not a static imitation of it.
- **Prove the hero before building past it.** When an approved comp exists, render the first viewport, capture it at the comp's own pixel dimensions, and set it beside the comp's first viewport before any later section: the hero carries the run's ambition, and every following section inherits its shortfall. Save that capture as `.impeccable/review/hero-repro.png` (create the directory); the finish reviewer verifies it exists, so a skipped checkpoint is a visible checkpoint. Judge scale and density as quantities, a field at a tenth of the comp's coverage or type at half its weight is a different design, and a five-minute retry here is what a rebuild verdict at the finish costs when this check is skipped.
- **Prove, don't claim.** Show the subject doing its job: the interface at work, the mechanism dramatized, specifics a competitor could not copy-paste. Sections that restate a claim in different words add length, not substance. Demonstration data is design material: author it at full fidelity and label it synthetic; claims stay uninventable.
- **Author the assets; never substitute chrome.** Great surfaces live on carefully made content: names, entries, copy, covers, thumbnails, textures. In greenfield work every blank the ask round left open is yours to author at production fidelity; content is authorable, claims are labelable, no section is omittable. An unanswered commercial claim ships as a clearly marked placeholder on the user's replacement list. When image generation exists, producing the design's imagery is part of building, at the scale the composition needs: a viewport that wants atmosphere gets a full-bleed layered scene, and a library of small centered subjects standardized for tidiness forecloses it. Gradients, glass, and generic icon tiles where an authored asset belongs are the gap wearing chrome; icons drawn in the world's own grammar are the remedy, not the target.
- **Build the form's web leverage.** When the chosen world names a technique (canvas, WebGL, view transitions, generative motion), build the technique itself, not a static imitation of it; the graceful fallback serves constrained clients, it is not the default experience.
- **Pace the scroll like a studio.** Vary density, scale, image, motion, and quiet inside one grammar; a dense passage earns a quiet one, and the page ends anchored by a real close. One spacing rhythm throughout, with more space above a heading than below it.
- **Use real, verified imagery when the brief implies it.** Search for the subject's physical object rather than the category; one decisive photo beats five mediocre ones. Verify stock URLs resolve.
- **Author motion as material.** Give the page the form's native motion once, orchestrated, rather than scattered hover effects. Bound expensive effects and keep content visible by default.
- **Author motion as material.** The form has native motion, what it does in life between states; give the page that motion once, orchestrated, rather than scattered hover effects. Bound expensive effects and keep content visible by default.
Preserve semantics, accessibility, performance, responsiveness, project conventions, and working behavior.
## 7. Inspect and finish
Inspect the surface's target sizes in one batched screenshot round: desktop and mobile on the web; on a native platform (`ios` / `android` / `adaptive`), the shipped device classes per OS, captured from the simulator or emulator the way the platform reference's Verifying the build section describes. When the harness reports the user's actual viewport (an in-app browser's size, a named resolution), add that width to the set: the width that breaks is the one the user sees first. Critique the render against the user's request and the direction contract, fix material gaps, and confirm with one final round; two rounds is the ceiling, and fixes batch between them rather than earning per-tweak screenshots. On a comp-led build, run `node "<skill-base-dir>/scripts/comp-diff.mjs" --comp <approved comp> --build .impeccable/review/desktop.png --spec .impeccable/build/spec.json --out-dir .impeccable/review/diff/final` and read its region rows and paired crops as the critique: the side-by-side is the view the build thread never has on its own, and a region it scores missing or contradicted is a fix whatever the page looks like from memory. Never judge fidelity from one full-page thumbnail; it hides exactly the failures that matter. On a Persuade surface, verify the mode did its job: a first-time visitor should know what this is, why it matters, and what to do within seconds, in the form's own vocabulary.
Inspect the surface's target sizes in one batched screenshot round: desktop and mobile on the web; on a native platform (`ios` / `android` / `adaptive`), the shipped device classes per OS, captured from the simulator or emulator the way the platform reference's Verifying the build section describes. When the harness reports the user's actual viewport (an in-app browser's size, a named resolution), add that width to the set: the width that breaks is the one the user sees first. Critique the render against the user's request and the direction contract, fix material gaps, and confirm with one final round; two rounds is the ceiling, and fixes batch between them rather than earning per-tweak screenshots. When an approved comp exists, the critique is a side-by-side: view the comp region and the build region together, the hero and each section as its own crop at legible scale, never one full-page thumbnail, which hides exactly the failures that matter, crude controls, wrong lettering character, flattened material, behind a superficially similar section order. On a Persuade surface, verify the mode did its job: a first-time visitor should know what this is, why it matters, and what to do within seconds, in the form's own vocabulary.
A capture is evidence only when it is valid, and you validate before you send. Settle or disable entrance motion first: an element hidden by animation timing reads as a missing element and gets fixed into a regression. Capture full-page shots from the document top. Capture the comp comparison at the comp's own pixel dimensions. Then open every file once and confirm it shows what its name claims: no black or blank regions, no wrong section behind a right filename, no half-loaded state. A malformed capture sent onward costs the whole round; the reviewer answers it with `disposition: recapture` and nothing it reviewed binds.
After the second inspection round the build thread's polishing is over: no further defect hunts, micro-edit scripts, or rebuilds here; whatever remains ships through the handoffs, where a fresh context does the finding better and cheaper. On the web, where this harness runs no design hook, run `node "<skill-base-dir>/scripts/detect.mjs" --json` on the changed targets once here, fix what is mechanical, and pass the remaining findings to the reviewer; a hookless web build that skips this ships every tell the hook exists to catch. A native platform skips the detector entirely: it reads HTML and CSS and has no verdict on native code, so the reviewer's floor check is the only slop gate and the input packet says so. Capture the screenshots into `.impeccable/review/`, one file per captured viewport (on the web, `desktop.png` and `mobile.png`, plus `user-<width>.png` whenever the user's viewport joined the inspected set; on native, one per device class, such as `phone.png` and `tablet.png`, suffixed per OS on adaptive), creating that directory when the harness does not; the paths you pass the reviewer are its spec, every viewport you inspected is named required in the packet, and that directory is where it looks when a passed path is missing.
After the second inspection round the build thread's polishing is over: no further defect hunts, micro-edit scripts, or rebuilds here; whatever remains ships through the handoffs, where a fresh context does the finding better and cheaper. On the web, where this harness runs no design hook, run `node .claude/skills/impeccable/scripts/detect.mjs --json` on the changed targets once here, fix what is mechanical, and pass the remaining findings to the reviewer; a hookless web build that skips this ships every tell the hook exists to catch. A native platform skips the detector entirely: it reads HTML and CSS and has no verdict on native code, so the reviewer's floor check is the only slop gate and the input packet says so. Capture the screenshots into `.impeccable/review/`, one file per captured viewport (on the web, `desktop.png` and `mobile.png`, plus `user-<width>.png` whenever the user's viewport joined the inspected set; on native, one per device class, such as `phone.png` and `tablet.png`, suffixed per OS on adaptive), creating that directory when the harness does not; the paths you pass the reviewer are its spec, every viewport you inspected is named required in the packet, and that directory is where it looks when a passed path is missing.
Then spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccable_finish_reviewer` in codex; `/impeccable-finish-reviewer` in Cursor; on GitHub Copilot say "Use the impeccable-finish-reviewer agent"), with the original request, confirmed answers, the artifact path, the screenshot paths, the direction contract, existing hook findings, the QUALITY BAR card and approved comp paths (a code-led build has no approved comp; the chosen decision comp rides in that slot as the critique reference, named as such), on a comp-led build the build state (`.impeccable/build/state.json`), the spec, and the diff directories (`.impeccable/review/diff/hero/` and `.impeccable/review/diff/final/`, whose side-by-side, heatmap, region pairs, and `report.json` are the fidelity evidence), the craft-floor reference path, and on a native platform the platform reference path(s), [ios.md](ios.md) / [android.md](android.md), both on adaptive, plus one line saying no detector ran, so the reviewer judges in the platform's conventions rather than the web's. The reviewer has no browser; screenshots you fail to pass are checks it cannot run. Never read the shipped agents' definition files before spawning; the harness loads them at spawn, and you owe only the input packet. Wait on any agent with one long timeout rather than a loop of short polls, and spend the wait on the next independent step. Verify the return carries the five contract sections (a recapture return carries one, its recapture list); on an empty or thrashed return, respawn once with the same inputs. This review never runs inside the build thread and never inherits it: spawn the reviewer fresh, with no forked conversation history (`fork_turns: 0` in codex); a reviewer that inherits your transcript inherits your framing, your optimism, and your abstractions, and everything it needs travels in the inputs above. Only a harness with no subagent capability at all substitutes a fresh in-thread pass after stepping fully out of the build context, run from [degraded/finish-reviewer.md](degraded/finish-reviewer.md), and a substituted or failed-and-replaced review is disclosed in one line at finish, never silently.
Then spawn the shipped finish reviewer, `impeccable-finish-reviewer` (`impeccable_finish_reviewer` in codex; `/impeccable-finish-reviewer` in Cursor; on GitHub Copilot say "Use the impeccable-finish-reviewer agent"), with the original request, confirmed answers, the artifact path, the screenshot paths, the direction contract, existing hook findings, the QUALITY BAR card and approved comp paths (a code-led build has no approved comp; the chosen decision comp rides in that slot as the critique reference, named as such), the craft-floor reference path, and on a native platform the platform reference path(s), [ios.md](ios.md) / [android.md](android.md), both on adaptive, plus one line saying no detector ran, so the reviewer judges in the platform's conventions rather than the web's. The reviewer has no browser; screenshots you fail to pass are checks it cannot run. Never read the shipped agents' definition files before spawning; the harness loads them at spawn, and you owe only the input packet. Wait on any agent with one long timeout rather than a loop of short polls, and spend the wait on the next independent step. Verify the return carries the five contract sections (a recapture return carries one, its recapture list); on an empty or thrashed return, respawn once with the same inputs. This review never runs inside the build thread and never inherits it: spawn the reviewer fresh, with no forked conversation history (`fork_turns: 0` in codex); a reviewer that inherits your transcript inherits your framing, your optimism, and your abstractions, and everything it needs travels in the inputs above. Only a harness with no subagent capability at all substitutes a fresh in-thread pass after stepping fully out of the build context, run from [degraded/finish-reviewer.md](degraded/finish-reviewer.md), and a substituted or failed-and-replaced review is disclosed in one line at finish, never silently.
Act on the disposition word; there are exactly four. **recapture**: the evidence failed, not the build. Recapture what the return names under the capture-validity rules, then run a full review over the new evidence. A review conducted on invalid evidence binds nothing, and a verdict pass may never follow it. **rebuild**: fidelity failed wholesale, not in patches. Skip the fix batch and execute the rebuild immediately: re-derive the named regions, produce the named assets, and send the result back for a fresh full review, never a verdict pass; a rebuild replaces regions wholesale, so the whole matrix runs again over the recaptures. Tell the user what is happening rather than asking permission to fix a failure. Consult the user only on a second rebuild directive, both verdicts on the table, or when rebuilding would discard content the user approved. **ship**: nothing is owed; report the verdict at its scope and continue to the documenter. **fix**: apply the material fixes in one batch, rebuild once, and recapture the same viewports over the same files. A recapture measures positions, loading, and overflow; it cannot measure whether a fix reached the quality the finding named, so send the recaptured screenshots back to the same reviewer for a verdict scoring every material fix resolved, partial, or unresolved (through the harness's agent continuation; without one, run the scoring fresh from [degraded/finish-reviewer.md](degraded/finish-reviewer.md)'s Verdict Pass). Fixes scored partial or unresolved get another batch, recapture, and verdict. Two rounds is the budget an unattended run ends at; an attended session's ceiling belongs to the user, so when the second verdict still lists open items, put the table in front of them and let them choose between shipping as it stands and funding another round. Whoever decides, stop the moment a round resolves nothing, and the reviewer's findings are the only list you work from, never your own re-opened hunt. Do not run a second detector.
Act on the disposition word; there are exactly four. **recapture**: the evidence failed, not the build. Recapture what the return names under the capture-validity rules, then run a full review over the new evidence. A review conducted on invalid evidence binds nothing, and a verdict pass may never follow it. **rebuild**: fidelity failed wholesale, not in patches. Skip the fix batch and execute the rebuild immediately: re-derive the named regions, produce the named assets, and send the result back for a fresh full review, never a verdict pass; a rebuild replaces regions wholesale, so the whole matrix runs again over the recaptures. Tell the user what is happening rather than asking permission to fix a failure. Consult the user only on a second rebuild directive, both verdicts on the table, or when rebuilding would discard content the user approved. **ship**: nothing is owed; report the verdict at its scope and continue to the documenter. **fix**: apply the material fixes in one batch, rebuild once, and recapture the same viewports over the same files. A recapture measures positions, loading, and overflow; it cannot measure whether a fix reached the quality the finding named, so send the recaptured screenshots back to the same reviewer for a verdict scoring every material fix resolved, partial, or unresolved (through the harness's agent continuation; without one, run the scoring fresh from [degraded/finish-reviewer.md](degraded/finish-reviewer.md)'s Verdict Pass). Fixes scored partial or unresolved get another batch, recapture, and verdict. Two rounds is the budget an unattended run ends at; an attended session's ceiling belongs to the user, so when the second verdict still lists open items, put the table in front of them and let them choose between shipping as it stands and funding another round; the table states in one line that the documenter has not run and, when DESIGN.md is still the pre-build seed, names that too, so the stop reads as what it is, a paused run with an unrecorded world. Whoever decides, stop the moment a round resolves nothing, and the reviewer's findings are the only list you work from, never your own re-opened hunt. Do not run a second detector.
A rebuild and a fix round share one asset rule: a raster either round creates or replaces is still asset work under [visualize.md](visualize.md)'s Produce section and keeps its **provenance** like every build raster, and a raster the round abandons is deleted in the same batch. Before either round's result goes back for review or verdict, run `node "<skill-base-dir>/scripts/embed-prompt.mjs" --scan <asset-dir...>` over the directories the artifact's rasters ship from and clear every file it reports by embedding what it is missing: the exact generation prompt for a produced raster, the origin for a sourced, stock, or pre-existing one. The scan only reads; deletion is reserved for rasters the round abandoned, never for a file the scan flagged.
A rebuild and a fix round share one asset rule: a raster either round creates or replaces is still asset work under [visualize.md](visualize.md)'s Produce section and keeps its **provenance** like every build raster, and a raster the round abandons is deleted in the same batch. Before either round's result goes back for review or verdict, run `node .claude/skills/impeccable/scripts/embed-prompt.mjs --scan <asset-dir...>` over the directories the artifact's rasters ship from and clear every file it reports by embedding what it is missing: the exact generation prompt for a produced raster, the origin for a sourced, stock, or pre-existing one. The scan only reads; deletion is reserved for rasters the round abandoned, never for a file the scan flagged.
Report the final verdict under the reviewer's own disposition word and at its actual scope. A verdict pass scores the listed fixes and nothing else: "the reviewer scored all three fixes resolved" is a claim it supports, "no material issues remain" is not. A table with open material findings is never announced as a pass, never softened, and never dressed as whole-surface approval when only a fix list was scored. When the user answers a ship with evidence against it, their own screenshot, a named mismatch with the comp, that evidence outranks every capture you made: put their material in the packet and spawn a fresh reviewer for a new full review. Patching inline and self-certifying is how a rejected page ships twice.
@@ -0,0 +1,560 @@
# Visual Cues Pipeline
Loaded by `/impeccable document` seed mode (Step 4) on the questionnaire path. Input: the seed interview's three named references and one anti-reference, the asset observations from seed Step 2, and PRODUCT.md. The chat interview asks no color, typography, or motion question on this path; the questionnaire and this pipeline own those decisions. Output: cue images plus `cues.json` under `.impeccable/visual-cues/`, ready for the user to pick from by eye in a later round.
Tell the user once, before starting: *"Generating visual cues; this can take a minute or two."* Then work without narration. Chat carries no per-image commentary, no palette tables, no prompt dumps; the folder is the deliverable.
## The image
Each cue is **one generation**: the hero.
```text
HERO [slug].png (1500x1500)
+---------------------------+
| one close-framed scene, |
| the product's world, |
| four scene objects |
| carrying the palette, |
| every surface in frame |
| one of the four colors, |
| everything in crisp |
| deep focus, no blur |
+---------------------------+
saved as-is, NO crop
```
The **hero** is the visual cue: one close-framed scene from the product's world, its four objects carrying the palette as large color fields. This is what the user will pick between, so the colors get the real estate, and the frame has exactly two known thieves. **Blur**: an out-of-focus background is frame spent on mush, so everything renders in crisp, deep focus, front to back. **Undressed space**: every surface in frame is set-dressed to carry one of the four colors; the ground and backdrop belong to the neutral's material, and there is no bare wall, empty room, or whole person spending frame on colors nobody chose (hands mid-work belong to the scene; a face and outfit donate skin, hair, and clothing to the palette).
Example, hero = a flower atelier's worktable, framed close: unbleached linen spread as the ground and backdrop (neutral), a massed bank of wine-plum blooms in a ceramic vessel as the subject (primary), a band of dusty-rose petals beside it (secondary), one persimmon bloom set apart (tertiary), a florist's hands mid-arrangement, everything sharp.
## The studio
Palettes come from **six competing specialists**, not from you. One mind composing six palettes converges on one taste, and six versions of one mood defeat the pick round. Each specialist is a subagent locked to a **persona**: a different method of searching color space (object association, cultural reframing, remote analogy, self-imposed constraint, audience perspective-taking, emotional sequencing). Same brief, same output format, different search method; the separation is what makes the six palettes genuinely different.
The studio runs as **one parallel wave**. You carve six territories from the brief (Step 2), then all six personas spawn at once (Step 3), each composing a palette inside its own territory and staging it in its hero. No chained reviews, no revision loops: distinctness is settled upfront by the territory assignments, and speed comes from doing everything in one wave.
Subagents start without your context, so everything a specialist needs must reach it whole. The invariant material (brief packet, persona methods, territory map, craft rules, prompt skeleton, work steps) travels as one **brief file** every spawn reads; only the per-persona slots (persona number and name, territory line) ride in the spawn task itself. Copy shared blocks into the brief file **verbatim**; a summarized rule is a dropped rule, and retyping the full set into six long tasks costs minutes of pure prompt-typing per wave.
## Step 1: Assemble the brief packet
Write one self-contained text block that a specialist with zero context can design from. Include, in full:
- **The product**: from PRODUCT.md, what it is, sells, or shows; the audience; the positioning; the personality words.
- **The interview**: the three named references and the anti-reference. State that the anti-reference is a hard constraint on every palette. There is no chat color strategy or hue anchor on this path; the territories (Step 2) own the color search.
- **The assets**: the seed Step 2 observations (logo colors, recurring materials, photo moods).
Label it `BRIEF PACKET`; it goes into the brief file once (Step 3), so every specialist designs from the identical packet. Do **not** add your own palette leanings to it: the personas do the leaning.
## The six personas
The numbers only name the personas; Step 2 pairs each with a territory.
1. **The Ecological Naturalist**: derive every color from real materials, organisms, weather, or landscapes in the product's world. Name the physical source of each hex. No abstract "brand blue" thinking; the palette must feel materially plausible, textural, grounded.
2. **The Cross-Cultural Anthropologist**: treat color as cultural meaning. Compare at least two cultural lenses relevant to this audience, find where the meanings align and where they diverge, and turn that tension into the palette. Do not stereotype or flatten into cliché.
3. **The Analogy Hacker**: never start from the product category. Choose one distant domain (a jazz progression, a thermal camera, a medieval manuscript, a subway map, a laboratory stain chart) and translate its structure into color logic. The palette should never emerge from category convention, yet feel coherent once explained.
4. **The Constraint Poet**: before composing, invent three to five severe but fruitful constraints ("one accent only", "every color must survive dusk", "mineral tones plus one synthetic intruder"), then compose the strongest palette inside them. Do not relax the rules; tension is the point.
5. **The Audience Empath**: design from the audience's exact emotional and cognitive state at their first critical encounter with the product: what they need to feel, notice, and trust in that moment. The brand's ego does not vote.
6. **The Emotion Dramaturge**: build the palette as an emotional arc, not a static board. Define the felt sequence of using this product (invitation, curiosity, tension, confidence, release) and assign hue, lightness, and saturation to its beats.
Accessibility and implementation stay **out** of the personas: the PALETTE RULES block carries the contrast requirements for everyone. A persona whose identity is "the contrast checker" composes cautious mud.
## Shared blocks
These go into the brief file (Step 3) in the order its assembly list names. They are the single source of the craft rules; never restate them loosely.
### PALETTE RULES
```text
Compose exactly four hex values with a 60-30-10 balance. These are
website/app colors, headed for design tokens, not scene colors:
- neutral (~60%, the dominant): the surface, what most of a screen will
be. An off-white or near-white with a temperature tint, at least as
pale as #ECEAE6: a mid-tone neutral that reads fine as a scene material
turns into a gray slab once it is a screen background. Near-black is
the one alternative, when the mood calls for dark; there is no
in-between. Never pure #FFFFFF or #000000.
- primary (~30%): the brand color, the mood's main carrier. Must read
clearly against the neutral.
- secondary: structure and support: an adjacent hue, or the primary
shifted in lightness and chroma. Visibly a different swatch, not a
darker copy of primary.
- tertiary (~10%, the accent): the most saturated of the four and used
smallest; distinct in hue from primary so it keeps signal value.
Hard rules:
- Write one mood phrase specific enough to compose from. Good: "dawn
delivery run, cut stems in cold water, the city still gray". Bad:
"modern and clean"; a phrase that fits any brand composes nothing.
- Every color earns its place: for each role, one line on what it does
and why it fits this product. A color you cannot justify in one line
gets replaced, not kept because it looks nice.
- Contrast is non-negotiable: primary must read clearly on the neutral;
tertiary must pop against both. A palette that fails either is not done.
- Any two roles must be nameable apart at a glance. A dark green primary
next to a dark green neutral is one color, not two.
- The brief's anti-reference is a hard constraint.
```
### CONCEPT RULES
```text
Attach one one-line cue concept to the palette; the hero image stages it.
- The concept lives in the product's own world, named with the brief's
own nouns. A concept that could belong to any other product is not
done; sharpen it until it could only be this brand.
- Give it a material world (botanical, ceramic, paper, textile, metal,
glass, stone, food) as the supporting cast around the product's
subject, never a replacement for it.
- Name four scene objects, the palette's physical carriers, each passing
three tests: it lives inside the scene, so it plausibly sits in the
hero composition; it can carry its color as one large unbroken field
at close framing (a massed bank of blooms, a draped cloth, a glazed
vessel; a single bud or a thin ribbon cannot, and a color whose
carrier is one small object ships as an unjudgeable sliver); and it
is plain and unprinted (no tags, labels, packaging, printed cards,
or stationery), because text on an object ruins the cue.
- Name the concept with a two-word slug (amber-dusk, coastal-glass).
```
### HERO PROMPT skeleton
Written like screenplay direction, not a keyword list: subject doing something, in a place, in a light. The scene stays the product's world; the palette's real estate is won inside it, by set dressing and by focus, never by deleting the scene. **Never ask for shallow depth of field, bokeh, or a soft background**: an out-of-focus stretch of frame is real estate spent on mush, so the prompt demands crisp, deep focus front to back. And every surface in frame is dressed to carry one of the four colors: the ground and backdrop belong to the neutral's material, and no bare wall, empty room, or whole person appears (hands mid-work belong to the scene; a face and outfit donate skin, hair, and clothing to the frame).
Name every color in plain language only, as a rich material description ("deep wine-plum, the color of reduced port"), tied to its carrier. **Never put a hex code, or any number, in an image prompt**: image models that render text well will paint it onto the image as a label or a swatch strip, and even one stray numeral fails the wordless check below. The hexes already travel in the PALETTE report line, and the compile step snaps them to rendered pixels; the prompt's job is the color's look, not its code. For the same reason, say what fills the frame instead of listing what to omit; a bare "no text" line is the weakest form of the instruction and the wordless sentence below is the strong form. Keep both.
Light the scene to reveal color, not to set a mood. In a dim, dusky, or nocturnal rendering every color sinks into one warm-brown murk the user cannot sample from, so bright, generous light is a hard rule even when the concept's moment is dark: an "after hours" or "dawn" concept keeps its props and story but is lit like a studio still, not like the hour. Dark palettes are welcome; dark renderings are not; a near-black primary should read as a rich, clearly-lit surface, not as underexposure.
The neutral's ground pays the highest price for shading. The compile step snaps each role to the pixels the hero actually rendered, and the picker shows the snapped value, so a nominally off-white linen that renders in mid-gray shadow ships a mid-gray surface color to the user. Describe the neutral's material as pale in the prompt ("pale unbleached linen, near-white in even light") and keep its field lit edge to edge, so the rendered ground stays as pale as the composed hex. Fill every `[bracketed]` slot; never leave template language in the prompt.
```text
One full-bleed photograph, square format, framed close: [one scene from
the product's world: subject and what it is doing, setting], the subject
filling most of the frame, not a wide view of the room. The scene
contains [object A], [object B], [object C], and [object D], all plainly
visible. The scene is art-directed as bold color blocking in a strict
four-color story: every surface in frame carries one of the four colors,
each color one large unbroken field, none reduced to a sliver, no
stretch of frame left to a color outside the four: [the neutral's
carrier], [plain-language color with a material-world comparison], as
the ground and backdrop, about half the frame, evenly lit edge to edge
with no shadow gradient across it; [the primary's carrier],
[color description], one continuous mass over roughly a third of the
frame, carried by the main subject; [the secondary's carrier], [color
description], a clear supporting field beside it; [the tertiary's
carrier], [color description], one small vivid accent, big enough to
read at a glance. Focus: deep and even, every object and surface in
crisp sharp focus from front to back; no blur, no bokeh, no soft
out-of-focus background anywhere in the frame. Camera: [tight still-life
framing and angle, e.g. "straight-on still life at table height" or
"high overhead of the worktable"]. Lighting: bright, even, generous
studio daylight; every color fully lit, true, and saturated, no area
lost to shadow. Mood: [two or three adjectives from the brief's
personality]. The image is completely wordless: every material is plain
and unprinted, a world with no lettering, numerals, tags, labels, or
graphics anywhere in it. Rich, saturated, editorial color; not a dim,
dusky, nocturnal, or candlelit image. Photorealistic, real texture. No
text, no watermark.
```
## Step 2: Carve the territories
Split the brief's color space into six **territories**, one per persona. Each is a one-line claim with two halves: a scene ground (a mood, a moment, a positioning angle) and, always, a **hue ground** it closes on (a named hue register). A hue-silent territory does not constrain color: give six specialists scenic territories and one shared brief, and every one of them will resolve to the product's one obvious hue; the hue ground is what makes the palettes diverge, the scene ground is what makes the stories diverge. Example set for a florist: "the delivery run before the city wakes: cold blue-teal dawn", "the atelier after hours: lacquer near-black with amber", "the potting bench: warm terracotta and unbleached paper", "gallery restraint: paper-white with one ink accent", "market-stall abundance: saturated market greens", "the drying room: muted botanical earth and rose".
Hard rules:
- **No two hue grounds share a hue family.** Six registers, six families.
- **A hue anchor exists only when an asset fixes one** (a logo's sampled color, a recurring moodboard hue from the seed Step 2 observations). When one exists it belongs to exactly one territory (two only when the brief argues for it). Name its owner; Step 3 tells everyone else the anchor is off-limits. An anchor left unassigned is an anchor every persona obeys. No asset anchor: no owner, and the map's anchor sentence is dropped.
- **A territory claims colors, not lighting.** "Lacquer near-black with amber" means those hues, staged in bright, clear light like every other palette; the HERO PROMPT skeleton forbids dim renderings, and a dark-moment territory ("after hours", "dawn") does not override it.
- The anti-reference rules all six.
Assign each territory to the persona whose method suits it best (the Naturalist takes the most material ground, the Dramaturge the most emotional, the Empath the one closest to the audience's state).
Done when: six one-line territories exist, each closing on a hue ground, no two hue grounds in one family, any asset-fixed anchor owned by exactly one, each assigned to a persona.
## Step 3: The wave (parallel)
**Pick the generation path first.** The harness's native image-generation tool is the path whenever one exists and works; a native tool that **cannot generate** (zero credits, failed auth) counts as absent: fall through without asking the user, and mention the swap in the final report. The keyless path is [image-api.md](image-api.md): its shipped wrapper and pre-answered setup are canonical, so a key in `.impeccable/.env` or a leftover project-local wrapper never outranks a working native tool, and never needs re-deriving when it is the path.
**Do not smoke-test the path.** Presence is the whole check: a tool the harness lists works, and the image-api.md wrapper already retries transient failures internally, so a preflight generation buys nothing the first persona's report would not carry, and it costs a generation call and half a minute on every clean run. Instead, fill the brief file's tool slot with the exact call the spawns will make: the tool or wrapper command, the square-size parameter to pass, where it writes output files (some native tools ignore directory paths and save to a fixed folder of their own; say so in the slot, so no specialist rediscovers it alone), and whether the output is already guaranteed square (the shipped wrapper's is), so no specialist burns a tool call measuring it.
If the harness exposes any subagent/spawn tool (Task, spawn_agent, agents, or similar), parallel is **required**, not preferred: emit all six spawns as **one tool-call batch, a single message carrying six spawn calls**, one persona per subagent, each doing the full job (palette, concept, hero), and only then wait for the reports. Spawning one, waiting for its report, then spawning the next is a serial loop and a failure even though every spawn "used a subagent"; so is generating any image yourself while a subagent tool exists. The whole run must take only as long as the slowest single persona. Attach the harness's image-generation skill to each spawn when the harness expects that (Codex: the `imagegen` skill). (No subagent tool at all: Step 4.)
### The brief file
Write `.impeccable/visual-cues/brief.md` once, before spawning: the SPECIALIST BRIEF body below with its tool slot filled, then, appended in this order, the BRIEF PACKET, **The six personas** list verbatim from this document, the TERRITORIES block from Step 2's carve, then PALETTE RULES, CONCEPT RULES, and the HERO PROMPT skeleton with its framing paragraphs, verbatim from this document. One byte-exact file read by all six replaces six retyped copies of the same several-thousand-word block: the spawn tasks stay a few lines long, the wave starts in seconds instead of minutes, and retries reread the identical rules. **If the harness's subagents cannot read files**, paste the brief file's full contents into each task instead; the file stays the single source either way.
The TERRITORIES block is the wave's off-limits map, written once here instead of five off-limits lines retyped into every spawn:
```text
TERRITORIES (your task names your row; every other row is off-limits)
1. [persona name]: [territory line]
2. [persona name]: [territory line]
3. [persona name]: [territory line]
4. [persona name]: [territory line]
5. [persona name]: [territory line]
6. [persona name]: [territory line]
The hue anchor ([the asset-fixed anchor]) belongs to row [N] alone. If
that row is yours, carry it; otherwise your primary must live in a
different hue family.
```
The block's closing anchor sentence appears only when Step 2 named an asset-fixed anchor and its owner; with no anchor, end the block after row 6.
SPECIALIST BRIEF body:
```text
You are a color specialist. You compose one brand palette inside an
assigned territory, then stage it in one hero image. Your spawn task
names your persona and your territory; this file carries everything
else: the brief packet, your persona's method, the territory map, the
craft rules, the prompt skeleton, and the steps below.
This file is your only read. Do not open PRODUCT.md, DESIGN.md, or any
other repo file: the BRIEF PACKET already carries everything they would
tell you, and every extra read costs the wave time.
Generate the image with [the exact tool or command for the chosen
generation path, the square-size parameter to pass, and where it
writes output files]. Use only that; do not edit repo files.
The hero gets a hard budget of three generation calls, all reasons
combined (failed calls, timeouts, and the retry checks below). A call
that fails with a network, API, or timeout error may be re-run as-is
within that budget; when the budget is spent, stop and report per step
5. The failure is the parent's problem, not yours: never debug DNS or
connectivity, never install packages, and never edit or rewrite the
generation tooling.
1. Compose your palette, in your persona's method, inside your
territory, following the PALETTE RULES section below.
2. Draft the concept for the palette, following the CONCEPT RULES
section below.
3. Critique your own work before touching the image. Check the palette
against every PALETTE RULES line, against your territory's hue
ground, and against every other row of the TERRITORIES block; check
the concept against every CONCEPT RULES line. Name each failure and
fix it. A primary that drifted into another territory's hue family,
or into an anchor you do not own, is a failure to fix now, not one
to ship.
4. Build the hero prompt from the HERO PROMPT skeleton below and
generate the HERO image at 1500x1500 or the nearest supported
square. The image must be square: a size line inside the prompt
does not pin the canvas, so whenever the tool accepts a size or
aspect-ratio parameter, pass square (1:1) explicitly; the compile
step rejects non-square images, and the fix is regenerating with
that parameter actually set, not editing the file. Five sibling
specialists share the generation tool's output folder, so a default
output name is a race that hands you a sibling's image: if the tool
accepts an output filename, pass [slug]-hero.png, and work only with
the exact file path the tool reports back for YOUR generation. A
tool that ignores directory paths and saves to its own fixed folder
is normal, not an error: after the inspection below, copy the
reported file to [visual-cues dir]/[slug]-hero.png and report the
copy's path.
Open the result and inspect it once, four checks, each with at most
one retry, all inside the three-call budget; keep the last result
regardless.
- Ownership: the scene is yours, staging your palette; a wrong
subject or palette means you picked up a sibling's file from the
race above, so regenerate once with the [slug] filename.
- Wordless: any lettering, numeral, label, or swatch strip anywhere
in the frame fails the cue; regenerate once, same prompt, plus
"The image contains no lettering, numerals, or graphic marks of
any kind; every surface is plain and unprinted."
- Real estate: if the palette's fields read as slivers, with frame
spent on a blurred background, a bare wall, an empty room, or a
whole person instead of the four colors, regenerate once, same
prompt, plus "Frame tighter on the scene's four color carriers;
every surface in frame carries one of the four colors, and
everything is in crisp sharp focus, no blur anywhere."
- Light: if the image is dim, dusky, or nocturnal, with palette
colors sinking into shadow, or the neutral's ground renders
visibly darker than its composed color (off-white linen reading
as mid-gray), regenerate once, same prompt, plus "Render the
scene in bright, generous daylight-quality studio light; every
color fully lit and clearly readable, the ground pale and evenly
lit edge to edge, no darkness anywhere in the frame."
5. Reply with exactly these three lines and nothing else, the path
being the file you verified in step 4:
COMPLETED [slug]
HERO [absolute path to the hero PNG]
PALETTE primary=#RRGGBB;secondary=#RRGGBB;tertiary=#RRGGBB;neutral=#RRGGBB
If the budget runs out first, reply instead with the ERROR line plus
one line for each thing you finished before the failure, so a retry
can start where you stopped:
ERROR [persona number] [short reason]
PALETTE primary=#RRGGBB;secondary=#RRGGBB;tertiary=#RRGGBB;neutral=#RRGGBB
HERO-PROMPT [the finished hero prompt, on one line]
```
### The spawn task
Each spawn task is a few lines; the brief file carries the weight. The persona's method, the off-limits map, and the anchor rule all live in the brief; do **not** paste them back into the tasks, that is the retyping the brief file exists to kill:
```text
You are a color specialist. Read [absolute path to
.impeccable/visual-cues/brief.md] now, before anything else, and follow
it exactly: it carries your brief, your persona's method, the territory
map, craft rules, prompt skeleton, work steps, generation budget, and
report format.
You are persona [N], [persona name].
YOUR TERRITORY: [this persona's one-line territory]
Your answer is unsuccessful if it occupies the same visual, emotional,
or strategic territory as another specialist, or if your primary lands
in a hue family another territory claims. Stay inside your own.
```
Six spawns fit the observed Codex ceiling of 6 concurrent subagents, so the wave normally runs whole. If a spawn is rejected with a thread-limit error, collect the accepted spawns, close those agents to release their slots, then run a second pass for the rejects. If every spawn ERRORs because subagents lack the image tool, fall back to Step 4's loop using the territories you already carved. Close every agent after collecting its report. If two reports share a slug, rename one before Step 5 (the compile `--slug` flag controls the filenames).
Retry an ERROR persona at most once, and never from scratch: the retry task is the original spawn task plus the ERROR report's PALETTE / HERO-PROMPT lines and one added instruction, "these lines are finished work from your first attempt; skip the steps they cover and resume at the first uncovered step." An ERROR persona that fails its retry is dropped; five good cues beat a stalled pipeline.
Done when: every persona has either a three-line COMPLETED report or an ERROR report.
## Step 4: Serial path (no subagents)
Only when the harness has no subagent tool at all: pick the generation path by the same precedence rule, keep the same six territories, and play all **six** personas yourself, one at a time and honestly in-method (the Naturalist names physical sources; the Constraint Poet writes its constraints before composing), following the SPECIALIST BRIEF body from its step 1 (palette inside the territory, concept, hero, look-and-retry) and recording the same facts a subagent would report (slug, hero path, palette). No brief file needed: this document is already in your context. The user still gets six cues; only the clock differs.
Same done-condition as Step 3, over all six personas.
## Step 5: Compile
Before anything else, two gates on the reported heroes:
- **Unique**: hash every reported hero (`md5 [paths]`); each must be unique. Two identical heroes mean two subagents raced on a shared default output filename; re-spawn one of the pair and take its fresh file before compiling.
- **Square**: check every reported hero's dimensions (`sips -g pixelWidth -g pixelHeight [paths]` on macOS); width must equal height. The compile script rejects non-square inputs, and squaring after the fact is off the table (cropping eats scene, padding invents background), so a non-square hero is a failed generation: re-spawn that persona once and take the fresh file. Still non-square after the re-spawn: drop the cue.
A gate re-spawn follows Step 3's retry pattern: the original spawn task plus the report's PALETTE line (its palette was fine; only the image failed the gate) and the resume instruction, so the retry regenerates the hero without recomposing.
For each COMPLETED report, run one command, carrying the report's slug and its `PALETTE` line:
```text
node .claude/skills/impeccable/scripts/visual-cues.mjs compile [hero.png] \
--slug [slug] \
--palette "primary=#RRGGBB;secondary=#RRGGBB;tertiary=#RRGGBB;neutral=#RRGGBB" \
--out .impeccable/visual-cues
```
The script copies the hero untouched to `[slug].png` (removing a `[slug]-hero.png` intermediate inside the out dir, so the folder holds one file per cue, not a byte-identical pair); for each palette role it searches the hero for the closest rendered pixel (`snapped`, with its hero position), then updates `cues.json`:
```json
{
"cues": ["amber-dusk", "coastal-glass"],
"palette": {
"amber-dusk": { "primary": { "hex": "#B8422E", "snapped": "#B4402F", "at": [312, 540] } }
}
}
```
Done when: `cues.json` lists one entry per completed palette and every listed slug has its hero PNG on disk.
## Step 6: Compose the font pairs
Run this pass yourself after compiling the cues and before launching the picker. Do not spawn specialists; six pairs need one editor holding the same brand facts and ranking them together.
Build the composition context from exactly these inputs:
- **The surface modes** this step names below. The interview asks no typography direction on this path, so the modes, the references, and the assets are the anchor; compose from them instead of asking for a direction.
- **The three named references** and **the anti-reference** from the seed interview. The anti-reference is a hard constraint on every pair.
- From PRODUCT.md, only `## Users`, `## Product Purpose`, `## Positioning`, and `## Brand Commitments`.
- The seed Step 2 asset observations when they exist, with the logo's letterforms as the strongest evidence.
Do **not** read PRODUCT.md wholesale into this task or add any other section to the composition context. The chosen palette does not exist yet; the picker joins it to the pairs later.
### Name the surfaces before composing
A pair that carries a landing page can fail a dashboard outright. The landing page asks the heading face for a six-word line at 40px and up; the dashboard asks the body face for a 12px column label sitting next to a number. Suggest fonts without knowing which of those is on the table and you are guessing at the only question that separates the shortlists.
So decide first what this product is made of, from PRODUCT.md and the codebase, using the four surface kinds the picker's first question offers: `persuade` (landing, marketing, pricing), `operate` (app UI, dashboards, admin, settings), `read` (docs, articles, guides, changelogs), `experience` (portfolios, galleries, showcases). Name every kind the product already implies, not the one it leads with: a tool with a marketing site and a documentation site is `operate, read, persuade`. This is the same set Step 7 writes into `context.json` as `modes`, so make the judgment once, here, and carry it. No clear signal anywhere leaves the set at `persuade` alone.
What each surface asks of a pair:
- **persuade**: the heading face is the page. It has to hold a short line at display size, where counters, joins, and one badly drawn character are all visible at a glance. The body face sets a paragraph and two button labels, so it is asked for less. This is the surface where a face with a point of view earns its place.
- **operate**: the body face is also the interface face, and it works between 11px and 14px on column headings, form labels, menu items, and numbers in a row. Ask it for a 1, l, and I that stay apart, a 0 that does not read as O, lining figures, and a medium or semibold that the family actually draws rather than one the browser fakes. Headings here are 16px to 24px panel titles set many times per screen, so a face that only comes alive at poster size is the wrong heading for this surface.
- **read**: the body face carries hundreds of words at 16px to 18px across a 60 to 75 character line, which is the hardest job on this list. Ask it for a generous x-height, an italic the family drew rather than sloped, and a bold that still reads inline. The heading face sits inside running text at 1.2 to 1.6 times the body, close enough that a mismatch in proportion shows immediately.
- **experience**: the work is the subject and the type is the room around it. The heading face can be the most expressive of the six pairs. The body face sets captions, credits, and index metadata at 11px to 13px, often tracked out in caps, so it has to stay even when letter-spaced and survive at those sizes.
Rank against the strictest surface in the set, never the loudest. Operate and read set the floor the body face has to clear; persuade and experience set how far the heading is allowed to go. Every pair still has to serve every named surface: the user picks one pair for the whole product, and one type system comes out the other end. A pair that only holds up on one surface belongs at the bottom of the list, or off it. None of this loosens [new-work.md](new-work.md)'s `rule:skill-typo-reflex-faces`, which rules all six pairs whatever the surfaces are.
Compose six distinct territories, then resolve each into one heading and body pair:
- Spread the six across type directions that serve the surface set (serif display + sans body, single sans, display + mono, and their neighbours), each pair's voice argued from a named reference, an asset letterform, or a PRODUCT.md brand fact. No two pairs may share a heading family or read as the same voice.
- Apply [new-work.md](new-work.md)'s `rule:skill-typo-reflex-faces` as the canonical denylist and subject-world test. A family the user named in the interview or supplied assets is the only exception.
- Follow [typeset.md](typeset.md)'s workhorse discipline. Give the heading a point of view; give the body a real text face that stays legible at 15px and provides regular and bold weights. A display face in the body slot fails the pair. Where the surface set names `operate` or `read`, that 15px floor is not the test the body face has to pass: the sizes in those two entries above are.
- Verify every family exists on Google Fonts under the exact current name. Spelling is part of correctness; use `Source Sans 3`, never a retired family name.
- Every pair uses Latin-script faces, and the specimen headline and preview copy are written in English, even when the product's own language is not. Multilingual and CJK support is not built yet: a non-Latin face renders the picker's previews and scale sheets wrong, so English stands in for now. TODO: language-aware pairs that match PRODUCT.md's language and load the right Google Fonts subsets, once the picker's previews support them.
- Write `why` as three to five words naming the pair's voice, not a sentence about the brand. The picker sets it in tracked caps under the two family names, so anything longer wraps and stops scanning. `Considered and editorial`, not `Source Serif 4 gives the questionnaire an editorial voice while Source Sans 3 keeps guidance easy to scan`.
- Order the pairs best-first, judged on the strictest surface in the set. `pairs[0]` is the recommendation and reaches the picker pre-selected.
Choose the headline and every wireframe label from the product's own world. Do not invent claims or use placeholder prose that could describe any brand.
- **Hero**: a headline of at most six words (`specimen.headline`).
- **Wireframe**: every other label the type-preview artboard shows (`preview`): a short brand mark, four nav labels, nav and menu actions, two CTA labels, four proof chips, a section title, one section link, three gallery cards (`title` + `meta`), four footer links, and a footer mark. Pull each string from PRODUCT.md, the interview, or supplied assets. Keep labels short enough to fit the artboard.
**Running text is not yours to write.** The picker sets every paragraph in lorem, because a body face is judged on texture and real prose pulls the eye into reading it instead. Leave `specimen.body` and `preview.sectionBody` out of the file.
Write `.impeccable/visual-cues/fonts.json` with this shape:
```json
{
"version": 1,
"specimen": {
"headline": "Six words from the product's world"
},
"preview": {
"brand": "Ab",
"nav": ["Shop", "Stories", "Visit", "About"],
"navAction": "Order",
"menuAction": "Menu",
"ctaPrimary": "Primary action",
"ctaSecondary": "Secondary action",
"proof": ["Proof one", "Proof two", "Proof three", "Proof four"],
"sectionTitle": "Section title",
"sectionLink": "Section link",
"gallery": [
{ "title": "Card one", "meta": "Detail one" },
{ "title": "Card two", "meta": "Detail two" },
{ "title": "Card three", "meta": "Detail three" }
],
"footerLinks": ["Link one", "Link two", "Link three", "Link four"],
"footerMark": "© Brand"
},
"pairs": [
{
"id": "kebab-slug",
"name": "Short human label",
"heading": { "family": "Exact Google Fonts Name", "weight": 600 },
"body": { "family": "Exact Google Fonts Name", "weight": 400 },
"why": "One sentence tying this pair to a named brand fact."
}
]
}
```
Write exactly six pair entries. Each role carries the single weight it needs; the picker also loads weight 700 for each body family. A per-pair `specimen` or `preview` override may replace the shared strings when the brand evidence warrants it.
If the references are missing because the interview was skipped, say in one line that the typography set is composed from product truth alone, then compose all six from the four allowed PRODUCT.md sections. Still write the file.
Parse the finished file as JSON and verify its version, specimen, preview (every field above), six unique ids, six unique heading families, role names, weights, and short `why` fields before continuing.
Done when: `fonts.json` is parseable, contains exactly six ranked pairs, every family name has been checked against Google Fonts, and the preview copy reads as this product, not generic SaaS filler.
## Step 7: Launch the picker
Before launching, write the surface set from Step 6 into `.impeccable/design-context/context.json` as a top-level `modes` array: any of `persuade`, `operate`, `read`, `experience`. Do not re-derive it; the font pairs were composed against that reading, and a second judgment here would hand the user tiles the shortlist never answered to. The picker's first question pre-checks those tiles as its starting point; the user corrects the set by hand, and the final selection returns in the answers as `surface-modes`. Omit the field when the product gave no clear signal; the picker then starts from `persuade` alone.
In the same write, add a top-level `context` object carrying the chat half of the run. The whole file is `{ "schemaVersion": 1, "modes": [...], "context": {...} }`, and it is the store's copy of what chat learned, because after the last question the picker shows the user a design context document assembled from everything the interview learned, and the browser only knows what it asked itself. Every field is optional and the document renders whatever arrives, so fill what the run actually established and leave out the rest:
```json
"context": {
"product": {
"name": "[product name]",
"purpose": "[one-sentence purpose from PRODUCT.md]",
"success": "[the success definition from PRODUCT.md Product Purpose, one line]",
"platform": "[bare value from PRODUCT.md Platform: web, ios, android, or adaptive]",
"positioning": { "not": "[what it is not, from PRODUCT.md Positioning]", "this": "[what it is instead]" },
"clarities": ["[one line per item of PRODUCT.md's what-must-be-clear-first list]"],
"conversion": "[primary conversion from PRODUCT.md Product Purpose, bare action: book a consultation]",
"principles": [{ "title": "[principle name from PRODUCT.md Design Principles]", "detail": "[one clause: what it means for design]" }],
"surfaces": { "persuade": "[what this surface is for this product, one line]", "operate": "[...]", "read": "[...]", "experience": "[...]" },
"operatingContext": "[one line from PRODUCT.md Operating Context]"
},
"audience": {
"primary": "[who]", "secondary": "[who]",
"emotion": "[emotional goal on landing]",
"leaving": "[what they should leave with, from the purpose and success definition]",
"needs": ["[need]"],
"trust": ["[trust trigger, from PRODUCT.md Evidence on Hand and Users]"],
"inclusion": ["[who must not be excluded, from PRODUCT.md Accessibility and Inclusion]"]
},
"brand": {
"words": ["[word]"],
"personality": "[one sentence from PRODUCT.md Brand Personality]",
"principles": ["[one line per principle from PRODUCT.md Product Principles, or the legacy Design Principles heading]"],
"voice": [{ "say": "[a concrete line the product would write; 2 to 4 pairs, wording examples, never adjectives]", "not": "[the same message written the way the product refuses to sound]" }],
"commitments": ["[one line per commitment from PRODUCT.md Brand Commitments]"]
},
"assets": [
"[asset name: what Step 2 read off it; a plain string when no file was provided]",
{ "file": "[filename staged in .impeccable/design-context/assets/]", "kind": "[logo, moodboard, or reference]", "note": "[the one-line Step 2 observation for this file]" }
],
"color": { "assetLocks": ["[one short color fact an asset fixes, e.g. Primary locked from the logo mark; only when an asset names one]"] },
"interview": {
"references": [{ "name": "[interview reference, one entry per name]", "takeaway": "[one clause: what this reference lends the design]" }],
"antiReference": { "name": "[the interview's anti-reference]", "why": "[one clause: why this is the wrong direction]" }
}
}
```
Quote the user's answers, not paraphrases of them; the document labels interview fields as the questions they answered. A missing block renders as a pointer to where that truth lives (PRODUCT.md), so a run with no `context.json` at all still produces a complete document. The document reads each field from `context.json` first and falls back to a legacy `cues.json` that still carries it.
The optionality is field by field, and the document omits the block of any field that does not arrive, so fill a field only when its PRODUCT.md section or interview answer exists. A legacy PRODUCT.md without Positioning, Platform, Operating Context, or Brand Commitments yields a context without those fields, never an invented value. `product.clarities` carries PRODUCT.md's "What must be clear first" list under a shorter key. `product.conversion` names the single action the product most wants. `product.principles` carries PRODUCT.md's Design Principles, one `{ title, detail }` entry per line. `product.surfaces` maps each mode the run might choose to what that surface is for this product, not the generic tile copy. Only include keys for surfaces that exist in the product; the document reads the map for whichever surfaces the questionnaire chose. `interview.references` and `interview.antiReference` also accept their older shapes, plain strings, which render as the bare pills and single-name callout they always did. Never write `interview.colorStrategy`, `interview.hueAnchor`, `interview.typeDirection`, or `interview.motionEnergy`: the chat interview does not ask those questions on this path, `answers.json` owns color, typography, and motion, and the document already renders its interview-direction blocks only when those keys arrive, so their absence reads as chat silence, not as a gap. `assets` mixes both shapes in one list: a file the user actually provided is staged under `.impeccable/design-context/assets/` (seed Step 2 owns the copy) and written as the object form, which the document renders as an image (a `logo` proofed on the committed primary and neutral grounds, a `moodboard` or `reference` in a wide frame, the note under it); a words-only observation stays the plain string it always was.
Three of the additions are derived at write time rather than asked: `brand.principles` copies the PRODUCT.md principles list (the current Product Principles heading or the legacy Design Principles one), `brand.voice` distills Brand Personality and Brand Commitments into two to four say / not pairs, each half a concrete line of wording the product would or would not publish, never an adjective, and `color.assetLocks` records color facts the provided assets fix (one short line each, written only when Step 2 actually read such a fact off an asset). None of the three adds an interview question, and all three are omitted rather than invented when their source is missing.
Five of the questions are then answered per surface rather than once for the whole run, because the answer that suits a marketing page rarely suits the tool it sells: `color-strategy`, `motion-energy` (how much movement there is), `boundary-style` (how sections are separated), `corner-style` (how round shapes are), and `depth-style` (how far off the page things sit). Each of the five comes back twice over. The bare key holds the leading surface's answer, which is the first chosen tile in tile order and the one every later screen previews. Alongside it is one `<key>-<mode>` key for every surface chosen, `<mode>` being `persuade`, `operate`, `read`, or `experience`. Surfaces the user never opened are included too, holding the default for their kind; a surface nobody chose returns nothing at all.
`motion-energy` is the one exception to that shape, because the question is only put to two of the four surfaces. A landing page and a portfolio are watched, so how much they move is a house decision; a tool and a document are worked in, and their movement follows the interface. So the motion keys cover the chosen surfaces among `persuade` and `experience` only, and the bare key holds the first of those two in tile order rather than the run's leading surface: on an app UI plus portfolio run, `motion-energy` is the portfolio's answer. **When a run chooses neither of those surfaces the question is never asked, and no `motion-energy` key comes back at all.** Read it as absent rather than defaulted, and say nothing about movement in DESIGN.md; a default written as a decision is a decision the user never made.
`layout-structure` (how strict the composition is) is put to the same two surfaces, for the neighbouring reason: on a landing page and a portfolio the composition of the page is the thing being judged, where a tool's regions and a document's single measure come from what they have to hold. It is not a per-surface key, though. One answer is kept for the whole run and every surface is previewed on it, so **it comes back as the bare `layout-structure` and nothing else, owned by the first of `persuade` and `experience` in tile order, and it is absent entirely on a run of neither.** Read that absence the same way: no grid rule in DESIGN.md, and nothing borrowed from the interview to cover the gap.
The picker does not offer every option on every surface. A landing page can take any answer to all five questions, and the other three surfaces have options withheld from them: a page people work in or read at length is not offered the loudest color or the deepest shadow, a tool is not offered separation by spacing alone, and a portfolio is not offered four working colors or fully round controls. So a value that comes back is one that suits the surface it came from, and a difference between two surfaces is a decision rather than an inconsistency to reconcile.
When more than one surface comes back, DESIGN.md says what each of them does with color, movement, section separation, corner radius, and depth, instead of stating one answer for the product.
Tell the user in one line that the visual cues are ready at `.impeccable/visual-cues/` (name the count), then run `node .claude/skills/impeccable/scripts/picker-server.mjs` from the project root as a foreground command and parse its `PICKER_URL` line.
- **Cursor**: `browser_navigate` to the `PICKER_URL`; that is the in-IDE browser, where the questionnaire belongs. Do not skip this, and do not use the system opener while the tool works. The tab is the user's viewport only; never drive the questionnaire yourself, because the answers are the user's.
- **Another harness with a browser tool**: open the URL with that tool, on the same viewport-only rule.
- **No browser tool, or the tool call failed**: open the URL with the system opener (macOS `open`, Linux `xdg-open`), then tell the user in one line to finish in the opened tab.
- **Even the opener failed**: tell the user *"The design picker is running at [URL]; open it in your browser and finish there."*
Whichever branch ran, wait on the foreground process.
A relaunch on a project that has already been through this arrives with the previous answers filled in, and resumes an unfinished run from its own draft; `--fresh` starts blank. [design-context.md](design-context.md) owns that path.
The server process exiting is the completion signal; never poll or watch the answers file while it runs.
- **Exit 0**: read the `ANSWERS` path, tell the user the answers were received in one line, then return to [document.md](document.md) Steps 5-6 and write the seed DESIGN.md from that file (its questionnaire-seed mapping owns which key lands where). Do not show or describe the cues or ask for a pick in chat; the picker already settled the pick. The user's tab is meanwhile showing the design context document the picker built from the run, and that document is now a working surface: on submit the server forked a detached edit session (`picker-doc-session.mjs`) that keeps the tab connected. After the seed DESIGN.md is written, enter the edit loop below.
- **Exit 2**: tell the user the picker closed unanswered and that they can relaunch it with the same command. Never restart it unprompted.
## The document edit loop
The revealed document is editable in place, on live mode's division of labor:
- **Field edits are applied before you hear about them.** A palette color or a line of product truth is staged in the page, and pressing Apply sends the batch to the session, which writes every value into the store and journals it. What reaches you is the prose those values leave stale: a `save_batch` event naming each change and the document it is owed in.
- **Asks in words queue for you from the start.** Font changes (including uploaded faces, saved under `.impeccable/design-context/fonts/`) and freeform requests arrive as `edit_request` events, because there is no value to apply until you decide what it should be.
- **The session is the only writer of the store while it runs.** Never write `answers.json` or `context.json` yourself during the loop; attach the values to your reply instead (below) and let the session apply them. DESIGN.md and PRODUCT.md are yours.
After writing the seed DESIGN.md, tell the user in one line that the document in their tab is live for edits, then poll:
```
node .claude/skills/impeccable/scripts/picker-doc-poll.mjs
```
One-shot, exactly like live mode's poll: it blocks until one event and prints it as JSON. Run it on live mode's harness policy: on Claude Code as a background task; on Cursor as a one-shot poll in a background terminal with notify on `"type":"(edit_request|exit)"`; on Codex as a yielded foreground exec; elsewhere one-shot foreground. Never `--timeout` it short.
- `{"type":"edit_request", "id", "kind", "prompt", "category", "payload"}`: do the work. Apply the change to DESIGN.md, move any uploaded font files where the project keeps assets, then reply and poll again. Where a questionnaire key names the same fact, attach it rather than writing it, so the tab re-renders it and one process stays in charge of the store:
```
node .claude/skills/impeccable/scripts/picker-doc-poll.mjs --reply <id> done "One line the user sees in the tab"
node .claude/skills/impeccable/scripts/picker-doc-poll.mjs --reply <id> done "Swapped the pair" --answers '{"font-heading":"Fraunces"}'
```
Reply `error` with a reason when the ask cannot be applied; reply `retry` to put it back in the queue untouched.
- `{"type":"save_batch", "id", "changes", "downstream", "replyCommand"}`: the values are already in the store, so do not apply them again. Read `downstream` and bring each named document in line: `design-md` items are values DESIGN.md states (swap the value, and rename a color whose description no longer fits it), `product-md` items are product truth PRODUCT.md owns. Then reply with the command the event carries. A document that does not exist yet, or a value the document already carries, is success: reply `done`. Reply `error` only when a document exists and cannot be edited.
- `{"type":"timeout"}`: nothing arrived in the budget; poll again.
- `{"type":"exit"}`: the session ended (tab closed or timed out). Before moving on, read `runtime/journal.jsonl` for `change` entries you never saw a `save_batch` for, which is what a session that died mid-save leaves behind, and reconcile the prose around them. Then stop polling; the loop is over.
The user may keep working in chat while the document sits open; treat an `edit_request` like any other user instruction, just delivered through the tab.
@@ -90,5 +90,9 @@
"typeset": {
"description": "Improves typography by fixing font choices, hierarchy, sizing, weight, and readability so text feels intentional. Use when the user mentions fonts, type, readability, text hierarchy, sizing looks off, or wants more polished, intentional typography.",
"argumentHint": "[target]"
},
"design-context": {
"description": "Reopen, revise, export, or import the design interview and its design context document",
"argumentHint": "[open|edit|export|import] [bundle-file]"
}
}
@@ -47,6 +47,7 @@
*
* Usage:
* node scripts/concept-seed.mjs --scope direction --mode persuade
* node scripts/concept-seed.mjs --scope direction --mode persuade --seed-declined="<user's verbatim skip answer>"
* node scripts/concept-seed.mjs --scope surface --mode operate --from <key>
* node scripts/concept-seed.mjs --scope surface --mode operate --grain flow
* node scripts/concept-seed.mjs --scope direction --candidate-count 6
@@ -55,6 +56,16 @@
* node scripts/concept-seed.mjs --chosen <challenger-id> --kind challenger --from <key> --scope direction
* node scripts/concept-seed.mjs --kind assigned --from <key> --scope direction
*
* --seed-declined records that the user was offered the document --seed
* questionnaire on a no-DESIGN.md project and skipped it. The flag carries
* evidence: its value is the user's verbatim skip answer, quoted from the
* conversation. Without it, a --scope direction roll on a project with no
* DESIGN.md refuses to deal and prints the pause directive instead; a bare
* or empty flag refuses the same way (a real live session self-passed the
* boolean form without asking, which is why the flag demands the quote).
* Fabricating or paraphrasing the quote is a contract violation, not a
* shortcut.
*
* --grain names how much of the product is in play: product, flow, view, or
* region. A docs site, an onboarding flow, a landing page and a data table are
* four different amounts of product and want different compositions. Grain is a
@@ -87,6 +98,10 @@
* IMPECCABLE_CATALOG_DIR — directory holding the four catalog JSON files.
* IMPECCABLE_API_URL — roll API base (default https://impeccable.style/api).
* IMPECCABLE_NO_TELEMETRY — disables the choice ping (DO_NOT_TRACK also honored).
* IMPECCABLE_SEED_DECLINED — set to 1 to bypass the seed pause without a
* quote; the unattended escape for eval and CI harnesses that
* legitimately roll direction on a no-DESIGN.md workspace. Never for
* attended sessions; the bypass is logged to stderr so it stays auditable.
*/
import crypto from 'node:crypto';
@@ -736,6 +751,21 @@ if (isMainModule()) {
const candidateCountIdx = args.indexOf('--candidate-count');
const chosenIdx = args.indexOf('--chosen');
const kindIdx = args.indexOf('--kind');
// --seed-declined carries evidence: the user's verbatim skip answer, in
// either --seed-declined="<answer>" or --seed-declined <answer> form. A
// bare or empty flag does not count; a live session self-passed the
// boolean form without asking the user, so the flag demands the quote.
let seedDeclinedAnswer = null;
for (let i = 0; i < args.length; i += 1) {
if (args[i] === '--seed-declined') {
const next = args[i + 1];
seedDeclinedAnswer = next !== undefined && !next.startsWith('--') ? next : '';
} else if (args[i].startsWith('--seed-declined=')) {
seedDeclinedAnswer = args[i].slice('--seed-declined='.length);
}
}
const seedDeclinedByEnv = process.env.IMPECCABLE_SEED_DECLINED === '1';
const seedDeclined = Boolean(seedDeclinedAnswer && seedDeclinedAnswer.trim()) || seedDeclinedByEnv;
try {
if (chosenIdx !== -1 || kindIdx !== -1) {
// Choice ping: always exits 0, telemetry must never fail a design flow.
@@ -776,7 +806,8 @@ if (isMainModule()) {
// rolled directions with no PRODUCT.md, so nothing grounded the fusion).
// The --chosen branch above stays ungated; telemetry never blocks.
const { loadContext } = await import('./context.mjs');
if (!loadContext(process.cwd()).hasProduct) {
const ctx = loadContext(process.cwd());
if (!ctx.hasProduct) {
process.stdout.write([
'NO_PRODUCT_MD: the dice stay in the cup until product truth exists.',
'Complete the init ask round and write PRODUCT.md first (reference/init.md), then re-run this exact command.',
@@ -784,6 +815,31 @@ if (isMainModule()) {
].join(' ') + '\n');
process.exit(1);
}
// Mechanical seed-pause gate: prose alone did not keep a model from
// rolling a direction before offering the seed questionnaire (a real
// session read a stale reference file and dealt straight after init),
// and a boolean flag did not either (the next session self-passed it
// without asking). A direction roll invents the visual world, so on a
// project with no DESIGN.md the user gets the choice first; a seed
// DESIGN.md counts as present, so a post-questionnaire re-entry never
// re-asks. The flag now carries the user's verbatim skip answer.
const scopeArg = scopeIdx !== -1 ? args[scopeIdx + 1] : 'surface';
if (scopeArg === 'direction' && !ctx.hasDesign && !seedDeclined) {
process.stdout.write([
'NO_DESIGN_MD: the dice stay in the cup until the user answers the seed question.',
'First action: create a tracked todo "Ask user: document --seed or skip" with the harness todo tool and start no other todo until it is answered; with no todo tool, state this gate to the user in chat before anything else.',
'Then ask one question: recommend `document --seed`, the guided interview plus browser questionnaire (reference/document.md, seed mode), because a world built from the user\'s own choices beats one assigned to them; offer the skip in the same breath.',
'User accepts: run document seed mode; its seed DESIGN.md reads as an established world, so no direction roll happens.',
'User skips: re-run this exact command with --seed-declined="<their verbatim skip answer>" quoting the user\'s actual words from this conversation.',
'The original build request is never a skip answer, a bare or empty flag refuses again, and a fabricated or paraphrased quote is a contract violation.',
].join(' ') + '\n');
process.exit(1);
}
if (scopeArg === 'direction' && !ctx.hasDesign && seedDeclinedByEnv && !(seedDeclinedAnswer && seedDeclinedAnswer.trim())) {
// Auditable trace for the unattended escape; stderr keeps the seed
// output clean for the agent.
process.stderr.write('seed pause bypassed via IMPECCABLE_SEED_DECLINED (unattended harness escape)\n');
}
process.stdout.write(await renderConceptSeed({
scope: scopeIdx !== -1 ? args[scopeIdx + 1] : 'surface',
key: fromIdx !== -1
@@ -0,0 +1,67 @@
#!/usr/bin/env node
/** Write this project's design context out in two forms.
*
* node <scripts_path>/design-context-export.mjs [--out DIR] [--no-assets]
*
* design-context.md one document a reader or another tool can follow
* design-context.bundle.json everything needed to rebuild the store elsewhere
*
* Prints one EXPORTED line per file written. Exit 1 when the project has no
* design interview to export.
*/
import { migrate } from './design-context/store.mjs';
import { exportDesignContext } from './design-context/portability.mjs';
function printHelp() {
console.log(`Usage: node design-context-export.mjs [options]
Write the design context to a readable document and a portable bundle.
Options:
--out DIR Where to write (default: .impeccable/design-context/exports)
--no-assets Leave supplied files and the cue image out of the bundle
--help Show this help
Output:
EXPORTED PATH One line per file written
See reference/design-context.md for the canonical agent flow.`);
}
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
printHelp();
process.exit(0);
}
const readValue = (name) => {
const exact = args.find((arg) => arg.startsWith(`${name}=`));
if (exact) return exact.slice(name.length + 1);
const at = args.indexOf(name);
return at !== -1 && args[at + 1] && !args[at + 1].startsWith('--') ? args[at + 1] : '';
};
const unknown = args.find((arg) => arg.startsWith('--')
&& !['--out', '--no-assets', '--help'].some((flag) => arg === flag || arg.startsWith(`${flag}=`)));
if (unknown) {
console.error(`Unknown option: ${unknown}`);
process.exit(1);
}
await migrate(process.cwd());
try {
const { markdownPath, bundlePath, skipped } = await exportDesignContext(process.cwd(), {
outDir: readValue('--out') || undefined,
includeAssets: !args.includes('--no-assets'),
});
for (const entry of skipped) {
console.error(`Skipped ${entry.path} (${entry.bytes} bytes): ${entry.reason}`);
}
console.log(`EXPORTED ${markdownPath}`);
console.log(`EXPORTED ${bundlePath}`);
} catch (error) {
console.error(error.message);
process.exit(1);
}
@@ -0,0 +1,83 @@
#!/usr/bin/env node
/** Rebuild a design context in this project from a bundle another one exported.
*
* node <scripts_path>/design-context-import.mjs <bundle.json>
* [--design skip|write] [--force]
*
* Refuses a project that already has a design context unless --force, and
* refuses either way while an edit session is running, because the session is
* the only writer of the store while it lives.
*
* Prints IMPORTED <n> files and DESIGN_MD carried|absent for the agent to
* branch on. Exit 1 on a bundle this release cannot read.
*/
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { migrate, paths, pidAlive, readAnswers, readJsonSoft } from './design-context/store.mjs';
import { importDesignContext, validateBundle } from './design-context/portability.mjs';
function printHelp() {
console.log(`Usage: node design-context-import.mjs <bundle.json> [options]
Rebuild this project's design context from an exported bundle.
Options:
--design skip|write Write DESIGN.md when the bundle carries one and this
project has none (default: skip)
--force Replace an existing design context
--help Show this help
Output:
IMPORTED N files
DESIGN_MD carried|absent
See reference/design-context.md for the canonical agent flow.`);
}
const args = process.argv.slice(2);
if (!args.length || args.includes('--help') || args.includes('-h')) {
printHelp();
process.exit(args.length ? 0 : 1);
}
const source = args.find((arg) => !arg.startsWith('--'));
if (!source) {
console.error('Name the bundle to import.');
process.exit(1);
}
const designAt = args.indexOf('--design');
const design = designAt !== -1 && args[designAt + 1] ? args[designAt + 1] : 'skip';
if (!['skip', 'write'].includes(design)) {
console.error('--design must be skip or write');
process.exit(1);
}
await migrate(process.cwd());
const target = paths(process.cwd());
/* A running session holds the store: importing under it would swap the run out
from beneath the document someone is reading and the batch it may owe. */
const session = await readJsonSoft(target.sessionJson);
if (session && pidAlive(session.pid)) {
console.error(`A design context document is open on http://127.0.0.1:${session.port}. Close it, then import.`);
process.exit(1);
}
if (!args.includes('--force') && await readAnswers(process.cwd())) {
console.error('This project already has a design context. Re-run with --force to replace it.');
process.exit(1);
}
let bundle;
try {
bundle = validateBundle(JSON.parse(await readFile(path.resolve(process.cwd(), source), 'utf8')));
} catch (error) {
console.error(error.message);
process.exit(1);
}
const result = await importDesignContext(process.cwd(), bundle, { design });
console.log(`IMPORTED ${result.written} files`);
console.log(`DESIGN_MD ${result.designCarried ? 'carried' : 'absent'}${result.designWritten ? ' written' : ''}`);
@@ -0,0 +1,82 @@
/** What the design context document lets a person edit, and where it lands.
*
* Every editable field has an id the browser sends and this file resolves into
* a file and a path inside it. That is what makes applying a change a
* deterministic write rather than a search: the document names the field, not
* the text it happens to hold.
*
* `file` is the store file the value lives in. For `context`, the path is
* relative to the top-level `context` object, so `product.purpose` addresses
* `context.product.purpose` inside context.json.
*
* `downstream` names the document the agent reconciles afterwards. The value
* itself is already applied by the time the agent hears about it; what needs a
* reader is the prose around it.
*/
export const BINDINGS = {
'palette.primary': { file: 'answers', path: 'palette-primary', kind: 'color', downstream: 'design-md' },
'palette.secondary': { file: 'answers', path: 'palette-secondary', kind: 'color', downstream: 'design-md' },
'palette.tertiary': { file: 'answers', path: 'palette-tertiary', kind: 'color', downstream: 'design-md' },
'palette.neutral': { file: 'answers', path: 'palette-neutral', kind: 'color', downstream: 'design-md' },
'product.purpose': { file: 'context', path: 'product.purpose', kind: 'text', maxLen: 600, downstream: 'product-md' },
'product.positioning.not': { file: 'context', path: 'product.positioning.not', kind: 'text', maxLen: 300, downstream: 'product-md' },
'product.positioning.this': { file: 'context', path: 'product.positioning.this', kind: 'text', maxLen: 300, downstream: 'product-md' },
'audience.primary': { file: 'context', path: 'audience.primary', kind: 'text', maxLen: 300, downstream: 'product-md' },
'audience.secondary': { file: 'context', path: 'audience.secondary', kind: 'text', maxLen: 300, downstream: 'product-md' },
'audience.emotion': { file: 'context', path: 'audience.emotion', kind: 'text', maxLen: 300, downstream: 'product-md' },
'audience.leaving': { file: 'context', path: 'audience.leaving', kind: 'text', maxLen: 300, downstream: 'product-md' },
'brand.personality': { file: 'context', path: 'brand.personality', kind: 'text', maxLen: 600, downstream: 'product-md' },
};
const DEFAULT_MAX_LEN = 2000;
const HEX = /^#[0-9a-fA-F]{6}$/;
export const bindingFor = (id) => (Object.hasOwn(BINDINGS, id) ? BINDINGS[id] : null);
/**
* Turn what a contenteditable produced into something safe to write.
*
* Everything arriving here was typed into a browser, so it is treated as text
* and nothing else: control characters go, newlines collapse (every bound field
* is a single line in the document), and the length is capped where the field
* says so. A value that survives is a string; a value that cannot be one throws.
*/
export function sanitizeValue(binding, raw) {
if (binding.kind === 'color') {
const value = String(raw ?? '').trim().toUpperCase();
if (!HEX.test(value)) throw new Error('Expected a #rrggbb color');
return value;
}
const text = String(raw ?? '')
/* Newlines first, because they are the one control character with a
meaning here: a pasted paragraph becomes one line rather than nothing. */
.replace(/[\r\n\t]+/g, ' ')
.replace(/[\u0000-\u001F\u007F]/g, '')
.replace(/\s{2,}/g, ' ')
.trim();
if (!text) throw new Error('Expected some text');
return text.slice(0, binding.maxLen || DEFAULT_MAX_LEN);
}
/** Read a dotted path out of a plain object, without creating anything. */
export function readPath(root, dotted) {
return dotted.split('.').reduce((node, key) => (node && typeof node === 'object' ? node[key] : undefined), root);
}
/** Write a dotted path into a plain object, creating the objects on the way. */
export function writePath(root, dotted, value) {
const keys = dotted.split('.');
const last = keys.pop();
let node = root;
for (const key of keys) {
if (!node[key] || typeof node[key] !== 'object' || Array.isArray(node[key])) node[key] = {};
node = node[key];
}
node[last] = value;
return root;
}
@@ -0,0 +1,339 @@
/** Taking a design context out of a project, and putting one into another.
*
* Two shapes, because they answer different questions. `design-context.md` is
* for a reader, human or otherwise: one document that says what was decided
* and why, which can be handed to another tool as the rules to follow. The
* bundle is for this toolchain: everything needed to rebuild the store
* somewhere else, including the bytes of the files the user supplied.
*
* The bundle carries the schema version, not the store. A store file's era is
* readable from its own keys, and stamping the browser's submission would mean
* rewriting what it sent.
*/
import { readFile, mkdir, readdir, writeFile } from 'node:fs/promises';
import path from 'node:path';
import {
paths,
readAnswers,
readContext,
readJsonSoft,
writeAnswers,
writeContext,
writeJsonAtomic,
SCHEMA_VERSION,
} from './store.mjs';
export const BUNDLE_KIND = 'impeccable-design-context';
export const BUNDLE_SCHEMA = 1;
const MAX_FILE_BYTES = 1024 * 1024;
const MAX_BUNDLE_BYTES = 20 * 1024 * 1024;
const MIME = new Map([
['.svg', 'image/svg+xml'], ['.png', 'image/png'], ['.jpg', 'image/jpeg'],
['.jpeg', 'image/jpeg'], ['.webp', 'image/webp'], ['.gif', 'image/gif'],
['.woff2', 'font/woff2'], ['.woff', 'font/woff'], ['.ttf', 'font/ttf'], ['.otf', 'font/otf'],
]);
/* Exactly the three places an export puts bytes, and so exactly the three an
import will write them back to. Anything else in a bundle is not ours. */
const ALLOWED_FILE = /^(assets\/[^/]+|fonts\/[^/]+|cue\.png)$/;
const SURFACE_LABELS = { persuade: 'Landing page', operate: 'Tool', read: 'Docs', experience: 'Portfolio' };
const ROLES = ['primary', 'secondary', 'tertiary', 'neutral'];
const PER_SURFACE = ['color-strategy', 'boundary-style', 'corner-style', 'depth-style', 'motion-energy'];
/* ============================================================
Export
============================================================ */
async function collectFiles(cwd, { includeAssets = true } = {}) {
const target = paths(cwd);
const files = [];
const skipped = [];
let total = 0;
const take = async (absolute, relative) => {
let bytes;
try {
bytes = await readFile(absolute);
} catch {
return;
}
if (bytes.length > MAX_FILE_BYTES || total + bytes.length > MAX_BUNDLE_BYTES) {
skipped.push({ path: relative, bytes: bytes.length, reason: 'too large for the bundle' });
return;
}
total += bytes.length;
files.push({
path: relative,
mime: MIME.get(path.extname(relative).toLowerCase()) || 'application/octet-stream',
base64: bytes.toString('base64'),
});
};
if (!includeAssets) return { files, skipped };
for (const [dir, prefix] of [[target.assetsDir, 'assets'], [target.fontsDir, 'fonts']]) {
let names = [];
try {
names = await readdir(dir);
} catch {
continue;
}
for (const name of names.sort()) await take(path.join(dir, name), `${prefix}/${name}`);
}
await take(target.cuePng, 'cue.png');
return { files, skipped };
}
export async function buildBundle(cwd, { includeAssets = true, now = new Date() } = {}) {
const target = paths(cwd);
const answers = await readAnswers(cwd);
if (!answers) throw new Error('No design interview found. Run /impeccable document to create one.');
const stored = (await readContext(cwd)) || { schemaVersion: SCHEMA_VERSION };
const cues = await readJsonSoft(target.cuesJson);
const source = typeof answers['palette-source'] === 'string' ? answers['palette-source'] : '';
/* A seed or custom palette names no cue, so there is no image and no dealt
entry to carry. The hexes in the answers are the palette of record. */
const chosenCuePalette = source && cues?.palette?.[source] ? cues.palette[source] : null;
const { files, skipped } = await collectFiles(cwd, { includeAssets });
let designMd = null;
try {
designMd = await readFile(path.resolve(cwd, 'DESIGN.md'), 'utf8');
} catch {
/* Not written yet, which an import is told about rather than guessing. */
}
return {
schemaVersion: BUNDLE_SCHEMA,
kind: BUNDLE_KIND,
exportedAt: now.toISOString(),
product: { name: stored.context?.product?.name || '' },
context: stored,
answers,
/* Whole, never trimmed: the questionnaire validates the manifest by its
pair count and quietly falls back to its own set at any other number. */
fonts: await readJsonSoft(target.fontsManifestJson),
chosenCue: chosenCuePalette ? { slug: source, palette: chosenCuePalette } : null,
designMd,
files,
...(skipped.length ? { skipped } : {}),
};
}
/* ============================================================
The readable compilation
============================================================ */
const line = (label, value) => (value ? `- **${label}:** ${value}\n` : '');
function paletteTable(answers) {
const rows = ROLES
.map((role) => [role, String(answers[`palette-${role}`] || '')])
.filter(([, hex]) => hex);
if (!rows.length) return '';
return `| Role | Value |\n| --- | --- |\n${rows.map(([role, hex]) => `| ${role} | \`${hex}\` |`).join('\n')}\n\n`;
}
function perSurfaceTable(answers, surfaces) {
const rows = [];
for (const key of PER_SURFACE) {
for (const mode of surfaces) {
const value = answers[`${key}-${mode}`];
if (value) rows.push([key, SURFACE_LABELS[mode] || mode, String(value), answers[key] === value]);
}
}
if (!rows.length) return '';
return `| Question | Surface | Answer |\n| --- | --- | --- |\n${rows
.map(([key, label, value, leads]) => `| ${key} | ${label}${leads ? ' (leads)' : ''} | ${value} |`)
.join('\n')}\n\n`;
}
/** One document a reader, or another tool, can follow without this toolchain. */
export function renderMarkdown(bundle) {
const context = bundle.context?.context || {};
const answers = bundle.answers || {};
const name = bundle.product?.name || 'This product';
const surfaces = [].concat(answers['surface-modes'] || []).filter(Boolean);
const out = [];
out.push(`# Design context: ${name}\n\n`);
out.push('The decisions this product\'s design follows, and the reasoning behind them. ');
out.push('Exported from Impeccable; treat it as the source of truth for visual and product direction.\n\n');
const audience = context.audience || {};
if (Object.keys(audience).length) {
out.push('## Audience\n\n');
out.push(line('Primary', audience.primary));
out.push(line('Secondary', audience.secondary));
out.push(line('On arrival', audience.emotion));
out.push(line('Leaving with', audience.leaving));
if (audience.needs?.length) out.push(`- **Needs:** ${audience.needs.join('; ')}\n`);
if (audience.trust?.length) out.push(`- **Trust triggers:** ${audience.trust.join('; ')}\n`);
if (audience.inclusion?.length) out.push(`- **Must not exclude:** ${audience.inclusion.join('; ')}\n`);
out.push('\n');
}
const product = context.product || {};
if (Object.keys(product).length) {
out.push('## Product\n\n');
out.push(line('Purpose', product.purpose));
out.push(line('Success', product.success));
out.push(line('Platform', product.platform));
out.push(line('Primary conversion', product.conversion));
if (product.positioning?.not) out.push(`- **Not this:** ${product.positioning.not}\n`);
if (product.positioning?.this) out.push(`- **This:** ${product.positioning.this}\n`);
if (product.clarities?.length) out.push(`- **Clear first:** ${product.clarities.join('; ')}\n`);
out.push('\n');
}
const brand = context.brand || {};
if (Object.keys(brand).length) {
out.push('## Brand\n\n');
if (brand.words?.length) out.push(line('Words', brand.words.join(', ')));
out.push(line('Personality', brand.personality));
if (brand.commitments?.length) out.push(`- **Commitments:** ${brand.commitments.join('; ')}\n`);
if (brand.voice?.length) {
out.push('\nVoice, as wording rather than adjectives:\n\n');
for (const pair of brand.voice) {
if (pair?.say && pair?.not) out.push(`- Say: ${pair.say}\n Not: ${pair.not}\n`);
}
}
out.push('\n');
}
const interview = context.interview || {};
if (interview.references?.length || interview.antiReference) {
out.push('## References\n\n');
for (const reference of interview.references || []) {
if (typeof reference === 'string') out.push(`- ${reference}\n`);
else if (reference?.name) out.push(`- **${reference.name}**${reference.takeaway ? `: ${reference.takeaway}` : ''}\n`);
}
const anti = interview.antiReference;
if (typeof anti === 'string') out.push(`- **Anti-reference:** ${anti}\n`);
else if (anti?.name) out.push(`- **Anti-reference:** ${anti.name}${anti.why ? ` (${anti.why})` : ''}\n`);
out.push('\n');
}
out.push('## Decisions\n\n');
if (surfaces.length) {
out.push(`Surfaces: ${surfaces.map((mode) => SURFACE_LABELS[mode] || mode).join(', ')}. `);
out.push('The first of these owns any answer stated once for the whole product.\n\n');
}
out.push('### Palette\n\n');
out.push(paletteTable(answers));
if (bundle.chosenCue?.slug) out.push(`Sampled from the generated cue \`${bundle.chosenCue.slug}\`.\n\n`);
out.push('### Typography\n\n');
out.push(line('Heading', answers['font-heading']));
out.push(line('Body', answers['font-body']));
out.push(line('Type scale', answers['type-scale'] && `${answers['type-scale']} (${answers['type-scale-ratio']})`));
out.push('\n');
if (answers['icon-pack-name']) {
out.push('### Icons\n\n');
out.push(`- **Pack:** ${answers['icon-pack-name']}${answers['icon-pack-license'] ? ` (${answers['icon-pack-license']})` : ''}\n`);
if (answers['icon-pack-url']) out.push(`- **Source:** ${answers['icon-pack-url']}\n`);
out.push('\nEvery icon comes from this pack; do not mix sets.\n\n');
}
const perSurface = perSurfaceTable(answers, surfaces);
if (perSurface) {
out.push('### Per surface\n\n');
out.push(perSurface);
}
if (answers['layout-structure']) out.push(`Composition: ${answers['layout-structure']}, one answer for the whole product.\n\n`);
if (bundle.designMd) {
out.push('## DESIGN.md\n\n');
out.push('The design document this context produced, verbatim.\n\n');
out.push('<!-- begin DESIGN.md -->\n\n');
out.push(bundle.designMd.trim());
out.push('\n\n<!-- end DESIGN.md -->\n');
}
return out.join('');
}
export async function exportDesignContext(cwd, { outDir, includeAssets = true, now } = {}) {
const bundle = await buildBundle(cwd, { includeAssets, now });
const destination = outDir ? path.resolve(cwd, outDir) : paths(cwd).exportsDir;
await mkdir(destination, { recursive: true });
const markdownPath = path.join(destination, 'design-context.md');
const bundlePath = path.join(destination, 'design-context.bundle.json');
await writeFile(markdownPath, renderMarkdown(bundle));
await writeJsonAtomic(bundlePath, bundle);
return { markdownPath, bundlePath, skipped: bundle.skipped || [] };
}
/* ============================================================
Import
============================================================ */
export function validateBundle(bundle) {
if (!bundle || typeof bundle !== 'object') throw new Error('That file is not a design context bundle');
if (bundle.kind !== BUNDLE_KIND) throw new Error(`Expected a ${BUNDLE_KIND} bundle, found ${String(bundle.kind)}`);
if (bundle.schemaVersion !== BUNDLE_SCHEMA) {
throw new Error(`This bundle is schema version ${String(bundle.schemaVersion)}; this release reads ${BUNDLE_SCHEMA}. Update impeccable.`);
}
if (!bundle.answers || typeof bundle.answers !== 'object') throw new Error('The bundle carries no answers');
return bundle;
}
export async function importDesignContext(cwd, bundle, { design = 'skip' } = {}) {
validateBundle(bundle);
const target = paths(cwd);
await writeAnswers(bundle.answers, cwd);
const context = bundle.context && typeof bundle.context === 'object'
? bundle.context
: { schemaVersion: SCHEMA_VERSION };
await writeContext(context, cwd);
let written = 0;
for (const file of Array.isArray(bundle.files) ? bundle.files : []) {
const relative = String(file?.path || '');
/* Containment is not enough on its own: a bundle could otherwise name a
store file and overwrite what was just written. Only the three places an
export puts bytes are accepted. */
if (!ALLOWED_FILE.test(relative)) {
process.stderr.write(`Skipped ${relative || '(unnamed)'}: not a place a design context keeps files\n`);
continue;
}
const absolute = path.resolve(target.storeDir, relative);
if (path.relative(target.storeDir, absolute).startsWith('..')) continue;
await mkdir(path.dirname(absolute), { recursive: true });
await writeFile(absolute, Buffer.from(String(file.base64 || ''), 'base64'));
written += 1;
}
/* The questionnaire cannot run without a cue manifest: its palette screen
loads the deck and the built-in seeds together, and neither arrives if the
file is missing. An imported project gets a valid one either way, carrying
the chosen cue's dealt values when the bundle brought them. */
if (!(await readJsonSoft(target.cuesJson))) {
await writeJsonAtomic(target.cuesJson, {
cues: [],
...(bundle.chosenCue?.slug ? { palette: { [bundle.chosenCue.slug]: bundle.chosenCue.palette } } : { palette: {} }),
});
}
if (bundle.fonts && !(await readJsonSoft(target.fontsManifestJson))) {
await writeJsonAtomic(target.fontsManifestJson, bundle.fonts);
}
let designWritten = false;
if (design === 'write' && typeof bundle.designMd === 'string' && bundle.designMd.trim()) {
const designPath = path.resolve(cwd, 'DESIGN.md');
if (!(await readFile(designPath, 'utf8').then(() => true).catch(() => false))) {
await writeFile(designPath, bundle.designMd);
designWritten = true;
}
}
return { written, designWritten, designCarried: typeof bundle.designMd === 'string' && Boolean(bundle.designMd.trim()) };
}
@@ -0,0 +1,236 @@
/** The save flow behind the design context document.
*
* A person edits fields in the document; the edits stage in the browser and
* arrive here as one batch when they press Apply. Applying is deterministic:
* every change names a binding, the binding names a file and a path, and the
* value is written through the store. Nothing is searched for and no model is
* involved, which is what makes a save either complete or refused rather than
* approximately done.
*
* What the agent gets afterwards is the reconciliation, not the write. The
* values are already on disk by the time the batch reaches a poll; DESIGN.md
* and PRODUCT.md are the agent's to bring in line with them.
*
* browser --POST /doc/save-------> applied here, journaled, batch queued
* agent --GET /doc/poll-------> save_batch (leased)
* agent --POST /doc/reply------> acknowledged, version bumped
* browser --GET /doc/state------> version moved, so re-read and re-render
*
* The batch is journaled before it is offered and cleared only on an
* acknowledgement, so a session that dies mid-flight re-offers it on the next
* boot rather than losing the work.
*/
import { bindingFor, readPath, sanitizeValue, writePath } from './bindings.mjs';
import {
appendJournal,
readAnswers,
readContext,
replayJournal,
writeAnswers,
writeContext,
SCHEMA_VERSION,
} from './store.mjs';
const MAX_CHANGES = 100;
/* Long enough that an agent doing real prose work is never raced, short enough
that an agent that died does not hold the batch for the session's lifetime. */
const LEASE_MS = 10 * 60_000;
function httpError(statusCode, message) {
const error = new Error(message);
error.statusCode = statusCode;
return error;
}
export function createSaveRoutes({ cwd = process.cwd(), onChange = () => {} } = {}) {
/* Recovered from the journal at boot: a batch the agent never acknowledged
is still owed, whoever was running when it was made. */
const replayed = replayJournal(cwd);
let pending = replayed.pendingBatch
? { ...replayed.pendingBatch, leaseUntil: 0 }
: null;
let counter = Number(replayed.lastSeq) || 0;
const summary = () => (pending
? { id: pending.id, status: pending.status, count: pending.changes.length }
: null);
function validate(body) {
const changes = Array.isArray(body?.changes) ? body.changes : null;
if (!changes?.length) throw httpError(400, 'changes must be a non-empty array');
if (changes.length > MAX_CHANGES) throw httpError(400, `at most ${MAX_CHANGES} changes per save`);
return changes.map((change) => {
const binding = bindingFor(String(change?.bindingId ?? ''));
if (!binding) throw httpError(400, `Unknown field: ${String(change?.bindingId ?? '')}`);
let value;
try {
value = sanitizeValue(binding, change.to);
} catch (error) {
throw httpError(400, `${change.bindingId}: ${error.message}`);
}
return {
bindingId: String(change.bindingId),
binding,
from: typeof change.from === 'string' ? change.from : '',
to: value,
};
});
}
/** One read and one write per file, so a save lands whole or not at all. */
async function applyToStore(changes) {
const files = new Map();
const load = async (file) => {
if (!files.has(file)) {
files.set(file, file === 'answers'
? (await readAnswers(cwd)) || {}
: (await readContext(cwd)) || { schemaVersion: SCHEMA_VERSION });
}
return files.get(file);
};
for (const change of changes) {
const document = await load(change.binding.file);
/* context.json wraps its payload, so a binding path addresses the
context object rather than the file's own root. */
const root = change.binding.file === 'context'
? (document.context ??= {})
: document;
change.previous = String(readPath(root, change.binding.path) ?? '');
writePath(root, change.binding.path, change.to);
}
if (files.has('answers')) await writeAnswers(files.get('answers'), cwd);
if (files.has('context')) await writeContext(files.get('context'), cwd);
}
return {
summary,
hasPending: () => Boolean(pending),
/** POST /doc/save */
async save(body) {
if (pending) throw httpError(409, 'A save is already applying');
const changes = validate(body);
await applyToStore(changes);
for (const change of changes) {
appendJournal({
type: 'change',
bindingId: change.bindingId,
from: change.previous,
to: change.to,
}, cwd);
}
counter += 1;
const id = `batch-${String(counter).padStart(3, '0')}`;
const recorded = changes.map(({ bindingId, previous, to, binding }) => ({
bindingId,
from: previous,
to,
downstream: binding.downstream,
}));
appendJournal({ type: 'batch', id, status: 'pending', changes: recorded }, cwd);
pending = { id, status: 'pending', changes: recorded, leaseUntil: 0 };
onChange();
return { id, count: recorded.length };
},
/** The event a polling agent is handed, or nothing when none is due. */
takeBatchEvent(replyCommandFor) {
if (!pending || pending.leaseUntil > Date.now()) return null;
/* Stamped before anything awaits, so a second poll arriving in the same
tick cannot be handed the same batch. */
pending.leaseUntil = Date.now() + LEASE_MS;
return {
type: 'save_batch',
id: pending.id,
changes: pending.changes,
downstream: pending.changes.filter((change) => change.downstream !== 'none'),
replyCommand: replyCommandFor(pending.id),
};
},
/**
* POST /doc/reply for a batch.
*
* An unknown id keeps the lease and says which batch is actually owed, so
* an agent that replied to the wrong thing can correct itself rather than
* leaving the work stranded.
*/
async reply(body) {
if (!pending) throw httpError(404, 'No save is waiting for a reply');
if (body.id !== pending.id) {
throw httpError(404, `Unknown save ${String(body.id)}; the one waiting is ${pending.id}`);
}
if (!['done', 'error', 'retry'].includes(body.status)) {
throw httpError(400, 'status must be done, error, or retry');
}
if (body.status === 'retry') {
pending.leaseUntil = 0;
onChange();
return { ok: true, status: 'pending' };
}
/* The agent's own follow-on writes ride here rather than going to the
store directly, so this process stays the only writer while it runs. */
const applied = await applyAgentUpdates(body, cwd);
appendJournal({ type: 'batch', id: pending.id, status: body.status, message: String(body.message || '') }, cwd);
pending = null;
onChange();
return { ok: true, status: body.status, applied };
},
/** Journaled so the tab re-reads on a font or freeform request too. */
noteRequest(id, status) {
appendJournal({ type: 'request', id, status }, cwd);
},
};
}
/**
* Key-value updates an agent attaches to its reply.
*
* Answers keys are written as given, since the questionnaire's own vocabulary
* is wider than the bound fields; context values go through their binding when
* one exists, so the same rules apply to both writers.
*/
async function applyAgentUpdates(body, cwd) {
const applied = { answers: 0, context: 0 };
if (body.answers && typeof body.answers === 'object' && !Array.isArray(body.answers)) {
const answers = (await readAnswers(cwd)) || {};
for (const [key, value] of Object.entries(body.answers)) {
if (typeof value !== 'string' && !Array.isArray(value)) continue;
answers[key] = value;
applied.answers += 1;
}
if (applied.answers) await writeAnswers(answers, cwd);
}
if (body.context && typeof body.context === 'object' && !Array.isArray(body.context)) {
const stored = (await readContext(cwd)) || { schemaVersion: SCHEMA_VERSION };
const root = (stored.context ??= {});
for (const [dotted, value] of Object.entries(body.context)) {
if (typeof value !== 'string') continue;
const binding = bindingFor(dotted);
let next = value;
if (binding) {
try {
next = sanitizeValue(binding, value);
} catch {
continue;
}
}
writePath(root, binding ? binding.path : dotted, next);
applied.context += 1;
}
if (applied.context) await writeContext(stored, cwd);
}
return applied;
}
@@ -0,0 +1,241 @@
/** The design-context store: the one place that knows where design context lives.
*
* Layout, under the project root:
*
* .impeccable/design-context/
* context.json { schemaVersion, modes, context } the chat half of the interview
* answers.json the questionnaire submission, flat FormData shape
* assets/ brand files the user supplied
* fonts/ font faces the user uploaded
* cue.png the chosen hero, copied at submit so the document stands alone
* runtime/ session.json, journal.jsonl, draft.json (gitignored)
* exports/ design-context.md, design-context.bundle.json (gitignored)
*
* Two rules hold this together. Every write goes through writeJsonAtomic, so a
* reader never sees a torn file. Every read comes off disk, so no process ever
* answers from a copy the file has moved past.
*
* Zero dependencies beyond node: builtins, like every other picker script.
*/
import fs from 'node:fs';
import { readFile, mkdir, rename, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
export const STORE_DIR = '.impeccable/design-context';
export const WORKSPACE_DIR = '.impeccable/visual-cues';
/* The shape of context.json. Bump only when the shape changes, never for a release. */
export const SCHEMA_VERSION = 1;
const LEGACY_DIR = '.impeccable/design-interview';
const LEGACY_FONTS_PREFIX = `${LEGACY_DIR}/fonts/`;
export function paths(cwd = process.cwd()) {
const store = path.resolve(cwd, STORE_DIR);
const runtime = path.join(store, 'runtime');
return {
storeDir: store,
contextJson: path.join(store, 'context.json'),
answersJson: path.join(store, 'answers.json'),
assetsDir: path.join(store, 'assets'),
fontsDir: path.join(store, 'fonts'),
cuePng: path.join(store, 'cue.png'),
runtimeDir: runtime,
sessionJson: path.join(runtime, 'session.json'),
journalJsonl: path.join(runtime, 'journal.jsonl'),
draftJson: path.join(runtime, 'draft.json'),
exportsDir: path.join(store, 'exports'),
workspaceDir: path.resolve(cwd, WORKSPACE_DIR),
cuesJson: path.resolve(cwd, WORKSPACE_DIR, 'cues.json'),
fontsManifestJson: path.resolve(cwd, WORKSPACE_DIR, 'fonts.json'),
};
}
/** The project-relative path an uploaded font is reported by, and stored under. */
export function fontRelativePath(name) {
return path.join(STORE_DIR, 'fonts', name);
}
export async function writeJsonAtomic(filePath, value) {
await mkdir(path.dirname(filePath), { recursive: true });
const temporary = `${filePath}.tmp`;
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`);
await rename(temporary, filePath);
}
export async function readJsonSoft(filePath) {
try {
const parsed = JSON.parse(await readFile(filePath, 'utf8'));
return parsed && typeof parsed === 'object' ? parsed : null;
} catch {
return null;
}
}
export const readContext = (cwd = process.cwd()) => readJsonSoft(paths(cwd).contextJson);
export const writeContext = (value, cwd = process.cwd()) => writeJsonAtomic(paths(cwd).contextJson, value);
export const readAnswers = (cwd = process.cwd()) => readJsonSoft(paths(cwd).answersJson);
export const writeAnswers = (value, cwd = process.cwd()) => writeJsonAtomic(paths(cwd).answersJson, value);
export const readDraft = (cwd = process.cwd()) => readJsonSoft(paths(cwd).draftJson);
export const writeDraft = (value, cwd = process.cwd()) => writeJsonAtomic(paths(cwd).draftJson, value);
export const clearDraft = (cwd = process.cwd()) => rm(paths(cwd).draftJson, { force: true }).catch(() => {});
/* ============================================================
The journal: append-only, replayed on every read.
============================================================ */
/** Append one event, stamped with the next seq and a timestamp. Returns the seq. */
export function appendJournal(event, cwd = process.cwd()) {
const { runtimeDir, journalJsonl } = paths(cwd);
const seq = replayJournal(cwd).lastSeq + 1;
fs.mkdirSync(runtimeDir, { recursive: true });
fs.appendFileSync(journalJsonl, `${JSON.stringify({ seq, ts: new Date().toISOString(), ...event })}\n`);
return seq;
}
/**
* Fold the journal into the state a booting session needs.
*
* Lines the fold cannot use are collected rather than thrown: a legacy
* doc-edits.jsonl record carries { at, type: 'color' } and no seq, and a torn
* final line is possible after a hard kill. Neither can move lastSeq or
* resurrect a batch, so both are diagnostics, not failures.
*/
export function replayJournal(cwd = process.cwd()) {
const { journalJsonl } = paths(cwd);
const state = { lastSeq: 0, pendingBatch: null, entries: [], diagnostics: [] };
let raw;
try {
raw = fs.readFileSync(journalJsonl, 'utf8');
} catch {
return state;
}
for (const line of raw.split('\n')) {
if (!line.trim()) continue;
let entry;
try {
entry = JSON.parse(line);
} catch {
state.diagnostics.push({ reason: 'unparseable', line: line.slice(0, 200) });
continue;
}
if (!entry || typeof entry !== 'object' || !Number.isInteger(entry.seq)) {
state.diagnostics.push({ reason: 'legacy-or-unsequenced', type: entry?.type || null });
continue;
}
state.entries.push(entry);
if (entry.seq > state.lastSeq) state.lastSeq = entry.seq;
if (entry.type === 'batch') {
state.pendingBatch = entry.status === 'pending' ? entry : null;
}
}
return state;
}
/* ============================================================
Migration from the pre-store layout.
============================================================ */
export function pidAlive(pid) {
if (!Number.isInteger(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch (error) {
/* EPERM means the process exists and is not ours to signal. */
return error.code === 'EPERM';
}
}
async function moveFile(from, to) {
if (fs.existsSync(to) || !fs.existsSync(from)) return false;
await mkdir(path.dirname(to), { recursive: true });
await rename(from, to);
return true;
}
/* Directories move child by child: renaming onto an existing directory fails,
and a run interrupted halfway leaves a destination that already exists. */
async function moveDirContents(fromDir, toDir) {
if (!fs.existsSync(fromDir)) return;
await mkdir(toDir, { recursive: true });
for (const name of fs.readdirSync(fromDir)) {
await moveFile(path.join(fromDir, name), path.join(toDir, name));
}
try {
if (fs.readdirSync(fromDir).length === 0) fs.rmdirSync(fromDir);
} catch {
/* Something arrived between the read and the remove; leaving it is safe. */
}
}
/** Uploaded-face paths were recorded as strings inside the answers themselves. */
function rewriteFontSources(answers) {
if (!answers || typeof answers !== 'object') return null;
let touched = false;
for (const [key, value] of Object.entries(answers)) {
if (typeof value !== 'string' || !value.includes(LEGACY_FONTS_PREFIX)) continue;
answers[key] = value.split(LEGACY_FONTS_PREFIX).join(`${STORE_DIR}/fonts/`);
touched = true;
}
return touched ? answers : null;
}
/**
* Bring a pre-store project onto the current layout. Idempotent and silent:
* a project that is already current, or was never interviewed, does nothing.
*
* A live session of the old shape holds the old paths in its own constants, so
* migrating under it would strand its writes. That case defers to the next boot.
*/
export async function migrate(cwd = process.cwd()) {
const legacyDir = path.resolve(cwd, LEGACY_DIR);
if (!fs.existsSync(legacyDir)) {
await migrateContextFromCues(cwd);
return { migrated: false, deferred: false };
}
const legacySession = path.join(legacyDir, 'doc-session.json');
const session = await readJsonSoft(legacySession);
if (session && pidAlive(session.pid)) return { migrated: false, deferred: true };
const target = paths(cwd);
await moveFile(path.join(legacyDir, 'answers.json'), target.answersJson);
await moveFile(path.join(legacyDir, 'doc-edits.jsonl'), target.journalJsonl);
await moveDirContents(path.join(legacyDir, 'assets'), target.assetsDir);
await moveDirContents(path.join(legacyDir, 'fonts'), target.fontsDir);
const answers = await readJsonSoft(target.answersJson);
const rewritten = rewriteFontSources(answers);
if (rewritten) await writeJsonAtomic(target.answersJson, rewritten);
await rm(legacySession, { force: true }).catch(() => {});
try {
if (fs.readdirSync(legacyDir).length === 0) fs.rmdirSync(legacyDir);
} catch {
/* Files the migration does not own stay where they are. */
}
await migrateContextFromCues(cwd);
return { migrated: true, deferred: false };
}
/* The chat half of the interview used to ride inside the cue manifest. It is
not a generation artifact, so it moves to the store; cues.json keeps its
cues and palette and is left untouched. */
async function migrateContextFromCues(cwd) {
const target = paths(cwd);
if (fs.existsSync(target.contextJson)) return;
const cues = await readJsonSoft(target.cuesJson);
if (!cues) return;
const hasModes = Array.isArray(cues.modes);
const hasContext = cues.context && typeof cues.context === 'object';
if (!hasModes && !hasContext) return;
await writeJsonAtomic(target.contextJson, {
schemaVersion: SCHEMA_VERSION,
...(hasModes ? { modes: cues.modes } : {}),
...(hasContext ? { context: cues.context } : {}),
});
}
@@ -0,0 +1,363 @@
#!/usr/bin/env node
// image-gen.mjs — image generation for keyless harnesses.
// Playbook: skill/reference/image-api.md (canonical; this help text is not).
//
// node image-gen.mjs --prompt "..." --out /abs/path.png
// [--ref /abs/ref.png] [--width 1408] [--height 1408]
//
// One CLI, several providers. IMAGE_GEN_PROVIDER in .impeccable/.env picks
// the backend:
// bfl FLUX (Black Forest Labs). No ref: flux-pro-1.1 text-to-image;
// with ref: flux-kontext-max image-to-image, aspect ratio 1:1.
// gemini Google Nano Banana (Gemini image models), always square 1:1.
// <else> delegates to a project-local .impeccable/image-gen.mjs that
// implements this same CLI (see image-api.md for the contract).
// When the provider line is missing it is inferred from the key's shape
// (Google keys start with "AIza"; anything else is treated as bfl).
//
// Prints the absolute output path on success; exits non-zero with the
// error on stderr on failure. Dependency-free; needs curl and (as a DNS
// fallback) dig on PATH.
//
// Reads IMAGE_GEN_API_KEY from the environment, falling back to
// ./.impeccable/.env relative to the working directory, so callers never
// need to `source` anything: run it from the project root and it finds
// the key itself.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import dns from "node:dns";
import { execFileSync, spawnSync } from "node:child_process";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ------------------------------------------------------------------ env
// The key lives in .impeccable/.env per the document seed flow. Loading it
// here (instead of requiring the caller to export it) removes the one setup
// step subagents historically forgot, which cost a failed call each time.
// IMAGE_API_KEY is accepted as a legacy alias: early seed runs wrote that
// name, and those .env files are still in the wild.
function loadEnv(...names) {
for (const name of names) if (process.env[name]) return process.env[name];
const envPath = path.join(process.cwd(), ".impeccable", ".env");
if (!fs.existsSync(envPath)) return undefined;
const vars = {};
for (const line of fs.readFileSync(envPath, "utf8").split("\n")) {
const m = line.match(/^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)\s*$/);
if (m) vars[m[1]] = m[2].replace(/^["']|["']$/g, "");
}
for (const name of names) if (vars[name]) return vars[name];
return undefined;
}
// ------------------------------------------------------------------ DNS
// Sandboxed harnesses (Claude Code among them) often block the default
// resolver for the providers' hosts while the hosts stay reachable by IP.
// So every request resolves the host here — system resolver first, then
// dig against the default, Google, and Cloudflare resolvers — and pins
// curl to the IP with --resolve. fetch() is never used; it dies at the
// DNS stage.
async function resolveIp(hostname) {
for (let attempt = 0; attempt < 3; attempt++) {
try {
const { address } = await dns.promises.lookup(hostname, { family: 4 });
if (address) return address;
} catch {
// fall through to dig
}
for (const server of [null, "8.8.8.8", "1.1.1.1"]) {
try {
const args = ["+short", "+time=3", "A", hostname];
if (server) args.push(`@${server}`);
const ips = execFileSync("dig", args, { encoding: "utf8" })
.trim()
.split("\n")
.map((l) => l.trim())
.filter((l) => /^\d+\.\d+\.\d+\.\d+$/.test(l));
if (ips.length > 0) return ips[ips.length - 1];
} catch {
// next resolver
}
}
await sleep(1000);
}
throw new Error(`cannot resolve ${hostname} via system resolver, dig, 8.8.8.8, or 1.1.1.1`);
}
// ----------------------------------------------------------------- curl
// Returns { status, json, text } instead of throwing on HTTP errors, so
// callers can branch on 402 (credits) and 429 (rate/quota) rather than
// seeing one opaque curl failure. Request bodies always travel via a temp
// file: a base64 reference image passed as a literal -d argument overflows
// argv (E2BIG) and kills the call before it reaches the network.
async function curlJson(url, { method = "GET", headers = {}, body } = {}) {
const { hostname } = new URL(url);
const ip = await resolveIp(hostname);
const args = ["-sS", "--max-time", "180", "--resolve", `${hostname}:443:${ip}`, "-X", method, "-w", "\n%{http_code}"];
for (const [k, v] of Object.entries(headers)) args.push("-H", `${k}: ${v}`);
let bodyFile;
if (body !== undefined) {
bodyFile = path.join(os.tmpdir(), `image-gen-body-${process.pid}-${Date.now()}.json`);
fs.writeFileSync(bodyFile, body);
args.push("-d", `@${bodyFile}`);
}
args.push(url);
try {
const out = execFileSync("curl", args, { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
const nl = out.lastIndexOf("\n");
const status = parseInt(out.slice(nl + 1), 10);
const text = out.slice(0, nl);
let json = null;
try {
json = JSON.parse(text);
} catch {
// non-JSON body (edge HTML error page); callers see json === null
}
return { status, json, text };
} finally {
if (bodyFile) fs.rmSync(bodyFile, { force: true });
}
}
async function download(url, outPath) {
const { hostname } = new URL(url);
let lastErr;
// Re-resolve on every attempt: CDN delivery hosts are the flakiest to
// resolve, and a fresh IP is usually what fixes a failure.
for (let attempt = 0; attempt < 3; attempt++) {
try {
const ip = await resolveIp(hostname);
execFileSync("curl", ["-sS", "-f", "--max-time", "60", "--resolve", `${hostname}:443:${ip}`, "-o", outPath, url]);
if (fs.existsSync(outPath) && fs.statSync(outPath).size > 0) return;
lastErr = new Error("download produced an empty file");
} catch (e) {
lastErr = e;
}
await sleep(2000 * (attempt + 1));
}
throw lastErr;
}
// ----------------------------------------------------------------- args
function getArg(name, def) {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 ? process.argv[i + 1] : def;
}
function fail(msg) {
console.error(msg);
process.exit(1);
}
// ------------------------------------------------------------------ bfl
// FLUX is asynchronous: submit returns a polling_url, poll until Ready,
// download the signed result URL inside its 10-minute expiry. Transient
// failures are absorbed internally so a network blip costs this script
// seconds instead of costing a caller one of its generation attempts.
// Only two failures are final on the spot: 402 means the account is out
// of credits (a human must top up; retrying is pointless), and a
// moderation status means the prompt itself must change.
async function generateBfl({ apiKey, prompt, ref, width, height, out }) {
for (const [label, v] of [["width", width], ["height", height]]) {
if (Number.isNaN(v) || v < 256 || v > 1440 || v % 32 !== 0) {
fail(`${label} ${v} out of range: BFL takes 256-1440 in multiples of 32`);
}
}
const base = "https://api.bfl.ai";
let endpoint, body;
if (ref) {
endpoint = "/v1/flux-kontext-max";
body = { prompt, input_image: fs.readFileSync(ref).toString("base64"), aspect_ratio: "1:1", output_format: "png" };
} else {
endpoint = "/v1/flux-pro-1.1";
body = { prompt, width, height, output_format: "png" };
}
const authHeaders = { "x-key": apiKey, "Content-Type": "application/json", accept: "application/json" };
let submit;
for (let attempt = 0; ; attempt++) {
try {
submit = await curlJson(base + endpoint, { method: "POST", headers: authHeaders, body: JSON.stringify(body) });
} catch (e) {
submit = { status: 0, json: null, text: e.message };
}
if (submit.status === 200 && submit.json?.polling_url) break;
if (submit.status === 402) fail("BFL account is out of credits; add credits at dashboard.bfl.ai and re-run");
if (submit.status === 401 || submit.status === 403) fail(`BFL rejected the key (HTTP ${submit.status}): check IMAGE_GEN_API_KEY`);
if (attempt >= 2) fail(`Submit failed after 3 attempts (last HTTP ${submit.status}): ${submit.text?.slice(0, 300)}`);
// 429 is the active-task cap (24 tasks; 6 for kontext-max): wait longer.
await sleep(submit.status === 429 ? 10000 : 2000 * (attempt + 1));
}
// Poll the returned polling_url (never a reconstructed one; the global
// endpoint requires it). Tolerate a few consecutive transient poll
// failures — the task keeps running server-side regardless.
let result;
let pollFailures = 0;
for (let i = 0; i < 150; i++) {
await sleep(2000);
let poll;
try {
poll = await curlJson(submit.json.polling_url, { headers: { "x-key": apiKey, accept: "application/json" } });
} catch {
poll = null;
}
if (!poll || poll.status >= 500 || !poll.json) {
if (++pollFailures >= 5) fail("Polling failed 5 times in a row; giving up");
continue;
}
pollFailures = 0;
if (poll.json.status === "Ready") {
result = poll.json.result;
break;
}
if (["Error", "Failed", "Content Moderated", "Request Moderated", "Task not found"].includes(poll.json.status)) {
fail(`Generation failed with status "${poll.json.status}": ${JSON.stringify(poll.json).slice(0, 300)}`);
}
}
if (!result) fail("Timed out waiting for the generation (5 minutes)");
// The sample URL is signed and expires after 10 minutes; download now.
await download(result.sample, out);
}
// --------------------------------------------------------------- gemini
// Nano Banana is synchronous: one generateContent call returns the image
// as base64 in the response, no polling, no delivery CDN. The aspect ratio
// is pinned 1:1 in imageConfig, so output is always square regardless of
// --width/--height (Gemini picks its own pixel size per tier; the pipeline
// only requires square). Moderation shows up as a response with no image
// part plus a block reason, not as an HTTP error.
async function generateGemini({ apiKey, prompt, ref, out }) {
// IMAGE_GEN_MODEL overrides for users on a different tier; the default
// is the high-volume Nano Banana model.
let model = loadEnv("IMAGE_GEN_MODEL") || "gemini-3.1-flash-image";
const parts = [{ text: prompt }];
if (ref) parts.push({ inlineData: { mimeType: "image/png", data: fs.readFileSync(ref).toString("base64") } });
const body = JSON.stringify({
contents: [{ parts }],
generationConfig: { responseModalities: ["IMAGE"], imageConfig: { aspectRatio: "1:1" } },
});
const headers = { "x-goog-api-key": apiKey, "Content-Type": "application/json" };
const urlFor = (m) => `https://generativelanguage.googleapis.com/v1beta/models/${m}:generateContent`;
let res;
for (let attempt = 0; ; attempt++) {
try {
res = await curlJson(urlFor(model), { method: "POST", headers, body });
} catch (e) {
res = { status: 0, json: null, text: e.message };
}
if (res.status === 200) break;
const msg = res.json?.error?.message || res.text?.slice(0, 300) || "";
if (res.status === 400 && /API key not valid/i.test(msg)) fail(`Gemini rejected the key: check IMAGE_GEN_API_KEY (${msg.slice(0, 200)})`);
if (res.status === 401 || res.status === 403) fail(`Gemini rejected the key (HTTP ${res.status}): ${msg.slice(0, 200)}`);
// Model ids drift between stable and -preview suffixes as Google
// promotes them; try the sibling name once before giving up.
if (res.status === 404 && !model.endsWith("-preview")) {
model = `${model}-preview`;
continue;
}
if (res.status === 429 && attempt >= 4) fail(`Gemini quota or rate limit exhausted after 5 attempts: ${msg.slice(0, 200)}; check the plan and billing for this key`);
if (attempt >= 4) fail(`Gemini call failed after 5 attempts (last HTTP ${res.status}): ${msg.slice(0, 300)}`);
await sleep(res.status === 429 ? 15000 : 2000 * (attempt + 1));
}
const blocked = res.json?.promptFeedback?.blockReason;
if (blocked) fail(`Prompt was moderated (${blocked}); reword the prompt and re-run`);
const cand = res.json?.candidates?.[0];
const imgPart = cand?.content?.parts?.find((p) => p.inlineData?.data || p.inline_data?.data);
if (!imgPart) {
const reason = cand?.finishReason || "no image part in the response";
fail(`Generation returned no image (${reason}); reword the prompt and re-run`);
}
// Gemini often returns JPEG bytes whatever the caller's filename says,
// and the pipelines' compile steps decode PNG only, so convert here
// rather than making every caller rediscover the mismatch.
writeAsPng(Buffer.from(imgPart.inlineData?.data || imgPart.inline_data.data, "base64"), out);
}
// Writes image bytes to `out` as a real PNG. PNG input passes through;
// anything else (JPEG, WebP) is converted with the first available system
// tool: sips ships with macOS, ImageMagick and ffmpeg cover Linux.
function writeAsPng(buf, out) {
if (buf.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
fs.writeFileSync(out, buf);
return;
}
const tmp = path.join(os.tmpdir(), `image-gen-raw-${process.pid}-${Date.now()}.img`);
fs.writeFileSync(tmp, buf);
const converters = [
["sips", ["-s", "format", "png", tmp, "--out", out]],
["magick", [tmp, `png:${out}`]],
["convert", [tmp, `png:${out}`]],
["ffmpeg", ["-y", "-i", tmp, out]],
];
try {
for (const [cmd, args] of converters) {
try {
execFileSync(cmd, args, { stdio: "ignore" });
if (fs.existsSync(out) && fs.statSync(out).size > 0) return;
} catch {
// tool missing or failed; try the next one
}
}
fail("Provider returned non-PNG image bytes and no converter is available (tried sips, magick, convert, ffmpeg); install one and re-run");
} finally {
fs.rmSync(tmp, { force: true });
}
}
// ----------------------------------------------------------------- main
const prompt = getArg("prompt");
const out = getArg("out");
const ref = getArg("ref");
// 1408 is the default square: comfortably under BFL's 1440 cap and
// divisible by 32. Gemini ignores it (aspect ratio 1:1 pins its square).
const width = parseInt(getArg("width", "1408"), 10);
const height = parseInt(getArg("height", "1408"), 10);
const apiKey = loadEnv("IMAGE_GEN_API_KEY", "IMAGE_API_KEY");
// Users and earlier runs write provider names loosely ("flux" for bfl,
// "nano-banana" for gemini); normalize the known spellings instead of
// failing on them. Google API keys start with "AIza" (classic) or "AQ."
// (newer), so a missing provider line is recoverable from the key itself.
const PROVIDER_ALIASES = {
bfl: "bfl", flux: "bfl", "black-forest-labs": "bfl",
gemini: "gemini", google: "gemini", "nano-banana": "gemini", nanobanana: "gemini",
};
const looksGoogle = apiKey?.startsWith("AIza") || apiKey?.startsWith("AQ.");
const rawProvider = (loadEnv("IMAGE_GEN_PROVIDER") || (looksGoogle ? "gemini" : "bfl")).toLowerCase();
const provider = PROVIDER_ALIASES[rawProvider] || rawProvider;
if (!prompt || !out) fail("Usage: --prompt <p> --out <abs path> [--ref <abs path>] [--width n] [--height n]");
if (provider !== "bfl" && provider !== "gemini") {
// Unknown provider: hand the same argv to a project-local wrapper that
// implements this CLI. The env guard stops a copied shipped script from
// delegating to itself forever.
const custom = path.join(process.cwd(), ".impeccable", "image-gen.mjs");
if (process.env.IMPECCABLE_IMAGE_GEN_DELEGATED || !fs.existsSync(custom)) {
fail(`Unknown IMAGE_GEN_PROVIDER "${provider}" and no ${custom}; supported providers are bfl and gemini, or write that file implementing the same CLI (see reference/image-api.md)`);
}
const child = spawnSync(process.execPath, [custom, ...process.argv.slice(2)], {
stdio: "inherit",
env: { ...process.env, IMPECCABLE_IMAGE_GEN_DELEGATED: "1" },
});
process.exit(child.status ?? 1);
}
if (!apiKey) fail("Missing IMAGE_GEN_API_KEY (environment or ./.impeccable/.env)");
fs.mkdirSync(path.dirname(out), { recursive: true });
if (provider === "gemini") await generateGemini({ apiKey, prompt, ref, out });
else await generateBfl({ apiKey, prompt, ref, width, height, out });
console.log(path.resolve(out));
@@ -27,6 +27,7 @@
*/
import crypto from 'node:crypto';
import { pathToFileURL } from 'node:url';
// Seeds are inlined (129 entries, hand-curated via a tinder review of
// ~400 candidates from ColorHunt + synthesis + Radix/brand/Pantone anchors).
@@ -494,6 +495,12 @@ function hueWord(H) {
// ---------------------------------------------------------------
// The picker server imports SEEDS to serve /palettes.json; the CLI tail
// below only runs when this file is the entry point, so importing it has
// no side effects.
export { SEEDS };
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const args = parseArgs(process.argv.slice(2));
const seed = pickSeed(SEEDS, args);
const [L, C, H] = seed.oklch;
@@ -626,3 +633,4 @@ Dark text is correct only on PALE fills (L > 0.85) or PURE-NEUTRAL fills
Return your composed palette in CSS custom properties using OKLCH, then
build with it. The seed is the start, not the recipe.
`);
}
@@ -0,0 +1,137 @@
#!/usr/bin/env node
/** Agent poll CLI for the design-document edit session.
*
* The picker forks picker-doc-session.mjs on submit; this is how the agent
* hears from it, on the live-poll.mjs contract: one-shot by default, block
* until one event arrives, print it as JSON on stdout, exit.
*
* node picker-doc-poll.mjs # block, print one event
* node picker-doc-poll.mjs --timeout=600000 # total budget in ms
* node picker-doc-poll.mjs --reply <id> <status> [message]
* node picker-doc-poll.mjs --reply <id> done "msg" --answers '{"key":"value"}'
*
* Events printed: {"type":"edit_request","id","kind","prompt","category",
* "payload"} for work a person asked for in words,
* {"type":"save_batch","id","changes","downstream","replyCommand"} for edits
* already applied to the store and owed a prose pass in DESIGN.md or
* PRODUCT.md, {"type":"timeout"} when the budget runs out (poll again), and
* {"type":"exit"} when the session ended (stop polling).
*
* Reply statuses: done (change applied; message shown to the user in the
* document), error (could not apply; message explains), retry (release the
* request back to pending).
*
* --answers and --context attach values for the session to write. The session
* is the only writer of the store while it runs, so a value the agent settles
* travels here rather than being written to those files directly.
*
* Session discovery: .impeccable/design-context/runtime/session.json, written
* by the session process and removed when it exits; a missing file prints
* {"type":"exit"} so a finished session never hangs the loop.
*/
import { readFile } from 'node:fs/promises';
import { paths } from './design-context/store.mjs';
const sessionPath = paths(process.cwd()).sessionJson;
/* Sliced under undici's 300s header timeout, same as live-poll. */
const PER_REQUEST_MS = 270_000;
const DEFAULT_TOTAL_MS = 600_000;
async function session() {
try {
return JSON.parse(await readFile(sessionPath, 'utf8'));
} catch {
return null;
}
}
const args = process.argv.slice(2);
function readFlag(name, fallback) {
const exact = args.find((arg) => arg.startsWith(`${name}=`));
if (exact) return exact.slice(name.length + 1);
const at = args.indexOf(name);
if (at !== -1 && args[at + 1]) return args[at + 1];
return fallback;
}
const info = await session();
if (!info) {
console.log(JSON.stringify({ type: 'exit', reason: 'no-session' }));
process.exit(0);
}
const base = `http://127.0.0.1:${info.port}`;
const VALUE_FLAGS = new Set(['--answers', '--context', '--timeout']);
/* The message is whatever positional words are left, so a flag and the value
that belongs to it both have to come out first, or an attached JSON payload
would be read back to the user as their confirmation line. */
function positionalAfter(marker) {
const words = [];
for (let index = args.indexOf(marker) + 1; index < args.length; index += 1) {
const arg = args[index];
if (arg.startsWith('--')) {
if (VALUE_FLAGS.has(arg)) index += 1;
continue;
}
words.push(arg);
}
return words;
}
if (args.includes('--reply')) {
const [id, status, ...rest] = positionalAfter('--reply');
if (!id || !status) {
console.error('usage: picker-doc-poll.mjs --reply <id> <done|error|retry> [message] [--answers JSON] [--context JSON]');
process.exit(1);
}
/* Values the agent settled while doing the work, handed to the session to
write. Bad JSON is a mistake worth stopping for rather than dropping. */
const attached = {};
for (const flag of ['answers', 'context']) {
const raw = readFlag(`--${flag}`, '');
if (!raw) continue;
try {
attached[flag] = JSON.parse(raw);
} catch {
console.error(`--${flag} must be a JSON object`);
process.exit(1);
}
}
const response = await fetch(`${base}/doc/reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: info.token, id, status, message: rest.join(' '), ...attached }),
}).catch(() => null);
if (!response?.ok) {
console.error(`Reply failed: ${response ? response.status : 'session unreachable'}`);
process.exit(1);
}
console.log(JSON.stringify(await response.json()));
process.exit(0);
}
const totalBudget = Number(readFlag('--timeout', DEFAULT_TOTAL_MS));
const deadline = Date.now() + (Number.isFinite(totalBudget) && totalBudget > 0 ? totalBudget : DEFAULT_TOTAL_MS);
for (;;) {
const slice = Math.min(deadline - Date.now(), PER_REQUEST_MS);
if (slice <= 0) {
console.log(JSON.stringify({ type: 'timeout' }));
process.exit(0);
}
let payload;
try {
const response = await fetch(`${base}/doc/poll?token=${encodeURIComponent(info.token)}&timeout=${slice}`);
payload = await response.json();
} catch {
/* The session process exited between polls. */
console.log(JSON.stringify({ type: 'exit', reason: 'session-gone' }));
process.exit(0);
}
if (payload.type === 'timeout') continue;
console.log(JSON.stringify(payload));
process.exit(0);
}
@@ -0,0 +1,465 @@
#!/usr/bin/env node
/** Design-document edit session (self-contained, zero dependencies).
*
* The picker server forks this detached sibling the moment the questionnaire
* submits, so the review tab's design context document stays connected after
* the picker itself exits 0 (the agent's completion signal). It runs on its
* own pre-scanned port with CORS open to the picker origin, and it mediates
* three parties the way the live server does, scaled down to polling:
*
* browser --POST /doc/save----------> applied to the store, batch queued
* browser --POST /doc/request-------> queue --GET /doc/poll--> agent
* agent --POST /doc/reply---------> queue status + version bump
* browser --GET /doc/state (poll)--> { version, requests, batch } -> re-read
*
* It also serves the document's own images: GET /brand-assets/* for what the
* user supplied, GET /assets/* for the ones the picker ships. Both are
* token-gated and read-only. They are here rather than on the picker server
* because article images load when a view opens, which is always after the
* picker has exited.
*
* Edits made in the document stage in the browser and arrive here as one batch.
* Applying them is deterministic and belongs to this process: each change names
* a field, the field names a place in the store, and the value is written
* there. What reaches the agent afterwards is the reconciliation the store
* cannot do for itself, the prose in DESIGN.md and PRODUCT.md that describes
* those values. Anything needing judgment up front, a font change or a freeform
* ask, queues for the agent the same way, and it long-polls through
* picker-doc-poll.mjs exactly like live mode's live-poll.mjs.
*
* This process is the only writer of the store while it runs; the agent's own
* follow-on values ride in on its reply. That is what keeps a save and an
* agent working at the same time from overwriting each other.
*
* Session discovery for the agent CLI: .impeccable/design-context/runtime/
* session.json { pid, port, token }. Removed on exit. Every applied change is
* journaled to runtime/journal.jsonl beside it, so a session that dies with a
* batch outstanding re-offers it and the agent can reconcile prose at the end.
*
* Usage (spawned by picker-server.mjs, not by hand):
* node picker-doc-session.mjs --port 8501 --timeout 60
* with IMPECCABLE_DOC_TOKEN in the environment.
*/
import http from 'node:http';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { fontRelativePath, migrate, paths, readJsonSoft, writeJsonAtomic } from './design-context/store.mjs';
import { createSaveRoutes } from './design-context/session-routes.mjs';
const store = paths(process.cwd());
const answersPath = store.answersJson;
const contextPath = store.contextJson;
const sessionPath = store.sessionJson;
const fontsDir = store.fontsDir;
const brandAssetsDir = store.assetsDir;
/* The built picker beside this script: the document's own images (section
foils, rail textures, placeholder brand assets) are served from here after
the submit-flow picker server has exited. Read-only, image types only. */
const pickerAssetsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), 'picker', 'assets');
const MAX_BODY_BYTES = 1024 * 1024;
const FONT_EXTENSIONS = new Set(['.woff2', '.woff', '.ttf', '.otf']);
const BRAND_ASSET_MIME = new Map([
['.svg', 'image/svg+xml'],
['.png', 'image/png'],
['.jpg', 'image/jpeg'],
['.jpeg', 'image/jpeg'],
['.webp', 'image/webp'],
['.gif', 'image/gif'],
]);
const PICKER_ASSET_MIME = new Map([
['.png', 'image/png'],
['.jpg', 'image/jpeg'],
['.jpeg', 'image/jpeg'],
['.webp', 'image/webp'],
['.svg', 'image/svg+xml'],
]);
const REQUEST_KINDS = new Set(['font', 'freeform']);
/* Long polls are sliced under common proxy/undici header timeouts, the same
270s ceiling live-poll uses. */
const MAX_POLL_MS = 270_000;
/* The tab polls /doc/state every couple of seconds while open; when it has
been quiet this long the session is over and the agent's poll gets exit. */
const BROWSER_GONE_MS = 10 * 60_000;
/* A tab adopts the session within seconds of the submit that forked it. If
no poll ever arrives (a test harness, a closed tab), die young instead of
holding a port for the full ceiling. */
const ADOPT_GRACE_MS = 90_000;
const args = process.argv.slice(2);
const readArg = (name, fallback) => {
const at = args.indexOf(name);
return at !== -1 && args[at + 1] ? args[at + 1] : fallback;
};
const port = Number(readArg('--port', '0'));
const timeoutMinutes = Number(readArg('--timeout', '60'));
const token = process.env.IMPECCABLE_DOC_TOKEN || '';
if (!port || !token) {
console.error('picker-doc-session is spawned by picker-server.mjs and needs --port plus IMPECCABLE_DOC_TOKEN.');
process.exit(1);
}
let version = 1;
let requestSeq = 0;
const requests = [];
/* The save flow lives in its own module; this shell keeps the server, the
timers, and the token. Every applied save bumps the same version the tab
polls, so the document re-reads itself without a second signal. */
const saves = createSaveRoutes({ onChange: () => { bumpVersion(); wakeParkedPolls(); } });
let lastBrowserSeen = Date.now();
let adopted = false;
const parkedPolls = [];
function sendJson(response, statusCode, body) {
response.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
'Access-Control-Allow-Origin': '*',
});
response.end(JSON.stringify(body));
}
function httpError(statusCode, message) {
const error = new Error(message);
error.statusCode = statusCode;
return error;
}
async function readJsonBody(request) {
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > MAX_BODY_BYTES) throw httpError(413, 'Request body exceeds 1 MB');
chunks.push(chunk);
}
let value;
try {
value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
} catch {
throw httpError(400, 'Body must be valid JSON');
}
if (!value || typeof value !== 'object' || Array.isArray(value)) throw httpError(400, 'Body must be a JSON object');
return value;
}
const summarize = (entry) => ({
id: entry.id,
kind: entry.kind,
prompt: entry.prompt,
category: entry.category,
status: entry.status,
message: entry.message || '',
});
/* ============================================================
Requests that need judgment, queued for the agent.
============================================================ */
function wakeParkedPolls() {
while (parkedPolls.length) {
const parked = parkedPolls.shift();
clearTimeout(parked.timer);
parked.resolve();
}
}
function nextPending() {
return requests.find((entry) => entry.status === 'pending');
}
async function handleDocPoll(response, query) {
const budget = Math.min(Number(query.get('timeout')) || MAX_POLL_MS, MAX_POLL_MS);
const deadline = Date.now() + budget;
for (;;) {
if (Date.now() - lastBrowserSeen > BROWSER_GONE_MS) {
sendJson(response, 200, { type: 'exit', reason: 'browser-gone' });
return;
}
const entry = nextPending();
if (entry) {
entry.status = 'working';
bumpVersion();
sendJson(response, 200, { type: 'edit_request', ...summarize(entry), payload: entry.payload });
return;
}
/* The values are already in the store; what is handed over is the prose
still owed to DESIGN.md and PRODUCT.md. The reply command travels with
the event so the instruction cannot drift from the contract. */
const batch = saves.takeBatchEvent((id) => `node picker-doc-poll.mjs --reply ${id} done "One line the user sees in the tab"`);
if (batch) {
sendJson(response, 200, batch);
return;
}
const remaining = deadline - Date.now();
if (remaining <= 0) {
sendJson(response, 200, { type: 'timeout' });
return;
}
await new Promise((resolve) => {
const parked = { resolve, timer: setTimeout(resolve, Math.min(remaining, 5_000)) };
parkedPolls.push(parked);
});
}
}
function bumpVersion() {
version += 1;
}
/* ============================================================
Server
============================================================ */
const server = http.createServer((request, response) => {
void handleRequest(request, response).catch((error) => {
if (!response.headersSent) sendJson(response, error.statusCode || 500, { error: error.message });
else response.destroy();
});
});
async function handleRequest(request, response) {
const url = new URL(request.url, 'http://localhost');
const requestPath = url.pathname;
if (request.method === 'OPTIONS') {
response.writeHead(204, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, X-Font-Filename',
'Access-Control-Max-Age': '600',
});
response.end();
return;
}
/* Font uploads carry bytes, not JSON; token rides the query string. */
if (request.method === 'POST' && requestPath === '/font-upload') {
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
const name = path.basename(request.headers['x-font-filename'] || '');
if (!name || !FONT_EXTENSIONS.has(path.extname(name).toLowerCase())) {
throw httpError(400, 'Expected a .woff2, .woff, .ttf, or .otf filename');
}
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > MAX_BODY_BYTES) throw httpError(413, 'Font exceeds 1 MB');
chunks.push(chunk);
}
await mkdir(fontsDir, { recursive: true });
await writeFile(path.join(fontsDir, name), Buffer.concat(chunks));
sendJson(response, 200, { ok: true, path: fontRelativePath(name) });
return;
}
/* Brand-asset images for the document's Brand article. The picker server
serves the same directory while it lives; it exits on submit, and the
article's images load after that, so the tab fetches them from here
with the session token on the query string, the same rule as the
sibling GET routes. Filenames only, extension-gated, one directory. */
if (request.method === 'GET' && requestPath.startsWith('/brand-assets/')) {
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
let assetName;
try {
assetName = decodeURIComponent(requestPath.slice('/brand-assets/'.length));
} catch {
throw httpError(400, 'Invalid path');
}
const extension = path.extname(assetName).toLowerCase();
const filePath = path.resolve(brandAssetsDir, assetName);
if (!assetName || assetName !== path.basename(assetName)
|| !BRAND_ASSET_MIME.has(extension)
|| path.relative(brandAssetsDir, filePath).startsWith('..')) {
throw httpError(404, 'Not found');
}
let body;
try {
body = await readFile(filePath);
} catch {
throw httpError(404, 'Not found');
}
response.writeHead(200, {
'Content-Type': BRAND_ASSET_MIME.get(extension),
'Content-Length': body.length,
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'max-age=86400',
});
response.end(body);
return;
}
/* The document's own static images, for the tab that outlives the picker
server: the submit flow exits on /submit, and article images only load when
a view opens, which is always after that. Same trust model as the route
above, token-gated and read-only, but contained rather than flat, because
the vendored files sit in per-category subdirectories. */
if (request.method === 'GET' && requestPath.startsWith('/assets/')) {
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
let assetPath;
try {
assetPath = decodeURIComponent(requestPath.slice('/assets/'.length));
} catch {
throw httpError(400, 'Invalid path');
}
const extension = path.extname(assetPath).toLowerCase();
const filePath = path.resolve(pickerAssetsDir, assetPath);
const contained = path.relative(pickerAssetsDir, filePath);
if (!assetPath
|| assetPath.includes('\0')
|| !PICKER_ASSET_MIME.has(extension)
|| contained.startsWith('..')
|| path.isAbsolute(contained)) {
throw httpError(404, 'Not found');
}
let body;
try {
body = await readFile(filePath);
} catch {
throw httpError(404, 'Not found');
}
response.writeHead(200, {
'Content-Type': PICKER_ASSET_MIME.get(extension),
'Content-Length': body.length,
'Access-Control-Allow-Origin': '*',
'Cache-Control': 'max-age=86400',
});
response.end(body);
return;
}
if (request.method === 'GET' && requestPath === '/doc/state') {
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
lastBrowserSeen = Date.now();
adopted = true;
sendJson(response, 200, {
ok: true,
version,
requests: requests.map(summarize),
agentWaiting: parkedPolls.length > 0,
batch: saves.summary(),
});
return;
}
if (request.method === 'GET' && requestPath === '/doc/answers') {
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
const answers = JSON.parse(await readFile(answersPath, 'utf8'));
sendJson(response, 200, { ok: true, version, answers });
return;
}
/* The chat half of the run, read fresh so an agent's rewrite reaches the tab. */
if (request.method === 'GET' && requestPath === '/doc/context') {
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
let stored = null;
try {
stored = JSON.parse(await readFile(contextPath, 'utf8'));
} catch {
/* A run whose chat half was never recorded still has a document. */
}
sendJson(response, 200, {
ok: true,
version,
modes: stored?.modes ?? null,
context: stored?.context ?? null,
});
return;
}
if (request.method === 'GET' && requestPath === '/doc/poll') {
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
await handleDocPoll(response, url.searchParams);
return;
}
if (request.method !== 'POST') throw httpError(404, 'Not found');
const body = await readJsonBody(request);
if (body.token !== token) throw httpError(403, 'Bad token');
/* Everything staged in the document arrives at once. Applying is this
process's job; reconciling the prose around it is the agent's. */
if (requestPath === '/doc/save') {
const applied = await saves.save(body);
sendJson(response, 200, { ok: true, version, ...applied });
return;
}
if (requestPath === '/doc/request') {
if (!REQUEST_KINDS.has(body.kind)) throw httpError(400, 'kind must be font or freeform');
const prompt = String(body.prompt || '').trim();
if (!prompt || prompt.length > 4000) throw httpError(400, 'prompt is required, 4000 characters max');
requestSeq += 1;
const entry = {
id: `req-${String(requestSeq).padStart(3, '0')}`,
kind: body.kind,
prompt,
category: String(body.category || ''),
payload: body.payload && typeof body.payload === 'object' ? body.payload : {},
status: 'pending',
message: '',
};
requests.push(entry);
bumpVersion();
wakeParkedPolls();
sendJson(response, 200, { ok: true, id: entry.id, version });
return;
}
if (requestPath === '/doc/reply') {
// A save and a request are both replied to here, told apart by the id.
if (saves.hasPending() && String(body.id || '').startsWith('batch-')) {
const result = await saves.reply(body);
sendJson(response, 200, { ok: true, version, ...result });
return;
}
const entry = requests.find((item) => item.id === body.id);
if (!entry) throw httpError(404, 'Unknown request id');
if (!['done', 'error', 'retry'].includes(body.status)) throw httpError(400, 'status must be done, error, or retry');
entry.status = body.status === 'retry' ? 'pending' : body.status;
entry.message = String(body.message || '');
saves.noteRequest(entry.id, entry.status);
bumpVersion();
if (entry.status === 'pending') wakeParkedPolls();
sendJson(response, 200, { ok: true, version });
return;
}
throw httpError(404, 'Not found');
}
server.listen(port, '127.0.0.1', async () => {
await migrate(process.cwd());
await writeJsonAtomic(sessionPath, { pid: process.pid, port, token });
});
server.on('error', () => process.exit(1));
/* The session dies with its audience: no browser poll for BROWSER_GONE_MS,
or the hard ceiling, whichever lands first. */
const reaper = setInterval(() => {
const quiet = Date.now() - lastBrowserSeen;
if (quiet > BROWSER_GONE_MS || (!adopted && quiet > ADOPT_GRACE_MS)) shutdown();
}, 15_000);
const ceiling = setTimeout(shutdown, timeoutMinutes * 60_000);
async function shutdown() {
clearInterval(reaper);
clearTimeout(ceiling);
wakeParkedPolls();
/* Only if it is still ours. A session that outlived its tab can be shutting
down at the moment a newer one writes the same path, and taking the file
with it would leave the live session undiscoverable. */
const recorded = await readJsonSoft(sessionPath);
if (!recorded || recorded.pid === process.pid) {
await rm(sessionPath, { force: true }).catch(() => {});
}
server.close(() => process.exit(0));
server.closeAllConnections?.();
setTimeout(() => process.exit(0), 1_000).unref();
}
process.once('SIGINT', shutdown);
process.once('SIGTERM', shutdown);
@@ -0,0 +1,572 @@
#!/usr/bin/env node
/** Browser questionnaire server (self-contained, zero dependencies).
* Serves picker files and cues, writes one JSON submission, then exits.
* Usage: node <scripts_path>/picker-server.mjs [--port 8500]
* [--cues-dir .impeccable/visual-cues] [--timeout 60]
*/
import http from 'node:http';
import { spawn } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { copyFile, readFile, mkdir, rm, stat, writeFile } from 'node:fs/promises';
import net from 'node:net';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { SEEDS } from './palette.mjs';
import {
clearDraft,
fontRelativePath,
migrate,
paths,
pidAlive,
readAnswers,
readDraft,
readJsonSoft,
writeDraft,
writeJsonAtomic,
} from './design-context/store.mjs';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const pickerDir = path.join(scriptDir, 'picker');
const store = paths(process.cwd());
const answersPath = store.answersJson;
const fontsDir = store.fontsDir;
const brandAssetsDir = store.assetsDir;
const MAX_BODY_BYTES = 1024 * 1024;
const FONT_EXTENSIONS = new Set(['.woff2', '.woff', '.ttf', '.otf']);
const BRAND_ASSET_EXTENSIONS = ['.svg', '.png', '.jpg', '.jpeg', '.webp', '.gif'];
const MIME = new Map([
['.html', 'text/html; charset=utf-8'],
['.css', 'text/css; charset=utf-8'],
['.js', 'text/javascript; charset=utf-8'],
['.jpg', 'image/jpeg'],
['.jpeg', 'image/jpeg'],
['.webp', 'image/webp'],
['.gif', 'image/gif'],
['.png', 'image/png'],
['.svg', 'image/svg+xml'],
['.json', 'application/json; charset=utf-8'],
['.woff2', 'font/woff2'],
['.woff', 'font/woff'],
['.ttf', 'font/ttf'],
['.otf', 'font/otf'],
]);
function printHelp() {
console.log(`Usage: node picker-server.mjs [options]
Serve the Impeccable design picker and wait for one form submission.
Options:
--port PORT Scan for an open port from PORT (default: 8500)
--cues-dir PATH Visual cues directory (default: .impeccable/visual-cues)
--timeout MINUTES Exit 2 if nothing submits (default: 60)
--fresh Start blank, ignoring any previous answers or draft
--help Show this help
Output:
PICKER_URL URL Printed when the server is ready
ANSWERS PATH Printed after answers.json is written
Also served, for the design context document the questionnaire reveals:
/context.json The chat half of the interview, from the design-context store
/cue.png The chosen cue image, copied into the store at submit
See reference/visual-cues.md for the canonical agent flow.`);
}
function readOption(args, index) {
const arg = args[index];
const equals = arg.indexOf('=');
if (equals !== -1) return { value: arg.slice(equals + 1), next: index };
if (!args[index + 1] || args[index + 1].startsWith('--')) {
throw new Error(`${arg} requires a value`);
}
return { value: args[index + 1], next: index + 1 };
}
function parseArgs(args) {
const options = {
port: 8500,
cuesDir: path.resolve(process.cwd(), '.impeccable/visual-cues'),
timeoutMinutes: 60,
fresh: false,
doc: false,
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === '--help' || arg === '-h') return { help: true };
/* Value-less flags are read before the guard below, which would reject
them, and before readOption, which demands a value for every flag. */
if (arg === '--fresh') { options.fresh = true; continue; }
if (arg === '--doc') { options.doc = true; continue; }
if (!arg.startsWith('--port') && !arg.startsWith('--cues-dir') && !arg.startsWith('--timeout')) throw new Error(`Unknown option: ${arg}`);
const { value, next } = readOption(args, index);
index = next;
if (arg.startsWith('--port')) options.port = Number(value);
if (arg.startsWith('--cues-dir')) options.cuesDir = path.resolve(process.cwd(), value);
if (arg.startsWith('--timeout')) options.timeoutMinutes = Number(value);
}
if (!Number.isInteger(options.port) || options.port < 1 || options.port > 65535) throw new Error('--port must be an integer from 1 to 65535');
if (!Number.isFinite(options.timeoutMinutes) || options.timeoutMinutes <= 0) throw new Error('--timeout must be a positive number of minutes');
return options;
}
async function findOpenPort(start = 8500) {
if (start > 65535) throw new Error('No open picker port found');
return new Promise((resolve) => {
const probe = net.createServer();
probe.listen(start, '127.0.0.1', () => {
const port = probe.address().port;
probe.close(() => resolve(port));
});
probe.on('error', () => resolve(findOpenPort(start + 1)));
});
}
function sendJson(response, statusCode, body) {
response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8' });
response.end(JSON.stringify(body));
}
function httpError(statusCode, message) {
const error = new Error(message);
error.statusCode = statusCode;
return error;
}
function decodeRequestPath(rawUrl = '/') {
let decoded = rawUrl.split('?')[0];
try {
for (let pass = 0; pass < 3; pass += 1) {
const next = decodeURIComponent(decoded);
if (next === decoded) break;
decoded = next;
}
} catch {
return null;
}
decoded = decoded.replaceAll('\\', '/');
if (decoded.includes('\0') || decoded.split('/').includes('..')) return null;
return decoded.startsWith('/') ? decoded : `/${decoded}`;
}
function containedPath(baseDir, relativePath) {
const candidate = path.resolve(baseDir, relativePath);
const relative = path.relative(baseDir, candidate);
if (relative.startsWith('..') || path.isAbsolute(relative)) return null;
return candidate;
}
async function serveFile(response, baseDir, relativePath, allowedExtensions = MIME.keys()) {
const filePath = containedPath(baseDir, relativePath);
const extension = path.extname(relativePath).toLowerCase();
if (!filePath || ![...allowedExtensions].includes(extension) || !MIME.has(extension)) {
sendJson(response, 404, { error: 'Not found' });
return;
}
try {
const info = await stat(filePath);
if (!info.isFile()) throw new Error('Not a file');
const body = await readFile(filePath);
response.writeHead(200, {
'Content-Type': MIME.get(extension),
'Content-Length': body.length,
});
response.end(body);
} catch {
sendJson(response, 404, { error: 'Not found' });
}
}
async function readJsonBody(request) {
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > MAX_BODY_BYTES) throw httpError(413, 'Request body exceeds 1 MB');
chunks.push(chunk);
}
let value;
try {
value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
} catch {
throw httpError(400, 'Body must be valid JSON');
}
if (!value || typeof value !== 'object' || Array.isArray(value)) throw httpError(400, 'Body must be a JSON object');
return value;
}
let options;
try {
options = parseArgs(process.argv.slice(2));
} catch (error) {
console.error(error.message);
process.exit(1);
}
if (options.help) {
printHelp();
process.exit(0);
}
/* A project interviewed by an older release keeps its answers, assets, and
uploaded faces under the pre-store layout. Bring them across before serving. */
await migrate(process.cwd());
const port = await findOpenPort(options.port);
let completed = false;
let timeout;
let docWatch;
/* In document mode the run already happened: this process serves the document
built from it, and the edit session is what it waits on. */
let docSession = null;
const server = http.createServer((request, response) => {
void handleRequest(request, response).catch((error) => {
if (!response.headersSent) sendJson(response, error.statusCode || 500, { error: error.message });
else response.destroy();
});
});
async function handleRequest(request, response) {
const requestPath = decodeRequestPath(request.url);
if (!requestPath) {
sendJson(response, 400, { error: 'Invalid path' });
return;
}
if (request.method === 'POST' && requestPath === '/submit') {
/* Document mode is showing a run that already finished; there is nothing
left to submit, and writing one would overwrite the answers it renders. */
if (options.doc) {
sendJson(response, 409, { error: 'The document is open; there is nothing to submit' });
return;
}
if (completed) {
sendJson(response, 409, { error: 'Submission already received' });
return;
}
const answers = await readJsonBody(request);
await writeJsonAtomic(answersPath, answers);
await copyChosenCue(answers);
/* The run is on the record now, so the half-finished copy of it goes. */
await clearDraft();
completed = true;
clearTimeout(timeout);
/* The document the review tab is about to reveal stays editable through a
detached sibling: it owns the edit endpoints on its own port, so this
process can still exit as the agent's completion signal. The tab learns
where to reach it from this response; the agent learns from
runtime/session.json, which the sibling writes at boot. */
const doc = await spawnDocSession();
response.once('finish', () => {
console.log(`ANSWERS ${answersPath}`);
server.close(() => process.exit(0));
server.closeAllConnections?.();
});
sendJson(response, 200, { ok: true, doc });
return;
}
/* The questionnaire posts its whole form after every screen change, so a run
the visitor walks away from resumes where they left it instead of starting
over. The submission supersedes the draft and removes it. */
if (request.method === 'POST' && requestPath === '/autosave') {
if (completed) {
sendJson(response, 409, { error: 'Submission already received' });
return;
}
await writeDraft(await readJsonBody(request));
sendJson(response, 200, { ok: true });
return;
}
// Uploaded faces are stored, not parsed: the questionnaire defers validation
// to the end, so the server only needs to put the bytes where the agent can
// reach them and hand back the path the answers will carry.
if (request.method === 'POST' && requestPath === '/font-upload') {
const name = path.basename(request.headers['x-font-filename'] || '');
if (!name || !FONT_EXTENSIONS.has(path.extname(name).toLowerCase())) {
sendJson(response, 400, { error: 'Expected a .woff2, .woff, .ttf, or .otf filename' });
return;
}
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > MAX_BODY_BYTES) throw httpError(413, 'Font exceeds 1 MB');
chunks.push(chunk);
}
await mkdir(fontsDir, { recursive: true });
await writeFile(path.join(fontsDir, name), Buffer.concat(chunks));
sendJson(response, 200, { path: fontRelativePath(name) });
return;
}
if (request.method !== 'GET') {
sendJson(response, 405, { error: 'Method not allowed' });
return;
}
/* One fetch tells the client how to start: which surface it is serving, and
the answers to restore, if any. Never cached, because the draft moves
while the questionnaire is open and a stale copy would restore a run the
visitor has already moved past. */
if (requestPath === '/boot.json') {
const { prior, priorSource } = await resolvePrior();
response.setHeader('Cache-Control', 'no-store');
sendJson(response, 200, {
mode: options.doc ? 'doc' : 'questionnaire',
prior,
priorSource,
/* Present only where the document is live for edits. Absent leaves it
rendering read-only, which is the honest state when no session took. */
doc: docSession ? { base: `http://127.0.0.1:${docSession.port}`, token: docSession.token } : null,
});
return;
}
if (requestPath === '/cues.json') {
await serveFile(response, options.cuesDir, 'cues.json', ['.json']);
return;
}
/* The chat half of the interview, and the chosen cue, both live in the store
rather than the generation workspace. The document reads them after this
process exits, so they carry the same cache rule the cue images do. */
if (requestPath === '/context.json') {
response.setHeader('Cache-Control', 'max-age=86400');
await serveFile(response, store.storeDir, 'context.json', ['.json']);
return;
}
if (requestPath === '/cue.png') {
response.setHeader('Cache-Control', 'max-age=86400');
await serveFile(response, store.storeDir, 'cue.png', ['.png']);
return;
}
if (requestPath === '/fonts.json') {
await serveFile(response, options.cuesDir, 'fonts.json', ['.json']);
return;
}
if (requestPath === '/palettes.json') {
sendJson(response, 200, {
seeds: SEEDS.map(({ id, oklch, mood }) => ({ id, oklch, mood })),
});
return;
}
if (requestPath.startsWith('/cues/')) {
const cueName = requestPath.slice('/cues/'.length);
if (!cueName || cueName.includes('/')) {
sendJson(response, 404, { error: 'Not found' });
return;
}
// Cue images are re-requested by the design context document after this
// process has exited (article content only enters the live DOM after
// submit), so they must be servable from the browser's cache.
response.setHeader('Cache-Control', 'max-age=86400');
await serveFile(response, options.cuesDir, cueName, ['.png']);
return;
}
/* Brand-asset files the agent staged from the chat interview (logos, mood
boards, reference images), displayed by the design context document.
Read-only, one directory, filenames only. The /assets/ prefix is taken
by the picker's own static files, hence the distinct name. */
if (requestPath.startsWith('/brand-assets/')) {
const assetName = requestPath.slice('/brand-assets/'.length);
if (!assetName || assetName.includes('/')) {
sendJson(response, 404, { error: 'Not found' });
return;
}
response.setHeader('Cache-Control', 'max-age=86400');
await serveFile(response, brandAssetsDir, assetName, BRAND_ASSET_EXTENSIONS);
return;
}
// Uploaded faces are read back so the specimen can render in them.
if (requestPath.startsWith('/fonts/')) {
const fontName = requestPath.slice('/fonts/'.length);
if (!fontName || fontName.includes('/')) {
sendJson(response, 404, { error: 'Not found' });
return;
}
await serveFile(response, fontsDir, fontName, [...FONT_EXTENSIONS]);
return;
}
const assetPath = requestPath === '/' ? 'index.html' : requestPath.slice(1);
await serveFile(response, pickerDir, assetPath);
}
/* An unfinished run outranks a finished one: the draft is where the visitor
actually is, the submission is where they last were. --fresh declines both. */
async function resolvePrior() {
if (options.fresh) return { prior: null, priorSource: null };
const draft = await readDraft();
if (draft) return { prior: draft, priorSource: 'draft' };
const answers = await readAnswers();
if (answers) return { prior: answers, priorSource: 'submitted' };
return { prior: null, priorSource: null };
}
/* The document renders the chosen cue long after this process is gone, and a
later reopen has no generation workspace to reach into, so the one picked
hero joins the store. A seed or custom palette names no cue: nothing to copy. */
async function copyChosenCue(answers) {
const slug = typeof answers['palette-source'] === 'string' ? answers['palette-source'] : '';
if (!slug || slug !== path.basename(slug)) return;
try {
await mkdir(path.dirname(store.cuePng), { recursive: true });
await copyFile(path.join(options.cuesDir, `${slug}.png`), store.cuePng);
} catch {
/* Not a cue palette, or the workspace is gone. */
}
}
async function spawnDocSession() {
/* The read-only path is otherwise unreachable from a test, and a document
that renders without an edit session is a real state worth exercising. */
if (process.env.IMPECCABLE_DOC_SESSION_DISABLE === '1') return null;
try {
const docPort = await findOpenPort(port + 1);
const docToken = randomUUID();
const child = spawn(process.execPath, [
path.join(scriptDir, 'picker-doc-session.mjs'),
'--port', String(docPort),
'--timeout', String(options.timeoutMinutes),
], {
cwd: process.cwd(),
detached: true,
stdio: 'ignore',
env: { ...process.env, IMPECCABLE_DOC_TOKEN: docToken },
});
child.unref();
return { base: `http://127.0.0.1:${docPort}`, token: docToken };
} catch {
/* The document still renders read-only; only the edit loop is lost. */
return null;
}
}
/* ============================================================
Document mode: serving the design context document on its own.
============================================================ */
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** Does the recorded session answer for itself? Also marks it adopted. */
async function probeSession(record) {
if (!record?.port || !record?.token) return false;
try {
const response = await fetch(
`http://127.0.0.1:${record.port}/doc/state?token=${encodeURIComponent(record.token)}`,
{ signal: AbortSignal.timeout(2000) },
);
return response.ok;
} catch {
return false;
}
}
async function waitForSessionRecord(deadlineMs) {
const until = Date.now() + deadlineMs;
for (;;) {
const record = await readJsonSoft(store.sessionJson);
if (record?.port) return record;
if (Date.now() > until) return null;
await sleep(150);
}
}
/**
* One live session per project.
*
* A session that answers is rejoined, so reopening a tab closed a minute ago
* lands back in the session the agent is already polling, and the probe itself
* is what keeps it from being reaped. A dead record is cleared, and a recorded
* process that will not answer is stopped and waited out before a replacement
* is forked: two sessions would write one discovery file, and the loser's
* shutdown would carry off the winner's record.
*/
async function adoptDocSession() {
const recorded = await readJsonSoft(store.sessionJson);
if (recorded && pidAlive(recorded.pid)) {
if (await probeSession(recorded)) return recorded;
try { process.kill(recorded.pid, 'SIGTERM'); } catch { /* already gone */ }
for (let waited = 0; waited < 5000 && pidAlive(recorded.pid); waited += 200) await sleep(200);
}
await rm(store.sessionJson, { force: true }).catch(() => {});
if (!await spawnDocSession()) return null;
/* A session forked before any tab exists has a short window to be adopted or
it dies young, and in document mode the tab arrives only once a person
opens the URL. This probe is the adoption. */
const record = await waitForSessionRecord(5000);
if (!record) return null;
await probeSession(record);
return record;
}
/* The session ending is this process's completion signal in document mode.
Liveness is the recorded process plus an answer from it, never the presence
of the discovery file on its own: a session that crashes leaves the file
behind, and a sibling shutting down can carry the file off while the real
session is still serving. */
function watchDocSession(record) {
let misses = 0;
const tick = async () => {
if (completed) return;
if (!pidAlive(record.pid)) return finishDocMode();
misses = (await probeSession(record)) ? 0 : misses + 1;
if (misses >= 2) return finishDocMode();
docWatch = setTimeout(tick, 5000);
};
docWatch = setTimeout(tick, 5000);
}
function finishDocMode() {
if (completed) return;
completed = true;
clearTimeout(timeout);
clearTimeout(docWatch);
console.log('DOC_SESSION_ENDED');
server.close(() => process.exit(0));
server.closeAllConnections?.();
}
function stopWithoutSubmission(message) {
if (completed) return;
clearTimeout(timeout);
console.error(message);
server.close(() => process.exit(2));
server.closeAllConnections?.();
}
/* Document mode needs a run to show and a session to keep it editable, both
settled before the URL is printed: an agent that reads PICKER_URL is told
the document is ready. */
if (options.doc) {
if (!await readAnswers()) {
console.error('No design interview found. Run /impeccable document to create one.');
process.exit(1);
}
docSession = await adoptDocSession();
}
server.listen(port, '127.0.0.1', () => {
console.log(`PICKER_URL http://127.0.0.1:${port}`);
timeout = setTimeout(
() => stopWithoutSubmission(options.doc
? 'Design context document closed without an edit session.'
: 'Picker timed out without a submission.'),
options.timeoutMinutes * 60_000,
);
/* With no session there is nothing to outlive, so the ceiling is the only
limit and the document stays up read-only until it runs out. */
if (options.doc && docSession) watchDocSession(docSession);
});
server.on('error', (error) => {
console.error(`Picker server error: ${error.message}`);
process.exit(1);
});
process.once('SIGINT', () => stopWithoutSubmission('Picker closed without a submission.'));
process.once('SIGTERM', () => stopWithoutSubmission('Picker closed without a submission.'));
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<style>
/* Light OS theme: black icon. Dark OS theme: pristine kinpaku gold,
matching the header logo (--ks-kinpaku, #ffb900) rather than the old
muddier #d8a83a. */
path { fill: #141207; }
@media (prefers-color-scheme: dark) {
path { fill: #ffb900; }
}
</style>
<path d="M7 1 L18 1 L8 31 L7 31 Q1 31 1 25 L1 7 Q1 1 7 1 Z"/>
<path d="M22 1 L25 1 Q31 1 31 7 L31 25 Q31 31 25 31 L12 31 Z"/>
</svg>

After

Width:  |  Height:  |  Size: 501 B

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -31,7 +31,7 @@ const CODEX_HARNESSES = new Set(['.codex', '.agents']);
// Valid sub-command names
const VALID_COMMANDS = [
'craft', 'init', 'extract', 'document', 'shape',
'critique', 'audit',
'critique', 'design-context', 'audit',
'polish', 'bolder', 'quieter', 'distill', 'harden', 'onboard', 'live',
'animate', 'colorize', 'typeset', 'layout', 'delight', 'overdrive',
'clarify', 'adapt', 'optimize',
@@ -375,7 +375,7 @@ if (hasFlag('start')) {
const state = JSON.parse(fs.readFileSync(stateFile(key), 'utf8'));
console.log(`QUESTION URL: ${state.url}`);
console.log(`QUESTION KEY: ${key}`);
console.log('Open the URL for the user now: in-app browser when the harness has one, otherwise the system opener (macOS `open`, Linux `xdg-open`), otherwise show the URL.');
console.log('Open the URL for the user now: on Cursor with browser_navigate (the in-IDE browser), on another harness with its in-app browser tool, otherwise the system opener (macOS `open`, Linux `xdg-open`), otherwise show the URL.');
console.log(`Then collect the answer with: node ${fileURLToPath(import.meta.url)} --wait --key ${key}`);
process.exit(0);
}
@@ -0,0 +1,317 @@
#!/usr/bin/env node
// visual-cues.mjs — compile step for document seed visual cues.
// Pipeline doc: skill/reference/visual-cues.md (canonical; this help text is not).
//
// Each cue is one full-bleed hero scene staging a planned four-color palette.
//
// node visual-cues.mjs compile <hero.png> --slug <two-word-slug>
// [--palette "primary=#RRGGBB;secondary=...;tertiary=...;neutral=..."]
// [--out <dir>] (default: .impeccable/visual-cues)
// Copies the hero untouched to <slug>.png (removing the source when it
// is an intermediate inside <out>, so the folder holds one file per
// cue, not a byte-identical pair), finds each planned palette hex's
// closest pixel in the hero, and updates <out>/cues.json.
// The hero must be square: generation happens on a square canvas
// (a size/aspect parameter, not just a prompt line), and a non-square
// input is a generation to redo, not an image to fix up here.
//
// Dependency-free: PNG decode on node:zlib. Rejects interlaced and
// indexed-color PNGs; convert those with sips/ImageMagick/PIL first.
import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync, realpathSync, unlinkSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import zlib from 'node:zlib';
// ---------------------------------------------------------------- PNG codec
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
// PNG filter type 4 (Paeth): predicts a byte from its left (a), above (b),
// and above-left (c) neighbors, picking whichever of a, b, or a+b-c lands
// closest to the actual gradient. Used by decodePng's unfilter step.
function paeth(a, b, c) {
const p = a + b - c;
const pa = Math.abs(p - a);
const pb = Math.abs(p - b);
const pc = Math.abs(p - c);
if (pa <= pb && pa <= pc) return a;
if (pb <= pc) return b;
return c;
}
export function decodePng(buf) {
if (!buf.subarray(0, 8).equals(PNG_SIG)) throw new Error('not a PNG file');
// Walk the chunk stream: each chunk is [4-byte length][4-byte type][data][4-byte crc].
// IHDR carries the header fields; IDAT is the (possibly multi-chunk)
// compressed pixel data, concatenated below before inflating; other
// chunk types (tEXt, iCCP, etc.) are skipped since nothing here needs them.
let pos = 8;
let ihdr = null;
const idat = [];
while (pos + 8 <= buf.length) {
const len = buf.readUInt32BE(pos);
const type = buf.toString('ascii', pos + 4, pos + 8);
const data = buf.subarray(pos + 8, pos + 8 + len);
if (type === 'IHDR') {
ihdr = {
width: data.readUInt32BE(0),
height: data.readUInt32BE(4),
bitDepth: data[8],
colorType: data[9],
interlace: data[12],
};
} else if (type === 'IDAT') {
idat.push(data);
} else if (type === 'IEND') {
break;
}
pos += 12 + len; // length + type + data + crc
}
if (!ihdr) throw new Error('PNG has no IHDR chunk');
const { width, height, bitDepth, colorType, interlace } = ihdr;
if (interlace) throw new Error('interlaced PNG not supported; re-save without interlacing (sips, ImageMagick, or PIL)');
if (colorType === 3) throw new Error('indexed-color PNG not supported; convert to RGB/RGBA first (sips, ImageMagick, or PIL)');
if (bitDepth !== 8 && bitDepth !== 16) throw new Error(`unsupported bit depth ${bitDepth}; convert to 8-bit first`);
const channels = { 0: 1, 2: 3, 4: 2, 6: 4 }[colorType];
if (!channels) throw new Error(`unsupported color type ${colorType}`);
const sampleBytes = bitDepth / 8;
const bpp = channels * sampleBytes; // bytes per pixel
const stride = width * bpp; // bytes per scanline, excluding the filter-type byte
const raw = zlib.inflateSync(Buffer.concat(idat));
// Each scanline in the inflated stream is prefixed with a 1-byte filter
// type (0-4) that says how it was delta-encoded against the row above
// and/or the pixel to the left; undo that in place, row by row, since
// filter 2-4 need the already-unfiltered previous row to reconstruct.
const px = Buffer.alloc(height * stride);
let rp = 0;
for (let y = 0; y < height; y++) {
const filter = raw[rp++];
const row = px.subarray(y * stride, (y + 1) * stride);
raw.copy(row, 0, rp, rp + stride);
rp += stride;
const prev = y > 0 ? px.subarray((y - 1) * stride, y * stride) : null;
if (filter === 0) continue; // None: bytes are already the real pixel values
if (filter === 1) {
// Sub: each byte was stored as (value - left).
for (let i = bpp; i < stride; i++) row[i] = (row[i] + row[i - bpp]) & 0xff;
} else if (filter === 2) {
// Up: each byte was stored as (value - above).
if (prev) for (let i = 0; i < stride; i++) row[i] = (row[i] + prev[i]) & 0xff;
} else if (filter === 3) {
// Average: each byte was stored as (value - floor((left + above) / 2)).
for (let i = 0; i < stride; i++) {
const left = i >= bpp ? row[i - bpp] : 0;
const up = prev ? prev[i] : 0;
row[i] = (row[i] + ((left + up) >> 1)) & 0xff;
}
} else if (filter === 4) {
// Paeth: each byte was stored as (value - paeth(left, above, above-left)).
for (let i = 0; i < stride; i++) {
const a = i >= bpp ? row[i - bpp] : 0;
const b = prev ? prev[i] : 0;
const c = prev && i >= bpp ? prev[i - bpp] : 0;
row[i] = (row[i] + paeth(a, b, c)) & 0xff;
}
} else {
throw new Error(`unknown PNG filter ${filter} at row ${y}`);
}
}
// Normalize every supported color type (grayscale, RGB, grayscale+alpha,
// RGBA) down to one consistent RGBA8 buffer, so everything past this
// point (palette search) only ever deals with one shape. 16-bit samples
// keep only the high byte; visual cues never need more than 8 bits of
// precision per channel.
const rgba = Buffer.alloc(width * height * 4);
const at = (base, ch) => px[base + ch * sampleBytes];
for (let i = 0; i < width * height; i++) {
const base = i * bpp;
let r, g, b, a;
if (colorType === 0) {
r = g = b = at(base, 0);
a = 255;
} else if (colorType === 2) {
r = at(base, 0); g = at(base, 1); b = at(base, 2);
a = 255;
} else if (colorType === 4) {
r = g = b = at(base, 0);
a = at(base, 1);
} else {
r = at(base, 0); g = at(base, 1); b = at(base, 2); a = at(base, 3);
}
const o = i * 4;
rgba[o] = r; rgba[o + 1] = g; rgba[o + 2] = b; rgba[o + 3] = a;
}
return { width, height, rgba, hasAlpha: colorType === 4 || colorType === 6 };
}
// The pipeline ships squares, and squaring after the fact always loses
// something (cropping eats scene, padding invents background), so square
// is required at the source: the generation call must pin a 1:1 canvas.
// A non-square input here means that call must be redone.
function requireSquare(img, label) {
if (img.width !== img.height) {
throw new Error(`${label} is ${img.width}x${img.height}, not square; regenerate it with the tool's square (1:1) size/aspect parameter, a prompt line alone does not pin the canvas`);
}
}
// ----------------------------------------------------------------- palette
// role=#RRGGBB per entry; a legacy trailing @x,y is accepted and ignored
// (the search below beats model-reported coordinates every time).
const PALETTE_ENTRY = /^([a-z][a-z-]*)=(#[0-9a-fA-F]{6})(?:@\d+,\d+)?$/;
function parsePalette(str) {
const out = {};
for (const part of str.split(';')) {
const m = part.trim().match(PALETTE_ENTRY);
if (!m) throw new Error(`bad palette entry "${part.trim()}" (expected role=#RRGGBB)`);
out[m[1]] = { hex: m[2].toUpperCase() };
}
return out;
}
// The parent designed the palette, so the planned hex is known; what needs
// measuring is where and how faithfully the hero staged it. Search the whole
// hero for the pixel closest to each planned hex. hex stays the planned
// value; snapped is the closest rendered pixel; at is its hero position.
function snapPalette(img, palette) {
const out = {};
// Sample on a grid instead of every pixel: ~150 samples per axis is dense
// enough to find a representative patch of any staged color, and scanning
// a 1500x1500 hero at full resolution for every role adds up otherwise.
const step = Math.max(1, Math.floor(Math.min(img.width, img.height) / 150));
for (const [role, entry] of Object.entries(palette)) {
const pr = parseInt(entry.hex.slice(1, 3), 16);
const pg = parseInt(entry.hex.slice(3, 5), 16);
const pb = parseInt(entry.hex.slice(5, 7), 16);
let best = Infinity;
let bx = 0;
let by = 0;
// Squared Euclidean distance in RGB space; skipping the sqrt is fine
// since only the relative ordering of distances matters here.
for (let y = 0; y < img.height; y += step) {
for (let x = 0; x < img.width; x += step) {
const o = (y * img.width + x) * 4;
const dr = img.rgba[o] - pr;
const dg = img.rgba[o + 1] - pg;
const db = img.rgba[o + 2] - pb;
const d = dr * dr + dg * dg + db * db;
if (d < best) { best = d; bx = x; by = y; }
}
}
const o = (by * img.width + bx) * 4;
const snapped = `#${[img.rgba[o], img.rgba[o + 1], img.rgba[o + 2]]
.map((v) => v.toString(16).padStart(2, '0'))
.join('')
.toUpperCase()}`;
out[role] = { hex: entry.hex, snapped, at: [bx, by] };
}
return out;
}
// ---------------------------------------------------------------- cues.json
// Reads the existing cues.json (if any) and merges this cue in, so compiling
// the six concepts one after another accumulates into one shared manifest
// instead of each compile overwriting the last.
function updateCuesJson(outDir, slug, palette) {
const path = join(outDir, 'cues.json');
let data = {};
if (existsSync(path)) data = JSON.parse(readFileSync(path, 'utf8'));
data.cues = data.cues || [];
if (!data.cues.includes(slug)) data.cues.push(slug);
if (palette) {
data.palette = data.palette || {};
data.palette[slug] = palette;
}
writeFileSync(path, JSON.stringify(data, null, 2) + '\n');
return data;
}
// -------------------------------------------------------------------- CLI
// Minimal flag parser: positional args collect into `_`, everything after
// a `--name` becomes args.name. Good enough for this script's small,
// fixed set of options; no need for a dependency here.
function parseArgs(argv) {
const args = { _: [] };
for (let i = 0; i < argv.length; i++) {
if (argv[i].startsWith('--')) {
args[argv[i].slice(2)] = argv[i + 1];
i++;
} else {
args._.push(argv[i]);
}
}
return args;
}
// Errors surface as JSON on stderr (matching the success shape on stdout)
// so the calling agent can parse either outcome the same way.
function fail(msg) {
console.error(JSON.stringify({ ok: false, error: msg }));
process.exit(1);
}
function cmdCompile(args) {
const [heroFile] = args._;
const slug = args.slug;
if (!heroFile || !slug) {
fail('usage: visual-cues.mjs compile <hero.png> --slug <slug> [--palette "..."] [--out <dir>]');
}
if (!/^[a-z0-9]+(-[a-z0-9]+)+$/.test(slug)) fail(`slug "${slug}" must be lowercase words joined by hyphens (e.g. amber-dusk)`);
const outDir = resolve(args.out || '.impeccable/visual-cues');
const hero = decodePng(readFileSync(resolve(heroFile)));
requireSquare(hero, 'hero');
mkdirSync(outDir, { recursive: true });
const heroPath = join(outDir, `${slug}.png`);
const srcPath = resolve(heroFile);
copyFileSync(srcPath, heroPath); // the hero ships untouched, no crop
// Subagents drop `<slug>-hero.png` intermediates into the out dir; once
// the canonical `<slug>.png` exists, that intermediate is a byte-identical
// duplicate that doubles the folder, so remove it. A source outside the
// out dir (a native tool's own output folder) is not ours to delete.
if (srcPath !== heroPath && dirname(srcPath) === outDir) unlinkSync(srcPath);
// --palette is optional: the agent may compile before it has finished
// designing the palette, and can re-run compile later once it has hexes.
let palette = null;
if (args.palette) palette = snapPalette(hero, parsePalette(args.palette));
updateCuesJson(outDir, slug, palette);
console.log(JSON.stringify({
ok: true,
slug,
hero: heroPath,
palette,
cuesJson: join(outDir, 'cues.json'),
}, null, 2));
}
function main() {
const [cmd, ...rest] = process.argv.slice(2);
const args = parseArgs(rest);
try {
if (cmd === 'compile') cmdCompile(args);
else fail('usage: visual-cues.mjs compile <hero.png> --slug <slug> [options] (see reference/visual-cues.md)');
} catch (err) {
fail(err.message);
}
}
// Only auto-run when invoked directly (`node visual-cues.mjs ...`), not
// when another module imports its exports (decodePng, etc.), e.g. from a
// test file. import.meta.url is Node's realpath of the entry file, so
// argv[1] must be realpath'd too, not just path.resolve'd: a skill
// installed via symlink (the standard `skills link`/install path) makes
// argv[1] the symlink path, which never equality-matches the resolved
// realpath, so main() silently never ran.
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(resolve(process.argv[1]))).href) {
main();
}