-
+
Featured story
A study in asymmetry
-
How a single stem, a dark vessel, and generous space hold the composition together.
+
How a single focal point, a quiet ground, and generous space hold the composition together.
Explore the study →
@@ -563,15 +593,15 @@ import { dcxAsset } from './assets.js';
Consultation
Plan the first conversation
-
Share the date and setting so the studio can prepare.
+
Share the date and setting so the team can prepare.
Book now
-
Commission guide
+
Process guide
Understand the process
-
Read the stages, timing, and what the studio needs from you.
+
Read the stages, timing, and what the team needs from you.
View the guide
diff --git a/picker/scripts/design-context.js b/picker/scripts/design-context.js
index 83153f7bc..3a56f7f5f 100644
--- a/picker/scripts/design-context.js
+++ b/picker/scripts/design-context.js
@@ -68,12 +68,16 @@ const contextReady = Promise.all([getJson('/context.json'), cuesReady])
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`);
+/* The chosen cue, wherever this page can still reach it. A live doc session
+ outlives every server the page booted from, so it is the first choice, and
+ the store's copy is made during submit before that session is forked. A
+ document opened later without a session still has the picker server serving
+ the store copy; a submit run before either exists reads the workspace image
+ the questionnaire already displayed, which is in the browser's cache. */
+const cueImageSrc = (slug) => {
+ if (docSession) return `${docSession.base}/cue.png?token=${encodeURIComponent(docSession.token)}`;
+ return docMode ? '/cue.png' : `/cues/${encodeURIComponent(slug)}.png`;
+};
const seedHexFor = (source, role) => {
const slot = seedPalettes?.[source]?.[role];
@@ -88,7 +92,10 @@ const seedHexFor = (source, role) => {
const ROLES = ['primary', 'secondary', 'tertiary', 'neutral'];
const SURFACE_ORDER = ['persuade', 'operate', 'read', 'experience'];
const SURFACE_LABELS = { persuade: 'Landing page', operate: 'Tool', read: 'Docs', experience: 'Portfolio' };
-const PER_SURFACE = ['color-strategy', 'boundary-style', 'corner-style', 'depth-style'];
+/* The five questions asked per surface, matching portability.mjs. The bare key
+ is the leading surface's answer, which is what DESIGN.md records as the rule
+ for the whole product. */
+const PER_SURFACE = ['color-strategy', 'boundary-style', 'corner-style', 'depth-style', 'motion-energy'];
const fieldValue = (name) => {
const field = form.elements[name];
@@ -290,6 +297,19 @@ document.addEventListener('load', (event) => {
document.addEventListener('error', (event) => {
const image = event.target;
if (!(image instanceof HTMLImageElement)) return;
+ /* A card that asked for the cue swaps to its vendored photo rather than
+ hiding: the entry is real either way, only the imagery moved. One swap
+ only, so a fallback that also fails lands at the hide rule below. */
+ if (image.dataset.dcxSwapSrc) {
+ const fallback = image.dataset.dcxSwapSrc;
+ delete image.dataset.dcxSwapSrc;
+ if (image.dataset.dcxSwapAlt) {
+ image.alt = image.dataset.dcxSwapAlt;
+ delete image.dataset.dcxSwapAlt;
+ }
+ image.src = fallback;
+ return;
+ }
const casualty = image.closest('[data-dcx-hide-on-error]');
if (casualty) casualty.hidden = true;
}, true);
@@ -323,6 +343,7 @@ function paintCommitted(node) {
set('n-ink', contrastInk(colors.neutral));
set('p-ink', contrastInk(colors.primary));
set('t-ink', contrastInk(colors.tertiary));
+ set('s-on-n', readableOn(colors.secondary, colors.neutral));
set('p-on-n', readableOn(colors.primary, colors.neutral));
set('t-on-n', readableOn(colors.tertiary, colors.neutral));
set('t-on-p', readableOn(colors.tertiary, colors.primary));
@@ -337,6 +358,7 @@ function proofHtml(source, { strategy = '', kind = 'board', marks = null } = {})
if (!source) return '';
const clone = source.cloneNode(true);
clone.hidden = false;
+ const sourceSurface = source.getAttribute('data-surface') || '';
clone.removeAttribute('data-surface');
for (const node of [clone, ...clone.querySelectorAll('[id]')]) node.removeAttribute('id');
for (const node of $$('button, input', clone)) {
@@ -356,6 +378,13 @@ function proofHtml(source, { strategy = '', kind = 'board', marks = null } = {})
if (value) wrap.setAttribute(`data-dcx-${key}`, value);
}
}
+ /* The surface the drawing came from, restated on the frame: the clone loses
+ its own data-surface above, and the stylesheet's quiet-base rule has to
+ tell a persuade board from a derived one. Marks that already named a
+ surface win, since those carry the committed answer. */
+ if (sourceSurface && !wrap.hasAttribute('data-dcx-surface')) {
+ wrap.setAttribute('data-dcx-surface', sourceSurface);
+ }
paintCommitted(wrap);
wrap.appendChild(clone);
return wrap.outerHTML;
@@ -964,6 +993,24 @@ function renderDocument() {
description: MODE_DEFS[surface.mode] || surface.goal || '',
}));
const name = snapshot.context?.product?.name || '';
+ /* Bridges for the document engine, whose modules read globals rather than
+ importing this file. The cue URL is empty when the palette came from a
+ seed deck or a custom pick, which keeps the vendored card photo; the
+ product name fills the specimen fields that would otherwise show another
+ studio's. */
+ const dealtCues = Array.isArray(snapshot.cueSlugs) ? snapshot.cueSlugs : [];
+ const chosenCue = snapshot.paletteSource && (docMode || dealtCues.includes(snapshot.paletteSource))
+ ? snapshot.paletteSource
+ : '';
+ window.dcxCueImageSrc = chosenCue ? cueImageSrc(chosenCue) : '';
+ window.dcxProductName = name;
+ /* The components inventory reads the committed palette through --pkc-* on
+ : its article is rebuilt on every remount, so the paint lives on the
+ one node that survives. A run that never committed a full palette leaves
+ the gate off and keeps the vendored demo colors. */
+ const paletteLive = ROLES.every((role) => fieldValue(`palette-${role}`));
+ document.body.classList.toggle('dcx-palette-live', paletteLive);
+ if (paletteLive) paintCommitted(document.body);
for (const [id, build] of Object.entries(BUILDERS)) {
const template = document.getElementById(`dcx-detail-${id}`);
template.innerHTML = `
${build(snapshot, name)}`;
@@ -983,6 +1030,16 @@ function collectAnswers() {
if (!(name in answers)) answers[name] = value;
else answers[name] = Array.isArray(answers[name]) ? [...answers[name], value] : [answers[name], value];
}
+ /* The visible radio follows whichever surface tab was shown last, so the bare
+ key can leave carrying a trailing surface's answer. The leading surface owns
+ it: restate it from that surface's own field before this goes to disk. */
+ const chosen = [].concat(answers['surface-modes'] || []);
+ const leading = SURFACE_ORDER.filter((mode) => chosen.includes(mode));
+ for (const key of PER_SURFACE) {
+ if (!(key in answers)) continue;
+ const owner = leading.find((mode) => answers[`${key}-${mode}`]);
+ if (owner) answers[key] = answers[`${key}-${owner}`];
+ }
return answers;
}
diff --git a/picker/styles/dcx/dcx-components.css b/picker/styles/dcx/dcx-components.css
index 2024e5eae..96c155692 100644
--- a/picker/styles/dcx/dcx-components.css
+++ b/picker/styles/dcx/dcx-components.css
@@ -499,3 +499,107 @@
font-size: 0.86rem;
line-height: 1.55;
}
+
+/* ============================================================
+ Specimens in the committed palette: when the run committed one,
+ the inventory renders in it. design-context.js sets
+ .dcx-palette-live on and paints --pkc-* there (the body
+ survives document remounts; the article does not). Without the
+ gate the vendored kinpaku colors above stand. Danger stays
+ --ks-vermilion: semantic, not brand. Geometry untouched.
+ ============================================================ */
+.dcx-palette-live .dcx-detail-article--components .dcx-component-canvas {
+ background: var(--pkc-neutral);
+}
+
+.dcx-palette-live .dcx-detail-article--components .dcx-demo-button--primary {
+ border-color: var(--pkc-primary);
+ background: var(--pkc-primary);
+ color: var(--pkc-p-ink);
+}
+
+.dcx-palette-live .dcx-detail-article--components .dcx-demo-button--secondary {
+ border-color: color-mix(in oklab, var(--pkc-n-ink) 14%, var(--pkc-neutral));
+ background: color-mix(in oklab, var(--pkc-n-ink) 14%, var(--pkc-neutral));
+ color: var(--pkc-n-ink);
+}
+
+.dcx-palette-live .dcx-detail-article--components .dcx-demo-button--tertiary {
+ border-color: color-mix(in oklab, var(--pkc-tertiary) 55%, var(--pkc-neutral));
+ background: color-mix(in oklab, var(--pkc-tertiary) 13%, transparent);
+ color: var(--pkc-t-on-n);
+}
+
+.dcx-palette-live .dcx-detail-article--components .dcx-demo-button--outline {
+ border-color: color-mix(in oklab, var(--pkc-n-ink) 22%, transparent);
+ color: var(--pkc-n-ink);
+}
+
+.dcx-palette-live .dcx-detail-article--components .dcx-demo-button--ghost {
+ color: color-mix(in oklab, var(--pkc-n-ink) 92%, var(--pkc-neutral));
+}
+
+.dcx-palette-live .dcx-detail-article--components .dcx-demo-icon-button--secondary {
+ background: color-mix(in oklab, var(--pkc-n-ink) 14%, var(--pkc-neutral));
+ color: var(--pkc-n-ink);
+}
+
+.dcx-palette-live .dcx-detail-article--components .dcx-demo-icon-button--outline {
+ border-color: color-mix(in oklab, var(--pkc-n-ink) 22%, transparent);
+ color: var(--pkc-s-on-n);
+}
+
+.dcx-palette-live .dcx-detail-article--components .dcx-demo-spinner {
+ border-color: color-mix(in oklab, var(--pkc-p-ink) 35%, transparent);
+ border-top-color: var(--pkc-p-ink);
+}
+
+.dcx-palette-live .dcx-detail-article--components .dcx-demo-field-label {
+ color: color-mix(in oklab, var(--pkc-n-ink) 75%, var(--pkc-neutral));
+}
+
+.dcx-palette-live .dcx-detail-article--components .dcx-demo-input {
+ border-color: color-mix(in oklab, var(--pkc-n-ink) 22%, transparent);
+ background: color-mix(in oklab, var(--pkc-n-ink) 7%, var(--pkc-neutral));
+ color: var(--pkc-n-ink);
+}
+
+.dcx-palette-live .dcx-detail-article--components .dcx-demo-input.is-placeholder {
+ color: color-mix(in oklab, var(--pkc-n-ink) 55%, var(--pkc-neutral));
+}
+
+.dcx-palette-live .dcx-detail-article--components .dcx-demo-field:not(.is-invalid) small {
+ color: color-mix(in oklab, var(--pkc-n-ink) 75%, var(--pkc-neutral));
+}
+
+.dcx-palette-live .dcx-detail-article--components .dcx-demo-input--affix > span {
+ color: var(--pkc-s-on-n);
+}
+
+.dcx-palette-live .dcx-detail-article--components .dcx-showcase-card {
+ border-color: color-mix(in oklab, var(--pkc-n-ink) 18%, transparent);
+ background: color-mix(in oklab, var(--pkc-n-ink) 5%, var(--pkc-neutral));
+}
+
+.dcx-palette-live .dcx-detail-article--components .dcx-showcase-card h5,
+.dcx-palette-live .dcx-detail-article--components .dcx-showcase-card blockquote {
+ color: var(--pkc-n-ink);
+}
+
+.dcx-palette-live .dcx-detail-article--components .dcx-showcase-card p,
+.dcx-palette-live .dcx-detail-article--components .dcx-showcase-card dd {
+ color: color-mix(in oklab, var(--pkc-n-ink) 92%, var(--pkc-neutral));
+}
+
+.dcx-palette-live .dcx-detail-article--components .dcx-showcase-card-meta {
+ color: color-mix(in oklab, var(--pkc-n-ink) 75%, var(--pkc-neutral));
+}
+
+.dcx-palette-live .dcx-detail-article--components .dcx-showcase-card dt,
+.dcx-palette-live .dcx-detail-article--components .dcx-showcase-card .dcx-component-kind {
+ color: color-mix(in oklab, var(--pkc-n-ink) 64%, var(--pkc-neutral));
+}
+
+.dcx-palette-live .dcx-detail-article--components .dcx-showcase-card-link {
+ color: var(--pkc-s-on-n);
+}
diff --git a/picker/styles/dcx/dcx-detail.css b/picker/styles/dcx/dcx-detail.css
index 519e6cfdf..9ae79b470 100644
--- a/picker/styles/dcx/dcx-detail.css
+++ b/picker/styles/dcx/dcx-detail.css
@@ -623,7 +623,8 @@
overflow-wrap: anywhere;
}
-.dcx-detail-article .dcx-fan-note {
+.dcx-detail-article .dcx-fan-note,
+.dcx-detail-article .dcx-default-note {
max-width: 72ch;
margin-top: 12px;
color: var(--ks-text-faint);
diff --git a/picker/styles/design-context.css b/picker/styles/design-context.css
index 5e50828d1..a3df28550 100644
--- a/picker/styles/design-context.css
+++ b/picker/styles/design-context.css
@@ -968,7 +968,8 @@ body.dcx-open { overflow: hidden; background: linear-gradient(180deg, var(--ks-l
opacity: 0.82;
}
-.dcx-fan-note {
+.dcx-fan-note,
+.dcx-default-note {
margin: 10px 0 0;
font-size: 0.8rem;
color: var(--ks-text-faint);
@@ -2249,10 +2250,12 @@ body.dcx-live .dcx-request { display: inline-flex; }
--derived-panel-edge: color-mix(in oklab, var(--pv-strong) 20%, var(--pv-neutral));
}
-/* Restrained lock for the non-persuade clones, mirrored from the strategy
- remap so the derived boards hold the gallery's base regardless of the
- run's strategy answer. */
-.dcx-proof--board > .picker-preview:not([data-surface="persuade"]) {
+/* Quiet base for derived-surface clones that carry no strategy answer of their
+ own (the Product surface cards). A frame that restates a strategy renders
+ that strategy instead, and a persuade frame keeps the full palette. The
+ clone's own data-surface is stripped by proofHtml, so this keys on the
+ frame's data-dcx-surface restatement. Body mirrored from picker.css:3404. */
+.dcx-proof--board:not([data-dcx-strategy]):not([data-dcx-surface="persuade"]) > .picker-preview {
--pv-secondary: color-mix(in oklab, var(--pkc-neutral) 78%, var(--pkc-n-ink));
--pv-tertiary: color-mix(in oklab, var(--pkc-neutral) 86%, var(--pkc-n-ink));
--pv-t-ink: var(--pkc-n-ink);
diff --git a/picker/styles/screens/depth.css b/picker/styles/screens/depth.css
index 24b895017..a409665b0 100644
--- a/picker/styles/screens/depth.css
+++ b/picker/styles/screens/depth.css
@@ -137,10 +137,11 @@
.picker-screen[data-screen="10"] .picker-board-stage > .picker-preview .pg-cap::before { box-shadow: var(--derived-shadow-card) !important; }
.picker-screen[data-screen="10"] .picker-board-stage > .picker-preview :is(.po-chart i, .po-side, .po-panel) { box-shadow: var(--derived-shadow-surface) !important; }
-/* Depth persuade rides the drenched base, the gallery's baseCellId lock.
- The role swap has to land before the persuade-specific secondary locks,
- or the ground stays light and every derived depth cell misses by a mile. */
-.picker-screen[data-screen="10"] .picker-board-stage > .picker-preview[data-surface="persuade"] {
+/* Depth persuade rode the gallery's drenched base cell unconditionally, which
+ painted the board in the primary for every run. The swap now applies only
+ when the run actually chose the drenched strategy; otherwise the committed
+ base from the shared 08/09/10 map (boundaries.css) carries the board. */
+#picker-form:has(input[name="color-strategy"][value="drenched"]:checked) .picker-screen[data-screen="10"] .picker-board-stage > .picker-preview[data-surface="persuade"] {
--pv-neutral: var(--pkc-primary);
--pv-n-ink: var(--pkc-p-ink);
--pv-primary: var(--pkc-p-ink);
@@ -155,18 +156,18 @@
--pv-secondary-wash: color-mix(in oklab, var(--pkc-p-ink) 16%, var(--pkc-primary));
}
-.picker-screen[data-screen="10"] .picker-board-stage > .picker-preview[data-surface="persuade"] .pv-desktop .pv-image {
+#picker-form:has(input[name="color-strategy"][value="drenched"]:checked) .picker-screen[data-screen="10"] .picker-board-stage > .picker-preview[data-surface="persuade"] .pv-desktop .pv-image {
background: linear-gradient(135deg, color-mix(in oklab, var(--pkc-p-ink) 16%, var(--pkc-primary)), color-mix(in oklab, var(--pkc-secondary) 34%, var(--pkc-primary))) !important;
border-color: color-mix(in oklab, var(--pkc-secondary) 52%, var(--pkc-primary)) !important;
}
-.picker-screen[data-screen="10"] .picker-board-stage > .picker-preview[data-surface="persuade"] .pv-desktop .pv-image::after { background: var(--pkc-tertiary) !important; }
+#picker-form:has(input[name="color-strategy"][value="drenched"]:checked) .picker-screen[data-screen="10"] .picker-board-stage > .picker-preview[data-surface="persuade"] .pv-desktop .pv-image::after { background: var(--pkc-tertiary) !important; }
-.picker-screen[data-screen="10"] .picker-board-stage > .picker-preview[data-surface="persuade"] .pv-desktop .pv-actions i:last-child {
+#picker-form:has(input[name="color-strategy"][value="drenched"]:checked) .picker-screen[data-screen="10"] .picker-board-stage > .picker-preview[data-surface="persuade"] .pv-desktop .pv-actions i:last-child {
border-color: color-mix(in oklab, var(--pkc-secondary) 68%, var(--pkc-p-ink)) !important;
background: color-mix(in oklab, var(--pkc-secondary) 18%, var(--pkc-primary)) !important;
}
-.picker-screen[data-screen="10"] .picker-board-stage > .picker-preview[data-surface="persuade"] .pv-desktop .pv-nav-bars i:nth-child(2) {
+#picker-form:has(input[name="color-strategy"][value="drenched"]:checked) .picker-screen[data-screen="10"] .picker-board-stage > .picker-preview[data-surface="persuade"] .pv-desktop .pv-nav-bars i:nth-child(2) {
background: color-mix(in oklab, var(--pkc-secondary) 72%, var(--pkc-p-ink)) !important;
}
diff --git a/skill/reference/document.md b/skill/reference/document.md
index 5b0face9f..496967547 100644
--- a/skill/reference/document.md
+++ b/skill/reference/document.md
@@ -451,14 +451,14 @@ 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-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:
+**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. **`_chosen` names the fields they actually set**: it holds a JSON-encoded array of per-surface keys, and a `
-` field missing from that array is a **preset** the picker minted when the surface was switched on, not an answer. Read the answers file plus the picked cue's palette entry in `.impeccable/visual-cues/cues.json` (`palette-source` names it), and map:
- **Frontmatter**: `name` and `description`, plus real `colors` (the four `palette-*` hex values under descriptive slugs; these are picked, not sampled) and real `typography` (`font-heading` and `font-body` are exact family names; give each role its family and weight intent, leave sizes for implementation). Derive the two text inks and record them under `colors` too: one near-black and one near-white, the pair the picker's previews already set their text in over these exact surfaces, each holding 4.5:1 against the grounds it will carry copy on, so a builder needing body-text contrast finds ink in the system instead of inventing a fifth color. Still no `rounded`, `spacing`, or `components`: the corner and spacing answers are qualitative, and nothing is built.
- **Overview**: Creative North Star and philosophy phrased from the questionnaire's color-strategy and motion answers plus the chat references; reference the user's anti-reference directly. Name the chosen surfaces (`surface-modes`) and what each is for. Movement stays here, after the North Star, but the questionnaire asks it of a landing page and a portfolio only, so write what the keys support:
- `motion-energy-` keys present, all agreeing: one philosophy sentence for the product, as before.
- Keys present and disagreeing: one sentence per surface, named (*"The landing page moves on state change only; the portfolio stages entrances and drives sequences on scroll."*). The bare `motion-energy` is the leading one of the two.
- **No `motion-energy` key at all**: the run has neither of those surfaces, so movement was never asked. Say nothing about it, and do not fill the gap from the register; this path's chat interview never asked about motion, so there is nothing to borrow. The next Scan-mode run reads the real transitions out of the code.
-- **Colors**: the four roles with their picked hex, noting the cue they came from. Name the chosen cue by its slug and name its kept image at `.impeccable/design-context/cue.png`, so a later build opens the picture the palette came from instead of imagining it; note that the unpicked cue images stay in `.impeccable/visual-cues/` for later art direction. Then open the kept image and describe it into the same section, three or four sentences under a **Cue, in words:** lead: the physical material each palette role lives on in the picture (cloth, glass, enamel, paper), the light and its temperature, the surface finish and grain, and the one material move that makes the image itself. Name each color as it appears on its material; `#C92823` as soft matte wrapping cloth instructs an image model where the bare hex only tints. Generation prompts on this world restate this passage (new-work.md and visualize.md say where), so a seed that records only the cue's file path leaves the material world to the model's imagination. `color-strategy` becomes the Named Rule. When surfaces differ (`color-strategy-` keys), state each surface's strategy and which surface leads (the bare key's owner).
+- **Colors**: the four roles with their picked hex, noting the cue they came from. Name the chosen cue by its slug and name its kept image at `.impeccable/design-context/cue.png`, so a later build opens the picture the palette came from instead of imagining it; note that the unpicked cue images stay in `.impeccable/visual-cues/` for later art direction. Then open the kept image and describe it into the same section, three or four sentences under a **Cue, in words:** lead: the physical material each palette role lives on in the picture (cloth, glass, enamel, paper), the light and its temperature, the surface finish and grain, and the one material move that makes the image itself. Name each color as it appears on its material; `#C92823` as soft matte wrapping cloth instructs an image model where the bare hex only tints. Generation prompts on this world restate this passage (new-work.md and visualize.md say where), so a seed that records only the cue's file path leaves the material world to the model's imagination. The **chosen** strategy becomes the Named Rule. When surfaces differ (`color-strategy-` keys), state each surface's strategy and which surface leads (the bare key's owner).
- **Typography**: the real pair by name, the pairing's character, and the type scale as a rule: `type-scale` names it, `type-scale-ratio` is the ratio (e.g. *"Major third: each heading step is 1.25x the last"*). Base size and exact steps stay `[resolved at implementation]`. A `font-heading-source` / `font-body-source` value means a user-provided font file; record where it lives.
- **Layout**: `boundary-style` (how sections separate) per surface when the `-` keys differ, plus `layout-structure` (how pages are composed), which the questionnaire asks of a landing page and a portfolio only. No invented grids beyond what the answers state.
- `layout-structure` present: one bare key and no `-` keys, so state it as a rule for the whole product rather than per surface.
@@ -468,7 +468,7 @@ This seed writes a minimal frontmatter with `name` and `description` only; no co
- **Components**: still omit; nothing exists yet.
- **Do's and Don'ts**: the interview-only guidance, plus a Do fixing the icon source: every icon comes from the chosen pack (`icon-pack-name`, license, URL), no mixed sets. When the interview staged brand files (`context.assets` object entries in `.impeccable/design-context/context.json`), add one Do per file naming its path under `.impeccable/design-context/assets/`, its kind, and its note; a staged logo is the product's real mark and the build uses the file itself.
-Per-surface answers come back for every chosen surface, defaults included, and a difference between surfaces is a decision the picker enforced, not an inconsistency to smooth over (the option lists differ per surface). Where all surfaces agree, state the answer once for the product. `motion-energy` and `layout-structure` are the two keys that can be missing entirely, since movement and composition are asked of a landing page and a portfolio only; [visual-cues.md](visual-cues.md) has the full contract.
+Per-surface answers come back for every chosen surface, presets included, and a difference between surfaces is a decision the picker enforced, not an inconsistency to smooth over (the option lists differ per surface, so a pick one surface allows can be unavailable on another and that surface falls to its preset). **Write a preset as provisional**, on the surface's own line: name the value, say it is that surface's default because the surface was never configured, and keep it out of the Named Rules and out of every product-wide sentence. Naming an untouched preset as a rule invents a law the user never chose. Where all surfaces agree **and `_chosen` shows the agreement was picked**, state the answer once for the product. `motion-energy` and `layout-structure` are the two keys that can be missing entirely, since movement and composition are asked of a landing page and a portfolio only; [visual-cues.md](visual-cues.md) has the full contract.
Both seeds skip the `.impeccable/design.json` sidecar: nothing to render yet. Real tokens for sizes, spacing, and components land on the next Scan-mode run.
diff --git a/skill/scripts/picker-doc-session.mjs b/skill/scripts/picker-doc-session.mjs
index 1728fe74a..69230d644 100644
--- a/skill/scripts/picker-doc-session.mjs
+++ b/skill/scripts/picker-doc-session.mjs
@@ -41,6 +41,7 @@
* with IMPECCABLE_DOC_TOKEN in the environment.
*/
+import { execFileSync } from 'node:child_process';
import http from 'node:http';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
@@ -59,6 +60,21 @@ const brandAssetsDir = store.assetsDir;
the submit-flow picker server has exited. Read-only, image types only. */
const pickerAssetsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), 'picker', 'assets');
+/* The Hooks page reads and writes hook config through hook-admin.mjs, the one
+ writer whose shapes stay validated; this server only ferries JSON either
+ way. Sync is fine: the admin runs in milliseconds, holds no sockets, and a
+ moment of backpressure on the doc port costs nothing. */
+const hookAdminScript = path.join(path.dirname(fileURLToPath(import.meta.url)), 'hook-admin.mjs');
+function runHookAdmin(args, input) {
+ const stdout = execFileSync(process.execPath, [hookAdminScript, ...args], {
+ cwd: process.cwd(),
+ input: input ?? '',
+ encoding: 'utf-8',
+ timeout: 15_000,
+ });
+ return JSON.parse(stdout);
+}
+
const MAX_BODY_BYTES = 1024 * 1024;
const FONT_EXTENSIONS = new Set(['.woff2', '.woff', '.ttf', '.otf']);
const BRAND_ASSET_MIME = new Map([
@@ -291,6 +307,29 @@ async function handleRequest(request, response) {
return;
}
+ /* The chosen cue, copied into the store at submit (picker-server.mjs
+ copyChosenCue). The components cards and the Color article render it after
+ the picker server has exited, so the tab fetches it here: token-gated, one
+ fixed file, PNG only, the trust model of the routes beside it. A run whose
+ palette named no cue has no file, which is the 404. */
+ if (request.method === 'GET' && requestPath === '/cue.png') {
+ if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
+ let body;
+ try {
+ body = await readFile(store.cuePng);
+ } catch {
+ throw httpError(404, 'Not found');
+ }
+ response.writeHead(200, {
+ 'Content-Type': 'image/png',
+ 'Content-Length': body.length,
+ 'Access-Control-Allow-Origin': '*',
+ 'Cache-Control': 'max-age=86400',
+ });
+ response.end(body);
+ return;
+ }
+
/* The document's own static images, for the tab that outlives the picker
server: the submit flow exits on /submit, and article images only load when
a view opens, which is always after that. Same trust model as the route
@@ -344,6 +383,21 @@ async function handleRequest(request, response) {
return;
}
+ /* The Hooks page's live state: the shared config's hook switch and the
+ detector ignore lists, read through hook-admin so this server never
+ parses config shapes itself. */
+ if (request.method === 'GET' && requestPath === '/doc/hooks') {
+ if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
+ let state;
+ try {
+ state = runHookAdmin(['state']);
+ } catch (error) {
+ throw httpError(500, String(error.stderr || error.message || error).trim().split('\n')[0]);
+ }
+ sendJson(response, 200, { ok: true, state });
+ return;
+ }
+
if (request.method === 'GET' && requestPath === '/doc/answers') {
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
const answers = JSON.parse(await readFile(answersPath, 'utf8'));
@@ -387,6 +441,24 @@ async function handleRequest(request, response) {
return;
}
+ /* The Hooks page's Apply: the full desired state arrives at once and is
+ written by hook-admin.mjs apply, with no model in the loop. Same
+ deterministic contract as /doc/save: complete or refused, never
+ approximately done. */
+ if (requestPath === '/doc/hooks') {
+ if (!body.state || typeof body.state !== 'object' || Array.isArray(body.state)) {
+ throw httpError(400, 'state must be a JSON object');
+ }
+ let state;
+ try {
+ state = runHookAdmin(['apply'], JSON.stringify(body.state));
+ } catch (error) {
+ throw httpError(400, String(error.stderr || error.message || error).trim().split('\n')[0]);
+ }
+ sendJson(response, 200, { ok: true, state });
+ return;
+ }
+
if (requestPath === '/doc/request') {
if (!REQUEST_KINDS.has(body.kind)) throw httpError(400, 'kind must be font or freeform');
const prompt = String(body.prompt || '').trim();
diff --git a/skill/scripts/picker-server.mjs b/skill/scripts/picker-server.mjs
index 50b144303..0c2253675 100644
--- a/skill/scripts/picker-server.mjs
+++ b/skill/scripts/picker-server.mjs
@@ -262,6 +262,12 @@ async function handleRequest(request, response) {
where to reach it from this response; the agent learns from
runtime/session.json, which the sibling writes at boot. */
const doc = await spawnDocSession();
+ /* The tab fires its first asset requests the moment this response lands,
+ and an img that reaches a forked session still booting fails once and
+ never retries. The session writes its record only after listen succeeds,
+ so the record on disk is readiness itself; no HTTP probe, which would
+ also mark the session adopted before any tab has seen it. */
+ if (doc) await waitForSessionRecord(5000);
response.once('finish', () => {
console.log(`ANSWERS ${answersPath}`);
server.close(() => process.exit(0));
diff --git a/tests/picker-server.test.mjs b/tests/picker-server.test.mjs
index 99d677c82..a3c967140 100644
--- a/tests/picker-server.test.mjs
+++ b/tests/picker-server.test.mjs
@@ -165,6 +165,12 @@ async function waitForExit(processHandle) {
async function cleanup(t, fixture, server) {
t.after(async () => {
if (server?.processHandle.exitCode === null) server.processHandle.kill('SIGTERM');
+ /* A submit spawns a detached doc session that outlives the picker; reap it
+ by its own record or it squats a port a later test needs. */
+ try {
+ const record = JSON.parse(await readFile(path.join(fixture.cwd, '.impeccable/design-context/runtime/session.json'), 'utf8'));
+ if (record?.pid) process.kill(record.pid, 'SIGTERM');
+ } catch { /* no session was spawned, or it is already gone */ }
await rm(fixture.cwd, { recursive: true, force: true });
});
}
@@ -704,3 +710,129 @@ test('questionnaire refuses to start without cues.json', async () => {
await rm(fixture.cwd, { recursive: true, force: true });
}
});
+
+/* The chosen cue joins the doc session's image routes: one fixed store file,
+ token-gated, PNG only. The 404 is the run whose palette named no cue, so
+ nothing was ever copied in. */
+test('doc session serves the stored cue, token-gated', async () => {
+ const fixture = await createFixture();
+ const sessionScript = path.join(root, 'skill/scripts/picker-doc-session.mjs');
+ const child = spawn(process.execPath, [sessionScript, '--port', String(portBase + 70)], {
+ cwd: fixture.cwd,
+ env: { ...process.env, IMPECCABLE_DOC_TOKEN: 't-cue' },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+
+ try {
+ const sessionFile = path.join(fixture.cwd, '.impeccable/design-context/runtime/session.json');
+ let session = null;
+ for (let attempt = 0; attempt < 100 && !session; attempt += 1) {
+ if (existsSync(sessionFile)) session = JSON.parse(await readFile(sessionFile, 'utf8'));
+ else await new Promise((resolve) => setTimeout(resolve, 50));
+ }
+ assert.ok(session?.port, 'the session never recorded its port');
+ const base = `http://127.0.0.1:${session.port}`;
+
+ // No submit has copied a cue in yet.
+ assert.equal((await fetch(`${base}/cue.png?token=t-cue`)).status, 404);
+
+ await mkdir(path.join(fixture.cwd, '.impeccable/design-context'), { recursive: true });
+ await writeFile(path.join(fixture.cwd, '.impeccable/design-context/cue.png'), Buffer.from('fake-cue-png'));
+
+ const ok = await fetch(`${base}/cue.png?token=t-cue`);
+ assert.equal(ok.status, 200);
+ assert.equal(ok.headers.get('content-type'), 'image/png');
+ assert.match(ok.headers.get('cache-control') || '', /max-age/);
+ assert.equal(await ok.text(), 'fake-cue-png');
+
+ assert.equal((await fetch(`${base}/cue.png?token=wrong`)).status, 403);
+ assert.equal((await fetch(`${base}/cue.png`)).status, 403);
+ } finally {
+ child.kill('SIGTERM');
+ await rm(fixture.cwd, { recursive: true, force: true });
+ }
+});
+
+/* The Hooks page's live channel: GET /doc/hooks reads the project's hook
+ state and POST /doc/hooks applies a full desired state, both through
+ hook-admin.mjs with no model in the loop. Exact-set semantics: an entry
+ missing from a later payload is a removal, which the union-merging CLI
+ verbs cannot express. */
+test('doc session reads and applies hook state, token-gated', async () => {
+ const fixture = await createFixture();
+ const sessionScript = path.join(root, 'skill/scripts/picker-doc-session.mjs');
+ const child = spawn(process.execPath, [sessionScript, '--port', String(portBase + 80)], {
+ cwd: fixture.cwd,
+ env: { ...process.env, IMPECCABLE_DOC_TOKEN: 't-hooks' },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+
+ try {
+ const sessionFile = path.join(fixture.cwd, '.impeccable/design-context/runtime/session.json');
+ let session = null;
+ for (let attempt = 0; attempt < 100 && !session; attempt += 1) {
+ if (existsSync(sessionFile)) session = JSON.parse(await readFile(sessionFile, 'utf8'));
+ else await new Promise((resolve) => setTimeout(resolve, 50));
+ }
+ assert.ok(session?.port, 'the session never recorded its port');
+ const base = `http://127.0.0.1:${session.port}`;
+
+ // Fresh project: defaults, nothing ignored.
+ const fresh = await fetch(`${base}/doc/hooks?token=t-hooks`);
+ assert.equal(fresh.status, 200);
+ assert.deepEqual((await fresh.json()).state, {
+ enabled: true, ignoreRules: [], ignoreFiles: [], ignoreValues: [],
+ });
+
+ // Apply a full state and read it back from the response and the disk.
+ const applied = await fetch(`${base}/doc/hooks`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ token: 't-hooks',
+ state: {
+ enabled: false,
+ ignoreRules: ['side-tab'],
+ ignoreFiles: ['src/legacy/**'],
+ ignoreValues: [{ rule: 'overused-font', value: 'Inter', reason: 'user confirmed' }],
+ },
+ }),
+ });
+ assert.equal(applied.status, 200);
+ const appliedState = (await applied.json()).state;
+ assert.equal(appliedState.enabled, false);
+ assert.deepEqual(appliedState.ignoreRules, ['side-tab']);
+ const config = JSON.parse(await readFile(path.join(fixture.cwd, '.impeccable/config.json'), 'utf8'));
+ assert.equal(config.hook.enabled, false);
+ assert.deepEqual(config.detector.ignoreRules, ['side-tab']);
+ assert.deepEqual(config.detector.ignoreFiles, ['src/legacy/**']);
+ assert.equal(config.detector.ignoreValues.length, 1);
+
+ // Removals stick: a payload without the entries clears them on disk.
+ const cleared = await fetch(`${base}/doc/hooks`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ token: 't-hooks',
+ state: { enabled: false, ignoreRules: [], ignoreFiles: [], ignoreValues: [] },
+ }),
+ });
+ assert.equal(cleared.status, 200);
+ const clearedConfig = JSON.parse(await readFile(path.join(fixture.cwd, '.impeccable/config.json'), 'utf8'));
+ assert.deepEqual(clearedConfig.detector.ignoreRules, []);
+ assert.deepEqual(clearedConfig.detector.ignoreValues, []);
+
+ // The gate and the validation hold.
+ assert.equal((await fetch(`${base}/doc/hooks`)).status, 403);
+ assert.equal((await fetch(`${base}/doc/hooks?token=wrong`)).status, 403);
+ const rejected = await fetch(`${base}/doc/hooks`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ token: 't-hooks', state: { ignoreValues: [{ rule: 'x', value: '*' }] } }),
+ });
+ assert.equal(rejected.status, 400);
+ } finally {
+ child.kill('SIGTERM');
+ await rm(fixture.cwd, { recursive: true, force: true });
+ }
+});