Release prep: skill v4.0.0, CLI v3.3.0

Bump skill to 4.0.0 (plugin.json + marketplace.json) and the CLI to
3.3.0 (package.json), then run build:release to regenerate the plugin
subtree and all provider harness output to the new versions.

Skill 4.0.0 ships external-dice direction assignment, the reviewed
world catalog dealt through the roll API with rendered quality-bar
cards, the in-browser serve-question decision page, visualize-before-
build, the rebuilt new-work flow, and the 58-rule detector under hook
enforcement. CLI 3.3.0 grows the deterministic detector to 58 rules
and adds config-declared context roots, per-file rule scoping, and
--target resolution for nested products.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-21 18:53:45 -07:00
co-authored by Claude Fable 5
parent 3c47eb1a8c
commit 311c30f11f
389 changed files with 71136 additions and 8757 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 4.0.0-alpha.10
version: 4.0.0
user-invocable: true
argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · init|document|extract|live] [target]"
license: Apache 2.0
@@ -18,7 +18,7 @@ Core principles:
1. Run `node .github/skills/impeccable/scripts/context.mjs` once per session (if the runtime shows this skill's loaded base directory, run `node <skill-base-dir>/scripts/context.mjs`; keep cwd at the user's project). Pass a named source file or route as `--target <path>`. It loads PRODUCT.md, DESIGN.md, the matching surface brief, and native-platform guidance when applicable; follow its directives and do not rerun it.
2. Before acting, load the one playbook that owns the request: the Commands table's reference for an explicit or clearly implied sub-command, or [reference/new-work.md](reference/new-work.md) for a new surface or replacement visual world. Then inspect the target and at least one representative source of incumbent visual truth (tokens, theme, CSS, component, or asset) before editing.
3. After analysis and direction are resolved, load [reference/build-floor.md](reference/build-floor.md) immediately before editing UI. Do not load it for planning-only work.
3. After analysis and direction are resolved, load [reference/craft-floor.md](reference/craft-floor.md) immediately before editing UI. It carries the quality floor, the absolute bans, and the reflexes no detector catches. Do not load it for planning-only work.
## How to design
@@ -1,15 +0,0 @@
# Build floor
Apply this only after analysis and direction are settled. Build without announcing the checklist.
- **Contrast:** body and placeholder text ≥4.5:1; large text ≥3:1. On colored surfaces, tint secondary text from that hue or the foreground instead of using gray.
- **Depth:** shadows describe light with offset and soft blur; zero-offset colored halos are decoration.
- **Spacing:** tight groups, generous separation, no cramped containers; space above a heading exceeds space below. Verify computed values.
- **Type:** body measure 6575ch; display max 6rem and tracking floor -0.04em; balance headings; use clear scale/weight contrast; test overflow at every breakpoint.
- **Motion:** author one coherent moment instead of scattered effects. Use exponential ease-out and an already-visible default. Premium motion may add focus, depth, masks, light, or material change through blur/filter, backdrop-filter, clip-path/masks, or shadow when smooth; do not rely on transform/opacity alone.
- **Shipping:** real content, working controls, responsive composition, keyboard focus, and the states users hit: hover, disabled, loading, error, and empty.
- **Copy:** use the product's language; controls name their action, errors name the problem and recovery.
- **Coverage:** every brief requirement must exist and be findable within seconds.
Before finishing changed UI, follow the quality guidance supplied by `context.mjs` and hooks. Context requests a manual scan only when no automatic detector is active; never add a second detector pass.
+2 -2
View File
@@ -21,7 +21,7 @@ Show the probes together and ask what should carry forward, what feels false to
Do not begin code until the user approves a direction or explicitly delegates the choice. If they delegate, choose using the task brief, PRODUCT.md, and DESIGN.md, and state the evidence. Approval refines the task concept; it does not modify DESIGN.md.
After approval, summarize the composition and the parts of the comp that must not be literalized. Return to new-work.md and write the direction contract from the approved surface concept before code.
After approval, summarize the composition and the parts of the comp that must not be literalized. Return to new-work.md, record the direction contract from the approved surface concept, then build.
## Inventory implementation fidelity
@@ -35,4 +35,4 @@ Treat the comp as a north star, not something to trace. Do not rasterize core UI
When clean raster ingredients are required and a scoped subagent is available and authorized, use `impeccable_asset_producer`. Give it the approved comp, output paths, required dimensions and formats, transparency needs, crop notes, and what must remain semantic code. Otherwise produce the minimum required assets with the native image-generation capability in the current thread.
Return to [new-work.md](new-work.md) for the direction contract, implementation, and finishing pass.
Return to [new-work.md](new-work.md) for the direction contract, implementation, and the finishing pass.
@@ -0,0 +1,39 @@
# Craft floor
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.
## Verify
Each of these is a check on the built result, not an intention.
- **Contrast:** body and placeholder text ≥4.5:1, large text ≥3:1. On colored surfaces tint secondary text from that hue or the foreground; never gray.
- **Depth:** shadows carry an offset and a soft blur. A zero-offset colored halo is decoration.
- **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.
- **States:** hover, disabled, loading, error, empty. Plus real content, working controls, responsive composition, keyboard focus.
- **Copy:** the product's own language. Controls name their action; errors name the problem and the recovery.
- **Coverage:** every brief requirement present and findable within seconds.
## Refuse
These are the category's defaults, not bans: the brief's own words can earn any of them. Reaching for one when the axis is free means you were not deciding; recognizing that means rewriting the element, not softening it.
Page scaffolds:
- Same-size cards of icon plus heading plus text as the page structure. Cards are the lazy container; nested cards are always wrong.
- The hero-metric template: big number, small label, supporting stats, accent.
- A tracked uppercase eyebrow over every section. One named kicker is a system; an eyebrow everywhere is grammar you did not choose.
- Section numbers (01 / 02 / 03) unless the sequence itself carries information the reader needs.
- A modal for a task that needs neither interruption nor protected focus.
Surface habits:
- Gradient text. Emphasis comes from weight or size.
- Glass and blur as decoration rather than as a specific effect.
- A colored `border-left` or `border-right` above 1px on cards, list items, callouts, or alerts.
- 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.
- Light or dark picked by category. Pick it from the use scene: who, where, under what ambient light.
The floor holds the mechanics; it never picks the direction. With every check green, spend the page on the committed world, and when torn between refined and committed, commit.
@@ -355,7 +355,7 @@ For projects with no visual system to extract yet. Produces a user-chosen visual
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 **Select one direction** for A, D, or E so the visual world and its first expression are chosen as one pair. 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.
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 **Select one direction** for A, D, or E 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.
If new-work already completed the workshop in this session, use its chosen direction directly. Do not ask again.
+3 -1
View File
@@ -4,7 +4,9 @@ Manage the **design detector hook** for the current project.
The hook runs the impeccable design detector on direct file edits to design-relevant files (`.tsx`, `.jsx`, `.html`, `.vue`, `.svelte`, `.astro`, `.css`, `.scss`, `.sass`, `.less`, `.ts`, `.js`). Claude Code, Codex, and GitHub Copilot use a post-tool-use hook and push a short system reminder into the agent's context after the edit; findings get a correction prompt, pending issues get a re-nudge, and clean UI-ish files get a short ack unless quiet mode is on (`hook.quiet` in config). Plain `.ts` and `.js` files are still scanned, but stay quiet unless the detector finds something. Cursor uses `preToolUse` to block bad proposed writes before they land and stays silent when it allows a clean write.
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. That Stop message also asks for one authored review of detector-blind model reflexes. A session that touched no UI files stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit while `context.mjs` supplies the detector-blind review. When no automatic hook is active, `context.mjs` instead supplies that review plus one manual detector command.
The detector rules run in two tiers. The per-edit hook surfaces only the immediate tier: mechanical, unambiguous problems worth interrupting an edit for, such as broken images, overflowing or clipped content, contrast and legibility failures, gradient text, glow shadows, and design-system drift. Everything else (copy cadence, palette and typography taste, layout rhythm) is deferred to a deep pass on the `Stop` hook event, which runs the full rule set over every UI file touched in the session and surfaces the remaining findings once, deduplicated against what the per-edit pass already reported. A session with nothing left to report stops silently. Set `hook.perEditRules` to `"all"` in `.impeccable/config.json` to restore the full rule set on every edit. The Stop deep pass is wired for Claude Code and Codex, which both dispatch a native `Stop` hook event. Cursor does not get one (its stop hook is not consistently dispatched; the pre-write gate covers it), and GitHub Copilot's stop-style events do not feed context back to the model, so they keep the full detector per edit.
Every hook is a mechanical pass. The reflexes no scanner catches live in [craft-floor.md](craft-floor.md), which the skill loads before it edits UI, so they apply whether or not a hook is wired. A session with no automatic hook gets one `MANUAL_DETECTOR_REQUIRED` directive from `context.mjs` asking for a single detector run at the end.
This command toggles the hook **per project** by editing `.impeccable/config.json` (the unified Impeccable config; hook runtime settings live under its `hook` key, and shared detector ignores live under `detector`). Per-developer overrides, including the install consent decision (`hook.consent`) the CLI records, live in the gitignored `.impeccable/config.local.json`. Set `hook.enabled: false` to turn the hook off, `hook.quiet: true` to silence the clean/pending acks, or `hook.auditLog` to a file path for an NDJSON log. The legacy `IMPECCABLE_HOOK_DISABLED`, `IMPECCABLE_HOOK_QUIET`, and `IMPECCABLE_HOOK_LOG` env vars are still honored and override these config values when set.
@@ -1,189 +0,0 @@
# Interaction Design
## The Eight Interactive States
Every interactive element needs these states designed:
| State | When | Visual Treatment |
|-------|------|------------------|
| **Default** | At rest | Base styling |
| **Hover** | Pointer over (not touch) | Subtle lift, color shift |
| **Focus** | Keyboard/programmatic focus | Visible ring (see below) |
| **Active** | Being pressed | Pressed in, darker |
| **Disabled** | Not interactive | Reduced opacity, no pointer |
| **Loading** | Processing | Spinner, skeleton |
| **Error** | Invalid state | Red border, icon, message |
| **Success** | Completed | Green check, confirmation |
**The common miss**: Designing hover without focus, or vice versa. They're different. Keyboard users never see hover states.
## Focus Rings: Do Them Right
**Never `outline: none` without replacement.** It's an accessibility violation. Instead, use `:focus-visible` to show focus only for keyboard users:
```css
/* Hide focus ring for mouse/touch */
button:focus {
outline: none;
}
/* Show focus ring for keyboard */
button:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
```
**Focus ring design**:
- High contrast (3:1 minimum against adjacent colors)
- 2-3px thick
- Offset from element (not inside it)
- Consistent across all interactive elements
## Form Design: The Non-Obvious
**Placeholders aren't labels.** They disappear on input. Always use visible `<label>` elements. **Validate on blur**, not on every keystroke (exception: password strength). Place errors **below** fields with `aria-describedby` connecting them.
## Loading States
**Optimistic updates**: Show success immediately, rollback on failure. Use for low-stakes actions (likes, follows), not payments or destructive actions. **Skeleton screens > spinners**: they preview content shape and feel faster than generic spinners.
## Modals: The Inert Approach
Focus trapping in modals used to require complex JavaScript. Now use the `inert` attribute:
```html
<!-- When modal is open -->
<main inert>
<!-- Content behind modal can't be focused or clicked -->
</main>
<dialog open>
<h2>Modal Title</h2>
<!-- Focus stays inside modal -->
</dialog>
```
Or use the native `<dialog>` element:
```javascript
const dialog = document.querySelector('dialog');
dialog.showModal(); // Opens with focus trap, closes on Escape
```
## The Popover API
For tooltips, dropdowns, and non-modal overlays, use native popovers:
```html
<button popovertarget="menu">Open menu</button>
<div id="menu" popover>
<button>Option 1</button>
<button>Option 2</button>
</div>
```
**Benefits**: Light-dismiss (click outside closes), proper stacking, no z-index wars, accessible by default.
## Dropdown & Overlay Positioning
Dropdowns rendered with `position: absolute` inside a container that has `overflow: hidden` or `overflow: auto` will be clipped. This is the single most common dropdown bug in generated code.
### CSS Anchor Positioning
The modern solution uses the CSS Anchor Positioning API to tether an overlay to its trigger without JavaScript:
```css
.trigger {
anchor-name: --menu-trigger;
}
.dropdown {
position: fixed;
position-anchor: --menu-trigger;
position-area: block-end span-inline-end;
margin-top: 4px;
}
/* Flip above if no room below */
@position-try --flip-above {
position-area: block-start span-inline-end;
margin-bottom: 4px;
}
```
Because the dropdown uses `position: fixed`, it escapes any `overflow` clipping on ancestor elements. The `@position-try` block handles viewport edges automatically. **Browser support**: Chrome 125+, Edge 125+. Not yet in Firefox or Safari - use a fallback for those browsers.
### Popover + Anchor Combo
Combining the Popover API with anchor positioning gives you stacking, light-dismiss, accessibility, and correct positioning in one pattern:
```html
<button popovertarget="menu" class="trigger">Open</button>
<div id="menu" popover class="dropdown">
<button>Option 1</button>
<button>Option 2</button>
</div>
```
The `popover` attribute places the element in the **top layer**, which sits above all other content regardless of z-index or overflow. No portal needed.
### Portal / Teleport Pattern
In component frameworks, render the dropdown at the document root and position it with JavaScript:
- **React**: `createPortal(dropdown, document.body)`
- **Vue**: `<Teleport to="body">`
- **Svelte**: Use a portal library or mount to `document.body`
Calculate position from the trigger's `getBoundingClientRect()`, then apply `position: fixed` with `top` and `left` values. Recalculate on scroll and resize.
### Fixed Positioning Fallback
For browsers without anchor positioning support, `position: fixed` with manual coordinates avoids overflow clipping:
```css
.dropdown {
position: fixed;
/* top/left set via JS from trigger's getBoundingClientRect() */
}
```
Check viewport boundaries before rendering. If the dropdown would overflow the bottom edge, flip it above the trigger. If it would overflow the right edge, align it to the trigger's right side instead.
## Destructive Actions: Undo > Confirm
**Undo is better than confirmation dialogs.** Users click through confirmations mindlessly. Remove from UI immediately, show undo toast, actually delete after toast expires. Use confirmation only for truly irreversible actions (account deletion), high-cost actions, or batch operations.
## Keyboard Navigation Patterns
### Roving Tabindex
For component groups (tabs, menu items, radio groups), one item is tabbable; arrow keys move within:
```html
<div role="tablist">
<button role="tab" tabindex="0">Tab 1</button>
<button role="tab" tabindex="-1">Tab 2</button>
<button role="tab" tabindex="-1">Tab 3</button>
</div>
```
Arrow keys move `tabindex="0"` between items. Tab moves to the next component entirely.
### Skip Links
Provide skip links (`<a href="#main-content">Skip to main content</a>`) for keyboard users to jump past navigation. Hide off-screen, show on focus.
## Gesture Discoverability
Swipe-to-delete and similar gestures are invisible. Hint at their existence:
- **Partially reveal**: Show delete button peeking from edge
- **Onboarding**: Coach marks on first use
- **Alternative**: Always provide a visible fallback (menu with "Delete")
Don't rely on gestures as the only way to perform actions.
---
**Avoid**: Removing focus indicators without alternatives. Using placeholder text as labels. Touch targets <44x44px. Generic error messages. Custom controls without ARIA/keyboard support.
+2 -2
View File
@@ -112,7 +112,7 @@ node .github/skills/impeccable/scripts/live-insert.mjs --id EVENT_ID --count EVE
- `--position``event.insert.position` (`before` | `after`)
- Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text)
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Load `brand.md` or `product.md` (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`.
For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live/<id>/manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's `<style>` block. Do **not** edit the route source during generation; the browser mounts the temporary component before/after the live anchor while the user cycles variants. On Accept, `live-accept.mjs` inserts the selected component markup into `sourceFile` immediately and deletes the temp session after the source write succeeds.
@@ -192,7 +192,7 @@ All three carry `fallback: "agent-driven"`. Follow **Handle fallback** below.
### 3. Load the action's reference
If `event.action` is `impeccable` (the default freeform action), use SKILL.md's shared laws and decide the visitor mode from the selected surface. Do not load a sub-command reference. **Freeform is not a pass to skip parameters:** you still follow the composition budget and the freeform bias in **§7 Parameters** below. Sub-command files list MUST-have signature knobs; freeform has no such file, so sizing knobs from surface weight and primary axes is entirely on you.
If `event.action` is `impeccable` (the default freeform action), work from SKILL.md's design rules plus [craft-floor.md](craft-floor.md), and decide the visitor mode from the selected surface. Do not load a sub-command reference. **Freeform is not a pass to skip parameters:** you still follow the composition budget and the freeform bias in **§7 Parameters** below. Sub-command files list MUST-have signature knobs; freeform has no such file, so sizing knobs from surface weight and primary axes is entirely on you.
Any other `event.action` (`bolder`, `quieter`, `distill`, `polish`, `typeset`, `colorize`, `layout`, `adapt`, `animate`, `delight`, `overdrive`): Read `reference/<action>.md` before planning. Each sub-command encodes a specific discipline; skipping its reference produces generic output. Those files may require specific params; layer them on top of the §7 budget, not instead of it.
+55 -136
View File
@@ -1,179 +1,98 @@
# New visual work
This flow owns the durable visual world when authority is absent, expanding, or explicitly replaced, plus only as much task-level shaping as the requested scope needs. PRODUCT.md owns product truth, DESIGN.md confirmed visual truth, and `.impeccable/surfaces/` durable task strategy. Complete [init.md](init.md) first when PRODUCT.md is missing. Missing DESIGN.md does not route back to init.
Use this flow when making a new surface or replacing a visual identity. PRODUCT.md owns product truth. DESIGN.md owns durable visual decisions. A surface brief keeps strategy that belongs only to one route or artifact. Complete [init.md](init.md) first when PRODUCT.md is missing; a missing DESIGN.md does not route back to init.
## Vocabulary
## 1. Decide what is already true
Terms this file uses throughout, defined once:
Read DESIGN.md, representative code, tokens, components, and assets.
- **World:** the durable visual identity: palette, materials, type voice, ornament logic, and component character that outlive any single surface.
- **Coupled pair:** one world joined to one concrete first-surface expression, selected together as a single decision, never as two tournaments.
- **Staging:** an identity-free structural idea for a surface (hierarchy, sequence, interaction). The seed may append several; they bring no palette, typeface, or material.
- **Candidate floor:** the five veto tests in section 4. A candidate passes all five or is discarded and replaced.
- **Direction contract:** six promise blocks written into the artifact's opening comment before code, audited against the render by the hooks.
- **Surface brief:** durable surface strategy persisted to `.impeccable/surfaces/` via `surface-brief.mjs`.
- **Attended run:** a user is present to answer questions. Unattended fallbacks apply only when no one can answer.
- **Redesign:** preserve product truth, content, function, constraints, and explicit brand commitments; replace the old visual world rather than polishing it. The old look is evidence of what the subject is, not authority over what it becomes.
- **Established world:** inherit it. A missing DESIGN.md does not erase a coherent identity already present in code; document that identity instead of inventing a replacement.
- **Incomplete brand:** preserve confirmed assets and recognizable traits, then help the user expand the system for this new surface.
- **No visual authority:** create a new world with the user.
## 1. Name the intent
A section, component, feature, or state inside an established surface inherits that surface. Do not turn a local addition into a new identity exercise.
- **Greenfield:** no coherent visual implementation.
- **Local extension:** a section, feature, component, or state inside an established surface and world.
- **New surface:** a whole page, route, screen, flow, or standalone experience inside an established world.
- **Expression expansion:** an established brand entering an unresolved whole-surface family or app boundary.
- **Redesign/rebrand:** replace the world while preserving unchanged product truth, content, function, native affordances, constraints, and brand commitments.
- **Refinement:** leave this flow for the scoped command; preserve the world and scope.
## 2. Ask what will change the work
Classify before ideation: work that must join an existing page is a local extension, and a novel layout it would benefit from does not promote it to a new surface. A plain "redesign this page/site" authorizes replacement. "Redesign this within the current brand/system" means extension or refinement. Ask once only when the wording is genuinely ambiguous.
Ask one round of two or three related questions through the structured question tool when available. Skip settled facts; a precise request may need only a compact confirmation.
## 2. Resolve visual authority
- **Persuade:** clarify who must act, what they should believe, and which real proof, content, or assets can earn that belief.
- **Operate:** clarify the task, information, important states, frequency, and constraints.
- **Read:** clarify the reader's question, source material, structure, and wayfinding.
- **Experience:** clarify what leads, how exploration unfolds, and which interaction or transition matters.
Read DESIGN.md and representative code, tokens, components, and assets. Choose one path:
Across modes, ask what success looks like, what must remain untouched, and what would make a polished result feel wrong. Do not ask for CSS values or canned aesthetic lanes.
- **A. Explicit redesign.** The old DESIGN.md and implementation are not authority. Keep only unrevoked product facts, content, function, native expectations, constraints, and brand commitments. Establish a replacement world.
- **B. DESIGN.md covers this kind of surface.** Use its invariants and normative tokens; skip world-building and discover the surface.
- **C. Coherent implementation, no DESIGN.md.** Code, assets, tokens, type, and component behavior are incumbent authority. Run [document.md](document.md) in scan mode, confirm the extracted invariants, then write DESIGN.md. Do not offer replacement worlds unless the user asked for a redesign.
- **D. Brand exists, whole-surface family unresolved.** Preserve logo, color/type assets, voice, recognizable component/motion traits, and constraints. Ask what must carry and where expression may expand; offer two or three compatible ranges, not replacement identities, and merge the choice into DESIGN.md (child-app DESIGN.md when the range is local).
- **E. No confirmed visual authority.** Establish a world. Scaffolds, framework defaults, and stray utilities are not identity.
## 3. Choose the right amount of invention
A local extension stays on B or C: its surrounding surface is authority even when DESIGN.md is incomplete.
### Extend an existing surface
## 3. Discover the requested surface
Inherit its world and composition. Resolve only the new purpose, content, hierarchy, states, interaction, and how the addition joins the surrounding experience. Do not run a concept tournament or change DESIGN.md unless the user approves a durable system change.
Name this surface's audience, job, visitor mode, real content, primary action/task, evidence, constraints, and memorable moment. PRODUCT.md supplies truth and DESIGN.md the world; neither decides narrative or composition.
### Create a whole surface inside an established world
In an attended run, ask one round of at most three material questions without repeating durable facts: CTA hierarchy, proof sequence, content gaps, and interaction outcomes belong here, not in PRODUCT.md. For a fully specified narrow request, state the interpretation and invite correction. When `shape` has already completed its discovery interview, reuse those confirmed answers and do not ask again.
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:
## 4. The candidate floor
`node .github/skills/impeccable/scripts/concept-seed.mjs --scope surface --mode <mode>`
When generating choices, veto rather than rationalize. Before a candidate reaches the user, discard and replace it if any floor fails:
The script assigns which structure gets built: your top-ranked structure is what every run would ship, and a single ranking is deterministic, so the dice come from outside. Dress its staging challengers in the committed identity and weigh them against your list before building. Never run the script for a local extension or a precisely specified narrow request; shape those directly.
- **Truth:** every product relationship it visualizes exists; resemblance is not evidence.
- **Translation:** remove the literal source prop and label. A coherent product-native experience must remain, still carrying the source's aesthetic and compositional laws. Function without that character is safe flattening; character without product structure is costume.
- **Signature:** one authored move makes the experience unmistakable and materially shapes implementation. Name what the visitor experiences and what becomes possible; routine state or polish does not pass.
- **Survival:** its identity and signature remain compelling on the primary device within the real asset and tool budget.
- **Fit:** its risk is an honest tradeoff, not a probable violation of the brief or audience.
### Create or replace the visual world
Passing some floors never rescues a failed one. The selected signature becomes the direction contract's `BAR-RAISER`.
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, so no die face is spent on the page the category already ships.
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, the notation, publications, identity programs, data graphics, and interfaces it reads daily, not only its physical objects; a nameable abstract system (a school of poster, a documentation standard, a data-graphic tradition) 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; the audience's world is larger than that, so 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 .github/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>` and follow what it prints. The script assigns which direction gets built and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, and clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity; losing to strong grounded material is a valid outcome, and beating a thin or tool-monoculture list is the point.
5. Present one direction, fully committed: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Offer re-roll with an optional one-line steer instead of a ranked menu; a lineup invites the safest card. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. In an attended session where a browser can open, present the decision visually: write an options payload with the assigned direction leading, the dealt challengers as alternates carrying their QUALITY BAR cards, and re-roll plus steer enabled, then run `node .github/skills/impeccable/scripts/serve-question.mjs --start --payload <file>` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, preferring the in-app browser when the harness has one, then the system opener, then showing the URL. Collect the choice with `--wait --key <key>`, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. Fall back to the structured question tool when no browser can open at all.
## 5. Shape or select the direction
Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. When the harness can view images, open the QUALITY BAR board and hero the seed prints for the world you build (when it only reads local images, download the card to a temp file first and view that): they set the craft level the build must reach, the finish, commitment, and art direction of a rendered reference, and never dictate the composition; your surface serves this product.
Do not select a new world and its first surface concept in separate tournaments. That creates a safe global choice followed by a more interesting local choice whose "lineage" exists only in prose. Pick the one lane that matches the intent and authority path.
Every direction the roll can land on must already be viable: every relationship and claim it visualizes true, a real palette and component family, a distinctive composition with one product-specific experience, workable at full-surface scale within the available assets, tools, and performance budget. A candidate that fails on truth is replaced before the roll, never rescued by it. Truth binds claims, not demonstrations: in greenfield work, author whatever illustrative material the concept needs at full fidelity, label it synthetic wherever a visitor could mistake it for the real thing, and hand the user the list of what to replace with real material. What stays uninventable are commercial and factual claims: prices, customers, benchmarks, endpoints, capabilities the product does not have. Refusing a bold direction because its demonstration data does not exist yet is the timidity reflex wearing honesty's clothes.
### Local extension (B or C): inherit, do not reseed
For **Persuade**, the opening must make the offer intelligible and desirable, expose a clear action, and demonstrate something only this product can prove. Conversion lives inside the form's own vocabulary: a hook that lands in one line, a visible primary action, a legible reading order. A committed form that hides the offer or the action has not finished translating. For **Operate**, expression may never obscure the task, state, or familiar affordance. For **Read**, comprehension and wayfinding remain intact. For **Experience**, the work itself leads from the first viewport.
Inherit both the world and the surrounding surface's direction. Resolve only the decisions the addition actually introduces: purpose, content, hierarchy, state or interaction, and how it joins the existing sequence; in an attended run use short, related question rounds while those are open. Do not run `concept-seed.mjs`, generate competing surface metaphors, offer alternate worlds, write a direction contract, or change DESIGN.md without user approval. The result may still have an authored, surprising layout; its novelty must come from the material and the established grammar, not a new identity thesis. If the request reveals a genuine gap in the brand system, name the gap and ask before treating it as path D; never silently turn a section into an expression-expansion exercise.
## 4. Commit the world
### New surface inside stable authority (B or C)
Pick a color strategy before picking colors: Restrained (neutrals plus one accent; the default when the visitor came to operate or read), Committed (one saturated color carries 30-60% of the surface), Full palette (3-4 named roles), or Drenched (the surface IS the color). Persuade and Experience surfaces have permission for the bolder strategies; take them when the brief allows. Color commits at page scale: fields that own whole regions, not accents scattered over a neutral ground. Dark or light is never a default: write one sentence of physical scene (who uses this, where, under what light) and let it force the answer.
The world supplies the vocabulary; the task concept supplies the sentence.
Choose faces like objects from the subject's world, in the mode's register. Operate and Read surfaces are well served by system stacks and workhorse UI faces; Persuade and Experience surfaces want faces with a point of view, and these training-data defaults mean you stopped looking: Fraunces, Playfair Display, Cormorant, Lora, Crimson, Newsreader, Syne, Space Grotesk, Space Mono, IBM Plex, Inter-as-display, DM Sans, DM Serif, Outfit, Plus Jakarta Sans, Instrument Sans. Naming one of these faces anyway requires a reason no other face could satisfy, and a subject association is never that reason: books wanting a serif, bookshops wanting hand-lettering, and tech wanting a mono are the associations the list exists to break.
1. Derive five to seven structural candidates from the content, mechanism, audience, and confirmed authority. Translate its relationships and behavior, not just its styling.
2. Run `node .github/skills/impeccable/scripts/concept-seed.mjs --scope surface --mode <mode>` (the surface's mode: persuade, operate, read, or experience) only when a whole page, route, screen, flow, or standalone experience calls for high-concept exploration. Otherwise shape the strongest grounded structure directly. Never run it for a local extension. Translate every supplied STAGING CHALLENGER into this product's content and behavior, dress it in the committed identity, and compare it with the grounded structures; the point is to challenge the habitual layout, not append a flourish.
3. Name the habitual arrangement and predictable contrarian response. Apply the candidate floor, then judge survivors skin-blind: topology, sequence, or interaction must remain different after names and styling disappear.
4. When materially different whole-surface choices would help, present two or three neutral options with thesis, sequence, focal moment, signature, implementation consequence, and concrete inherited world rules, following **Present, visualize, re-roll** below. Let the user select or revise before code.
Calibration: AI-generated interfaces cluster around a few looks regardless of subject: warm cream ground, high-contrast serif display, and a terracotta or signal-red accent; near-black with one neon accent and glowing edges; broadsheet-editorial hairlines, italic display serif, and small tracked mono labels. All are legitimate when the brief calls for them; the brief always wins. Where the brief leaves the aesthetic free, landing in one of them means the self-check failed: if someone could guess your aesthetic from the category alone, or from category-plus-avoidance, rework until neither answer is obvious. Energy is not the enemy of trust: a brief's negative constraints (no gamification, no hype) rule out those devices, not exuberance, and adjectives describing the product's behavior (quiet support, calm coaching) do not dictate the surface's energy. A bookish, warm, or child-facing subject does not soften the calibration: book cloth, thread, jackets, endpapers, and shelf ephemera span the whole saturated spectrum, and cream paper is the smallest corner of that world; landing on cream plus serif for a book subject is the default wearing the subject's clothes. A brief-pinned world pins the world, not its softest rendition: the pinned world's full material range stays in play, and a rendition that matches what any model ships for that world failed the self-check at execution rather than selection.
Unattended: use the promoted candidate when a roll ran, otherwise the strongest grounded structure; it must survive both tests.
## 5. Record the decision
### New or replacement world (A, D, or E): choose a coupled pair
Before code, state the chosen direction as a contract in the artifact's opening comment, five short blocks, 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. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract.
1. **Ground.** Derive the product mechanism, user scene, audience's cultural home, and what this surface uniquely proves. Name the category default and its predictable contrarian response; neither may enter the shortlist unchanged.
2. **Derive pairs.** Generate five to seven grounded coupled pairs and order them by product fit. Each joins a durable system to a concrete first-surface structure, native behavior, non-routine signature move, and implementation consequence. Different names or materials on the same experience are one candidate.
3. **Break the ranking rut once.** Run `node .github/skills/impeccable/scripts/concept-seed.mjs --scope direction --mode <mode>`, where the mode is the first surface's: persuade, operate, read, or experience. The seed names a PROMOTED INDEX; elevate the pair at that position of your own ranked list into the serious shortlist and judge it as a peer of your top picks. For each world challenger, translate its system laws (material behavior, type/composition, topology, state, and motion) into product-native equivalents. Remove the literal carrier, not the character that made the source worth entering. Compare every translated pair with the grounded list on audience identification, product clarity, system leverage, and use of the medium. Translate each supplied FIRST-SURFACE STAGING into one concrete product structure and use the set to challenge your own habitual composition; when one survives, bind it to the world as one coupled decision.
4. **Test at full strength.** Apply the candidate floor. Strip names, styling, and source carrier; survivors must still differ in structure, sequence, or interaction. Their world must also govern the whole product: the navigation, a dense surface, and a quiet surface, with the last two dissimilar.
5. **Offer coupled choices.** Present two or three equally viable pairs without recommendation cues, following **Present, visualize, re-roll** below. For each, show the world rules, first-surface expression, signature move and implementation consequence, cross-surface breadth, and risk. If fewer than two clear every veto, derive replacements rather than padding the choice. Ask what is closest, should combine, or feels wrong; rejection is allowed.
6. **Resolve once.** The user selects or revises the pair. Extract the durable rules into DESIGN.md and the task-specific strategy into the surface brief; do not reopen either half independently.
When a new or replacement world is chosen, write DESIGN.md at the appropriate project or app boundary using [document.md](document.md). Record only durable system rules; exact tokens may remain provisional until the first build establishes them. An ordinary extension does not rewrite DESIGN.md.
Unattended: use the assigned grounded pair only if it survives product fit, coupling, and breadth; mark assumptions. This is fallback, not user choice.
### Present, visualize, re-roll
These rules govern every candidate presentation above, in either lane.
When the harness can generate images, visualizing the finalists is required, not decoration: render each presented candidate as a world board (palette, materials, type voice, component character) plus a first-surface mock at a realistic viewport; a surface-scope candidate inside a committed world gets the mock alone. Render designed artifacts: invented product names, real English interface copy, no instruction text transcribed onto the image, no dashboard or game chrome the concept itself did not earn. The images are selection aids only; the direction contract and DESIGN.md stay the sole authority, and no image persists as a project file.
Always offer **re-roll** beside the candidates, with an optional one-line steer for what is missing. Re-roll eliminates every candidate presented so far, grounded and challenger alike; none may return reworded. Derive genuinely new grounded candidates from unexplored angles of the same grounding, honor the steer, rerun the seed with `--from <key> --reroll <round>` so it draws challengers it has not yet shown, apply the candidate floor, rerank, and present again under these same rules. After two consecutive re-rolls, ask what quality is missing before rolling a third time; silent re-rolls converge on guessing.
An unattended run has no one to re-roll or eye-test. When image generation is available there, render one mock of the chosen direction and correct material drift between mock and build before finishing.
For a substantial high-fidelity surface with native image generation, load [codex.md](codex.md) after selection; probes stay inside the selected direction. For `shape`, stop after selection and continue in [shape.md](shape.md).
## 6. Expand, then contract, the chosen direction
If a competent default could satisfy the concept, sharpen its focal moment until one product-specific move changes implementation and raises the experience above competent convention.
Before compressing the decision, expand one coherent studio plan in working context:
- **Spatial:** the whole-surface composition, navigation, reveals, and rhythm, not only section order.
- **Motion:** the coordinated motion story and its major moments, not a list of repeated effects.
- **Interaction:** the flagship experience, its feedback, and what the visitor can understand or do through it.
- **Narrative:** how copy, evidence, and action build one persuasive or usable arc.
- **System:** how the world governs components, dense and quiet surfaces, states, and responsive behavior.
Make concrete decisions in every relevant discipline and make them cause one another. Do not use the list to decorate an unchanged page skeleton.
Before code, write a direction contract of at most 150 words in the artifact's opening HTML or framework comment; the first 200 characters must name `DIRECTION CONTRACT`. The artifact comment is the contract's only home: hooks audit the render against it, and the surface brief references it rather than duplicating it.
- `THESIS`: the product-specific idea and the category-default arrangement it refuses;
- `OWN-WORLD`: the confirmed DESIGN.md invariants, tokens, and materials it uses;
- `STORY`: what the visitor understands, believes, and does;
- `FIRST VIEWPORT`: exact composition, hierarchy, action, and where the concept exceeds competent convention;
- `BAR-RAISER`: the selected signature move: what the visitor experiences, what it makes possible, and the implementation consequence that keeps the surface from collapsing to a competent default;
- `FORM`: chosen structure or behavior, signature, implementation consequence, and seed key.
The contract is task-scoped, inspectable, and subordinate to the user's choice. A local extension skips it unless the user explicitly wants the addition to become a distinct authored moment; the shaped decisions are its implementation plan instead.
## 7. Persist the surface brief
Once the primary target or route is known, persist durable surface-level product/UX strategy separately from PRODUCT.md and DESIGN.md. `<primary-target>` is a concrete repo-relative source file path or route URL, never a natural-language description; the script derives the clone-stable slug mechanically, so never hand-write one. Prefer a clone-stable source file; map routes and alternate entries as related targets. For a local extension, update the parent surface's record only when the work establishes durable product strategy; do not create a component-level brief by reflex. Read any record first:
If the work establishes durable strategy for a route or artifact, read its existing surface brief, then update it:
`node .github/skills/impeccable/scripts/surface-brief.mjs read <primary-target>`
Exit 0: preserve still-valid decisions and change only what the user changed; for redesign, retain valid product strategy, content, function, and open decisions while replacing the visual direction. Exit 2: no brief exists. Write with `node .github/skills/impeccable/scripts/surface-brief.mjs write <primary-target> <body-file> [related-target ...]`, which persists `.impeccable/surfaces/<target-slug>.md` as durable context for later sessions; leave version control to the user's normal flow. Exclude global truth, exact tokens, transient notes, and work logs. The concise body:
`node .github/skills/impeccable/scripts/surface-brief.mjs write <primary-target> <body-file> [related-target ...]`
```markdown
# Surface brief: [name]
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.
## Scope
[Primary/related route or artifact, visitor mode, and what this surface owns.]
Visualize before you build whenever any image generation is available, a harness-native tool or the API fallback context.mjs reports: render the chosen direction as a design-system board and a first-surface mock, correct material drift between mock and intent, then build. Seeing the direction first measurably strengthens the result. [codex.md](codex.md) carries the deep native-generation flow; the mock is a selection aid, not authority.
## Product strategy
[Surface-specific audience and job, desired outcome, primary/secondary action or task, content and proof sequence, factual constraints, and explicitly open decisions.]
For `shape`, return the selected direction to [shape.md](shape.md) and stop before persistence or implementation.
## Selected direction
[Reference to the applicable DESIGN.md world or expression range, selected surface concept, focal moment, narrative/interaction sequence, implementation consequence, and a pointer to the artifact carrying the direction contract.]
## 6. Build with full commitment
## Open decisions
[Only unresolved items that later work must not silently invent. Omit when empty.]
```
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.
## 8. Write or update DESIGN.md
- **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. 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; never invent prices, customers, benchmarks, or capabilities.
- **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.** 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.
For A or E, write or replace DESIGN.md at the resolved project/app boundary; for D, merge only the approved expansion range. The format, in brief: YAML frontmatter carrying machine-readable tokens (colors, typography, rounded, spacing, components), then the canonical markdown sections in fixed order, with tokens normative and prose as application context. [document.md](document.md) carries the full schema and examples; never fetch a remote spec. Record the chosen durable rules and add:
Preserve semantics, accessibility, performance, responsiveness, project conventions, and working behavior.
`<!-- STATUS: DIRECTIONAL SEED; exact tokens remain provisional until the first implementation pass. -->`
## 7. Inspect and finish
Do not fabricate YAML tokens; add exact values only after the user, assets, or implementation establishes them. The selected pair authorizes its world and first expression together without another confirmation. A local extension does not change DESIGN.md unless the user approves a durable system addition.
Inspect desktop and mobile, critique the render against the user's request, the direction contract, and DESIGN.md, fix material gaps, and re-inspect. On a Persuade surface, verify the mode did its job: a first-time visitor should know what this is, why it matters, and what to do within seconds, in the form's own vocabulary. After a first implementation of a new world, update DESIGN.md with the exact tokens and behaviors that survived the build.
## 9. Plan and build
Plan from the selected direction or local shape and real content, never a category skeleton. In redesign, remove inherited visual tokens. Load only needed specialist references; focal interaction or authored animation reads [animate.md](animate.md), even without the `animate` command.
Build the strongest coherent direction once. Its grammar governs navigation, actions, controls, content, and transitions without disguising affordances. Give the focal form the scale that gives it force; do not trap it inside a conventional opening panel.
- **Make the opening a thesis.** Demonstrate the mechanism immediately; leave an idea, interaction, or evidence, not merely mood.
- **Commit before correcting.** Land the hard move at full strength before refining it. In unattended work, safety is the known risk.
- **Commit at page scale.** Let color, material, image, or type own a region when the world calls for it. Scattered signature decoration is not commitment.
- **Pace the whole surface.** Vary density, scale, image, motion, and quiet inside one grammar. Cut repeated claims; prove the mechanism with real artifact, interaction, data, transformation, or content.
- **Author motion as material.** Motion expresses the world and task. Premium moments go beyond repeated transform/opacity through earned focus, depth, masks, continuity, light, or material change. Bound expensive effects, test in-browser, and keep content visible by default.
Preserve semantics, affordances, accessibility, performance, responsiveness, and project conventions. Operate/Read express through topology, hierarchy, density, rhythm, and state; Persuade/Experience may earn drama.
## 10. Solidify the visual record
After first implementation of a new/replacement world or approved expansion, refresh DESIGN.md from the build: replace provisional direction with the exact type, color roles, tokens, spacing/radii, components, states, and motion that survived; add normative YAML tokens only for values the implementation actually uses; remove the directional-seed status once record and implementation agree; preserve broader world invariants and expression ranges. Do not promote the task's story, opening composition, or one-off motif into a global rule unless it is intentionally reusable. Ordinary extension does not rewrite DESIGN.md; only approved durable changes do.
## 11. Finish like a studio
Inspect desktop and mobile; critique against the brief, DESIGN.md, and the applicable shape, concept, or contract; patch material defects; recheck skin-blind. Follow the quality guidance supplied by `context.mjs` and hooks. Context requests a manual scan only when no automatic detector is active; never add another detector pass. Fix real gaps and classify false positives until none remain.
When the harness can run a separate agent, this finishing review belongs there, not in the build thread: hand the reviewer the artifact path, its direction contract, and the detector command, and have it return a short list of material fixes; apply them and finish. A fresh reader catches what the builder's own eyes slide past.
When the harness can run a separate agent, this review belongs there, not in the build thread: give it the original request, confirmed answers, the artifact path, its direction contract, DESIGN.md, and existing hook findings. Ask for a short list of material fixes, promise by promise against the contract, apply them, and finish. Do not run a second detector.
@@ -1,6 +1,6 @@
# Operate mode depth (and Read notes)
When design SERVES the product: app UIs, admin dashboards, settings panels, data tables, tools, authenticated surfaces, anything where the user is in a task. The essentials live in SKILL.md (modes, craft floor); this file is extended depth, written for Operate surfaces. Read surfaces (docs, guides, long-form) take SKILL.md's Read mode plus this file's typography and consistency rules; their prose measure and navigation matter more than component density.
When design SERVES the product: app UIs, admin dashboards, settings panels, data tables, tools, authenticated surfaces, anything where the user is in a task. The essentials live in SKILL.md's modes and [craft-floor.md](craft-floor.md); this file is extended depth, written for Operate surfaces. Read surfaces (docs, guides, long-form) take SKILL.md's Read mode plus this file's typography and consistency rules; their prose measure and navigation matter more than component density.
## The product slop test
@@ -34,6 +34,7 @@ Every interactive component has: default, hover, focus, active, disabled, loadin
- Skeleton states for loading, not spinners in the middle of content.
- Empty states that teach the interface, not "nothing here."
- Consistent affordances across the surface. Same button shape. Same form-control vocabulary. Same icon style.
- Overlays escape their container. An absolutely positioned dropdown inside an `overflow: hidden` or `overflow: auto` ancestor gets clipped; reach for `<dialog>`, the popover API, `position: fixed`, or a portal.
## Motion
@@ -2,8 +2,6 @@
Discover what should be made and how it should work, then return a confirmed design brief without code.
**Product gate:** when context reports that PRODUCT.md is missing, load and complete [init.md](init.md) before Phase 1. Do not substitute shape questions for the product interview. Once PRODUCT.md exists, return here; product context does not replace task discovery.
## Phase 1: Discovery interview
Do not write code or choose visual direction yet.
@@ -1,10 +1,10 @@
#!/usr/bin/env node
/**
* External concept seed: the dice half of new-work's coupled-direction and
* External concept seed: the dice half of new-work's complete-direction and
* established-world surface procedures.
*
* The model derives a grounded shortlist of candidate FORMS from the
* audience's world and the subject's cultural home (see
* Before this script runs, the model retrieves cultural material and derives
* a grounded shortlist of complete candidate directions from it (see
* reference/new-work.md). Left alone, it then always builds its #1 —
* and a single model's resonance ranking is deterministic, so every run
* in a category ships the same one or two concepts. Measured: 30/35
@@ -13,19 +13,23 @@
*
* This script rolls them from outside, the same trick that made the
* palette seed work:
* - PROMOTED INDEX: which entry of the model's own resonance-ordered
* shortlist must be taken seriously beside its favorites. The dice never
* choose an ungrounded ingredient; they only refuse the argmax rut.
* - ASSIGNED INDEX: which entry of the model's own resonance-ordered
* shortlist gets built. The assignment is the dice: it never chooses an
* ungrounded ingredient, it only refuses the argmax rut. Attended runs
* present the assigned direction and offer re-roll instead of a ranked
* lineup, because a lineup hands selection back to a taste function
* (model or user) and taste functions pick the safest card.
* - CHALLENGERS (6): outside forms from concept-ingredients.json, two from
* each challenger tier (graphic system, instrument language, atmosphere
* world), weighed
* against the derived candidates on audience identification, product
* clarity, system leverage, and use of the medium. They win only when they beat the
* grounded list; measured behavior is that they lose to strong cultural
* material and win over thin categories, which is the intended shape.
* world), fused with the product first (challenger supplies form and
* system grammar, product supplies every fact, clarity wins conflicts),
* then weighed against the derived candidates on audience identification
* and product clarity. They win only when they beat the grounded list;
* measured behavior is that they lose to strong cultural material and
* win over thin categories, which is the intended shape.
* - RE-ROLL (--reroll <n>): round n of the same base key. The script
* recomputes what rounds 0..n-1 drew, excludes all of it, and rolls a
* fresh promoted index, challengers, and staging. One base key therefore
* fresh assigned index, challengers, and staging. One base key therefore
* reproduces the entire chain of rounds.
* - RATINGS: the reviewer's approval ratings weight the challenger draw
* (3-star doubles the odds, 1-star sits out); the approved pool itself
@@ -34,6 +38,7 @@
* Usage:
* node scripts/concept-seed.mjs --scope direction --mode persuade
* node scripts/concept-seed.mjs --scope surface --mode operate --from <key>
* node scripts/concept-seed.mjs --scope direction --candidate-count 6
* node scripts/concept-seed.mjs --scope direction --mode persuade --from <key> --reroll 1
* node scripts/concept-seed.mjs --chosen <challenger-id> --from <key> --scope direction
*
@@ -43,7 +48,7 @@
*
* Challenger data resolves in order: a local catalog directory (the private
* service repo, evals, and tests set IMPECCABLE_CATALOG_DIR), then the roll
* API at impeccable.style, then a degraded promotion-only seed when both are
* API at impeccable.style, then a degraded assignment-only seed when both are
* unavailable. --chosen sends the anonymous choice ping for API-dealt rolls;
* DO_NOT_TRACK or IMPECCABLE_NO_TELEMETRY disables it.
*
@@ -70,7 +75,7 @@ const here = dirname(fileURLToPath(import.meta.url));
// Data resolution order: a local catalog (the private service repo, evals, and
// tests point IMPECCABLE_CATALOG_DIR at one), then the roll API, then a
// degraded promotion-only seed. The full catalog does not ship with the skill.
// degraded assignment-only seed. The full catalog does not ship with the skill.
const CATALOG_DIR = process.env.IMPECCABLE_CATALOG_DIR || here;
const API_BASE = (process.env.IMPECCABLE_API_URL || 'https://impeccable.style/api').replace(/\/$/, '');
const API_TIMEOUT_MS = Number(process.env.IMPECCABLE_API_TIMEOUT || 4000);
@@ -154,18 +159,25 @@ export async function pingChosen({ chosenId, key, scope, mode }) {
}
}
const CARD_BASE = 'https://impeccable.style/worlds/cards';
export function renderChallenger(concept, index) {
const system = concept.system.map(rule => ` - ${rule}`).join('\n');
const board = concept.cardBoard || `${CARD_BASE}/${concept.id}.webp`;
const hero = concept.cardHero || `${CARD_BASE}/${concept.id}-hero.webp`;
return ` ${index + 1}. ${concept.form}
SOURCE ID: ${concept.id}
CREATIVE SPARK: ${concept.spark}
SYSTEM GRAMMAR:
${system}
WEB LEVERAGE: ${concept.webLeverage}`;
WEB LEVERAGE: ${concept.webLeverage}
QUALITY BAR: board ${board} · hero ${hero}`;
}
export function renderStaging(composition, index = null) {
const grammar = composition.grammar.map(rule => ` - ${rule}`).join('\n');
return ` ${index == null ? '' : `${index + 1}. `}${composition.form}
SOURCE ID: ${composition.id}
SPARK: ${composition.spark}
STAGING GRAMMAR:
${grammar}
@@ -309,6 +321,7 @@ export function renderConceptSeed({
key = process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex'),
reroll = 0,
mode = null,
candidateCount = 7,
catalogDir = CATALOG_DIR,
_resolvedData = undefined,
} = {}) {
@@ -321,15 +334,18 @@ export function renderConceptSeed({
if (mode !== null && !SEED_MODES.has(mode)) {
throw new Error('concept-seed: --mode must be persuade, operate, read, or experience');
}
if (!Number.isInteger(candidateCount) || candidateCount < 5 || candidateCount > 7) {
throw new Error('concept-seed: --candidate-count must be an integer from 5 to 7');
}
const unit = (salt) => {
const h = crypto.createHash('sha256').update(`${scope}:${salt}:${key}`).digest();
return h.readUInt32BE(0) / 0xffffffff;
};
const indexSalt = reroll === 0 ? 'index' : `index:reroll-${reroll}`;
const buildIndex = 3 + Math.floor(unit(indexSalt) * 5); // 3..7
const buildIndex = 3 + Math.floor(unit(indexSalt) * (candidateCount - 2)); // 3..candidateCount
// Local catalog first (private repo, evals, tests), then the roll API,
// then a degraded promotion-only seed. The promoted index is pure local
// then a degraded assignment-only seed. The assigned index is pure local
// math, so even a fully offline run keeps the anti-argmax mechanism.
let data = _resolvedData ?? null;
if (_resolvedData === undefined) {
@@ -357,6 +373,7 @@ export function renderConceptSeed({
key,
reroll,
mode,
candidateCount,
catalogDir,
_resolvedData: roll ? {
source: 'api',
@@ -371,68 +388,64 @@ export function renderConceptSeed({
}
const promotedInstruction = scope === 'direction'
? `After ordering the grounded coupled directions by product fit, promote
candidate ${buildIndex} into the serious shortlist. Each candidate must join
a durable visual system to a concrete expression for the requested first
surface; select or revise that pair as one decision. It must survive the
current task plus navigation, quiet and dense content, interaction and state,
and a substantially different future surface.`
? `After ordering the grounded directions by resonance, build candidate
${buildIndex} of your own grounded list; the assignment never points at a
challenger. The assignment is the roll, not a suggestion: your top-ranked
direction is what every run would ship, so the script decides which grounded
direction gets built. Each direction joins a durable visual system to a
concrete expression for the requested first surface, decided as one. It must
survive the current task plus navigation, quiet and dense content,
interaction and state, and a substantially different future surface. In an
attended run, present the assigned direction fully committed and offer
re-roll; never present a ranked lineup to choose from. Re-roll yourself only
on named factual grounds, when the assignment cannot carry the product's
truth or task; taste is never grounds.`
: `After ordering the task's grounded structural candidates by resonance,
promote candidate ${buildIndex} into the serious shortlist. In an attended
run, present it beside the strongest materially different candidates and
let the user select or revise the surface concept. In a truly unattended
run, use it when it survives audience identification, product clarity,
system leverage, and use of the medium.`;
build candidate ${buildIndex} of your own grounded list; the assignment never
points at a challenger. The assignment is the roll, not a suggestion.
In an attended run, present the assigned structure and offer re-roll; never
present a ranked lineup to choose from. Re-roll yourself only when the
assignment fails audience identification or product clarity on named
factual grounds.`;
const challengerInstruction = scope === 'direction'
? `Translate each challenger's organizing logic into reusable identity grammar
and a strong first-surface structure before judging it. Noticeable form is
allowed when the product stays clear. Compare audience identification,
product clarity, system leverage, and use of the medium.`
: `A challenger wins only when it beats the grounded list on audience
identification, product clarity, system leverage, and use of the medium. It may change task topology or
? `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 the fused result against the assigned direction on exactly
two axes, audience identification and product clarity. Losing to strong
grounded material is a valid outcome; beating a thin or tool-monoculture
list is the point. A fused challenger that wins both axes becomes the build.`
: `A challenger wins only when its fused result beats the grounded list on
audience identification and product clarity. It may change task topology or
interaction, but never the committed visual identity.`;
const authorityInstruction = scope === 'direction'
? `PRODUCT.md and explicit incumbent brand commitments constrain every coupled
direction. The seed never chooses exact colors, fonts, tokens, or a user
preference, and it never permits the world and first surface to be selected
independently.`
? `PRODUCT.md and explicit incumbent brand commitments constrain every direction.
The seed never chooses exact colors, fonts, tokens, or a user preference, and
it never permits the world and first surface to be selected independently.`
: `PRODUCT.md and DESIGN.md constrain every surface candidate's identity
vocabulary; they do not cancel task-level composition. The seed never
authorizes a new palette, type system, material world, or unfamiliar control
behavior.`;
const richnessInstruction = `The CREATIVE SPARK is a visual world, artifact, or graphic tradition people
would genuinely choose to enter, study, or explore, and whose palette,
materials, type voice, and component grammar a designer could sketch on
sight, not decorative art direction. The challengers are drawn two from each
translation tier: graphic systems that map to interface almost directly,
instrument or display languages that carry interaction physics, and material
or performed worlds that need the largest translation step; judge
each in its own register and pay that translation cost honestly. Translate
its scale, material, spatial or compositional law, tension, rhythm, and
memorable human experience into product structure. Inherit a movement's or
artifact's rules, never just its name: grid, geometry, ornament logic,
material behavior, and information structure become the interface's. Preserve
the spark's imaginative distance: do not collapse a galaxy into a mission
dashboard, a forest into a taxonomy app, or a performance into a control
console. Translation is complete only when the source name and physical prop
can disappear while a product-native relationship, state change, or proof
remains. A carrier survives only when product evidence makes it functional;
never name a candidate after the prompt merely to preserve the spark. Use
Three.js, generative motion, film language, typography, craft,
or another ambitious medium when it materially strengthens the task; keep
semantic structure and graceful fallbacks fully capable.`;
const richnessInstruction = `The CREATIVE SPARK is a complete visual system, not a theme or decorative
reference. Translate every supplied system rule into the product: palette and
material, type and composition, topology, controls and states, and adaptation.
Keep the source's visible character, scale, rhythm, and interaction instead of
reducing vivid grammar to generic nouns. When the source is already a credible
interface language, commit to it across navigation, content, controls, and
states. Otherwise keep a literal carrier only when it becomes functional.
Ambitious motion, spatial media, or interaction is welcome when it strengthens
the product without weakening semantics, performance, or fallback behavior.`;
if (!data) {
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: degraded; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''})
PROMOTED INDEX: ${buildIndex}
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: degraded; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''} --candidate-count ${candidateCount})
ASSIGNED INDEX: ${buildIndex}
${promotedInstruction}
The promotion exists to refuse the model's ranking rut, not to outrank the
user or the brief. Never expose promotion metadata in choice labels or order.
The assignment exists to refuse the model's ranking rut, never to outrank
the user or the brief. Never expose assignment metadata in user-facing labels.
No challengers this run: the roll service was unreachable and no local
catalog exists. Proceed with the grounded candidates alone; the promotion
catalog exists. Proceed with the grounded candidates alone; the assignment
above still applies at full strength.
${authorityInstruction}
A user- or brief-pinned decision beats the roll, always.
@@ -461,14 +474,18 @@ habitual composition, but keep only structures that strengthen this product.\n`
after resolution. The ping is anonymous (chosen id only) and is skipped
automatically when DO_NOT_TRACK or IMPECCABLE_NO_TELEMETRY is set.\n`
: '';
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: ${data.source}; approved pool: ${data.poolRevision}; ${data.approvedCount}/${data.catalogCount} human-approved; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''} to reproduce this roll against this catalog revision)
${rerollBlock}PROMOTED INDEX: ${buildIndex}
return `${scope.toUpperCase()} CONCEPT SEED (key: ${key}; mode: ${mode ?? 'unscoped'}; source: ${data.source}; approved pool: ${data.poolRevision}; ${data.approvedCount}/${data.catalogCount} human-approved; rerun with --scope ${scope}${mode ? ` --mode ${mode}` : ''} --from ${key}${reroll > 0 ? ` --reroll ${reroll}` : ''} --candidate-count ${candidateCount} to reproduce this roll against this catalog revision)
${rerollBlock}ASSIGNED INDEX: ${buildIndex}
${promotedInstruction}
The promotion exists to refuse the model's ranking rut, not to outrank the
user or the brief. Never expose promotion metadata in choice labels or order.
The assignment exists to refuse the model's ranking rut, never to outrank
the user or the brief. Never expose assignment metadata in user-facing labels.
CHALLENGERS:
${data.challengers.map(renderChallenger).join('\n')}
${stagingBlock}${challengerInstruction}
When you can view images, open the QUALITY BAR board and hero for any
challenger you weigh seriously and for the world you build. They exist as a
craft bar, the finish level and commitment the build is expected to reach,
never as a mockup to copy; your surface serves this product, not that render.
${authorityInstruction}
${richnessInstruction}
${telemetryBlock}A user- or brief-pinned decision beats the roll, always.
@@ -481,6 +498,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
const scopeIdx = args.indexOf('--scope');
const rerollIdx = args.indexOf('--reroll');
const modeIdx = args.indexOf('--mode');
const candidateCountIdx = args.indexOf('--candidate-count');
const chosenIdx = args.indexOf('--chosen');
try {
if (chosenIdx !== -1) {
@@ -500,6 +518,7 @@ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.ur
: (process.env.IMPECCABLE_CONCEPT_SEED || crypto.randomBytes(4).toString('hex')),
reroll: rerollIdx !== -1 ? Number(args[rerollIdx + 1]) : 0,
mode: modeIdx !== -1 ? args[modeIdx + 1] : null,
candidateCount: candidateCountIdx !== -1 ? Number(args[candidateCountIdx + 1]) : 7,
}));
}
} catch (error) {
+33 -13
View File
@@ -32,7 +32,6 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseTargetOptions } from './lib/target-args.mjs';
import { IMPECCABLE_COMMAND, IMPECCABLE_PROVIDER_ID } from './lib/provider.mjs';
import { renderLlmOnlySlopReview } from './lib/slop-review.mjs';
import { resolveSurfaceBrief } from './lib/surface-briefs.mjs';
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
@@ -1127,7 +1126,8 @@ async function cli() {
];
appendSurfaceBriefContext(parts, ctx);
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
appendHookFallback(parts, ctx);
appendDetectorFallback(parts, ctx);
appendImageGenDirective(parts);
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
parts.push(buildMissingTargetDirective());
}
@@ -1141,7 +1141,8 @@ async function cli() {
}
appendSurfaceBriefContext(parts, ctx);
parts.push(buildResolvedContextDirective(ctx, cliOptions, { targetExists }));
appendHookFallback(parts, ctx);
appendDetectorFallback(parts, ctx);
appendImageGenDirective(parts);
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
parts.push(buildMissingTargetDirective());
}
@@ -1195,6 +1196,7 @@ const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
agents: ['.codex/hooks.json'],
cursor: ['.cursor/hooks.json'],
github: ['.github/hooks/impeccable.json'],
grok: ['.grok/hooks/impeccable.json'],
});
function truthyEnv(value) {
@@ -1223,7 +1225,7 @@ function hookEnabledAt(root) {
return enabled;
}
const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents']);
const STOP_REVIEW_PROVIDERS = new Set(['claude-code', 'codex', 'agents', 'grok']);
function automaticHookMode(ctx) {
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') {
@@ -1244,15 +1246,33 @@ function automaticHookMode(ctx) {
return 'none';
}
function appendHookFallback(parts, ctx) {
const hookMode = automaticHookMode(ctx);
if (hookMode === 'stop') return;
const native = ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive';
parts.push(renderLlmOnlySlopReview({
automaticDetector: hookMode === 'per-edit',
manualDetector: hookMode === 'none' && !native,
scriptsPath: path.dirname(fileURLToPath(import.meta.url)),
}));
// Image generation availability: harness-native tools always win, but when the
// environment carries an OpenAI key the API fallback works everywhere. The
// flag only reports capability; generate-image.mjs states cost before use.
function appendImageGenDirective(parts) {
if (!process.env.OPENAI_API_KEY) return;
const scriptsPath = path.dirname(fileURLToPath(import.meta.url));
parts.push([
'IMAGE_GEN_AVAILABLE: An OpenAI key is present, so image generation works even without a harness-native image tool:',
`\`node ${scriptsPath}/generate-image.mjs --prompt "..." --out <file>\` (gpt-image-2, billed to the user's key; say so before the first render).`,
'Prefer the harness-native image tool when one exists. Visualizing a direction before building it measurably strengthens the result.',
].join(' '));
}
// reference/craft-floor.md carries the detector-blind reflexes on every build,
// so the only gap left here is the mechanical pass. A hook covers it, per-edit
// or Stop; a session without one has to run the detector by hand. The detector
// reads HTML and CSS, so native projects get nothing.
function appendDetectorFallback(parts, ctx) {
if (automaticHookMode(ctx) !== 'none') return;
if (ctx.platform === 'ios' || ctx.platform === 'android' || ctx.platform === 'adaptive') return;
const scriptsPath = path.dirname(fileURLToPath(import.meta.url));
parts.push([
'MANUAL_DETECTOR_REQUIRED: No automatic Impeccable design hook is active this session.',
`Once the changed web UI is finished, run the mechanical detector over it: \`node ${scriptsPath}/detect.mjs --json <changed targets>\`.`,
'Run it once, and not earlier during concept selection.',
].join(' '));
}
function buildResolvedContextDirective(ctx, options, { targetExists = null } = {}) {
@@ -0,0 +1,54 @@
#!/usr/bin/env node
/**
* API image generation fallback: renders a mock or world board with the
* user's own OpenAI key when the harness has no native image generation.
*
* context.mjs reports availability (it checks OPENAI_API_KEY); harness-native
* generation always wins when present. This uses gpt-image-2 and spends the
* user's API credit (roughly $0.05-0.25 per image at default quality), so the
* skill states that before the first call in a session.
*
* node generate-image.mjs --prompt "..." --out mock.png [--size 1536x1024] [--quality medium]
* node generate-image.mjs --prompt-file prompt.txt --out mock.png
*/
import fs from 'node:fs';
function arg(name, fallback = null) {
const i = process.argv.indexOf(`--${name}`);
if (i === -1) return fallback;
const v = process.argv[i + 1];
return v && !v.startsWith('--') ? v : fallback;
}
const key = process.env.OPENAI_API_KEY;
if (!key) {
console.error('generate-image: OPENAI_API_KEY is not set; use the harness-native image tool instead.');
process.exit(1);
}
const promptFile = arg('prompt-file');
const prompt = promptFile ? fs.readFileSync(promptFile, 'utf8') : arg('prompt');
const out = arg('out');
if (!prompt || !out) {
console.error('generate-image: --prompt (or --prompt-file) and --out are required.');
process.exit(1);
}
const size = arg('size', '1536x1024');
const quality = arg('quality', 'medium');
const response = await fetch('https://api.openai.com/v1/images/generations', {
method: 'POST',
headers: { Authorization: `Bearer ${key}`, 'content-type': 'application/json' },
body: JSON.stringify({ model: 'gpt-image-2', prompt, size, quality, n: 1 }),
});
if (!response.ok) {
console.error(`generate-image: API error ${response.status}: ${(await response.text()).slice(0, 300)}`);
process.exit(1);
}
const json = await response.json();
const b64 = json?.data?.[0]?.b64_json;
if (!b64) {
console.error('generate-image: no image in response');
process.exit(1);
}
fs.writeFileSync(out, Buffer.from(b64, 'base64'));
console.log(`IMAGE: ${out} (${size}, ${quality}, gpt-image-2, billed to your OpenAI key)`);
+5 -158
View File
@@ -26,7 +26,6 @@
* loadDetector() -> Promise<{ detectText, detectHtml }>
* matchesAnyGlob(filePath, globs)
* normalizeScanTargets(primaryTargets, projectCwd)
* extractDirectionContract(content) / renderContractAudit(entries, opts)
* runHook(deps) -> { exitCode, stdout, audit, reason? }
* runStopHook(deps) -> { exitCode, stdout, audit, emission? }
*
@@ -46,7 +45,6 @@ import path from 'node:path';
import { pathToFileURL, fileURLToPath } from 'node:url';
import { extractPlatform, loadContext } from './context.mjs';
import { IMPECCABLE_COMMAND } from './lib/provider.mjs';
import { renderStopSlopReview } from './lib/slop-review.mjs';
// `detector.extensions` (issue #316) is shared with Live's source search, which
// needs the same answer for `.heex` / `.blade.php` when it hunts for session
// markers. lib/template-extensions.mjs owns the shape; re-exported here because
@@ -1889,100 +1887,6 @@ export async function runHook({ stdinJson, env = {}, cwd = process.cwd(), now =
}
}
// ── Direction-contract audit ─────────────────────────────────────────────
// The skill's decide-then-build step opens the built HTML artifact with a
// DIRECTION CONTRACT comment (THESIS / OWN-WORLD / STORY / FIRST VIEWPORT /
// BAR-RAISER / FORM blocks). At Stop time the deep pass extracts that
// comment and feeds it back so the model audits the render against its own
// promises. Proven in the eval harness: sample contracts promised radical
// compositions and the build shipped the standard template anyway, because
// nothing ever judged the build against the contract. Zero extra API calls:
// this is a message, not a judge. It fires at most once per file per session
// (a `contractAudited` flag on the session cache entry, the same state the
// deep pass uses for finding dedupe) and rides the Stop pass's existing
// emission rather than adding another block round.
// How far into the file to look for the leading comment. A contract lives at
// the very top of the artifact; anything deeper is not the contract.
export const CONTRACT_HEAD_CHARS = 6000;
// The contract/concept marker must appear early in the comment body, so a
// license header or unrelated note does not get mistaken for a contract.
export const CONTRACT_MARKER_CHARS = 200;
// Cap the extracted contract so a rambling comment cannot blow up the
// Stop message.
export const CONTRACT_MAX_CHARS = 1800;
// Cap contract sections per Stop emission so many touched artifacts cannot
// stack an unbounded message.
export const CONTRACT_AUDIT_MAX_FILES = 3;
export const CONTRACT_EXTS = new Set(['.html', '.htm', '.astro', '.svelte', '.vue', '.jsx', '.tsx']);
export const CONTRACT_REQUIRED_FIELDS = ['THESIS', 'OWN-WORLD', 'STORY', 'FIRST VIEWPORT', 'BAR-RAISER', 'FORM'];
/**
* Extract the artifact's own direction-contract comment from the head of an
* HTML or component file. Supports HTML-family comments and JSX block
* comments so the contract works in the Astro/Svelte/Vue/React scaffolds the
* skill actually builds. Returns the trimmed, length-capped body, or null
* when the file carries no valid contract block.
*/
export function extractDirectionContract(content) {
if (typeof content !== 'string' || !content) return null;
const head = content.slice(0, CONTRACT_HEAD_CHARS);
const candidates = [];
for (const pattern of [/<!--([\s\S]*?)-->/g, /\{\/\*([\s\S]*?)\*\/\}/g]) {
for (const match of head.matchAll(pattern)) {
const index = match.index ?? 0;
const linePrefix = head.slice(head.lastIndexOf('\n', index - 1) + 1, index).trim();
if (linePrefix.startsWith('//')) continue;
candidates.push({ index, body: match[1].trim() });
}
}
candidates.sort((a, b) => a.index - b.index);
for (const candidate of candidates) {
if (!candidate.body || !/direction\s+contract|concept\s+contract/i.test(candidate.body.slice(0, CONTRACT_MARKER_CHARS))) continue;
return candidate.body.slice(0, CONTRACT_MAX_CHARS);
}
return null;
}
export function missingDirectionContractFields(contract) {
const body = typeof contract === 'string' ? contract : '';
return CONTRACT_REQUIRED_FIELDS.filter((field) => {
const label = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+');
return !new RegExp(`\\b${label}\\s*:`, 'i').test(body);
});
}
/**
* Render the contract-audit section of the Stop message. `entries` is
* [{ filePath, contract }]; at most CONTRACT_AUDIT_MAX_FILES are shown.
* The two failure shapes named here are the ones observed in practice:
* a promise the pixels do not deliver, and a contract whose own plan is
* the standard template wearing the concept's nouns.
*/
export function renderContractAudit(entries, opts = {}) {
if (!Array.isArray(entries) || entries.length === 0) return '';
const cwd = opts.cwd || process.cwd();
const shown = entries.slice(0, CONTRACT_AUDIT_MAX_FILES);
const blocks = shown.map(({ filePath, contract }) => {
const display = relativize(filePath, cwd);
const missing = missingDirectionContractFields(contract);
const integrity = missing.length > 0
? `\n\nContract integrity defect: missing ${missing.join(', ')}. Repair the contract and the implementation together.`
: '';
return `${display} opens with this direction contract, written when the direction was decided:\n\n${contract}${integrity}`;
});
return [
`${ENVELOPE_PREFIX} Direction-contract audit. Before finishing, audit the rendered page against the contract it opens with, promise by promise.`,
'',
blocks.join('\n\n'),
'',
'Two failure shapes to check honestly:',
'1. A promise that is not in the pixels: the contract describes a composition or structure the built page does not deliver, because the build fell back to a standard arrangement, possibly one the contract explicitly rejects. Rebuild that part until the render matches the promise.',
"2. A contract whose own section plan is the standard template wearing the concept's nouns: if a neighboring product could ship the same sequence of sections under different labels, revise the plan and the page together.",
'State each promise and whether the render delivers it, and fix every gap before finishing.',
].join('\n');
}
// Cap on files the Stop deep pass will scan. The touched-file list is
// session-scoped and already capped per edit, but a very long session could
// accumulate more than the 30s hook timeout comfortably covers.
@@ -1991,10 +1895,7 @@ export const STOP_MAX_FILES = 20;
/**
* Run the Stop-event deep pass: the FULL detector rule set over every UI
* file touched this session, surfaced once, deduped against everything the
* per-edit hook already reported. Touched HTML artifacts that open with a
* direction-contract comment additionally get a one-time contract-audit
* section appended after the detector findings (see extractDirectionContract
* above). Same result contract as runHook():
* per-edit hook already reported. Same result contract as runHook():
* { exitCode, stdout, audit, emission? }
*
* Never throws; exits silent (and fast) when the session touched no UI
@@ -2054,27 +1955,13 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
return result({ skipped: 'native-platform', platform, durationMs: Date.now() - started });
}
const session = ensureSession(cache, sessionId);
const needsSlopReview = session.llmSlopReviewed !== true;
const det = detector || await loadDetector();
if (!det || typeof det.detectText !== 'function') {
if (!needsSlopReview) return result({ skipped: 'detector-missing', durationMs: Date.now() - started });
session.llmSlopReviewed = true;
session.updatedAt = Date.now();
persistCache(projectCwd, cache);
const text = renderStopSlopReview();
return {
exitCode: 0,
stdout: payload(text, 'Stop', harness),
emission: { kind: 'stop-llm-slop-review', llmSlopReview: true },
audit: { ...audit, emitted: true, detectorMissing: true, llmSlopReview: true, chars: text.length, durationMs: Date.now() - started },
};
return result({ skipped: 'detector-missing', durationMs: Date.now() - started });
}
const scanOptions = designSystemOptions(config, det, projectCwd);
const freshGroups = [];
const contractEntries = [];
let scanned = 0;
for (const filePath of touched) {
if (scanned >= STOP_MAX_FILES) break;
@@ -2096,22 +1983,6 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
? configuredExt.engine === 'html'
: (ext === '.html' || ext === '.htm');
// Direction-contract audit: HTML and component artifacts, at most once per file
// per session. The flag lives on the same session cache entry the
// finding dedupe uses, so a second Stop fire stays quiet about it.
const contractCapable = useHtmlEngine || CONTRACT_EXTS.has(ext);
if (contractCapable) {
const fileEntry = ensureFile(cache, sessionId, filePath);
if (!fileEntry.contractAudited) {
const contract = extractDirectionContract(content);
if (contract) {
fileEntry.contractAudited = true;
ensureSession(cache, sessionId).updatedAt = Date.now();
contractEntries.push({ filePath, contract });
}
}
}
if (useHtmlEngine && typeof det.detectHtml === 'function') {
try { findings = await det.detectHtml(filePath, scanOptions); } catch { findings = []; }
} else {
@@ -2130,51 +2001,27 @@ export async function runStopHook({ stdinJson, env = {}, cwd = process.cwd(), no
}
audit.scannedFiles = scanned;
if (freshGroups.length === 0 && contractEntries.length === 0 && !needsSlopReview) {
if (freshGroups.length === 0) {
return result({ emitted: false, skipped: 'stop-clean', durationMs: Date.now() - started });
}
if (needsSlopReview) {
session.llmSlopReviewed = true;
session.updatedAt = Date.now();
}
// Fresh findings and first-time contract audits earn the cache write;
// both mark this batch as surfaced so the next Stop fire is silent
// Fresh findings earn the cache write so the next Stop fire is silent
// unless new issues appear.
persistCache(projectCwd, cache);
// Detector findings first, then the contract audit. Both ride the same
// single Stop emission: the audit never adds an extra block round.
const parts = [];
if (freshGroups.length > 0) {
parts.push(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }));
}
if (contractEntries.length > 0) {
parts.push(renderContractAudit(contractEntries, { cwd: projectCwd }));
}
if (needsSlopReview) {
parts.push(renderStopSlopReview());
}
const text = appendDesignSystemNote(parts.join('\n\n'), scanOptions);
const text = appendDesignSystemNote(renderGroupedTemplate(freshGroups, config, { cwd: projectCwd }), scanOptions);
return {
exitCode: 0,
stdout: payload(text, 'Stop', harness),
emission: {
kind: 'stop-deep-pass',
groups: freshGroups,
...(contractEntries.length > 0
? { contractFiles: contractEntries.map((entry) => entry.filePath) }
: {}),
...(needsSlopReview ? { llmSlopReview: true } : {}),
},
audit: {
...audit,
emitted: true,
freshFiles: freshGroups.length,
freshFindings: freshGroups.reduce((sum, group) => sum + group.findings.length, 0),
...(contractEntries.length > 0 ? { contractAudits: contractEntries.length } : {}),
...(needsSlopReview ? { llmSlopReview: true } : {}),
chars: text.length,
durationMs: Date.now() - started,
},
+1 -3
View File
@@ -10,9 +10,7 @@
* `hookSpecificOutput.additionalContext` when findings exist.
* - Stop: runs the FULL detector rule set over every UI file touched this
* session (the deep pass), deduped against what the per-edit pass already
* surfaced, and emits once via the Stop additionalContext channel. Touched
* HTML artifacts opening with a direction-contract comment get a one-time
* contract-audit section appended to the same emission.
* surfaced, and emits once via the Stop additionalContext channel.
*
* Contract: never break a turn. Always exit 0. Clean files emit a small ack
* unless quiet mode is enabled; a clean Stop pass is silent.
@@ -1,30 +0,0 @@
export const LLM_ONLY_SLOP_ITEMS = Object.freeze([
'Monospace used merely to signal “technical” or “developer.”',
'Light or dark mode chosen by category habit rather than the actual use scene.',
'Everything wrapped in cards, or identical icon-heading-text cards repeated as the page structure.',
'Hero-metric scaffolds: one oversized number, a small label, supporting stats, and an accent treatment.',
'Decorative glassmorphism, meaningless sparklines, or generic rounded rectangles with drop shadows.',
'A modal chosen by reflex when the task does not require interruption or protected focus.',
]);
export function renderLlmOnlySlopReview({ manualDetector = false, automaticDetector = false, scriptsPath = null } = {}) {
const lines = [
automaticDetector
? 'AI_SLOP_REVIEW_REQUIRED: The automatic detector covers mechanical rules, but this harness has no reliable late review. Before finishing changed UI, inspect the authored result for detector-blind model reflexes:'
: 'AI_SLOP_REVIEW_REQUIRED: The automatic Impeccable design hook is not available for this session. Before finishing changed UI, inspect the authored result for detector-blind model reflexes:',
...LLM_ONLY_SLOP_ITEMS.map((item) => `- ${item}`),
'Fix reflexes, not intentional choices required by the brief or established visual authority.',
];
if (manualDetector && scriptsPath) {
lines.push(`Then run the mechanical detector once over the changed web UI: \`node ${scriptsPath}/detect.mjs --json <changed targets>\`. Do not run it earlier during concept selection.`);
}
return lines.join('\n');
}
export function renderStopSlopReview() {
return [
'[impeccable@1] Detector-blind AI-slop review. The mechanical pass is complete; now inspect the rendered result for model reflexes it cannot reliably detect:',
...LLM_ONLY_SLOP_ITEMS.map((item) => `- ${item}`),
'Fix reflexes, not intentional choices required by the brief or established visual authority. Do not rerun the detector; this is the authored judgment layer.',
].join('\n');
}
+2 -2
View File
@@ -21,8 +21,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
// All known harness directories
const HARNESS_DIRS = [
'.claude', '.cursor', '.gemini', '.codex', '.agents',
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev',
'.claude', '.cursor', '.gemini', '.codex', '.agents', '.github', '.grok',
'.trae', '.trae-cn', '.pi', '.opencode', '.kiro', '.rovodev', '.vibe', '.qoder',
];
const CODEX_HARNESSES = new Set(['.codex', '.agents']);
@@ -0,0 +1,546 @@
#!/usr/bin/env node
/**
* Visual question server: present a decision to the user as a themed page
* instead of a plain-text prompt, then block until they answer.
*
* The script IS the wait: run it via the shell, it serves the page, prints
* the URL (and tries to open the default browser), and does not exit until
* the user chooses. The answer lands on stdout as one line:
*
* ANSWER: {"optionId":"...","steer":"..."}
*
* Exit codes: 0 answered · 2 timed out or closed without answering.
*
* Payload (JSON file via --payload, or stdin):
* {
* "title": "Choose the visual world",
* "question": "The roll assigned Fillmore Handbill. Keep it, take an alternate, or re-roll.",
* "options": [
* {
* "id": "assigned", // returned verbatim
* "label": "Fillmore Handbill",
* "kicker": "THE ROLL", // optional badge; the assigned option leads
* "lineage": "1966-71 Fillmore ...", // optional
* "body": "why it fits, first viewport, risk ...", // optional, plain text
* "hero": "https://... or /abs/path.webp", // optional image
* "board": "https://... or /abs/path.webp" // optional secondary image
* }, ...
* ],
* "reroll": true, // adds a re-roll action (returns {"optionId":"reroll"})
* "steer": true // adds a free-text steer field returned with any answer
* }
*
* Options render as large cards: hero render first when present (the dealt
* catalog worlds already have cards; grounded directions may present text-only
* or a freshly generated mock). Local image paths are served by this server;
* nothing is uploaded anywhere.
*
* Modes:
* (default) block until answered; ANSWER on stdout; exit 0.
* --schema print the canonical payload example and exit.
* --start for harnesses that cannot leave a shell blocked: daemonize the
* server, print QUESTION URL + QUESTION KEY, exit immediately.
* Never auto-opens a browser: the agent routes the URL to the
* best surface it has (in-app browser first, then the system
* opener); pass --open to force the system browser instead.
* --wait --key K [--poll 60] poll for the answer: exit 0 + ANSWER line,
* exit 3 WAITING (run --wait again), exit 2 server gone,
* exit 4 PAGE CLOSED (the tab went away without an answer;
* re-present, reopen the URL, or fall back).
* --stop --key K kill a daemonized question.
* --update --key K --payload F deliver the next hand after a re-roll: the
* live page swaps to loading cards when the user re-rolls, and
* reloads into this new payload the moment it lands.
*
* node serve-question.mjs --payload question.json [--timeout 900] [--no-open] [--port 0]
*/
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
function arg(name, fallback = null) {
const i = process.argv.indexOf(`--${name}`);
if (i === -1) return fallback;
const v = process.argv[i + 1];
return v && !v.startsWith('--') ? v : fallback;
}
const hasFlag = (name) => process.argv.includes(`--${name}`);
const payloadPath = arg('payload');
const timeoutSec = Number(arg('timeout', '900'));
const portArg = Number(arg('port', '0'));
const QUESTION_DIR = path.join(process.cwd(), '.impeccable', 'questions');
const stateFile = (key) => path.join(QUESTION_DIR, `${key}.state.json`);
const answerFile = (key) => path.join(QUESTION_DIR, `${key}.answer.json`);
if (hasFlag('schema')) {
console.log(JSON.stringify({
title: 'Choose the visual world',
question: 'The roll assigned Fillmore Handbill. Keep it, take an alternate, or re-roll.',
options: [
{ id: 'assigned', label: 'Fillmore Handbill', kicker: 'THE ROLL', lineage: '1966-71 Fillmore psychedelic handbills', body: 'Why it fits, the first viewport, the honest risk.', hero: 'https://impeccable.style/worlds/cards/fillmore-handbill-hero.webp', board: 'https://impeccable.style/worlds/cards/fillmore-handbill.webp' },
{ id: 'challenger-teletext', label: 'Teletext Service', lineage: 'broadcast teletext magazines', body: 'Fused alternate.', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' },
],
reroll: true,
steer: true,
}, null, 2));
console.log('\nOption ids return verbatim in ANSWER; "reroll" is reserved. hero/board accept URLs or local paths.');
process.exit(0);
}
if (hasFlag('wait')) {
const key = arg('key');
if (!key) { console.error('serve-question: --wait needs --key'); process.exit(1); }
const pollSec = Number(arg('poll', '60'));
const deadline = Date.now() + pollSec * 1000;
const answered = () => fs.existsSync(answerFile(key));
const alive = () => {
try { process.kill(JSON.parse(fs.readFileSync(stateFile(key), 'utf8')).pid, 0); return true; }
catch { return false; }
};
let sawClose = false;
while (Date.now() < deadline) {
if (answered()) break;
if (!alive()) {
console.log('serve-question: the question server is gone with no answer');
process.exit(2);
}
try {
const state = JSON.parse(fs.readFileSync(stateFile(key), 'utf8'));
if (state.lastBeat && Date.now() - state.lastBeat > 15000) { sawClose = true; break; }
} catch { /* state mid-write */ }
await new Promise((r) => setTimeout(r, 1000));
}
if (sawClose && !answered()) {
console.log('PAGE CLOSED: the question page went away without an answer; re-present, reopen the URL, or fall back to the structured question tool');
process.exit(4);
}
if (!answered()) { console.log(`WAITING: no answer yet after ${pollSec}s; run --wait --key ${key} again`); process.exit(3); }
const collected = fs.readFileSync(answerFile(key), 'utf8').trim();
console.log(`ANSWER: ${collected}`);
// A re-roll keeps the table open: the server stays alive awaiting --update,
// so only the answer file is consumed. Terminal choices clean up fully.
let isRerollAnswer = false;
try { isRerollAnswer = JSON.parse(collected).optionId === 'reroll'; } catch { /* treat as terminal */ }
try { fs.rmSync(answerFile(key)); } catch { /* already gone */ }
if (!isRerollAnswer) { try { fs.rmSync(stateFile(key)); } catch { /* already gone */ } }
process.exit(0);
}
if (hasFlag('stop')) {
const key = arg('key');
if (!key) { console.error('serve-question: --stop needs --key'); process.exit(1); }
try { process.kill(JSON.parse(fs.readFileSync(stateFile(key), 'utf8')).pid); } catch { /* dead already */ }
try { fs.rmSync(answerFile(key)); } catch {}
try { fs.rmSync(stateFile(key)); } catch {}
console.log('stopped');
process.exit(0);
}
if (hasFlag('update')) {
const key = arg('key');
if (!key || !payloadPath) { console.error('serve-question: --update needs --key and --payload'); process.exit(1); }
JSON.parse(fs.readFileSync(payloadPath, 'utf8'));
try { process.kill(JSON.parse(fs.readFileSync(stateFile(key), 'utf8')).pid, 0); }
catch { console.error('serve-question: no live question server for that key'); process.exit(2); }
fs.copyFileSync(payloadPath, path.join(QUESTION_DIR, `${key}.next.json`));
console.log('next round delivered; the page reloads itself');
process.exit(0);
}
if (hasFlag('start')) {
if (!payloadPath) { console.error('serve-question: --start needs --payload <file>'); process.exit(1); }
JSON.parse(fs.readFileSync(payloadPath, 'utf8'));
fs.mkdirSync(QUESTION_DIR, { recursive: true });
const key = arg('key') || Math.random().toString(16).slice(2, 10);
// In start mode the agent is alive and owns browser routing; the server
// only opens the system browser itself when --open forces it.
const child = spawn(process.execPath, [
fileURLToPath(import.meta.url), '--payload', payloadPath, '--detached-serve', '--key', key,
'--timeout', String(timeoutSec), ...(hasFlag('open') ? [] : ['--no-open']),
], { detached: true, stdio: 'ignore' });
child.unref();
const deadline = Date.now() + 8000;
while (Date.now() < deadline && !fs.existsSync(stateFile(key))) await new Promise((r) => setTimeout(r, 100));
if (!fs.existsSync(stateFile(key))) { console.error('serve-question: server failed to start'); process.exit(1); }
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(`Then collect the answer with: node ${fileURLToPath(import.meta.url)} --wait --key ${key}`);
process.exit(0);
}
let raw;
if (payloadPath) raw = fs.readFileSync(payloadPath, 'utf8');
else raw = fs.readFileSync(0, 'utf8');
// Round state is mutable: a re-roll keeps this server alive and --update
// swaps in the next hand, so payload, options, and the local-image table
// rebuild per round.
let payload;
let options;
let localImages = [];
function loadRound(json) {
const parsed = JSON.parse(json);
if (!parsed || !Array.isArray(parsed.options) || parsed.options.length === 0) {
throw new Error('payload needs an options array');
}
localImages = [];
const imageSrc = (value) => {
if (!value) return null;
if (/^https?:\/\//.test(value)) return value;
const abs = path.resolve(value);
if (!fs.existsSync(abs)) return null;
localImages.push(abs);
return `/img/${localImages.length - 1}`;
};
payload = parsed;
options = parsed.options.map((option) => ({
...option,
heroSrc: imageSrc(option.hero),
boardSrc: imageSrc(option.board),
}));
}
try { loadRound(raw); } catch (error) { console.error(`serve-question: ${error.message}`); process.exit(1); }
const detachedKey = hasFlag('detached-serve') ? arg('key') : null;
const nextFile = () => detachedKey ? path.join(QUESTION_DIR, `${detachedKey}.next.json`) : null;
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
function page() {
const flipChip = (label) => `<button type="button" class="chip flip" aria-label="Flip the card"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4a8 8 0 1 1-8 8" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/><path d="M4 5.5V12h6.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg><span>${label}</span></button>`;
const expandChip = `<button type="button" class="chip expand" aria-label="Expand the image"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9V4h5M20 15v5h-5M20 9V4h-5M4 15v5h5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg></button>`;
const cards = options.map((option, index) => `
<article class="card" style="--fan:${index === 0 ? '0deg' : (index % 2 ? '1.4deg' : '-1.2deg')};--deal:${index * 90}ms" data-id="${esc(option.id)}">
<div class="card-inner">
<div class="face front${index === 0 ? ' lead' : ''}">
${option.kicker ? `<span class="kicker">${esc(option.kicker)}</span>` : ''}
<div class="media">
${option.heroSrc ? `<img src="${esc(option.heroSrc)}" alt="">` : '<div class="hero-blank"></div>'}
<div class="chips">${option.heroSrc ? expandChip : ''}${option.boardSrc ? flipChip('Board') : ''}</div>
</div>
<div class="body">
${option.lineage ? `<p class="tier">${esc(option.lineage)}</p>` : ''}
<h2>${esc(option.label)}</h2>
${option.body ? `<p class="detail">${esc(option.body)}</p>` : ''}
<button class="choose" data-id="${esc(option.id)}">Build this</button>
</div>
</div>
${option.boardSrc ? `<div class="face back${index === 0 ? ' lead' : ''}">
<div class="media back-media">
<img src="${esc(option.boardSrc)}" alt="">
<div class="chips">${expandChip}${flipChip('Hero')}</div>
</div>
<div class="body back-bar">
<p class="tier">Design-system board &middot; ${esc(option.label)}</p>
<button class="choose" data-id="${esc(option.id)}">Build this</button>
</div>
</div>` : ''}
</div>
</article>`).join('\n');
return `<!doctype html>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(payload.title || 'impeccable · decision')}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Albert+Sans:wght@400;500;600&family=Alumni+Sans:wght@100;400&display=swap" rel="stylesheet">
<style>
/* Neo kinpaku tokens, mirrored from impeccable.style kinpaku-tokens.css */
:root {
color-scheme: dark;
--ks-kinpaku: oklch(84% 0.19 80.46);
--ks-kinpaku-pale: oklch(86% 0.07 84);
--ks-kinpaku-rich: oklch(77% 0.13 82);
--ks-kinpaku-deep: oklch(61% 0.085 78);
--ks-dark-ink: oklch(14% 0.018 95);
--ks-patina: oklch(70% 0.12 188);
--ks-lacquer: oklch(7% 0.006 95);
--ks-lacquer-raised: oklch(11% 0.006 95);
--ks-graphite: oklch(15% 0.008 95);
--ks-graphite-2: oklch(19% 0.008 95);
--ks-champagne: oklch(91% 0 0);
--ks-text: oklch(88% 0 0);
--ks-text-muted: oklch(72% 0 0);
--ks-text-faint: oklch(62% 0 0);
--ks-rule: oklch(78% 0 0 / 0.16);
--ks-font-display: "Alumni Sans", "Albert Sans", Arial, sans-serif;
--ks-font: "Albert Sans", "Avenir Next", "Helvetica Neue", Arial, system-ui, sans-serif;
--ks-mono: "SFMono-Regular", "Roboto Mono", "JetBrains Mono", Consolas, monospace;
}
* { box-sizing: border-box; margin: 0; }
body { background: var(--ks-lacquer); color: var(--ks-text); font: 15px/1.55 var(--ks-font); padding: 1.8rem clamp(1rem, 5vw, 4rem) 2rem; min-height: 100dvh; display: flex; flex-direction: column; }
#ambient { position: fixed; inset: -40px; z-index: 0; background-size: cover; background-position: center; filter: blur(34px) saturate(1.05); opacity: 0; transition: opacity .55s ease, background-image .2s; pointer-events: none; }
#scrim { position: fixed; inset: 0; z-index: 0; background: linear-gradient(180deg, oklch(7% 0.006 95 / 0.62), oklch(7% 0.006 95 / 0.78)); pointer-events: none; }
header, main, footer { position: relative; z-index: 1; }
#lightbox { position: fixed; inset: 0; z-index: 50; display: flex; align-items: center; justify-content: center; background: oklch(4% 0.004 95 / 0.93); cursor: zoom-out; opacity: 0; transition: opacity .25s ease; }
#lightbox[hidden] { display: none; }
#lightbox.open { opacity: 1; }
#lightbox img { max-width: 94vw; max-height: 94vh; border: 1px solid var(--ks-rule); border-radius: 8px; box-shadow: 0 30px 80px oklch(0% 0 0 / 0.6); }
header { width: 100%; max-width: 90rem; margin: 0 auto; }
.brand { display: flex; align-items: center; gap: .55rem; color: var(--ks-kinpaku); }
.brand svg { width: 22px; height: 22px; }
.wordmark { font-family: var(--ks-font-display); font-weight: 400; font-size: 1.125rem; letter-spacing: 0.15em; text-transform: uppercase; line-height: 1; color: var(--ks-kinpaku); }
.headline { display: flex; align-items: center; gap: .9rem; }
.headline-die { flex: none; width: 34px; height: 34px; color: var(--ks-kinpaku); }
h1 { font-family: var(--ks-font-display); font-weight: 100; font-size: clamp(2.6rem, 5vw, 4.2rem); letter-spacing: -0.01em; line-height: 1.02; color: var(--ks-champagne); }
.question { color: var(--ks-text-muted); margin-top: .7rem; max-width: 52rem; }
main { flex: 1; display: flex; align-items: center; width: 100%; max-width: 90rem; margin: 0 auto; }
.stage { width: 100%; display: flex; flex-direction: column; gap: 1.5rem; }
.grid { display: grid; gap: 1.6rem; grid-template-columns: repeat(auto-fit, minmax(min(23rem, 100%), 1fr)); width: 100%; }
.card { position: relative; perspective: 1400px; transform: rotate(var(--fan, 0deg)); transition: transform .25s cubic-bezier(.16, 1, .3, 1); }
.card:hover { transform: rotate(0deg) translateY(-4px); }
.card-inner { position: relative; height: 100%; transform-style: preserve-3d; transition: transform .7s cubic-bezier(.16, 1, .3, 1); }
.card.flipped .card-inner { transform: rotateY(180deg); }
.face { background: var(--ks-lacquer-raised); border: 1px solid var(--ks-rule); border-radius: 10px; box-shadow: 0 18px 40px oklch(0% 0 0 / 0.35); overflow: hidden; display: flex; flex-direction: column; backface-visibility: hidden; -webkit-backface-visibility: hidden; }
.face.front { position: relative; height: 100%; }
.face.back { position: absolute; inset: 0; transform: rotateY(180deg); }
.face.lead { border-color: var(--ks-kinpaku); box-shadow: 0 0 0 1px var(--ks-kinpaku), 0 18px 40px oklch(0% 0 0 / 0.45); }
.card:hover .face { border-color: var(--ks-kinpaku-deep); }
.card:hover .face.lead { border-color: var(--ks-kinpaku); }
@media (prefers-reduced-motion: reduce) { .card-inner { transition: none; } }
.kicker { position: absolute; z-index: 2; top: 12px; left: 12px; padding: 4px 10px; background: var(--ks-kinpaku); color: var(--ks-dark-ink); font-family: var(--ks-mono); font-size: .625rem; letter-spacing: .24em; text-transform: uppercase; border-radius: 4px; }
.media { position: relative; width: 100%; aspect-ratio: 16/9; flex: none; }
.media img { width: 100%; height: 100%; object-fit: cover; display: block; background: linear-gradient(100deg, var(--ks-graphite) 40%, var(--ks-graphite-2) 50%, var(--ks-graphite) 60%); }
.face.back { background: var(--ks-lacquer-deep); }
.back-bar { margin-top: auto; background: var(--ks-lacquer-raised); }
.hero-blank { width: 100%; height: 100%; background: linear-gradient(100deg, var(--ks-graphite) 40%, var(--ks-graphite-2) 50%, var(--ks-graphite) 60%); }
.back-bar { flex: none; flex-direction: row; align-items: center; justify-content: space-between; gap: .8rem; }
.chips { position: absolute; z-index: 1; right: 10px; bottom: 10px; display: flex; gap: 6px; }
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 9px; font-family: var(--ks-mono); font-size: .625rem; letter-spacing: .18em; text-transform: uppercase; color: var(--ks-text); background: oklch(7% 0.006 95 / 0.72); border: 1px solid var(--ks-rule); border-radius: 5px; cursor: pointer; backdrop-filter: blur(4px); transition: color .2s, border-color .2s; }
.chip:hover { color: var(--ks-kinpaku); border-color: var(--ks-kinpaku-deep); }
.chip svg { width: 12px; height: 12px; }
.body { padding: .95rem 1.1rem 1.2rem; display: flex; flex-direction: column; gap: .5rem; flex: 1; }
.tier { font-family: var(--ks-mono); font-size: .625rem; letter-spacing: .24em; text-transform: uppercase; color: var(--ks-text-faint); }
h2 { font-family: var(--ks-font); font-size: 1.125rem; font-weight: 500; line-height: 1.35; color: var(--ks-champagne); }
.detail { color: var(--ks-text-muted); font-size: .88rem; white-space: pre-wrap; }
button.choose { margin-top: auto; align-self: start; background: var(--ks-kinpaku); color: var(--ks-dark-ink); border: 0; font-family: var(--ks-font); font-size: 1rem; font-weight: 500; line-height: 1.35; padding: 10px 38px; border-radius: 6px; cursor: pointer; transition: background .15s; }
button.choose:hover { background: var(--ks-kinpaku-pale); }
footer { width: 100%; max-width: 90rem; margin: 1.6rem auto 0; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; }
#steer { flex: 1; min-width: 16rem; background: var(--ks-lacquer-raised); color: var(--ks-text); border: 1px solid var(--ks-rule); border-radius: 7px; padding: .6rem .85rem; font: inherit; }
#steer:focus { outline: none; border-color: var(--ks-patina); }
#reroll { display: inline-flex; align-items: center; align-self: stretch; gap: 8px; padding: 0 16px; font-family: var(--ks-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--ks-kinpaku); background: transparent; border: 1px solid var(--ks-rule); border-radius: 6px; cursor: pointer; transition: border-color .2s ease, color .2s ease; }
#reroll:hover { color: var(--ks-kinpaku-pale); border-color: var(--ks-kinpaku-deep); }
#reroll svg { width: 15px; height: 15px; }
.card.skeleton .media { background: var(--ks-graphite); }
.shimmer { width: 100%; height: 100%; background: linear-gradient(100deg, var(--ks-graphite) 35%, var(--ks-graphite-2) 50%, var(--ks-graphite) 65%); background-size: 220% 100%; animation: shimmer 1.4s linear infinite; }
.card.skeleton .line { height: 11px; border-radius: 4px; background: linear-gradient(100deg, var(--ks-graphite) 35%, var(--ks-graphite-2) 50%, var(--ks-graphite) 65%); background-size: 220% 100%; animation: shimmer 1.4s linear infinite; }
.card.skeleton .line.tier { height: 8px; }
.card.skeleton .line.title { height: 17px; border-radius: 5px; }
.card.skeleton .line.button { height: 38px; width: 128px; border-radius: 6px; margin-top: auto; }
.card.skeleton .w40 { width: 40%; } .card.skeleton .w70 { width: 70%; } .card.skeleton .w90 { width: 90%; } .card.skeleton .w80 { width: 80%; } .card.skeleton .w60 { width: 60%; }
.card.skeleton .body { flex: 1; }
@keyframes shimmer { from { background-position: 120% 0; } to { background-position: -80% 0; } }
@media (prefers-reduced-motion: reduce) { .shimmer, .card.skeleton .line { animation: none; } }
.done { display: flex; flex-direction: column; align-items: center; gap: 1rem; padding: 7rem 1rem; font-family: var(--ks-font-display); font-size: 1.4rem; color: var(--ks-champagne); text-align: center; }
</style>
<div id="ambient" aria-hidden="true"></div>
<div id="scrim" aria-hidden="true"></div>
<div id="lightbox" hidden><img alt=""></div>
<header>
<div class="brand">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/><path d="M16.5 2.5 L19 2.5 Q21.5 2.5 21.5 5 L21.5 19 Q21.5 21.5 19 21.5 L8.5 21.5 Z"/></svg>
<span class="wordmark">Impeccable</span>
</div>
</header>
<main>
<div class="stage">
<div class="headline">
<svg class="headline-die" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="4" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="8.4" cy="8.4" r="1.5" fill="currentColor"/><circle cx="15.6" cy="8.4" r="1.5" fill="currentColor"/><circle cx="8.4" cy="15.6" r="1.5" fill="currentColor"/><circle cx="15.6" cy="15.6" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>
<h1>${esc(payload.title || 'Choose a direction')}</h1>
</div>
${payload.question ? `<p class="question">${esc(payload.question)}</p>` : ''}
<div class="grid">${cards}</div>
</div>
</main>
<footer>
${payload.steer ? '<input id="steer" placeholder="Optional steer: what should be different or kept?">' : ''}
${payload.reroll ? '<button id="reroll"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="4" fill="none" stroke="currentColor" stroke-width="1.6"/><circle cx="8.4" cy="8.4" r="1.5" fill="currentColor"/><circle cx="15.6" cy="8.4" r="1.5" fill="currentColor"/><circle cx="8.4" cy="15.6" r="1.5" fill="currentColor"/><circle cx="15.6" cy="15.6" r="1.5" fill="currentColor"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg><span>Re-roll</span></button>' : ''}
</footer>
<script>
const steer = () => document.getElementById('steer')?.value || '';
const beat = () => { try { navigator.sendBeacon('/heartbeat'); } catch { fetch('/heartbeat', { method: 'POST' }); } };
beat();
setInterval(beat, 5000);
async function answer(optionId) {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId, steer: steer() }) });
document.body.innerHTML = '<div class="done"><svg viewBox="0 0 24 24" width="38" height="38" fill="oklch(84% 0.19 80.46)" aria-hidden="true"><path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"/><path d="M16.5 2.5 L19 2.5 Q21.5 2.5 21.5 5 L21.5 19 Q21.5 21.5 19 21.5 L8.5 21.5 Z"/></svg>Choice recorded. The agent is resuming; you can close this tab.</div>';
}
document.querySelectorAll('button.choose').forEach(b => b.addEventListener('click', () => answer(b.dataset.id)));
document.querySelectorAll('.flip').forEach(b => b.addEventListener('click', (e) => {
e.stopPropagation();
b.closest('.card').classList.toggle('flipped');
}));
// Deal from the stack: cards begin piled at the grid's center, blurred,
// then travel to their seats with a stagger.
const cards = [...document.querySelectorAll('.card')];
if (!matchMedia('(prefers-reduced-motion: reduce)').matches && cards.length) {
const grid = document.querySelector('.grid').getBoundingClientRect();
const cx = grid.left + grid.width / 2, cy = grid.top + grid.height / 2;
cards.forEach((card, i) => {
const r = card.getBoundingClientRect();
const dx = cx - (r.left + r.width / 2), dy = cy - (r.top + r.height / 2);
card.style.transition = 'none';
card.style.transform = 'translate(' + dx + 'px,' + (dy + 14) + 'px) rotate(' + (i % 2 ? 5 : -4) + 'deg) scale(.9)';
card.style.opacity = '0';
card.style.filter = 'blur(10px)';
card.style.zIndex = String(cards.length - i);
});
requestAnimationFrame(() => requestAnimationFrame(() => {
cards.forEach((card, i) => {
const delay = i * 110;
card.style.transition = 'transform .7s cubic-bezier(.16,1,.3,1) ' + delay + 'ms, opacity .45s ease ' + delay + 'ms, filter .55s ease ' + delay + 'ms';
card.style.transform = ''; card.style.opacity = '1'; card.style.filter = '';
card.addEventListener('transitionend', function done(e) {
if (e.propertyName !== 'transform') return;
card.style.transition = ''; card.style.opacity = ''; card.style.zIndex = '';
card.removeEventListener('transitionend', done);
});
});
}));
}
// Ambient: the hovered card's hero bleeds into the page ground under a scrim.
const ambient = document.getElementById('ambient');
document.querySelectorAll('.card').forEach(card => {
const hero = card.querySelector('.face.front .media img');
if (!hero) return;
card.addEventListener('mouseenter', () => { ambient.style.backgroundImage = 'url("' + hero.getAttribute('src') + '")'; ambient.style.opacity = '1'; });
card.addEventListener('mouseleave', () => { ambient.style.opacity = '0'; });
});
// Expand: lightbox for whichever face is showing.
const lightbox = document.getElementById('lightbox');
const lightboxImg = lightbox.querySelector('img');
document.querySelectorAll('.expand').forEach(b => b.addEventListener('click', (e) => {
e.stopPropagation();
const card = b.closest('.card');
const face = card.classList.contains('flipped') ? '.face.back' : '.face.front';
const img = card.querySelector(face + ' .media img');
if (!img) return;
lightboxImg.src = img.getAttribute('src');
lightbox.hidden = false;
requestAnimationFrame(() => lightbox.classList.add('open'));
}));
const closeLightbox = () => { lightbox.classList.remove('open'); setTimeout(() => { lightbox.hidden = true; }, 250); };
lightbox.addEventListener('click', closeLightbox);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !lightbox.hidden) closeLightbox(); });
document.getElementById('reroll')?.addEventListener('click', async () => {
await fetch('/answer', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ optionId: 'reroll', steer: steer() }) });
const grid = document.querySelector('.grid');
const cardsNow = [...grid.querySelectorAll('.card')];
const g = grid.getBoundingClientRect();
const cx = g.left + g.width / 2, cy = g.top + g.height / 2;
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) {
cardsNow.forEach((card, i) => {
const r = card.getBoundingClientRect();
card.style.transition = 'transform .5s cubic-bezier(.5,0,.75,0) ' + (i * 60) + 'ms, opacity .4s ease ' + (i * 60 + 120) + 'ms, filter .45s ease ' + (i * 60) + 'ms';
card.style.transform = 'translate(' + (cx - (r.left + r.width / 2)) + 'px,' + (cy - (r.top + r.height / 2) + 14) + 'px) rotate(' + (i % 2 ? 6 : -5) + 'deg) scale(.9)';
card.style.opacity = '0';
card.style.filter = 'blur(8px)';
});
await new Promise(r => setTimeout(r, 700));
}
const cardHeight = cardsNow[0] ? cardsNow[0].getBoundingClientRect().height : 0;
grid.innerHTML = cardsNow.map(() => '<article class="card skeleton"' + (cardHeight ? ' style="height:' + cardHeight + 'px"' : '') + '><div class="card-inner"><div class="face front"><div class="media"><div class="shimmer"></div></div><div class="body"><div class="line tier w40"></div><div class="line title w70"></div><div class="line w90"></div><div class="line w80"></div><div class="line w60"></div><div class="line button"></div></div></div></div></article>').join('');
document.getElementById('reroll')?.setAttribute('disabled', '');
const poll = setInterval(async () => {
try {
const status = await (await fetch('/next-status')).json();
if (status.ready) { clearInterval(poll); location.reload(); }
} catch { /* server briefly busy */ }
}, 1200);
});
</script>`;
}
const server = http.createServer((req, res) => {
if (req.method === 'GET' && req.url === '/') {
const pending = nextFile();
if (pending && fs.existsSync(pending)) {
try { loadRound(fs.readFileSync(pending, 'utf8')); fs.rmSync(pending); } catch { /* keep current round */ }
}
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(page());
return;
}
if (req.method === 'POST' && req.url === '/heartbeat') {
res.writeHead(204); res.end();
if (detachedKey) {
const now = Date.now();
if (!server.lastBeatWrite || now - server.lastBeatWrite > 4000) {
server.lastBeatWrite = now;
try {
const state = JSON.parse(fs.readFileSync(stateFile(detachedKey), 'utf8'));
state.lastBeat = now;
fs.writeFileSync(stateFile(detachedKey), JSON.stringify(state));
} catch { /* state file recreated on next beat */ }
}
}
return;
}
if (req.method === 'GET' && req.url === '/next-status') {
const pending = nextFile();
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ready: Boolean(pending && fs.existsSync(pending)) }));
return;
}
const imageMatch = req.method === 'GET' && req.url?.match(/^\/img\/(\d+)$/);
if (imageMatch) {
const abs = localImages[Number(imageMatch[1])];
if (!abs) { res.writeHead(404); res.end(); return; }
const type = abs.endsWith('.webp') ? 'image/webp' : abs.endsWith('.png') ? 'image/png' : 'image/jpeg';
res.writeHead(200, { 'content-type': type });
fs.createReadStream(abs).pipe(res);
return;
}
if (req.method === 'POST' && req.url === '/answer') {
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end('{"ok":true}');
let parsed = {};
try { parsed = JSON.parse(body); } catch { /* empty steer */ }
const answer = JSON.stringify({ optionId: parsed.optionId ?? null, steer: parsed.steer ?? '' });
const isReroll = parsed.optionId === 'reroll';
if (detachedKey) {
fs.mkdirSync(QUESTION_DIR, { recursive: true });
fs.writeFileSync(answerFile(detachedKey), answer + '\n');
} else {
console.log(`ANSWER: ${answer}`);
}
// A re-roll in detached mode keeps the table open: the client shows a
// loading hand and reloads when --update delivers the next round.
if (!(isReroll && detachedKey)) setTimeout(() => process.exit(0), 150);
});
return;
}
res.writeHead(404); res.end();
});
server.listen(portArg, '127.0.0.1', () => {
const { port } = server.address();
const url = `http://127.0.0.1:${port}/`;
if (hasFlag('detached-serve')) {
fs.mkdirSync(QUESTION_DIR, { recursive: true });
fs.writeFileSync(stateFile(arg('key')), JSON.stringify({ pid: process.pid, port, url }));
} else {
console.log(`QUESTION URL: ${url}`);
console.log('Waiting for the user to choose in the browser (Ctrl-C aborts)...');
}
if (!hasFlag('no-open')) {
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
try { spawn(opener, [url], { stdio: 'ignore', detached: true }).unref(); } catch { /* URL printed anyway */ }
}
if (timeoutSec > 0) {
setTimeout(() => {
console.log('serve-question: timed out with no answer');
process.exit(2);
}, timeoutSec * 1000).unref?.();
}
});