mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
update
This commit is contained in:
@@ -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’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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
============================================================ */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user