This commit is contained in:
Abdul Wahab
2026-09-01 10:04:59 +05:00
parent 6cfeb7d374
commit 8b6bbaef3d
7 changed files with 439 additions and 5 deletions
+126 -2
View File
@@ -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) => `
<figcaption class="dcx-asset-caption">
<code>${escapeHtml(entry.file)}</code>${entry.note ? `
<p>${escapeHtml(entry.note)}</p>` : ''}
</figcaption>`;
const chipHex = (roleName) => s.palette.find((entry) => entry.role === roleName)?.hex || '';
if (logos.length) {
parts.push(block('Marks', `<div class="dcx-marks">${logos.map((entry) => `
<figure class="dcx-mark" data-dcx-hide-on-error>
<div class="dcx-mark-pair">
<span class="dcx-mark-chip"${chipHex('Primary') ? ` style="--chip-ground:${chipHex('Primary')};"` : ''}><img src="${brandAssetSrc(entry.file)}" alt="${escapeHtml(entry.file)} on the primary color" /></span>
<span class="dcx-mark-chip"${chipHex('Neutral') ? ` style="--chip-ground:${chipHex('Neutral')};"` : ''}><img src="${brandAssetSrc(entry.file)}" alt="${escapeHtml(entry.file)} on the neutral color" /></span>
</div>
${assetCaption(entry)}
</figure>`).join('')}</div>`
+ note('Provided marks proofed on the committed primary and neutral grounds. The files are staged in <code>.impeccable/design-interview/assets/</code>.')));
}
if (boards.length) {
parts.push(block('Boards and references', `<div class="dcx-boards">${boards.map((entry) => `
<figure class="dcx-board" data-dcx-hide-on-error>
<span class="dcx-board-frame"><img src="${brandAssetSrc(entry.file)}" alt="${escapeHtml(entry.file)}" loading="lazy" /></span>
${assetCaption(entry)}
</figure>`).join('')}</div>`
+ note('Boards and reference images provided in chat, staged in <code>.impeccable/design-interview/assets/</code>.')));
}
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 `<span class="dcx-cue-dot" data-role="${role}" style="--at-x:${Number(slot.at[0]) || 0}; --at-y:${Number(slot.at[1]) || 0};${fill ? ` --dot-fill:${escapeHtml(fill)};` : ''}"></span>`;
}).join('');
const roleRows = s.palette.map((entry) => `
<div class="dcx-cue-role">
<span class="dcx-cue-role-dot" style="--dot-fill:${entry.hex};"></span>
<span class="dcx-cue-role-name">${escapeHtml(entry.role)}</span>
<code>${entry.hex}</code>
<code>${escapeHtml(formatOklch(entry.hex))}</code>
</div>`).join('');
parts.push(block('The cue', `<div class="dcx-cue" data-dcx-hide-on-error>
<figure class="dcx-cue-frame">
<img data-dcx-cue-img src="/cues/${encodeURIComponent(chosenCue)}.png" alt="The chosen visual cue, ${escapeHtml(chosenCue)}" />
${dots}
</figure>
<div class="dcx-cue-card">
<span class="dcx-cue-tag">Chosen cue</span>
<h3 class="dcx-cue-name">${escapeHtml(chosenCue)}</h3>
${roleRows}
</div>
</div>`
+ note('The image the palette was sampled from, each role&rsquo;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', `<div class="dcx-cue-strip">${others.map((slug) => `
<figure class="dcx-cue-thumb" data-dcx-hide-on-error>
<img src="/cues/${encodeURIComponent(slug)}.png" alt="" loading="lazy" />
<figcaption>${escapeHtml(slug)}</figcaption>
</figure>`).join('')}</div>`
+ note('The directions not taken, kept on disk in <code>.impeccable/visual-cues/</code>.')));
}
}
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);
}
+199
View File
@@ -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.
============================================================ */
+3 -1
View File
@@ -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": "<filename>", "kind": "logo" | "moodboard" | "reference", "note": "<one-line observation>" }`, 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-<mode>` 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-<mode>` 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-<mode>` 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 `-<mode>` 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 `-<mode>` keys, so state it as a rule for the whole product rather than per surface.
+5 -2
View File
@@ -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.
+45
View File
@@ -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();
+24
View File
@@ -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);
+37
View File
@@ -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'),
'<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80"><circle cx="40" cy="40" r="26" fill="#175558"/></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(), /<svg/);
assert.equal((await fetch(`${server.url}/brand-assets/missing.png`)).status, 404);
// Right directory, wrong extension: .json is not a brand-asset type.
assert.equal((await fetch(`${server.url}/brand-assets/notes.json`)).status, 404);
// fetch() collapses dot segments before sending, so raw sockets carry the
// traversal attempts, the same technique the existing traversal test uses.
for (const attempt of [
'/brand-assets/../answers.json',
'/brand-assets/%2e%2e/answers.json',
'/brand-assets/..%2Fanswers.json',
]) {
assert.ok([400, 404].includes(await rawGet(server.url, attempt)), attempt);
}
});
test('palette CLI still prints a seed', () => {
const output = execFileSync(process.execPath, [paletteScript], {
cwd: root,