diff --git a/picker/scripts/design-context.js b/picker/scripts/design-context.js
index 1505dcc92..81c26be44 100644
--- a/picker/scripts/design-context.js
+++ b/picker/scripts/design-context.js
@@ -25,12 +25,14 @@ const shell = $('[data-dcx-shell]');
let seedContext = null;
let seedModes = null;
let seedPalettes = null;
+let seedCues = null;
fetch('/cues.json')
.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(() => {});
@@ -134,6 +136,7 @@ function takeSnapshot() {
return {
context: seedContext,
suggestedModes: seedModes,
+ cueSlugs: seedCues,
surfaces,
palette,
paletteSource: fieldValue('palette-source'),
@@ -226,6 +229,38 @@ const fromChat = (what, home) => empty(
`${what} in chat, before the browser questionnaire. ${home} is the durable copy.`,
);
+/* Staged brand-asset files are served by the picker server before submit and
+ by the doc session after it: the picker process exits when the submit
+ response lands, and article images only load when a detail view opens,
+ which is always after that. docSession is assigned before any render that
+ can reach the live DOM (startDocSession re-renders the templates). */
+const brandAssetSrc = (file) => (docSession
+ ? `${docSession.base}/brand-assets/${encodeURIComponent(file)}?token=${encodeURIComponent(docSession.token)}`
+ : `/brand-assets/${encodeURIComponent(file)}`);
+
+/* Cue and asset images load after their innerHTML render. The load pass
+ stamps the cue frame with the image's natural size, which is the space
+ the cues.json sample coordinates live in (the same division the picker's
+ own ring placement does); the error pass hides the broken entry so a
+ missing file never leaves a dead image in an article. Capture phase,
+ because load and error do not bubble. */
+document.addEventListener('load', (event) => {
+ const image = event.target;
+ if (!(image instanceof HTMLImageElement) || !('dcxCueImg' in image.dataset)) return;
+ const frame = image.closest('.dcx-cue-frame');
+ if (!frame) return;
+ frame.style.setProperty('--cue-w', String(image.naturalWidth || 1));
+ frame.style.setProperty('--cue-h', String(image.naturalHeight || 1));
+ frame.dataset.loaded = 'yes';
+}, true);
+
+document.addEventListener('error', (event) => {
+ const image = event.target;
+ if (!(image instanceof HTMLImageElement)) return;
+ const casualty = image.closest('[data-dcx-hide-on-error]');
+ if (casualty) casualty.hidden = true;
+}, true);
+
/* Readable ink for a fan panel, from the swatch's own luminance. */
function inkFor(hex) {
const [r, g, b] = [1, 3, 5].map((at) => parseInt(hex.slice(at, at + 2), 16) / 255);
@@ -500,8 +535,49 @@ function buildBrand(s, name) {
parts.push(block('Anti-reference', anti
+ note('Q5 of the seed interview. A hard constraint on every palette and pair that followed.')));
}
- if (Array.isArray(s.context?.assets) && s.context.assets.length) {
- parts.push(block('Assets provided', list(s.context.assets.map(escapeHtml))
+ /* Assets: an object entry carries a staged file under
+ .impeccable/design-interview/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
+ wide frame. A file that fails to load hides its own entry (the
+ delegated error listener), never the article. */
+ const assets = Array.isArray(s.context?.assets) ? s.context.assets : [];
+ const isFileAsset = (entry) => Boolean(entry) && typeof entry === 'object'
+ && typeof entry.file === 'string' && entry.file;
+ const fileAssets = assets.filter(isFileAsset);
+ const textAssets = assets.filter((entry) => !isFileAsset(entry));
+ const logos = fileAssets.filter((entry) => entry.kind === 'logo');
+ const boards = fileAssets.filter((entry) => entry.kind !== 'logo');
+ const assetCaption = (entry) => `
+
+ ${escapeHtml(entry.file)}${entry.note ? `
+
`
+ + note('Provided marks proofed on the committed primary and neutral grounds. The files are staged in .impeccable/design-interview/assets/.')));
+ }
+ if (boards.length) {
+ parts.push(block('Boards and references', `
`
+ + note('Boards and reference images provided in chat, staged in .impeccable/design-interview/assets/.')));
+ }
+ if (textAssets.length) {
+ parts.push(block('Assets provided', list(textAssets.map((entry) => escapeHtml(
+ typeof entry === 'string' ? entry : (entry.note || entry.file || ''),
+ )))
+ note('Gathered before the interview; the questions were grounded in what they showed.')));
}
return parts.join('');
@@ -519,6 +595,50 @@ const ROLE_STORY = {
function buildColor(s, name) {
const interview = s.context?.interview || {};
const parts = [heading(4, 'Color', 'Palette, roles, per-surface strategy, copyable values.', name)];
+ /* The chosen cue: the image the palette was sampled from, its four sample
+ 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. */
+ const cueSlugs = Array.isArray(s.cueSlugs) ? s.cueSlugs : [];
+ const chosenCue = cueSlugs.includes(s.paletteSource) ? s.paletteSource : '';
+ if (chosenCue && s.palette.length) {
+ const cuePalette = seedPalettes?.[chosenCue] || {};
+ const dots = ROLES.map((role) => {
+ const slot = cuePalette[role];
+ if (!slot || !Array.isArray(slot.at) || slot.at.length !== 2) return '';
+ const fill = String(slot.snapped || slot.hex || '');
+ return ``;
+ }).join('');
+ const roleRows = s.palette.map((entry) => `
+
`
+ + note('The image the palette was sampled from, each role’s sample point marked in its dealt color. The values beside it are the committed ones, which move when a role is edited after sampling.')));
+ const others = cueSlugs.filter((slug) => slug !== chosenCue);
+ if (others.length) {
+ parts.push(block('Also generated', `
`
+ + note('The directions not taken, kept on disk in .impeccable/visual-cues/.')));
+ }
+ }
if (s.palette.length) {
const step = 100 / (s.palette.length + 1);
const fan = s.palette.map((entry, index) => `
@@ -1166,6 +1286,10 @@ const docLive = () => Boolean(docSession);
function startDocSession(doc) {
docSession = doc;
document.body.classList.add('dcx-live');
+ /* Rebuild the templates with this session's URLs: brand-asset images can
+ only load through the session, because the picker server exits right
+ after submit and article images fetch after that exit. */
+ refreshDocument();
schedulePoll(1500);
}
diff --git a/picker/styles/design-context.css b/picker/styles/design-context.css
index c8ac676d5..f07026df9 100644
--- a/picker/styles/design-context.css
+++ b/picker/styles/design-context.css
@@ -1654,6 +1654,13 @@ body.dcx-open { overflow: hidden; background: linear-gradient(180deg, var(--ks-l
background: transparent;
}
+/* On screen 11 the field's rows stretch into the rail's fixed height; at the
+ sheet's natural height here they would collapse to the glyph's own box, so
+ the square proportion the slide showed is restated per cell. */
+.dcx-proof--icons .picker-icon-cell {
+ aspect-ratio: 1;
+}
+
/* ============================================================
Surface cards — the tile anatomy, kept.
============================================================ */
@@ -1709,6 +1716,198 @@ body.dcx-open { overflow: hidden; background: linear-gradient(180deg, var(--ks-l
color: var(--ks-text-faint);
}
+/* ============================================================
+ The chosen cue and the brand-asset gallery.
+
+ .dcx-cue shows the image the palette was sampled from. The
+ sample dots arrive with source-image pixel coordinates
+ (cues.json palette[slug][role].at); the load listener stamps
+ the image's natural size on the frame, and the calc division
+ below keeps each dot glued to its pixel at any rendered width.
+ .dcx-cue-strip is the unpicked cues, dimmed, not interactive.
+ .dcx-marks proofs a provided logo on the committed primary and
+ neutral grounds; .dcx-boards frames boards and references wide.
+ ============================================================ */
+.dcx-cue {
+ display: grid;
+ grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
+ gap: clamp(16px, 2.5vw, 30px);
+ align-items: start;
+}
+
+@media (max-width: 760px) {
+ .dcx-cue { grid-template-columns: minmax(0, 1fr); }
+}
+
+.dcx-cue-frame {
+ position: relative;
+ margin: 0;
+ border: 1px solid var(--ks-rule);
+ background: var(--panel-bg);
+}
+
+.dcx-cue-frame img {
+ display: block;
+ width: 100%;
+ height: auto;
+}
+
+.dcx-cue-dot {
+ position: absolute;
+ left: calc(var(--at-x, 0) / var(--cue-w, 1024) * 100%);
+ top: calc(var(--at-y, 0) / var(--cue-h, 1024) * 100%);
+ width: 16px;
+ height: 16px;
+ transform: translate(-50%, -50%);
+ border-radius: 50%;
+ background: var(--dot-fill, var(--accent));
+ border: 2px solid var(--ks-champagne);
+ box-shadow: 0 1px 6px var(--shadow-color);
+ transition: opacity 0.3s var(--ks-ease);
+}
+
+.dcx-cue-frame:not([data-loaded="yes"]) .dcx-cue-dot { opacity: 0; }
+
+.dcx-cue-card {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: clamp(14px, 2vw, 22px);
+ border: 1px solid var(--ks-rule);
+ background: var(--panel-bg);
+}
+
+.dcx-cue-tag {
+ font-family: var(--ks-mono);
+ font-size: 0.62rem;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: var(--ks-text-faint);
+}
+
+.dcx-cue-name {
+ margin: 0 0 4px;
+ font-family: var(--ks-font-display);
+ font-weight: 300;
+ font-size: 1.45rem;
+ color: var(--ks-text);
+}
+
+.dcx-cue-role {
+ display: grid;
+ grid-template-columns: 14px minmax(0, 1fr) auto auto;
+ gap: 10px;
+ align-items: center;
+}
+
+.dcx-cue-role-dot {
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: var(--dot-fill, var(--accent));
+ border: 1px solid var(--ks-rule);
+}
+
+.dcx-cue-role-name {
+ color: var(--ks-text-muted);
+ font-size: 0.9rem;
+}
+
+.dcx-cue-role code {
+ font-family: var(--ks-mono);
+ font-size: 0.78rem;
+ color: var(--ks-text-faint);
+ white-space: nowrap;
+}
+
+.dcx-cue-strip {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
+ gap: 14px;
+}
+
+.dcx-cue-thumb {
+ margin: 0;
+ opacity: 0.6;
+ filter: saturate(0.7);
+}
+
+.dcx-cue-thumb img {
+ display: block;
+ width: 100%;
+ height: auto;
+ border: 1px solid var(--ks-rule);
+}
+
+.dcx-cue-thumb figcaption {
+ margin-top: 6px;
+ font-family: var(--ks-mono);
+ font-size: 0.68rem;
+ letter-spacing: 0.08em;
+ color: var(--ks-text-faint);
+}
+
+.dcx-marks,
+.dcx-boards {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
+ gap: clamp(16px, 2.5vw, 24px);
+}
+
+.dcx-mark { margin: 0; }
+
+.dcx-mark-pair {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+}
+
+.dcx-mark-chip {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 128px;
+ padding: 18px;
+ border: 1px solid var(--ks-rule);
+ background: var(--chip-ground, var(--ks-lacquer-raised));
+}
+
+.dcx-mark-chip img {
+ display: block;
+ max-width: 70%;
+ max-height: 88px;
+}
+
+.dcx-board { margin: 0; }
+
+.dcx-board-frame {
+ display: block;
+ border: 1px solid var(--ks-rule);
+ background: var(--panel-bg);
+}
+
+.dcx-board-frame img {
+ display: block;
+ width: 100%;
+ height: auto;
+}
+
+.dcx-asset-caption { margin-top: 8px; }
+
+.dcx-asset-caption code {
+ font-family: var(--ks-mono);
+ font-size: 0.78rem;
+ color: var(--ks-text-faint);
+}
+
+.dcx-asset-caption p {
+ margin: 4px 0 0;
+ max-width: 52ch;
+ color: var(--ks-text-muted);
+ font-size: 0.9rem;
+ line-height: 1.55;
+}
+
/* ============================================================
Swatch board — each role at real size, both notations.
============================================================ */
diff --git a/skill/reference/document.md b/skill/reference/document.md
index 004fbdb86..5bdf4874e 100644
--- a/skill/reference/document.md
+++ b/skill/reference/document.md
@@ -380,6 +380,8 @@ 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.
+
These observations exist to sharpen Step 3. **No assets: skip straight to Step 3** with generic options.
### Step 3: The interview
@@ -456,7 +458,7 @@ This seed writes a minimal frontmatter with `name` and `description` only; no co
- `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. `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 note that the unpicked cue images stay in `.impeccable/visual-cues/` for later art direction. `color-strategy` becomes the Named Rule. When surfaces differ (`color-strategy-` 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.
diff --git a/skill/reference/visual-cues.md b/skill/reference/visual-cues.md
index 29365308d..fbc775725 100644
--- a/skill/reference/visual-cues.md
+++ b/skill/reference/visual-cues.md
@@ -485,7 +485,10 @@ In the same write, add a top-level `context` object carrying the chat half of th
"voice": [{ "say": "[a concrete line the product would write; 2 to 4 pairs, wording examples, never adjectives]", "not": "[the same message written the way the product refuses to sound]" }],
"commitments": ["[one line per commitment from PRODUCT.md Brand Commitments]"]
},
- "assets": ["[asset name: what Step 2 read off it]"],
+ "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]" }
+ ],
"color": { "assetLocks": ["[one short color fact an asset fixes, e.g. Primary locked from the logo mark; only when an asset names one]"] },
"interview": {
"references": [{ "name": "[interview reference, one entry per name]", "takeaway": "[one clause: what this reference lends the design]" }],
@@ -496,7 +499,7 @@ 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.
-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.
+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.
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.
diff --git a/skill/scripts/picker-doc-session.mjs b/skill/scripts/picker-doc-session.mjs
index 8af204113..25b1a26da 100644
--- a/skill/scripts/picker-doc-session.mjs
+++ b/skill/scripts/picker-doc-session.mjs
@@ -36,10 +36,19 @@ 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 MAX_BODY_BYTES = 1024 * 1024;
const FONT_EXTENSIONS = new Set(['.woff2', '.woff', '.ttf', '.otf']);
+const BRAND_ASSET_MIME = new Map([
+ ['.svg', 'image/svg+xml'],
+ ['.png', 'image/png'],
+ ['.jpg', 'image/jpeg'],
+ ['.jpeg', 'image/jpeg'],
+ ['.webp', 'image/webp'],
+ ['.gif', 'image/gif'],
+]);
const 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
@@ -251,6 +260,42 @@ async function handleRequest(request, response) {
return;
}
+ /* Brand-asset images for the document's Brand article. The picker server
+ serves the same directory while it lives; it exits on submit, and the
+ article's images load after that, so the tab fetches them from here
+ with the session token on the query string, the same rule as the
+ sibling GET routes. Filenames only, extension-gated, one directory. */
+ if (request.method === 'GET' && requestPath.startsWith('/brand-assets/')) {
+ if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
+ let assetName;
+ try {
+ assetName = decodeURIComponent(requestPath.slice('/brand-assets/'.length));
+ } catch {
+ throw httpError(400, 'Invalid path');
+ }
+ const extension = path.extname(assetName).toLowerCase();
+ const filePath = path.resolve(brandAssetsDir, assetName);
+ if (!assetName || assetName !== path.basename(assetName)
+ || !BRAND_ASSET_MIME.has(extension)
+ || path.relative(brandAssetsDir, filePath).startsWith('..')) {
+ throw httpError(404, 'Not found');
+ }
+ let body;
+ try {
+ body = await readFile(filePath);
+ } catch {
+ throw httpError(404, 'Not found');
+ }
+ response.writeHead(200, {
+ 'Content-Type': BRAND_ASSET_MIME.get(extension),
+ 'Content-Length': body.length,
+ 'Access-Control-Allow-Origin': '*',
+ 'Cache-Control': 'max-age=86400',
+ });
+ response.end(body);
+ return;
+ }
+
if (request.method === 'GET' && requestPath === '/doc/state') {
if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
lastBrowserSeen = Date.now();
diff --git a/skill/scripts/picker-server.mjs b/skill/scripts/picker-server.mjs
index 2c5c8008b..fd73c8714 100644
--- a/skill/scripts/picker-server.mjs
+++ b/skill/scripts/picker-server.mjs
@@ -17,13 +17,18 @@ 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 MAX_BODY_BYTES = 1024 * 1024;
const FONT_EXTENSIONS = new Set(['.woff2', '.woff', '.ttf', '.otf']);
+const BRAND_ASSET_EXTENSIONS = ['.svg', '.png', '.jpg', '.jpeg', '.webp', '.gif'];
const MIME = new Map([
['.html', 'text/html; charset=utf-8'],
['.css', 'text/css; charset=utf-8'],
['.js', 'text/javascript; charset=utf-8'],
['.jpg', 'image/jpeg'],
+ ['.jpeg', 'image/jpeg'],
+ ['.webp', 'image/webp'],
+ ['.gif', 'image/gif'],
['.png', 'image/png'],
['.svg', 'image/svg+xml'],
['.json', 'application/json; charset=utf-8'],
@@ -267,9 +272,28 @@ async function handleRequest(request, response) {
sendJson(response, 404, { error: 'Not found' });
return;
}
+ // Cue images are re-requested by the design context document after this
+ // process has exited (article content only enters the live DOM after
+ // submit), so they must be servable from the browser's cache.
+ response.setHeader('Cache-Control', 'max-age=86400');
await serveFile(response, options.cuesDir, cueName, ['.png']);
return;
}
+ /* Brand-asset files the agent staged from the chat interview (logos, mood
+ boards, reference images), displayed by the design context document.
+ Read-only, one directory, filenames only. The /assets/ prefix is taken
+ by the picker's own static files, hence the distinct name. */
+ if (requestPath.startsWith('/brand-assets/')) {
+ const assetName = requestPath.slice('/brand-assets/'.length);
+ if (!assetName || assetName.includes('/')) {
+ sendJson(response, 404, { error: 'Not found' });
+ return;
+ }
+ response.setHeader('Cache-Control', 'max-age=86400');
+ await serveFile(response, brandAssetsDir, assetName, BRAND_ASSET_EXTENSIONS);
+ return;
+ }
+
// Uploaded faces are read back so the specimen can render in them.
if (requestPath.startsWith('/fonts/')) {
const fontName = requestPath.slice('/fonts/'.length);
diff --git a/tests/picker-server.test.mjs b/tests/picker-server.test.mjs
index 235897903..02a4cb76a 100644
--- a/tests/picker-server.test.mjs
+++ b/tests/picker-server.test.mjs
@@ -287,6 +287,43 @@ test('fonts endpoint returns 404 when fonts.json is absent', async (t) => {
assert.deepEqual(await response.json(), { error: 'Not found' });
});
+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');
+ await mkdir(assetsDir, { recursive: true });
+ await writeFile(
+ path.join(assetsDir, 'mark.svg'),
+ '',
+ );
+ // A sibling secret one directory up; traversal attempts aim at it.
+ await writeFile(
+ path.join(fixture.cwd, '.impeccable/design-interview/answers.json'),
+ '{"secret":true}\n',
+ );
+ const server = await startPicker(fixture.cwd, ['--port', String(portBase + 30)]);
+ await cleanup(t, fixture, server);
+
+ const ok = await fetch(`${server.url}/brand-assets/mark.svg`);
+ assert.equal(ok.status, 200);
+ assert.match(ok.headers.get('content-type'), /^image\/svg\+xml/);
+ assert.match(ok.headers.get('cache-control') || '', /max-age/);
+ assert.match(await ok.text(), /