/* Design context document, the questionnaire's final act and its own surface.
*
* This module is the document's data layer. It reads the finished interview,
* builds each category's article into the #dcx-detail-* templates, reveals the
* tile shell, and keeps the live edit session. What it no longer owns is the
* presentation: the mosaic morph, sidebar, scroll-spy, and section styling are
* the engine's, in scripts/dcx/, ported from the standalone design context
* demo. It mounts those same templates as one continuous document.
*
* Everything is assembled client-side before the POST resolves, because the
* server's exit on /submit is the completion signal the agent waits on, and
* after it there is nothing to fetch.
*
* The document is also openable on its own, long after that run. The boot
* contract says which of the two this page is, and document mode renders from
* the design-context store with no submit involved.
*/
import { contrastInk, contrastInkHex, formatOklch, readableOn } from './color.js';
import { getBoot, hydrationReady } from './boot.js';
import { loadIconPacks } from './palette-picker.js';
const $ = (selector, root = document) => root.querySelector(selector);
const $$ = (selector, root = document) => [...root.querySelectorAll(selector)];
const form = $('#picker-form');
const shell = $('[data-dcx-shell]');
/* Seed context (the chat half of the interview) lives in the design-context
store; the dealt palettes stay with the cues that generated them. Both are
fetched at load, before the server can exit: the context block feeds the
chat-sourced pages, and the palette map is what the provenance tags compare
committed values against. Both promises are kept rather than discarded,
because a document opened directly renders from them instead of waiting on
a submit that never comes. */
let seedContext = null;
let seedModes = null;
let seedPalettes = null;
let seedCues = null;
const getJson = (url) => fetch(url)
.then((response) => (response.ok ? response.json() : null))
.catch(() => null);
const cuesReady = getJson('/cues.json').then((data) => {
seedPalettes = data?.palette || null;
seedCues = Array.isArray(data?.cues) ? data.cues : null;
return data;
});
/* Field by field, not file by file: a store written before a field existed,
or one carrying only half a run, still falls back to whatever the cue
manifest kept from the release that wrote it. */
const contextReady = Promise.all([getJson('/context.json'), cuesReady])
.then(([stored, cues]) => {
seedContext = stored?.context ?? cues?.context ?? null;
const modes = Array.isArray(stored?.modes) ? stored.modes : cues?.modes;
seedModes = Array.isArray(modes) ? modes : null;
});
/* The winning cue's dealt value for one role, read the way the deck's own
createState reads it in palette-picker.js: the pixel-snapped value when
the search landed, the planned hex otherwise, uppercased. A palette
source that is not a cue in cues.json (a seed-deck card, a custom
palette) has no entry here and returns nothing, which is what turns the
provenance tag off. */
/* Which of the two surfaces this page is. Set before the document renders in
document mode, read by the parts of it that differ. */
let docMode = false;
/* The chosen cue, wherever this page can still reach it. A live doc session
outlives every server the page booted from, so it is the first choice, and
the store's copy is made during submit before that session is forked. A
document opened later without a session still has the picker server serving
the store copy; a submit run before either exists reads the workspace image
the questionnaire already displayed, which is in the browser's cache. */
const cueImageSrc = (slug) => {
if (docSession) return `${docSession.base}/cue.png?token=${encodeURIComponent(docSession.token)}`;
return docMode ? '/cue.png' : `/cues/${encodeURIComponent(slug)}.png`;
};
const seedHexFor = (source, role) => {
const slot = seedPalettes?.[source]?.[role];
if (!slot) return '';
return String(slot.snapped || slot.hex || '').toUpperCase();
};
/* ============================================================
Snapshot — everything the document renders, read once.
============================================================ */
const ROLES = ['primary', 'secondary', 'tertiary', 'neutral'];
const SURFACE_ORDER = ['persuade', 'operate', 'read', 'experience'];
const SURFACE_LABELS = { persuade: 'Landing page', operate: 'Tool', read: 'Docs', experience: 'Portfolio' };
/* The five questions asked per surface, matching portability.mjs. The bare key
is the leading surface's answer, which is what DESIGN.md records as the rule
for the whole product. */
const PER_SURFACE = ['color-strategy', 'boundary-style', 'corner-style', 'depth-style', 'motion-energy'];
const fieldValue = (name) => {
const field = form.elements[name];
return field && typeof field.value === 'string' ? field.value : '';
};
/* The option copy is already on the page, on the radios the user answered
with, so the document quotes the screens instead of keeping a second copy
of every title and description. */
function optionCopy(name, value) {
const input = form.querySelector(`input[name="${name}"][value="${value}"]`);
const label = input?.closest('label');
if (!label) return { title: value, desc: '' };
const title = label.querySelector('.picker-strategy-title, .picker-icon-title');
const desc = label.querySelector('.picker-strategy-desc, .picker-icon-meta');
return {
title: (title?.textContent || value).replace(/^[\d.]+\s*/, '').trim(),
desc: (desc?.textContent || '').trim(),
};
}
function chosenSurfaces() {
return $$('input[name="surface-modes"]:checked', form)
.sort((a, b) => SURFACE_ORDER.indexOf(a.value) - SURFACE_ORDER.indexOf(b.value))
.map((input) => {
const tile = input.closest('.picker-mode-tile');
return {
mode: input.value,
label: input.dataset.surfaceLabel || input.value,
goal: tile?.querySelector('.picker-mode-goal')?.textContent.trim() || '',
examples: $$('.picker-mode-pills i', tile || form).map((pill) => pill.textContent.trim()),
};
});
}
/* Per-surface answers: the base key holds the leading surface, and each chosen
surface has its own hidden field, marked data-chosen when the user actually
visited it rather than inheriting the default for its kind.
A question is not always put to every surface, and the field it rendered is
which: a surface with nowhere to answer was never asked, so it is left out
rather than shown holding the leading surface's pick. An empty list means the
run never saw the screen at all. */
function perSurface(name, surfaces) {
return surfaces.flatMap((surface) => {
const field = form.querySelector(`input[data-surface-field="${name}-${surface.mode}"]`);
if (!field) return [];
const value = field.value || fieldValue(name);
return [{
...surface,
value,
chosen: field.dataset.chosen === 'yes',
...optionCopy(name, value),
}];
});
}
/* A flat question keeps one answer rather than one per surface, and its fields
are still what says whether it was put at all: none of the chosen surfaces
holding one means the run never saw the screen. The leading applicable surface
owns the answer, which is the rule the bare key in answers.json is written by
too. */
function flatAnswer(name, surfaces) {
const [leader] = perSurface(name, surfaces);
return leader ?? { value: '', title: '', desc: '' };
}
function takeSnapshot() {
const surfaces = chosenSurfaces();
const palette = ROLES.map((role) => ({
role: role[0].toUpperCase() + role.slice(1),
hex: fieldValue(`palette-${role}`).toUpperCase(),
})).filter((entry) => entry.hex);
const scaleInput = form.querySelector('input[name="type-scale"]:checked');
const pairCard = form.querySelector('input[name="font-pair"]:checked')?.closest('.picker-type-option');
return {
context: seedContext,
suggestedModes: seedModes,
cueSlugs: seedCues,
surfaces,
palette,
paletteSource: fieldValue('palette-source'),
strategy: perSurface('color-strategy', surfaces),
boundaries: perSurface('boundary-style', surfaces),
corners: perSurface('corner-style', surfaces),
depth: perSurface('depth-style', surfaces),
motion: perSurface('motion-energy', surfaces),
layout: flatAnswer('layout-structure', surfaces),
fonts: {
heading: fieldValue('font-heading'),
body: fieldValue('font-body'),
headingSource: fieldValue('font-heading-source'),
bodySource: fieldValue('font-body-source'),
why: pairCard?.querySelector('[data-pair-why]')?.textContent.trim() || '',
},
scale: {
name: scaleInput?.dataset.scaleName || '',
ratio: Number(fieldValue('type-scale-ratio') || scaleInput?.dataset.ratio || 0),
desc: scaleInput ? optionCopy('type-scale', scaleInput.value).desc : '',
},
icons: {
pack: fieldValue('icon-pack-name'),
license: fieldValue('icon-pack-license'),
url: fieldValue('icon-pack-url'),
},
};
}
/* ============================================================
Article builders — the prototype's block vocabulary, filled
from the snapshot. Every builder returns innerHTML for one
dcx-detail template.
============================================================ */
/* A stored value the schema asks for as a bare action arrives as a fragment
("walk over"), and the callout slot beside it holds sentences. Lift the
first letter only, and only when the first word is otherwise lowercase, so
a deliberate lowercase token (iPhone, npm) is left alone. Applied at ONE
call site (Primary conversion); the other value slots are fed by templates
that already ask for sentences, and several hold identifiers. */
const sentenceCase = (value) => {
const text = String(value);
const first = text.split(/\s/)[0] || '';
if (!/^[a-z]/.test(text) || /[A-Z]/.test(first)) return text;
return text[0].toUpperCase() + text.slice(1);
};
const escapeHtml = (value) => String(value)
.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
const heading = (index, title, lede, productName) => `
${escapeHtml(lede)}${escapeHtml(title)}
${escapeHtml(name)}
${body}
${extra}${escapeHtml(title)}
${body}
${text}
`; /* Chat-round material renders when the agent passed it along, and says where it lives when it did not — an interview that skipped a question is a fact the document reports, not a gap it papers over. */ const fromChat = (what, home) => empty( 'Captured in chat', `${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; /* A card that asked for the cue swaps to its vendored photo rather than hiding: the entry is real either way, only the imagery moved. One swap only, so a fallback that also fails lands at the hide rule below. */ if (image.dataset.dcxSwapSrc) { const fallback = image.dataset.dcxSwapSrc; delete image.dataset.dcxSwapSrc; if (image.dataset.dcxSwapAlt) { image.alt = image.dataset.dcxSwapAlt; delete image.dataset.dcxSwapAlt; } image.src = fallback; return; } const casualty = image.closest('[data-dcx-hide-on-error]'); if (casualty) casualty.hidden = true; }, true); /* 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); const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b; return lum > 0.55 ? 'oklch(20% 0.01 95)' : 'oklch(95% 0.005 95)'; } /* ============================================================ Proofs — the questionnaire's own drawings, borrowed. The form never leaves the DOM, only the flow: every preview the questionnaire painted (mode tiles, strategy artboards, the type scale sheet, the icon grid) is still standing behind the document, inline variables and all. A detail view that wants to show a decision clones the drawing that sold it instead of describing it. ============================================================ */ /* The committed palette under the --pkc-* names the strategy remaps read, mirroring palette-picker's syncCommittedPalette for nodes that live outside the strategy stage. */ function paintCommitted(node) { const colors = {}; for (const role of ROLES) colors[role] = fieldValue(`palette-${role}`); if (Object.values(colors).some((hex) => !hex)) return; const set = (name, value) => node.style.setProperty(`--pkc-${name}`, value); for (const role of ROLES) set(role, colors[role]); set('n-ink', contrastInk(colors.neutral)); set('p-ink', contrastInk(colors.primary)); set('t-ink', contrastInk(colors.tertiary)); set('s-on-n', readableOn(colors.secondary, colors.neutral)); set('p-on-n', readableOn(colors.primary, colors.neutral)); set('t-on-n', readableOn(colors.tertiary, colors.neutral)); set('t-on-p', readableOn(colors.tertiary, colors.primary)); set('p-on-p', readableOn(colors.primary, colors.primary)); set('t-on-t', readableOn(colors.tertiary, colors.tertiary)); set('p-on-i', readableOn(colors.primary, contrastInkHex(colors.primary))); } /* Component-scale corner and depth ramps for the committed kit. The shadow bodies are verbatim from design-context.css's [data-dcx-depth=...] rules; the radius values are their component-scale equivalents, because the artboard ramp ([data-dcx-corner=...] in the same file) is drawn at miniature scale, where 3px reads as a card radius. Change a body here and there together. */ const KIT_RADIUS = { sharp: { control: '0px', surface: '0px' }, 'slightly-soft': { control: '4px', surface: '6px' }, friendly: { control: '10px', surface: '14px' }, pill: { control: '999px', surface: '18px' }, }; const KIT_SHADOW = { flat: { card: 'none', control: 'none', surface: 'none' }, 'soft-lift': { card: '0 1px 1px rgb(0 0 0 / 0.15), 0 3px 7px rgb(0 0 0 / 0.14)', control: '0 1px 2px rgb(0 0 0 / 0.24)', surface: '0 2px 5px rgb(0 0 0 / 0.14)', }, floating: { card: '0 2px 3px rgb(0 0 0 / 0.2), 0 9px 18px rgb(0 0 0 / 0.24)', control: '0 3px 6px rgb(0 0 0 / 0.32)', surface: '0 6px 14px rgb(0 0 0 / 0.22)', }, }; const KIT_SERIF = /serif|mincho|baskerville|bitter|marcellus|slab|antiqua|garamond|didot|bodoni/i; const kitStack = (family) => `"${family.replaceAll('"', '\\"')}", ${KIT_SERIF.test(family) ? 'serif' : 'sans-serif'}`; /* The rest of the committed kit, published the way paintCommitted publishes color: onto one node, every value or none. A var() that resolves to nothing invalidates its declaration at computed-value time (a missing radius would become 0, not the vendored default), so the gate class only goes on when fonts, radius, and shadow are all present. Reads the LEADING surface's answers (s.corners[0], s.depth[0]): the visible radio follows whichever surface tab was shown last, while perSurface() orders by SURFACE_ORDER. */ function paintKit(node, s) { const radius = KIT_RADIUS[s.corners[0]?.value]; const shadow = KIT_SHADOW[s.depth[0]?.value]; if (!s.fonts.heading || !s.fonts.body || !radius || !shadow) return false; const set = (name, value) => node.style.setProperty(`--pkc-${name}`, value); set('font-heading', kitStack(s.fonts.heading)); set('font-body', kitStack(s.fonts.body)); set('font-heading-weight', document.querySelector('input[name="font-pair"]:checked')?.closest('.picker-type-option') ?.style.getPropertyValue('--pair-heading-weight') || '400'); set('radius-control', radius.control); set('radius-surface', radius.surface); set('shadow-card', shadow.card); set('shadow-control', shadow.control); set('shadow-surface', shadow.surface); if (s.scale.ratio) set('scale-ratio', String(s.scale.ratio)); return true; } /* Clone one questionnaire drawing into a dcx frame. Inline styles travel with the clone; ids do not (they would collide with the originals). */ function proofHtml(source, { strategy = '', kind = 'board', marks = null } = {}) { if (!source) return ''; const clone = source.cloneNode(true); clone.hidden = false; const sourceSurface = source.getAttribute('data-surface') || ''; clone.removeAttribute('data-surface'); for (const node of [clone, ...clone.querySelectorAll('[id]')]) node.removeAttribute('id'); for (const node of $$('button, input', clone)) { node.setAttribute('tabindex', '-1'); node.setAttribute('disabled', ''); } const wrap = document.createElement('div'); wrap.className = `dcx-proof dcx-proof--${kind}`; wrap.setAttribute('aria-hidden', 'true'); if (strategy) wrap.dataset.dcxStrategy = strategy; /* Committed structural answers, restated on the frame. On the screens they reach a carried artboard through checked inputs inside #picker-form, which a clone in the document is outside of, so design-context.css mirrors those bodies against these attributes instead. */ if (marks) { for (const [key, value] of Object.entries(marks)) { if (value) wrap.setAttribute(`data-dcx-${key}`, value); } } /* The surface the drawing came from, restated on the frame: the clone loses its own data-surface above, and the stylesheet's quiet-base rule has to tell a persuade board from a derived one. Marks that already named a surface win, since those carry the committed answer. */ if (sourceSurface && !wrap.hasAttribute('data-dcx-surface')) { wrap.setAttribute('data-dcx-surface', sourceSurface); } paintCommitted(wrap); wrap.appendChild(clone); return wrap.outerHTML; } /* The strategy screen mounts one painted artboard per chosen surface and leaves them standing; the tile previews on the surfaces screen never move at all. Both are lookups, not rebuilds. */ const surfaceBoard = (mode) => $(`[data-surface-stage] [data-surface="${mode}"]`); const surfaceTilePreview = (mode) => $(`input[name="surface-modes"][value="${mode}"]`) ?.closest('.picker-mode-tile')?.querySelector('.picker-preview'); /* The boundaries screen keeps one carried ps artboard per surface, and by that screen the drawing is the page as chosen so far. It is the clone source for any proof that wants the whole material story on one page. */ const questionBoard = (question, mode) => $(`[data-question="${question}"] .picker-board-stage > :is(.picker-artboard, .picker-preview)[data-surface="${mode}"]`); const carriedBoard = (mode) => questionBoard('boundaries', mode); /* The motion screen boards one drawing per surface AND option; the answer names which one is the committed drawing. */ const motionBoard = (mode, value) => $(`[data-question="motion"] .picker-board-stage > :is(.picker-artboard, .picker-preview)[data-motion-cell="${mode}-${value}"]`); /* One surface's committed structural answers, as the data-dcx-* marks the document stylesheet keys its mirrored bodies on. Layout is one answer for the whole run, so every surface's frame carries the same value. */ const materialMarks = (s, mode) => ({ surface: mode, boundary: s.boundaries.find((entry) => entry.mode === mode)?.value || '', corner: s.corners.find((entry) => entry.mode === mode)?.value || '', depth: s.depth.find((entry) => entry.mode === mode)?.value || '', layout: s.layout.value || '', }); /* One line per surface for the per-surface questions, marking whether the user configured the surface or it kept the default for its kind. */ const surfaceDefs = (entries) => defs(entries.map((entry) => ({ dt: escapeHtml(entry.label), dd: `${escapeHtml(entry.title)} · ${escapeHtml(entry.desc)}${entry.chosen ? '' : ' (default for this surface)'}`, }))); function buildAudience(s, name) { const audience = s.context?.audience || {}; const parts = [heading(1, 'Audience', 'Who it is for, emotional state, needs, trust triggers.', name)]; const who = [ audience.primary && { dt: 'Primary', dd: escapeHtml(audience.primary) }, audience.secondary && { dt: 'Secondary', dd: escapeHtml(audience.secondary) }, ].filter(Boolean); parts.push(block('Who they are', who.length ? defs(who) : fromChat('The primary and secondary user read was confirmed', 'PRODUCT.md · Users')));
/* Arrival-only context keeps the old single-callout block; a leaving line
widens it into the two-beat journey, side by side. */
if (audience.emotion || audience.leaving) {
const arrival = audience.emotion ? callout('On arrival', escapeHtml(audience.emotion), true) : '';
const leaving = audience.leaving ? callout('Leaving with', escapeHtml(audience.leaving), true) : '';
if (arrival && leaving) {
parts.push(block('Emotional journey', `PRODUCT.md: ${s.suggestedModes.map(label).join(', ')}; ${tail}.`);
}
/* Display labels for the PRODUCT.md platform value; an unrecognized value
renders as written rather than being dropped. */
const PLATFORM_LABELS = { web: 'Web', ios: 'iOS', android: 'Android', adaptive: 'Adaptive' };
function buildProduct(s, name) {
const product = s.context?.product || {};
const parts = [heading(2, 'Product', 'Purpose, surfaces, use cases, what must be clear first.', name)];
const purposeCallout = product.purpose
? callout(product.name || name || 'This product', escapeHtml(product.purpose), false,
product.success ? `\n ${escapeHtml(product.success)}
` : '') : fromChat('The purpose and success definition were confirmed', 'PRODUCT.md · Product Purpose');
const platform = typeof product.platform === 'string' && product.platform.trim()
? `${escapeHtml(PLATFORM_LABELS[product.platform.trim()] || product.platform.trim())}`
: '';
parts.push(block('Purpose', platform
? `${escapeHtml(product.operatingContext)}
`)); } const surfaceMap = product.surfaces && typeof product.surfaces === 'object' ? product.surfaces : {}; parts.push(block('Surfaces', surfaceCards(s, (surface) => { const specific = typeof surfaceMap[surface.mode] === 'string' ? surfaceMap[surface.mode].trim() : ''; return `${escapeHtml(specific || surface.goal || '')}
${surface.examples.length ? chips(surface.examples) : ''}`; }) + surfaceProvenanceNote(s) + note('Chosen on the questionnaire’s first screen, drawn in the committed palette; the leading surface owns every bare answer key in the sections that follow.'))); return parts.join(''); } function buildBrand(s, name) { const brand = s.context?.brand || {}; const interview = s.context?.interview || {}; const parts = [heading(3, 'Brand', 'Identity, voice, references, taste boundaries.', name)]; /* Personality: the confirmed sentence when the agent passed it; the three words alone over the pointer to the durable copy when only they arrived; the plain pointer otherwise. */ parts.push(block('Personality', brand.personality ? callout(brand.words?.join(' · ') || 'Voice', escapeHtml(brand.personality), true) : (Array.isArray(brand.words) && brand.words.length ? callout(brand.words.join(' · '), 'Three words, voice, and tone were confirmed in chat, before the browser questionnaire.PRODUCT.md · Brand Personality is the durable copy.', true)
: fromChat('Three words, voice, and tone were confirmed', 'PRODUCT.md · Brand Personality'))));
/* Voice: say / not wording pairs the agent derived from Brand Personality
and Brand Commitments at cues-write time. Concrete lines to write with,
never adjectives, and no interview question stands behind the field.
Pairs missing either half are dropped rather than rendered lopsided. */
const voicePairs = (Array.isArray(brand.voice) ? brand.voice : [])
.filter((pair) => pair && typeof pair === 'object' && pair.say && pair.not);
if (voicePairs.length) {
parts.push(block('Voice', `${escapeHtml(pair.say)}
${escapeHtml(pair.not)}
PRODUCT.md: wording to write with beside wording to refuse.')));
}
/* Principles: PRODUCT.md's own list, in the prototype's numbered anatomy,
folded into two columns. */
if (Array.isArray(brand.principles) && brand.principles.length) {
parts.push(block('Principles', `PRODUCT.md’s principles section; the durable copy lives there.')));
}
if (Array.isArray(brand.commitments) && brand.commitments.length) {
parts.push(block('Commitments', list(brand.commitments.map(escapeHtml))));
}
if (Array.isArray(interview.references) && interview.references.length) {
/* Q4 references arrive as plain strings from old cues.json files and as
{ name, takeaway } objects from new ones; a mixed list renders each
entry in its own form. Strings stay the bare pills they were. */
const cards = interview.references.filter((ref) => ref && typeof ref === 'object' && ref.name);
const plain = interview.references.filter((ref) => typeof ref === 'string');
const inner = (cards.length ? `${escapeHtml(ref.takeaway)}
` : ''}${escapeHtml(entry.file)}${entry.note ? `
${escapeHtml(entry.note)}
` : ''}.impeccable/design-context/assets/.')));
}
if (boards.length) {
parts.push(block('Boards and references', `.impeccable/design-context/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('');
}
/* The role descriptions the palette screen taught with, reused so the board
reads like the screen that made the decision. */
const ROLE_STORY = {
Primary: 'Your main brand color: buttons, links, the color people remember.',
Secondary: 'Supports the primary: section accents, hovers, secondary buttons.',
Tertiary: 'The rare accent: badges, highlights, one detail per screen.',
Neutral: 'Backgrounds and large surfaces: most of every page.',
};
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.
The image itself comes from the store, where the submit put a copy of the
one that was picked, so a document reopened after the generation workspace
was cleaned still has its cue. The workspace only has to still be there
for the sample dots and the directions not taken. */
const cueSlugs = Array.isArray(s.cueSlugs) ? s.cueSlugs : [];
/* A document opened on its own reads the cue out of the store, so the
generation workspace no longer has to still list it. A palette that never
came from a cue has no copy there either, and the whole block hides itself
when the image fails, which is the same answer arrived at later. */
const chosenCue = s.paletteSource && (docMode || 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) => `
${entry.hex}
${escapeHtml(formatOklch(entry.hex))}
.impeccable/visual-cues/.')));
}
}
if (s.palette.length) {
const step = 100 / (s.palette.length + 1);
const fan = s.palette.map((entry, index) => `
`).join('');
parts.push(block('Palette', `${escapeHtml(s.paletteSource)} cue` : ''}, roles in the order you arranged them. Hover to fan; click to copy the hex.`)));
/* One full-width band per role: the swatch at real size with both value
notations, the role's job, the ink that survives on it, where the value
came from, and a copy affordance. The provenance tag compares the
committed value against the winning cue's dealt value and stays away
when the source is not a cue; the ink chip is a pure derivation and
always renders. */
const colorContext = s.context?.color || {};
parts.push(block('Roles and values', `${escapeHtml(entry.desc)}
${escapeHtml(s.fonts.heading)}
${s.fonts.headingSource ? `${escapeHtml(s.fonts.headingSource)}` : ''}
${escapeHtml(s.fonts.body)}
${s.fonts.bodySource ? `${escapeHtml(s.fonts.bodySource)}` : ''}
${escapeHtml(s.fonts.why || 'Chosen on the font pair screen against every surface this product ships.')}
`)); } else { parts.push(block('The pair', empty('No pair selected', 'The font pair screen was not completed on this run.'))); } if (s.scale.ratio) { /* The scale screen's sheet, cloned with its computed sizes: every step at true rendered size in the chosen faces, px and rem alongside. */ parts.push(block('Type scale', `${escapeHtml(s.scale.name)} · ratio ${s.scale.ratio.toFixed(3)} on a 16px base. ${escapeHtml(s.scale.desc)}
${escapeHtml(entry.desc)}
${preview ? preview(entry) : ''}${s.scale.ratio.toFixed(3)} on a 16px base` } : null,
s.icons.pack ? { dt: 'Icons', dd: `${escapeHtml(s.icons.pack)}${s.icons.license ? ` · ${escapeHtml(s.icons.license)}` : ''}` } : null,
s.palette.length ? { dt: 'Palette', dd: s.palette.map((entry) => `${entry.hex}`).join(' · ') } : null,
s.layout.value ? { dt: 'Layout', dd: `${escapeHtml(s.layout.title)}, one answer for the run` } : null,
].filter(Boolean);
if (kit.length) {
parts.push(block('The kit', defs(kit)
+ note('The tokens the seed DESIGN.md will carry in its frontmatter, gathered from their own pages in this document.')));
}
parts.push(block('Components', empty(
'No component library seeded yet',
'Components are documented on the first scan pass, once there is code to capture actual tokens and states from. Re-run /impeccable document then.',
)));
return parts.join('');
}
const BUILDERS = {
audience: buildAudience,
product: buildProduct,
brand: buildBrand,
color: buildColor,
typography: buildTypography,
iconography: buildIconography,
material: buildMaterial,
interface: buildInterface,
};
/* What each chosen surface is, in the document's own register. The new document
replaces the material article's preview boards with these definitions
(dcx-detail.js reads window.dcxSurfaceDefs); persuade and experience carry the
standalone demo's sentences verbatim. */
const MODE_DEFS = {
persuade: 'A public-facing page that introduces the experience and guides visitors toward its primary action.',
operate: 'A working surface for completing tasks, where familiar patterns and a predictable layout come first.',
read: 'A reading surface for understanding, where type, structure, and pacing carry the page.',
experience: 'A project-led page for presenting selected work, its context, and its outcomes.',
};
function renderDocument() {
const snapshot = takeSnapshot();
window.dcxSurfaceDefs = snapshot.surfaces.map((surface) => ({
label: surface.label,
description: MODE_DEFS[surface.mode] || surface.goal || '',
}));
const name = snapshot.context?.product?.name || '';
/* Bridges for the document engine, whose modules read globals rather than
importing this file. The cue URL is empty when the palette came from a
seed deck or a custom pick, which keeps the vendored card photo; the
product name fills the specimen fields that would otherwise show another
studio's. */
const dealtCues = Array.isArray(snapshot.cueSlugs) ? snapshot.cueSlugs : [];
const chosenCue = snapshot.paletteSource && (docMode || dealtCues.includes(snapshot.paletteSource))
? snapshot.paletteSource
: '';
window.dcxCueImageSrc = chosenCue ? cueImageSrc(chosenCue) : '';
window.dcxProductName = name;
/* The components inventory reads the committed palette through --pkc-* on
: its article is rebuilt on every remount, so the paint lives on the
one node that survives. A run that never committed a full palette leaves
the gate off and keeps the vendored demo colors. */
const paletteLive = ROLES.every((role) => fieldValue(`palette-${role}`));
document.body.classList.toggle('dcx-palette-live', paletteLive);
if (paletteLive) paintCommitted(document.body);
/* The rest of the committed kit rides the same node: faces, corners, depth,
and the scale ratio. Without these the inventory can only be recolored. */
document.body.classList.toggle('dcx-kit-live', paintKit(document.body, snapshot));
for (const [id, build] of Object.entries(BUILDERS)) {
const template = document.getElementById(`dcx-detail-${id}`);
template.innerHTML = `Edit session offline
Changes stay in this tab; reconnecting…
${escapeHtml(entry.prompt)}
${escapeHtml(entry.message || TRAY_LABELS[entry.status] || entry.status)}