From bba73a22839d23db722ed41913fdeb580698aa10 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Date: Fri, 21 Aug 2026 20:55:08 +0500 Subject: [PATCH] update --- .claude-plugin/marketplace.json | 4 +- .claude-plugin/plugin.json | 2 +- .gitignore | 4 + CLAUDE.md | 2 +- README.md | 7 +- README.npm.md | 2 +- docs/DESIGN-CONTEXT-ARCHITECTURE.md | 173 +++++++++ picker/data/surfaces.js | 6 +- picker/pages/index.astro | 22 ++ picker/scripts/boot.js | 35 ++ picker/scripts/design-context.js | 311 ++++++++++++++-- picker/scripts/hydrate.js | 189 ++++++++++ picker/scripts/palette-picker.js | 87 ++++- picker/styles/design-context.css | 103 +++++- picker/styles/picker.css | 25 ++ scripts/lib/skill-categories.js | 1 + scripts/lib/utils.js | 3 +- scripts/test-suites.mjs | 2 +- skill/SKILL.src.md | 1 + skill/reference/design-context.md | 93 +++++ skill/reference/document.md | 9 +- skill/reference/visual-cues.md | 25 +- skill/scripts/command-metadata.json | 4 + skill/scripts/design-context-export.mjs | 67 ++++ skill/scripts/design-context-import.mjs | 83 +++++ skill/scripts/design-context/bindings.mjs | 82 +++++ skill/scripts/design-context/portability.mjs | 339 ++++++++++++++++++ .../scripts/design-context/session-routes.mjs | 236 ++++++++++++ skill/scripts/design-context/store.mjs | 241 +++++++++++++ skill/scripts/picker-doc-poll.mjs | 56 ++- skill/scripts/picker-doc-session.mjs | 150 ++++---- skill/scripts/picker-server.mjs | 237 +++++++++++- skill/scripts/pin.mjs | 2 +- tests/picker-server.test.mjs | 139 ++++++- 34 files changed, 2571 insertions(+), 171 deletions(-) create mode 100644 docs/DESIGN-CONTEXT-ARCHITECTURE.md create mode 100644 picker/scripts/boot.js create mode 100644 picker/scripts/hydrate.js create mode 100644 skill/reference/design-context.md create mode 100644 skill/scripts/design-context-export.mjs create mode 100644 skill/scripts/design-context-import.mjs create mode 100644 skill/scripts/design-context/bindings.mjs create mode 100644 skill/scripts/design-context/portability.mjs create mode 100644 skill/scripts/design-context/session-routes.mjs create mode 100644 skill/scripts/design-context/store.mjs diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 5b0c81ea3..890c15388 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -2,7 +2,7 @@ "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "impeccable", "metadata": { - "description": "Design fluency for AI harnesses. 1 skill, 23 commands, and curated anti-patterns for impeccable frontend design." + "description": "Design fluency for AI harnesses. 1 skill, 24 commands, and curated anti-patterns for impeccable frontend design." }, "owner": { "name": "Paul Bakaus", @@ -11,7 +11,7 @@ "plugins": [ { "name": "impeccable", - "description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.", + "description": "Design fluency for frontend development. 1 skill with 24 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.", "version": "4.1.2", "author": { "name": "Paul Bakaus", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 7c323124e..ebdfaf0ec 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "impeccable", - "description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.", + "description": "Design fluency for frontend development. 1 skill with 24 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.", "version": "4.1.2", "author": { "name": "Paul Bakaus", diff --git a/.gitignore b/.gitignore index f187251a5..3b569a28b 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,10 @@ Thumbs.db .impeccable/.env # Generated visual-cue images + cues.json (document seed Step 4). .impeccable/visual-cues/ +# The design-context store itself is the user's own record and is trackable; +# only its session state and its generated exports stay local. +.impeccable/design-context/runtime/ +.impeccable/design-context/exports/ **/.impeccable/hook.pending.json .impeccable/provider-smoke/ src/__impeccable_provider_smoke_*.html diff --git a/CLAUDE.md b/CLAUDE.md index 5dc28eedc..821653220 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ ## Architecture (v3.0+) -There is **one** user-invocable skill, `impeccable`, with **23 commands** underneath it. Users type `/impeccable polish`, `/impeccable audit`, etc. The skill is defined in `skill/`: +There is **one** user-invocable skill, `impeccable`, with **24 commands** underneath it. Users type `/impeccable polish`, `/impeccable audit`, etc. The skill is defined in `skill/`: - `SKILL.src.md` — frontmatter (with the auto-trigger-optimized description and the `allowed-tools` list), shared design laws, and the **Commands** router table. Provider `SKILL.md` files are generated from this source. - `reference/` — one `.md` per command (`audit.md`, `polish.md`, `critique.md`, etc.), the shared playbooks the router loads outside the command table (`new-work.md`, `craft-floor.md`, `operate.md`, `routing.md`), and the native platform references (`ios.md`, `android.md`). When a sub-command is matched, the router loads its reference file. diff --git a/README.md b/README.md index b957b7e5b..aed719b5e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Impeccable -Design guidance for AI coding agents. 1 skill, 23 commands, live browser iteration, and 61 deterministic detector rules for AI-generated frontend design. +Design guidance for AI coding agents. 1 skill, 24 commands, live browser iteration, and 61 deterministic detector rules for AI-generated frontend design. > **Quick start:** From your project root, run `npx impeccable install`, then run `/impeccable init` inside your AI coding tool. Full docs: [impeccable.style](https://impeccable.style). @@ -12,7 +12,7 @@ Every model trained on the same SaaS templates. Skip the guidance and you get th Impeccable adds: - **One setup flow.** `/impeccable init` records durable product truth in `PRODUCT.md`, so later commands know the audience, purpose, operating context, constraints, voice, and evidence without confusing those facts with surface-level visual direction. -- **23 commands.** A shared design vocabulary with your AI: `polish`, `audit`, `critique`, `distill`, `animate`, `bolder`, `quieter`, and more. +- **24 commands.** A shared design vocabulary with your AI: `polish`, `audit`, `critique`, `distill`, `animate`, `bolder`, `quieter`, and more. - **61 deterministic detector rules** plus LLM-only critique checks. The CLI and browser extension run the deterministic rules with no LLM and no API key. ## What's Included @@ -33,7 +33,7 @@ Start every new project with: `init` inspects the project, asks only for material gaps in durable product truth, and writes `PRODUCT.md`. Visitor mode and visual direction are chosen later for each surface; incumbent or newly built visual systems are recorded separately in `DESIGN.md`. -### 23 Commands +### 24 Commands All commands are accessed through `/impeccable`: @@ -43,6 +43,7 @@ All commands are accessed through `/impeccable`: | `/impeccable init` | One-time setup: gather durable product context, write PRODUCT.md, configure live mode when applicable, recommend next steps | | `/impeccable document` | Generate root DESIGN.md from existing project code | | `/impeccable extract` | Pull reusable components and tokens into the design system | +| `/impeccable design-context` | Reopen, revise, export, or import the design interview and its design context document | | `/impeccable shape` | Plan UX/UI before writing code | | `/impeccable critique` | UX design review: hierarchy, clarity, emotional resonance | | `/impeccable audit` | Run technical quality checks (a11y, performance, responsive) | diff --git a/README.npm.md b/README.npm.md index 1e0284238..24c7f8a1e 100644 --- a/README.npm.md +++ b/README.npm.md @@ -81,7 +81,7 @@ impeccable detect [options] [file-or-dir-or-url...] ## Part of Impeccable -This CLI is part of [Impeccable](https://impeccable.style), a cross-provider design skill pack for AI-powered development tools. The full suite includes 23 commands for Claude, Cursor, GitHub Copilot, Gemini, Codex, Hermes Agent, Veto, and more. +This CLI is part of [Impeccable](https://impeccable.style), a cross-provider design skill pack for AI-powered development tools. The full suite includes 24 commands for Claude, Cursor, GitHub Copilot, Gemini, Codex, Hermes Agent, Veto, and more. ## License diff --git a/docs/DESIGN-CONTEXT-ARCHITECTURE.md b/docs/DESIGN-CONTEXT-ARCHITECTURE.md new file mode 100644 index 000000000..3e735615b --- /dev/null +++ b/docs/DESIGN-CONTEXT-ARCHITECTURE.md @@ -0,0 +1,173 @@ +# Design context architecture + +How the design interview, the design context document, and everything the user +gives us during the seed process are organized, and why. + +## The problem this layout solves + +The design context is one product concept. Before this layout it was scattered +across two directories with no owner: + +- `.impeccable/design-interview/` held the questionnaire submission, uploaded + fonts, staged brand assets, and two runtime files. +- `.impeccable/visual-cues/` held the cue generation workspace, and also, buried + inside `cues.json`, the `context` object carrying the entire chat half of the + interview plus the `modes` set. Neither is a generation artifact. +- The design context document was not a file at all. It was a client-side render + that existed only after a submit, died with its session, and could not be + reopened. +- Three scripts hardcoded the storage directory independently, with no shared + constant. The agent-side poll CLI treats a missing session file as a clean + exit, so a half-finished rename would fail silently and no test would catch it. + +Export, import, resume, rebuild, and edit each had to reassemble state from those +places with implicit coupling. One canonical store with one code owner replaces +that. + +## File layout, in the user's project + +``` +.impeccable/design-context/ the store: the one exportable unit + context.json { schemaVersion, modes, context } the chat half of the run + answers.json the questionnaire submission, flat form-field shape + assets/ brand files the user supplied + fonts/ font faces the user uploaded + cue.png the chosen hero, copied at submit + runtime/ session.json, journal.jsonl, draft.json (gitignored) + exports/ design-context.md, design-context.bundle.json (gitignored) + +.impeccable/visual-cues/ the generation workspace, unchanged + brief.md, .png x6, cues.json (cues + palette only), fonts.json +``` + +The reasoning, in the order the decisions were made: + +1. **Durable versus regenerable is the axis.** It matches the policy this repo's + own `.gitignore` states: generated sidecars and config may be tracked, but + runtime recovery state and local assets stay local. Everything in the store + above `runtime/` is a user decision or a user-supplied file, so it is + trackable and exportable. The workspace is regenerable process output and + stays ignored. +2. **`context.json` moves the chat half out of `cues.json`.** The context object + was never a cue artifact. It rode there because that was the only file the + picker served. With its own home it survives cue regeneration, imports without + dragging six hero images along, and can be edited by the save flow without + touching the generation workspace. +3. **`cue.png` makes the document self-sufficient.** The Color article renders + the chosen cue only when the cue manifest still lists it, which means the + document breaks once the workspace is cleaned or in a project that received + the context by import. Copying the one chosen hero into the store at submit + time costs a few hundred kilobytes and buys rendering independence. The five + unpicked heroes stay in the workspace as art direction leftovers. +4. **`runtime/` isolates ephemera.** Session discovery, the journal, and the + mid-questionnaire draft never belong in an export or a commit. One gitignore + line covers all of it permanently. +5. **PRODUCT.md and DESIGN.md stay at the project root.** They are the canonical + documents the whole toolchain reads. `context.json` carries distilled copies + with provenance, never replacements. Edits that touch product truth reconcile + back into PRODUCT.md through the agent rather than the store growing a second + product record. + +## Code layout + +The shape mirrors `skill/scripts/live/`, which solves the same class of problem. + +``` +skill/scripts/design-context/ + store.mjs the only code that knows store paths or writes store files + bindings.mjs the editable-field registry for the document + session-routes.mjs HTTP handlers for the document edit session + +skill/scripts/ + picker-server.mjs static serving, boot contract, submit, autosave, spawn + picker-doc-session.mjs the session shell: http server, timers, token + picker-doc-poll.mjs the agent's poll CLI + design-context-export.mjs / design-context-import.mjs + +picker/scripts/ + boot.js one memoized fetch of the boot contract + hydrate.js restoring a previous run into the questionnaire + palette-picker.js the questionnaire; owns running hydration + design-context.js the document; document mode, pending ledger, save bar +``` + +Three ownership rules keep this correct as it grows. + +**store.mjs is the single writer of store files.** Server, session, and import +all go through it. Every write is atomic: write a temporary file beside the +target, then rename over it, so a reader never sees a torn file. + +**Reads come off disk per request.** Files are the truth. Process memory is a +cache at best. This is live mode's stat-keyed cache rule in its simplest form, +which is all a small store needs. + +**During a live session, only the session process writes store files.** The agent +edits its own documents, DESIGN.md and PRODUCT.md, directly. Any answer or +context updates it needs to make ride in its reply payload for the session to +apply. Without that split, an agent doing a read, modify, write cycle on the +answers file can silently drop a change the session wrote in between. + +## The live session + +The document is a working surface, not a report. The patterns below are taken +from live mode, scaled to one page, one store, and one agent. + +1. **Append-only journal with pure replay.** The session appends one line per + render-relevant event to `runtime/journal.jsonl`: applied changes, batch + transitions, and request transitions. A booting session replays the file to + recover its sequence number and any unacknowledged batch. Lines it cannot use, + including records written by an older release that carry no sequence number, + become diagnostics rather than failures. +2. **Stage, then apply.** Edits accumulate in a client-side ledger keyed by field + binding. Re-editing a field updates the new value but keeps the first original, + so the record of what changed stays true across repeated edits. +3. **Delivery that survives a dead agent.** One batch is outstanding at a time and + is journaled. A poll leases it, a reply acknowledges it, and a session restart + re-offers anything unacknowledged. An invalid reply returns a corrective hint + while the lease holds, so the agent can fix its own message. +4. **Server-owned truth, DOM as cache.** The state endpoint carries the journal + sequence and the current batch. The client compares sequence numbers and + re-fetches when the server has moved ahead. That re-fetch and re-render is the + hot reload. The count of staged changes before Apply is the client's own, + because nothing has reached the server yet. +5. **One in-flight lock.** While a batch is unacknowledged the document's edit + affordances are disabled. A re-render triggered by anything else re-applies + still-staged edits onto the fresh DOM, so a background event never wipes work + the user has not saved. +6. **Loopback discovery with a per-run token.** The session records its process + id, port, and token; readers probe liveness before trusting the record; the + token, not the origin, is the security boundary. + +What was deliberately left out, because each answers a question this surface does +not ask: the evidence gathering and verification pipeline and the repair loop +(live cannot know which source file a DOM edit belongs to, while every field here +has an authoritative address and applying is a deterministic write); server-sent +events and reload-resume state (a two-second poll already covers it); poll lanes +and priorities and chunking (one agent, one batch); the roots manifest (one fixed +path under the project root). + +## Data flow + +``` +SEED + agent stages assets into the store, runs the cue pipeline, writes the + workspace and the store's context.json, then launches the picker + client boots, prefills from a draft or a previous submission, autosaves + submit writes answers, copies the chosen hero, forks the session, exits 0 + agent seeds DESIGN.md from the answers, then enters the edit loop + +REOPEN + the picker server runs in document mode, reusing or spawning a session + the client renders the document directly, with no submit + the session ending is the agent's completion signal + +EDIT + staged changes leave the ledger as one batch, the session applies them to + the store and queues the downstream work, the agent reconciles DESIGN.md + and PRODUCT.md prose and replies, the sequence advances, the tab re-fetches + and re-renders + +EXPORT AND IMPORT + the store compiles to a readable markdown document plus a lossless bundle + importing a bundle rebuilds a working store in another project +``` diff --git a/picker/data/surfaces.js b/picker/data/surfaces.js index 6ec9afdaf..5ae475405 100644 --- a/picker/data/surfaces.js +++ b/picker/data/surfaces.js @@ -3,9 +3,9 @@ the skill's modes, and the questions that are answered once for each of them. Values travel as `surface-modes` (multi-select) from screen 01b; the agent - pre-checks what PRODUCT.md suggests via cues.json, and the visitor corrects - it. Persuade is the markup default so the answer can never arrive empty on - runs whose cues carry no hint. + pre-checks what PRODUCT.md suggests via the design-context store's + context.json, and the visitor corrects it. Persuade is the markup default so + the answer can never arrive empty on runs that carry no hint. */ export const SURFACE_MODES = ['persuade', 'operate', 'read', 'experience']; diff --git a/picker/pages/index.astro b/picker/pages/index.astro index 909549450..b92a99189 100644 --- a/picker/pages/index.astro +++ b/picker/pages/index.astro @@ -257,6 +257,11 @@ const questions = [
+ {/* Which per-surface answers were actually opened. Every chosen surface + leaves an answer either way, so the value alone cannot tell a + confirmed default from an inherited one, and the design context + document says which. Filled by the questionnaire as it runs. */} +
@@ -272,6 +277,13 @@ const questions = [

Let's build your
design system.

+ {/* Shown only when the boot contract restored a run; the body + attribute the restore sets is what reveals it. */} + +
+ +
+ diff --git a/picker/scripts/boot.js b/picker/scripts/boot.js new file mode 100644 index 000000000..11cc12d92 --- /dev/null +++ b/picker/scripts/boot.js @@ -0,0 +1,35 @@ +/** The boot contract: one fetch that tells the page how to start. + * + * Both scripts on the page read it, so it is fetched once and shared. A server + * that predates the contract answers 404, and the page starts the way it always + * did: a blank questionnaire. + */ + +let bootPromise = null; + +export function getBoot() { + bootPromise ??= fetch('/boot.json') + .then((response) => (response.ok ? response.json() : null)) + .catch(() => null) + .then((data) => ({ + mode: data?.mode === 'doc' ? 'doc' : 'questionnaire', + prior: data?.prior && typeof data.prior === 'object' && !Array.isArray(data.prior) ? data.prior : null, + priorSource: data?.priorSource || null, + doc: data?.doc || null, + })); + return bootPromise; +} + +/* Restoring a previous run happens inside the questionnaire, which owns the + state being restored. The document waits on this before it renders, so it + never reads a form that is still half filled. It resolves either way: a run + with nothing to restore is ready immediately. */ +let settle; +export const hydrationReady = new Promise((resolve) => { settle = resolve; }); + +/* The attribute is the observable half: it drives the note on the start screen + and gives anything watching the page one thing to wait for. */ +export function markHydrated(source) { + document.body.dataset.hydrated = source || 'none'; + settle(source || null); +} diff --git a/picker/scripts/design-context.js b/picker/scripts/design-context.js index ef2060c59..864bd69cc 100644 --- a/picker/scripts/design-context.js +++ b/picker/scripts/design-context.js @@ -1,16 +1,22 @@ -/* Design context document — the questionnaire's final act. +/* Design context document, the questionnaire's final act and its own surface. * - * When the run reaches the review screen this module saves the answers, then + * When a run reaches the review screen this module saves the answers, then * swaps the picker for the eight-category design context document. The mosaic * landing, tile-to-fullscreen morph, sidebar shell, and article vocabulary are * ported unchanged from docs/design-context-categorization/design-context.html; * what changed is the content: the prototype rendered one example project, this * renders the interview that just ended. Everything is assembled client-side * before the POST resolves, because the server's exit on /submit is the - * completion signal the agent waits on — after it there is nothing to fetch. + * completion signal the agent waits on, and after it there is nothing to fetch. + * + * The document is also openable on its own, long after that run. The boot + * contract says which of the two this page is, and document mode renders from + * the design-context store with no submit involved. */ import { contrastInk, contrastInkHex, formatOklch, readableOn } from './color.js'; +import { getBoot, hydrationReady } from './boot.js'; +import { loadIconPacks } from './palette-picker.js'; const $ = (selector, root = document) => root.querySelector(selector); const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; @@ -18,23 +24,37 @@ const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; const form = $('#picker-form'); const shell = $('[data-dcx-shell]'); -/* Seed context (the chat half of the interview) and the dealt palettes ride - in on cues.json. Fetched at load, before the server can exit: the context - block feeds the chat-sourced pages, and the palette map is what the - provenance tags compare committed values against. */ +/* Seed context (the chat half of the interview) lives in the design-context + store; the dealt palettes stay with the cues that generated them. Both are + fetched at load, before the server can exit: the context block feeds the + chat-sourced pages, and the palette map is what the provenance tags compare + committed values against. Both promises are kept rather than discarded, + because a document opened directly renders from them instead of waiting on + a submit that never comes. */ let seedContext = null; let seedModes = null; let seedPalettes = null; let seedCues = null; -fetch('/cues.json') + +const getJson = (url) => fetch(url) .then((response) => (response.ok ? response.json() : null)) - .then((data) => { - seedContext = data?.context || null; - seedModes = Array.isArray(data?.modes) ? data.modes : null; - seedPalettes = data?.palette || null; - seedCues = Array.isArray(data?.cues) ? data.cues : null; - }) - .catch(() => {}); + .catch(() => null); + +const cuesReady = getJson('/cues.json').then((data) => { + seedPalettes = data?.palette || null; + seedCues = Array.isArray(data?.cues) ? data.cues : null; + return data; +}); + +/* Field by field, not file by file: a store written before a field existed, + or one carrying only half a run, still falls back to whatever the cue + manifest kept from the release that wrote it. */ +const contextReady = Promise.all([getJson('/context.json'), cuesReady]) + .then(([stored, cues]) => { + seedContext = stored?.context ?? cues?.context ?? null; + const modes = Array.isArray(stored?.modes) ? stored.modes : cues?.modes; + seedModes = Array.isArray(modes) ? modes : null; + }); /* The winning cue's dealt value for one role, read the way the deck's own createState reads it in palette-picker.js: the pixel-snapped value when @@ -42,6 +62,17 @@ fetch('/cues.json') source that is not a cue in cues.json (a seed-deck card, a custom palette) has no entry here and returns nothing, which is what turns the provenance tag off. */ +/* Which of the two surfaces this page is. Set before the document renders in + document mode, read by the parts of it that differ. */ +let docMode = false; + +/* The submit flow renders before the server has exited but reveals after, and + the store's copy of the cue is made during that submit: a URL first requested + after the exit would find nothing serving it. The cue the questionnaire + already displayed is in the browser's cache, so that run keeps reading it + from the workspace, and only a document opened later reads the store copy. */ +const cueImageSrc = (slug) => (docMode ? '/cue.png' : `/cues/${encodeURIComponent(slug)}.png`); + const seedHexFor = (source, role) => { const slot = seedPalettes?.[source]?.[role]; if (!slot) return ''; @@ -221,6 +252,16 @@ const empty = (title, body) => ` const note = (text) => `

${text}

`; +/* A value the document lets a person change in place. + + The binding id is the whole address: the session resolves it to a file and + a path, so nothing on this side has to know where the text lives. The + original travels with it because an edit reports what it replaced, and a + field edited twice still has to report the value the store started from. + Editing itself is switched on after render, and only where a session can + accept it. */ +const editable = (bindingId, text) => `${escapeHtml(text)}`; + /* Chat-round material renders when the agent passed it along, and says where it lives when it did not — an interview that skipped a question is a fact the document reports, not a gap it papers over. */ @@ -362,8 +403,8 @@ function buildAudience(s, name) { const audience = s.context?.audience || {}; const parts = [heading(1, 'Audience', 'Who it is for, emotional state, needs, trust triggers.', name)]; const who = [ - audience.primary && { dt: 'Primary', dd: escapeHtml(audience.primary) }, - audience.secondary && { dt: 'Secondary', dd: escapeHtml(audience.secondary) }, + audience.primary && { dt: 'Primary', dd: editable('audience.primary', audience.primary) }, + audience.secondary && { dt: 'Secondary', dd: editable('audience.secondary', audience.secondary) }, ].filter(Boolean); parts.push(block('Who they are', who.length ? defs(who) @@ -371,8 +412,8 @@ function buildAudience(s, name) { /* Arrival-only context keeps the old single-callout block; a leaving line widens it into the two-beat journey, side by side. */ if (audience.emotion || audience.leaving) { - const arrival = audience.emotion ? callout('On arrival', escapeHtml(audience.emotion), true) : ''; - const leaving = audience.leaving ? callout('Leaving with', escapeHtml(audience.leaving), true) : ''; + const arrival = audience.emotion ? callout('On arrival', editable('audience.emotion', audience.emotion), true) : ''; + const leaving = audience.leaving ? callout('Leaving with', editable('audience.leaving', audience.leaving), true) : ''; if (arrival && leaving) { parts.push(block('Emotional journey', `
${arrival}${leaving}
`)); } else { @@ -437,7 +478,7 @@ function buildProduct(s, name) { const product = s.context?.product || {}; const parts = [heading(2, 'Product', 'Purpose, surfaces, use cases, what must be clear first.', name)]; const purposeCallout = product.purpose - ? callout(product.name || name || 'This product', escapeHtml(product.purpose), false, + ? callout(product.name || name || 'This product', editable('product.purpose', product.purpose), false, product.success ? `\n

${escapeHtml(product.success)}

` : '') : fromChat('The purpose and success definition were confirmed', 'PRODUCT.md · Product Purpose'); const platform = typeof product.platform === 'string' && product.platform.trim() @@ -448,8 +489,8 @@ function buildProduct(s, name) { : purposeCallout)); if (product.positioning && (product.positioning.not || product.positioning.this)) { const cells = [ - product.positioning.not && callout('Not this', escapeHtml(product.positioning.not)), - product.positioning.this && callout('This', escapeHtml(product.positioning.this), true), + product.positioning.not && callout('Not this', editable('product.positioning.not', product.positioning.not)), + product.positioning.this && callout('This', editable('product.positioning.this', product.positioning.this), true), ].filter(Boolean).join(''); parts.push(block('Positioning', `
${cells}
`)); } @@ -485,7 +526,7 @@ function buildBrand(s, name) { words alone over the pointer to the durable copy when only they arrived; the plain pointer otherwise. */ parts.push(block('Personality', brand.personality - ? callout(brand.words?.join(' · ') || 'Voice', escapeHtml(brand.personality), true) + ? callout(brand.words?.join(' · ') || 'Voice', editable('brand.personality', brand.personality), true) : (Array.isArray(brand.words) && brand.words.length ? callout(brand.words.join(' · '), 'Three words, voice, and tone were confirmed in chat, before the browser questionnaire. PRODUCT.md · Brand Personality is the durable copy.', true) : fromChat('Three words, voice, and tone were confirmed', 'PRODUCT.md · Brand Personality')))); @@ -536,7 +577,7 @@ function buildBrand(s, name) { + note('Q5 of the seed interview. A hard constraint on every palette and pair that followed.'))); } /* Assets: an object entry carries a staged file under - .impeccable/design-interview/assets/ and renders as an image; a plain + .impeccable/design-context/assets/ and renders as an image; a plain string keeps the text line it always had. A logo is proofed on two chips, the committed primary and the committed neutral, so a colored and a quiet ground are judged at once; boards and references get a @@ -564,7 +605,7 @@ function buildBrand(s, name) {
${assetCaption(entry)} `).join('')}
` - + note('Provided marks proofed on the committed primary and neutral grounds. The files are staged in .impeccable/design-interview/assets/.'))); + + note('Provided marks proofed on the committed primary and neutral grounds. The files are staged in .impeccable/design-context/assets/.'))); } if (boards.length) { parts.push(block('Boards and references', `
${boards.map((entry) => ` @@ -572,7 +613,7 @@ function buildBrand(s, name) { ${escapeHtml(entry.file)} ${assetCaption(entry)} `).join('')}
` - + note('Boards and reference images provided in chat, staged in .impeccable/design-interview/assets/.'))); + + note('Boards and reference images provided in chat, staged in .impeccable/design-context/assets/.'))); } if (textAssets.length) { parts.push(block('Assets provided', list(textAssets.map((entry) => escapeHtml( @@ -599,9 +640,20 @@ function buildColor(s, name) { points marked at the cues.json coordinates in each role's dealt color, and the rest of the generated set dimmed below. Skipped without ceremony when the palette came from a seed deck or a custom pick rather than a - cue, or when the run had no cues at all. */ + cue, or when the run had no cues at all. + + The image itself comes from the store, where the submit put a copy of the + one that was picked, so a document reopened after the generation workspace + was cleaned still has its cue. The workspace only has to still be there + for the sample dots and the directions not taken. */ const cueSlugs = Array.isArray(s.cueSlugs) ? s.cueSlugs : []; - const chosenCue = cueSlugs.includes(s.paletteSource) ? s.paletteSource : ''; + /* A document opened on its own reads the cue out of the store, so the + generation workspace no longer has to still list it. A palette that never + came from a cue has no copy there either, and the whole block hides itself + when the image fails, which is the same answer arrived at later. */ + const chosenCue = s.paletteSource && (docMode || cueSlugs.includes(s.paletteSource)) + ? s.paletteSource + : ''; if (chosenCue && s.palette.length) { const cuePalette = seedPalettes?.[chosenCue] || {}; const dots = ROLES.map((role) => { @@ -619,7 +671,7 @@ function buildColor(s, name) { `).join(''); parts.push(block('The cue', `
- The chosen visual cue, ${escapeHtml(chosenCue)} + The chosen visual cue, ${escapeHtml(chosenCue)} ${dots}
@@ -973,6 +1025,46 @@ document.addEventListener('picker:screenchange', ({ detail }) => { finishSequence(); }); +/* Document mode: the run already happened, so the document renders from the + store instead of waiting on a submit that will never come. + + Everything it reads has to be in hand before the first render, because + nothing re-renders it afterwards on its own: the version the tab compares + against and the version a fresh session starts at are both 1, so a render + that raced its own data would stay wrong until an edit moved the number. + That means the context and cue fetches, the restored form, and the icon + sheet, which the questionnaire otherwise fetches only when its screen is + reached and whose absence quietly drops a block from the document. */ +getBoot().then(async (boot) => { + if (boot.mode !== 'doc') return; + docMode = true; + // Nothing here submits, and there is no half-finished run to save. + finished = true; + await Promise.all([cuesReady, contextReady, hydrationReady, loadIconPacks().catch(() => {})]); + if (boot.doc?.base && boot.doc?.token) startDocSession(boot.doc); + renderDocument(); + revealDocument(); +}); + +/* A run walked away from is a run that can be resumed: the whole form goes to + the server after every screen, so closing the tab costs the visitor nothing + but the trip. Debounced because arrow keys can walk several screens faster + than a request completes, and dropped silently on failure, since a draft the + server never took is only the resume that will not happen. */ +let draftTimer; +document.addEventListener('picker:screenchange', () => { + if (finished) return; + clearTimeout(draftTimer); + draftTimer = setTimeout(() => { + if (finished) return; + fetch('/autosave', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(collectAnswers()), + }).catch(() => {}); + }, 500); +}); + $('[data-doc-retry]')?.addEventListener('click', finishSequence); /* ============================================================ @@ -1320,8 +1412,16 @@ async function pollDocState() { renderTray(); if (state.version !== docVersion) { docVersion = state.version; - await adoptAnswers(); + /* Something moved on disk: a save of this tab's own, a request the + agent finished, or a value it settled while doing either. Re-read + both halves of the store and rebuild, including the article that is + open, since the templates alone are not what anyone is looking at. */ + await adoptStoreState(); + const openScroll = current?.expander?.querySelector('.dcx-main')?.scrollTop ?? 0; refreshDocument(); + const main = current?.expander?.querySelector('.dcx-main'); + if (main) main.scrollTop = openScroll; + markEditables(); } schedulePoll(2000); } catch { @@ -1367,12 +1467,102 @@ function ensureFace(family) { document.head.appendChild(link); } +/* The chat half moves too: an agent reconciling a batch can rewrite a purpose + line, and the document is where that has to show up. Assigned onto the same + variables the boot fetch fills, so every builder reads the new values. */ +async function adoptContext() { + const response = await fetch(`${docSession.base}/doc/context?token=${encodeURIComponent(docSession.token)}`); + if (!response.ok) return; + const payload = await response.json(); + if (payload.context && typeof payload.context === 'object') seedContext = payload.context; + if (Array.isArray(payload.modes)) seedModes = payload.modes; +} + +const adoptStoreState = () => Promise.all([adoptAnswers(), adoptContext()]); + function refreshDocument() { renderDocument(); if (current) renderCategory(current.id, current.expander, false); + markEditables(); } -/* ---------- Simple edits: palette colors ---------- */ +/* ---------- Staged edits: the pending ledger and the save bar ---------- + + Edits land in the page immediately and on disk deliberately. That split is + what lets a person try three changes and keep two: until Apply, nothing has + been written, and the document is only showing what it would look like. + + The ledger keys on the binding id and keeps the FIRST original it saw, so a + field edited three times still reports the value the store actually holds. + ------------------------------------------------------------------------ */ + +const staged = new Map(); +/* One-off cards in the tray, for outcomes that are not a queued request. */ +const trayNotes = []; +const saveBar = $('[data-dcx-savebar]'); +let applying = false; +let wasShowing = false; + +function stage(bindingId, from, to) { + if (!bindingId) return; + const existing = staged.get(bindingId); + if (to === (existing ? existing.from : from)) staged.delete(bindingId); + else staged.set(bindingId, { from: existing ? existing.from : from, to }); + renderSaveBar(); +} + +function renderSaveBar() { + if (!saveBar) return; + const count = staged.size; + saveBar.hidden = !count || !docLive(); + saveBar.toggleAttribute('data-applying', applying); + if (saveBar.hidden) return; + const label = $('[data-dcx-apply-label]', saveBar); + const counter = $('[data-dcx-apply-count]', saveBar); + label.textContent = applying ? 'Applying' : `Apply ${count === 1 ? 'change' : 'changes'}`; + counter.textContent = String(count); + counter.hidden = applying; + $('[data-dcx-apply]', saveBar).disabled = applying; + $('[data-dcx-discard]', saveBar).disabled = applying; + $('[data-dcx-apply]', saveBar).setAttribute( + 'aria-label', + `Apply ${count} ${count === 1 ? 'change' : 'changes'} to the design context`, + ); + if (!wasShowing) { + saveBar.setAttribute('data-just-appeared', ''); + setTimeout(() => saveBar.removeAttribute('data-just-appeared'), 700); + } + wasShowing = true; +} + +/* Editing is offered only where it can be accepted, and re-armed after every + render because the article is rebuilt rather than patched. */ +function markEditables() { + const live = docLive() && !applying; + for (const node of $$('[data-dcx-binding]')) { + node.contentEditable = live ? 'plaintext-only' : 'false'; + const id = node.dataset.dcxBinding; + const pendingValue = staged.get(id)?.to; + // A re-render rebuilt this element from the store, so anything staged + // against it has to be written back on: the bar still counts it. + if (pendingValue !== undefined && node.textContent !== pendingValue) { + node.textContent = pendingValue; + } + node.toggleAttribute('data-dcx-dirty', pendingValue !== undefined); + } + renderSaveBar(); +} + +/* plaintext-only keeps pasted markup out; this is the second half of that, + because a browser without the mode still allows rich text. */ +document.addEventListener('input', (event) => { + const node = event.target.closest?.('[data-dcx-binding]'); + if (!node) return; + stage(node.dataset.dcxBinding, node.dataset.dcxOriginal ?? '', node.textContent.trim()); + node.toggleAttribute('data-dcx-dirty', staged.has(node.dataset.dcxBinding)); +}); + +/* ---------- Palette swatches stage like everything else ---------- */ document.addEventListener('click', (event) => { const button = event.target.closest('[data-edit-color]'); @@ -1384,22 +1574,59 @@ document.addEventListener('click', (event) => { document.addEventListener('change', (event) => { const input = event.target.closest?.('[data-color-input-for]'); if (!input) return; - applyColorEdit(input.dataset.colorInputFor, input.value.toUpperCase()); + stageColorEdit(input.dataset.colorInputFor, input.value.toUpperCase()); }); -async function applyColorEdit(role, hex) { +function stageColorEdit(role, hex) { const field = form.elements[`palette-${role}`]; if (!field || field.value.toUpperCase() === hex) return; + const previous = field.value.toUpperCase(); field.value = hex; refreshDocument(); - if (!docLive()) return; + stage(`palette.${role}`, previous, hex); +} + +/* ---------- Apply and discard ---------- */ + +$('[data-dcx-apply]')?.addEventListener('click', async () => { + if (!staged.size || applying || !docLive()) return; + const count = staged.size; + if (!window.confirm(`Apply ${count} ${count === 1 ? 'change' : 'changes'} to the design context?`)) return; + + const changes = [...staged].map(([bindingId, { from, to }]) => ({ bindingId, from, to })); + applying = true; + markEditables(); try { - const result = await docPost('/doc/edit', { kind: 'color', role, value: hex }); + const result = await docPost('/doc/save', { changes }); docVersion = result.version; - } catch { - setDocOnline(false); - renderTray(); + staged.clear(); + trayNote(`Applied ${count} ${count === 1 ? 'change' : 'changes'}`, 'done'); + } catch (error) { + trayNote('Those changes could not be saved. They are still here.', 'error'); + } finally { + applying = false; + markEditables(); } +}); + +$('[data-dcx-discard]')?.addEventListener('click', async () => { + if (!staged.size || applying) return; + const count = staged.size; + if (!window.confirm(`Discard ${count} ${count === 1 ? 'change' : 'changes'}?`)) return; + staged.clear(); + // The store is the rollback: re-reading it puts every field back. + await adoptStoreState(); + refreshDocument(); + markEditables(); +}); + +function trayNote(message, status) { + trayNotes.push({ id: `note-${trayNotes.length}`, status, message }); + renderTray(); + setTimeout(() => { + trayNotes.shift(); + renderTray(); + }, 6000); } /* ---------- Complex edits: the request modal ---------- */ @@ -1465,12 +1692,18 @@ const TRAY_LABELS = { }; function renderTray() { + /* Notes are transient outcomes of a save; requests are work the agent owes. */ if (!tray) return; const items = trayRequests.slice(-4); const offline = docSession && !docOnline; - tray.hidden = !offline && items.length === 0; + tray.hidden = !offline && items.length === 0 && trayNotes.length === 0; tray.innerHTML = [ offline ? '

Edit session offline

Changes stay in this tab; reconnecting…

' : '', + ...trayNotes.map((entry) => ` +
+ +

${escapeHtml(entry.message)}

+
`), ...items.map((entry) => `
diff --git a/picker/scripts/hydrate.js b/picker/scripts/hydrate.js new file mode 100644 index 000000000..13ad56903 --- /dev/null +++ b/picker/scripts/hydrate.js @@ -0,0 +1,189 @@ +/** Restoring a previous run into the questionnaire. + * + * The one rule this file is built around: setting `.checked` or `.value` from + * script fires no event, and every committer on this page runs off change + * events. A bare write therefore leaves the radio saying one thing and the + * hidden field the rest of the run reads saying another. So every answer is + * restored by clicking its control, or by calling the function the control + * would have called. + * + * The order matters too. Clicking a radio runs record(), which stamps + * data-chosen on every field it writes, so which answers a person actually + * visited is restored last, after every click has had its say. + * + * The questionnaire owns the state being restored, so palette-picker.js passes + * the handles this needs rather than this file reaching into it. + */ + +const ROLES = ['primary', 'secondary', 'tertiary', 'neutral']; + +/* Groups whose answer is one radio the whole run reads. Absent keys are left + alone: a run that was never asked about movement must come back without a + movement key, not with a default standing in for a decision. */ +const RADIO_GROUPS = [ + 'color-strategy', + 'motion-energy', + 'layout-structure', + 'boundary-style', + 'corner-style', + 'depth-style', + 'type-scale', + 'icon-pack', +]; + +const asText = (value) => (typeof value === 'string' ? value : ''); + +/* Clicking is the whole point: activation runs the change handlers that commit + the answer, which a bare `checked = true` would skip. */ +function clickOption(name, value) { + if (!value) return false; + const input = document.querySelector(`input[name="${name}"][value="${CSS.escape(value)}"]`); + if (!input || input.disabled) return false; + input.click(); + return true; +} + +function surfaceFields() { + return [...document.querySelectorAll('input[type="hidden"][data-surface-field]')]; +} + +export function hydrateAnswers(prior, ctx) { + if (!prior) return; + + restoreSurfaces(prior, ctx); + for (const group of RADIO_GROUPS) { + if (group in prior) clickOption(group, asText(prior[group])); + } + restoreSurfaceFields(prior); + restorePalette(prior, ctx); + restoreFonts(prior, ctx); + restoreIconMeta(prior); + restoreChosen(prior, ctx); +} + +/* Which surfaces the run covers, first, because every per-surface field is + filled with its default the moment a tile is checked. */ +function restoreSurfaces(prior, ctx) { + const raw = prior['surface-modes']; + const wanted = new Set(Array.isArray(raw) ? raw : (asText(raw) ? [raw] : [])); + if (!wanted.size) return; + // The same guard the agent's own hint carries: a set naming no real tile + // would otherwise clear the run's only required answer. + if (!ctx.modeInputs.some((input) => wanted.has(input.value))) return; + for (const input of ctx.modeInputs) input.checked = wanted.has(input.value); + ctx.syncModes(); +} + +function restoreSurfaceFields(prior) { + for (const field of surfaceFields()) { + const key = field.dataset.surfaceField; + if (field.disabled || !(key in prior)) continue; + const value = asText(prior[key]); + if (value) field.value = value; + } +} + +/* The deck has no programmatic selection path: the committing click reads the + card the scroller is parked on. So the card's own state is written first, + which is also what stops a later reorder or reset from reverting the fields + to the colors the cue was dealt with. */ +function restorePalette(prior, ctx) { + const source = asText(prior['palette-source']); + const colors = {}; + for (const role of ROLES) { + const hex = asText(prior[`palette-${role}`]); + if (hex) colors[role] = hex; + } + if (!source && !Object.keys(colors).length) return; + + /* The fields come first, and unconditionally: the colors are the answer, the + source is what the document names them by, and neither depends on the deck + still being able to show the card they came from. A document reopened after + the generation workspace was cleaned has no deck at all, and it still has a + palette. */ + const sourceField = document.querySelector('[name="palette-source"]'); + if (sourceField && source) sourceField.value = source; + for (const role of ROLES) { + const field = document.querySelector(`[name="palette-${role}"]`); + if (field && colors[role]) field.value = colors[role]; + } + + // Nothing was dealt, so there is no card to park on and nothing to repaint. + if (!ctx.cards.length) return; + + const index = ctx.cards.findIndex((item) => item.id === source); + const target = index === -1 ? 0 : index; + /* Writing the card's own state is also what stops a later reorder or reset + from reverting the fields to the colors the cue was dealt with. */ + const state = ctx.states.get(ctx.cards[target]?.id); + if (state?.colors) Object.assign(state.colors, colors); + ctx.setCurrent(target); + ctx.render(); + ctx.syncDeckScroll(); +} + +/* A pair still on the rail is chosen by clicking it, which runs syncFontPair. + A pair that is not, an upload or a set of faces this run was not dealt, is + rebuilt as the custom pair and registered before its card is added: the rail + resolves a click through the manifest, so a card the manifest does not know + cannot be chosen a second time. */ +function restoreFonts(prior, ctx) { + const wanted = asText(prior['font-pair']); + const heading = asText(prior['font-heading']); + const body = asText(prior['font-body']); + const manifest = ctx.fontManifest(); + + if (wanted && wanted !== 'custom' && manifest.pairs.some(({ id }) => id === wanted)) { + if (clickOption('font-pair', wanted)) return; + } + if (!heading || !body) return; + + const pair = { + id: 'custom', + name: 'Custom', + heading: { family: heading, weight: 600, source: asText(prior['font-heading-source']) }, + body: { family: body, weight: 400, source: asText(prior['font-body-source']) }, + why: 'From your last run', + }; + manifest.pairs = [pair, ...manifest.pairs.filter(({ id }) => id !== 'custom')]; + for (const node of ctx.pairNodes()) { + if (node.querySelector('input')?.value === 'custom') ctx.removePairCard(node); + } + ctx.addPairCard(pair, { checked: true, first: true }); + ctx.loadCustomFace(pair); + ctx.syncFontPair(pair); + ctx.applyHoist({ force: true }); +} + +/* The pack's own radio carries the license and URL when it is still on offer; + these are the record of one that is not. */ +function restoreIconMeta(prior) { + for (const key of ['icon-pack-name', 'icon-pack-license', 'icon-pack-url']) { + const field = document.querySelector(`[name="${key}"]`); + const value = asText(prior[key]); + if (field && !field.value && value) field.value = value; + } +} + +/* Last, because every click above stamped its own. A default nobody opened and + a default someone confirmed hold the same value, so only this list tells them + apart, and the document says which is which. */ +function restoreChosen(prior, ctx) { + let chosen = null; + try { + const parsed = JSON.parse(asText(prior._chosen) || 'null'); + if (Array.isArray(parsed)) chosen = new Set(parsed); + } catch { + /* Written by a run that did not keep the distinction. */ + } + for (const field of surfaceFields()) { + const key = field.dataset.surfaceField; + if (field.disabled) continue; + // A run that kept no list was confirmed wholesale at submit, so every + // answer it carries counts as visited. + const visited = chosen ? chosen.has(key) : key in prior; + if (visited) field.dataset.chosen = 'yes'; + else delete field.dataset.chosen; + } + ctx.syncChosenField(); +} diff --git a/picker/scripts/palette-picker.js b/picker/scripts/palette-picker.js index 378d69a9c..69ac9291b 100644 --- a/picker/scripts/palette-picker.js +++ b/picker/scripts/palette-picker.js @@ -1,4 +1,6 @@ import { contrastInk, contrastInkHex, formatOklch, hexToOklch, neutralContrastIssue, oklchToHex, readableOn, seedToRoles } from './color.js'; +import { getBoot, markHydrated } from './boot.js'; +import { hydrateAnswers } from './hydrate.js'; const ROLES = ['primary', 'secondary', 'tertiary', 'neutral']; const screen = document.querySelector('[data-screen="02"]'); @@ -901,7 +903,10 @@ function commitIconPack(input) { paintIconPack(input.value); } -function loadIconPacks() { +/* Exported because a document opened on its own never visits the icon screen, + and the sheet it clones is drawn by this fetch. Memoized, so the two callers + cost one request. */ +export function loadIconPacks() { iconRequest ??= fetch('/icon-packs.json') .then((response) => (response.ok ? response.json() : Promise.reject())) .then((data) => { @@ -4806,6 +4811,20 @@ $('[data-select-palette]').addEventListener('click', panel.onclick); $('[data-deck-prev]').onclick = () => browse(current - 1); $('[data-deck-next]').onclick = () => browse(current + 1); + +/* The deck's position IS the scroll offset: the listener below reads the card + back out of it on every scroll. A card selected while the screen was hidden + therefore has to leave the scroller parked on its own snap point, or the + visitor's first scroll would compute its way back to the first card. Hidden + elements measure zero, so this reports whether it could do its job and runs + again when the screen is first shown. */ +function syncDeckScroll() { + const height = points.firstElementChild?.offsetHeight || 0; + if (!height) return false; + const wanted = current * height; + if (Math.abs(scroller.scrollTop - wanted) > 1) scroller.scrollTop = wanted; + return true; +} scroller.addEventListener('scroll', () => { const height = points.firstElementChild?.offsetHeight || 1; const next = Math.min(cards.length - 1, Math.round(scroller.scrollTop / height)); @@ -4820,6 +4839,8 @@ scroller.addEventListener('scroll', () => { }, { passive: true }); document.addEventListener('picker:screenchange', (event) => { activate(event.detail.screen === '02'); + // A restored card could not be scrolled to while the screen had no size. + if (event.detail.screen === '02') syncDeckScroll(); // The hub re-reads every answer on arrival, so an edit made on a // question screen is on its card by the time the return lands. if (event.detail.screen === '04b') renderHub(); @@ -4983,6 +5004,21 @@ function syncModePreview() { const chosenSurfaces = () => modeInputs.filter((input) => input.checked); const surfaceInput = (value) => modeInputs.find((input) => input.value === value); +/* Which per-surface answers a person actually opened, carried in the form so a + later run can restore the distinction. Every chosen surface leaves an answer + whether or not it was ever looked at, so the value alone cannot say whether + a default was confirmed or merely inherited, and the document says which. + Derived from the fields on every write rather than tracked alongside them, + which is the only version that cannot drift. */ +const chosenField = document.querySelector('input[name="_chosen"]'); +function syncChosenField() { + if (!chosenField) return; + const keys = [...document.querySelectorAll('input[type="hidden"][data-surface-field][data-chosen="yes"]')] + .filter((field) => !field.disabled) + .map((field) => field.dataset.surfaceField); + chosenField.value = JSON.stringify(keys); +} + function buildSurfaceQuestion(tabs) { const name = tabs.dataset.surfaceTabs; // The stage the strip sits on is also the box a per-surface drawing has to @@ -5188,6 +5224,7 @@ function buildSurfaceQuestion(tabs) { field.dataset.chosen = 'yes'; } markTabs(); + syncChosenField(); } /* Arrow keys walk the group, which is the one thing a row of buttons owes a @@ -5230,6 +5267,7 @@ function alignSurfaces(leader) { } const syncSurfaces = () => { for (const question of surfaceQuestions) question.sync(); + syncChosenField(); }; const paintStage = () => { for (const question of surfaceQuestions) question.paint(); @@ -5558,12 +5596,19 @@ syncModePreview(); try { const get = (url) => fetch(url).then((response) => response.ok ? response.json() : Promise.reject()); - const [cueData, seedData] = await Promise.all([get('/cues.json'), get('/palettes.json')]); - // The agent's reading of PRODUCT.md arrives as cues.modes and pre-checks - // the surface tiles. Applied only when it names at least one real tile, so - // a bad hint cannot uncheck everything. - if (Array.isArray(cueData.modes)) { - const wanted = new Set(cueData.modes); + const [cueData, seedData, storedContext] = await Promise.all([ + get('/cues.json'), + get('/palettes.json'), + // Absent on a run that predates the store, so this one may not reject. + fetch('/context.json').then((response) => (response.ok ? response.json() : null)).catch(() => null), + ]); + // The agent's reading of PRODUCT.md arrives as modes and pre-checks the + // surface tiles: from the design-context store when it carries them, from + // the cue manifest otherwise. Applied only when it names at least one real + // tile, so a bad hint cannot uncheck everything. + const hintedModes = Array.isArray(storedContext?.modes) ? storedContext.modes : cueData.modes; + if (Array.isArray(hintedModes)) { + const wanted = new Set(hintedModes); if (modeInputs.some((input) => wanted.has(input.value))) { for (const input of modeInputs) input.checked = wanted.has(input.value); syncModesNext(); @@ -5597,3 +5642,31 @@ try { // The built-in pairs keep older and incomplete runs moving. } renderFontPairs(manifest, usingFallback); + +/* Everything the run can be restored into now exists: the deck is built, the + pairs are dealt, and every per-surface field holds its default. A previous + run, or one walked away from, is written over that. */ +const boot = await getBoot(); +try { + hydrateAnswers(boot.prior, { + modeInputs, + syncModes: () => { syncModesNext(); syncModePreview(); }, + states, + cards, + setCurrent: (index) => { current = index; }, + render, + syncDeckScroll, + fontManifest: () => fontManifest, + pairNodes: () => [...pairOrder], + addPairCard, + removePairCard, + loadCustomFace, + syncFontPair, + applyHoist, + syncChosenField, + }); +} catch { + /* A restore that cannot complete must not cost the visitor the run; the + questionnaire's own defaults are still standing behind it. */ +} +markHydrated(boot.prior ? boot.priorSource : null); diff --git a/picker/styles/design-context.css b/picker/styles/design-context.css index 487efcc8e..9728c587a 100644 --- a/picker/styles/design-context.css +++ b/picker/styles/design-context.css @@ -2233,11 +2233,112 @@ body.dcx-live .dcx-request { display: inline-flex; } /* ============================================================ Request tray — queued work and its status, bottom right. ============================================================ */ +/* Editable values read as text until the pointer is over them, so an article + stays a document rather than a form. A staged one keeps a mark, because the + count on the bar has to be attributable to something on the page. */ +.dcx-editable { + border-radius: 2px; + outline-offset: 3px; +} + +body.dcx-live .dcx-editable:hover { + box-shadow: inset 0 -1px 0 var(--ks-rule); + cursor: text; +} + +body.dcx-live .dcx-editable:focus { + outline: 1px solid var(--ks-kinpaku); + background: oklch(78% 0.12 82 / 0.1); +} + +body.dcx-live .dcx-editable[data-dcx-dirty] { + box-shadow: inset 0 -1px 0 var(--ks-kinpaku); +} + +/* Sits above the tray, sharing its column so the two never overlap. */ +.dcx-savebar { + position: fixed; + right: 22px; + bottom: 22px; + /* Above the fullscreen expander: editing happens inside an open article, so + the control that saves it cannot sit behind the article. */ + z-index: 620; + display: flex; + align-items: center; + gap: 10px; + width: min(340px, calc(100vw - 44px)); +} + +.dcx-savebar[hidden] { display: none; } + +/* The bar owns the corner and the tray stacks above it. A plain sibling + selector does the arithmetic, so neither has to know the other is there. */ +.dcx-savebar:not([hidden]) ~ .dcx-tray { + bottom: 78px; +} + +.dcx-savebar-apply { + display: inline-flex; + align-items: center; + gap: 10px; + flex: 1; + justify-content: space-between; + padding: 12px 14px; + border: 1px solid var(--ks-kinpaku); + background: var(--ks-kinpaku); + color: var(--ks-kinpaku-ink); + font-family: inherit; + font-size: 0.82rem; + font-weight: 600; + cursor: pointer; +} + +.dcx-savebar-apply:hover { filter: brightness(1.08); } +.dcx-savebar-apply:disabled { cursor: wait; filter: brightness(0.94); } + +.dcx-savebar-count { + padding: 2px 8px; + background: oklch(4% 0.004 95 / 0.18); + font-family: var(--ks-font-mono, ui-monospace, monospace); + font-size: 0.72rem; + font-weight: 700; +} + +.dcx-savebar-count[hidden] { display: none; } + +.dcx-savebar-discard { + padding: 12px 12px; + border: 1px solid var(--ks-rule); + background: var(--ks-lacquer); + color: var(--ks-text-muted); + font-family: inherit; + font-size: 0.72rem; + letter-spacing: 0.08em; + text-transform: uppercase; + cursor: pointer; +} + +.dcx-savebar-discard:hover { color: var(--ks-text); } +.dcx-savebar-discard:disabled { opacity: 0.5; cursor: not-allowed; } + +/* The bar arriving is the one moment it has to be noticed. */ +@keyframes dcx-savebar-in { + 0% { transform: scale(0.9); opacity: 0; } + 60% { transform: scale(1.04); opacity: 1; } + 100% { transform: scale(1); opacity: 1; } +} + +.dcx-savebar[data-just-appeared] { + animation: dcx-savebar-in 460ms cubic-bezier(0.22, 1, 0.36, 1); +} + .dcx-tray { position: fixed; right: 22px; bottom: 22px; - z-index: 300; + /* Same reason as the save bar: what the agent is doing has to stay readable + while an article is open, which is when the asking happens. */ + z-index: 600; display: grid; gap: 10px; width: min(340px, calc(100vw - 44px)); diff --git a/picker/styles/picker.css b/picker/styles/picker.css index ed9e97484..7aab83a41 100644 --- a/picker/styles/picker.css +++ b/picker/styles/picker.css @@ -272,6 +272,31 @@ body.picker-page { text-wrap: balance; } +/* Shown only when a previous run was restored into the form. The body carries + which kind, so the note and its two readings are one element rather than a + branch in script. */ +.picker-restored-note { + margin: 0; + color: var(--ks-text-muted); + font-size: 0.95rem; + line-height: 1.5; +} + +[data-restored-draft], +[data-restored-submitted] { + display: none; +} + +body[data-hydrated='draft'] .picker-restored-note, +body[data-hydrated='submitted'] .picker-restored-note { + display: block; +} + +body[data-hydrated='draft'] [data-restored-draft], +body[data-hydrated='submitted'] [data-restored-submitted] { + display: inline; +} + .picker-actions { display: flex; flex-wrap: wrap; diff --git a/scripts/lib/skill-categories.js b/scripts/lib/skill-categories.js index 497797b5f..69daf0422 100644 --- a/scripts/lib/skill-categories.js +++ b/scripts/lib/skill-categories.js @@ -12,6 +12,7 @@ export const SKILL_CATEGORIES = { impeccable: 'create', shape: 'create', // EVALUATE - review and assess + 'design-context': 'system', critique: 'evaluate', audit: 'evaluate', // REFINE - improve existing design diff --git a/scripts/lib/utils.js b/scripts/lib/utils.js index 1beabb4c9..abaaa55b4 100644 --- a/scripts/lib/utils.js +++ b/scripts/lib/utils.js @@ -645,7 +645,8 @@ const EXCLUDED_FROM_SUGGESTIONS = new Set([ // These are the commands that audit/critique/etc. reference when suggesting next steps. const IMPECCABLE_SUB_COMMANDS = [ 'adapt', 'animate', 'audit', 'bolder', 'clarify', 'colorize', - 'critique', 'delight', 'distill', 'document', 'harden', 'layout', + 'critique', + 'design-context', 'delight', 'distill', 'document', 'harden', 'layout', 'onboard', 'optimize', 'overdrive', 'polish', 'quieter', 'shape', 'typeset', ]; diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs index 897c4a68c..7dc318fd0 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -27,7 +27,7 @@ export const SUITES = { ...COMMON_INFRA_PATTERNS, /^picker\//, /^scripts\/(?!benchmark-detector|build-browser-detector|build-extension)/, - /^skill\/(SKILL\.src\.md|agents\/|reference\/|scripts\/(cleanup-deprecated|comp-diff|comp-spec|build-phase|font-match|data\/font-index|concept-seed|generate-image|context|context-signals|critique-storage|design-parser|doctor|hook|impeccable-paths|is-generated|lib\/(artifact-schema|png|raster|image-metrics|font-fingerprint|font-index|hero-checks|composition-catalog|concept-catalog|provider|staleness|staleness-deep|staleness-notice|surface-briefs|target-slug|template-extensions)|picker|pin|surface-brief))/, + /^skill\/(SKILL\.src\.md|agents\/|reference\/|scripts\/(cleanup-deprecated|comp-diff|comp-spec|build-phase|font-match|data\/font-index|concept-seed|generate-image|context|context-signals|critique-storage|design-context|design-parser|doctor|hook|impeccable-paths|is-generated|lib\/(artifact-schema|png|raster|image-metrics|font-fingerprint|font-index|hero-checks|composition-catalog|concept-catalog|provider|staleness|staleness-deep|staleness-notice|surface-briefs|target-slug|template-extensions)|picker|pin|surface-brief))/, /^README(\.npm)?\.md$/, /^cli\/bin\//, ], diff --git a/skill/SKILL.src.md b/skill/SKILL.src.md index b962047c7..8c7b4f470 100644 --- a/skill/SKILL.src.md +++ b/skill/SKILL.src.md @@ -48,6 +48,7 @@ Choose the mode from the requested surface, not the product, and persist it only | `init` | Build | Capture durable product context in PRODUCT.md | [reference/init.md](reference/init.md) | | `document` | Build | Generate DESIGN.md from existing project code | [reference/document.md](reference/document.md) | | `extract [target]` | Build | Pull reusable tokens and components into design system | [reference/extract.md](reference/extract.md) | +| `design-context [open/edit/export/import]` | Build | Reopen, revise, export, or import the design interview and its document | [reference/design-context.md](reference/design-context.md) | | `critique [target]` | Evaluate | UX design review with heuristic scoring | [reference/critique.md](reference/critique.md) | | `audit [target]` | Evaluate | Technical quality checks (a11y, perf, responsive) | [reference/audit.md](reference/audit.md) · native: [reference/audit.native.md](reference/audit.native.md) | | `polish [target]` | Refine | Final quality pass before shipping | [reference/polish.md](reference/polish.md) | diff --git a/skill/reference/design-context.md b/skill/reference/design-context.md new file mode 100644 index 000000000..d7d62f3c7 --- /dev/null +++ b/skill/reference/design-context.md @@ -0,0 +1,93 @@ +# Design Context + +Loaded by `{{command_prefix}}impeccable design-context`. Owns the design interview record, the document built from it, and its portable form. The interview itself is created by `{{command_prefix}}impeccable document` seed mode; this command is everything afterwards. + +## Where it lives + +One store, under the project root: + +```text +.impeccable/design-context/ + context.json the chat half of the interview: product, audience, brand, interview + answers.json the questionnaire's decisions + assets/ brand files the user supplied + fonts/ font faces the user uploaded + cue.png the chosen cue image, copied at submit + runtime/ session.json, journal.jsonl, draft.json (local, gitignored) + exports/ the written-out forms (local, gitignored) +``` + +`.impeccable/visual-cues/` is separate on purpose: it is the generation workspace, regenerable and gitignored, and the document no longer depends on it. The store is the user's own record and is theirs to commit. + +## No argument + +Report status in two lines, then act: + +- Whether `answers.json` exists, and when it was last written. +- Whether a draft is waiting (`runtime/draft.json`), whether DESIGN.md is seeded, and whether a session is live (`runtime/session.json` naming a running process). + +With answers on disk, do `open`. Without them, say the design context is created by the questionnaire and offer `{{command_prefix}}impeccable document`. Never start the questionnaire unasked. + +## open + +Reopen the document, live for edits. + +Run `node {{scripts_path}}/picker-server.mjs --doc` from the project root as a foreground command and parse its `PICKER_URL` line. Open it and wait exactly as [visual-cues.md](visual-cues.md)'s launch paragraph does: its harness-browser ladder (in-IDE browser first, then another browser tool, then the system opener, then telling the user the URL) and its wait-on-the-foreground-process rule. Skip everything earlier in its Step 7: the cue announcement and the `modes` and `context` writes belong to a run that is generating cues, and this one is not. + +Then enter the document edit loop below. The process exiting is the signal: + +- `DOC_SESSION_ENDED` and exit 0: the document was closed. Say so in one line; the loop is over. +- Exit 2: it timed out or was never opened. Say it can be reopened with the same command, and never relaunch unprompted. +- Exit 1: no interview exists. Route to `{{command_prefix}}impeccable document`. + +## edit + +Re-run the questionnaire over the previous answers. + +Say in one line what it will do before launching, and settle DESIGN.md in the same breath, because a new run replaces the seed the last one produced: *"This re-runs the questionnaire with your previous answers filled in. When you finish, I will refresh DESIGN.md from the new answers. Refresh it, overwrite it, or merge by hand?"* That is the whole consent for this run; do not ask again afterwards. + +Then run `node {{scripts_path}}/picker-server.mjs`, using the same launch ladder and wait rule as `open`. Prefill happens on its own: an unfinished run resumes from its draft, a finished one loads its answers, and `--fresh` starts blank. Cues and `context.json` already exist from the previous run, so do not regenerate cues and do not repeat Step 7's pre-launch writes. + +On exit 0, go to [document.md](document.md) Steps 5-6 and write the seed from the new `answers.json`, honoring the choice made before launch. On exit 2, nothing was answered and nothing changed. + +If `.impeccable/visual-cues/cues.json` is missing, the questionnaire cannot run: its palette screen loads the dealt cues and the built-in seeds together and neither arrives without that file. Say so and offer a full `{{command_prefix}}impeccable document --seed` run instead. + +## export + +```text +node {{scripts_path}}/design-context-export.mjs [--out DIR] [--no-assets] +``` + +Writes two files and prints an `EXPORTED` line for each. Tell the user what each is for, in one line each: + +- `design-context.md` is the design context as one readable document. It is what to hand another tool, another agent, or a collaborator who needs to follow this design. +- `design-context.bundle.json` is the same context in a form `{{command_prefix}}impeccable design-context import` reads, including the files the user supplied. + +Do not read the export back into the conversation; the user asked for a file, not a recitation. + +## import + +```text +node {{scripts_path}}/design-context-import.mjs [--design skip|write] [--force] +``` + +It refuses a project that already has a design context unless `--force`, and refuses while a document is open either way. Report what it prints: + +- `DESIGN_MD carried` with a DESIGN.md already here: ask whether to refresh it from the imported context, overwrite it, or merge by hand, then act. +- `DESIGN_MD carried` with none here: offer to write it (`--design write`) or to re-seed from the imported answers through [document.md](document.md) Steps 5-6. +- `DESIGN_MD absent`: say the bundle carried decisions but no design document, and offer to seed one. + +Then offer `open`. + +## The document edit loop + +The document is a working surface. Follow [visual-cues.md](visual-cues.md)'s "The document edit loop" section; it is the canonical contract for polling, the event kinds, and the reply commands. Two things to hold on to while you are in it: + +- **The session is the only writer of the store.** Never edit `answers.json` or `context.json` yourself while a session runs. Values you settle travel on your reply, through `--answers` or `--context`. DESIGN.md and PRODUCT.md are yours to write directly. +- **A `save_batch` is already applied.** The user's values are in the store before you hear about them. Your work is the prose those values leave stale, in whichever document the event's `downstream` names. + +## Pitfalls + +- Never poll `answers.json` while a server runs. The process exiting is the signal. +- Never drive the questionnaire yourself. The answers are the user's, and a run you filled in is a run they did not make. +- Editing in the document changes values that are already there. A field the interview never captured is added by asking through the document's own request control, not by this command. diff --git a/skill/reference/document.md b/skill/reference/document.md index 5bdf4874e..153b837fa 100644 --- a/skill/reference/document.md +++ b/skill/reference/document.md @@ -380,7 +380,7 @@ Look at every asset provided (attached in chat or a file path) and record what i - **Reference / product images**: density, palette, type feel; what the user is drawn to. - **Moodboards**: recurring hues, textures, era, register cues. -On the questionnaire path, the files themselves also feed the design context document the picker shows after the last question. When the user provided actual files (a logo, a mood board, a reference image), copy each one into `.impeccable/design-interview/assets/`, keeping its filename. Record every staged file for Step 4's cues write: it becomes an object entry in `cues.json` `context.assets`, `{ "file": "", "kind": "logo" | "moodboard" | "reference", "note": "" }`, where the note is what this step read off it. An observation with no file behind it stays a plain string entry, as before. On the interview-only path, stage nothing; the observations feed the questions and the seed alone. +On the questionnaire path, the files themselves also feed the design context document the picker shows after the last question. When the user provided actual files (a logo, a mood board, a reference image), copy each one into `.impeccable/design-context/assets/`, keeping its filename. Record every staged file for Step 4's context write: it becomes an object entry in `context.json` `context.assets`, `{ "file": "", "kind": "logo" | "moodboard" | "reference", "note": "" }`, where the note is what this step read off it. An observation with no file behind it stays a plain string entry, as before. On the interview-only path, stage nothing; the observations feed the questions and the seed alone. These observations exist to sharpen Step 3. **No assets: skip straight to Step 3** with generic options. @@ -425,7 +425,7 @@ Group each path's questions into one `AskUserQuestion` interaction. Options must **Interview-only path: skip this step.** Go to Step 5 and seed from the answers alone. Step 1 already settled the capability question; do not re-open it here. -On the questionnaire path, **stop and load [visual-cues.md](visual-cues.md)** and follow its pipeline; it owns everything from the one-line user announcement and the persona palette studio through generation, `cues.json`, and the picker pause. Do not restate its mechanics here or in chat. The picker's exit is the handoff: when the server exits 0 and `.impeccable/design-interview/answers.json` lands, come back here and run Steps 5-6 with that file in hand. +On the questionnaire path, **stop and load [visual-cues.md](visual-cues.md)** and follow its pipeline; it owns everything from the one-line user announcement and the persona palette studio through generation, `cues.json`, and the picker pause. Do not restate its mechanics here or in chat. The picker's exit is the handoff: when the server exits 0 and `.impeccable/design-context/answers.json` lands, come back here and run Steps 5-6 with that file in hand. ### Step 5: Write seed DESIGN.md @@ -451,7 +451,7 @@ Mark the file as a seed with this comment as the first line of the markdown body This seed writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. -**Questionnaire seed** (`.impeccable/design-interview/answers.json` exists from this run). The user answered every screen by eye, so the seed carries their answers as decisions, not directions. Read the answers file plus the picked cue's palette entry in `.impeccable/visual-cues/cues.json` (`palette-source` names it), and map: +**Questionnaire seed** (`.impeccable/design-context/answers.json` exists from this run). The user answered every screen by eye, so the seed carries their answers as decisions, not directions. Read the answers file plus the picked cue's palette entry in `.impeccable/visual-cues/cues.json` (`palette-source` names it), and map: - **Frontmatter**: `name` and `description`, plus real `colors` (the four `palette-*` hex values under descriptive slugs; these are picked, not sampled) and real `typography` (`font-heading` and `font-body` are exact family names; give each role its family and weight intent, leave sizes for implementation). Derive the two text inks and record them under `colors` too: one near-black and one near-white, the pair the picker's previews already set their text in over these exact surfaces, each holding 4.5:1 against the grounds it will carry copy on, so a builder needing body-text contrast finds ink in the system instead of inventing a fifth color. Still no `rounded`, `spacing`, or `components`: the corner and spacing answers are qualitative, and nothing is built. - **Overview**: Creative North Star and philosophy phrased from the questionnaire's color-strategy and motion answers plus the chat references; reference the user's anti-reference directly. Name the chosen surfaces (`surface-modes`) and what each is for. Movement stays here, after the North Star, but the questionnaire asks it of a landing page and a portfolio only, so write what the keys support: @@ -476,10 +476,11 @@ Both seeds skip the `.impeccable/design.json` sidecar: nothing to render yet. Re 1. Show the seed DESIGN.md. Call out that it is a seed (the marker is the literal commitment). 2. Tell the user: "Re-run `/impeccable document` once you have some code. That pass will extract real tokens and generate the sidecar." +3. On the questionnaire path, add one line: the interview is kept, and `{{command_prefix}}impeccable design-context` reopens the document, re-runs the questionnaire over these answers, or writes the context out for another tool. See [design-context.md](design-context.md). Your own write is the freshest source; no reload needed. -When the questionnaire ran, the confirm is not the end of the turn: the design context document in the user's tab is live for edits through the session the picker forked. Follow the document edit loop in [visual-cues.md](visual-cues.md): poll, apply `edit_request`s to this same DESIGN.md, reply. A color the user changed in the tab before your seed write is already in `answers.json`; one changed after lands in DESIGN.md without you (the session swaps the hex itself, journaled in `.impeccable/design-interview/doc-edits.jsonl` for the name-reconciliation pass at exit). +When the questionnaire ran, the confirm is not the end of the turn: the design context document in the user's tab is live for edits through the session the picker forked. Follow the document edit loop in [visual-cues.md](visual-cues.md): poll, apply `edit_request`s to this same DESIGN.md, reply. A color the user changed in the tab before your seed write is already in `answers.json`; one changed after arrives as a `save_batch` event, its value already in the store and its description in DESIGN.md yours to bring in line. ## Style guidelines diff --git a/skill/reference/visual-cues.md b/skill/reference/visual-cues.md index fbc775725..155679087 100644 --- a/skill/reference/visual-cues.md +++ b/skill/reference/visual-cues.md @@ -376,7 +376,7 @@ Do **not** read PRODUCT.md wholesale into this task or add any other section to A pair that carries a landing page can fail a dashboard outright. The landing page asks the heading face for a six-word line at 40px and up; the dashboard asks the body face for a 12px column label sitting next to a number. Suggest fonts without knowing which of those is on the table and you are guessing at the only question that separates the shortlists. -So decide first what this product is made of, from PRODUCT.md and the codebase, using the four surface kinds the picker's first question offers: `persuade` (landing, marketing, pricing), `operate` (app UI, dashboards, admin, settings), `read` (docs, articles, guides, changelogs), `experience` (portfolios, galleries, showcases). Name every kind the product already implies, not the one it leads with: a tool with a marketing site and a documentation site is `operate, read, persuade`. This is the same set Step 7 writes into `cues.json` as `modes`, so make the judgment once, here, and carry it. No clear signal anywhere leaves the set at `persuade` alone. +So decide first what this product is made of, from PRODUCT.md and the codebase, using the four surface kinds the picker's first question offers: `persuade` (landing, marketing, pricing), `operate` (app UI, dashboards, admin, settings), `read` (docs, articles, guides, changelogs), `experience` (portfolios, galleries, showcases). Name every kind the product already implies, not the one it leads with: a tool with a marketing site and a documentation site is `operate, read, persuade`. This is the same set Step 7 writes into `context.json` as `modes`, so make the judgment once, here, and carry it. No clear signal anywhere leaves the set at `persuade` alone. What each surface asks of a pair: @@ -452,9 +452,9 @@ Done when: `fonts.json` is parseable, contains exactly six ranked pairs, every f ## Step 7: Launch the picker -Before launching, write the surface set from Step 6 into `cues.json` as a top-level `modes` array: any of `persuade`, `operate`, `read`, `experience`. Do not re-derive it; the font pairs were composed against that reading, and a second judgment here would hand the user tiles the shortlist never answered to. The picker's first question pre-checks those tiles as its starting point; the user corrects the set by hand, and the final selection returns in the answers as `surface-modes`. Omit the field when the product gave no clear signal; the picker then starts from `persuade` alone. +Before launching, write the surface set from Step 6 into `.impeccable/design-context/context.json` as a top-level `modes` array: any of `persuade`, `operate`, `read`, `experience`. Do not re-derive it; the font pairs were composed against that reading, and a second judgment here would hand the user tiles the shortlist never answered to. The picker's first question pre-checks those tiles as its starting point; the user corrects the set by hand, and the final selection returns in the answers as `surface-modes`. Omit the field when the product gave no clear signal; the picker then starts from `persuade` alone. -In the same write, add a top-level `context` object carrying the chat half of the run, because after the last question the picker shows the user a design context document assembled from everything the interview learned, and the browser only knows what it asked itself. Every field is optional and the document renders whatever arrives, so fill what the run actually established and leave out the rest: +In the same write, add a top-level `context` object carrying the chat half of the run. The whole file is `{ "schemaVersion": 1, "modes": [...], "context": {...} }`, and it is the store's copy of what chat learned, because after the last question the picker shows the user a design context document assembled from everything the interview learned, and the browser only knows what it asked itself. Every field is optional and the document renders whatever arrives, so fill what the run actually established and leave out the rest: ```json "context": { @@ -487,7 +487,7 @@ In the same write, add a top-level `context` object carrying the chat half of th }, "assets": [ "[asset name: what Step 2 read off it; a plain string when no file was provided]", - { "file": "[filename staged in .impeccable/design-interview/assets/]", "kind": "[logo, moodboard, or reference]", "note": "[the one-line Step 2 observation for this file]" } + { "file": "[filename staged in .impeccable/design-context/assets/]", "kind": "[logo, moodboard, or reference]", "note": "[the one-line Step 2 observation for this file]" } ], "color": { "assetLocks": ["[one short color fact an asset fixes, e.g. Primary locked from the logo mark; only when an asset names one]"] }, "interview": { @@ -497,9 +497,9 @@ In the same write, add a top-level `context` object carrying the chat half of th } ``` -Quote the user's answers, not paraphrases of them; the document labels interview fields as the questions they answered. A missing block renders as a pointer to where that truth lives (PRODUCT.md), so an old `cues.json` without `context` still produces a complete document. +Quote the user's answers, not paraphrases of them; the document labels interview fields as the questions they answered. A missing block renders as a pointer to where that truth lives (PRODUCT.md), so a run with no `context.json` at all still produces a complete document. The document reads each field from `context.json` first and falls back to a legacy `cues.json` that still carries it. -The optionality is field by field, and the document omits the block of any field that does not arrive, so fill a field only when its PRODUCT.md section or interview answer exists. A legacy PRODUCT.md without Positioning, Platform, Operating Context, or Brand Commitments yields a context without those fields, never an invented value. `product.clarities` carries PRODUCT.md's "What must be clear first" list under a shorter key. `product.conversion` names the single action the product most wants. `product.principles` carries PRODUCT.md's Design Principles, one `{ title, detail }` entry per line. `product.surfaces` maps each mode the run might choose to what that surface is for this product, not the generic tile copy. Only include keys for surfaces that exist in the product; the document reads the map for whichever surfaces the questionnaire chose. `interview.references` and `interview.antiReference` also accept their older shapes, plain strings, which render as the bare pills and single-name callout they always did. Never write `interview.colorStrategy`, `interview.hueAnchor`, `interview.typeDirection`, or `interview.motionEnergy`: the chat interview does not ask those questions on this path, `answers.json` owns color, typography, and motion, and the document already renders its interview-direction blocks only when those keys arrive, so their absence reads as chat silence, not as a gap. `assets` mixes both shapes in one list: a file the user actually provided is staged under `.impeccable/design-interview/assets/` (seed Step 2 owns the copy) and written as the object form, which the document renders as an image (a `logo` proofed on the committed primary and neutral grounds, a `moodboard` or `reference` in a wide frame, the note under it); a words-only observation stays the plain string it always was. +The optionality is field by field, and the document omits the block of any field that does not arrive, so fill a field only when its PRODUCT.md section or interview answer exists. A legacy PRODUCT.md without Positioning, Platform, Operating Context, or Brand Commitments yields a context without those fields, never an invented value. `product.clarities` carries PRODUCT.md's "What must be clear first" list under a shorter key. `product.conversion` names the single action the product most wants. `product.principles` carries PRODUCT.md's Design Principles, one `{ title, detail }` entry per line. `product.surfaces` maps each mode the run might choose to what that surface is for this product, not the generic tile copy. Only include keys for surfaces that exist in the product; the document reads the map for whichever surfaces the questionnaire chose. `interview.references` and `interview.antiReference` also accept their older shapes, plain strings, which render as the bare pills and single-name callout they always did. Never write `interview.colorStrategy`, `interview.hueAnchor`, `interview.typeDirection`, or `interview.motionEnergy`: the chat interview does not ask those questions on this path, `answers.json` owns color, typography, and motion, and the document already renders its interview-direction blocks only when those keys arrive, so their absence reads as chat silence, not as a gap. `assets` mixes both shapes in one list: a file the user actually provided is staged under `.impeccable/design-context/assets/` (seed Step 2 owns the copy) and written as the object form, which the document renders as an image (a `logo` proofed on the committed primary and neutral grounds, a `moodboard` or `reference` in a wide frame, the note under it); a words-only observation stays the plain string it always was. Three of the additions are derived at write time rather than asked: `brand.principles` copies the PRODUCT.md principles list (the current Product Principles heading or the legacy Design Principles one), `brand.voice` distills Brand Personality and Brand Commitments into two to four say / not pairs, each half a concrete line of wording the product would or would not publish, never an adjective, and `color.assetLocks` records color facts the provided assets fix (one short line each, written only when Step 2 actually read such a fact off an asset). None of the three adds an interview question, and all three are omitted rather than invented when their source is missing. @@ -522,6 +522,8 @@ Tell the user in one line that the visual cues are ready at `.impeccable/visual- Whichever branch ran, wait on the foreground process. +A relaunch on a project that has already been through this arrives with the previous answers filled in, and resumes an unfinished run from its own draft; `--fresh` starts blank. [design-context.md](design-context.md) owns that path. + The server process exiting is the completion signal; never poll or watch the answers file while it runs. - **Exit 0**: read the `ANSWERS` path, tell the user the answers were received in one line, then return to [document.md](document.md) Steps 5-6 and write the seed DESIGN.md from that file (its questionnaire-seed mapping owns which key lands where). Do not show or describe the cues or ask for a pick in chat; the picker already settled the pick. The user's tab is meanwhile showing the design context document the picker built from the run, and that document is now a working surface: on submit the server forked a detached edit session (`picker-doc-session.mjs`) that keeps the tab connected. After the seed DESIGN.md is written, enter the edit loop below. @@ -531,8 +533,9 @@ The server process exiting is the completion signal; never poll or watch the ans The revealed document is editable in place, on live mode's division of labor: -- **Simple edits never reach you.** A palette color change is applied by the session process itself: it rewrites `answers.json`, swaps the old hex for the new one across DESIGN.md, and journals the change to `.impeccable/design-interview/doc-edits.jsonl`. If the color edit landed before your seed write, the answers file you seed from already carries it. -- **Complex edits queue for you.** Font changes (including uploaded faces, saved under `.impeccable/design-interview/fonts/`) and freeform asks arrive as `edit_request` events. +- **Field edits are applied before you hear about them.** A palette color or a line of product truth is staged in the page, and pressing Apply sends the batch to the session, which writes every value into the store and journals it. What reaches you is the prose those values leave stale: a `save_batch` event naming each change and the document it is owed in. +- **Asks in words queue for you from the start.** Font changes (including uploaded faces, saved under `.impeccable/design-context/fonts/`) and freeform requests arrive as `edit_request` events, because there is no value to apply until you decide what it should be. +- **The session is the only writer of the store while it runs.** Never write `answers.json` or `context.json` yourself during the loop; attach the values to your reply instead (below) and let the session apply them. DESIGN.md and PRODUCT.md are yours. After writing the seed DESIGN.md, tell the user in one line that the document in their tab is live for edits, then poll: @@ -542,14 +545,16 @@ node {{scripts_path}}/picker-doc-poll.mjs One-shot, exactly like live mode's poll: it blocks until one event and prints it as JSON. Run it on live mode's harness policy: on Claude Code as a background task; on Cursor as a one-shot poll in a background terminal with notify on `"type":"(edit_request|exit)"`; on Codex as a yielded foreground exec; elsewhere one-shot foreground. Never `--timeout` it short. -- `{"type":"edit_request", "id", "kind", "prompt", "category", "payload"}`: do the work. Apply the change to DESIGN.md (and `answers.json` where a questionnaire key names the same fact, so the tab re-renders it), move any uploaded font files where the project keeps assets, then reply and poll again: +- `{"type":"edit_request", "id", "kind", "prompt", "category", "payload"}`: do the work. Apply the change to DESIGN.md, move any uploaded font files where the project keeps assets, then reply and poll again. Where a questionnaire key names the same fact, attach it rather than writing it, so the tab re-renders it and one process stays in charge of the store: ``` node {{scripts_path}}/picker-doc-poll.mjs --reply done "One line the user sees in the tab" + node {{scripts_path}}/picker-doc-poll.mjs --reply done "Swapped the pair" --answers '{"font-heading":"Fraunces"}' ``` Reply `error` with a reason when the ask cannot be applied; reply `retry` to put it back in the queue untouched. +- `{"type":"save_batch", "id", "changes", "downstream", "replyCommand"}`: the values are already in the store, so do not apply them again. Read `downstream` and bring each named document in line: `design-md` items are values DESIGN.md states (swap the value, and rename a color whose description no longer fits it), `product-md` items are product truth PRODUCT.md owns. Then reply with the command the event carries. A document that does not exist yet, or a value the document already carries, is success: reply `done`. Reply `error` only when a document exists and cannot be edited. - `{"type":"timeout"}`: nothing arrived in the budget; poll again. -- `{"type":"exit"}`: the session ended (tab closed or timed out). Before moving on, read `doc-edits.jsonl` and reconcile any prose the deterministic edits left stale: a swapped hex whose descriptive color name in DESIGN.md no longer matches its value gets a fresh name. Then stop polling; the loop is over. +- `{"type":"exit"}`: the session ended (tab closed or timed out). Before moving on, read `runtime/journal.jsonl` for `change` entries you never saw a `save_batch` for, which is what a session that died mid-save leaves behind, and reconcile the prose around them. Then stop polling; the loop is over. The user may keep working in chat while the document sits open; treat an `edit_request` like any other user instruction, just delivered through the tab. diff --git a/skill/scripts/command-metadata.json b/skill/scripts/command-metadata.json index dad8ef2e0..4d02204f1 100644 --- a/skill/scripts/command-metadata.json +++ b/skill/scripts/command-metadata.json @@ -90,5 +90,9 @@ "typeset": { "description": "Improves typography by fixing font choices, hierarchy, sizing, weight, and readability so text feels intentional. Use when the user mentions fonts, type, readability, text hierarchy, sizing looks off, or wants more polished, intentional typography.", "argumentHint": "[target]" + }, + "design-context": { + "description": "Reopen, revise, export, or import the design interview and its design context document", + "argumentHint": "[open|edit|export|import] [bundle-file]" } } diff --git a/skill/scripts/design-context-export.mjs b/skill/scripts/design-context-export.mjs new file mode 100644 index 000000000..185b8bbce --- /dev/null +++ b/skill/scripts/design-context-export.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node +/** Write this project's design context out in two forms. + * + * node /design-context-export.mjs [--out DIR] [--no-assets] + * + * design-context.md one document a reader or another tool can follow + * design-context.bundle.json everything needed to rebuild the store elsewhere + * + * Prints one EXPORTED line per file written. Exit 1 when the project has no + * design interview to export. + */ + +import { migrate } from './design-context/store.mjs'; +import { exportDesignContext } from './design-context/portability.mjs'; + +function printHelp() { + console.log(`Usage: node design-context-export.mjs [options] + +Write the design context to a readable document and a portable bundle. + +Options: + --out DIR Where to write (default: .impeccable/design-context/exports) + --no-assets Leave supplied files and the cue image out of the bundle + --help Show this help + +Output: + EXPORTED PATH One line per file written + +See reference/design-context.md for the canonical agent flow.`); +} + +const args = process.argv.slice(2); +if (args.includes('--help') || args.includes('-h')) { + printHelp(); + process.exit(0); +} + +const readValue = (name) => { + const exact = args.find((arg) => arg.startsWith(`${name}=`)); + if (exact) return exact.slice(name.length + 1); + const at = args.indexOf(name); + return at !== -1 && args[at + 1] && !args[at + 1].startsWith('--') ? args[at + 1] : ''; +}; + +const unknown = args.find((arg) => arg.startsWith('--') + && !['--out', '--no-assets', '--help'].some((flag) => arg === flag || arg.startsWith(`${flag}=`))); +if (unknown) { + console.error(`Unknown option: ${unknown}`); + process.exit(1); +} + +await migrate(process.cwd()); + +try { + const { markdownPath, bundlePath, skipped } = await exportDesignContext(process.cwd(), { + outDir: readValue('--out') || undefined, + includeAssets: !args.includes('--no-assets'), + }); + for (const entry of skipped) { + console.error(`Skipped ${entry.path} (${entry.bytes} bytes): ${entry.reason}`); + } + console.log(`EXPORTED ${markdownPath}`); + console.log(`EXPORTED ${bundlePath}`); +} catch (error) { + console.error(error.message); + process.exit(1); +} diff --git a/skill/scripts/design-context-import.mjs b/skill/scripts/design-context-import.mjs new file mode 100644 index 000000000..51c1d379c --- /dev/null +++ b/skill/scripts/design-context-import.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node +/** Rebuild a design context in this project from a bundle another one exported. + * + * node /design-context-import.mjs + * [--design skip|write] [--force] + * + * Refuses a project that already has a design context unless --force, and + * refuses either way while an edit session is running, because the session is + * the only writer of the store while it lives. + * + * Prints IMPORTED files and DESIGN_MD carried|absent for the agent to + * branch on. Exit 1 on a bundle this release cannot read. + */ + +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { migrate, paths, pidAlive, readAnswers, readJsonSoft } from './design-context/store.mjs'; +import { importDesignContext, validateBundle } from './design-context/portability.mjs'; + +function printHelp() { + console.log(`Usage: node design-context-import.mjs [options] + +Rebuild this project's design context from an exported bundle. + +Options: + --design skip|write Write DESIGN.md when the bundle carries one and this + project has none (default: skip) + --force Replace an existing design context + --help Show this help + +Output: + IMPORTED N files + DESIGN_MD carried|absent + +See reference/design-context.md for the canonical agent flow.`); +} + +const args = process.argv.slice(2); +if (!args.length || args.includes('--help') || args.includes('-h')) { + printHelp(); + process.exit(args.length ? 0 : 1); +} + +const source = args.find((arg) => !arg.startsWith('--')); +if (!source) { + console.error('Name the bundle to import.'); + process.exit(1); +} + +const designAt = args.indexOf('--design'); +const design = designAt !== -1 && args[designAt + 1] ? args[designAt + 1] : 'skip'; +if (!['skip', 'write'].includes(design)) { + console.error('--design must be skip or write'); + process.exit(1); +} + +await migrate(process.cwd()); +const target = paths(process.cwd()); + +/* A running session holds the store: importing under it would swap the run out + from beneath the document someone is reading and the batch it may owe. */ +const session = await readJsonSoft(target.sessionJson); +if (session && pidAlive(session.pid)) { + console.error(`A design context document is open on http://127.0.0.1:${session.port}. Close it, then import.`); + process.exit(1); +} + +if (!args.includes('--force') && await readAnswers(process.cwd())) { + console.error('This project already has a design context. Re-run with --force to replace it.'); + process.exit(1); +} + +let bundle; +try { + bundle = validateBundle(JSON.parse(await readFile(path.resolve(process.cwd(), source), 'utf8'))); +} catch (error) { + console.error(error.message); + process.exit(1); +} + +const result = await importDesignContext(process.cwd(), bundle, { design }); +console.log(`IMPORTED ${result.written} files`); +console.log(`DESIGN_MD ${result.designCarried ? 'carried' : 'absent'}${result.designWritten ? ' written' : ''}`); diff --git a/skill/scripts/design-context/bindings.mjs b/skill/scripts/design-context/bindings.mjs new file mode 100644 index 000000000..326abde6d --- /dev/null +++ b/skill/scripts/design-context/bindings.mjs @@ -0,0 +1,82 @@ +/** What the design context document lets a person edit, and where it lands. + * + * Every editable field has an id the browser sends and this file resolves into + * a file and a path inside it. That is what makes applying a change a + * deterministic write rather than a search: the document names the field, not + * the text it happens to hold. + * + * `file` is the store file the value lives in. For `context`, the path is + * relative to the top-level `context` object, so `product.purpose` addresses + * `context.product.purpose` inside context.json. + * + * `downstream` names the document the agent reconciles afterwards. The value + * itself is already applied by the time the agent hears about it; what needs a + * reader is the prose around it. + */ + +export const BINDINGS = { + 'palette.primary': { file: 'answers', path: 'palette-primary', kind: 'color', downstream: 'design-md' }, + 'palette.secondary': { file: 'answers', path: 'palette-secondary', kind: 'color', downstream: 'design-md' }, + 'palette.tertiary': { file: 'answers', path: 'palette-tertiary', kind: 'color', downstream: 'design-md' }, + 'palette.neutral': { file: 'answers', path: 'palette-neutral', kind: 'color', downstream: 'design-md' }, + + 'product.purpose': { file: 'context', path: 'product.purpose', kind: 'text', maxLen: 600, downstream: 'product-md' }, + 'product.positioning.not': { file: 'context', path: 'product.positioning.not', kind: 'text', maxLen: 300, downstream: 'product-md' }, + 'product.positioning.this': { file: 'context', path: 'product.positioning.this', kind: 'text', maxLen: 300, downstream: 'product-md' }, + + 'audience.primary': { file: 'context', path: 'audience.primary', kind: 'text', maxLen: 300, downstream: 'product-md' }, + 'audience.secondary': { file: 'context', path: 'audience.secondary', kind: 'text', maxLen: 300, downstream: 'product-md' }, + 'audience.emotion': { file: 'context', path: 'audience.emotion', kind: 'text', maxLen: 300, downstream: 'product-md' }, + 'audience.leaving': { file: 'context', path: 'audience.leaving', kind: 'text', maxLen: 300, downstream: 'product-md' }, + + 'brand.personality': { file: 'context', path: 'brand.personality', kind: 'text', maxLen: 600, downstream: 'product-md' }, +}; + +const DEFAULT_MAX_LEN = 2000; +const HEX = /^#[0-9a-fA-F]{6}$/; + +export const bindingFor = (id) => (Object.hasOwn(BINDINGS, id) ? BINDINGS[id] : null); + +/** + * Turn what a contenteditable produced into something safe to write. + * + * Everything arriving here was typed into a browser, so it is treated as text + * and nothing else: control characters go, newlines collapse (every bound field + * is a single line in the document), and the length is capped where the field + * says so. A value that survives is a string; a value that cannot be one throws. + */ +export function sanitizeValue(binding, raw) { + if (binding.kind === 'color') { + const value = String(raw ?? '').trim().toUpperCase(); + if (!HEX.test(value)) throw new Error('Expected a #rrggbb color'); + return value; + } + + const text = String(raw ?? '') + /* Newlines first, because they are the one control character with a + meaning here: a pasted paragraph becomes one line rather than nothing. */ + .replace(/[\r\n\t]+/g, ' ') + .replace(/[\u0000-\u001F\u007F]/g, '') + .replace(/\s{2,}/g, ' ') + .trim(); + if (!text) throw new Error('Expected some text'); + return text.slice(0, binding.maxLen || DEFAULT_MAX_LEN); +} + +/** Read a dotted path out of a plain object, without creating anything. */ +export function readPath(root, dotted) { + return dotted.split('.').reduce((node, key) => (node && typeof node === 'object' ? node[key] : undefined), root); +} + +/** Write a dotted path into a plain object, creating the objects on the way. */ +export function writePath(root, dotted, value) { + const keys = dotted.split('.'); + const last = keys.pop(); + let node = root; + for (const key of keys) { + if (!node[key] || typeof node[key] !== 'object' || Array.isArray(node[key])) node[key] = {}; + node = node[key]; + } + node[last] = value; + return root; +} diff --git a/skill/scripts/design-context/portability.mjs b/skill/scripts/design-context/portability.mjs new file mode 100644 index 000000000..e9a095fee --- /dev/null +++ b/skill/scripts/design-context/portability.mjs @@ -0,0 +1,339 @@ +/** Taking a design context out of a project, and putting one into another. + * + * Two shapes, because they answer different questions. `design-context.md` is + * for a reader, human or otherwise: one document that says what was decided + * and why, which can be handed to another tool as the rules to follow. The + * bundle is for this toolchain: everything needed to rebuild the store + * somewhere else, including the bytes of the files the user supplied. + * + * The bundle carries the schema version, not the store. A store file's era is + * readable from its own keys, and stamping the browser's submission would mean + * rewriting what it sent. + */ + +import { readFile, mkdir, readdir, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { + paths, + readAnswers, + readContext, + readJsonSoft, + writeAnswers, + writeContext, + writeJsonAtomic, + SCHEMA_VERSION, +} from './store.mjs'; + +export const BUNDLE_KIND = 'impeccable-design-context'; +export const BUNDLE_SCHEMA = 1; + +const MAX_FILE_BYTES = 1024 * 1024; +const MAX_BUNDLE_BYTES = 20 * 1024 * 1024; + +const MIME = new Map([ + ['.svg', 'image/svg+xml'], ['.png', 'image/png'], ['.jpg', 'image/jpeg'], + ['.jpeg', 'image/jpeg'], ['.webp', 'image/webp'], ['.gif', 'image/gif'], + ['.woff2', 'font/woff2'], ['.woff', 'font/woff'], ['.ttf', 'font/ttf'], ['.otf', 'font/otf'], +]); + +/* Exactly the three places an export puts bytes, and so exactly the three an + import will write them back to. Anything else in a bundle is not ours. */ +const ALLOWED_FILE = /^(assets\/[^/]+|fonts\/[^/]+|cue\.png)$/; + +const SURFACE_LABELS = { persuade: 'Landing page', operate: 'Tool', read: 'Docs', experience: 'Portfolio' }; +const ROLES = ['primary', 'secondary', 'tertiary', 'neutral']; +const PER_SURFACE = ['color-strategy', 'boundary-style', 'corner-style', 'depth-style', 'motion-energy']; + +/* ============================================================ + Export + ============================================================ */ + +async function collectFiles(cwd, { includeAssets = true } = {}) { + const target = paths(cwd); + const files = []; + const skipped = []; + let total = 0; + + const take = async (absolute, relative) => { + let bytes; + try { + bytes = await readFile(absolute); + } catch { + return; + } + if (bytes.length > MAX_FILE_BYTES || total + bytes.length > MAX_BUNDLE_BYTES) { + skipped.push({ path: relative, bytes: bytes.length, reason: 'too large for the bundle' }); + return; + } + total += bytes.length; + files.push({ + path: relative, + mime: MIME.get(path.extname(relative).toLowerCase()) || 'application/octet-stream', + base64: bytes.toString('base64'), + }); + }; + + if (!includeAssets) return { files, skipped }; + + for (const [dir, prefix] of [[target.assetsDir, 'assets'], [target.fontsDir, 'fonts']]) { + let names = []; + try { + names = await readdir(dir); + } catch { + continue; + } + for (const name of names.sort()) await take(path.join(dir, name), `${prefix}/${name}`); + } + await take(target.cuePng, 'cue.png'); + return { files, skipped }; +} + +export async function buildBundle(cwd, { includeAssets = true, now = new Date() } = {}) { + const target = paths(cwd); + const answers = await readAnswers(cwd); + if (!answers) throw new Error('No design interview found. Run /impeccable document to create one.'); + + const stored = (await readContext(cwd)) || { schemaVersion: SCHEMA_VERSION }; + const cues = await readJsonSoft(target.cuesJson); + const source = typeof answers['palette-source'] === 'string' ? answers['palette-source'] : ''; + /* A seed or custom palette names no cue, so there is no image and no dealt + entry to carry. The hexes in the answers are the palette of record. */ + const chosenCuePalette = source && cues?.palette?.[source] ? cues.palette[source] : null; + + const { files, skipped } = await collectFiles(cwd, { includeAssets }); + let designMd = null; + try { + designMd = await readFile(path.resolve(cwd, 'DESIGN.md'), 'utf8'); + } catch { + /* Not written yet, which an import is told about rather than guessing. */ + } + + return { + schemaVersion: BUNDLE_SCHEMA, + kind: BUNDLE_KIND, + exportedAt: now.toISOString(), + product: { name: stored.context?.product?.name || '' }, + context: stored, + answers, + /* Whole, never trimmed: the questionnaire validates the manifest by its + pair count and quietly falls back to its own set at any other number. */ + fonts: await readJsonSoft(target.fontsManifestJson), + chosenCue: chosenCuePalette ? { slug: source, palette: chosenCuePalette } : null, + designMd, + files, + ...(skipped.length ? { skipped } : {}), + }; +} + +/* ============================================================ + The readable compilation + ============================================================ */ + +const line = (label, value) => (value ? `- **${label}:** ${value}\n` : ''); + +function paletteTable(answers) { + const rows = ROLES + .map((role) => [role, String(answers[`palette-${role}`] || '')]) + .filter(([, hex]) => hex); + if (!rows.length) return ''; + return `| Role | Value |\n| --- | --- |\n${rows.map(([role, hex]) => `| ${role} | \`${hex}\` |`).join('\n')}\n\n`; +} + +function perSurfaceTable(answers, surfaces) { + const rows = []; + for (const key of PER_SURFACE) { + for (const mode of surfaces) { + const value = answers[`${key}-${mode}`]; + if (value) rows.push([key, SURFACE_LABELS[mode] || mode, String(value), answers[key] === value]); + } + } + if (!rows.length) return ''; + return `| Question | Surface | Answer |\n| --- | --- | --- |\n${rows + .map(([key, label, value, leads]) => `| ${key} | ${label}${leads ? ' (leads)' : ''} | ${value} |`) + .join('\n')}\n\n`; +} + +/** One document a reader, or another tool, can follow without this toolchain. */ +export function renderMarkdown(bundle) { + const context = bundle.context?.context || {}; + const answers = bundle.answers || {}; + const name = bundle.product?.name || 'This product'; + const surfaces = [].concat(answers['surface-modes'] || []).filter(Boolean); + const out = []; + + out.push(`# Design context: ${name}\n\n`); + out.push('The decisions this product\'s design follows, and the reasoning behind them. '); + out.push('Exported from Impeccable; treat it as the source of truth for visual and product direction.\n\n'); + + const audience = context.audience || {}; + if (Object.keys(audience).length) { + out.push('## Audience\n\n'); + out.push(line('Primary', audience.primary)); + out.push(line('Secondary', audience.secondary)); + out.push(line('On arrival', audience.emotion)); + out.push(line('Leaving with', audience.leaving)); + if (audience.needs?.length) out.push(`- **Needs:** ${audience.needs.join('; ')}\n`); + if (audience.trust?.length) out.push(`- **Trust triggers:** ${audience.trust.join('; ')}\n`); + if (audience.inclusion?.length) out.push(`- **Must not exclude:** ${audience.inclusion.join('; ')}\n`); + out.push('\n'); + } + + const product = context.product || {}; + if (Object.keys(product).length) { + out.push('## Product\n\n'); + out.push(line('Purpose', product.purpose)); + out.push(line('Success', product.success)); + out.push(line('Platform', product.platform)); + out.push(line('Primary conversion', product.conversion)); + if (product.positioning?.not) out.push(`- **Not this:** ${product.positioning.not}\n`); + if (product.positioning?.this) out.push(`- **This:** ${product.positioning.this}\n`); + if (product.clarities?.length) out.push(`- **Clear first:** ${product.clarities.join('; ')}\n`); + out.push('\n'); + } + + const brand = context.brand || {}; + if (Object.keys(brand).length) { + out.push('## Brand\n\n'); + if (brand.words?.length) out.push(line('Words', brand.words.join(', '))); + out.push(line('Personality', brand.personality)); + if (brand.commitments?.length) out.push(`- **Commitments:** ${brand.commitments.join('; ')}\n`); + if (brand.voice?.length) { + out.push('\nVoice, as wording rather than adjectives:\n\n'); + for (const pair of brand.voice) { + if (pair?.say && pair?.not) out.push(`- Say: ${pair.say}\n Not: ${pair.not}\n`); + } + } + out.push('\n'); + } + + const interview = context.interview || {}; + if (interview.references?.length || interview.antiReference) { + out.push('## References\n\n'); + for (const reference of interview.references || []) { + if (typeof reference === 'string') out.push(`- ${reference}\n`); + else if (reference?.name) out.push(`- **${reference.name}**${reference.takeaway ? `: ${reference.takeaway}` : ''}\n`); + } + const anti = interview.antiReference; + if (typeof anti === 'string') out.push(`- **Anti-reference:** ${anti}\n`); + else if (anti?.name) out.push(`- **Anti-reference:** ${anti.name}${anti.why ? ` (${anti.why})` : ''}\n`); + out.push('\n'); + } + + out.push('## Decisions\n\n'); + if (surfaces.length) { + out.push(`Surfaces: ${surfaces.map((mode) => SURFACE_LABELS[mode] || mode).join(', ')}. `); + out.push('The first of these owns any answer stated once for the whole product.\n\n'); + } + out.push('### Palette\n\n'); + out.push(paletteTable(answers)); + if (bundle.chosenCue?.slug) out.push(`Sampled from the generated cue \`${bundle.chosenCue.slug}\`.\n\n`); + + out.push('### Typography\n\n'); + out.push(line('Heading', answers['font-heading'])); + out.push(line('Body', answers['font-body'])); + out.push(line('Type scale', answers['type-scale'] && `${answers['type-scale']} (${answers['type-scale-ratio']})`)); + out.push('\n'); + + if (answers['icon-pack-name']) { + out.push('### Icons\n\n'); + out.push(`- **Pack:** ${answers['icon-pack-name']}${answers['icon-pack-license'] ? ` (${answers['icon-pack-license']})` : ''}\n`); + if (answers['icon-pack-url']) out.push(`- **Source:** ${answers['icon-pack-url']}\n`); + out.push('\nEvery icon comes from this pack; do not mix sets.\n\n'); + } + + const perSurface = perSurfaceTable(answers, surfaces); + if (perSurface) { + out.push('### Per surface\n\n'); + out.push(perSurface); + } + if (answers['layout-structure']) out.push(`Composition: ${answers['layout-structure']}, one answer for the whole product.\n\n`); + + if (bundle.designMd) { + out.push('## DESIGN.md\n\n'); + out.push('The design document this context produced, verbatim.\n\n'); + out.push('\n\n'); + out.push(bundle.designMd.trim()); + out.push('\n\n\n'); + } + + return out.join(''); +} + +export async function exportDesignContext(cwd, { outDir, includeAssets = true, now } = {}) { + const bundle = await buildBundle(cwd, { includeAssets, now }); + const destination = outDir ? path.resolve(cwd, outDir) : paths(cwd).exportsDir; + await mkdir(destination, { recursive: true }); + + const markdownPath = path.join(destination, 'design-context.md'); + const bundlePath = path.join(destination, 'design-context.bundle.json'); + await writeFile(markdownPath, renderMarkdown(bundle)); + await writeJsonAtomic(bundlePath, bundle); + return { markdownPath, bundlePath, skipped: bundle.skipped || [] }; +} + +/* ============================================================ + Import + ============================================================ */ + +export function validateBundle(bundle) { + if (!bundle || typeof bundle !== 'object') throw new Error('That file is not a design context bundle'); + if (bundle.kind !== BUNDLE_KIND) throw new Error(`Expected a ${BUNDLE_KIND} bundle, found ${String(bundle.kind)}`); + if (bundle.schemaVersion !== BUNDLE_SCHEMA) { + throw new Error(`This bundle is schema version ${String(bundle.schemaVersion)}; this release reads ${BUNDLE_SCHEMA}. Update impeccable.`); + } + if (!bundle.answers || typeof bundle.answers !== 'object') throw new Error('The bundle carries no answers'); + return bundle; +} + +export async function importDesignContext(cwd, bundle, { design = 'skip' } = {}) { + validateBundle(bundle); + const target = paths(cwd); + + await writeAnswers(bundle.answers, cwd); + const context = bundle.context && typeof bundle.context === 'object' + ? bundle.context + : { schemaVersion: SCHEMA_VERSION }; + await writeContext(context, cwd); + + let written = 0; + for (const file of Array.isArray(bundle.files) ? bundle.files : []) { + const relative = String(file?.path || ''); + /* Containment is not enough on its own: a bundle could otherwise name a + store file and overwrite what was just written. Only the three places an + export puts bytes are accepted. */ + if (!ALLOWED_FILE.test(relative)) { + process.stderr.write(`Skipped ${relative || '(unnamed)'}: not a place a design context keeps files\n`); + continue; + } + const absolute = path.resolve(target.storeDir, relative); + if (path.relative(target.storeDir, absolute).startsWith('..')) continue; + await mkdir(path.dirname(absolute), { recursive: true }); + await writeFile(absolute, Buffer.from(String(file.base64 || ''), 'base64')); + written += 1; + } + + /* The questionnaire cannot run without a cue manifest: its palette screen + loads the deck and the built-in seeds together, and neither arrives if the + file is missing. An imported project gets a valid one either way, carrying + the chosen cue's dealt values when the bundle brought them. */ + if (!(await readJsonSoft(target.cuesJson))) { + await writeJsonAtomic(target.cuesJson, { + cues: [], + ...(bundle.chosenCue?.slug ? { palette: { [bundle.chosenCue.slug]: bundle.chosenCue.palette } } : { palette: {} }), + }); + } + if (bundle.fonts && !(await readJsonSoft(target.fontsManifestJson))) { + await writeJsonAtomic(target.fontsManifestJson, bundle.fonts); + } + + let designWritten = false; + if (design === 'write' && typeof bundle.designMd === 'string' && bundle.designMd.trim()) { + const designPath = path.resolve(cwd, 'DESIGN.md'); + if (!(await readFile(designPath, 'utf8').then(() => true).catch(() => false))) { + await writeFile(designPath, bundle.designMd); + designWritten = true; + } + } + + return { written, designWritten, designCarried: typeof bundle.designMd === 'string' && Boolean(bundle.designMd.trim()) }; +} diff --git a/skill/scripts/design-context/session-routes.mjs b/skill/scripts/design-context/session-routes.mjs new file mode 100644 index 000000000..dff9c9a0b --- /dev/null +++ b/skill/scripts/design-context/session-routes.mjs @@ -0,0 +1,236 @@ +/** The save flow behind the design context document. + * + * A person edits fields in the document; the edits stage in the browser and + * arrive here as one batch when they press Apply. Applying is deterministic: + * every change names a binding, the binding names a file and a path, and the + * value is written through the store. Nothing is searched for and no model is + * involved, which is what makes a save either complete or refused rather than + * approximately done. + * + * What the agent gets afterwards is the reconciliation, not the write. The + * values are already on disk by the time the batch reaches a poll; DESIGN.md + * and PRODUCT.md are the agent's to bring in line with them. + * + * browser --POST /doc/save-------> applied here, journaled, batch queued + * agent --GET /doc/poll-------> save_batch (leased) + * agent --POST /doc/reply------> acknowledged, version bumped + * browser --GET /doc/state------> version moved, so re-read and re-render + * + * The batch is journaled before it is offered and cleared only on an + * acknowledgement, so a session that dies mid-flight re-offers it on the next + * boot rather than losing the work. + */ + +import { bindingFor, readPath, sanitizeValue, writePath } from './bindings.mjs'; +import { + appendJournal, + readAnswers, + readContext, + replayJournal, + writeAnswers, + writeContext, + SCHEMA_VERSION, +} from './store.mjs'; + +const MAX_CHANGES = 100; +/* Long enough that an agent doing real prose work is never raced, short enough + that an agent that died does not hold the batch for the session's lifetime. */ +const LEASE_MS = 10 * 60_000; + +function httpError(statusCode, message) { + const error = new Error(message); + error.statusCode = statusCode; + return error; +} + +export function createSaveRoutes({ cwd = process.cwd(), onChange = () => {} } = {}) { + /* Recovered from the journal at boot: a batch the agent never acknowledged + is still owed, whoever was running when it was made. */ + const replayed = replayJournal(cwd); + let pending = replayed.pendingBatch + ? { ...replayed.pendingBatch, leaseUntil: 0 } + : null; + let counter = Number(replayed.lastSeq) || 0; + + const summary = () => (pending + ? { id: pending.id, status: pending.status, count: pending.changes.length } + : null); + + function validate(body) { + const changes = Array.isArray(body?.changes) ? body.changes : null; + if (!changes?.length) throw httpError(400, 'changes must be a non-empty array'); + if (changes.length > MAX_CHANGES) throw httpError(400, `at most ${MAX_CHANGES} changes per save`); + + return changes.map((change) => { + const binding = bindingFor(String(change?.bindingId ?? '')); + if (!binding) throw httpError(400, `Unknown field: ${String(change?.bindingId ?? '')}`); + let value; + try { + value = sanitizeValue(binding, change.to); + } catch (error) { + throw httpError(400, `${change.bindingId}: ${error.message}`); + } + return { + bindingId: String(change.bindingId), + binding, + from: typeof change.from === 'string' ? change.from : '', + to: value, + }; + }); + } + + /** One read and one write per file, so a save lands whole or not at all. */ + async function applyToStore(changes) { + const files = new Map(); + const load = async (file) => { + if (!files.has(file)) { + files.set(file, file === 'answers' + ? (await readAnswers(cwd)) || {} + : (await readContext(cwd)) || { schemaVersion: SCHEMA_VERSION }); + } + return files.get(file); + }; + + for (const change of changes) { + const document = await load(change.binding.file); + /* context.json wraps its payload, so a binding path addresses the + context object rather than the file's own root. */ + const root = change.binding.file === 'context' + ? (document.context ??= {}) + : document; + change.previous = String(readPath(root, change.binding.path) ?? ''); + writePath(root, change.binding.path, change.to); + } + + if (files.has('answers')) await writeAnswers(files.get('answers'), cwd); + if (files.has('context')) await writeContext(files.get('context'), cwd); + } + + return { + summary, + hasPending: () => Boolean(pending), + + /** POST /doc/save */ + async save(body) { + if (pending) throw httpError(409, 'A save is already applying'); + const changes = validate(body); + await applyToStore(changes); + + for (const change of changes) { + appendJournal({ + type: 'change', + bindingId: change.bindingId, + from: change.previous, + to: change.to, + }, cwd); + } + + counter += 1; + const id = `batch-${String(counter).padStart(3, '0')}`; + const recorded = changes.map(({ bindingId, previous, to, binding }) => ({ + bindingId, + from: previous, + to, + downstream: binding.downstream, + })); + appendJournal({ type: 'batch', id, status: 'pending', changes: recorded }, cwd); + pending = { id, status: 'pending', changes: recorded, leaseUntil: 0 }; + onChange(); + return { id, count: recorded.length }; + }, + + /** The event a polling agent is handed, or nothing when none is due. */ + takeBatchEvent(replyCommandFor) { + if (!pending || pending.leaseUntil > Date.now()) return null; + /* Stamped before anything awaits, so a second poll arriving in the same + tick cannot be handed the same batch. */ + pending.leaseUntil = Date.now() + LEASE_MS; + return { + type: 'save_batch', + id: pending.id, + changes: pending.changes, + downstream: pending.changes.filter((change) => change.downstream !== 'none'), + replyCommand: replyCommandFor(pending.id), + }; + }, + + /** + * POST /doc/reply for a batch. + * + * An unknown id keeps the lease and says which batch is actually owed, so + * an agent that replied to the wrong thing can correct itself rather than + * leaving the work stranded. + */ + async reply(body) { + if (!pending) throw httpError(404, 'No save is waiting for a reply'); + if (body.id !== pending.id) { + throw httpError(404, `Unknown save ${String(body.id)}; the one waiting is ${pending.id}`); + } + if (!['done', 'error', 'retry'].includes(body.status)) { + throw httpError(400, 'status must be done, error, or retry'); + } + + if (body.status === 'retry') { + pending.leaseUntil = 0; + onChange(); + return { ok: true, status: 'pending' }; + } + + /* The agent's own follow-on writes ride here rather than going to the + store directly, so this process stays the only writer while it runs. */ + const applied = await applyAgentUpdates(body, cwd); + appendJournal({ type: 'batch', id: pending.id, status: body.status, message: String(body.message || '') }, cwd); + pending = null; + onChange(); + return { ok: true, status: body.status, applied }; + }, + + /** Journaled so the tab re-reads on a font or freeform request too. */ + noteRequest(id, status) { + appendJournal({ type: 'request', id, status }, cwd); + }, + }; +} + +/** + * Key-value updates an agent attaches to its reply. + * + * Answers keys are written as given, since the questionnaire's own vocabulary + * is wider than the bound fields; context values go through their binding when + * one exists, so the same rules apply to both writers. + */ +async function applyAgentUpdates(body, cwd) { + const applied = { answers: 0, context: 0 }; + + if (body.answers && typeof body.answers === 'object' && !Array.isArray(body.answers)) { + const answers = (await readAnswers(cwd)) || {}; + for (const [key, value] of Object.entries(body.answers)) { + if (typeof value !== 'string' && !Array.isArray(value)) continue; + answers[key] = value; + applied.answers += 1; + } + if (applied.answers) await writeAnswers(answers, cwd); + } + + if (body.context && typeof body.context === 'object' && !Array.isArray(body.context)) { + const stored = (await readContext(cwd)) || { schemaVersion: SCHEMA_VERSION }; + const root = (stored.context ??= {}); + for (const [dotted, value] of Object.entries(body.context)) { + if (typeof value !== 'string') continue; + const binding = bindingFor(dotted); + let next = value; + if (binding) { + try { + next = sanitizeValue(binding, value); + } catch { + continue; + } + } + writePath(root, binding ? binding.path : dotted, next); + applied.context += 1; + } + if (applied.context) await writeContext(stored, cwd); + } + + return applied; +} diff --git a/skill/scripts/design-context/store.mjs b/skill/scripts/design-context/store.mjs new file mode 100644 index 000000000..3bd975238 --- /dev/null +++ b/skill/scripts/design-context/store.mjs @@ -0,0 +1,241 @@ +/** The design-context store: the one place that knows where design context lives. + * + * Layout, under the project root: + * + * .impeccable/design-context/ + * context.json { schemaVersion, modes, context } the chat half of the interview + * answers.json the questionnaire submission, flat FormData shape + * assets/ brand files the user supplied + * fonts/ font faces the user uploaded + * cue.png the chosen hero, copied at submit so the document stands alone + * runtime/ session.json, journal.jsonl, draft.json (gitignored) + * exports/ design-context.md, design-context.bundle.json (gitignored) + * + * Two rules hold this together. Every write goes through writeJsonAtomic, so a + * reader never sees a torn file. Every read comes off disk, so no process ever + * answers from a copy the file has moved past. + * + * Zero dependencies beyond node: builtins, like every other picker script. + */ + +import fs from 'node:fs'; +import { readFile, mkdir, rename, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +export const STORE_DIR = '.impeccable/design-context'; +export const WORKSPACE_DIR = '.impeccable/visual-cues'; +/* The shape of context.json. Bump only when the shape changes, never for a release. */ +export const SCHEMA_VERSION = 1; + +const LEGACY_DIR = '.impeccable/design-interview'; +const LEGACY_FONTS_PREFIX = `${LEGACY_DIR}/fonts/`; + +export function paths(cwd = process.cwd()) { + const store = path.resolve(cwd, STORE_DIR); + const runtime = path.join(store, 'runtime'); + return { + storeDir: store, + contextJson: path.join(store, 'context.json'), + answersJson: path.join(store, 'answers.json'), + assetsDir: path.join(store, 'assets'), + fontsDir: path.join(store, 'fonts'), + cuePng: path.join(store, 'cue.png'), + runtimeDir: runtime, + sessionJson: path.join(runtime, 'session.json'), + journalJsonl: path.join(runtime, 'journal.jsonl'), + draftJson: path.join(runtime, 'draft.json'), + exportsDir: path.join(store, 'exports'), + workspaceDir: path.resolve(cwd, WORKSPACE_DIR), + cuesJson: path.resolve(cwd, WORKSPACE_DIR, 'cues.json'), + fontsManifestJson: path.resolve(cwd, WORKSPACE_DIR, 'fonts.json'), + }; +} + +/** The project-relative path an uploaded font is reported by, and stored under. */ +export function fontRelativePath(name) { + return path.join(STORE_DIR, 'fonts', name); +} + +export async function writeJsonAtomic(filePath, value) { + await mkdir(path.dirname(filePath), { recursive: true }); + const temporary = `${filePath}.tmp`; + await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`); + await rename(temporary, filePath); +} + +export async function readJsonSoft(filePath) { + try { + const parsed = JSON.parse(await readFile(filePath, 'utf8')); + return parsed && typeof parsed === 'object' ? parsed : null; + } catch { + return null; + } +} + +export const readContext = (cwd = process.cwd()) => readJsonSoft(paths(cwd).contextJson); +export const writeContext = (value, cwd = process.cwd()) => writeJsonAtomic(paths(cwd).contextJson, value); +export const readAnswers = (cwd = process.cwd()) => readJsonSoft(paths(cwd).answersJson); +export const writeAnswers = (value, cwd = process.cwd()) => writeJsonAtomic(paths(cwd).answersJson, value); +export const readDraft = (cwd = process.cwd()) => readJsonSoft(paths(cwd).draftJson); +export const writeDraft = (value, cwd = process.cwd()) => writeJsonAtomic(paths(cwd).draftJson, value); +export const clearDraft = (cwd = process.cwd()) => rm(paths(cwd).draftJson, { force: true }).catch(() => {}); + +/* ============================================================ + The journal: append-only, replayed on every read. + ============================================================ */ + +/** Append one event, stamped with the next seq and a timestamp. Returns the seq. */ +export function appendJournal(event, cwd = process.cwd()) { + const { runtimeDir, journalJsonl } = paths(cwd); + const seq = replayJournal(cwd).lastSeq + 1; + fs.mkdirSync(runtimeDir, { recursive: true }); + fs.appendFileSync(journalJsonl, `${JSON.stringify({ seq, ts: new Date().toISOString(), ...event })}\n`); + return seq; +} + +/** + * Fold the journal into the state a booting session needs. + * + * Lines the fold cannot use are collected rather than thrown: a legacy + * doc-edits.jsonl record carries { at, type: 'color' } and no seq, and a torn + * final line is possible after a hard kill. Neither can move lastSeq or + * resurrect a batch, so both are diagnostics, not failures. + */ +export function replayJournal(cwd = process.cwd()) { + const { journalJsonl } = paths(cwd); + const state = { lastSeq: 0, pendingBatch: null, entries: [], diagnostics: [] }; + + let raw; + try { + raw = fs.readFileSync(journalJsonl, 'utf8'); + } catch { + return state; + } + + for (const line of raw.split('\n')) { + if (!line.trim()) continue; + let entry; + try { + entry = JSON.parse(line); + } catch { + state.diagnostics.push({ reason: 'unparseable', line: line.slice(0, 200) }); + continue; + } + if (!entry || typeof entry !== 'object' || !Number.isInteger(entry.seq)) { + state.diagnostics.push({ reason: 'legacy-or-unsequenced', type: entry?.type || null }); + continue; + } + state.entries.push(entry); + if (entry.seq > state.lastSeq) state.lastSeq = entry.seq; + if (entry.type === 'batch') { + state.pendingBatch = entry.status === 'pending' ? entry : null; + } + } + return state; +} + +/* ============================================================ + Migration from the pre-store layout. + ============================================================ */ + +export function pidAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + /* EPERM means the process exists and is not ours to signal. */ + return error.code === 'EPERM'; + } +} + +async function moveFile(from, to) { + if (fs.existsSync(to) || !fs.existsSync(from)) return false; + await mkdir(path.dirname(to), { recursive: true }); + await rename(from, to); + return true; +} + +/* Directories move child by child: renaming onto an existing directory fails, + and a run interrupted halfway leaves a destination that already exists. */ +async function moveDirContents(fromDir, toDir) { + if (!fs.existsSync(fromDir)) return; + await mkdir(toDir, { recursive: true }); + for (const name of fs.readdirSync(fromDir)) { + await moveFile(path.join(fromDir, name), path.join(toDir, name)); + } + try { + if (fs.readdirSync(fromDir).length === 0) fs.rmdirSync(fromDir); + } catch { + /* Something arrived between the read and the remove; leaving it is safe. */ + } +} + +/** Uploaded-face paths were recorded as strings inside the answers themselves. */ +function rewriteFontSources(answers) { + if (!answers || typeof answers !== 'object') return null; + let touched = false; + for (const [key, value] of Object.entries(answers)) { + if (typeof value !== 'string' || !value.includes(LEGACY_FONTS_PREFIX)) continue; + answers[key] = value.split(LEGACY_FONTS_PREFIX).join(`${STORE_DIR}/fonts/`); + touched = true; + } + return touched ? answers : null; +} + +/** + * Bring a pre-store project onto the current layout. Idempotent and silent: + * a project that is already current, or was never interviewed, does nothing. + * + * A live session of the old shape holds the old paths in its own constants, so + * migrating under it would strand its writes. That case defers to the next boot. + */ +export async function migrate(cwd = process.cwd()) { + const legacyDir = path.resolve(cwd, LEGACY_DIR); + if (!fs.existsSync(legacyDir)) { + await migrateContextFromCues(cwd); + return { migrated: false, deferred: false }; + } + + const legacySession = path.join(legacyDir, 'doc-session.json'); + const session = await readJsonSoft(legacySession); + if (session && pidAlive(session.pid)) return { migrated: false, deferred: true }; + + const target = paths(cwd); + await moveFile(path.join(legacyDir, 'answers.json'), target.answersJson); + await moveFile(path.join(legacyDir, 'doc-edits.jsonl'), target.journalJsonl); + await moveDirContents(path.join(legacyDir, 'assets'), target.assetsDir); + await moveDirContents(path.join(legacyDir, 'fonts'), target.fontsDir); + + const answers = await readJsonSoft(target.answersJson); + const rewritten = rewriteFontSources(answers); + if (rewritten) await writeJsonAtomic(target.answersJson, rewritten); + + await rm(legacySession, { force: true }).catch(() => {}); + try { + if (fs.readdirSync(legacyDir).length === 0) fs.rmdirSync(legacyDir); + } catch { + /* Files the migration does not own stay where they are. */ + } + + await migrateContextFromCues(cwd); + return { migrated: true, deferred: false }; +} + +/* The chat half of the interview used to ride inside the cue manifest. It is + not a generation artifact, so it moves to the store; cues.json keeps its + cues and palette and is left untouched. */ +async function migrateContextFromCues(cwd) { + const target = paths(cwd); + if (fs.existsSync(target.contextJson)) return; + const cues = await readJsonSoft(target.cuesJson); + if (!cues) return; + const hasModes = Array.isArray(cues.modes); + const hasContext = cues.context && typeof cues.context === 'object'; + if (!hasModes && !hasContext) return; + await writeJsonAtomic(target.contextJson, { + schemaVersion: SCHEMA_VERSION, + ...(hasModes ? { modes: cues.modes } : {}), + ...(hasContext ? { context: cues.context } : {}), + }); +} diff --git a/skill/scripts/picker-doc-poll.mjs b/skill/scripts/picker-doc-poll.mjs index e880af3ba..fb0813864 100644 --- a/skill/scripts/picker-doc-poll.mjs +++ b/skill/scripts/picker-doc-poll.mjs @@ -8,24 +8,32 @@ * node picker-doc-poll.mjs # block, print one event * node picker-doc-poll.mjs --timeout=600000 # total budget in ms * node picker-doc-poll.mjs --reply [message] + * node picker-doc-poll.mjs --reply done "msg" --answers '{"key":"value"}' * * Events printed: {"type":"edit_request","id","kind","prompt","category", - * "payload"} for work, {"type":"timeout"} when the budget runs out (poll - * again), {"type":"exit"} when the session ended (stop polling). + * "payload"} for work a person asked for in words, + * {"type":"save_batch","id","changes","downstream","replyCommand"} for edits + * already applied to the store and owed a prose pass in DESIGN.md or + * PRODUCT.md, {"type":"timeout"} when the budget runs out (poll again), and + * {"type":"exit"} when the session ended (stop polling). * * Reply statuses: done (change applied; message shown to the user in the * document), error (could not apply; message explains), retry (release the * request back to pending). * - * Session discovery: .impeccable/design-interview/doc-session.json, written + * --answers and --context attach values for the session to write. The session + * is the only writer of the store while it runs, so a value the agent settles + * travels here rather than being written to those files directly. + * + * Session discovery: .impeccable/design-context/runtime/session.json, written * by the session process and removed when it exits; a missing file prints * {"type":"exit"} so a finished session never hangs the loop. */ import { readFile } from 'node:fs/promises'; -import path from 'node:path'; +import { paths } from './design-context/store.mjs'; -const sessionPath = path.resolve(process.cwd(), '.impeccable/design-interview/doc-session.json'); +const sessionPath = paths(process.cwd()).sessionJson; /* Sliced under undici's 300s header timeout, same as live-poll. */ const PER_REQUEST_MS = 270_000; const DEFAULT_TOTAL_MS = 600_000; @@ -55,17 +63,47 @@ if (!info) { } const base = `http://127.0.0.1:${info.port}`; +const VALUE_FLAGS = new Set(['--answers', '--context', '--timeout']); + +/* The message is whatever positional words are left, so a flag and the value + that belongs to it both have to come out first, or an attached JSON payload + would be read back to the user as their confirmation line. */ +function positionalAfter(marker) { + const words = []; + for (let index = args.indexOf(marker) + 1; index < args.length; index += 1) { + const arg = args[index]; + if (arg.startsWith('--')) { + if (VALUE_FLAGS.has(arg)) index += 1; + continue; + } + words.push(arg); + } + return words; +} + if (args.includes('--reply')) { - const at = args.indexOf('--reply'); - const [id, status, ...rest] = args.slice(at + 1).filter((arg) => !arg.startsWith('--')); + const [id, status, ...rest] = positionalAfter('--reply'); if (!id || !status) { - console.error('usage: picker-doc-poll.mjs --reply [message]'); + console.error('usage: picker-doc-poll.mjs --reply [message] [--answers JSON] [--context JSON]'); process.exit(1); } + /* Values the agent settled while doing the work, handed to the session to + write. Bad JSON is a mistake worth stopping for rather than dropping. */ + const attached = {}; + for (const flag of ['answers', 'context']) { + const raw = readFlag(`--${flag}`, ''); + if (!raw) continue; + try { + attached[flag] = JSON.parse(raw); + } catch { + console.error(`--${flag} must be a JSON object`); + process.exit(1); + } + } const response = await fetch(`${base}/doc/reply`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token: info.token, id, status, message: rest.join(' ') }), + body: JSON.stringify({ token: info.token, id, status, message: rest.join(' '), ...attached }), }).catch(() => null); if (!response?.ok) { console.error(`Reply failed: ${response ? response.status : 'session unreachable'}`); diff --git a/skill/scripts/picker-doc-session.mjs b/skill/scripts/picker-doc-session.mjs index 25b1a26da..3ecc8c6f1 100644 --- a/skill/scripts/picker-doc-session.mjs +++ b/skill/scripts/picker-doc-session.mjs @@ -7,20 +7,28 @@ * own pre-scanned port with CORS open to the picker origin, and it mediates * three parties the way the live server does, scaled down to polling: * - * browser --POST /doc/edit-----------> applied here (simple edits) + * browser --POST /doc/save----------> applied to the store, batch queued * browser --POST /doc/request-------> queue --GET /doc/poll--> agent * agent --POST /doc/reply---------> queue status + version bump - * browser --GET /doc/state (poll)--> { version, requests } -> re-render + * browser --GET /doc/state (poll)--> { version, requests, batch } -> re-read * - * Simple edits (a palette color) are deterministic: this process rewrites - * answers.json and swaps the value in DESIGN.md itself, no model involved. - * Anything needing judgment queues for the agent, which long-polls through + * Edits made in the document stage in the browser and arrive here as one batch. + * Applying them is deterministic and belongs to this process: each change names + * a field, the field names a place in the store, and the value is written + * there. What reaches the agent afterwards is the reconciliation the store + * cannot do for itself, the prose in DESIGN.md and PRODUCT.md that describes + * those values. Anything needing judgment up front, a font change or a freeform + * ask, queues for the agent the same way, and it long-polls through * picker-doc-poll.mjs exactly like live mode's live-poll.mjs. * - * Session discovery for the agent CLI: .impeccable/design-interview/ - * doc-session.json { pid, port, token }. Removed on exit. Every applied - * simple edit is journaled to doc-edits.jsonl in the same directory so the - * agent can reconcile prose (a renamed color's description) at session end. + * This process is the only writer of the store while it runs; the agent's own + * follow-on values ride in on its reply. That is what keeps a save and an + * agent working at the same time from overwriting each other. + * + * Session discovery for the agent CLI: .impeccable/design-context/runtime/ + * session.json { pid, port, token }. Removed on exit. Every applied change is + * journaled to runtime/journal.jsonl beside it, so a session that dies with a + * batch outstanding re-offers it and the agent can reconcile prose at the end. * * Usage (spawned by picker-server.mjs, not by hand): * node picker-doc-session.mjs --port 8501 --timeout 60 @@ -30,14 +38,15 @@ import http from 'node:http'; import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; +import { fontRelativePath, migrate, paths, readJsonSoft, writeJsonAtomic } from './design-context/store.mjs'; +import { createSaveRoutes } from './design-context/session-routes.mjs'; -const interviewDir = path.resolve(process.cwd(), '.impeccable/design-interview'); -const answersPath = path.join(interviewDir, 'answers.json'); -const sessionPath = path.join(interviewDir, 'doc-session.json'); -const ledgerPath = path.join(interviewDir, 'doc-edits.jsonl'); -const fontsDir = path.join(interviewDir, 'fonts'); -const brandAssetsDir = path.join(interviewDir, 'assets'); -const designPath = path.resolve(process.cwd(), 'DESIGN.md'); +const store = paths(process.cwd()); +const answersPath = store.answersJson; +const contextPath = store.contextJson; +const sessionPath = store.sessionJson; +const fontsDir = store.fontsDir; +const brandAssetsDir = store.assetsDir; const MAX_BODY_BYTES = 1024 * 1024; const FONT_EXTENSIONS = new Set(['.woff2', '.woff', '.ttf', '.otf']); @@ -49,7 +58,6 @@ const BRAND_ASSET_MIME = new Map([ ['.webp', 'image/webp'], ['.gif', 'image/gif'], ]); -const ROLES = new Set(['primary', 'secondary', 'tertiary', 'neutral']); const REQUEST_KINDS = new Set(['font', 'freeform']); /* Long polls are sliced under common proxy/undici header timeouts, the same 270s ceiling live-poll uses. */ @@ -78,6 +86,10 @@ if (!port || !token) { let version = 1; let requestSeq = 0; const requests = []; +/* The save flow lives in its own module; this shell keeps the server, the + timers, and the token. Every applied save bumps the same version the tab + polls, so the document re-reads itself without a second signal. */ +const saves = createSaveRoutes({ onChange: () => { bumpVersion(); wakeParkedPolls(); } }); let lastBrowserSeen = Date.now(); let adopted = false; const parkedPolls = []; @@ -123,51 +135,8 @@ const summarize = (entry) => ({ message: entry.message || '', }); -async function appendLedger(entry) { - await mkdir(interviewDir, { recursive: true }); - await writeFile(ledgerPath, `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`, { flag: 'a' }); -} - /* ============================================================ - Simple edits — deterministic, applied here. - ============================================================ */ - -async function applyColorEdit({ role, value }) { - if (!ROLES.has(role)) throw httpError(400, 'Unknown palette role'); - if (!/^#[0-9a-fA-F]{6}$/.test(value || '')) throw httpError(400, 'Value must be a #rrggbb hex color'); - const hex = value.toUpperCase(); - - const answers = JSON.parse(await readFile(answersPath, 'utf8')); - const previous = String(answers[`palette-${role}`] || '').toUpperCase(); - answers[`palette-${role}`] = hex; - await writeFile(answersPath, `${JSON.stringify(answers, null, 2)}\n`); - - /* DESIGN.md may not exist yet (the agent writes the seed while the user - reads the document); the answers file is the source it will seed from, - so an early edit is already carried. */ - let designTouched = false; - if (previous && previous !== hex) { - try { - const source = await readFile(designPath, 'utf8'); - /* A hex value is regex-safe: a literal # and hex digits. */ - const swapped = source.replace(new RegExp(previous, 'gi'), hex); - if (swapped !== source) { - await writeFile(designPath, swapped); - designTouched = true; - } - } catch { - /* No DESIGN.md yet. */ - } - } - - await appendLedger({ type: 'color', role, from: previous, to: hex, designTouched }); - return { role, from: previous, to: hex, designTouched }; -} - -const SIMPLE_EDITS = { color: applyColorEdit }; - -/* ============================================================ - Complex edits — queued for the agent. + Requests that need judgment, queued for the agent. ============================================================ */ function wakeParkedPolls() { @@ -198,6 +167,14 @@ async function handleDocPoll(response, query) { sendJson(response, 200, { type: 'edit_request', ...summarize(entry), payload: entry.payload }); return; } + /* The values are already in the store; what is handed over is the prose + still owed to DESIGN.md and PRODUCT.md. The reply command travels with + the event so the instruction cannot drift from the contract. */ + const batch = saves.takeBatchEvent((id) => `node picker-doc-poll.mjs --reply ${id} done "One line the user sees in the tab"`); + if (batch) { + sendJson(response, 200, batch); + return; + } const remaining = deadline - Date.now(); if (remaining <= 0) { sendJson(response, 200, { type: 'timeout' }); @@ -256,7 +233,7 @@ async function handleRequest(request, response) { } await mkdir(fontsDir, { recursive: true }); await writeFile(path.join(fontsDir, name), Buffer.concat(chunks)); - sendJson(response, 200, { ok: true, path: path.join('.impeccable/design-interview/fonts', name) }); + sendJson(response, 200, { ok: true, path: fontRelativePath(name) }); return; } @@ -305,6 +282,7 @@ async function handleRequest(request, response) { version, requests: requests.map(summarize), agentWaiting: parkedPolls.length > 0, + batch: saves.summary(), }); return; } @@ -316,6 +294,24 @@ async function handleRequest(request, response) { return; } + /* The chat half of the run, read fresh so an agent's rewrite reaches the tab. */ + if (request.method === 'GET' && requestPath === '/doc/context') { + if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token'); + let stored = null; + try { + stored = JSON.parse(await readFile(contextPath, 'utf8')); + } catch { + /* A run whose chat half was never recorded still has a document. */ + } + sendJson(response, 200, { + ok: true, + version, + modes: stored?.modes ?? null, + context: stored?.context ?? null, + }); + return; + } + if (request.method === 'GET' && requestPath === '/doc/poll') { if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token'); await handleDocPoll(response, url.searchParams); @@ -326,12 +322,11 @@ async function handleRequest(request, response) { const body = await readJsonBody(request); if (body.token !== token) throw httpError(403, 'Bad token'); - if (requestPath === '/doc/edit') { - const apply = SIMPLE_EDITS[body.kind]; - if (!apply) throw httpError(400, `No simple edit named ${String(body.kind)}; complex changes go through /doc/request`); - const applied = await apply(body); - bumpVersion(); - sendJson(response, 200, { ok: true, version, applied }); + /* Everything staged in the document arrives at once. Applying is this + process's job; reconciling the prose around it is the agent's. */ + if (requestPath === '/doc/save') { + const applied = await saves.save(body); + sendJson(response, 200, { ok: true, version, ...applied }); return; } @@ -357,11 +352,18 @@ async function handleRequest(request, response) { } if (requestPath === '/doc/reply') { + // A save and a request are both replied to here, told apart by the id. + if (saves.hasPending() && String(body.id || '').startsWith('batch-')) { + const result = await saves.reply(body); + sendJson(response, 200, { ok: true, version, ...result }); + return; + } const entry = requests.find((item) => item.id === body.id); if (!entry) throw httpError(404, 'Unknown request id'); if (!['done', 'error', 'retry'].includes(body.status)) throw httpError(400, 'status must be done, error, or retry'); entry.status = body.status === 'retry' ? 'pending' : body.status; entry.message = String(body.message || ''); + saves.noteRequest(entry.id, entry.status); bumpVersion(); if (entry.status === 'pending') wakeParkedPolls(); sendJson(response, 200, { ok: true, version }); @@ -372,8 +374,8 @@ async function handleRequest(request, response) { } server.listen(port, '127.0.0.1', async () => { - await mkdir(interviewDir, { recursive: true }); - await writeFile(sessionPath, `${JSON.stringify({ pid: process.pid, port, token }, null, 2)}\n`); + await migrate(process.cwd()); + await writeJsonAtomic(sessionPath, { pid: process.pid, port, token }); }); server.on('error', () => process.exit(1)); @@ -390,7 +392,13 @@ async function shutdown() { clearInterval(reaper); clearTimeout(ceiling); wakeParkedPolls(); - await rm(sessionPath, { force: true }).catch(() => {}); + /* Only if it is still ours. A session that outlived its tab can be shutting + down at the moment a newer one writes the same path, and taking the file + with it would leave the live session undiscoverable. */ + const recorded = await readJsonSoft(sessionPath); + if (!recorded || recorded.pid === process.pid) { + await rm(sessionPath, { force: true }).catch(() => {}); + } server.close(() => process.exit(0)); server.closeAllConnections?.(); setTimeout(() => process.exit(0), 1_000).unref(); diff --git a/skill/scripts/picker-server.mjs b/skill/scripts/picker-server.mjs index fd73c8714..2ea628f72 100644 --- a/skill/scripts/picker-server.mjs +++ b/skill/scripts/picker-server.mjs @@ -8,16 +8,29 @@ import http from 'node:http'; import { spawn } from 'node:child_process'; import { randomUUID } from 'node:crypto'; -import { readFile, mkdir, stat, writeFile } from 'node:fs/promises'; +import { copyFile, readFile, mkdir, rm, stat, writeFile } from 'node:fs/promises'; import net from 'node:net'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { SEEDS } from './palette.mjs'; +import { + clearDraft, + fontRelativePath, + migrate, + paths, + pidAlive, + readAnswers, + readDraft, + readJsonSoft, + writeDraft, + writeJsonAtomic, +} from './design-context/store.mjs'; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const pickerDir = path.join(scriptDir, 'picker'); -const answersPath = path.resolve(process.cwd(), '.impeccable/design-interview/answers.json'); -const fontsDir = path.resolve(process.cwd(), '.impeccable/design-interview/fonts'); -const brandAssetsDir = path.resolve(process.cwd(), '.impeccable/design-interview/assets'); +const store = paths(process.cwd()); +const answersPath = store.answersJson; +const fontsDir = store.fontsDir; +const brandAssetsDir = store.assetsDir; const MAX_BODY_BYTES = 1024 * 1024; const FONT_EXTENSIONS = new Set(['.woff2', '.woff', '.ttf', '.otf']); const BRAND_ASSET_EXTENSIONS = ['.svg', '.png', '.jpg', '.jpeg', '.webp', '.gif']; @@ -46,12 +59,17 @@ Options: --port PORT Scan for an open port from PORT (default: 8500) --cues-dir PATH Visual cues directory (default: .impeccable/visual-cues) --timeout MINUTES Exit 2 if nothing submits (default: 60) + --fresh Start blank, ignoring any previous answers or draft --help Show this help Output: PICKER_URL URL Printed when the server is ready ANSWERS PATH Printed after answers.json is written +Also served, for the design context document the questionnaire reveals: + /context.json The chat half of the interview, from the design-context store + /cue.png The chosen cue image, copied into the store at submit + See reference/visual-cues.md for the canonical agent flow.`); } @@ -65,11 +83,21 @@ function readOption(args, index) { return { value: args[index + 1], next: index + 1 }; } function parseArgs(args) { - const options = { port: 8500, cuesDir: path.resolve(process.cwd(), '.impeccable/visual-cues'), timeoutMinutes: 60 }; + const options = { + port: 8500, + cuesDir: path.resolve(process.cwd(), '.impeccable/visual-cues'), + timeoutMinutes: 60, + fresh: false, + doc: false, + }; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === '--help' || arg === '-h') return { help: true }; + /* Value-less flags are read before the guard below, which would reject + them, and before readOption, which demands a value for every flag. */ + if (arg === '--fresh') { options.fresh = true; continue; } + if (arg === '--doc') { options.doc = true; continue; } if (!arg.startsWith('--port') && !arg.startsWith('--cues-dir') && !arg.startsWith('--timeout')) throw new Error(`Unknown option: ${arg}`); const { value, next } = readOption(args, index); @@ -182,9 +210,17 @@ if (options.help) { process.exit(0); } +/* A project interviewed by an older release keeps its answers, assets, and + uploaded faces under the pre-store layout. Bring them across before serving. */ +await migrate(process.cwd()); + const port = await findOpenPort(options.port); let completed = false; let timeout; +let docWatch; +/* In document mode the run already happened: this process serves the document + built from it, and the edit session is what it waits on. */ +let docSession = null; const server = http.createServer((request, response) => { void handleRequest(request, response).catch((error) => { @@ -201,13 +237,21 @@ async function handleRequest(request, response) { } if (request.method === 'POST' && requestPath === '/submit') { + /* Document mode is showing a run that already finished; there is nothing + left to submit, and writing one would overwrite the answers it renders. */ + if (options.doc) { + sendJson(response, 409, { error: 'The document is open; there is nothing to submit' }); + return; + } if (completed) { sendJson(response, 409, { error: 'Submission already received' }); return; } const answers = await readJsonBody(request); - await mkdir(path.dirname(answersPath), { recursive: true }); - await writeFile(answersPath, `${JSON.stringify(answers, null, 2)}\n`); + await writeJsonAtomic(answersPath, answers); + await copyChosenCue(answers); + /* The run is on the record now, so the half-finished copy of it goes. */ + await clearDraft(); completed = true; clearTimeout(timeout); @@ -215,7 +259,7 @@ async function handleRequest(request, response) { detached sibling: it owns the edit endpoints on its own port, so this process can still exit as the agent's completion signal. The tab learns where to reach it from this response; the agent learns from - doc-session.json, which the sibling writes at boot. */ + runtime/session.json, which the sibling writes at boot. */ const doc = await spawnDocSession(); response.once('finish', () => { console.log(`ANSWERS ${answersPath}`); @@ -226,6 +270,19 @@ async function handleRequest(request, response) { return; } + /* The questionnaire posts its whole form after every screen change, so a run + the visitor walks away from resumes where they left it instead of starting + over. The submission supersedes the draft and removes it. */ + if (request.method === 'POST' && requestPath === '/autosave') { + if (completed) { + sendJson(response, 409, { error: 'Submission already received' }); + return; + } + await writeDraft(await readJsonBody(request)); + sendJson(response, 200, { ok: true }); + return; + } + // Uploaded faces are stored, not parsed: the questionnaire defers validation // to the end, so the server only needs to put the bytes where the agent can // reach them and hand back the path the answers will carry. @@ -244,7 +301,7 @@ async function handleRequest(request, response) { } await mkdir(fontsDir, { recursive: true }); await writeFile(path.join(fontsDir, name), Buffer.concat(chunks)); - sendJson(response, 200, { path: path.join('.impeccable/design-interview/fonts', name) }); + sendJson(response, 200, { path: fontRelativePath(name) }); return; } @@ -252,10 +309,40 @@ async function handleRequest(request, response) { sendJson(response, 405, { error: 'Method not allowed' }); return; } + /* One fetch tells the client how to start: which surface it is serving, and + the answers to restore, if any. Never cached, because the draft moves + while the questionnaire is open and a stale copy would restore a run the + visitor has already moved past. */ + if (requestPath === '/boot.json') { + const { prior, priorSource } = await resolvePrior(); + response.setHeader('Cache-Control', 'no-store'); + sendJson(response, 200, { + mode: options.doc ? 'doc' : 'questionnaire', + prior, + priorSource, + /* Present only where the document is live for edits. Absent leaves it + rendering read-only, which is the honest state when no session took. */ + doc: docSession ? { base: `http://127.0.0.1:${docSession.port}`, token: docSession.token } : null, + }); + return; + } if (requestPath === '/cues.json') { await serveFile(response, options.cuesDir, 'cues.json', ['.json']); return; } + /* The chat half of the interview, and the chosen cue, both live in the store + rather than the generation workspace. The document reads them after this + process exits, so they carry the same cache rule the cue images do. */ + if (requestPath === '/context.json') { + response.setHeader('Cache-Control', 'max-age=86400'); + await serveFile(response, store.storeDir, 'context.json', ['.json']); + return; + } + if (requestPath === '/cue.png') { + response.setHeader('Cache-Control', 'max-age=86400'); + await serveFile(response, store.storeDir, 'cue.png', ['.png']); + return; + } if (requestPath === '/fonts.json') { await serveFile(response, options.cuesDir, 'fonts.json', ['.json']); return; @@ -309,7 +396,35 @@ async function handleRequest(request, response) { await serveFile(response, pickerDir, assetPath); } +/* An unfinished run outranks a finished one: the draft is where the visitor + actually is, the submission is where they last were. --fresh declines both. */ +async function resolvePrior() { + if (options.fresh) return { prior: null, priorSource: null }; + const draft = await readDraft(); + if (draft) return { prior: draft, priorSource: 'draft' }; + const answers = await readAnswers(); + if (answers) return { prior: answers, priorSource: 'submitted' }; + return { prior: null, priorSource: null }; +} + +/* The document renders the chosen cue long after this process is gone, and a + later reopen has no generation workspace to reach into, so the one picked + hero joins the store. A seed or custom palette names no cue: nothing to copy. */ +async function copyChosenCue(answers) { + const slug = typeof answers['palette-source'] === 'string' ? answers['palette-source'] : ''; + if (!slug || slug !== path.basename(slug)) return; + try { + await mkdir(path.dirname(store.cuePng), { recursive: true }); + await copyFile(path.join(options.cuesDir, `${slug}.png`), store.cuePng); + } catch { + /* Not a cue palette, or the workspace is gone. */ + } +} + async function spawnDocSession() { + /* The read-only path is otherwise unreachable from a test, and a document + that renders without an edit session is a real state worth exercising. */ + if (process.env.IMPECCABLE_DOC_SESSION_DISABLE === '1') return null; try { const docPort = await findOpenPort(port + 1); const docToken = randomUUID(); @@ -331,6 +446,92 @@ async function spawnDocSession() { } } +/* ============================================================ + Document mode: serving the design context document on its own. + ============================================================ */ + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Does the recorded session answer for itself? Also marks it adopted. */ +async function probeSession(record) { + if (!record?.port || !record?.token) return false; + try { + const response = await fetch( + `http://127.0.0.1:${record.port}/doc/state?token=${encodeURIComponent(record.token)}`, + { signal: AbortSignal.timeout(2000) }, + ); + return response.ok; + } catch { + return false; + } +} + +async function waitForSessionRecord(deadlineMs) { + const until = Date.now() + deadlineMs; + for (;;) { + const record = await readJsonSoft(store.sessionJson); + if (record?.port) return record; + if (Date.now() > until) return null; + await sleep(150); + } +} + +/** + * One live session per project. + * + * A session that answers is rejoined, so reopening a tab closed a minute ago + * lands back in the session the agent is already polling, and the probe itself + * is what keeps it from being reaped. A dead record is cleared, and a recorded + * process that will not answer is stopped and waited out before a replacement + * is forked: two sessions would write one discovery file, and the loser's + * shutdown would carry off the winner's record. + */ +async function adoptDocSession() { + const recorded = await readJsonSoft(store.sessionJson); + if (recorded && pidAlive(recorded.pid)) { + if (await probeSession(recorded)) return recorded; + try { process.kill(recorded.pid, 'SIGTERM'); } catch { /* already gone */ } + for (let waited = 0; waited < 5000 && pidAlive(recorded.pid); waited += 200) await sleep(200); + } + await rm(store.sessionJson, { force: true }).catch(() => {}); + + if (!await spawnDocSession()) return null; + /* A session forked before any tab exists has a short window to be adopted or + it dies young, and in document mode the tab arrives only once a person + opens the URL. This probe is the adoption. */ + const record = await waitForSessionRecord(5000); + if (!record) return null; + await probeSession(record); + return record; +} + +/* The session ending is this process's completion signal in document mode. + Liveness is the recorded process plus an answer from it, never the presence + of the discovery file on its own: a session that crashes leaves the file + behind, and a sibling shutting down can carry the file off while the real + session is still serving. */ +function watchDocSession(record) { + let misses = 0; + const tick = async () => { + if (completed) return; + if (!pidAlive(record.pid)) return finishDocMode(); + misses = (await probeSession(record)) ? 0 : misses + 1; + if (misses >= 2) return finishDocMode(); + docWatch = setTimeout(tick, 5000); + }; + docWatch = setTimeout(tick, 5000); +} + +function finishDocMode() { + if (completed) return; + completed = true; + clearTimeout(timeout); + clearTimeout(docWatch); + console.log('DOC_SESSION_ENDED'); + server.close(() => process.exit(0)); + server.closeAllConnections?.(); +} + function stopWithoutSubmission(message) { if (completed) return; clearTimeout(timeout); @@ -339,12 +540,28 @@ function stopWithoutSubmission(message) { server.closeAllConnections?.(); } +/* Document mode needs a run to show and a session to keep it editable, both + settled before the URL is printed: an agent that reads PICKER_URL is told + the document is ready. */ +if (options.doc) { + if (!await readAnswers()) { + console.error('No design interview found. Run /impeccable document to create one.'); + process.exit(1); + } + docSession = await adoptDocSession(); +} + server.listen(port, '127.0.0.1', () => { console.log(`PICKER_URL http://127.0.0.1:${port}`); timeout = setTimeout( - () => stopWithoutSubmission('Picker timed out without a submission.'), + () => stopWithoutSubmission(options.doc + ? 'Design context document closed without an edit session.' + : 'Picker timed out without a submission.'), options.timeoutMinutes * 60_000, ); + /* With no session there is nothing to outlive, so the ceiling is the only + limit and the document stays up read-only until it runs out. */ + if (options.doc && docSession) watchDocSession(docSession); }); server.on('error', (error) => { diff --git a/skill/scripts/pin.mjs b/skill/scripts/pin.mjs index d80043df7..37c57c330 100644 --- a/skill/scripts/pin.mjs +++ b/skill/scripts/pin.mjs @@ -31,7 +31,7 @@ const CODEX_HARNESSES = new Set(['.codex', '.agents']); // Valid sub-command names const VALID_COMMANDS = [ 'craft', 'init', 'extract', 'document', 'shape', - 'critique', 'audit', + 'critique', 'design-context', 'audit', 'polish', 'bolder', 'quieter', 'distill', 'harden', 'onboard', 'live', 'animate', 'colorize', 'typeset', 'layout', 'delight', 'overdrive', 'clarify', 'adapt', 'optimize', diff --git a/tests/picker-server.test.mjs b/tests/picker-server.test.mjs index 02a4cb76a..d9f8fc3dc 100644 --- a/tests/picker-server.test.mjs +++ b/tests/picker-server.test.mjs @@ -87,7 +87,7 @@ before(() => { }); }); -async function createFixture({ fonts = true } = {}) { +async function createFixture({ fonts = true, context = null } = {}) { const cwd = await realpath(await mkdtemp(path.join(tmpdir(), 'impeccable-picker-'))); const cuesDir = path.join(cwd, '.impeccable/visual-cues'); await mkdir(cuesDir, { recursive: true }); @@ -102,7 +102,12 @@ async function createFixture({ fonts = true } = {}) { `${JSON.stringify(fontManifestFixture)}\n`, ); } - return { cwd, cuesDir }; + const storeDir = path.join(cwd, '.impeccable/design-context'); + if (context) { + await mkdir(storeDir, { recursive: true }); + await writeFile(path.join(storeDir, 'context.json'), `${JSON.stringify(context)}\n`); + } + return { cwd, cuesDir, storeDir }; } async function startPicker(cwd, args = []) { @@ -256,13 +261,13 @@ test('serves picker and cues, writes submission, prints answers, and exits 0', a const answersPath = path.join( fixture.cwd, - '.impeccable/design-interview/answers.json', + '.impeccable/design-context/answers.json', ); assert.deepEqual(JSON.parse(await readFile(answersPath, 'utf8')), answers); assert.match(server.stdout(), new RegExp(`ANSWERS ${answersPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)); // Reap the doc session so the fixture directory can be removed. - const sessionPath = path.join(fixture.cwd, '.impeccable/design-interview/doc-session.json'); + const sessionPath = path.join(fixture.cwd, '.impeccable/design-context/runtime/session.json'); for (let attempt = 0; attempt < 20; attempt += 1) { try { const session = JSON.parse(await readFile(sessionPath, 'utf8')); @@ -289,7 +294,7 @@ test('fonts endpoint returns 404 when fonts.json is absent', async (t) => { test('serves staged brand assets, 404s missing files, and rejects traversal', async (t) => { const fixture = await createFixture(); - const assetsDir = path.join(fixture.cwd, '.impeccable/design-interview/assets'); + const assetsDir = path.join(fixture.cwd, '.impeccable/design-context/assets'); await mkdir(assetsDir, { recursive: true }); await writeFile( path.join(assetsDir, 'mark.svg'), @@ -297,7 +302,7 @@ test('serves staged brand assets, 404s missing files, and rejects traversal', as ); // A sibling secret one directory up; traversal attempts aim at it. await writeFile( - path.join(fixture.cwd, '.impeccable/design-interview/answers.json'), + path.join(fixture.cwd, '.impeccable/design-context/answers.json'), '{"secret":true}\n', ); const server = await startPicker(fixture.cwd, ['--port', String(portBase + 30)]); @@ -324,6 +329,128 @@ test('serves staged brand assets, 404s missing files, and rejects traversal', as } }); +test('serves the stored context and the chosen cue, both cacheable', async (t) => { + const contextFixture = { + schemaVersion: 1, + modes: ['persuade', 'read'], + context: { product: { name: 'Hanazono' } }, + }; + const fixture = await createFixture({ context: contextFixture }); + const server = await startPicker(fixture.cwd, ['--port', String(portBase + 50)]); + await cleanup(t, fixture, server); + + const contextResponse = await fetch(`${server.url}/context.json`); + assert.equal(contextResponse.status, 200); + assert.match(contextResponse.headers.get('cache-control') || '', /max-age/); + assert.deepEqual(await contextResponse.json(), contextFixture); + + // Nothing has been submitted, so the store carries no chosen cue yet. + assert.equal((await fetch(`${server.url}/cue.png`)).status, 404); + + const exitPromise = waitForExit(server.processHandle); + await fetch(`${server.url}/submit`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ 'palette-source': 'hero-01' }), + }); + assert.equal((await exitPromise)[0], 0); + + // Submit copies the picked hero into the store, so the document can render + // it after this server is gone and after the workspace is cleaned. + const stored = await readFile(path.join(fixture.storeDir, 'cue.png'), 'utf8'); + assert.equal(stored, 'fake-png'); +}); + +test('a palette that names no cue leaves the store without one', async (t) => { + const fixture = await createFixture(); + const server = await startPicker(fixture.cwd, ['--port', String(portBase + 60)]); + await cleanup(t, fixture, server); + + const exitPromise = waitForExit(server.processHandle); + await fetch(`${server.url}/submit`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ 'palette-source': 'seed-042' }), + }); + assert.equal((await exitPromise)[0], 0); + + assert.equal(existsSync(path.join(fixture.storeDir, 'cue.png')), false); + assert.ok(existsSync(path.join(fixture.storeDir, 'answers.json'))); +}); + +test('migrate carries a pre-store project across and repeats harmlessly', async () => { + const store = await import(pathToFileURL(path.join(root, 'skill/scripts/design-context/store.mjs')).href); + const cwd = await realpath(await mkdtemp(path.join(tmpdir(), 'impeccable-migrate-'))); + const legacy = path.join(cwd, '.impeccable/design-interview'); + await mkdir(path.join(legacy, 'assets'), { recursive: true }); + await mkdir(path.join(legacy, 'fonts'), { recursive: true }); + await mkdir(path.join(cwd, '.impeccable/visual-cues'), { recursive: true }); + await writeFile( + path.join(legacy, 'answers.json'), + `${JSON.stringify({ + 'palette-primary': '#1E4A42', + 'font-heading-source': '.impeccable/design-interview/fonts/Display.woff2', + })}\n`, + ); + await writeFile(path.join(legacy, 'doc-edits.jsonl'), '{"at":"2026-01-01T00:00:00.000Z","type":"color"}\n'); + await writeFile(path.join(legacy, 'assets/mark.svg'), ''); + await writeFile(path.join(legacy, 'fonts/Display.woff2'), 'font-bytes'); + await writeFile( + path.join(cwd, '.impeccable/visual-cues/cues.json'), + `${JSON.stringify({ cues: ['hero-01'], palette: {}, modes: ['read'], context: { product: { name: 'Old' } } })}\n`, + ); + + assert.deepEqual(await store.migrate(cwd), { migrated: true, deferred: false }); + const target = store.paths(cwd); + assert.ok(existsSync(target.answersJson)); + assert.ok(existsSync(target.journalJsonl)); + assert.equal(await readFile(path.join(target.assetsDir, 'mark.svg'), 'utf8'), ''); + assert.equal(await readFile(path.join(target.fontsDir, 'Display.woff2'), 'utf8'), 'font-bytes'); + assert.equal(existsSync(legacy), false); + + // The uploaded-face path travelled inside the answers themselves. + const answers = JSON.parse(await readFile(target.answersJson, 'utf8')); + assert.equal(answers['font-heading-source'], '.impeccable/design-context/fonts/Display.woff2'); + + // The chat half moves out of the cue manifest, which keeps its own data. + assert.deepEqual(JSON.parse(await readFile(target.contextJson, 'utf8')), { + schemaVersion: 1, + modes: ['read'], + context: { product: { name: 'Old' } }, + }); + const cues = JSON.parse(await readFile(target.cuesJson, 'utf8')); + assert.deepEqual(cues.cues, ['hero-01']); + + // A legacy line carries no seq, so it can never move the counter. + assert.equal(store.replayJournal(cwd).lastSeq, 0); + + // Second pass: nothing left to move, nothing damaged. + assert.deepEqual(await store.migrate(cwd), { migrated: false, deferred: false }); + assert.ok(existsSync(target.answersJson)); + + await rm(cwd, { recursive: true, force: true }); +}); + +test('migrate defers while a pre-store session is still alive', async () => { + const store = await import(pathToFileURL(path.join(root, 'skill/scripts/design-context/store.mjs')).href); + const cwd = await realpath(await mkdtemp(path.join(tmpdir(), 'impeccable-migrate-live-'))); + const legacy = path.join(cwd, '.impeccable/design-interview'); + await mkdir(legacy, { recursive: true }); + await writeFile(path.join(legacy, 'answers.json'), '{"palette-primary":"#1E4A42"}\n'); + // This process is the liveness proof: a session of the old shape holds the + // old paths in its own constants, so moving files under it would strand it. + await writeFile( + path.join(legacy, 'doc-session.json'), + `${JSON.stringify({ pid: process.pid, port: 1, token: 'x' })}\n`, + ); + + assert.deepEqual(await store.migrate(cwd), { migrated: false, deferred: true }); + assert.ok(existsSync(path.join(legacy, 'answers.json'))); + assert.equal(existsSync(store.paths(cwd).answersJson), false); + + await rm(cwd, { recursive: true, force: true }); +}); + test('palette CLI still prints a seed', () => { const output = execFileSync(process.execPath, [paletteScript], { cwd: root,