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 ? ` +

${escapeHtml(entry.note)}

` : ''} +
`; + const chipHex = (roleName) => s.palette.find((entry) => entry.role === roleName)?.hex || ''; + if (logos.length) { + parts.push(block('Marks', `
${logos.map((entry) => ` +
+
+ ${escapeHtml(entry.file)} on the primary color + ${escapeHtml(entry.file)} on the neutral color +
+ ${assetCaption(entry)} +
`).join('')}
` + + 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', `
${boards.map((entry) => ` +
+ ${escapeHtml(entry.file)} + ${assetCaption(entry)} +
`).join('')}
` + + 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) => ` +
+ + ${escapeHtml(entry.role)} + ${entry.hex} + ${escapeHtml(formatOklch(entry.hex))} +
`).join(''); + parts.push(block('The cue', `
+
+ The chosen visual cue, ${escapeHtml(chosenCue)} + ${dots} +
+
+ Chosen cue +

${escapeHtml(chosenCue)}

+ ${roleRows} +
+
` + + 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', `
${others.map((slug) => ` +
+ +
${escapeHtml(slug)}
+
`).join('')}
` + + 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(), / { const output = execFileSync(process.execPath, [paletteScript], { cwd: root,