At vero eos et accusamus et iusto odio dignissimos ducimus, qui blanditiis praesentium voluptatum
deleniti atque, held together by a run of inline code mid sentence.
-
+
Nam libero tempore, cum soluta nobis
Eligendi optio cumque nihil impedit
Quo minus id quod maxime placeat
- function step(base, ratio, n) {
+ function step(base, ratio, n) {
return base * ratio ** n;
}
- A minor heading
-
+
A minor heading
+
Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet, ut et
voluptates repudiandae sint et molestiae non recusandae.
@@ -1105,11 +1137,15 @@ const questions = [
-
-
Your visual direction is ready.
-
-
- Finish
+
+
Thank you — that’s everything.
+
We have all we need to build your design context document. Assembling it now.
+
+
+
+
Your answers could not be saved — the picker server may have closed.
+
+ Try again
@@ -1127,13 +1163,212 @@ const questions = [
- 1 / 11
+ 1 / 11
+ {/* Design context document — hidden until the review screen saves the
+ answers. Mosaic, morph, and article vocabulary ported unchanged from
+ docs/design-context-categorization/design-context.html; the detail
+ templates start empty and are filled from the interview by
+ scripts/design-context.js. */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Audience
+
+
+
+
+
+
+
+
+
+
+
+ Product
+
+
+
+
+
+
+
+
+
+
+
+ Brand
+
+
+
+
+
+
+
+
+
+
+ Color
+
+
+
+
+
+
+
+
+
+
+ Typography
+
+
+
+
+
+
+
+
+
+
+
+ Iconography
+
+
+
+
+
+
+
+
+
+
+
+ Material
+
+
+
+
+
+
+
+
+
+ Interface
+
+
+
+
+
+ {/* Detail articles: empty shells filled per run by design-context.js. */}
+
+
+
+
+
+
+
+
+
+ {/* Expander shell: sidebar + topbar + main, cloned per open. */}
+
+
+
+
+ {/* Edit-request modal: complex changes the document cannot apply itself
+ are written up here and queued for the agent over the doc session. */}
+
+
+
+
+ Change
+ What should change?
+
+
+
Font files (optional)
+
+
+
+
+
+ Cancel
+ Send to the agent
+
+
+
+
+ {/* Live edit tray: every queued request with its status, fed by polling. */}
+
+
@@ -1211,6 +1446,7 @@ const questions = [
diff --git a/picker/scripts/design-context.js b/picker/scripts/design-context.js
new file mode 100644
index 000000000..72994677a
--- /dev/null
+++ b/picker/scripts/design-context.js
@@ -0,0 +1,1114 @@
+/* Design context document — the questionnaire's final act.
+ *
+ * When the run reaches the review screen this module saves the answers, then
+ * swaps the picker for the eight-category design context document. The mosaic
+ * landing, tile-to-fullscreen morph, sidebar shell, and article vocabulary are
+ * ported unchanged from docs/design-context-categorization/design-context.html;
+ * what changed is the content: the prototype rendered one example project, this
+ * renders the interview that just ended. Everything is assembled client-side
+ * before the POST resolves, because the server's exit on /submit is the
+ * completion signal the agent waits on — after it there is nothing to fetch.
+ */
+
+import { contrastInk, contrastInkHex, formatOklch, readableOn } from './color.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) rides in on cues.json as an
+ optional `context` block. Fetched at load, before the server can exit. */
+let seedContext = null;
+fetch('/cues.json')
+ .then((response) => (response.ok ? response.json() : null))
+ .then((data) => { seedContext = data?.context || null; })
+ .catch(() => {});
+
+/* ============================================================
+ Snapshot — everything the document renders, read once.
+ ============================================================ */
+
+const ROLES = ['primary', 'secondary', 'tertiary', 'neutral'];
+const SURFACE_ORDER = ['persuade', 'operate', 'read', 'experience'];
+const PER_SURFACE = ['color-strategy', 'boundary-style', 'corner-style', 'depth-style'];
+
+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),
+ }];
+ });
+}
+
+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,
+ 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: { value: fieldValue('layout-structure'), ...optionCopy('layout-structure', fieldValue('layout-structure')) },
+ 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.
+ ============================================================ */
+
+const escapeHtml = (value) => String(value)
+ .replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
+
+const heading = (index, title, lede, productName) => `
+
+ Design context · 0${index} / 08${productName ? ` · ${escapeHtml(productName)}` : ''}
+ ${escapeHtml(title)}
+ ${escapeHtml(lede)}
+ `;
+
+const block = (label, inner) => `
+
+ ${escapeHtml(label)}
+ ${inner}
+
`;
+
+const defs = (items) => `
+ ${items.map(({ dt, dd }) => `
+
${dt} ${dd} `).join('')}
+ `;
+
+const callout = (name, body, accent = false) => `
+
+
${escapeHtml(name)}
+
${body}
+
`;
+
+const list = (items) => `
+ ${items.map((item) => `${item} `).join('')} `;
+
+const chips = (items) => `
+ ${items.map((item) => `${escapeHtml(item)} `).join('')}
`;
+
+const empty = (title, body) => `
+
+
${escapeHtml(title)}
+
${body}
+
`;
+
+const note = (text) => `${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.`,
+);
+
+/* 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('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)));
+}
+
+/* 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' } = {}) {
+ if (!source) return '';
+ const clone = source.cloneNode(true);
+ clone.hidden = false;
+ 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;
+ 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');
+
+/* 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')));
+ if (audience.emotion) {
+ parts.push(block('Emotional state', callout('On arrival', escapeHtml(audience.emotion), true)));
+ }
+ if (Array.isArray(audience.needs) && audience.needs.length) {
+ parts.push(block('Needs', list(audience.needs.map(escapeHtml))));
+ }
+ return parts.join('');
+}
+
+/* A surface card in the questionnaire's own anatomy: the tile's dual-artboard
+ drawing on top, dressed in the committed palette, then the label and goal
+ the tile carried. */
+function surfaceCards(s, body) {
+ return `${s.surfaces.map((surface) => `
+
+ ${proofHtml(surfaceBoard(surface.mode) || surfaceTilePreview(surface.mode), { kind: 'board' })}
+
+
${escapeHtml(surface.label)}
+ ${body(surface)}
+
+ `).join('')}
`;
+}
+
+function buildProduct(s, name) {
+ const product = s.context?.product || {};
+ const parts = [heading(2, 'Product', 'Purpose, surfaces, use cases, what must be clear first.', name)];
+ parts.push(block('Purpose', product.purpose
+ ? callout(product.name || name || 'This product', escapeHtml(product.purpose))
+ : fromChat('The purpose and success definition were confirmed', 'PRODUCT.md · Product Purpose')));
+ parts.push(block('Surfaces', surfaceCards(s, (surface) => `
+ ${surface.goal ? escapeHtml(surface.goal) : ''}
+ ${surface.examples.length ? chips(surface.examples) : ''}`)
+ + note('Chosen on the questionnaire’s first screen, drawn in the committed palette; every per-surface answer in this document is keyed to this set.')));
+ 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)];
+ parts.push(block('Personality', brand.personality
+ ? callout(brand.words?.join(' · ') || 'Voice', escapeHtml(brand.personality), true)
+ : fromChat('Three words, voice, and tone were confirmed', 'PRODUCT.md · Brand Personality')));
+ if (Array.isArray(interview.references) && interview.references.length) {
+ parts.push(block('Named references', chips(interview.references)
+ + note('Q4 of the seed interview: brands, products, printed objects — not adjectives.')));
+ }
+ if (interview.antiReference) {
+ parts.push(block('Anti-reference', callout('Not this', escapeHtml(interview.antiReference))
+ + 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))
+ + 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)];
+ if (s.palette.length) {
+ const step = 100 / (s.palette.length + 1);
+ const fan = s.palette.map((entry, index) => `
+
+ ${escapeHtml(entry.role)} ${entry.hex}
+ `).join('');
+ parts.push(block('Palette', `${fan}
`
+ + note(`Committed on the palette screen${s.paletteSource ? ` from the ${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, and a copy affordance. */
+ parts.push(block('Roles and values', `${s.palette.map((entry) => `
+
+
+ ${entry.hex}
+ Copy
+
+
+
`).join('')}
`));
+
+ /* Every chosen surface gets its artboard back, remapped by the strategy
+ that surface answered with — the strategy screen's preview, kept. */
+ parts.push(block('Strategy per surface', `${s.strategy.map((entry) => `
+
+ ${proofHtml(surfaceBoard(entry.mode) || surfaceTilePreview(entry.mode), { strategy: entry.value, kind: 'board' })}
+
+
${escapeHtml(entry.label)} · ${escapeHtml(entry.title)} ${entry.chosen ? '' : ' default '}
+
${escapeHtml(entry.desc)}
+
+ `).join('')}
`
+ + note('How much of each surface the palette is allowed to carry, drawn the way the strategy screen previewed it. Options a surface cannot take were withheld there.')));
+ } else {
+ parts.push(block('Palette', empty('No palette committed', 'The palette screen was not completed on this run.')));
+ parts.push(block('Strategy per surface', surfaceDefs(s.strategy)));
+ }
+ if (interview.colorStrategy || interview.hueAnchor) {
+ parts.push(block('Interview direction', defs([
+ interview.colorStrategy && { dt: 'Strategy asked for', dd: escapeHtml(interview.colorStrategy) },
+ interview.hueAnchor && { dt: 'Hue anchor', dd: escapeHtml(interview.hueAnchor) },
+ ].filter(Boolean)) + note('Q1 of the seed interview. The cues were generated from this; the picks above are the decision.')));
+ }
+ return parts.join('');
+}
+
+function buildTypography(s, name) {
+ const interview = s.context?.interview || {};
+ const parts = [heading(5, 'Typography', 'Font families, type scale, hierarchy.', name)];
+ if (s.fonts.heading) {
+ /* Each family gets the fonttrio anatomy the font screen used: the name
+ set large in its own face, then a sentence in the partner. */
+ parts.push(block('The pair', `
+
+ Headings
+ ${escapeHtml(s.fonts.heading)}
+ ${s.fonts.headingSource ? `${escapeHtml(s.fonts.headingSource)}` : ''}
+
+
+ Body
+ ${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.')}
+ Change the fonts… `));
+ } 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)}
`
+ + proofHtml($('[data-scale-sheet]'), { kind: 'scale' })
+ + note('The scale screen’s sheet, kept at rendered size in the chosen faces. Values at a 16px base.')));
+ parts.push(block('In running text', proofHtml($('[data-scale-specimen]'), { kind: 'specimen' })
+ + note('The same scale on the components a page is built from — headings, ledes, lists, quotes, code.')));
+ }
+ if (interview.typeDirection) {
+ parts.push(block('Interview direction', callout('Direction asked for', escapeHtml(interview.typeDirection))
+ + note('Q2 of the seed interview. All six candidate pairs were composed inside this direction.')));
+ }
+ return parts.join('');
+}
+
+function buildIconography(s, name) {
+ const parts = [heading(6, 'Iconography', 'Icon library, license, where it lives.', name)];
+ if (s.icons.pack) {
+ const sheet = proofHtml($('[data-icon-sheet]'), { kind: 'icons' });
+ if (sheet) {
+ parts.push(block('The hand', sheet
+ + note(`${escapeHtml(s.icons.pack)}’s canonical set, as the icons screen previewed it. One pack, one hand: no mixed sets.`)));
+ }
+ parts.push(block('Library', defs([
+ { dt: 'Pack', dd: `${escapeHtml(s.icons.pack)} ` },
+ s.icons.license && { dt: 'License', dd: escapeHtml(s.icons.license) },
+ s.icons.url && { dt: 'Home', dd: `${escapeHtml(s.icons.url)} ` },
+ ].filter(Boolean)) + note('Chosen on the icons screen. The pack names the hand; stroke weight and metaphor rules resolve during implementation.')));
+ } else {
+ parts.push(block('Library', empty('No pack selected', 'The icons screen was not completed on this run.')));
+ }
+ return parts.join('');
+}
+
+/* A per-surface question laid out the way its screen asked it: one card per
+ surface, the pick's title carrying the card, the description under it. */
+const pickGrid = (entries) => `${entries.map((entry) => `
+
+ ${escapeHtml(entry.label)}${entry.chosen ? '' : ' default '}
+ ${escapeHtml(entry.title)}
+ ${escapeHtml(entry.desc)}
+ `).join('')}
`;
+
+function buildMaterial(s, name) {
+ const interview = s.context?.interview || {};
+ const parts = [heading(7, 'Material', 'Motion, layout structure, boundaries, corners, depth.', name)];
+ /* Movement is only asked of the two surfaces that make a claim on attention,
+ so this list is one or two entries long and empty on a run of neither. An
+ empty one says the question was never put, which is a different thing from
+ a quiet answer and is worth saying out loud. */
+ if (s.motion.length) {
+ parts.push(block('Motion per surface', surfaceDefs(s.motion)
+ + note(interview.motionEnergy
+ ? `The seed interview asked for ${escapeHtml(interview.motionEnergy)} ; the motion screen answered it per surface above.`
+ : 'Asked of the landing page and the portfolio only. A tool and a document are moved through rather than watched, so their movement follows the interface rather than a house style.')));
+ } else {
+ parts.push(block('Motion', empty(
+ 'Not asked on this run',
+ 'The motion screen is shown for a landing page and a portfolio. This run has neither, so no motion energy was chosen and none is recorded.',
+ )));
+ }
+ if (s.layout.value) {
+ parts.push(block('Layout structure', callout(s.layout.title, escapeHtml(s.layout.desc))));
+ }
+ parts.push(block('Boundaries per surface', pickGrid(s.boundaries)
+ + note('How sections separate on each surface.')));
+ parts.push(block('Corners per surface', pickGrid(s.corners)
+ + note('How round shapes are on each surface.')));
+ parts.push(block('Depth per surface', pickGrid(s.depth)
+ + note('How far off the page things sit on each surface.')));
+ return parts.join('');
+}
+
+function buildInterface(s, name) {
+ const parts = [heading(8, 'Interface', 'Per-surface decisions at a glance, component status.', name)];
+ const ASPECTS = [['Color strategy', 'strategy'], ['Boundaries', 'boundaries'], ['Corners', 'corners'], ['Depth', 'depth']];
+ parts.push(block('Decisions per surface', `${s.surfaces.map((surface) => `
+
+ ${proofHtml(surfaceBoard(surface.mode) || surfaceTilePreview(surface.mode), {
+ strategy: s.strategy.find((entry) => entry.mode === surface.mode)?.value || '',
+ kind: 'board',
+ })}
+
+
${escapeHtml(surface.label)}
+
${ASPECTS.map(([label, key]) => {
+ const entry = s[key].find((item) => item.mode === surface.mode);
+ return `
${escapeHtml(label)} ${escapeHtml(entry?.title || '—')}${entry && !entry.chosen ? ' default ' : ''} `;
+ }).join('')}
+
+ `).join('')}
`
+ + note('Each surface’s artboard in its own color strategy, with the four per-surface answers beneath it.')));
+ 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,
+};
+
+function renderDocument() {
+ const snapshot = takeSnapshot();
+ const name = snapshot.context?.product?.name || '';
+ for (const [id, build] of Object.entries(BUILDERS)) {
+ const template = document.getElementById(`dcx-detail-${id}`);
+ template.innerHTML = `${build(snapshot, name)} `;
+ }
+ const masthead = $('[data-dcx-product]');
+ if (masthead && name) masthead.textContent = `Design context — ${name}`;
+ if (name) document.title = `Design context — ${name}`;
+}
+
+/* ============================================================
+ Finish sequence — save, then reveal.
+ ============================================================ */
+
+const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+function collectAnswers() {
+ const answers = {};
+ for (const [name, value] of new FormData(form)) {
+ if (!(name in answers)) answers[name] = value;
+ else answers[name] = Array.isArray(answers[name]) ? [...answers[name], value] : [answers[name], value];
+ }
+ return answers;
+}
+
+async function submitAnswers() {
+ const response = await fetch('/submit', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(collectAnswers()),
+ });
+ /* 409 means an earlier attempt landed before its response was read; the
+ answers are on disk, which is all this step owes. */
+ if (!response.ok && response.status !== 409) throw new Error(`Submit failed: ${response.status}`);
+ if (response.ok) {
+ const body = await response.json().catch(() => null);
+ if (body?.doc?.base && body?.doc?.token) startDocSession(body.doc);
+ }
+}
+
+async function finishSequence() {
+ const status = $('[data-doc-status]');
+ const errorBox = $('[data-doc-error]');
+ const loader = $('[data-doc-loader]');
+ errorBox.hidden = true;
+ loader.removeAttribute('data-stalled');
+
+ renderDocument();
+
+ const stages = ['Saving your answers…', 'Assembling the categories…', 'Setting the type…'];
+ let stage = 0;
+ status.textContent = stages[0];
+ const ticker = setInterval(() => {
+ stage = Math.min(stage + 1, stages.length - 1);
+ status.textContent = stages[stage];
+ }, 900);
+
+ try {
+ /* The pause is real work plus a floor: the document is already built, but
+ a reveal that beats the reader's blink reads as a broken redirect. */
+ await Promise.all([submitAnswers(), wait(2400)]);
+ clearInterval(ticker);
+ status.textContent = 'Ready.';
+ revealDocument();
+ } catch {
+ clearInterval(ticker);
+ loader.setAttribute('data-stalled', '');
+ status.textContent = '';
+ errorBox.hidden = false;
+ }
+}
+
+let finished = false;
+document.addEventListener('picker:screenchange', ({ detail }) => {
+ if (detail.screen !== '12' || finished) return;
+ finished = true;
+ finishSequence();
+});
+
+$('[data-doc-retry]')?.addEventListener('click', finishSequence);
+
+/* ============================================================
+ Reveal + expander — ported from the prototype. The morph,
+ subnav, hash routing, fan, and stroke sizing are its code;
+ only the theme toggle and site header went (the picker has
+ neither).
+ ============================================================ */
+
+const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+const MORPH_MS = reduceMotion ? 0 : 540;
+const READY_MS = reduceMotion ? 0 : 260;
+
+const tiles = $$('.dcx-tile');
+const names = {};
+tiles.forEach((tile) => { names[tile.dataset.category] = tile.dataset.name; });
+const shellTemplate = document.getElementById('dcx-shell-template');
+
+let current = null;
+let revealed = false;
+
+function revealDocument() {
+ revealed = true;
+ document.body.classList.add('dcx-open');
+ shell.hidden = false;
+ window.scrollTo(0, 0);
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ $$('[data-reveal]', shell).forEach((el) => el.classList.add('revealed'));
+ sizeDrawStrokes();
+ });
+ });
+ const initial = location.hash.replace('#', '');
+ if (initial && initial in names) {
+ window.setTimeout(() => openCategory(initial, false), 120);
+ }
+}
+
+function copyText(value) {
+ if (navigator.clipboard?.writeText) {
+ return navigator.clipboard.writeText(value).catch(() => fallbackCopy(value));
+ }
+ fallbackCopy(value);
+ return Promise.resolve();
+}
+
+function fallbackCopy(value) {
+ const field = document.createElement('textarea');
+ field.value = value;
+ field.setAttribute('readonly', '');
+ field.style.position = 'fixed';
+ field.style.opacity = '0';
+ document.body.appendChild(field);
+ field.select();
+ document.execCommand('copy');
+ field.remove();
+}
+
+function initFan(root) {
+ $$('.dcx-fan', root).forEach((fan) => {
+ const panels = $$('.dcx-fan-panel', fan);
+ if (!panels.length) return;
+
+ function setActive(index) {
+ fan.classList.add('is-engaged');
+ panels.forEach((panel, i) => {
+ panel.classList.toggle('is-active', i === index);
+ panel.classList.toggle('is-neighbor', Math.abs(i - index) === 1);
+ });
+ }
+
+ function clearActive() {
+ fan.classList.remove('is-engaged');
+ panels.forEach((panel) => panel.classList.remove('is-active', 'is-neighbor'));
+ }
+
+ fan.addEventListener('mousemove', (event) => {
+ const rect = fan.getBoundingClientRect();
+ const progress = Math.min(0.999, Math.max(0, (event.clientX - rect.left) / rect.width));
+ let active = 0;
+ panels.forEach((panel, i) => {
+ const left = parseFloat(panel.style.getPropertyValue('--panel-left')) / 100;
+ if (progress >= left) active = i;
+ });
+ setActive(active);
+ });
+ fan.addEventListener('mouseleave', clearActive);
+
+ panels.forEach((panel, i) => {
+ panel.addEventListener('focus', () => setActive(i));
+ panel.addEventListener('blur', () => {
+ if (!fan.matches(':focus-within')) clearActive();
+ });
+ panel.addEventListener('click', () => {
+ const value = panel.dataset.copyColor;
+ if (!value) return;
+ copyText(value);
+ const label = panel.querySelector('.dcx-fan-name');
+ const original = panel.dataset.colorName || 'Color';
+ panel.classList.add('is-copied');
+ if (label) label.textContent = 'Copied!';
+ window.clearTimeout(panel._copyTimer);
+ panel._copyTimer = window.setTimeout(() => {
+ panel.classList.remove('is-copied');
+ if (label) label.textContent = original;
+ }, 900);
+ });
+ });
+ });
+}
+
+function buildSubnav(expander, activeId) {
+ $$('.dcx-nav-list li', expander).forEach((li) => {
+ const isActive = li.dataset.category === activeId;
+ li.classList.toggle('is-active', isActive);
+ const subnav = li.querySelector('.dcx-subnav');
+ if (!subnav) return;
+ subnav.innerHTML = '';
+ if (!isActive) return;
+ $$('.dcx-main .dcx-block[data-label]', expander).forEach((blockEl, i) => {
+ const btn = document.createElement('button');
+ btn.className = 'dcx-sub-link';
+ btn.type = 'button';
+ btn.textContent = blockEl.dataset.label.replace(/&/g, '&');
+ btn.setAttribute('data-dcx-subsection', String(i));
+ subnav.appendChild(btn);
+ });
+ });
+ $$('.dcx-nav-link', expander).forEach((link) => {
+ if (link.dataset.dcxNav === activeId) link.setAttribute('aria-current', 'page');
+ else link.removeAttribute('aria-current');
+ });
+}
+
+function scrollToBlock(expander, index) {
+ const blocks = $$('.dcx-main .dcx-block[data-label]', expander);
+ const target = blocks[Number(index)];
+ if (!target) return;
+ const main = expander.querySelector('.dcx-main');
+ const top = target.getBoundingClientRect().top - main.getBoundingClientRect().top + main.scrollTop - 18;
+ main.scrollTo({ top: Math.max(0, top), behavior: reduceMotion ? 'auto' : 'smooth' });
+}
+
+function renderCategory(id, expander, updateHash) {
+ const template = document.getElementById(`dcx-detail-${id}`);
+ if (!template) return;
+
+ expander.querySelector('.dcx-current').textContent = names[id] || id;
+
+ const main = expander.querySelector('.dcx-main');
+ main.innerHTML = '';
+ main.appendChild(template.content.cloneNode(true));
+ main.scrollTop = 0;
+ initFan(main);
+
+ if (current) {
+ const tile = $(`.dcx-tile--${id}`);
+ current.id = id;
+ current.tile = tile;
+ current.rect = tile.getBoundingClientRect();
+ }
+
+ buildSubnav(expander, id);
+ if (updateHash) history.pushState({ category: id }, '', `#${id}`);
+}
+
+function openCategory(id, updateHash) {
+ if (!(id in names)) return;
+ if (current && current.id === id) return;
+ closeCategory(false);
+
+ const tile = $(`.dcx-tile--${id}`);
+ if (!tile) return;
+
+ const rect = tile.getBoundingClientRect();
+ const expander = document.createElement('section');
+ expander.className = 'dcx-expander';
+ expander.setAttribute('role', 'dialog');
+ expander.setAttribute('aria-modal', 'true');
+ expander.setAttribute('aria-label', `${names[id]} details`);
+ expander.style.top = `${rect.top}px`;
+ expander.style.left = `${rect.left}px`;
+ expander.style.width = `${rect.width}px`;
+ expander.style.height = `${rect.height}px`;
+
+ expander.appendChild(shellTemplate.content.cloneNode(true));
+ document.body.appendChild(expander);
+ document.body.classList.add('is-locked');
+ current = { id: null, expander, tile, rect, opener: tile };
+ renderCategory(id, expander, false);
+ current.id = id;
+
+ requestAnimationFrame(() => {
+ expander.classList.add('is-full');
+ window.setTimeout(() => {
+ expander.classList.add('is-ready');
+ expander.querySelector('.dcx-close')?.focus({ preventScroll: true });
+ }, READY_MS);
+ });
+
+ expander.querySelector('.dcx-close').addEventListener('click', () => closeCategory(true));
+
+ expander.querySelector('.dcx-nav').addEventListener('click', (event) => {
+ const subLink = event.target.closest('[data-dcx-subsection]');
+ if (subLink) {
+ scrollToBlock(expander, subLink.getAttribute('data-dcx-subsection'));
+ return;
+ }
+ const navLink = event.target.closest('[data-dcx-nav]');
+ if (!navLink) return;
+ event.preventDefault();
+ if (current && navLink.dataset.dcxNav === current.id) return;
+ renderCategory(navLink.dataset.dcxNav, expander, true);
+ });
+
+ if (updateHash) history.pushState({ category: id }, '', `#${id}`);
+}
+
+function closeCategory(updateHash) {
+ if (!current) return;
+ const { expander, rect, opener } = current;
+ expander.classList.remove('is-ready', 'is-full');
+ expander.style.top = `${rect.top}px`;
+ expander.style.left = `${rect.left}px`;
+ expander.style.width = `${rect.width}px`;
+ expander.style.height = `${rect.height}px`;
+ window.setTimeout(() => {
+ expander.remove();
+ document.body.classList.remove('is-locked');
+ }, MORPH_MS);
+ current = null;
+ if (opener) opener.focus({ preventScroll: true });
+ if (updateHash && location.hash) history.pushState(null, '', location.pathname + location.search);
+}
+
+tiles.forEach((tile) => {
+ tile.addEventListener('click', () => openCategory(tile.dataset.category, true));
+});
+
+/* Vignette draw animations use the homepage's stroke-dasharray: 100
+ (user units), but non-scaling-stroke makes Chromium measure dashes in
+ screen pixels. Translate: --pl = 100 viewBox units at the rendered
+ scale, refreshed on resize. */
+function sizeDrawStrokes() {
+ $$('.dcx-viz-svg').forEach((svg) => {
+ const paths = $$('.anim-draw, .anim-draw-delay', svg);
+ if (!paths.length) return;
+ const rect = svg.getBoundingClientRect();
+ if (!rect.width || !rect.height) return;
+ const scale = Math.min(rect.width, rect.height) / 40;
+ paths.forEach((path) => {
+ /* The homepage icons fit inside dasharray 100; custom paths longer
+ than 60 units (breathe shows the first 60% of the dash) get a
+ proportionally larger dash so they still finish drawing. */
+ const units = Math.max(100, path.getTotalLength() / 0.6);
+ path.style.setProperty('--pl', `${(units * scale).toFixed(1)}px`);
+ });
+ });
+}
+let drawResizeTimer;
+window.addEventListener('resize', () => {
+ window.clearTimeout(drawResizeTimer);
+ drawResizeTimer = window.setTimeout(() => { if (revealed) sizeDrawStrokes(); }, 150);
+});
+
+document.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape' && revealed) closeCategory(true);
+});
+
+window.addEventListener('popstate', () => {
+ if (!revealed) return;
+ const id = location.hash.replace('#', '');
+ if (id && id in names) {
+ if (current) renderCategory(id, current.expander, false);
+ else openCategory(id, false);
+ } else {
+ closeCategory(false);
+ }
+});
+
+/* ============================================================
+ Live edit session — the document as a working surface.
+
+ The picker server forks a doc-session sibling on submit and hands
+ this tab its address and token. From then on the document is
+ editable through two paths:
+
+ - Simple edits (a palette color) POST /doc/edit and the session
+ applies them itself: answers.json and DESIGN.md are rewritten by
+ a deterministic function, no model in the loop.
+ - Anything needing judgment (fonts, freeform asks) POSTs
+ /doc/request; the agent long-polls the queue, does the work, and
+ replies. The tray shows each request move pending -> working ->
+ done.
+
+ The tab learns about the outside world the way live mode's browser
+ does, scaled to polling: /doc/state every couple of seconds, and a
+ version bump means re-fetch answers.json and re-render.
+ ============================================================ */
+
+let docSession = null;
+let docVersion = 1;
+let docOnline = false;
+let trayRequests = [];
+
+const tray = $('[data-dcx-tray]');
+const requestModal = $('[data-dcx-request-modal]');
+
+const docLive = () => Boolean(docSession);
+
+function startDocSession(doc) {
+ docSession = doc;
+ document.body.classList.add('dcx-live');
+ schedulePoll(1500);
+}
+
+async function docPost(pathname, body) {
+ const response = await fetch(`${docSession.base}${pathname}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ token: docSession.token, ...body }),
+ });
+ if (!response.ok) throw new Error(`${pathname} failed: ${response.status}`);
+ return response.json();
+}
+
+let pollTimer;
+function schedulePoll(ms) {
+ window.clearTimeout(pollTimer);
+ pollTimer = window.setTimeout(pollDocState, ms);
+}
+
+async function pollDocState() {
+ if (!docSession) return;
+ try {
+ const response = await fetch(`${docSession.base}/doc/state?token=${encodeURIComponent(docSession.token)}`);
+ if (!response.ok) throw new Error('state');
+ const state = await response.json();
+ setDocOnline(true);
+ trayRequests = state.requests || [];
+ renderTray();
+ if (state.version !== docVersion) {
+ docVersion = state.version;
+ await adoptAnswers();
+ refreshDocument();
+ }
+ schedulePoll(2000);
+ } catch {
+ setDocOnline(false);
+ renderTray();
+ schedulePoll(8000);
+ }
+}
+
+function setDocOnline(value) {
+ docOnline = value;
+ document.body.classList.toggle('dcx-live', Boolean(docSession) && value);
+}
+
+/* The form is still the single source the renderer reads, so an edit made
+ anywhere lands there: values fetched back from answers.json are written
+ into the same named fields the questionnaire filled. */
+async function adoptAnswers() {
+ const response = await fetch(`${docSession.base}/doc/answers?token=${encodeURIComponent(docSession.token)}`);
+ if (!response.ok) return;
+ const { answers } = await response.json();
+ for (const [name, value] of Object.entries(answers || {})) {
+ if (Array.isArray(value) || typeof value !== 'string') continue;
+ const field = form.elements[name];
+ if (!field) continue;
+ if (typeof RadioNodeList !== 'undefined' && field instanceof RadioNodeList) field.value = value;
+ else if ('value' in field && field.type !== 'checkbox') field.value = value;
+ }
+ ensureFace(fieldValue('font-heading'));
+ ensureFace(fieldValue('font-body'));
+}
+
+/* A family the agent swapped in may not be loaded on this page yet; ask
+ Google Fonts for it and let the browser fall back if it is not there. */
+function ensureFace(family) {
+ if (!family || document.fonts?.check?.(`16px '${family}'`)) return;
+ const id = `dcx-face-${family.replace(/\W+/g, '-').toLowerCase()}`;
+ if (document.getElementById(id)) return;
+ const link = document.createElement('link');
+ link.id = id;
+ link.rel = 'stylesheet';
+ link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family).replace(/%20/g, '+')}:wght@300..800&display=swap`;
+ document.head.appendChild(link);
+}
+
+function refreshDocument() {
+ renderDocument();
+ if (current) renderCategory(current.id, current.expander, false);
+}
+
+/* ---------- Simple edits: palette colors ---------- */
+
+document.addEventListener('click', (event) => {
+ const button = event.target.closest('[data-edit-color]');
+ if (!button) return;
+ const input = button.parentElement.querySelector(`[data-color-input-for="${button.dataset.editColor}"]`);
+ input?.click();
+});
+
+document.addEventListener('change', (event) => {
+ const input = event.target.closest?.('[data-color-input-for]');
+ if (!input) return;
+ applyColorEdit(input.dataset.colorInputFor, input.value.toUpperCase());
+});
+
+async function applyColorEdit(role, hex) {
+ const field = form.elements[`palette-${role}`];
+ if (!field || field.value.toUpperCase() === hex) return;
+ field.value = hex;
+ refreshDocument();
+ if (!docLive()) return;
+ try {
+ const result = await docPost('/doc/edit', { kind: 'color', role, value: hex });
+ docVersion = result.version;
+ } catch {
+ setDocOnline(false);
+ renderTray();
+ }
+}
+
+/* ---------- Complex edits: the request modal ---------- */
+
+let requestKind = 'freeform';
+
+document.addEventListener('click', (event) => {
+ const trigger = event.target.closest('[data-dcx-request], [data-dcx-request-kind]');
+ if (!trigger || !requestModal) return;
+ requestKind = trigger.dataset.dcxRequestKind || 'freeform';
+ const category = current ? names[current.id] : 'Design context';
+ $('[data-dcx-request-scope]', requestModal).textContent = requestKind === 'font'
+ ? 'Typography change' : `${category} change`;
+ $('[data-dcx-request-fonts]', requestModal).hidden = requestKind !== 'font';
+ const prompt = $('.dcx-request-prompt', requestModal);
+ prompt.value = '';
+ $('[data-dcx-request-upload-note]', requestModal).textContent = '';
+ requestModal.showModal();
+ prompt.focus();
+});
+
+$('[data-dcx-request-cancel]', requestModal)?.addEventListener('click', () => requestModal.close());
+
+$('[data-dcx-request-send]', requestModal)?.addEventListener('click', async () => {
+ const prompt = $('.dcx-request-prompt', requestModal).value.trim();
+ if (!prompt || !docLive()) return;
+ const send = $('[data-dcx-request-send]', requestModal);
+ send.disabled = true;
+ try {
+ const files = $('[data-dcx-request-files]', requestModal)?.files || [];
+ const uploaded = [];
+ for (const file of files) {
+ const response = await fetch(`${docSession.base}/font-upload?token=${encodeURIComponent(docSession.token)}`, {
+ method: 'POST',
+ headers: { 'X-Font-Filename': file.name },
+ body: file,
+ });
+ if (response.ok) uploaded.push((await response.json()).path);
+ }
+ const result = await docPost('/doc/request', {
+ kind: requestKind,
+ prompt,
+ category: current ? current.id : '',
+ payload: uploaded.length ? { fonts: uploaded } : {},
+ });
+ docVersion = result.version;
+ requestModal.close();
+ schedulePoll(400);
+ } catch {
+ $('[data-dcx-request-upload-note]', requestModal).textContent = 'The edit session is unreachable; the request was not sent.';
+ } finally {
+ send.disabled = false;
+ }
+});
+
+/* ---------- The tray ---------- */
+
+const TRAY_LABELS = {
+ pending: 'Queued for the agent',
+ working: 'The agent is on it',
+ done: 'Applied',
+ error: 'Could not apply',
+};
+
+function renderTray() {
+ if (!tray) return;
+ const items = trayRequests.slice(-4);
+ const offline = docSession && !docOnline;
+ tray.hidden = !offline && items.length === 0;
+ tray.innerHTML = [
+ offline ? 'Edit session offline
Changes stay in this tab; reconnecting…
' : '',
+ ...items.map((entry) => `
+
+
+
+
${escapeHtml(entry.prompt)}
+
${escapeHtml(entry.message || TRAY_LABELS[entry.status] || entry.status)}
+
+
`),
+ ].join('');
+}
diff --git a/picker/scripts/palette-picker.js b/picker/scripts/palette-picker.js
index e267725ab..08b6df5da 100644
--- a/picker/scripts/palette-picker.js
+++ b/picker/scripts/palette-picker.js
@@ -42,6 +42,12 @@ const LOREM = {
'Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione.',
],
note: 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque.',
+ /* Short enough to set on one line at the handset's measure, so a list item
+ stays a list item there instead of becoming a third paragraph. */
+ items: [
+ 'Excepteur sint occaecat',
+ 'Non proident, sunt in culpa',
+ ],
caption: 'Lorem ipsum dolor sit amet, consectetur adipiscing.',
};
@@ -63,6 +69,12 @@ const APP = {
amounts: ['$12,400', '$3,860', '$9,215'],
panel: ['Preferences', 'Last 30 days'],
switches: ['Email digest', 'Compact rows'],
+ chartTitle: 'Volume by channel',
+ /* One word each, because the label under a bar has the bar's own width and
+ nothing more: a category that wraps or truncates here is a fault in the
+ drawing rather than a report on the pair. The handset takes the first
+ three, which is why the widest of them comes early. */
+ lanes: ['Direct', 'Search', 'Social', 'Email', 'Other'],
};
/* The same argument as APP, for the surface where the words belong to the
@@ -328,6 +340,11 @@ function loadFontStylesheet(pairs) {
document.head.append(link);
}
+/* A board takes the slots its surface has and stops, so a list longer than the
+ slots is ordinary. The other direction is a fault in the markup, and it is
+ left blank here rather than hidden: a hidden slot would leave the board
+ drawing one item fewer than the composition its grid was measured at, and
+ the blank is what makes the miscount visible. */
function fillIndexed(root, selector, values) {
if (!root) return;
root.querySelectorAll(selector).forEach((node, index) => {
@@ -366,14 +383,17 @@ function fillBoard(board, preview, specimen) {
fill('[data-type-note-body]', LOREM.note);
fill('[data-type-crumb]', DOCS.crumb);
fill('[data-type-caption]', LOREM.caption);
+ fill('[data-type-chart-title]', APP.chartTitle);
for (const card of [desktop, phoneBody]) {
fillIndexed(card, '[data-type-nav]', preview.nav);
fillIndexed(card, '[data-type-proof]', preview.proof);
fillIndexed(card, '[data-type-gallery-title]', preview.gallery.map(({ title }) => title));
fillIndexed(card, '[data-type-gallery-meta]', preview.gallery.map(({ meta }) => meta));
fillIndexed(card, '[data-type-passage]', LOREM.passages);
+ fillIndexed(card, '[data-type-item]', LOREM.items);
fillIndexed(card, '[data-type-stop]', INDEX.stops);
fillIndexed(card, '[data-type-rail]', rail);
+ fillIndexed(card, '[data-type-lane]', APP.lanes);
fillIndexed(card, '[data-type-column]', APP.columns);
fillIndexed(card, '[data-type-figure]', APP.figures);
fillIndexed(card, '[data-type-amount]', APP.amounts);
@@ -804,7 +824,12 @@ commitIconPack(checkedIconPack());
Only a change of energy restarts it. Sliding the pointer across a row it is
already previewing would otherwise keep the page in a permanent entrance. */
const motionOptions = document.querySelector('[data-question="motion"] .picker-strategy-choices');
-const motionScene = document.querySelector('.picker-preview-motion');
+// One board per surface the question is put to. All of them are mounted and one
+// is shown, so the replay covers every board rather than the visible one: a
+// hidden board's timeline is cancelled by its own display: none and starts over
+// when its tab is opened, and the two must not disagree about which frame is
+// first.
+const motionScenes = [...document.querySelectorAll('.picker-preview-motion')];
const checkedMotion = () => motionOptions.querySelector('input:checked').value;
let motionShown;
@@ -813,51 +838,67 @@ function replayMotion(energy) {
motionShown = energy;
// The hover rules resolve on their own; this only puts the timeline back to
// its first frame, pseudo-elements and all.
- for (const animation of motionScene.getAnimations({ subtree: true })) {
- animation.cancel();
- animation.play();
+ for (const scene of motionScenes) {
+ for (const animation of scene.getAnimations({ subtree: true })) {
+ animation.cancel();
+ animation.play();
+ }
}
}
-/* The restrained scene's pointer route is written in container units, but the
- nav bars space themselves in fixed pixels, so where a given bar sits as a
+/* Every scene's pointer route is written in container units, but the elements
+ it visits space themselves in fixed pixels, so where a given one sits as a
fraction of the frame changes with the frame's size. The route is measured
off the live layout instead: one custom property per stop, re-resolved
whenever the frame resizes, so the pointer lands on the element that
- reacts at every viewport. offsetLeft rather than getBoundingClientRect,
- because the entrance animations translate the bands, and a route measured
- mid-entrance would aim below the nav. */
-const motionDesk = motionScene.querySelector('.ps-desktop');
+ reacts at every viewport.
-function plotMotionRoute() {
- if (!motionDesk.clientWidth) return;
- // Summed up the offsetParent chain rather than read once: an element's
- // offsets are relative to its nearest positioned ancestor, which for the
- // buttons is not the frame.
- const center = (el) => {
- let x = el.offsetWidth / 2;
- let y = el.offsetHeight / 2;
- for (let node = el; node && node !== motionDesk; node = node.offsetParent) {
- x += node.offsetLeft;
- y += node.offsetTop;
+ The stops a board does not have are simply not set, which is what lets one
+ table serve both boards: the landing page's scenes visit its buttons and
+ cards, the portfolio's visit its plates and its carousel arrow, and neither
+ keyframe list names a property its own board cannot measure. */
+const MOTION_STOPS = {
+ '--mtr-nav1': '.ps-nav-bars i:nth-child(1)',
+ '--mtr-nav2': '.ps-nav-bars i:nth-child(2)',
+ '--mtr-cta1': '.ps-actions i:first-child',
+ '--mtr-cta2': '.ps-actions i:last-child',
+ '--mtr-card1': '.ps-gallery-item:nth-child(1) > i',
+ '--mxi-work1': '.ps-index-row:nth-of-type(1) > .ps-image',
+ '--mxi-work2': '.ps-index-row:nth-of-type(2) > .ps-image',
+ '--mxi-rail': '.ps-index-arrow--next',
+};
+
+function plotMotionRoute(scene = null) {
+ for (const board of scene ? [scene] : motionScenes) {
+ const desk = board.querySelector('.ps-desktop');
+ if (!desk?.clientWidth) continue;
+ // Summed up the offsetParent chain rather than read once: an element's
+ // offsets are relative to its nearest positioned ancestor, which for the
+ // buttons is not the frame.
+ const center = (el) => {
+ let x = el.offsetWidth / 2;
+ let y = el.offsetHeight / 2;
+ for (let node = el; node && node !== desk; node = node.offsetParent) {
+ x += node.offsetLeft;
+ y += node.offsetTop;
+ }
+ return { x: (x / desk.clientWidth) * 100, y: (y / desk.clientHeight) * 100 };
+ };
+ for (const [name, selector] of Object.entries(MOTION_STOPS)) {
+ const el = desk.querySelector(selector);
+ if (!el) continue;
+ const c = center(el);
+ board.style.setProperty(name, `${c.x.toFixed(2)}cqw ${c.y.toFixed(2)}cqh`);
}
- return { x: (x / motionDesk.clientWidth) * 100, y: (y / motionDesk.clientHeight) * 100 };
- };
- const stop = (name, el) => {
- const c = center(el);
- motionScene.style.setProperty(name, `${c.x.toFixed(2)}cqw ${c.y.toFixed(2)}cqh`);
- };
- const nav1 = motionDesk.querySelector('.ps-nav-bars i:nth-child(1)');
- stop('--mtr-nav1', nav1);
- stop('--mtr-nav2', motionDesk.querySelector('.ps-nav-bars i:nth-child(2)'));
- stop('--mtr-cta1', motionDesk.querySelector('.ps-actions i:first-child'));
- stop('--mtr-cta2', motionDesk.querySelector('.ps-actions i:last-child'));
- stop('--mtr-card1', motionDesk.querySelector('.ps-gallery-item:nth-child(1) > i'));
- // The entry and exit point: straight above the first nav item, off-frame.
- motionScene.style.setProperty('--mtr-entry', `${center(nav1).x.toFixed(2)}cqw -8cqh`);
+ // The entry and exit point: straight above the first nav item, off-frame.
+ const nav1 = desk.querySelector('.ps-nav-bars i:nth-child(1)');
+ board.style.setProperty('--mtr-entry', `${center(nav1).x.toFixed(2)}cqw -8cqh`);
+ }
}
-new ResizeObserver(plotMotionRoute).observe(motionDesk);
+for (const scene of motionScenes) {
+ new ResizeObserver(() => plotMotionRoute(scene)).observe(scene.querySelector('.ps-desktop'));
+}
const motionRowValue = (node) => node?.closest('.picker-strategy-option')?.querySelector('input').value;
@@ -1318,6 +1359,7 @@ const moves = new WeakMap();
// result has to be carried.
const paletteBands = $('.picker-bands', panel);
const strategyBands = document.querySelector('[data-band-scope="strategy"]');
+const strategyGrid = document.querySelector('.picker-screen[data-screen="03"] .picker-strategy-grid');
const reduceMotion = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
const bandNodes = (scope) => ROLES.map((role) => $(`[data-band="${role}"]`, scope));
const gripNodes = (scope) => ROLES.map((role) => $(`[data-grip="${role}"]`, scope));
@@ -1435,6 +1477,35 @@ function paintStrategyBands() {
}
}
+/* Everything screen 03 spends on something other than the answer: the block's
+ own padding, the question over the grid, and the gap between them. The grid
+ caps itself against what is left, so this is the number that decides whether
+ the screen fits the window.
+
+ Measured as the difference between the block and the grid inside it rather
+ than added up from the parts, because the parts are not knowable from here:
+ the question is one or two lines at a size that tracks the window's width. It
+ comes to between 259 and 312px across the laptop range. The literal in the
+ rule was 250, short by 9 to 62, and the screen paid the difference by running
+ past the fold. Nothing here reads the grid's height back into itself, so the
+ value settles on the first pass. */
+function fitStrategyColumn() {
+ const block = strategyGrid?.parentElement;
+ if (!block || !block.offsetParent) return;
+ const chrome = `${Math.ceil(
+ block.getBoundingClientRect().height - strategyGrid.getBoundingClientRect().height,
+ )}px`;
+ // Writing an unchanged value would still be a style change, and the resize it
+ // provokes is what turns a settling measurement into a loop.
+ if (block.style.getPropertyValue('--pk-chrome') === chrome) return;
+ block.style.setProperty('--pk-chrome', chrome);
+}
+
+/* The block's height is the only thing that can move what sits above the grid,
+ so it is what is watched: a question that reflowed to a second line and a
+ window that moved the padding clamp both arrive here. */
+if (strategyGrid) new ResizeObserver(fitStrategyColumn).observe(strategyGrid.parentElement);
+
function reorderedSummary(from, to) {
const { colors } = state();
return slotOrder(from, to)
@@ -1675,8 +1746,13 @@ document.addEventListener('picker:screenchange', (event) => {
syncCommittedPalette(artboard);
}
// Coming back to the strategy screen from further along, where the palette may
- // have been reordered on the screen it was left on.
- if (event.detail.screen === '03') paintStrategyBands();
+ // have been reordered on the screen it was left on. A hidden block measures
+ // zero, so its budget is resolved on arrival for the same reason the scale
+ // sheet's fit is.
+ if (event.detail.screen === '03') {
+ paintStrategyBands();
+ fitStrategyColumn();
+ }
// A per-surface question holds whatever tab it was left on while its own
// screen is up, and the rest follow it.
const leader = surfaceQuestions.find((question) => question.screen === event.detail.screen);
@@ -1745,9 +1821,8 @@ const syncModesNext = () => {
First in tile order, not first clicked. A multi-select answer has no other
stable primary, and click order would move the palette's test page around
for reasons the visitor cannot see. */
-const modePreviews = modeInputs.map((input) => (
- input.closest('.picker-mode-tile')?.querySelector('.picker-preview')
-));
+const modeTiles = modeInputs.map((input) => input.closest('.picker-mode-tile'));
+const modePreviews = modeTiles.map((tile) => tile?.querySelector('.picker-preview'));
const landingPreview = preview.cloneNode(true);
let previewSource;
@@ -1756,6 +1831,14 @@ function syncModePreview() {
// are rebuilt from here: every path that changes the tiles already runs this.
syncSurfaces();
const chosen = modeInputs.findIndex((input) => input.checked);
+ /* Which tile leads is also what the stylesheet needs, to hang the view
+ transition's name on the one drawing screen 02 goes on to show. Marked
+ ahead of the early return below, so the answer never rests on whether the
+ drawing itself changed, and left off a tile whose drawing is not this
+ component, since nothing of that tile arrives on the next screen. */
+ modeTiles.forEach((tile, index) => {
+ tile?.toggleAttribute('data-lead', index === chosen && Boolean(modePreviews[index]));
+ });
// A tile drawn in something other than this component keeps the landing page,
// which is also the floor for the empty answer the continue button blocks.
const source = (chosen === -1 ? null : modePreviews[chosen]) ?? landingPreview;
@@ -1787,7 +1870,7 @@ function syncModePreview() {
data-surface-answered="colored {}"
data-surface-unanswered="no color strategy chosen yet" hidden>
- inside the box that draws the frame, plus one disabled hidden field per
+ inside the stage the frame is drawn on, plus one disabled hidden field per
surface marked data-surface-field="-". That field is where
the surface's answer is kept; whether it also carries a name, and so whether
the run records a key per surface or only the leading surface's choice, is
@@ -1796,6 +1879,14 @@ function syncModePreview() {
data-default-; why an option is out is on the option itself as
data-blocked-reason. All of that comes from data/surfaces.js.
+ Which surfaces the question is put to at all is the set of fields it
+ rendered. A screen that leaves one out is asking nothing of that surface, so
+ its tab is never offered, its answer is never defaulted, and the bare key
+ falls to the leading surface that was asked. A screen every chosen surface
+ was left out of is not part of the run: it goes out of the form and the
+ navigation steps over it, because a key holding an answer nobody was asked
+ for reads downstream as a decision, and nothing can tell the two apart.
+
data-surface-stage on the frame additionally mounts one drawing per chosen
surface, lifted from that surface's tile and painted with the committed
palette. Screen 03 is the only screen that wants that today. A screen that
@@ -1808,9 +1899,9 @@ const surfaceInput = (value) => modeInputs.find((input) => input.value === value
function buildSurfaceQuestion(tabs) {
const name = tabs.dataset.surfaceTabs;
- // The frame is the strip's own positioned ancestor, which is also the box a
- // per-surface drawing has to land inside, so it is read off the DOM rather
- // than named a second time in the markup.
+ // The stage the strip sits on is also the box a per-surface drawing has to
+ // land inside, so it is read off the DOM rather than named a second time in
+ // the markup.
const frame = tabs.parentElement;
const screen = tabs.closest('.picker-screen')?.dataset.screen;
const mounts = 'surfaceStage' in frame.dataset;
@@ -1832,6 +1923,14 @@ function buildSurfaceQuestion(tabs) {
const defaultFor = (value) => surfaceInput(value)?.getAttribute(`data-default-${name}`) || optionInputs()[0]?.value;
const fieldFor = (value) => document.querySelector(`input[type="hidden"][data-surface-field="${name}-${value}"]`);
const titleOf = (value) => rowOf(value)?.querySelector('.picker-strategy-title')?.textContent ?? value;
+ // The fields are the scope: a surface with nowhere to leave an answer is one
+ // this question was never put to.
+ const applies = (value) => Boolean(fieldFor(value));
+ const applicable = () => chosenSurfaces().filter((input) => applies(input.value));
+ // A question every surface takes is asked on every run; only a scoped one can
+ // end up with nothing to ask.
+ const scoped = modeInputs.some((input) => !applies(input.value));
+ const host = tabs.closest('.picker-screen');
let activeSurface = null;
/* One drawing per chosen surface. All of them stay mounted and one is shown,
@@ -1861,7 +1960,15 @@ function buildSurfaceQuestion(tabs) {
}
function sync() {
- const chosen = chosenSurfaces();
+ const chosen = applicable();
+ /* Nothing left to ask, so the screen leaves the run: marked for the
+ navigation to step over, and its group taken out of the form so no key
+ comes back for it. applyApplicability() re-enables the rows the moment a
+ surface that takes the question is chosen again. */
+ if (scoped) {
+ host?.toggleAttribute('data-skip', chosen.length === 0);
+ for (const input of optionInputs()) input.disabled = chosen.length === 0;
+ }
if (mounts) mount(chosen);
/* Every chosen surface leaves an answer whether or not it was ever opened,
@@ -1887,8 +1994,9 @@ function buildSurfaceQuestion(tabs) {
}
/* One surface needs no tabs: the frame is already showing the only answer
- there is. The dot is the whole report on state, filled once the surface has
- been answered deliberately and hollow while it is still holding a default. */
+ there is. A tab carries the surface it names and the dot that reports
+ whether that surface has been answered; which of the two states it is in is
+ markTabs()'s to write. */
function buildTabs(chosen) {
tabs.hidden = chosen.length < 2;
tabs.replaceChildren(...chosen.map((input) => {
@@ -1904,19 +2012,30 @@ function buildSurfaceQuestion(tabs) {
markTabs();
}
+ /* The answer a tab reports is its own surface's field rather than the radio
+ on screen, which belongs to whichever tab is open. The dot says which kind
+ of answer it is: filled once someone chose it, hollow while it is still the
+ default the surface was given. The label says the answer itself, which the
+ tab no longer shows. */
function markTabs() {
for (const tab of tabs.children) {
const value = tab.dataset.surfaceTab;
const field = fieldFor(value);
const set = Boolean(field?.dataset.chosen);
const on = value === activeSurface;
+ const title = field.value ? titleOf(field.value) : '';
+ const name = tab.textContent;
tab.dataset.set = set ? 'yes' : 'no';
tab.setAttribute('aria-pressed', on ? 'true' : 'false');
tab.tabIndex = on ? 0 : -1;
- const title = titleOf(field.value);
- tab.setAttribute('aria-label', set
- ? `${tab.textContent}, ${tabs.dataset.surfaceAnswered.replace('{}', properName ? title : title.toLowerCase())}`
- : `${tab.textContent}, ${tabs.dataset.surfaceUnanswered}`);
+ /* A screen with no rows dealt yet has no answer to report, which is a
+ different state from an answer nobody chose and is the one the
+ question's unanswered sentence was written for. */
+ const answered = title
+ && tabs.dataset.surfaceAnswered.replace('{}', properName ? title : title.toLowerCase());
+ tab.setAttribute('aria-label', title
+ ? `${name}, ${answered}${set ? '' : ' by default'}`
+ : `${name}, ${tabs.dataset.surfaceUnanswered}`);
}
}
@@ -1973,7 +2092,7 @@ function buildSurfaceQuestion(tabs) {
what it had, which is the whole reason the fields are kept per surface on
a question that only writes one of them down. */
function record(value) {
- const surfaces = flat ? chosenSurfaces().map((input) => input.value) : [activeSurface];
+ const surfaces = flat ? applicable().map((input) => input.value) : [activeSurface];
for (const surface of surfaces) {
const field = fieldFor(surface);
const allowed = allowedFor(surface);
@@ -2000,8 +2119,14 @@ function buildSurfaceQuestion(tabs) {
the radio the rest of the run reads is parked on whichever surface is
being looked at, and on the leading one when nothing on screen is showing
tabs. Without it the run would carry whichever surface was last on the tab,
- and an option switched off for that surface would leave the answer empty. */
- const park = (surface) => show(surface || chosenSurfaces()[0]?.value);
+ and an option switched off for that surface would leave the answer empty.
+
+ A surface this question was never put to cannot lead it, so the leading
+ surface overall is followed only where it was asked and the first surface
+ in tile order that was asked leads otherwise. That is the rule the bare key
+ is written by: a run of app UI plus a portfolio has app UI leading the
+ questions both surfaces answer, and the portfolio leading motion. */
+ const park = (surface) => show(applies(surface) ? surface : applicable()[0]?.value);
const api = { screen, sync, paint, park, active: () => activeSurface };
return api;
diff --git a/picker/styles/design-context.css b/picker/styles/design-context.css
new file mode 100644
index 000000000..322a79d65
--- /dev/null
+++ b/picker/styles/design-context.css
@@ -0,0 +1,1591 @@
+/* ============================================================
+ Design context document — ported from
+ docs/design-context-categorization/design-context.html.
+ The mosaic landing, tile-to-fullscreen morph, sidebar shell,
+ and article vocabulary are the prototype's; only the theme
+ plumbing changed (picker vendors the kinpaku tokens and runs
+ dark-only, so the light remaps and the standalone token copy
+ are gone). Section vars scope to the doc's own roots.
+ ============================================================ */
+.dcx-shell,
+.dcx-expander {
+ --accent: var(--ks-kinpaku);
+ --accent-hover: var(--ks-kinpaku-pale);
+ --accent-wash: oklch(78% 0.12 82 / 0.1);
+ --accent-line: oklch(78% 0.12 82 / 0.32);
+ --panel-bg: oklch(11% 0.006 95);
+ --shadow-color: oklch(0% 0 0 / 0.4);
+
+ --viz-ink: var(--ks-text);
+ --viz-mist: oklch(78% 0 0 / 0.3);
+ --viz-accent: var(--ks-kinpaku);
+
+ font-family: var(--ks-font);
+ font-size: 1rem;
+ line-height: 1.6;
+ color: var(--ks-text);
+ -webkit-font-smoothing: antialiased;
+}
+
+body.is-locked { overflow: hidden; }
+
+/* The document takes the page over: the picker's shell (form, progress,
+ hero art) leaves the flow entirely, and the mosaic becomes the page.
+ The width gate stands down too: the questionnaire needs a wide viewport,
+ but the document has its own stacked layout below 920px. */
+body.dcx-open .picker-shell { display: none; }
+body.dcx-open .picker-width-gate { display: none; }
+body.dcx-open { background: linear-gradient(180deg, var(--ks-lacquer), var(--ks-lacquer-deep)); }
+
+.dcx-shell {
+ min-height: 100svh;
+ background: linear-gradient(180deg, var(--ks-lacquer), var(--ks-lacquer-deep));
+}
+
+/* Masthead — the slot the prototype gave its site header, reduced to what
+ the picker owes: the mark, what this page is, and that the answers are
+ already on disk. */
+.dcx-masthead {
+ display: flex;
+ align-items: center;
+ gap: 18px;
+ padding: 18px clamp(14px, 2vw, 26px) 6px;
+}
+
+.dcx-masthead-brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ color: var(--ks-kinpaku);
+}
+
+.dcx-masthead-brand svg { width: 22px; height: 22px; fill: currentColor; flex-shrink: 0; }
+
+.dcx-masthead-brand span {
+ font-family: var(--ks-font-display);
+ font-weight: 400;
+ font-size: 1.02rem;
+ letter-spacing: 0.15em;
+ text-transform: uppercase;
+ line-height: 1;
+}
+
+.dcx-masthead-title {
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-family: var(--ks-mono);
+ font-size: 0.7rem;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+ color: var(--ks-text-muted);
+}
+
+.dcx-masthead-note {
+ font-family: var(--ks-mono);
+ font-size: 0.7rem;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+ color: var(--ks-text-faint);
+ white-space: nowrap;
+}
+
+@media (max-width: 920px) {
+ .dcx-masthead-note { display: none; }
+}
+
+.dcx-expander code {
+ font-family: var(--ks-mono);
+ font-size: 0.82em;
+ color: var(--ks-code-fg);
+ background: var(--ks-code-bg);
+ border-radius: 3px;
+ padding: 0.14em 0.4em;
+ white-space: nowrap;
+}
+
+/* ============================================================
+ Mosaic landing
+ ============================================================ */
+.dcx-shell {
+ --dcx-gap: clamp(10px, 0.9vw, 14px);
+ padding: var(--dcx-gap) clamp(14px, 2vw, 26px) clamp(16px, 2.2vw, 28px);
+}
+
+.dcx-grid {
+ position: relative;
+ /* The prototype budgeted 130px for the site header; the picker's masthead
+ is slimmer, so the grid keeps more of the viewport. */
+ height: calc(100svh - 96px);
+ min-height: 560px;
+ display: grid;
+ grid-template-columns: repeat(18, minmax(0, 1fr));
+ grid-template-rows: repeat(12, minmax(0, 1fr));
+ gap: var(--dcx-gap);
+}
+
+.dcx-tile--product { grid-column: 1 / 5; grid-row: 1 / 7; --vz-delay: -1.1s; }
+.dcx-tile--brand { grid-column: 5 / 11; grid-row: 1 / 6; --vz-delay: -1.9s; }
+.dcx-tile--audience { grid-column: 11 / 15; grid-row: 1 / 7; --vz-delay: -0.4s; }
+.dcx-tile--typography { grid-column: 15 / 19; grid-row: 1 / 6; --vz-delay: -2.6s; }
+.dcx-tile--color { grid-column: 5 / 10; grid-row: 6 / 13; --vz-delay: -0.7s; }
+.dcx-tile--iconography { grid-column: 1 / 5; grid-row: 7 / 13; --vz-delay: -1.3s; }
+.dcx-tile--interface { grid-column: 10 / 15; grid-row: 7 / 13; --vz-delay: -1.6s; }
+.dcx-tile--material { grid-column: 15 / 19; grid-row: 6 / 13; --vz-delay: -3.2s; }
+
+.dcx-tile {
+ position: relative;
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr);
+ gap: clamp(12px, 1.6vw, 24px);
+ min-width: 0;
+ min-height: 0;
+ padding: clamp(18px, 2.2vw, 36px);
+ background: var(--panel-bg);
+ border: 1px solid var(--ks-rule);
+ border-radius: 8px;
+ overflow: hidden;
+ color: var(--ks-champagne);
+ cursor: pointer;
+ text-align: left;
+ isolation: isolate;
+ container-type: inline-size;
+ font-family: inherit;
+ transition:
+ transform 220ms var(--ks-ease),
+ border-color 220ms var(--ks-ease),
+ background 220ms var(--ks-ease);
+}
+
+/* Hover follows the kit's pagination treatment: the surface stays put,
+ only the border (and title) take the accent. */
+.dcx-tile:hover,
+.dcx-tile:focus-visible {
+ transform: translateY(-2px);
+ border-color: var(--accent);
+ outline: none;
+}
+
+.dcx-tile:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
+
+/* Staggered reveal on load — the homepage foundation-grid treatment. */
+.dcx-tile[data-reveal],
+.dcx-badge[data-reveal] {
+ opacity: 0;
+ transform: translateY(30px);
+ transition:
+ opacity 0.8s var(--ks-ease),
+ transform 0.8s var(--ks-ease);
+ transition-delay: var(--reveal-delay, 0s);
+}
+
+.dcx-badge[data-reveal] { transform: translateY(0) scale(0.6); }
+
+.dcx-tile[data-reveal].revealed {
+ opacity: 1;
+ transform: translateY(0);
+ /* Hand hover transforms back to the fast transition once revealed. */
+ transition:
+ transform 220ms var(--ks-ease),
+ border-color 220ms var(--ks-ease),
+ background 220ms var(--ks-ease),
+ opacity 0.8s var(--ks-ease);
+ transition-delay: 0s;
+}
+
+.dcx-badge[data-reveal].revealed {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+}
+
+.dcx-tile-title {
+ position: relative;
+ z-index: 2;
+ margin: 0;
+ font-family: var(--ks-font-display);
+ font-size: clamp(2.3rem, 4.8vw, 4.4rem);
+ font-weight: 100;
+ line-height: 1.02;
+ letter-spacing: 0.01em;
+ color: inherit;
+ text-wrap: balance;
+ transition: color 220ms var(--ks-ease);
+}
+
+.dcx-tile:hover .dcx-tile-title,
+.dcx-tile:focus-visible .dcx-tile-title { color: var(--accent); }
+
+.dcx-tile--typography .dcx-tile-title,
+.dcx-tile--iconography .dcx-tile-title {
+ font-size: min(clamp(2.3rem, 4.8vw, 4.4rem), 20cqw);
+}
+
+/* Center mark on the gutter seam — the bare glyph in kinpaku gold,
+ inset by the same gap the mosaic tiles use. The padding ring is
+ painted page-background so it reads as seam, not as a frame. */
+.dcx-badge {
+ position: relative;
+ z-index: 0;
+ grid-column: 10 / 11;
+ grid-row: 6 / 7;
+ width: clamp(82px, 7vw, 118px);
+ height: clamp(82px, 7vw, 118px);
+ display: grid;
+ place-items: center;
+ align-self: center;
+ justify-self: center;
+ padding: var(--dcx-gap);
+ background: var(--ks-lacquer);
+ /* glyph corner radius (~9% of the box) plus the gap, so the ring hugs the mark */
+ border-radius: calc(9% + var(--dcx-gap));
+ color: var(--ks-kinpaku);
+ pointer-events: none;
+}
+
+.dcx-badge svg { width: 100%; height: 100%; display: block; fill: currentColor; }
+
+/* ============================================================
+ Tile vignettes — foundation-card animation system.
+ The title renders first; the vignette sits below it and scales
+ to fill whatever space the tile has left.
+ ============================================================ */
+.dcx-tile-title { order: 1; }
+
+.dcx-tile-viz {
+ order: 2;
+ min-width: 0;
+ min-height: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--viz-ink);
+}
+
+.dcx-viz-svg {
+ width: 100%;
+ height: 100%;
+ max-width: 100%;
+ max-height: 100%;
+}
+
+/* Every vignette stroke uses vector-effect: non-scaling-stroke, so the
+ line weight is a fixed pixel value no matter how large the tile lets
+ the SVG scale. One thinness across all eight tiles. */
+.dcx-viz-svg .vz-stroke { stroke-width: 3.5px; }
+.dcx-viz-svg .vz-ghost { stroke-width: 2.5px; }
+
+@media (max-width: 920px) {
+ .dcx-viz-svg .vz-stroke { stroke-width: 1.5px; }
+ .dcx-viz-svg .vz-ghost { stroke-width: 1px; }
+}
+
+/* Draw (Typography, Material, Brand, Audience figure).
+ Same draw-breathe / draw-in choreography as the impeccable homepage.
+ The homepage uses stroke-dasharray: 100 in user units, but with
+ vector-effect: non-scaling-stroke Chromium measures dashes in screen
+ pixels, so a fixed 100 chops scaled-up paths into segments. A script
+ sets --pl on each vignette SVG to "100 user units in screen px" and
+ the dash keyframes work from that, giving the exact site animation
+ at any tile size. */
+.anim-draw {
+ stroke-dasharray: var(--pl, 999px);
+ stroke-dashoffset: var(--pl, 999px);
+ animation: draw-breathe 4s ease-in-out var(--vz-delay, 0s) infinite;
+}
+.dcx-tile:hover .anim-draw { animation: draw-in 0.8s var(--ks-ease) forwards; }
+.anim-draw-delay {
+ stroke-dasharray: var(--pl, 999px);
+ stroke-dashoffset: var(--pl, 999px);
+}
+.dcx-tile:hover .anim-draw-delay { animation: draw-in 1s var(--ks-ease) 0.2s forwards; }
+@keyframes draw-breathe {
+ 0%, 100% { stroke-dashoffset: var(--pl, 999px); }
+ 50% { stroke-dashoffset: calc(var(--pl, 999px) * 0.4); }
+}
+@keyframes draw-in {
+ from { stroke-dashoffset: var(--pl, 999px); }
+ to { stroke-dashoffset: 0px; }
+}
+
+/* Color venn (breathing pulse, spread on hover) */
+.anim-move-x { animation: pulse-x 3s ease-in-out var(--vz-delay, 0s) infinite; }
+.dcx-tile:hover .anim-move-x { animation: spread-x 0.6s ease-in-out forwards; }
+.anim-move-x-opp { animation: pulse-x-opp 3s ease-in-out var(--vz-delay, 0s) infinite; }
+.dcx-tile:hover .anim-move-x-opp { animation: spread-x-opp 0.6s ease-in-out forwards; }
+.anim-fade-in { opacity: 0; transition: opacity 0.6s ease-in-out; }
+.dcx-tile:hover .anim-fade-in { opacity: 1; }
+@keyframes pulse-x { 0%, 100% { transform: translateX(0); } 50% { transform: translateX(1.5px); } }
+@keyframes pulse-x-opp { 0%, 100% { transform: translateX(0); } 50% { transform: translateX(-1.5px); } }
+@keyframes spread-x { from { transform: translateX(0); } to { transform: translateX(4px); } }
+@keyframes spread-x-opp { from { transform: translateX(0); } to { transform: translateX(-4px); } }
+
+/* Interface toggle (gentle drift, full snap on hover) */
+.anim-toggle-move { animation: toggle-drift 3s ease-in-out var(--vz-delay, 0s) infinite; }
+.dcx-tile:hover .anim-toggle-move { animation: toggle-snap 0.35s ease-in-out forwards; }
+@keyframes toggle-drift { 0%, 100% { transform: translateX(0); } 50% { transform: translateX(2px); } }
+@keyframes toggle-snap {
+ from { transform: translateX(0); fill: var(--viz-mist); }
+ to { transform: translateX(8px); fill: var(--viz-accent); }
+}
+
+/* Product pitch lines (cursor always blinks) */
+.anim-blink { animation: blink-key 1s step-end var(--vz-delay, 0s) infinite; }
+@keyframes blink-key { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
+
+/* Iconography glyph grid. At rest the four glyphs brighten one by one on
+ a shared 4.8s cycle (1.2s apart). Opacity only — scaling the glyphs
+ blurs the strokes into a halo and makes them balloon. Hovering kills
+ the cycle and lights all four with a small stagger. */
+.anim-pop {
+ opacity: 0.35;
+ transition: opacity 0.35s var(--ks-ease);
+ animation: pop-glow 4.8s ease-in-out infinite;
+}
+.anim-pop-1 { transition-delay: 0s; animation-delay: calc(var(--vz-delay, 0s) + 0s); }
+.anim-pop-2 { transition-delay: 0.07s; animation-delay: calc(var(--vz-delay, 0s) + 1.2s); }
+.anim-pop-3 { transition-delay: 0.14s; animation-delay: calc(var(--vz-delay, 0s) + 2.4s); }
+.anim-pop-4 { transition-delay: 0.21s; animation-delay: calc(var(--vz-delay, 0s) + 3.6s); }
+.dcx-tile:hover .anim-pop { animation: none; opacity: 1; }
+@keyframes pop-glow {
+ 0%, 25%, 100% { opacity: 0.35; }
+ 10% { opacity: 1; }
+}
+
+/* Audience second figure joins on hover */
+.anim-join { opacity: 0.25; transition: opacity 0.5s var(--ks-ease); }
+.dcx-tile:hover .anim-join { opacity: 1; }
+
+/* ============================================================
+ Expander — tile-to-fullscreen morph
+ ============================================================ */
+.dcx-expander {
+ position: fixed;
+ z-index: 500;
+ overflow: hidden;
+ border-radius: 8px;
+ background: var(--ks-lacquer);
+ color: var(--ks-text);
+ box-shadow: 0 18px 46px var(--shadow-color);
+ transition:
+ top 520ms var(--ks-ease),
+ left 520ms var(--ks-ease),
+ width 520ms var(--ks-ease),
+ height 520ms var(--ks-ease),
+ border-radius 520ms var(--ks-ease);
+}
+
+.dcx-expander.is-full {
+ top: 0 !important;
+ left: 0 !important;
+ width: 100vw !important;
+ height: 100svh !important;
+ border-radius: 0;
+}
+
+.dcx-expander-inner {
+ width: 100%;
+ height: 100svh;
+ min-height: 0;
+ display: grid;
+ grid-template-columns: 264px minmax(0, 1fr);
+ opacity: 0;
+ transform: translateY(44px);
+ transition: opacity 420ms var(--ks-ease), transform 520ms var(--ks-ease);
+ transition-delay: 260ms;
+}
+
+.dcx-expander.is-ready .dcx-expander-inner { opacity: 1; transform: translateY(0); }
+
+/* ============================================================
+ Expander sidebar — DocsSidebar visual language
+ ============================================================ */
+.dcx-sidebar {
+ min-height: 0;
+ overflow: hidden auto;
+ padding: 26px 22px 32px;
+ border-right: 1px solid var(--ks-rule);
+ background: var(--ks-lacquer-deep);
+ scrollbar-width: thin;
+ scrollbar-color: var(--ks-rule) transparent;
+}
+
+.dcx-sidebar-brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ margin: 2px 0 28px;
+ color: var(--ks-kinpaku);
+ text-decoration: none;
+}
+
+
+.dcx-sidebar-brand svg { width: 26px; height: 26px; fill: currentColor; flex-shrink: 0; }
+
+.dcx-sidebar-brand-name {
+ font-family: var(--ks-font-display);
+ font-weight: 400;
+ font-size: 1.02rem;
+ letter-spacing: 0.15em;
+ text-transform: uppercase;
+ line-height: 1;
+ -webkit-font-smoothing: auto;
+}
+
+.dcx-nav-label {
+ display: block;
+ font-size: 0.72rem;
+ font-weight: 650;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--accent);
+ margin-bottom: 10px;
+ padding-left: 14px;
+}
+
+.dcx-nav-list { list-style: none; padding: 0; margin: 0; }
+.dcx-nav-list li { margin: 0; }
+
+.dcx-nav-link {
+ display: block;
+ width: 100%;
+ padding: 5px 0 5px 12px;
+ border: 0;
+ border-left: 2px solid transparent;
+ background: transparent;
+ font-family: var(--ks-font);
+ font-size: 0.94rem;
+ font-weight: 400;
+ line-height: 1.5;
+ color: var(--ks-text);
+ text-align: left;
+ text-decoration: none;
+ cursor: pointer;
+ transition: color 160ms var(--ks-ease), border-color 160ms var(--ks-ease);
+}
+
+.dcx-nav-link:hover,
+.dcx-nav-link:focus-visible { color: var(--accent-hover); outline: none; }
+
+.dcx-nav-link[aria-current="page"] {
+ color: var(--accent);
+ font-weight: 600;
+ border-left-color: var(--accent);
+}
+
+.dcx-subnav { display: none; padding: 2px 0 8px 24px; }
+
+.dcx-nav-list li.is-active .dcx-subnav {
+ display: grid;
+ gap: 2px;
+ animation: subnav-in 180ms var(--ks-ease) both;
+}
+
+@keyframes subnav-in {
+ from { opacity: 0; transform: translateY(-4px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+.dcx-sub-link {
+ display: block;
+ width: 100%;
+ padding: 3px 0;
+ border: 0;
+ background: transparent;
+ font-family: var(--ks-font);
+ font-size: 0.85rem;
+ color: var(--ks-text-muted);
+ text-align: left;
+ cursor: pointer;
+ transition: color 160ms var(--ks-ease);
+}
+
+.dcx-sub-link:hover,
+.dcx-sub-link:focus-visible { color: var(--accent-hover); outline: none; }
+
+/* ============================================================
+ Expander topbar + main
+ ============================================================ */
+.dcx-panel {
+ position: relative;
+ min-width: 0;
+ min-height: 0;
+ display: grid;
+ grid-template-rows: 64px minmax(0, 1fr);
+}
+
+.dcx-topbar {
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 0 clamp(22px, 4vw, 48px);
+ border-bottom: 1px solid var(--ks-rule);
+}
+
+.dcx-current {
+ min-width: 0;
+ font-family: var(--ks-mono);
+ font-size: 0.7rem;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+ color: var(--ks-text-muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.dcx-close {
+ flex-shrink: 0;
+ width: 40px;
+ height: 40px;
+ display: grid;
+ place-items: center;
+ border: 1px solid var(--ks-rule);
+ border-radius: 999px;
+ background: transparent;
+ color: var(--ks-text);
+ cursor: pointer;
+ transition:
+ color 160ms var(--ks-ease),
+ border-color 160ms var(--ks-ease),
+ transform 220ms var(--ks-ease);
+}
+
+.dcx-close:hover,
+.dcx-close:focus-visible {
+ color: var(--accent);
+ border-color: var(--accent-line);
+ transform: rotate(90deg);
+ outline: none;
+}
+
+.dcx-close::before,
+.dcx-close::after {
+ content: "";
+ grid-area: 1 / 1;
+ width: 16px;
+ height: 1.5px;
+ background: currentColor;
+}
+
+.dcx-close::before { transform: rotate(45deg); }
+.dcx-close::after { transform: rotate(-45deg); }
+
+.dcx-main {
+ min-height: 0;
+ overflow: auto;
+ scrollbar-width: thin;
+ scrollbar-color: var(--ks-rule) transparent;
+}
+
+.dcx-main:focus { outline: none; }
+
+/* ============================================================
+ Article
+ ============================================================ */
+/* Wide enough for the questionnaire's own drawings to breathe; running text
+ holds its measure through the caps on lede, defs, and callouts below. */
+.dcx-article {
+ max-width: 1180px;
+ margin: 0 auto;
+ padding: clamp(36px, 5vw, 64px) clamp(22px, 4vw, 56px) 110px;
+}
+
+.dcx-eyebrow {
+ display: block;
+ font-family: var(--ks-mono);
+ font-size: 0.7rem;
+ letter-spacing: 0.18em;
+ text-transform: uppercase;
+ color: var(--accent);
+ margin-bottom: 16px;
+}
+
+.dcx-title {
+ margin: 0 0 18px;
+ font-family: var(--ks-font-display);
+ font-weight: 100;
+ font-size: clamp(3rem, 6vw, 5.2rem);
+ line-height: 1.02;
+ letter-spacing: -0.01em;
+ color: var(--ks-kinpaku);
+ text-wrap: balance;
+}
+
+
+.dcx-lede {
+ margin: 0;
+ max-width: 56ch;
+ font-size: 1.08rem;
+ line-height: 1.65;
+ color: var(--ks-text);
+}
+
+.dcx-block { margin-top: clamp(34px, 5vw, 52px); }
+
+.dcx-block-label {
+ display: block;
+ font-family: var(--ks-mono);
+ font-size: 0.68rem;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: var(--ks-text-faint);
+ margin-bottom: 14px;
+}
+
+/* Definition rows: term + description. */
+.dcx-defs { border-top: 1px solid var(--ks-rule); }
+
+.dcx-def {
+ display: grid;
+ grid-template-columns: minmax(180px, 0.44fr) minmax(0, 1fr);
+ gap: 6px 26px;
+ align-items: baseline;
+ padding: 15px 0;
+ border-bottom: 1px solid var(--ks-rule);
+}
+
+.dcx-def dt {
+ margin: 0;
+ font-size: 0.98rem;
+ font-weight: 600;
+ line-height: 1.4;
+ color: var(--ks-champagne);
+}
+
+.dcx-def dd {
+ margin: 0;
+ font-size: 0.92rem;
+ line-height: 1.55;
+ color: var(--ks-text-muted);
+}
+
+/* Chips. */
+.dcx-chips { display: flex; flex-wrap: wrap; gap: 8px; }
+
+.dcx-chip {
+ display: inline-flex;
+ align-items: center;
+ padding: 6px 13px;
+ border: 1px solid var(--accent-line);
+ border-radius: 999px;
+ background: var(--accent-wash);
+ color: var(--accent-hover);
+ font-size: 0.85rem;
+ font-weight: 500;
+}
+
+
+.dcx-chip--muted {
+ border-color: var(--ks-rule);
+ background: transparent;
+ color: var(--ks-text-muted);
+}
+
+/* Callout card (north star, named rules). */
+.dcx-callout {
+ padding: clamp(20px, 3vw, 30px);
+ border: 1px solid var(--ks-rule);
+ border-radius: 10px;
+ background: var(--panel-bg);
+}
+
+.dcx-callout + .dcx-callout { margin-top: 12px; }
+
+.dcx-callout-name {
+ margin: 0 0 8px;
+ font-size: 1.02rem;
+ font-weight: 600;
+ color: var(--ks-champagne);
+}
+
+.dcx-callout p:not(.dcx-callout-name) {
+ margin: 0;
+ font-size: 0.92rem;
+ line-height: 1.6;
+ color: var(--ks-text-muted);
+}
+
+.dcx-callout--accent { border-color: var(--accent-line); background: var(--accent-wash); }
+
+/* Numbered principle list. */
+.dcx-principles {
+ list-style: none;
+ counter-reset: principle;
+ margin: 0;
+ padding: 0;
+ border-top: 1px solid var(--ks-rule);
+}
+
+.dcx-principles li {
+ counter-increment: principle;
+ display: grid;
+ grid-template-columns: 44px minmax(0, 1fr);
+ gap: 20px;
+ align-items: baseline;
+ padding: 14px 0;
+ border-bottom: 1px solid var(--ks-rule);
+ font-size: 0.94rem;
+ line-height: 1.55;
+ color: var(--ks-text-muted);
+}
+
+.dcx-principles li::before {
+ content: counter(principle, decimal-leading-zero);
+ font-family: var(--ks-mono);
+ font-size: 0.78rem;
+ color: var(--accent);
+}
+
+.dcx-principles strong { color: var(--ks-champagne); font-weight: 600; }
+
+/* Plain bullet list. */
+.dcx-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ gap: 9px;
+}
+
+.dcx-list li {
+ position: relative;
+ padding-left: 20px;
+ font-size: 0.94rem;
+ line-height: 1.55;
+ color: var(--ks-text-muted);
+}
+
+.dcx-list li::before {
+ content: "";
+ position: absolute;
+ left: 2px;
+ top: 0.62em;
+ width: 7px;
+ height: 1.5px;
+ background: var(--accent);
+}
+
+.dcx-list strong { color: var(--ks-champagne); font-weight: 600; }
+
+/* Swatch fan (Color) — hover fans up, click copies. */
+.dcx-fan {
+ position: relative;
+ height: clamp(150px, 16vw, 195px);
+ overflow: hidden;
+ isolation: isolate;
+ border-radius: 10px 10px 0 0;
+}
+
+.dcx-fan::after {
+ content: "";
+ position: absolute;
+ left: 0; right: 0; bottom: 0;
+ z-index: 30;
+ height: 1px;
+ background: var(--ks-rule);
+ pointer-events: none;
+}
+
+.dcx-fan-panel {
+ position: absolute;
+ inset-block: 0;
+ left: var(--panel-left);
+ z-index: var(--panel-z);
+ width: var(--panel-width, 40%);
+ border: 0;
+ border-radius: 22px 22px 0 0;
+ padding: 0;
+ background: var(--panel-swatch);
+ box-shadow: inset 0 0 0 1px oklch(78% 0 0 / 0.14);
+ color: var(--panel-ink);
+ cursor: copy;
+ text-align: left;
+ transform: translate3d(0, 58%, 0);
+ transform-origin: 50% 100%;
+ transition:
+ transform 740ms cubic-bezier(0.16, 1, 0.3, 1),
+ filter 460ms cubic-bezier(0.16, 1, 0.3, 1);
+ will-change: transform;
+ backface-visibility: hidden;
+}
+
+.dcx-fan.is-engaged .dcx-fan-panel { transform: translate3d(0, 54%, 0); }
+.dcx-fan.is-engaged .dcx-fan-panel.is-neighbor { transform: translate3d(0, 48%, 0); }
+
+.dcx-fan.is-engaged .dcx-fan-panel.is-active,
+.dcx-fan-panel:focus-visible,
+.dcx-fan-panel.is-copied {
+ outline: none;
+ filter: saturate(1.04);
+ transform: translate3d(0, 10%, 0) rotate(-0.48deg);
+}
+
+.dcx-fan-info {
+ position: absolute;
+ top: clamp(20px, 2.6vw, 34px);
+ left: clamp(20px, 2.6vw, 34px);
+ right: clamp(20px, 2.6vw, 34px);
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 14px;
+ opacity: 0;
+ transform: translateY(10px);
+ transition: opacity 320ms var(--ks-ease), transform 420ms var(--ks-ease);
+ pointer-events: none;
+}
+
+.dcx-fan-panel.is-active .dcx-fan-info,
+.dcx-fan-panel:focus-visible .dcx-fan-info,
+.dcx-fan-panel.is-copied .dcx-fan-info { opacity: 1; transform: translateY(0); }
+
+.dcx-fan-name {
+ margin: 0;
+ font-family: var(--ks-font);
+ font-size: clamp(0.95rem, 1.6vw, 1.3rem);
+ font-weight: 700;
+ line-height: 1.1;
+ white-space: nowrap;
+}
+
+.dcx-fan-value {
+ margin: 0;
+ flex: 0 0 auto;
+ font-family: var(--ks-mono);
+ font-size: clamp(0.6rem, 1vw, 0.72rem);
+ letter-spacing: 0.04em;
+ opacity: 0.82;
+}
+
+.dcx-fan-note {
+ margin: 10px 0 0;
+ font-size: 0.8rem;
+ color: var(--ks-text-faint);
+}
+
+/* Type specimen (Typography) — set in the project's real faces. */
+.dcx-specimen {
+ display: grid;
+ gap: 18px;
+ padding: clamp(24px, 3.4vw, 40px);
+ border: 1px solid var(--ks-rule);
+ border-radius: 10px;
+ background: var(--panel-bg);
+}
+
+.dcx-specimen-display {
+ margin: 0;
+ font-family: "Shippori Mincho", "Hiragino Mincho ProN", "Yu Mincho", Georgia, serif;
+ font-weight: 400;
+ font-size: clamp(2.2rem, 4.6vw, 3.6rem);
+ line-height: 1.15;
+ color: var(--ks-champagne);
+ text-wrap: balance;
+}
+
+.dcx-specimen-body {
+ margin: 0;
+ max-width: 62ch;
+ font-family: "Source Sans 3", system-ui, sans-serif;
+ font-size: 1rem;
+ line-height: 1.6;
+ color: var(--ks-text-muted);
+}
+
+.dcx-specimen-label {
+ margin: 0;
+ font-family: "Source Sans 3", system-ui, sans-serif;
+ font-size: 0.875rem;
+ font-weight: 600;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--ks-text-faint);
+}
+
+/* Radius samples (Material). */
+.dcx-radius-row { display: flex; flex-wrap: wrap; gap: 18px; }
+
+.dcx-radius-sample { display: grid; gap: 8px; justify-items: center; }
+
+.dcx-radius-box {
+ width: 76px;
+ height: 76px;
+ border: 1.5px solid var(--accent-line);
+ background: var(--accent-wash);
+}
+
+.dcx-radius-caption {
+ font-family: var(--ks-mono);
+ font-size: 0.68rem;
+ color: var(--ks-text-muted);
+}
+
+/* Spacing scale bars (Material). */
+.dcx-space-rows { display: grid; gap: 8px; }
+
+.dcx-space-row {
+ display: grid;
+ grid-template-columns: 74px minmax(0, 1fr) 84px;
+ align-items: center;
+ gap: 14px;
+}
+
+.dcx-space-name,
+.dcx-space-value {
+ font-family: var(--ks-mono);
+ font-size: 0.7rem;
+ color: var(--ks-text-muted);
+}
+
+.dcx-space-value { text-align: right; color: var(--ks-text-faint); }
+
+.dcx-space-bar {
+ height: 12px;
+ width: var(--bar, 20%);
+ min-width: 6px;
+ border-radius: 3px;
+ background: var(--accent-wash);
+ border: 1px solid var(--accent-line);
+}
+
+/* Empty state (Iconography). */
+.dcx-empty {
+ padding: clamp(26px, 4vw, 40px);
+ border: 1px dashed var(--ks-rule);
+ border-radius: 10px;
+ text-align: left;
+}
+
+.dcx-empty-title {
+ margin: 0 0 8px;
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--ks-champagne);
+}
+
+.dcx-empty p:not(.dcx-empty-title) {
+ margin: 0;
+ font-size: 0.92rem;
+ line-height: 1.6;
+ color: var(--ks-text-muted);
+}
+
+/* ============================================================
+ Responsive fallback
+ ============================================================ */
+@media (max-width: 920px) {
+ .dcx-shell { padding: 10px; }
+
+ .dcx-grid {
+ height: auto;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ }
+
+ .dcx-tile {
+ grid-column: auto;
+ grid-row: auto;
+ grid-template-columns: minmax(0, 1fr) auto;
+ grid-template-rows: auto;
+ align-items: center;
+ align-content: center;
+ gap: 16px;
+ min-height: clamp(72px, 9svh, 104px);
+ padding: 16px 20px;
+ }
+
+ /* Compact row: title left, small vignette right. */
+ .dcx-tile-viz { width: 44px; height: 44px; }
+
+ .dcx-tile-title,
+ .dcx-tile--typography .dcx-tile-title,
+ .dcx-tile--iconography .dcx-tile-title {
+ font-size: clamp(1.9rem, 8vw, 2.6rem);
+ }
+
+ .dcx-tile--product { order: 1; }
+ .dcx-tile--audience { order: 2; }
+ .dcx-tile--brand { order: 3; }
+ .dcx-tile--color { order: 4; }
+ .dcx-tile--typography { order: 5; }
+ .dcx-tile--iconography { order: 6; }
+ .dcx-tile--material { order: 7; }
+ .dcx-tile--interface { order: 8; }
+
+ .dcx-badge { display: none; }
+
+ .dcx-expander-inner {
+ grid-template-columns: 1fr;
+ grid-template-rows: auto minmax(0, 1fr);
+ }
+
+ .dcx-sidebar {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: center;
+ gap: 12px;
+ padding: 10px 12px;
+ border-right: 0;
+ border-bottom: 1px solid var(--ks-rule);
+ overflow: hidden;
+ }
+
+ .dcx-sidebar-brand { margin: 0; }
+ .dcx-sidebar-brand-name { display: none; }
+ .dcx-nav-label { display: none; }
+
+ .dcx-nav { min-width: 0; overflow-x: auto; scrollbar-width: none; }
+ .dcx-nav::-webkit-scrollbar { display: none; }
+ .dcx-nav-list { display: flex; gap: 4px; }
+
+ .dcx-nav-link {
+ width: auto;
+ padding: 8px 10px;
+ border-left: 0;
+ border-bottom: 2px solid transparent;
+ white-space: nowrap;
+ }
+
+ .dcx-nav-link[aria-current="page"] {
+ border-left-color: transparent;
+ border-bottom-color: var(--accent);
+ }
+
+ .dcx-nav-list li.is-active .dcx-subnav { display: none; }
+
+ .dcx-panel { grid-template-rows: 56px minmax(0, 1fr); }
+ .dcx-topbar { padding: 0 16px; }
+ .dcx-article { padding: 30px 18px 84px; }
+
+ .dcx-def { grid-template-columns: 1fr; gap: 4px; }
+
+ .dcx-fan { height: 210px; }
+
+ .dcx-fan-info {
+ top: 18px; left: 16px; right: 16px;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+ }
+
+ .dcx-space-row { grid-template-columns: 58px minmax(0, 1fr) 70px; }
+}
+
+/* ============================================================
+ Borrowed proofs — questionnaire drawings inside the document.
+
+ A .dcx-proof frames a clone of a drawing the questionnaire
+ painted: a surface's dual artboard, the type scale sheet, the
+ long-form specimen, the icon grid. The clone arrives with its
+ inline variables; the frame supplies the context selectors the
+ originals took from their screens.
+ ============================================================ */
+.dcx-proof {
+ border: 1px solid var(--ks-rule);
+ background: var(--panel-bg);
+ padding: clamp(14px, 2vw, 22px);
+}
+
+/* The strategy stage's base mapping, restated for clones that live outside
+ it: the artboard reads the committed palette painted on the frame. */
+.dcx-proof--board > .picker-preview {
+ --pv-primary: var(--pkc-primary, var(--ks-champagne));
+ --pv-secondary: var(--pkc-secondary, var(--ks-patina));
+ --pv-tertiary: var(--pkc-tertiary, var(--ks-kinpaku));
+ --pv-neutral: var(--pkc-neutral, var(--ks-lacquer-raised));
+ --pv-n-ink: var(--pkc-n-ink, var(--ks-champagne));
+ --pv-p-ink: var(--pkc-p-ink, var(--ks-champagne));
+}
+
+/* The three strategy remaps, mirrored from picker.css's strategy stage.
+ There they key off hover and checked state inside #picker-form; here the
+ answer is settled, so a data attribute on the frame carries it. Change the
+ remap bodies in both places together. */
+.dcx-proof--board[data-dcx-strategy="restrained"] > .picker-preview {
+ --pv-secondary: color-mix(in oklab, var(--pkc-neutral) 78%, var(--pkc-n-ink));
+ --pv-tertiary: color-mix(in oklab, var(--pkc-neutral) 86%, var(--pkc-n-ink));
+ --pv-t-ink: var(--pkc-n-ink);
+ --pv-t-on-t: var(--pkc-n-ink);
+ --pv-t-on-n: color-mix(in oklab, var(--pkc-n-ink) 62%, var(--pkc-neutral));
+ --pv-t-on-p: var(--pkc-p-ink);
+}
+
+.dcx-proof--board[data-dcx-strategy="committed"] > .picker-preview {
+ --pv-neutral: color-mix(in oklab, var(--pkc-neutral) 95%, var(--pkc-primary));
+ --pv-secondary: var(--pkc-primary);
+ --pv-tertiary: var(--pkc-primary);
+ --pv-t-ink: var(--pkc-p-ink);
+ --pv-t-on-t: var(--pkc-p-on-p);
+ --pv-t-on-n: var(--pkc-p-on-n);
+ --pv-t-on-p: var(--pkc-p-on-p);
+}
+
+.dcx-proof--board[data-dcx-strategy="drenched"] > .picker-preview {
+ --pv-neutral: var(--pkc-primary);
+ --pv-n-ink: var(--pkc-p-ink);
+ --pv-primary: var(--pkc-p-ink);
+ --pv-p-ink: var(--pkc-primary);
+ --pv-p-on-n: var(--pkc-p-ink);
+ --pv-p-on-p: var(--pkc-primary);
+ --pv-secondary: var(--pkc-tertiary);
+ --pv-tertiary: var(--pkc-tertiary);
+ --pv-t-on-n: var(--pkc-t-on-p);
+}
+
+/* The scale sheet and specimen size themselves against their rail on screen
+ 05; in the document they stand at natural height and full width. */
+.dcx-proof--scale > .picker-scale-sheet {
+ grid-template-rows: none;
+ height: auto;
+ row-gap: 10px;
+}
+
+.dcx-proof--specimen > .picker-scale-specimen {
+ height: auto;
+ max-height: none;
+ overflow: visible;
+}
+
+.dcx-proof--icons > .picker-icon-sheet {
+ height: auto;
+ border: 0;
+ background: transparent;
+}
+
+/* ============================================================
+ Surface cards — the tile anatomy, kept.
+ ============================================================ */
+.dcx-surfaces {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(440px, 100%), 1fr));
+ gap: clamp(18px, 2.5vw, 28px);
+}
+
+.dcx-surfaces[data-count="1"] { grid-template-columns: minmax(0, 1fr); }
+
+.dcx-surface-card {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ min-width: 0;
+}
+
+.dcx-surface-copy { padding: 0 2px; }
+
+.dcx-surface-name {
+ margin: 0 0 8px;
+ font-family: var(--ks-font-display);
+ font-weight: 300;
+ font-size: 1.45rem;
+ letter-spacing: 0.01em;
+ color: var(--ks-text);
+}
+
+.dcx-surface-name em {
+ font-style: normal;
+ color: var(--accent);
+}
+
+.dcx-surface-goal {
+ margin: 0 0 12px;
+ max-width: 52ch;
+ color: var(--ks-text-muted);
+ font-size: 0.94rem;
+ line-height: 1.6;
+}
+
+.dcx-default-mark {
+ display: inline-block;
+ vertical-align: middle;
+ margin-left: 6px;
+ padding: 2px 8px;
+ border: 1px solid var(--ks-rule);
+ font-family: var(--ks-mono);
+ font-size: 0.62rem;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--ks-text-faint);
+}
+
+/* ============================================================
+ Swatch board — each role at real size, both notations.
+ ============================================================ */
+.dcx-swatches {
+ display: grid;
+ gap: clamp(14px, 2vw, 20px);
+}
+
+.dcx-swatch {
+ display: grid;
+ grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
+ gap: clamp(16px, 2.5vw, 30px);
+ align-items: center;
+}
+
+.dcx-swatch-chip {
+ position: relative;
+ display: flex;
+ align-items: flex-end;
+ min-height: 128px;
+ padding: 14px 16px;
+ border: 1px solid var(--ks-rule);
+ background: var(--swatch);
+ color: var(--swatch-ink);
+ cursor: pointer;
+ font: inherit;
+ text-align: left;
+ transition: transform 0.25s var(--ks-ease);
+}
+
+.dcx-swatch-chip:hover { transform: translateY(-2px); }
+
+.dcx-swatch-chip:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 3px;
+}
+
+.dcx-swatch-hex {
+ font-family: var(--ks-mono);
+ font-size: 0.88rem;
+ letter-spacing: 0.08em;
+}
+
+.dcx-swatch-copy-hint {
+ position: absolute;
+ top: 12px;
+ right: 14px;
+ font-family: var(--ks-mono);
+ font-size: 0.62rem;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ opacity: 0;
+ transition: opacity 0.2s var(--ks-ease);
+}
+
+.dcx-swatch-chip:hover .dcx-swatch-copy-hint,
+.dcx-swatch-chip:focus-visible .dcx-swatch-copy-hint { opacity: 0.85; }
+
+.dcx-swatch-meta h3 {
+ margin: 0 0 6px;
+ font-family: var(--ks-font-display);
+ font-weight: 300;
+ font-size: 1.3rem;
+ color: var(--ks-text);
+}
+
+.dcx-swatch-meta p {
+ margin: 0 0 8px;
+ max-width: 40ch;
+ color: var(--ks-text-muted);
+ font-size: 0.9rem;
+ line-height: 1.55;
+}
+
+.dcx-swatch-meta code {
+ font-family: var(--ks-mono);
+ font-size: 0.78rem;
+ color: var(--ks-text-faint);
+}
+
+/* ============================================================
+ Font pair — the fonttrio anatomy from the font screen.
+ ============================================================ */
+.dcx-pair {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
+ gap: clamp(16px, 2.5vw, 26px);
+}
+
+.dcx-pair-card {
+ border: 1px solid var(--ks-rule);
+ background: var(--panel-bg);
+ padding: clamp(20px, 3vw, 32px);
+}
+
+.dcx-pair-role {
+ display: block;
+ font-family: var(--ks-mono);
+ font-size: 0.66rem;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: var(--ks-text-faint);
+ margin-bottom: 14px;
+}
+
+.dcx-pair-name {
+ margin: 0;
+ font-size: clamp(2.1rem, 4vw, 3.2rem);
+ line-height: 1.1;
+ color: var(--ks-text);
+ text-wrap: balance;
+}
+
+.dcx-pair-source {
+ display: inline-block;
+ margin-top: 12px;
+ font-family: var(--ks-mono);
+ font-size: 0.74rem;
+ color: var(--ks-text-faint);
+}
+
+.dcx-pair-why {
+ margin: 18px 0 0;
+ max-width: 62ch;
+ font-size: 1.02rem;
+ line-height: 1.65;
+ color: var(--ks-text-muted);
+}
+
+.dcx-scale-head {
+ margin: 0 0 16px;
+ max-width: 62ch;
+ color: var(--ks-text-muted);
+ font-size: 0.96rem;
+ line-height: 1.6;
+}
+
+.dcx-scale-head strong { color: var(--ks-text); font-weight: 500; }
+
+/* ============================================================
+ Pick grid — one card per surface for the material questions.
+ ============================================================ */
+.dcx-picks {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr));
+ gap: clamp(14px, 2vw, 22px);
+}
+
+.dcx-pick {
+ border: 1px solid var(--ks-rule);
+ background: var(--panel-bg);
+ padding: clamp(18px, 2.5vw, 26px);
+}
+
+.dcx-pick-surface {
+ display: block;
+ font-family: var(--ks-mono);
+ font-size: 0.66rem;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: var(--ks-text-faint);
+ margin-bottom: 12px;
+}
+
+.dcx-pick-title {
+ margin: 0 0 8px;
+ font-family: var(--ks-font-display);
+ font-weight: 300;
+ font-size: 1.5rem;
+ color: var(--ks-text);
+}
+
+.dcx-pick-desc {
+ margin: 0;
+ color: var(--ks-text-muted);
+ font-size: 0.92rem;
+ line-height: 1.6;
+}
+
+/* ============================================================
+ Decision matrix — the Interface category's per-surface recap.
+ ============================================================ */
+.dcx-matrix {
+ margin: 0;
+ display: grid;
+ gap: 8px;
+}
+
+.dcx-matrix-row {
+ display: grid;
+ grid-template-columns: 130px minmax(0, 1fr);
+ gap: 12px;
+ padding: 8px 0;
+ border-bottom: 1px solid color-mix(in oklab, var(--ks-rule) 60%, transparent);
+}
+
+.dcx-matrix-row dt {
+ font-family: var(--ks-mono);
+ font-size: 0.68rem;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--ks-text-faint);
+ align-self: center;
+}
+
+.dcx-matrix-row dd {
+ margin: 0;
+ color: var(--ks-text);
+ font-size: 0.95rem;
+}
+
+@media (max-width: 920px) {
+ .dcx-swatch { grid-template-columns: 1fr; }
+ .dcx-matrix-row { grid-template-columns: 110px minmax(0, 1fr); }
+}
+
+/* ============================================================
+ Live editing — affordances appear once the edit session is up
+ (body.dcx-live), and stand down when it goes away.
+ ============================================================ */
+.dcx-edit,
+.dcx-request {
+ display: none;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 14px;
+ border: 1px solid var(--ks-rule);
+ background: transparent;
+ color: var(--ks-text-muted);
+ font-family: var(--ks-mono);
+ font-size: 0.68rem;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ cursor: pointer;
+ transition: color 0.2s var(--ks-ease), border-color 0.2s var(--ks-ease);
+}
+
+body.dcx-live .dcx-edit,
+body.dcx-live .dcx-request { display: inline-flex; }
+
+.dcx-edit:hover,
+.dcx-request:hover {
+ color: var(--accent);
+ border-color: var(--accent);
+}
+
+.dcx-edit:focus-visible,
+.dcx-request:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.dcx-swatch-actions {
+ position: relative;
+ display: block;
+ margin-top: 12px;
+}
+
+/* Same trick as the palette screen's custom-color input: present for the
+ picker dialog, invisible to layout. */
+.dcx-native-color {
+ position: absolute;
+ inset: auto auto 0 0;
+ width: 1px;
+ height: 1px;
+ opacity: 0;
+ pointer-events: none;
+ border: 0;
+ padding: 0;
+}
+
+.dcx-pair-why + .dcx-edit { margin-top: 18px; }
+
+.dcx-topbar .dcx-request { margin-left: auto; margin-right: 14px; }
+
+/* The topbar is a grid of current + close today; let the request button sit
+ between them without re-authoring the bar. */
+.dcx-topbar { display: flex; align-items: center; }
+.dcx-topbar .dcx-current { flex: 1; min-width: 0; }
+
+/* ============================================================
+ Request tray — queued work and its status, bottom right.
+ ============================================================ */
+.dcx-tray {
+ position: fixed;
+ right: 22px;
+ bottom: 22px;
+ z-index: 300;
+ display: grid;
+ gap: 10px;
+ width: min(340px, calc(100vw - 44px));
+}
+
+.dcx-tray[hidden] { display: none; }
+
+.dcx-tray-item {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 12px;
+ align-items: start;
+ padding: 12px 14px;
+ border: 1px solid var(--ks-rule);
+ background: var(--panel-bg);
+ box-shadow: 0 12px 32px rgb(0 0 0 / 0.35);
+}
+
+.dcx-tray-dot {
+ width: 9px;
+ height: 9px;
+ margin-top: 5px;
+ border-radius: 50%;
+ background: var(--ks-text-faint);
+}
+
+.dcx-tray-item[data-status="pending"] .dcx-tray-dot { background: var(--ks-text-muted); }
+
+.dcx-tray-item[data-status="working"] .dcx-tray-dot {
+ background: var(--accent);
+ animation: dcx-tray-pulse 1.2s ease-in-out infinite;
+}
+
+.dcx-tray-item[data-status="done"] .dcx-tray-dot { background: var(--ks-patina, #7ba98f); }
+.dcx-tray-item[data-status="error"] .dcx-tray-dot,
+.dcx-tray-item[data-status="offline"] .dcx-tray-dot { background: #c26d5a; }
+
+@keyframes dcx-tray-pulse {
+ 50% { opacity: 0.35; }
+}
+
+.dcx-tray-prompt {
+ margin: 0 0 3px;
+ font-size: 0.86rem;
+ line-height: 1.4;
+ color: var(--ks-text);
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.dcx-tray-note {
+ margin: 0;
+ font-family: var(--ks-mono);
+ font-size: 0.66rem;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--ks-text-faint);
+}
+
+/* ============================================================
+ Request modal — rides the picker's modal shell.
+ ============================================================ */
+.dcx-request-modal .dcx-request-prompt {
+ width: 100%;
+ resize: vertical;
+ min-height: 96px;
+}
+
+/* ============================================================
+ Reduced motion
+ ============================================================ */
+@media (prefers-reduced-motion: reduce) {
+ [class^="dcx-"], [class^="dcx-"]::before, [class^="dcx-"]::after,
+ [class*=" dcx-"], [class*=" dcx-"]::before, [class*=" dcx-"]::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ transition-delay: 0ms !important;
+ }
+
+ .dcx-tile[data-reveal],
+ .dcx-badge[data-reveal] {
+ opacity: 1;
+ transform: none;
+ }
+}
diff --git a/picker/styles/picker.css b/picker/styles/picker.css
index 4b7be2d51..ef969fa4b 100644
--- a/picker/styles/picker.css
+++ b/picker/styles/picker.css
@@ -185,11 +185,21 @@ body.picker-page {
view-transition-name: pk-band-neutral;
}
-/* Both screens now judge their answer on the same drawing, so the pair the
- transition carries is one component in two sizes: the frame grows out of the
- palette screen's rather than dissolving into a different page. The hidden
- surfaces are display: none and so are not rendered, which is what keeps the
- name unique while every chosen surface stays mounted. */
+/* Three screens judge their answer on the same drawing, so what the transition
+ carries is one component in three sizes: it grows out of the surface tile
+ into the palette screen's panel, and again onto the strategy stage, rather
+ than dissolving into a different page at each step. The hidden surfaces are
+ display: none and so are not rendered, which is what keeps the name unique
+ while every chosen surface stays mounted.
+
+ On 01b the frame is named rather than the component inside it. The tile draws
+ that component at its canonical width under a scale transform, and a group
+ animating its own size as well as that transform counts the reduction twice.
+ The frame is the drawing's real box at tile scale and carries the same 16 / 7
+ the other two do, so the pair interpolates without distortion. Only the
+ leading tile is marked, which is both the drawing screen 02 goes on to show
+ and what keeps the name on one element. */
+.picker-screen[data-screen="01b"] .picker-mode-tile[data-lead] .picker-mode-preview,
.picker-screen[data-screen="02"] .picker-preview,
.picker-screen[data-screen="03"] .picker-strategy-stage > .picker-preview {
view-transition-name: pk-test-page;
@@ -343,11 +353,16 @@ body.picker-page {
/* Eleven steps hold the same total width five did: the row is a measure of
how far along the interview is, and a wider one would start competing with
- the question. The ticks divide whatever that width is. */
+ the question. The ticks divide whatever that width is.
+
+ Eleven is the ceiling rather than the count. A question no chosen surface
+ takes is not a step, so the navigation writes the run's own count here and
+ the ticks past it leave the grid: the row still measures the same distance,
+ divided between the questions this run is actually asked. */
.picker-progress-track {
width: 232px;
display: grid;
- grid-template-columns: repeat(11, minmax(0, 1fr));
+ grid-template-columns: repeat(var(--pk-steps, 11), minmax(0, 1fr));
gap: 5px;
}
@@ -357,6 +372,10 @@ body.picker-page {
transition: background-color 260ms var(--ks-ease);
}
+.picker-progress-track i[data-off] {
+ display: none;
+}
+
.picker-progress[data-step="1"] .picker-progress-track i:nth-child(-n + 1),
.picker-progress[data-step="2"] .picker-progress-track i:nth-child(-n + 2),
.picker-progress[data-step="3"] .picker-progress-track i:nth-child(-n + 3),
@@ -2692,14 +2711,19 @@ body.picker-page {
former 14px, which now also sets the air between the frame and the CTA.
Everything this screen shows lives inside the grid, so what the grid may
- spend is the whole distance between the heading and the fold. Set so that
- 800px of viewport is the last height where the rows still reach their 108
- ceiling; under that the panel gives up row height and then, past its floor,
- scrolls. --pk-column, which the later question screens still read, is
- measured for a grid with the buttons underneath it and leaves this one 90px
- short of what it now owns. */
+ spend is the window less whatever the block spends around it: the padding,
+ the question, and the gap under it. --pk-chrome is that sum, measured by
+ fitStrategyColumn() rather than counted here, because the question is one or
+ two lines at a size that tracks the window's width. The literal is the value
+ before the script has measured,
+ and it is the number this rule used to carry outright; at laptop heights it
+ was short enough that the screen ran past the fold.
+
+ --pk-column, which the later question screens still read, is measured for a
+ grid with the buttons underneath it and leaves this one 90px short of what it
+ now owns. */
.picker-screen[data-screen="03"] .picker-strategy-grid {
- --pk-fit: calc(100svh - 250px);
+ --pk-fit: calc(100svh - var(--pk-chrome, 250px));
max-height: var(--pk-fit);
grid-template-rows: minmax(0, 1fr) auto;
row-gap: 14px;
@@ -2950,22 +2974,21 @@ body.picker-page {
align-content: center;
}
-/* The stage is the frame's own box: one row, and the tab strip taken out of the
- flow so it can sit on the corner. It is the positioned ancestor that strip
- measures itself against. */
+/* The stage is the frame's own box plus the strip over it: a row for the tab
+ strip and a row for the frame, so the strip's height is room the layout
+ reserved rather than room taken off the question above. */
.picker-strategy-stage {
min-width: 0;
- position: relative;
display: grid;
- grid-template-rows: minmax(0, 1fr);
+ grid-template-rows: auto minmax(0, 1fr);
}
.picker-strategy-stage > .picker-preview {
/* Every chosen surface is mounted and all but one are display: none, so the
row is named rather than left to auto-placement: a frame where two are
briefly shown puts them on top of each other instead of stacking the stage
- into a second row and taking the column past the choices' bottom edge. */
- grid-row: 1;
+ into a third row and taking the column past the choices' bottom edge. */
+ grid-row: 2;
/* The frame runs down to the bottom of the choices beside it, so it takes the
row's height rather than the 16 / 7 it carries everywhere else. */
aspect-ratio: auto;
@@ -2998,32 +3021,24 @@ body.picker-page {
/* The group only appears with a second surface to switch to; a single surface
is already the only thing the frame can be showing.
- Out of the flow, docked on the frame's top right corner. A row of its own cost
- the frame 65px of the height the choices beside it set, and cost it only when
- more than one surface was chosen, so the drawing changed size with a decision
- that has nothing to do with its size.
-
- On the edge rather than inside it. Every drawing puts its nav across the
- frame's top and the strip is taller than that band, so sitting inside covers
- the one place the primary shows up as chrome. Outside it the strip also keeps
- its own ground: raised lacquer and a hairline are read against the page, not
- against a drawing repainted in whatever palette and strategy were chosen. The
- 2px is the join, and the z-index is what keeps it visible, since the drawings
- raise plates of their own that would otherwise paint over it.
-
- Screen 03 hangs the strip on its stage, which is already positioned because
- it stacks a drawing per surface. A question screen has one drawing and hangs
- it on the artboard, which is that screen's frame. */
-.picker-artboard:has(> .picker-surface-tabs) {
- position: relative;
-}
+ In the flow, on the stage's first row, right-aligned over the frame. The row
+ is what reserves the strip's height, so nothing downstream has to guess at it
+ and the question above keeps the gap every screen shares.
+ On the edge rather than inside the frame. Every drawing puts its nav across
+ the frame's top and the strip is taller than that band, so sitting inside
+ covers the one place the primary shows up as chrome. Outside it the strip
+ also keeps its own ground: raised lacquer and a hairline are read against the
+ page, not against a drawing repainted in whatever palette and strategy were
+ chosen. The 1px pull is the join, landing the strip's bottom hairline on the
+ frame's top one, and the z-index is what keeps that edge visible, since the
+ drawings raise plates of their own that would otherwise paint over it. */
.picker-surface-tabs {
- position: absolute;
+ position: relative;
z-index: 1;
- top: 2px;
- right: 0;
- transform: translateY(-100%);
+ grid-row: 1;
+ justify-self: end;
+ margin-bottom: -1px;
display: flex;
gap: 2px;
padding: 4px;
@@ -3036,18 +3051,24 @@ body.picker-page {
display: none;
}
+/* One line: the dot and the surface this tab names. The picker's chrome
+ control, the same 0.8rem on the same tracking as `.picker-type-custom`, since
+ a strip docked on a frame's corner names which drawing is showing and should
+ not read as the page's loudest button. */
.picker-surface-tab {
display: inline-flex;
align-items: center;
gap: 10px;
- padding: 13px 22px;
+ padding: 9px 12px;
color: var(--ks-text-muted);
background: transparent;
border: 0;
border-radius: 2px;
font-family: inherit;
- font-size: 1.08rem;
- line-height: 1.2;
+ font-size: 0.8rem;
+ letter-spacing: 0.04em;
+ line-height: 1.15;
+ white-space: nowrap;
cursor: pointer;
transition:
background-color 180ms var(--ks-ease),
@@ -3072,6 +3093,7 @@ body.picker-page {
/* The whole report on state: filled once the surface has been answered
deliberately, hollow while it is still carrying the default it was given. */
.picker-surface-dot {
+ flex: none;
width: 8px;
height: 8px;
border: 1px solid color-mix(in oklab, var(--ks-patina) 55%, transparent);
@@ -3689,10 +3711,19 @@ body.picker-page {
height: 6px;
}
+/* The picture is the one row here whose height is a share of the frame rather
+ than a count of what is in it, so it is the row that gives when the frame is
+ shorter than the page: the bars below it are drawn at fixed pixel heights and
+ do not shrink with the window. Capped at its share rather than set to it, so
+ a tall frame is unchanged and a short one takes the difference out of the
+ picture instead of pushing the card row through the footer. The card row asks
+ for its own height first and takes the leftover after: as a bare fraction it
+ was handed whatever the rows above had finished with, which on a short frame
+ was a third of what a card is. */
.ps-phone-body {
min-height: 0;
display: grid;
- grid-template-rows: 31% auto auto auto 34px auto minmax(0, 1fr);
+ grid-template-rows: minmax(0, 31%) auto auto auto 34px auto minmax(min-content, 1fr);
gap: 6px;
padding: 6%;
}
@@ -3952,9 +3983,9 @@ body.picker-page {
/* The frame's own box on the font screen. Every chosen surface gets a board and
they are all mounted, so the row is named rather than left to auto-placement:
four boards would otherwise stack into four rows and take the column past the
- rail beside it. It is also the positioned ancestor the tab strip docks
- against, and the node the chosen pair's faces are written to, which is how one
- write sets all four boards. */
+ rail beside it. The tab strip takes the row above them, and the stage is also
+ the node the chosen pair's faces are written to, which is how one write sets
+ all four boards. */
.picker-type-stage {
/* The chosen pair is the run's answer rather than one board's, so the two
faces are declared here and written here, and every board is set in them
@@ -3968,19 +3999,11 @@ body.picker-page {
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr);
- grid-template-rows: minmax(0, 1fr);
-}
-
-/* The strip needs a positioned ancestor and the boards would rather not have
- one: the stacking context shifts the rasterization of their plate edges by a
- pixel. Nobody could see it, but a run with a single surface shows no strip and
- has no reason to pay for one, so the position arrives with it. */
-.picker-type-stage:has(> .picker-surface-tabs:not([hidden])) {
- position: relative;
+ grid-template-rows: auto minmax(0, 1fr);
}
.picker-type-stage > .picker-artboard {
- grid-row: 1;
+ grid-row: 2;
/* Both sides are named so the declared ratio has nothing left to decide. It
still does the asking: with no height of its own the stage measures the
ratio, which is what the row across from the rail is offered, and the taller
@@ -3997,6 +4020,23 @@ body.picker-page {
display: none;
}
+/* The same box for a question screen that draws a board per surface, which is
+ screen 06 today. The font stage above carries the pair's two faces as well,
+ and that is the whole of the difference between them. */
+.picker-board-stage {
+ min-width: 0;
+ min-height: 0;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-rows: auto minmax(0, 1fr);
+}
+
+.picker-board-stage > .picker-artboard {
+ grid-row: 2;
+ width: 100%;
+ height: 100%;
+}
+
/* The rail keeps the row height owned by the preview: the fieldset scrolls
inside it instead of stretching the page when six tall cards stack up. The
control row underneath is what tells you the list runs past its frame. On
@@ -4281,10 +4321,13 @@ body.picker-page {
sizes: it reads the step values syncTypeScale computed for the sheet, so one
scale cannot render two ways on one screen.
- What it deliberately has none of is color and no chrome past the panel it
+ What it deliberately has none of is color, and no chrome past the panel it
sits in. A palette here would ask screen 03's question again inside screen
05's, and a specimen that has to be read past its own decoration is not
- showing a type scale. */
+ showing a type scale. The annotation is held to the same rule: thirteen
+ accented marks on a colorless field were the brightest thing on the screen,
+ which put the loudest ink on the layer that is only there to be looked up.
+ Mono, faint, and out in the margin says annotation three times over. */
.picker-scale-specimen {
--pt-heading: var(--ks-font-display);
--pt-body: var(--ks-font);
@@ -4293,12 +4336,26 @@ body.picker-page {
at, so the air between components is measured in the scale being judged
rather than against it. */
--sp-rhythm: calc(var(--ts-step-0) * 1px);
+ /* The margin the annotation hangs in. Reserved as padding so a label can
+ never be measured against the line it names. Sized to the longest label
+ the column sets, five mono characters, plus enough slack that a fallback
+ mono face with wider glyphs still clears the panel edge. */
+ --sp-rail: 2.6rem;
+ --sp-rail-gap: 0.85rem;
+ --sp-pad: clamp(18px, 3.2%, 36px);
min-width: 0;
min-height: 0;
display: grid;
align-content: start;
- gap: calc(var(--sp-rhythm) * 1.6);
- padding: clamp(14px, 2.4%, 28px) clamp(18px, 3.2%, 36px);
+ /* Two intervals, not one. A page is read in sections, and a column set at a
+ single interval has none: the heading, its paragraph, and its list arrive
+ as three unrelated blocks, which is the one thing a specimen of a scale
+ must not say. Blocks inside a section sit close; the break before a
+ heading is the wide one. Both are counted in the base step, so the cadence
+ holds while every size in the column moves. */
+ gap: calc(var(--sp-rhythm) * 1.5);
+ padding-block: clamp(14px, 2.4%, 28px);
+ padding-inline: calc(var(--sp-pad) + var(--sp-rail) + var(--sp-rail-gap)) var(--sp-pad);
background: var(--ks-lacquer-raised);
color: var(--ks-text);
border: 1px solid var(--ks-rule);
@@ -4346,6 +4403,15 @@ body.picker-page {
min-height: 44px;
}
+/* A grid item's automatic minimum is its min-content width, so one word of the
+ display heading at the widest ratio can hold this column open past the panel
+ and hand it a horizontal scrollbar. Zeroing that floor and letting the word
+ break keeps every overflow on the axis the column already scrolls. */
+.picker-scale-specimen > * {
+ min-width: 0;
+ overflow-wrap: break-word;
+}
+
/* Headings take the display face and running text the body face, which is the
division screen 04 just committed to. */
.picker-scale-specimen :is(.sp-h1, .sp-h2, .sp-h3, .sp-h4) {
@@ -4377,7 +4443,42 @@ body.picker-page {
/* A heading belongs to what follows it, so the break in the column goes above
it and not below. The column gap alone reads as a list of unrelated blocks. */
.picker-scale-specimen :is(.sp-h2, .sp-h3, .sp-h4) {
- margin-block-start: calc(var(--sp-rhythm) * 0.9);
+ margin-block-start: calc(var(--sp-rhythm) * 2.4);
+}
+
+/* Which component each block is, said in the annotation layer rather than in
+ the specimen: the mono face where the specimen is proportional, the same
+ faint ink the sheet's reference numbers take, and the smallest step the kit
+ labels anything at.
+
+ It hangs in the reserved margin instead of entering the block. A float or a
+ right inset both shorten the line box they share, and this column exists to
+ show how a ratio wraps: at the widest ratios a floated label was breaking
+ the display heading onto a third line and leaving one character alone on the
+ first, so the annotation was deciding the texture it was there to describe.
+ Out here it decides nothing, and thirteen marks that were ragged against
+ thirteen different line endings share one edge. */
+.picker-scale-specimen [data-sp-label] {
+ position: relative;
+}
+
+.picker-scale-specimen [data-sp-label]::before {
+ content: attr(data-sp-label);
+ position: absolute;
+ top: 0;
+ inset-inline-end: calc(100% + var(--sp-rail-gap));
+ color: var(--ks-text-faint);
+ font-family: var(--ks-mono);
+ font-size: var(--ks-type-eyebrow-size);
+ font-weight: 500;
+ letter-spacing: 0.08em;
+ line-height: 1.5;
+ text-transform: uppercase;
+ white-space: nowrap;
+ /* Trimmed so the label's cap, not its leading, is what lands on the block's
+ top edge. The blocks above and below it are set at whatever the ratio
+ dealt, so any alignment measured from a line box moves with the scale. */
+ text-box: trim-both cap alphabetic;
}
.picker-scale-specimen :is(.sp-lede, .sp-p, .sp-list, .sp-quote) {
@@ -5021,10 +5122,20 @@ body.picker-page {
micro-copy sits one step below the body size, the headline two steps
above it. The phone artboard re-bases the same ladder smaller, so both
artboards stay on scale. A future type-scale step replaces the ratio;
- until then the preview follows this one everywhere. */
-.picker-preview-type {
+ until then the preview follows this one everywhere.
+
+ The structural boards are on it too. They set no glyphs, but the app shell,
+ the document, and the index are drawn out of this ladder's spacing steps, and
+ a board that resolved none of them would drop every rule that reaches for one.
+ The ink slots below stay where they are: those exist because a bar and a
+ printed line need different contrast, and a structural board is all bars. */
+.picker-preview-type,
+[data-carry] {
--pt-ratio: 1.618;
--pt-base: clamp(0.72rem, 0.86vw, 0.84rem);
+}
+
+.picker-preview-type {
/* Slot colors were mixed for bars, where a pale fill still reads as a
shape. Once those bars carry glyphs the same mix disappears, so the type
artboards re-derive every ink slot at reading contrast. The mix is taken
@@ -5063,7 +5174,9 @@ body.picker-page {
numbers. The gutter is the one exception: it is a share of the frame,
because a band's inset reads against the frame edge, not the type. */
.picker-preview-type,
-.picker-preview-type .ps-phone {
+.picker-preview-type .ps-phone,
+[data-carry],
+[data-carry] .ps-phone {
--pt-micro: max(0.62rem, calc(var(--pt-base) / var(--pt-ratio)));
--pt-title: calc(var(--pt-base) * var(--pt-ratio));
--pt-display: calc(var(--pt-base) * var(--pt-ratio) * var(--pt-ratio));
@@ -5082,6 +5195,22 @@ body.picker-page {
--ps-gutter: 6%;
}
+/* On a structural board the band inset is the drawn field's own margin, and it is
+ a constant on purpose. What the layout answer moves on a shell, a document, or
+ an index is the proportion of their regions, and the margin is only there so
+ those regions can be checked against the field. Hung off the layout answer
+ instead, the balanced page would take the artboard's 5.5 and 3.2, and every
+ region edge would land three pixels shy of the line it was drawn to meet:
+ read as sloppiness on a tool, where on the landing page the same lean is the
+ answer being shown. */
+[data-carry] {
+ --ps-gutter: 6%;
+}
+
+[data-carry] .ps-phone {
+ --ps-gutter: var(--pvs-phone-gutter, 6%);
+}
+
/* Centering a text box centers its em box, and every family puts its caps
somewhere else inside that box, so a centered label moves each time the
pair changes. Trimming to the cap and baseline edges makes the box the
@@ -5175,10 +5304,6 @@ body.picker-page {
aspect-ratio: 1;
}
-.picker-preview-type .ps-desktop .ps-gallery-item:nth-child(4) {
- display: none;
-}
-
/* Words set taller than bars, and the phone is the narrowest frame they have
to fit. Its card row goes: nav, hero, proof and a section of running copy
already show every face at both sizes, and the row that was left over is
@@ -5188,9 +5313,13 @@ body.picker-page {
proof row at a fixed 34px was the one place a tall pair had nowhere to go.
Whatever height is left over is spread between the sections rather than
pooled under the last one, so a compact pair reads as a page with air in it
- and a wide one closes up to its own gap without spilling past the frame. */
+ and a wide one closes up to its own gap without spilling past the frame.
+
+ The picture keeps a cap rather than a share, for the reason under the shared
+ phone body: it is the only row here that is not a count of its own contents,
+ so it is the row that gives first when the frame runs short. */
.picker-preview-type .ps-phone-body {
- grid-template-rows: 18% repeat(5, auto);
+ grid-template-rows: minmax(0, 18%) repeat(5, auto);
gap: var(--ps-gap-sm);
align-content: space-between;
padding: var(--ps-band-y) var(--ps-gutter);
@@ -5207,6 +5336,30 @@ body.picker-page {
gap: var(--ps-gap-sm);
}
+/* The two buttons divide the row rather than each asking for the width its own
+ label wants. A pair of labels nobody wrote to a length was setting the widest
+ line on the handset, and a nowrap line is a width the column has to find:
+ the row came out wider than the phone, and every band under it was drawn to
+ the row instead of to the frame.
+
+ Two even columns rather than two flexible children, because a flex item still
+ reports what its label wants when its container is asked for a minimum, and
+ the minimum is exactly what was overflowing here. A label longer than half the
+ row ends in an ellipsis; the clip margin is the house allowance for a
+ descender under text-box trimming. */
+.picker-preview-type .ps-phone-body .ps-actions {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+}
+
+.picker-preview-type .ps-phone-body .ps-actions i {
+ min-width: 0;
+ padding-inline: 0.8em;
+ overflow: clip;
+ overflow-clip-margin: 0.4em;
+ text-overflow: ellipsis;
+}
+
/* The band already sits inside the body's gutter; its own inset put it a
step to the right of everything it lines up with. */
.picker-preview-type .ps-phone-body .ps-proof {
@@ -5230,6 +5383,26 @@ body.picker-page {
display: none;
}
+/* Asked of the board rather than the window, for the reason the docs and ops
+ boards are: the ladder floors while the frame keeps shrinking, so a short
+ frame holds fewer blocks at the same reading size. The ops and docs boards
+ name their own container over this one. */
+.picker-preview-type {
+ container-type: size;
+ container-name: ps-type-board;
+}
+
+/* Below this the handset is holding more sections than the frame has room for,
+ and the picture it takes them out of has already given everything it has. The
+ section pair is what goes, on the same terms as the card row above: its
+ heading face is the headline's and its body face is the lede's, both already
+ set on this page, and the desktop beside it keeps the section in full. */
+@container ps-type-board (height < 500px) {
+ .picker-preview-type .ps-phone-body .ps-editorial-copy {
+ display: none;
+ }
+}
+
.pt-headline {
max-width: 16ch;
margin: 0;
@@ -5273,7 +5446,8 @@ body.picker-page {
/* Phone artboard type: the same ladder re-based to handset scale, held above
the legibility floor rather than scaled straight down. */
-.picker-preview-type .ps-phone {
+.picker-preview-type .ps-phone,
+[data-carry] .ps-phone {
--pt-base: clamp(0.66rem, 0.72vw, 0.74rem);
}
@@ -5290,13 +5464,14 @@ body.picker-page {
Overflow stays visible: a bar had to be kept inside its box, but a line of
text hangs its descenders below the baseline, and the trimmed box ends at
- the baseline. Hiding the overflow would cut the tail off every g and p.
+ the baseline. Hiding the overflow would cut the tail off every g and p, so
+ the proof label, which does have to truncate, clips with a margin instead.
- The nowrap below is the default for a one-line label, and the proof and
- card labels opt back out of it further down. Every selector in this list
- is one class and one type, so those two rules win on source order. Adding
- a more specific one here (a descendant pair, say) silently raises the whole
- :is() above them and puts the clipping back. */
+ The nowrap below is the default for a one-line label, and the card labels
+ opt back out of it further down. Every selector in this list is one class
+ and one type, so the rules that override this one win on source order.
+ Adding a more specific one here (a descendant pair, say) silently raises
+ the whole :is() above them and puts the clipping back. */
.picker-preview-type :is(
.ps-nav-bars i,
.ps-proof-item span,
@@ -5386,7 +5561,13 @@ body.picker-page {
The label is centered by the box being cut to the glyphs, so this stays a
block: a grid container has no line box of its own to trim. The filled
button carries a transparent border so it resolves to the same height as
- the outlined one sitting next to it. */
+ the outlined one sitting next to it.
+
+ The hero buttons are ``, so the style is stated rather than inherited:
+ the slots that reset the UA italic in one shared list cannot take these two,
+ because that list also clears the background and the radius and a button is
+ the fill it sits on. The nav button beside them is a `div` and never had the
+ italic to lose, which is how the pair came to be set two ways. */
.picker-preview-type .ps-nav-action,
.picker-preview-type .ps-actions i {
width: auto;
@@ -5397,6 +5578,7 @@ body.picker-page {
color: var(--pvs-cta-text);
font-family: var(--pt-body);
font-size: var(--pt-micro);
+ font-style: normal;
font-weight: 600;
line-height: 1;
text-align: center;
@@ -5472,12 +5654,18 @@ body.picker-page {
background-color: var(--pvs-accent-d);
}
-/* A wide display face wraps the label rather than losing the end of a word to
- the cell edge with no sign it went missing. */
+/* A chip is one line. The columns are even and the row is read across, so the
+ label that ran long took a second line and set the whole band to two
+ heights at once. Clipped rather than hidden because the box above is trimmed
+ to the baseline: the clip margin is the room the tail of a g or a y needs,
+ and the ellipsis still lands on the inline edge. */
.picker-preview-type .ps-proof-item span {
min-width: 0;
+ overflow: clip;
+ overflow-clip-margin: 0.4em;
color: var(--pvs-bars);
- white-space: normal;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
/* Wraps rather than truncates: a section title is the product's own words and
@@ -5528,17 +5716,31 @@ body.picker-page {
column for column: a rail, a working column, a settings panel, at the
18.68 / 51.84 / rest the drawing already uses.
- What it drops is what a chart is. A chart carries no glyphs, so a pair
- cannot be judged on one, and the numbers it stood for can. What it refuses
- to add is a display line: nothing on a dashboard is set at 35px, and a board
- that opened with one would be recommending a pair for a page this surface
- never has. The largest step here is the panel title, and the face doing the
- most work is the body face between 10 and 12px.
+ What it refuses to add is a display line: nothing on a dashboard is set at
+ 35px, and a board that opened with one would be recommending a pair for a
+ page this surface never has. The largest step here is the section title, and
+ the face doing the most work is the body face between 10 and 12px.
+
+ The chart is the one element on the board that is still mostly a drawing.
+ Its bars carry no glyphs and prove nothing about a pair, so they are held to
+ a share of the plot and are there for the two pieces of text they bracket:
+ the heading above them and the category labels below, which is the tightest
+ slot on any of the boards. Both are what a chart really costs a pairing.
============================================================ */
.picker-preview-type--ops .ps-desktop {
grid-template-rows: 9.4% minmax(0, 1fr) 8.3%;
}
+/* The board is asked its own height, the way the docs board is, because that is
+ what the working column has to fit inside and it is not the window's: the
+ frame shrinks with the window while the type ladder floors, so a short frame
+ holds the same four blocks at a larger relative size. What a short frame
+ spends less on hangs off the query at the end of this section. */
+.picker-preview-type--ops {
+ container-type: size;
+ container-name: ps-ops-board;
+}
+
.ps-ops {
min-width: 0;
min-height: 0;
@@ -5560,10 +5762,29 @@ body.picker-page {
padding: var(--ps-band-y) calc(var(--pt-base) * 0.7);
}
+/* The working column runs to the bottom of the band rather than stopping partway
+ down it: whatever a pair leaves over is spread between the four blocks instead
+ of pooling under the table, which is how the docs and portfolio boards already
+ fill. It takes no height gate, unlike the docs column, because the inset it
+ would spend on a tall frame is already there on every frame (this padding is
+ symmetric) and because `space-between` is `start` again the moment the free
+ space runs out, so a short frame keeps the drawing it has.
+
+ What it does not do is gain a block on a tall frame the way the docs column
+ gains its list. That column was short a structural element; this one draws the
+ four a dashboard is made of, so a fifth would be filling for its own sake. Its
+ table is also filled from the run's own item slots, and a row past their count
+ would come up blank.
+
+ `min-height: 0` for the same reason the docs column carries it: without it the
+ column takes its automatic minimum height from the table and grows past the
+ row, which pushes the footer out of the frame. Held to the band, the
+ widest-setting pair runs to the band's edge instead. */
.ps-ops-main {
min-width: 0;
+ min-height: 0;
display: grid;
- align-content: start;
+ align-content: space-between;
gap: var(--ps-gap-lg);
padding: var(--ps-band-y) calc(var(--pt-base) * 1.1);
}
@@ -5576,7 +5797,8 @@ body.picker-page {
.ps-ops-item b,
.ps-ops-row b,
.ps-ops-field b,
- .ps-ops-switch b
+ .ps-ops-switch b,
+ .ps-ops-lanes b
) {
min-width: 0;
color: var(--pvs-bars);
@@ -5662,6 +5884,89 @@ body.picker-page {
line-height: 1.35;
}
+.ps-ops-chart {
+ min-width: 0;
+ display: grid;
+ gap: var(--ps-gap-sm);
+}
+
+/* A chart title is a heading, so it takes the heading face, and it takes it at
+ the reading step rather than the title step: the section title above it is
+ already the largest thing on the board, and a second line at that size would
+ read as a second section. Two sizes of the same face is the useful thing here
+ anyway, because a display face that holds at 22px and falls apart at 13 is
+ exactly the fault this board exists to surface. */
+.ps-ops-chart-title {
+ color: var(--pvs-title);
+ font-family: var(--pt-heading);
+ font-size: var(--pt-base);
+ font-weight: var(--pt-heading-weight);
+ line-height: 1.3;
+ overflow-wrap: break-word;
+}
+
+/* The bars and the labels are two grids over one column structure, so a label
+ is under its own bar without either row measuring the other. Implicit columns
+ rather than a stated count, because the handset draws three of them and the
+ desktop five and neither should have to restate it here.
+
+ The axis is the plot's own bottom border, so the bars stand on it at every
+ width. Their height is a share of the plot rather than a value, which is what
+ keeps this a drawing of a chart: nothing here is a reading of data. */
+.ps-ops-plot,
+.ps-ops-lanes {
+ display: grid;
+ grid-auto-flow: column;
+ grid-auto-columns: minmax(0, 1fr);
+ gap: calc(var(--pt-base) * 0.7);
+}
+
+.ps-ops-plot {
+ height: calc(var(--pt-base) * 5.5);
+ align-items: end;
+ border-bottom: 1px solid var(--pvs-rule);
+}
+
+/* The lanes that are not the leading one take the fill an unselected row takes,
+ which is the same slot the wireframe chart on the surface tile fills its bars
+ with. It is the one slot that stays a light step off the ground under every
+ strategy and moves only in hue, and five bars are an area where the board's
+ accents are points: at accent weight the chart would outrank the table it
+ sits above. */
+.ps-ops-plot > i {
+ height: var(--h);
+ background-color: var(--pvs-ghost);
+}
+
+/* The leading lane is the only bar a schematic chart has to say anything with,
+ so it takes the fill the board already spends on a chosen state: the open
+ rail plate and the switch that is on. Under Drenched that fill is the page's
+ own ink, which is the one reading of primary that stays visible when the
+ ground is the primary. */
+.ps-ops-plot > .ps-ops-bar--lead {
+ background-color: var(--pvs-cta);
+}
+
+/* Centered under the bar, and the one micro label on this board that is a
+ caption rather than a row: it has its bar's width and no more, which is why
+ the words it is filled with are single and short. */
+.picker-preview-type--ops .ps-ops-lanes b {
+ text-align: center;
+}
+
+/* Three columns over a handset's width come out square at the desktop's plot
+ height, and a square is a tile rather than a bar. The gap is spent on both
+ rows or the labels walk off the bars they name. */
+.picker-preview-type--ops .ps-phone-body :is(.ps-ops-plot, .ps-ops-lanes),
+[data-carry][data-surface="operate"] .ps-phone-body :is(.ps-ops-plot, .ps-ops-lanes) {
+ gap: calc(var(--pt-base) * 2);
+}
+
+.picker-preview-type--ops .ps-phone-body .ps-ops-plot,
+[data-carry][data-surface="operate"] .ps-phone-body .ps-ops-plot {
+ height: calc(var(--pt-base) * 7);
+}
+
.ps-ops-table {
display: grid;
border-top: 1px solid var(--pvs-rule);
@@ -5779,21 +6084,69 @@ body.picker-page {
cut to what they are and what they come to, and the two switches. The rail
and the panel are chrome a phone gives to a drawer, and a drawer is not on
screen. */
-.picker-preview-type--ops .ps-phone-body {
+/* The structural boards are on the same selectors from here down wherever the
+ rule is about the shape of the body rather than the setting of its words. A
+ handset holding four blocks of a tool holds them the same way whether they are
+ printed or drawn, and writing that twice is how two boards claiming to be the
+ same anatomy drift apart. */
+.picker-preview-type--ops .ps-phone-body,
+[data-carry][data-surface="operate"] .ps-phone-body {
grid-template-rows: repeat(4, auto);
- align-content: start;
+ align-content: space-between;
gap: var(--ps-gap-md);
}
-.picker-preview-type--ops .ps-phone-body .ps-ops-row {
+.picker-preview-type--ops .ps-phone-body .ps-ops-row,
+[data-carry][data-surface="operate"] .ps-phone-body .ps-ops-row {
grid-template-columns: auto minmax(0, 1fr) auto;
}
-.picker-preview-type--ops .ps-phone-body .ps-ops-panel {
+.picker-preview-type--ops .ps-phone-body .ps-ops-panel,
+[data-carry][data-surface="operate"] .ps-phone-body .ps-ops-panel {
gap: 0;
padding: 0;
}
+/* A frame with less room than the drawing needs. The plot is the one block here
+ whose height is a count rather than its contents, so it is the block that
+ gives: the chart keeps its title, its bars, and its labels, and loses the air
+ between them, which is the reduction it already makes on the handset. The
+ column closes to the middle step at the same time.
+
+ Shortening rather than dropping, because a dashboard without its chart is the
+ one thing this board exists to show a pair against, and 1280 by 800 is a
+ laptop rather than an edge case. */
+@container ps-ops-board (height < 500px) {
+ .picker-preview-type--ops .ps-ops-main {
+ gap: var(--ps-gap-md);
+ }
+
+ .picker-preview-type--ops .ps-ops-plot {
+ height: calc(var(--pt-base) * 2.8);
+ }
+
+ .picker-preview-type--ops .ps-phone-body,
+ [data-carry][data-surface="operate"] .ps-phone-body {
+ gap: var(--ps-gap-sm);
+ }
+
+ .picker-preview-type--ops .ps-phone-body .ps-ops-plot,
+ [data-carry][data-surface="operate"] .ps-phone-body .ps-ops-plot {
+ height: calc(var(--pt-base) * 4.4);
+ }
+}
+
+/* The bottom of the range, where the ladder has floored and the frame is still
+ shrinking. The handset is the frame that runs out first, and the plot is the
+ block that gives on it for the same reason it gives above: four blocks at
+ their own heights and one whose height is a number. */
+@container ps-ops-board (height < 400px) {
+ .picker-preview-type--ops .ps-phone-body .ps-ops-plot,
+ [data-carry][data-surface="operate"] .ps-phone-body .ps-ops-plot {
+ height: calc(var(--pt-base) * 3.2);
+ }
+}
+
/* ============================================================
The Docs board.
@@ -5815,6 +6168,15 @@ body.picker-page {
grid-template-rows: 9.4% minmax(0, 1fr) 8.3%;
}
+/* The board is asked its own height because that is what the document has to
+ fill, and it is not the window's: the frame shrinks with the window while
+ the type ladder floors, so a short frame holds fewer blocks at the same
+ reading size. Everything the fuller page needs hangs off the query below. */
+.picker-preview-type--read {
+ container-type: size;
+ container-name: ps-docs-board;
+}
+
.ps-docs {
min-width: 0;
min-height: 0;
@@ -5869,6 +6231,10 @@ body.picker-page {
.ps-docs-main {
min-width: 0;
+ /* Without it the column takes its automatic minimum height from the copy and
+ grows past the row, which pushes the footer out of the frame. Held to the
+ band, the widest-setting pair runs to the band's edge instead. */
+ min-height: 0;
display: grid;
align-content: start;
padding: var(--ps-band-y) 6% 0 calc(var(--pt-base) * 1.6);
@@ -5900,6 +6266,13 @@ body.picker-page {
margin-top: calc(var(--pt-base) * 0.7);
}
+/* A heading belongs to the passage under it, so that one step stays shorter
+ than the step between two passages. Left at the paragraph interval the
+ subsection floated between the block above it and the block below. */
+.picker-preview-type--read .ps-docs-sub + .ps-docs-para {
+ margin-top: calc(var(--pt-base) * 0.55);
+}
+
.ps-docs-sub {
margin-top: calc(var(--pt-base) * 1.5);
color: var(--pvs-title);
@@ -5910,24 +6283,45 @@ body.picker-page {
overflow-wrap: break-word;
}
+/* The marker is the list's own, so it takes the ink the rail's markers take
+ rather than a drawn element that would have to be positioned against a line
+ whose height changes with the pair. Off until the frame is tall enough to
+ hold it, which is what the container query below decides. */
+.picker-preview-type--read .ps-docs-list {
+ display: none;
+ margin: calc(var(--pt-base) * 1.2) 0 0;
+ padding-left: calc(var(--pt-base) * 1.2);
+ color: var(--pvs-copy);
+ font-family: var(--pt-body);
+ font-size: var(--pt-base);
+ line-height: 1.55;
+}
+
+.picker-preview-type--read .ps-docs-list li {
+ max-width: 62ch;
+}
+
+.picker-preview-type--read .ps-docs-list li + li {
+ margin-top: calc(var(--pt-base) * 0.45);
+}
+
+.picker-preview-type--read .ps-docs-list li::marker {
+ color: var(--pvs-bars);
+}
+
/* A callout is the one place a documentation page prints body copy on a ground
- that is not the page, which is where a light body weight stops holding. */
-.ps-docs-note {
- display: flex;
- gap: calc(var(--pt-base) * 0.6);
+ that is not the page, which is where a light body weight stops holding.
+
+ The accent is the box's own border. It was a border plus a round marker sized
+ off the type scale, and the marker was a flex item with an auto height, so
+ the box it drew was the flex line rather than the circle it was declared as:
+ a stretched ellipse whose length changed with the callout, detached from the
+ edge by the padding. A border cannot leave its box or fall short of it. */
+.picker-preview-type--read .ps-docs-note {
margin-top: calc(var(--pt-base) * 1.3);
padding: calc(var(--pt-base) * 0.7) calc(var(--pt-base) * 0.9);
background-color: var(--pvs-ghost);
- border-left: calc(var(--pt-base) * 0.2) solid var(--pvs-accent-c);
-}
-
-.ps-docs-note-dot {
- flex: none;
- margin-top: calc(var(--pt-base) * 0.42);
- width: calc(var(--pt-base) * 0.34);
- aspect-ratio: 1;
- background-color: var(--pvs-accent-c);
- border-radius: 50%;
+ border-left: calc(var(--pt-base) * 0.25) solid var(--pvs-accent-c);
}
.ps-docs-note-copy {
@@ -5952,10 +6346,12 @@ body.picker-page {
}
/* The handset is the same document with the rail folded into a crumb, which is
- how the drawing puts it too. One passage rather than two: past that the note
- leaves the card. */
-.picker-preview-type--read .ps-phone-body {
- grid-template-rows: repeat(5, auto);
+ how the drawing puts it too. One passage rather than two: the list is what
+ the width is spent on instead, and past that the note leaves the card. The
+ blocks carry the rhythm, so the gap stays at zero. */
+.picker-preview-type--read .ps-phone-body,
+[data-carry][data-surface="read"] .ps-phone-body {
+ grid-template-rows: repeat(6, auto);
align-content: start;
gap: 0;
}
@@ -5980,14 +6376,57 @@ body.picker-page {
margin-top: calc(var(--pt-base) * 2.1);
}
+/* A frame with room for the whole page. The document runs to the bottom of the
+ band instead of stopping partway down it and leaving the footer standing on
+ white: the list arrives, the column takes an inset under it, and whatever a
+ pair leaves over is spread between the blocks rather than pooled under the
+ last one, which is how the landing and portfolio handsets already fill.
+
+ Below this the band and the reading size have pulled apart far enough that
+ the original five blocks are the page: the type ladder floors on a small
+ window while the frame keeps shrinking, so there is no slack to spread and
+ the sixth block would run into the footer. */
+@container ps-docs-board (height >= 545px) {
+ .picker-preview-type--read .ps-docs-main {
+ align-content: space-between;
+ padding-bottom: var(--ps-band-y);
+ }
+
+ .picker-preview-type--read .ps-docs-list {
+ display: block;
+ }
+
+ .picker-preview-type--read .ps-phone-body {
+ align-content: space-between;
+ }
+}
+
+/* Two steps further down, where the handset's five remaining blocks are already
+ more than the frame holds. The callout is the block that goes: it is an aside
+ to the passage above it rather than part of the document's own ladder, and
+ everything above it is either a heading level or the running text the pair is
+ judged on. */
+@container ps-docs-board (height < 440px) {
+ .picker-preview-type--read .ps-phone-body .ps-docs-note,
+ [data-carry][data-surface="read"] .ps-phone-body .ps-docs-note {
+ display: none;
+ }
+}
+
/* ============================================================
The Portfolio board.
The sparsest of the four drawings, so it is the one that changes most. The
- drawing staggers two plates and a carousel rail under them. Here the second
- plate pays for a page title: the staggered pair proves nothing the single
- row does not, and this is the one surface whose heading face is chosen to be
- looked at rather than read past.
+ drawing staggers two plates and a carousel rail under them, and it keeps both
+ plates: this is also the one surface whose heading face is chosen to be looked
+ at rather than read past, so the page title is added above them rather than
+ paid for out of the second entry.
+
+ Both entry rows are an equal share of the band and the plate takes its row,
+ which is the construction the handset below already uses. That is what fills
+ the height: a plate held to its own ratio left the leftover pooled around one
+ centred row, and a band that has to hold whatever a pair's caption comes to
+ cannot be filled by a shape sized off its width.
The tracked caption meta is deliberate and it is the only tracked label in
the picker. An `experience` body face is asked to hold up letter-spaced and
@@ -6001,7 +6440,7 @@ body.picker-page {
min-width: 0;
min-height: 0;
display: grid;
- grid-template-rows: auto minmax(0, 1fr) auto;
+ grid-template-rows: auto minmax(0, 1fr) minmax(0, 1fr) auto;
gap: var(--ps-gap-md);
padding: var(--ps-band-y) var(--ps-gutter);
}
@@ -6017,14 +6456,18 @@ body.picker-page {
column-gap: 5.14%;
}
-/* The plate keeps its own proportion rather than taking the row's height. A
- frame that grew taller would otherwise stretch the work into a column, and
- the room left over reads as the margin an index is hung in. */
-.ps-index-row > .ps-image {
- height: auto;
- max-height: 100%;
- aspect-ratio: 4 / 3;
- align-self: center;
+/* The plate changes sides on the second entry by placement, so both rows stay
+ one element order and the caption keeps reading after the work it names. */
+.ps-index-row--flip {
+ grid-template-columns: minmax(0, 1fr) 42%;
+}
+
+.ps-index-row--flip > .ps-image {
+ grid-area: 1 / 2;
+}
+
+.ps-index-row--flip > .ps-index-cap {
+ grid-area: 1 / 1;
}
.ps-index-cap {
@@ -6108,15 +6551,543 @@ body.picker-page {
/* The handset drops the title and the rail and shows the work: two plates,
each with the caption the desktop row carries. */
-.picker-preview-type--experience .ps-phone-body {
+.picker-preview-type--experience .ps-phone-body,
+[data-carry][data-surface="experience"] .ps-phone-body {
grid-template-rows: minmax(0, 1fr) auto minmax(0, 1fr) auto;
gap: var(--ps-gap-sm);
}
-.picker-preview-type--experience .ps-phone-body .ps-index-cap {
+.picker-preview-type--experience .ps-phone-body .ps-index-cap,
+[data-carry][data-surface="experience"] .ps-phone-body .ps-index-cap {
padding-top: 0;
}
+/* ============================================================
+ The structural boards: the four bodies drawn as wireframes.
+
+ Screens 07 to 10 ask what a page is made of rather than what it is set in, and
+ they ask it of each surface the run chose. The three bodies above this were
+ built for the font screen, where every slot holds a printed line, and the font
+ screen is the only place a slot gets filled. So the same anatomy is drawn a
+ second way here: the tree, the class names, and the geometry are the ones
+ above, and what changes is that a slot is a bar.
+
+ Everything below is scoped to [data-carry], the attribute that marks a board
+ as one accumulating the structural answers. That is exactly screens 07 to 10,
+ so nothing here can reach the color screen's tiles, the font screen's
+ specimens, or the motion screen's scenes, all of which draw the same bodies
+ under their own terms.
+ ============================================================ */
+
+/* A single body between the chrome, so three bands rather than the landing
+ page's five. Declared on the row property rather than through --pvs-rows
+ because the layout answer owns that variable for the page it describes, and
+ the answer this board is showing must not be able to leave it with two empty
+ rows. The same construction the font screen's boards use. */
+[data-carry]:is([data-surface="operate"], [data-surface="read"], [data-surface="experience"]) .ps-desktop {
+ grid-template-rows: 9.4% minmax(0, 1fr) 8.3%;
+}
+
+/* ── A word, drawn ───────────────────────────────────────────
+ One declaration for every slot that holds a line of type upstairs. The height
+ ladder underneath is the type ladder read as thicknesses: a figure and a
+ heading are the two things on a tool you are meant to see from across a room,
+ a row cell and a rail label are the smallest marks the board makes, and a
+ running line sits between them. Widths are stated per slot, because a column
+ of bars all the same length is a table and not prose. */
+[data-carry] :is(
+ .ps-ops-title,
+ .ps-ops-item b,
+ .ps-ops-metric b,
+ .ps-ops-metric span,
+ .ps-ops-chart-title,
+ .ps-ops-lanes b,
+ .ps-ops-row b,
+ .ps-ops-panel-title,
+ .ps-ops-field b,
+ .ps-ops-switch b,
+ .ps-docs-item b,
+ .ps-docs-sub,
+ .ps-docs-crumb,
+ .ps-docs-list li,
+ .ps-docs-note-copy b,
+ .ps-docs-main .pt-headline i,
+ .ps-phone-body .pt-headline i,
+ .ps-docs-lede i,
+ .ps-docs-para i,
+ .ps-docs-note-copy > span i
+) {
+ display: block;
+ background-color: var(--pvs-bars);
+ border-radius: var(--pvs-radius-bar);
+}
+
+/* The two headings, at the two weights the ladder gives them. */
+[data-carry] :is(.ps-ops-title, .ps-docs-main .pt-headline i, .ps-phone-body .pt-headline i) {
+ height: calc(var(--pt-base) * 0.72);
+ background-color: var(--pvs-headline);
+}
+
+[data-carry] .ps-ops-title {
+ width: 38%;
+}
+
+[data-carry] :is(.ps-docs-sub, .ps-ops-chart-title, .ps-ops-panel-title) {
+ height: calc(var(--pt-base) * 0.5);
+ background-color: var(--pvs-title);
+}
+
+[data-carry] .ps-docs-sub {
+ width: 44%;
+}
+
+[data-carry] .ps-ops-chart-title {
+ width: 46%;
+}
+
+[data-carry] .ps-ops-panel-title {
+ width: 62%;
+}
+
+/* Running copy: a stack of lines with a short last one, so a passage reads as a
+ passage. The headline takes the same treatment at its own weight.
+
+ Each gap is the line-height the printed version sets, less the thickness of
+ the bar, so a stack of n bars stands exactly as tall as the n lines it replaces
+ and the block leaves the same room behind it. Guessing the gap instead is what
+ made these boards float apart in a band sized for text: a bar is a quarter the
+ height of the line it stands in for, and four blocks each short by that much
+ is a page of holes. */
+[data-carry] :is(.ps-docs-main, .ps-phone-body) .pt-headline {
+ display: grid;
+ gap: calc(var(--pt-base) * 1.3);
+ margin: 0;
+}
+
+[data-carry] :is(.ps-docs-main, .ps-phone-body) .pt-headline i:last-child {
+ width: 58%;
+}
+
+/* The steps between blocks are the printed board's own, restated because the
+ bars replace the elements those margins were declared on. Kept identical so a
+ document reads with the same rhythm whether it is set or drawn. */
+[data-carry] :is(.ps-docs-lede, .ps-docs-para) {
+ display: grid;
+ gap: calc(var(--pt-base) * 1.25);
+}
+
+[data-carry] .ps-docs-lede {
+ margin: calc(var(--pt-base) * 0.9) 0 0;
+}
+
+[data-carry] .ps-docs-para {
+ margin: calc(var(--pt-base) * 0.7) 0 0;
+}
+
+[data-carry] .ps-docs-sub + .ps-docs-para {
+ margin-top: calc(var(--pt-base) * 0.55);
+}
+
+[data-carry] .ps-docs-note-copy > span {
+ display: grid;
+ gap: calc(var(--pt-base) * 0.67);
+ margin: 0;
+}
+
+[data-carry] :is(.ps-docs-lede, .ps-docs-para) i {
+ height: calc(var(--pt-base) * 0.3);
+ background-color: var(--pvs-copy);
+}
+
+[data-carry] .ps-docs-lede i:last-child,
+[data-carry] .ps-docs-para i:last-child {
+ width: 62%;
+}
+
+[data-carry] .ps-docs-note-copy > span i {
+ height: calc(var(--pt-base) * 0.26);
+}
+
+[data-carry] .ps-docs-note-copy > span i:last-child {
+ width: 54%;
+}
+
+[data-carry] .ps-docs-note-copy b {
+ width: 42%;
+ height: calc(var(--pt-base) * 0.3);
+ background-color: var(--pvs-accent-c);
+}
+
+[data-carry] .ps-docs-crumb {
+ width: 46%;
+ height: calc(var(--pt-base) * 0.26);
+ margin-inline: auto;
+}
+
+/* A list item is one ragged line against its marker, and the marker is the
+ rail's own dot rather than a ::marker, which has no box a bar can stand on. */
+[data-carry] .ps-docs-list {
+ display: grid;
+ gap: calc(var(--pt-base) * 1.7);
+ margin: calc(var(--pt-base) * 1.2) 0 0;
+ padding: 0;
+ list-style: none;
+}
+
+[data-carry] .ps-docs-note {
+ margin-top: calc(var(--pt-base) * 1.3);
+}
+
+[data-carry] .ps-docs-list li {
+ position: relative;
+ height: calc(var(--pt-base) * 0.3);
+ margin-left: calc(var(--pt-base) * 1.1);
+ background-color: var(--pvs-copy);
+}
+
+[data-carry] .ps-docs-list li:nth-child(2) {
+ width: 82%;
+}
+
+[data-carry] .ps-docs-list li::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: calc(var(--pt-base) * -1.1);
+ width: calc(var(--pt-base) * 0.3);
+ aspect-ratio: 1;
+ background-color: var(--pvs-accent-c);
+ border-radius: var(--pvs-radius-dot);
+}
+
+/* The tool's own labels. A rail item and a table cell are the smallest marks on
+ the board, and their lengths are staggered so neither column reads as a
+ ruled form. */
+[data-carry] :is(.ps-ops-item b, .ps-docs-item b, .ps-ops-lanes b, .ps-ops-row b, .ps-ops-field b, .ps-ops-switch b, .ps-ops-metric span) {
+ height: calc(var(--pt-base) * 0.26);
+}
+
+[data-carry] :is(.ps-ops-item, .ps-docs-item) b {
+ flex: 1 1 auto;
+ min-width: 0;
+}
+
+[data-carry] .ps-ops-item b {
+ width: 74%;
+}
+
+/* The open plate is a fill, so the label on it takes the ink that fill was paired
+ with rather than the page's bar tone, which would be reading a light mark
+ against a dark ground and hoping. */
+[data-carry] .ps-ops-item--on b {
+ background-color: var(--pvs-cta-ink);
+}
+
+[data-carry] .ps-docs-item b {
+ width: 72%;
+}
+
+[data-carry] .ps-docs-item:nth-of-type(2) b {
+ width: 88%;
+}
+
+[data-carry] .ps-docs-item:nth-of-type(3) b {
+ width: 60%;
+}
+
+[data-carry] .ps-docs-item:nth-of-type(4) b {
+ width: 80%;
+}
+
+[data-carry] .ps-docs-item--on b {
+ background-color: var(--pvs-accent-c);
+}
+
+[data-carry] .ps-ops-item:nth-of-type(2) b {
+ width: 58%;
+}
+
+[data-carry] .ps-ops-item:nth-of-type(3) b {
+ width: 82%;
+}
+
+[data-carry] .ps-ops-metric b {
+ width: 56%;
+ height: calc(var(--pt-base) * 0.62);
+ background-color: var(--pvs-title);
+}
+
+[data-carry] .ps-ops-metric span {
+ width: 88%;
+}
+
+[data-carry] .ps-ops-lanes b {
+ width: 68%;
+ margin-inline: auto;
+}
+
+/* The row's outer tracks are sized by their contents, so a cell that carries no
+ words has to state the width the words would have taken. */
+[data-carry] .ps-ops-row b:first-of-type {
+ width: calc(var(--pt-base) * 4.4);
+ background-color: var(--pvs-title);
+}
+
+[data-carry] .ps-ops-row b:nth-of-type(2) {
+ width: calc(var(--pt-base) * 3);
+}
+
+[data-carry] .ps-ops-row b:last-of-type {
+ width: calc(var(--pt-base) * 2.2);
+ margin-left: auto;
+}
+
+[data-carry] .ps-ops-row--head b {
+ background-color: var(--pvs-copy);
+}
+
+[data-carry] .ps-ops-field b {
+ width: 64%;
+}
+
+[data-carry] .ps-ops-switch b {
+ width: 56%;
+}
+
+[data-carry] .ps-ops-switch:nth-of-type(3) b {
+ width: 70%;
+}
+
+/* ── The index, drawn ────────────────────────────────────────
+ The caption stand-ins the wireframe index already declares. They are styled
+ here rather than borrowed from the motion screen, whose copies are scoped to
+ its own scenes and carry the fixed pixel sizes those scenes animate. */
+[data-carry] :is(.ps-index-head, .ps-index-name, .ps-index-tag, .ps-index-lines b) {
+ display: block;
+ background-color: var(--pvs-bars);
+ border-radius: var(--pvs-radius-bar);
+}
+
+[data-carry] .ps-index-head {
+ width: 28%;
+ height: calc(var(--pt-base) * 0.72);
+ background-color: var(--pvs-headline);
+}
+
+[data-carry] .ps-index-name {
+ width: 68%;
+ height: calc(var(--pt-base) * 0.52);
+ background-color: var(--pvs-title);
+}
+
+[data-carry] .ps-index-tag {
+ width: 34%;
+ height: calc(var(--pt-base) * 0.24);
+}
+
+[data-carry] .ps-index-lines {
+ display: grid;
+ gap: calc(var(--pt-base) * 0.7);
+ margin-top: calc(var(--pt-base) * 0.3);
+}
+
+[data-carry] .ps-index-lines b {
+ width: 78%;
+ height: calc(var(--pt-base) * 0.26);
+ background-color: var(--pvs-copy);
+}
+
+[data-carry] .ps-index-lines b:last-child {
+ width: 52%;
+}
+
+[data-carry] .ps-index-track b {
+ width: calc(var(--pt-base) * 1.5);
+ height: calc(var(--pt-base) * 0.24);
+ background-color: var(--pvs-bars);
+ border-radius: var(--pvs-radius-bar);
+}
+
+[data-carry] .ps-index-stop--on {
+ background-color: var(--pvs-cta);
+}
+
+/* ── Shape and lift on the three bodies ──────────────────────
+ The corner and depth answers reach the landing page through the slots the
+ drawing has always read. These are the same slots on the parts the other
+ three bodies are made of, so one answer lands on every board.
+
+ Which slot a part takes is the part's own kind, not its size: a rail plate
+ and a switch are controls, a card and a callout are surfaces, and a bar
+ standing in for a line of text is never either, because at control radius it
+ stops reading as text and starts reading as a tag. */
+[data-carry] :is(.ps-ops-item, .ps-ops-field, .ps-ops-row) {
+ border-radius: var(--pvs-radius-control);
+}
+
+[data-carry] :is(.ps-ops-chart, .ps-ops-table, .ps-ops-panel, .ps-ops-rail, .ps-docs-note, .ps-docs-rail, .ps-index-cap) {
+ border-radius: var(--pvs-radius-surface);
+}
+
+/* A switch is the most control-shaped thing a tool has, so it is where this
+ answer reads hardest: fully round under Pill, and squared under Sharp, where a
+ drafted page should have no round control left on it. The knob follows the
+ track, because a square switch holding a circle is neither answer. Both were
+ fixed at 999px, which is the pill answer already given away. */
+[data-carry] :is(.ps-ops-toggle, .ps-ops-toggle::after) {
+ border-radius: var(--pvs-radius-control);
+}
+
+/* A bar stands on the axis, so only the two corners off it can take a radius.
+ Read off the surface slot rather than the control slot: a fully round bar at
+ Pill would be a lozenge floating over the baseline it is measured from. */
+[data-carry] .ps-ops-plot > i {
+ border-radius: var(--pvs-radius-surface) var(--pvs-radius-surface) 0 0;
+}
+
+/* Which of these actually carries a ground is the boundary answer's business, so
+ the depth screen withholds the lift where the answer left the box transparent.
+ The slots are wired here; the gating is in depth.css beside the answer that
+ knows it. */
+[data-carry] :is(.ps-ops-chart, .ps-ops-table, .ps-docs-note) {
+ box-shadow: var(--pvs-shadow-card);
+}
+
+[data-carry] :is(.ps-ops-item--on, .ps-ops-field, .ps-ops-toggle) {
+ box-shadow: var(--pvs-shadow-control);
+}
+
+[data-carry] :is(.ps-ops-rail, .ps-ops-panel, .ps-docs-rail) {
+ box-shadow: var(--pvs-shadow-surface);
+}
+
+/* ── The bands the answer divides ────────────────────────────
+ The layout answer's column tracks, per body. Every track is a whole number of
+ the field's columns, and the halves added on are the halves of a gutter that
+ put a region's dividing line down the middle of a gutter rather than against
+ one edge of it: a shell's regions abut and are told apart by a rule, where a
+ page's blocks are told apart by air. Defaults are the shape the drawing had
+ before the answer was asked, so a board is never left without one. */
+[data-carry] .ps-ops {
+ grid-template-columns: var(--pvs-shell-cols, calc(var(--pvs-w2, 15.3333%) + var(--pvs-gut, 1.6%) / 2) calc(var(--pvs-w7, 57.6667%) + var(--pvs-gut, 1.6%)) minmax(0, 1fr));
+ column-gap: 0;
+ padding-inline: var(--ps-gutter);
+}
+
+[data-carry] .ps-docs {
+ grid-template-columns: var(--pvs-measure-cols, calc(var(--pvs-w3, 23.8%) + var(--pvs-gut, 1.6%) / 2) minmax(0, 1fr));
+ padding-inline: var(--ps-gutter);
+}
+
+/* The metrics row is the shell's second reading of the answer: three equal
+ thirds under a strict grid, and the lead figure taking more than its
+ neighbours once the answer allows an emphasis. */
+[data-carry] .ps-ops-metrics {
+ grid-template-columns: var(--pvs-metric-cols, repeat(3, minmax(0, 1fr)));
+}
+
+[data-carry] .ps-index-row {
+ grid-template-columns: var(--pvs-entry-cols, var(--pvs-w6, 49.2%) minmax(0, 1fr));
+ column-gap: var(--pvs-gut, 1.6%);
+}
+
+[data-carry] .ps-index-row--flip {
+ grid-template-columns: var(--pvs-entry-flip-cols, minmax(0, 1fr) var(--pvs-w6, 49.2%));
+}
+
+/* The rail and the panel start on the page's margin like the nav's mark above
+ them, so their own inset is spent on the inside edge only and the dividing
+ line lands on the column line rather than a text inset past it. */
+[data-carry] .ps-ops-rail {
+ padding-left: 0;
+}
+
+[data-carry] .ps-ops-panel {
+ padding-right: 0;
+}
+
+[data-carry] .ps-docs-rail {
+ padding-left: 0;
+}
+
+/* The measure starts on the next column's own left edge, which is half a gutter
+ past the line the rail's rule stands on. A text inset chosen for looks would
+ put every line of the document a few points off the grid it is being checked
+ against, and this is the one screen where that is the fault on show. */
+[data-carry] .ps-docs-main {
+ padding-inline: calc(var(--pvs-gut, 1.6%) / 2) 0;
+}
+
+/* ── Filling the band ────────────────────────────────────────
+ The trap the docs board hit: a column holding less than its band pools all the
+ slack under the last block and leaves the footer standing on white. Bars are
+ shorter than the lines they stand in for, so these boards have more slack than
+ the specimens do, and every one of them spreads it between the blocks instead.
+
+ Under the query rather than always, and asked of the board's own height rather
+ than the window's, because the ladder floors on a small window while the frame
+ keeps shrinking: below this there is no slack to spread and forcing the spread
+ would push the last block through the footer. */
+[data-carry][data-surface="read"] {
+ container-type: size;
+ container-name: ps-docs-board;
+}
+
+[data-carry][data-surface="operate"] {
+ container-type: size;
+ container-name: ps-ops-board;
+}
+
+@container ps-docs-board (height >= 300px) {
+ [data-carry] .ps-docs-main {
+ align-content: space-between;
+ padding-bottom: var(--ps-band-y);
+ }
+
+ [data-carry] .ps-phone-body:has(.ps-docs-list) {
+ align-content: space-between;
+ }
+}
+
+/* The tool fills its pane differently from the document, and the difference is
+ real rather than a taste: a chart and a table are the two blocks on a
+ dashboard that are *given* their height rather than taking it from what is in
+ them, so the slack goes into them instead of into the gaps between blocks.
+ Spreading the gaps here left a shell whose four blocks floated apart with
+ nothing between them, which is the same emptiness the docs fix was chasing. */
+@container ps-ops-board (height >= 300px) {
+ [data-carry] .ps-ops-main {
+ grid-template-rows: auto auto minmax(0, 1fr) minmax(0, 1fr);
+ align-content: stretch;
+ padding-bottom: var(--ps-band-y);
+ }
+
+ [data-carry] .ps-ops-chart {
+ grid-template-rows: auto minmax(0, 1fr) auto;
+ }
+
+ [data-carry] .ps-ops-plot {
+ height: auto;
+ min-height: 0;
+ }
+
+ [data-carry] .ps-ops-table {
+ align-content: space-between;
+ }
+
+ /* Same reading on the handset: the plot is the block whose height is a count
+ rather than its contents, so it is the one that takes up the slack. */
+ [data-carry][data-surface="operate"] .ps-phone-body {
+ grid-template-rows: auto auto minmax(0, 1fr) auto auto;
+ align-content: stretch;
+ }
+
+ [data-carry][data-surface="operate"] .ps-phone-body .ps-ops-plot {
+ height: auto;
+ min-height: 0;
+ }
+}
+
.picker-palette-hint {
min-height: 3.2em;
padding-inline-end: 48px;
@@ -6142,6 +7113,73 @@ body.picker-page {
color: var(--ks-text-muted);
}
+/* Finish screen: the review copy plus the loader that covers the save and
+ the handoff into the design context document. */
+.picker-finish {
+ max-width: 560px;
+ gap: 18px;
+}
+
+.picker-finish-lede {
+ margin: 0;
+ color: var(--ks-text-muted);
+ font-size: 1.05rem;
+ line-height: 1.6;
+ text-wrap: pretty;
+}
+
+.picker-finish-loader {
+ width: min(360px, 100%);
+ height: 2px;
+ margin-top: 10px;
+ background: color-mix(in oklab, var(--ks-text) 14%, transparent);
+ overflow: hidden;
+}
+
+.picker-finish-loader i {
+ display: block;
+ height: 100%;
+ width: 38%;
+ background: var(--ks-kinpaku);
+ animation: picker-finish-sweep 1.4s var(--ks-ease) infinite;
+}
+
+.picker-finish-loader[data-stalled] i {
+ animation-play-state: paused;
+ opacity: 0.35;
+}
+
+@keyframes picker-finish-sweep {
+ from { translate: -100% 0; }
+ to { translate: 380% 0; }
+}
+
+.picker-finish-status {
+ margin: 0;
+ min-height: 1.6em;
+ color: var(--ks-text-faint);
+ font-size: 0.84rem;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.picker-finish-error {
+ margin: 0;
+ color: var(--ks-text-muted);
+ font-size: 0.94rem;
+ line-height: 1.6;
+}
+
+/* The stack's own display beats the hidden attribute's UA rule, so the error
+ state needs the attribute spelled out. */
+.picker-finish [data-doc-error][hidden] {
+ display: none;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .picker-finish-loader i { animation: none; translate: 0 0; width: 100%; opacity: 0.5; }
+}
+
@media (prefers-reduced-motion: reduce) {
.picker-hero-art,
.picker-progress,
diff --git a/picker/styles/screens/boundaries.css b/picker/styles/screens/boundaries.css
index 304523511..7123cd8da 100644
--- a/picker/styles/screens/boundaries.css
+++ b/picker/styles/screens/boundaries.css
@@ -115,3 +115,90 @@
[data-carry] .ps-phone-body .ps-gallery-item {
padding: calc(var(--pvs-panel-pad, 0px) * 0.5);
}
+
+/* ── The three region bodies ──────────────────────────────────
+ A shell, a document, and an index are separated in different places from a
+ landing page, so the same four answers are spent on different parts. What is
+ shared is the material: the answers above set a hairline, a ground, or an edge,
+ and each body below reads the one it has a use for.
+
+ The dividing rules on these bodies were hardcoded hairlines, which is the
+ thing this screen is asking about. A shell with a permanent rule down its rail
+ answers Open space with a line still on screen. */
+[data-carry] :is(.ps-ops-rail, .ps-ops-main, .ps-docs-rail) {
+ border-right: var(--pvs-divider-w) solid var(--pvs-divider-c);
+}
+
+/* A table is a run of sections stacked, so the hairline answer rules it. The
+ plot's own baseline is not in here: an axis is part of the chart's drawing and
+ not a boundary between two things. */
+[data-carry] .ps-ops-table {
+ border-top: var(--pvs-divider-w) solid var(--pvs-divider-c);
+}
+
+[data-carry] .ps-ops-row + .ps-ops-row {
+ border-top: var(--pvs-divider-w) solid var(--pvs-divider-c);
+}
+
+[data-carry] .ps-index-row + .ps-index-row,
+[data-carry] .ps-index-rail {
+ border-top: var(--pvs-divider-w) solid var(--pvs-divider-c);
+}
+
+[data-carry] :is(.ps-index-row + .ps-index-row, .ps-index-rail) {
+ padding-top: calc(var(--pvs-divider-w) * 8);
+}
+
+/* Grounds. Each one fills its own region and stops at the page margin rather than
+ bleeding to the frame edge. That is the same claim the layout screen's header
+ makes about these boards: their regions sit on the page grid, so the rail's
+ ground starts where the rail starts, on the first column line. Reaching the
+ edge would need the margin restated as a share of a different box, and the two
+ percentages do not resolve against the same width, so the tone would land a
+ point or two off the very line the board is drawn to check. */
+[data-carry] :is(.ps-ops-rail, .ps-ops-panel, .ps-docs-rail) {
+ background-color: var(--pvs-band);
+}
+
+/* Alternating grounds are what a run of entries has instead of bands: the second
+ entry sits on a different tone from the first, and the boundary is where the
+ tone changes. */
+[data-carry] .ps-index-row--flip {
+ background-color: var(--pvs-band-alt);
+}
+
+/* A table's heading row is the one place on a shell where a change of ground
+ does the whole job a rule would have done. */
+[data-carry] .ps-ops-row--head {
+ background-color: var(--pvs-band-alt);
+}
+
+/* The callout is the document's only container, so it is where this screen's
+ answer lands. Its accent edge is not part of the answer: that is what marks
+ the block as a callout, and it stays under all three. */
+[data-carry] .ps-docs-note {
+ padding: calc(var(--pt-base) * 0.7) calc(var(--pt-base) * 0.9);
+ background-color: var(--pvs-band-alt);
+ border-left: calc(var(--pt-base) * 0.25) solid var(--pvs-accent-c);
+}
+
+/* Cards, on a tool. Offered to Persuade and Operate only, so a shell is the one
+ region body that draws it: the chart, the table, and the side panel become
+ objects on a ground, and the rules that had been dividing them go, because an
+ edge and a hairline saying the same thing twice is the fault the answer after
+ Open space is meant to avoid. */
+[data-carry] :is(.ps-ops-chart, .ps-ops-table, .ps-ops-panel) {
+ background-color: var(--pvs-panel);
+ border: var(--pvs-panel-edge-w) solid var(--pvs-panel-edge);
+}
+
+[data-carry] :is(.ps-ops-chart, .ps-ops-table) {
+ padding: var(--pvs-panel-pad, 0px);
+}
+
+/* An open rail plate is a state and reads as one already, so under the card
+ answer the rest of the rail joins it rather than the plate growing an edge. */
+:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="boundary-style"])):has(input[name="boundary-style"][value="cards-and-panels"]:checked)) [data-carry] .ps-ops-item,
+#picker-form:has(.picker-strategy-option:hover input[name="boundary-style"][value="cards-and-panels"]) .picker-preview-boundaries .ps-ops-item {
+ background-color: var(--pvs-panel);
+}
diff --git a/picker/styles/screens/depth.css b/picker/styles/screens/depth.css
index d84fe62fb..7ed4aba21 100644
--- a/picker/styles/screens/depth.css
+++ b/picker/styles/screens/depth.css
@@ -80,3 +80,42 @@
#picker-form:not(:has(input[name="boundary-style"][value="surface-changes"]:checked)) [data-carry] :is(.ps-nav, .ps-phone-top) {
box-shadow: none;
}
+
+/* ── The same rule, on the three region bodies ────────────────
+ Each part is asked the same question the card above was: does the boundary
+ answer leave a ground under it. The answer differs part by part, because a
+ shell's regions, a document's callout, and an index's alternating band are
+ each filled by a different one of the four answers.
+
+ A rail and a document's rail take a ground only where sections are told apart
+ by tone, so that is the only answer they lift under. The side panel is filled
+ by that answer and by the card answer, so it lifts under both. */
+#picker-form:not(:has(input[name="boundary-style"][value="surface-changes"]:checked)) [data-carry] :is(.ps-ops-rail, .ps-docs-rail) {
+ box-shadow: none;
+}
+
+#picker-form:not(:has(input[name="boundary-style"][value="surface-changes"]:checked)):not(:has(input[name="boundary-style"][value="cards-and-panels"]:checked)) [data-carry] .ps-ops-panel {
+ box-shadow: none;
+}
+
+/* The chart and the table are containers only under the card answer. Under the
+ others the lift moves inward to the plot's bars, which are the one part of a
+ chart that is a surface in its own right, exactly as the landing page's card
+ hands its lift to the picture inside it. */
+#picker-form:not(:has(input[name="boundary-style"][value="cards-and-panels"]:checked)) [data-carry] :is(.ps-ops-chart, .ps-ops-table) {
+ box-shadow: none;
+}
+
+#picker-form:not(:has(input[name="boundary-style"][value="cards-and-panels"]:checked)) [data-carry] .ps-ops-plot > i {
+ box-shadow: var(--pvs-shadow-card);
+}
+
+/* The callout and the index's second entry are both filled by the tone answer
+ alone, so both are held to it. */
+#picker-form:not(:has(input[name="boundary-style"][value="surface-changes"]:checked)) [data-carry] .ps-docs-note {
+ box-shadow: none;
+}
+
+#picker-form:has(input[name="boundary-style"][value="surface-changes"]:checked) [data-carry] .ps-index-row--flip {
+ box-shadow: var(--pvs-shadow-card);
+}
diff --git a/picker/styles/screens/layout.css b/picker/styles/screens/layout.css
index ade543d92..e7ea1cd95 100644
--- a/picker/styles/screens/layout.css
+++ b/picker/styles/screens/layout.css
@@ -18,6 +18,49 @@
plus boundaries, corners, and depth, so each later question is asked about
the page as it has been chosen so far. The measure itself does not travel:
it is an instrument for this question, drawn only where it is asked.
+
+ ── One instrument, four pages ──────────────────────────────
+ This screen now draws a board per surface: a landing page, an app shell, a
+ document, an index. The paragraph below used to end by saying the instrument
+ is the same one under all three answers and what moves is the page standing on
+ it. That reading is kept, and it decides how a surface is handled.
+
+ The argument was never about answers in particular. It is that a measure must
+ not be cut from the thing it is measuring, because then the thing and the
+ ruler agree by construction and the ruler says nothing. A surface is another
+ thing being measured. Re-cutting the field per surface would fail in exactly
+ the same way and lose something the single field buys: with one origin and one
+ pitch under all four, an answer can be compared across them, and a dashboard
+ whose regions sit on the same lines a landing page's blocks sit on is making a
+ claim you can check.
+
+ So the field is unchanged. Six and six, twelve columns, one pitch, on every
+ board. What is now per surface is the body standing on it, and the answer is
+ read where each body actually keeps its proportion:
+
+ - the landing page, in the spans its hero and its card row take;
+ - the app shell, in where the rail, the working column, and the panel divide;
+ - the document, in how wide the measure runs beside its rail;
+ - the index, in how each entry splits between the work and its caption.
+
+ Two consequences worth stating, because both look like oversights otherwise.
+
+ The three region bodies pin their margin to the field's own six rather than to
+ the answer's page gutter. On the landing page the margin *is* part of the
+ answer, which is why Balanced keeps the artboard's 5.5 and 3.2 and leans; a
+ shell's regions are checked against the lines, and a rail three pixels shy of
+ one reads as a mistake rather than as a lean. The margin on those boards is
+ the field's, and the answer moves what is inside it.
+
+ And a region's dividing rule sits down the middle of a gutter rather than
+ against one edge, which is why the tracks below add half a gutter and a whole
+ one. A page's blocks are told apart by air and stop at column edges; a shell's
+ regions abut and are told apart by a line. Both land on the drawn field, in
+ the two different ways the drawn field can be met.
+
+ Freeform is offered to Persuade and Experience only (see
+ picker/data/surfaces.js). Operate and Read rule it out, so there is no
+ freeform shell and no freeform document here, and none is drawn.
============================================================ */
/* ── The measure ─────────────────────────────────────────────
@@ -51,8 +94,12 @@
A drawn grid makes it obvious when only some rows honor the same margin, so
chrome and the section band are brought onto the one measure. Scoped to the
structural artboards: screens 03 and 04 are judging color and glyphs, and
- their page is not under a ruler. */
-[data-carry]:not(.picker-preview-layout) :is(.ps-nav, .ps-editorial, .ps-footer) {
+ their page is not under a ruler.
+
+ The landing board only. The nav and the footer are shared chrome, and on the
+ three region boards they take the field's margin on both sides along with the
+ body under them, for the reason in the header. */
+[data-carry][data-surface="persuade"]:not(.picker-preview-layout) :is(.ps-nav, .ps-editorial, .ps-footer) {
padding-right: var(--pvs-gutter-end);
}
@@ -86,8 +133,9 @@
that re-fits itself to whatever page is on it is not a measure: the origin
and the column width would both follow the answer, and three pages each
checked against a ruler cut to fit would be three pages checked against
- nothing. The instrument is the same one under all three. What moves is the
- page standing on it, which is the whole of what this screen is asking.
+ nothing. The instrument is the same one under every answer and on every
+ surface. What moves is the page standing on it, which is the whole of what
+ this screen is asking.
Balanced is the answer that still disagrees. Its page is the shared
artboard's own 5.5 and 3.2 margins, so a field cut from those numbers would
@@ -234,9 +282,9 @@
display: none;
}
-#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="simple-grid"]) .picker-preview-layout .ps-phone-body,
-:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="simple-grid"]:checked) .picker-preview-layout .ps-phone-body {
- grid-template-rows: 31% auto auto auto 34px minmax(0, 1fr);
+#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="simple-grid"]) .picker-preview-layout[data-surface="persuade"] .ps-phone-body,
+:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="simple-grid"]:checked) .picker-preview-layout[data-surface="persuade"] .ps-phone-body {
+ grid-template-rows: minmax(0, 31%) auto auto auto 34px minmax(min-content, 1fr);
}
#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="simple-grid"]) .picker-preview-layout .ps-phone-body .ps-gallery,
@@ -384,7 +432,7 @@
angular cut, running uphill to the right like the desktop shape. The cut
depth rides the flag inside the polygon, so the other answers keep a
rectangle. Everything else on the handset stays on its lines. */
-[data-carry] .ps-phone-body > .ps-image {
+[data-carry][data-surface="persuade"] .ps-phone-body > .ps-image {
clip-path: polygon(0 0, 100% 0, 100% calc(100% - var(--pvs-drift, 0) * 16%), 0 100%);
}
@@ -403,3 +451,117 @@
[data-carry]:not(.picker-preview-layout) .ps-phone-body .ps-gallery {
grid-template-columns: var(--pvs-phone-gallery-cols, 1.05fr 0.95fr);
}
+
+/* ── The regions, per surface ────────────────────────────────
+ One block per answer, and each one says the same thing four ways because that
+ is the point of the four boards: the answer is a claim about proportion, and
+ proportion lives somewhere different on each kind of page.
+
+ The tracks are whole counts of the drawn field's columns. A number is written
+ as the span variable plus the gutter arithmetic that lands its edge on a line,
+ never as the percentage that comes out, so a reader can check the count: the
+ shell below is two, seven, and three, and it says so.
+
+ Both selectors, as everywhere on this screen: the hover preview, then the
+ committed answer. Unlike the landing blocks above, the two carry the same
+ values. A region's proportion is the answer, so there is no separate reading
+ of it to preview. */
+
+/* ── Simple grid, on the three region bodies ─────────────────
+ Even shares and one rule for the whole board. The shell divides two, seven,
+ three, which is the count a tool falls into when nothing is being emphasised.
+ The document runs its measure to the end of the page beside a three-column
+ rail. The index splits every entry six and six and stops alternating, because
+ two entries mirrored are two different rhythms and this is the answer with
+ one. */
+#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="simple-grid"]) [data-carry],
+:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="simple-grid"]:checked) [data-carry] {
+ --pvs-shell-cols:
+ calc(var(--pvs-w2) + var(--pvs-gut) / 2)
+ calc(var(--pvs-w7) + var(--pvs-gut))
+ minmax(0, 1fr);
+ --pvs-measure-cols:
+ calc(var(--pvs-w3) + var(--pvs-gut) / 2)
+ minmax(0, 1fr);
+ --pvs-metric-cols: repeat(3, minmax(0, 1fr));
+ --pvs-entry-cols: var(--pvs-w6) minmax(0, 1fr);
+ --pvs-entry-flip-cols: var(--pvs-w6) minmax(0, 1fr);
+}
+
+/* The stagger is placement rather than order, so undoing it is placement too:
+ both entries put the work first and the caption second. */
+#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="simple-grid"]) [data-carry] .ps-index-row--flip > .ps-image,
+:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="simple-grid"]:checked) [data-carry] .ps-index-row--flip > .ps-image {
+ grid-area: 1 / 1;
+}
+
+#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="simple-grid"]) [data-carry] .ps-index-row--flip > .ps-index-cap,
+:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="simple-grid"]:checked) [data-carry] .ps-index-row--flip > .ps-index-cap {
+ grid-area: 1 / 2;
+}
+
+/* ── Balanced, on the three region bodies ────────────────────
+ The same field spent unevenly, and each body spends it on the block that
+ carries the emphasis. The shell gives the working column a column off the
+ panel and lets its lead figure take more of the metrics row than its
+ neighbours. The document is the clearest of the three: the measure stops three
+ columns short of the page instead of running to the edge, which is the choice
+ a considered document actually makes and the one a strict grid will not.
+ The index leans each entry seven and five, and keeps the alternation, so the
+ run reads as a rhythm rather than a column. */
+#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="balanced"]) [data-carry],
+:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="balanced"]:checked) [data-carry] {
+ --pvs-shell-cols:
+ calc(var(--pvs-w2) + var(--pvs-gut) / 2)
+ calc(var(--pvs-w8) + var(--pvs-gut))
+ minmax(0, 1fr);
+ --pvs-measure-cols:
+ calc(var(--pvs-w3) + var(--pvs-gut) / 2)
+ calc(var(--pvs-w6) + var(--pvs-gut))
+ minmax(0, 1fr);
+ --pvs-metric-cols: 1.45fr 1fr 1fr;
+ --pvs-entry-cols: var(--pvs-w7) minmax(0, 1fr);
+ --pvs-entry-flip-cols: minmax(0, 1fr) var(--pvs-w7);
+}
+
+/* ── Freeform, on the index ──────────────────────────────────
+ Offered to Persuade and Experience only, so this is the one region body that
+ draws it. Same reading as the landing page's: the composition sits on the
+ lines, and then one thing is granted permission to leave them. On an index
+ that thing is the work itself, set down at a slight angle like a print laid on
+ a table, with the caption stepping onto the corner it left. Held next to the
+ drawn field it is teasing, each move reads as a decision.
+
+ The plates lean opposite ways so the run has a beat, and the angle is under
+ two degrees: a print laid down askew, not a page falling over. */
+#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="freeform"]) [data-carry],
+:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="freeform"]:checked) [data-carry] {
+ --pvs-entry-cols: var(--pvs-w7) minmax(0, 1fr);
+ --pvs-entry-flip-cols: minmax(0, 1fr) var(--pvs-w7);
+}
+
+#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="freeform"]) [data-carry][data-surface="experience"] .ps-index-row > .ps-image,
+:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="freeform"]:checked) [data-carry][data-surface="experience"] .ps-index-row > .ps-image {
+ rotate: -1.7deg;
+}
+
+#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="freeform"]) [data-carry][data-surface="experience"] .ps-index-row--flip > .ps-image,
+:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="freeform"]:checked) [data-carry][data-surface="experience"] .ps-index-row--flip > .ps-image {
+ rotate: 1.4deg;
+}
+
+/* The caption crosses the gutter onto the plate's corner, which is the move the
+ landing page makes with its buttons. It stays above the plate it is crossing,
+ and it is the caption that moves rather than the work, because a caption
+ overlapping a photograph is a printed page's own habit. */
+#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="freeform"]) [data-carry][data-surface="experience"] .ps-index-cap,
+:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="freeform"]:checked) [data-carry][data-surface="experience"] .ps-index-cap {
+ position: relative;
+ z-index: 1;
+ translate: -9% 0;
+}
+
+#picker-form:has(.picker-strategy-option:hover input[name="layout-structure"][value="freeform"]) [data-carry][data-surface="experience"] .ps-index-row--flip > .ps-index-cap,
+:where(#picker-form:not(:has(.picker-strategy-option:hover input[name="layout-structure"]))):has(input[name="layout-structure"][value="freeform"]:checked) [data-carry][data-surface="experience"] .ps-index-row--flip > .ps-index-cap {
+ translate: 9% 0;
+}
diff --git a/picker/styles/screens/motion.css b/picker/styles/screens/motion.css
index b34b60322..1e5ac3f3a 100644
--- a/picker/styles/screens/motion.css
+++ b/picker/styles/screens/motion.css
@@ -1249,6 +1249,553 @@
87.5%, 100% { opacity: 0; translate: 0 0; }
}
+/* ============================================================
+ The portfolio board, and its three scenes.
+
+ A landing page and a portfolio spend movement on different things, so the
+ two surfaces this question is put to are shown as two pages rather than as
+ one page relabelled. This board is the index the font screen already draws,
+ in bars: a page title, two staggered entries with a caption ladder beside
+ each, and the carousel rail under them.
+
+ Everything the two boards have in common is drawn once. The frame, the nav
+ and its dropdown, the pointer, the click ring, and the loop curtain all key
+ on the class both boards carry, so the nav station of every scene, the
+ curtain, and the nav's own entrance arrive here already written. What
+ follows is the part a portfolio does differently: the work is the interface,
+ so the stations that are not chrome are plates, captions, and the carousel.
+
+ The three scenes keep the landing page's timings to the frame, because the
+ two are compared by moving between tabs on one screen and a portfolio that
+ ran to a different clock would read as a different answer. Restrained keeps
+ its four stations and its 3.8s, responsive its three and its 4.2s,
+ choreographed its 5.6s reveal.
+
+ These rules are no more specific than the landing page's and come after
+ them, so source order is what settles the elements the two boards share.
+ Anything added here has to stay after them.
+ ============================================================ */
+.picker-preview-motion--index {
+ /* Three bands rather than five: the nav, the index, the footer. */
+ --pvs-rows: 9.4% minmax(0, 1fr) 8.3%;
+}
+
+.picker-preview-motion--index .ps-index {
+ gap: 13px;
+ padding: 2.4% var(--pvs-gutter-end) 2.4% var(--pvs-gutter);
+}
+
+.picker-preview-motion--index .ps-index-head {
+ display: block;
+ width: 26%;
+ height: 13px;
+ background-color: var(--pvs-headline);
+ border-radius: var(--pvs-radius-bar);
+}
+
+/* The plate clips because the caption band the scenes bring up starts below
+ its bottom edge. */
+.picker-preview-motion--index .ps-index-row > .ps-image {
+ overflow: hidden;
+}
+
+/* The view state: a caption band rising over the foot of the plate, which is
+ what a portfolio index answers a pointer with. The plate keeps its own
+ radius on the two corners it shares with the band. */
+.picker-preview-motion--index .ps-index-row > .ps-image::before {
+ content: "";
+ position: absolute;
+ inset: auto 0 0;
+ height: 26%;
+ background-color: var(--pvs-cta);
+ border-radius: 0 0 var(--pvs-radius-surface) var(--pvs-radius-surface);
+ opacity: 0;
+ translate: 0 100%;
+ pointer-events: none;
+}
+
+.picker-preview-motion--index .ps-index-cap {
+ gap: 9px;
+}
+
+.picker-preview-motion--index .ps-index-name {
+ display: block;
+ width: 66%;
+ height: 11px;
+ background-color: var(--pvs-title);
+ border-radius: var(--pvs-radius-bar);
+}
+
+.picker-preview-motion--index .ps-index-tag {
+ display: block;
+ width: 32%;
+ height: 5px;
+ background-color: var(--pvs-bars);
+ border-radius: var(--pvs-radius-bar);
+}
+
+.picker-preview-motion--index .ps-index-lines {
+ display: grid;
+ gap: 7px;
+ margin-top: 3px;
+}
+
+.picker-preview-motion--index .ps-index-lines b {
+ display: block;
+ width: 76%;
+ height: 6px;
+ background-color: var(--pvs-copy);
+ border-radius: var(--pvs-radius-bar);
+}
+
+.picker-preview-motion--index .ps-index-lines b:last-child {
+ width: 54%;
+}
+
+.picker-preview-motion--index .ps-index-arrow {
+ width: 7px;
+}
+
+.picker-preview-motion--index .ps-index-track {
+ align-items: center;
+ gap: 13px;
+}
+
+.picker-preview-motion--index .ps-index-track b {
+ display: block;
+ width: 22px;
+ height: 5px;
+ background-color: var(--pvs-bars);
+ border-radius: var(--pvs-radius-bar);
+}
+
+/* The track is in the selector so the current stop outranks the row it sits
+ in, which is scoped one element deeper. */
+.picker-preview-motion--index .ps-index-track .ps-index-stop--on {
+ background-color: var(--pvs-cta);
+}
+
+/* ── Restrained on the index: four stations, no travel but the pointer ──
+ The two nav items are the landing page's own first two stations and arrive
+ with them. The two that follow are plates: the caption band is present on
+ the frame the pointer earns it and gone on the frame it leaves, the title
+ takes the accent with it, and the plate's corner mark answers as well. The
+ first plate resets the instant the pointer is off it, so no two entries are
+ ever both showing their caption. */
+#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="restrained"]) .picker-preview-motion--index .ps-cursor,
+#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="restrained"]:checked) .picker-preview-motion--index .ps-cursor {
+ animation: mxr-path 3.8s linear infinite;
+}
+
+#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="restrained"]) .picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image::before,
+#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="restrained"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image::before {
+ animation: mxr-band-1 3.8s linear infinite;
+}
+
+#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="restrained"]) .picker-preview-motion--index .ps-index-row:nth-of-type(1) .ps-index-name,
+#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="restrained"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(1) .ps-index-name {
+ animation: mxr-name-1 3.8s linear infinite;
+}
+
+#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="restrained"]) .picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image::after,
+#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="restrained"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image::after {
+ animation: mxr-mark-1 3.8s linear infinite;
+}
+
+#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="restrained"]) .picker-preview-motion--index .ps-index-row:nth-of-type(2) > .ps-image::before,
+#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="restrained"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(2) > .ps-image::before {
+ animation: mxr-band-2 3.8s linear infinite;
+}
+
+#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="restrained"]) .picker-preview-motion--index .ps-index-row:nth-of-type(2) .ps-index-name,
+#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="restrained"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(2) .ps-index-name {
+ animation: mxr-name-2 3.8s linear infinite;
+}
+
+#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="restrained"]) .picker-preview-motion--index .ps-index-row:nth-of-type(2) > .ps-image::after,
+#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="restrained"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(2) > .ps-image::after {
+ animation: mxr-mark-2 3.8s linear infinite;
+}
+
+/* The landing page's own restrained stops, with the two plates standing in for
+ its two buttons: in 0-0.35, first nav 0.35-0.95, second 1.25-1.55, first
+ plate 2.20-2.85, second 3.15-3.45, out by 3.67. */
+@keyframes mxr-path {
+ 0% { translate: var(--mtr-entry, 64.5cqw -8cqh); }
+ 9.21%, 25% { translate: var(--mtr-nav1, 64.8cqw 4.8cqh); }
+ 32.89%, 40.79% { translate: var(--mtr-nav2, 71.4cqw 4.8cqh); }
+ 57.89%, 75% { translate: var(--mxi-work1, 25.6cqw 33.3cqh); }
+ 82.89%, 90.79% { translate: var(--mxi-work2, 74.4cqw 60.9cqh); }
+ 96.5%, 100% { translate: var(--mtr-entry, 64.5cqw -8cqh); }
+}
+
+@keyframes mxr-band-1 {
+ 0%, 57.88% { opacity: 0; translate: 0 100%; }
+ 57.89%, 74.99% { opacity: 1; translate: 0 0; }
+ 75%, 100% { opacity: 0; translate: 0 100%; }
+}
+
+@keyframes mxr-name-1 {
+ 0%, 57.88% { background-color: var(--pvs-title); }
+ 57.89%, 74.99% { background-color: var(--pvs-cta); }
+ 75%, 100% { background-color: var(--pvs-title); }
+}
+
+@keyframes mxr-mark-1 {
+ 0%, 57.88% { background-color: var(--pvs-accent-d); scale: 1; }
+ 57.89%, 74.99% { background-color: var(--pvs-cta); scale: 1.4; }
+ 75%, 100% { background-color: var(--pvs-accent-d); scale: 1; }
+}
+
+@keyframes mxr-band-2 {
+ 0%, 82.88% { opacity: 0; translate: 0 100%; }
+ 82.89%, 90.78% { opacity: 1; translate: 0 0; }
+ 90.79%, 100% { opacity: 0; translate: 0 100%; }
+}
+
+@keyframes mxr-name-2 {
+ 0%, 82.88% { background-color: var(--pvs-title); }
+ 82.89%, 90.78% { background-color: var(--pvs-cta); }
+ 90.79%, 100% { background-color: var(--pvs-title); }
+}
+
+@keyframes mxr-mark-2 {
+ 0%, 82.88% { background-color: var(--pvs-accent-d); scale: 1; }
+ 82.89%, 90.78% { background-color: var(--pvs-cta); scale: 1.4; }
+ 90.79%, 100% { background-color: var(--pvs-accent-d); scale: 1; }
+}
+
+/* ── Responsive on the index: three stations, each answered ──
+ The nav station and its staggered dropdown are the landing page's and run
+ here unchanged. The second is the first plate: the caption band rises over
+ its foot in 200ms, the title recolors and the corner mark answers with it.
+ The third is the carousel, which is the one control only this board has: the
+ next arrow turns, the track shifts under it, and the stop that was current
+ hands over to the next one.
+
+ A carousel that snapped back the moment the pointer left would be reporting
+ a state nobody put it in, so the advance is the one thing here that stays.
+ It returns across the single frame the pointer teleports off-frame on, which
+ is also what makes 100% equal 0% and the loop seamless. */
+#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="responsive"]) .picker-preview-motion--index .ps-cursor,
+#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="responsive"]:checked) .picker-preview-motion--index .ps-cursor {
+ animation: mxv-path 4.2s ease-in-out infinite;
+}
+
+#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="responsive"]) .picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image::before,
+#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="responsive"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image::before {
+ animation: mxv-band 4.2s linear infinite;
+}
+
+#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="responsive"]) .picker-preview-motion--index .ps-index-row:nth-of-type(1) .ps-index-name,
+#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="responsive"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(1) .ps-index-name {
+ animation: mxv-name 4.2s linear infinite;
+}
+
+#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="responsive"]) .picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image::after,
+#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="responsive"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image::after {
+ animation: mxv-mark 4.2s linear infinite;
+}
+
+#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="responsive"]) .picker-preview-motion--index .ps-index-arrow--next,
+#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="responsive"]:checked) .picker-preview-motion--index .ps-index-arrow--next {
+ animation: mxv-arrow 4.2s linear infinite;
+}
+
+#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="responsive"]) .picker-preview-motion--index .ps-index-track,
+#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="responsive"]:checked) .picker-preview-motion--index .ps-index-track {
+ animation: mxv-track 4.2s linear infinite;
+}
+
+#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="responsive"]) .picker-preview-motion--index .ps-index-track b:nth-child(1),
+#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="responsive"]:checked) .picker-preview-motion--index .ps-index-track b:nth-child(1) {
+ animation: mxv-stop-off 4.2s linear infinite;
+}
+
+#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="responsive"]) .picker-preview-motion--index .ps-index-track b:nth-child(2),
+#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="responsive"]:checked) .picker-preview-motion--index .ps-index-track b:nth-child(2) {
+ animation: mxv-stop-on 4.2s linear infinite;
+}
+
+/* Arrivals: nav at 5.95% (0.25s), plate at 34.52% (1.45s), arrow at 60.71%
+ (2.55s). Departures at 25%, 50%, and 83.33%, the landing page's own. */
+@keyframes mxv-path {
+ 0% { translate: var(--mtr-entry, 64.5cqw -8cqh); }
+ 5.95%, 25% { translate: var(--mtr-nav1, 64.8cqw 4.8cqh); }
+ 34.52%, 50% { translate: var(--mxi-work1, 25.6cqw 33.3cqh); }
+ 60.71%, 83.33% { translate: var(--mxi-rail, 92.6cqw 87.9cqh); }
+ 90%, 99.99% { translate: 32cqw 115cqh; }
+ 100% { translate: var(--mtr-entry, 64.5cqw -8cqh); }
+}
+
+@keyframes mxv-band {
+ 0%, 34.52% {
+ opacity: 0;
+ translate: 0 100%;
+ animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
+ }
+ 39.29%, 50% { opacity: 1; translate: 0 0; }
+ 52.86%, 100% { opacity: 0; translate: 0 100%; }
+}
+
+@keyframes mxv-name {
+ 0%, 34.52% {
+ background-color: var(--pvs-title);
+ animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
+ }
+ 38.33%, 50% { background-color: var(--pvs-cta); }
+ 52.86%, 100% { background-color: var(--pvs-title); }
+}
+
+@keyframes mxv-mark {
+ 0%, 34.52% {
+ background-color: var(--pvs-accent-d);
+ scale: 1;
+ animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
+ }
+ 39.29%, 50% { background-color: var(--pvs-cta); scale: 1.4; }
+ 52.86%, 100% { background-color: var(--pvs-accent-d); scale: 1; }
+}
+
+@keyframes mxv-arrow {
+ 0%, 60.71% {
+ border-color: var(--pvs-bars);
+ animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
+ }
+ 63.57%, 83.33% { border-color: var(--pvs-cta); }
+ 86.19%, 100% { border-color: var(--pvs-bars); }
+}
+
+@keyframes mxv-track {
+ 0%, 61.9% {
+ translate: 0 0;
+ animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
+ }
+ 67.62%, 99.99% { translate: -6px 0; }
+ 100% { translate: 0 0; }
+}
+
+@keyframes mxv-stop-off {
+ 0%, 61.9% {
+ background-color: var(--pvs-cta);
+ animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
+ }
+ 65.71%, 99.99% { background-color: var(--pvs-bars); }
+ 100% { background-color: var(--pvs-cta); }
+}
+
+@keyframes mxv-stop-on {
+ 0%, 61.9% {
+ background-color: var(--pvs-bars);
+ animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
+ }
+ 65.71%, 99.99% { background-color: var(--pvs-cta); }
+ 100% { background-color: var(--pvs-bars); }
+}
+
+/* ── Choreographed on the index: the index hangs itself ──
+ The curtain, the nav's arrival, and the footer's are the landing page's and
+ run here as they are. What this board sequences is an index being hung: the
+ page title wipes in, the first plate uncovers left to right and its caption
+ composes after it, the second plate uncovers from the other side because the
+ row is flipped and a reveal that ignored the flip would run away from the
+ caption it belongs to, then the carousel's arrows and stops arrive on an
+ 80ms walk. The finale is the pointer coming up to the first entry, which is
+ the same hover the responsive scene demonstrates, arriving as the last beat
+ of a sequence instead of as an answer.
+
+ In seconds of the 5.6: title 0.45-1.05, first plate 0.9-1.7 with its caption
+ 1.5-2.0, second plate 1.9-2.7 with its caption 2.5-3.0, arrows 3.1-3.4,
+ stops 3.2-3.6, footer 3.3-3.6, the hover 3.9-4.9, curtain 5.4-5.6.
+
+ The caption reveals sit on the ladder rather than on its bars, because the
+ title bar spends its own keyframes on the finale and an element takes one
+ animation per property. */
+#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="choreographed"]) .picker-preview-motion--index .ps-cursor,
+#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="choreographed"]:checked) .picker-preview-motion--index .ps-cursor {
+ animation: mxc-path 5.6s ease-in-out infinite;
+}
+
+#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="choreographed"]) .picker-preview-motion--index .ps-index-head,
+#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="choreographed"]:checked) .picker-preview-motion--index .ps-index-head {
+ animation: mxc-head 5.6s linear infinite;
+}
+
+#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="choreographed"]) .picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image,
+#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="choreographed"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image {
+ animation: mxc-plate-1 5.6s linear infinite;
+}
+
+#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="choreographed"]) .picker-preview-motion--index .ps-index-row:nth-of-type(2) > .ps-image,
+#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="choreographed"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(2) > .ps-image {
+ animation: mxc-plate-2 5.6s linear infinite;
+}
+
+#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="choreographed"]) .picker-preview-motion--index .ps-index-row:nth-of-type(1) .ps-index-cap,
+#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="choreographed"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(1) .ps-index-cap {
+ animation: mxc-cap-1 5.6s linear infinite;
+}
+
+#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="choreographed"]) .picker-preview-motion--index .ps-index-row:nth-of-type(2) .ps-index-cap,
+#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="choreographed"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(2) .ps-index-cap {
+ animation: mxc-cap-2 5.6s linear infinite;
+}
+
+#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="choreographed"]) .picker-preview-motion--index .ps-index-arrow,
+#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="choreographed"]:checked) .picker-preview-motion--index .ps-index-arrow {
+ animation: mxc-arrow 5.6s linear infinite;
+}
+
+#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="choreographed"]) .picker-preview-motion--index .ps-index-track b:nth-child(1),
+#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="choreographed"]:checked) .picker-preview-motion--index .ps-index-track b:nth-child(1) {
+ animation: mxc-stop-1 5.6s linear infinite;
+}
+
+#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="choreographed"]) .picker-preview-motion--index .ps-index-track b:nth-child(2),
+#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="choreographed"]:checked) .picker-preview-motion--index .ps-index-track b:nth-child(2) {
+ animation: mxc-stop-2 5.6s linear infinite;
+}
+
+#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="choreographed"]) .picker-preview-motion--index .ps-index-track b:nth-child(3),
+#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="choreographed"]:checked) .picker-preview-motion--index .ps-index-track b:nth-child(3) {
+ animation: mxc-stop-3 5.6s linear infinite;
+}
+
+#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="choreographed"]) .picker-preview-motion--index .ps-index-track b:nth-child(4),
+#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="choreographed"]:checked) .picker-preview-motion--index .ps-index-track b:nth-child(4) {
+ animation: mxc-stop-4 5.6s linear infinite;
+}
+
+#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="choreographed"]) .picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image::before,
+#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="choreographed"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image::before {
+ animation: mxc-band 5.6s linear infinite;
+}
+
+#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="choreographed"]) .picker-preview-motion--index .ps-index-row:nth-of-type(1) .ps-index-name,
+#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="choreographed"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(1) .ps-index-name {
+ animation: mxc-name 5.6s linear infinite;
+}
+
+#picker-form:has(.picker-strategy-option:hover input[name="motion-energy"][value="choreographed"]) .picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image::after,
+#picker-form:not(:has(.picker-strategy-option:hover input[name="motion-energy"])):has(input[name="motion-energy"][value="choreographed"]:checked) .picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image::after {
+ animation: mxc-mark 5.6s linear infinite;
+}
+
+/* The pointer waits below the frame until the index is hung, comes up to the
+ first entry at 3.9s, and is gone again before the curtain, so both ends of
+ the loop are off-frame. */
+@keyframes mxc-path {
+ 0%, 62.5% { translate: 45cqw 115cqh; }
+ 69.64%, 87.5% { translate: var(--mxi-work1, 25.6cqw 33.3cqh); }
+ 93%, 100% { translate: 45cqw 115cqh; }
+}
+
+@keyframes mxc-head {
+ 0%, 8.04% {
+ clip-path: inset(0 100% 0 0);
+ animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
+ }
+ 18.75%, 100% { clip-path: inset(0 0 0 0); }
+}
+
+@keyframes mxc-plate-1 {
+ 0%, 16.07% {
+ clip-path: inset(0 100% 0 0);
+ animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
+ }
+ 30.36%, 100% { clip-path: inset(0 0 0 0); }
+}
+
+@keyframes mxc-plate-2 {
+ 0%, 33.93% {
+ clip-path: inset(0 0 0 100%);
+ animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
+ }
+ 48.21%, 100% { clip-path: inset(0 0 0 0); }
+}
+
+@keyframes mxc-cap-1 {
+ 0%, 26.79% {
+ opacity: 0;
+ translate: 0 6px;
+ animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
+ }
+ 35.71%, 100% { opacity: 1; translate: 0 0; }
+}
+
+@keyframes mxc-cap-2 {
+ 0%, 44.64% {
+ opacity: 0;
+ translate: 0 6px;
+ animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
+ }
+ 53.57%, 100% { opacity: 1; translate: 0 0; }
+}
+
+@keyframes mxc-arrow {
+ 0%, 55.36% { opacity: 0; }
+ 60.71%, 100% { opacity: 1; }
+}
+
+@keyframes mxc-stop-1 {
+ 0%, 57.14% { opacity: 0; }
+ 62.5%, 100% { opacity: 1; }
+}
+
+@keyframes mxc-stop-2 {
+ 0%, 58.57% { opacity: 0; }
+ 63.93%, 100% { opacity: 1; }
+}
+
+@keyframes mxc-stop-3 {
+ 0%, 60% { opacity: 0; }
+ 65.36%, 100% { opacity: 1; }
+}
+
+@keyframes mxc-stop-4 {
+ 0%, 61.43% { opacity: 0; }
+ 66.79%, 100% { opacity: 1; }
+}
+
+@keyframes mxc-band {
+ 0%, 69.64% {
+ opacity: 0;
+ translate: 0 100%;
+ animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
+ }
+ 73.21%, 87.5% { opacity: 1; translate: 0 0; }
+ 89.29%, 100% { opacity: 0; translate: 0 100%; }
+}
+
+/* One animation for two beats: the title's own reveal, then the accent and the
+ extra measure it takes when the pointer arrives at the end of the scene. */
+@keyframes mxc-name {
+ 0%, 26.79% {
+ clip-path: inset(0 100% 0 0);
+ width: 66%;
+ background-color: var(--pvs-title);
+ animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
+ }
+ 33.93%, 70.54% {
+ clip-path: inset(0 0 0 0);
+ width: 66%;
+ background-color: var(--pvs-title);
+ animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
+ }
+ 75%, 87.5% { clip-path: inset(0 0 0 0); width: 74%; background-color: var(--pvs-cta); }
+ 89.29%, 100% { clip-path: inset(0 0 0 0); width: 66%; background-color: var(--pvs-title); }
+}
+
+@keyframes mxc-mark {
+ 0%, 70.54% {
+ background-color: var(--pvs-accent-d);
+ scale: 1;
+ animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
+ }
+ 74.11%, 87.5% { background-color: var(--pvs-cta); scale: 1.4; }
+ 89.29%, 100% { background-color: var(--pvs-accent-d); scale: 1; }
+}
+
/* A loop nobody asked for is the definition of a nonessential one. Reduced
motion gets the end of the scene instead: the pointer parked over the card
it chose, with every state already changed, which is the same information
@@ -1263,12 +1810,19 @@
.picker-preview-motion .ps-actions i::after,
.picker-preview-motion .ps-gallery-item > i::before,
.picker-preview-motion .ps-gallery-item > i::after,
+ .picker-preview-motion .ps-image::before,
.picker-preview-motion .ps-image::after,
.picker-preview-motion .ps-desktop::after,
.picker-preview-motion :is(
.ps-cursor,
.ps-nav,
.ps-nav-bars i,
+ .ps-index-head,
+ .ps-index-cap,
+ .ps-index-name,
+ .ps-index-arrow,
+ .ps-index-track,
+ .ps-index-track b,
.ps-hero-copy > *,
.ps-eyebrow,
.ps-headline i,
@@ -1316,4 +1870,25 @@
filter: opacity(var(--mt-dim));
translate: 0 var(--mt-sink);
}
+
+ /* The portfolio board's own resting frame: the pointer on the first entry
+ with that entry's caption band up, which is where all three of its scenes
+ end up. */
+ .picker-preview-motion--index .ps-cursor {
+ translate: var(--mxi-work1, 25.6cqw 33.3cqh);
+ }
+
+ .picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image::before {
+ opacity: 1;
+ translate: 0 0;
+ }
+
+ .picker-preview-motion--index .ps-index-row:nth-of-type(1) .ps-index-name {
+ background-color: var(--pvs-cta);
+ }
+
+ .picker-preview-motion--index .ps-index-row:nth-of-type(1) > .ps-image::after {
+ background-color: var(--pvs-cta);
+ scale: 1.4;
+ }
}
diff --git a/skill/reference/document.md b/skill/reference/document.md
index d646be4ce..8026e487b 100644
--- a/skill/reference/document.md
+++ b/skill/reference/document.md
@@ -73,7 +73,7 @@ If a `DESIGN.md` already exists, **do not silently overwrite it**. Show the user
## Two paths
- **Scan mode** (default): the project has design tokens, components, or rendered output. Extract, then confirm descriptive language. Use when there's code to analyze.
-- **Seed mode**: the project is pre-implementation (fresh init, nothing built yet). Gather any existing brand assets, interview for five high-level answers, optionally generate visual cues for the palette pick, write a minimal DESIGN.md marked ``. Re-run in scan mode once there's code.
+- **Seed mode**: the project is pre-implementation (fresh init, nothing built yet). Gather any existing brand assets, interview for five high-level answers, optionally generate visual cues and run the browser questionnaire, then write a seed DESIGN.md marked `` that carries every decision the interview and the questionnaire made. Re-run in scan mode once there's code.
Decide by scanning first (Scan mode Step 1). If the scan finds no tokens, no component files, and no rendered site, offer seed mode; don't silently switch. `/impeccable document --seed` forces seed mode on a pre-implementation project, but it does not authorize replacing coherent code: when an incumbent system exists, offer scan mode or route an explicit identity-replacement request through new-work.
@@ -410,29 +410,50 @@ Interview answers are words; a palette is easier picked by eye. Before writing t
- **No usable native path, no key**: pause and {{ask_instruction}} whether the user wants generated visual cues to pick a palette by eye. *"I can generate a few small palette-and-mood images so you choose a direction visually instead of from descriptions. That needs an image-generation API key (FLUX and Google Nano Banana are supported out of the box; other providers work too), stored as `IMAGE_GEN_API_KEY` in `.impeccable/.env`. Add one, or skip straight to the seed?"* If a key arrives, write it to `.impeccable/.env` together with `IMAGE_GEN_PROVIDER` (`bfl` for FLUX, `gemini` for Nano Banana, the provider's own name for anything else; when the user does not say, let the wrapper infer it from the key). Confirm that file is listed in the project's `.gitignore` (add it if missing; a committed key is a leak), then load [image-api.md](image-api.md). Its shipped wrapper is the whole integration for the built-in providers; only a provider it does not know earns the project-local wrapper that file specifies.
- **The user opts out, or no key arrives**: go to Step 5 and seed from the answers alone.
-When generation is available, **stop and load [visual-cues.md](visual-cues.md)** and follow its pipeline; it owns everything from the one-line user announcement and the persona palette studio through generation, `cues.json`, and the picker pause. Do not restate its mechanics here or in chat. Do not write DESIGN.md in that turn; Steps 5-6 run when a completed picker is handed to the later seed consumer, or immediately when the user opted out of generation.
+When generation is available, **stop and load [visual-cues.md](visual-cues.md)** and follow its pipeline; it owns everything from the one-line user announcement and the persona palette studio through generation, `cues.json`, and the picker pause. Do not restate its mechanics here or in chat. The picker's exit is the handoff: when the server exits 0 and `.impeccable/design-interview/answers.json` lands, come back here and run Steps 5-6 with that file in hand. When the user opted out of generation (or no key arrived), run Steps 5-6 immediately from the interview alone.
### Step 5: Write seed DESIGN.md
-Use the canonical section order from Scan mode. Populate what the interview, the assets, and any cue pick answer; leave the rest as honest placeholders. The seed is a scaffold, not a fabricated spec.
+Use the canonical section order from Scan mode. Populate what the interview, the assets, and the questionnaire answer; leave the rest as honest placeholders. The seed is a scaffold, not a fabricated spec, but a decision the user actually made in the picker is real and belongs in the file at full strength.
-Lead the file with:
+Mark the file as a seed with this comment as the first line of the markdown body, immediately after the frontmatter's closing `---` (the frontmatter must open the file or token parsers will not see it):
```markdown
```
-Per-section guidance in seed mode:
+**Two seeds exist**, and which one you write depends on whether Step 4's picker ran:
+
+**Interview-only seed** (the user opted out of generation, or no key arrived). Per-section guidance:
- **Overview**: Creative North Star and philosophy phrased from the answers (color strategy + motion energy + references). Reference the user's anti-reference directly.
-- **Colors**: Color strategy as a Named Rule (e.g. *"The Drenched Rule. The surface IS the color."*). Hue family or anchor reference. Colors sampled from a provided logo, or from a cue image the user picked in Step 4, are real; include them with exact values and note the source. Everything else stays `[to be resolved during implementation]`; those sampled anchors are the only hex a seed may carry.
+- **Colors**: Color strategy as a Named Rule (e.g. *"The Drenched Rule. The surface IS the color."*). Hue family or anchor reference. Colors sampled from a provided logo are real; include them with exact values and note the source. Everything else stays `[to be resolved during implementation]`; those sampled anchors are the only hex this seed may carry.
- **Typography**: the direction the user picked (e.g. "Serif display + sans body"). No font names yet: `[font pairing to be chosen at implementation]`.
- **Layout** and **Shapes**: omit unless an asset or answer established a spatial or form preference; do not invent grids or corner language pre-implementation.
- **Elevation & Depth**: inferred from motion energy. Restrained/Responsive → flat by default; Choreographed → layered. One sentence.
- **Components**: omit entirely; no components exist yet.
- **Do's and Don'ts**: carry PRODUCT.md's anti-references directly plus the anti-reference named in Q5.
-Seed mode writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet. Real tokens land on the next Scan-mode run. Skip the `.impeccable/design.json` sidecar in seed mode for the same reason: nothing to render.
+This seed writes a minimal frontmatter with `name` and `description` only; no colors, typography, rounded, spacing, or components yet.
+
+**Questionnaire seed** (`.impeccable/design-interview/answers.json` exists from this run). The user answered every screen by eye, so the seed carries their answers as decisions, not directions. Read the answers file plus the picked cue's palette entry in `.impeccable/visual-cues/cues.json` (`palette-source` names it), and map:
+
+- **Frontmatter**: `name` and `description`, plus real `colors` (the four `palette-*` hex values under descriptive slugs; these are picked, not sampled) and real `typography` (`font-heading` and `font-body` are exact family names; give each role its family and weight intent, leave sizes for implementation). Still no `rounded`, `spacing`, or `components`: the corner and spacing answers are qualitative, and nothing is built.
+- **Overview**: as the interview-only seed, plus name the chosen surfaces (`surface-modes`) and what each is for. Movement stays here, after the North Star, but the questionnaire asks it of a landing page and a portfolio only, so write what the keys support:
+ - `motion-energy-` keys present, all agreeing: one philosophy sentence for the product, as before.
+ - Keys present and disagreeing: one sentence per surface, named (*"The landing page moves on state change only; the portfolio stages entrances and drives sequences on scroll."*). The bare `motion-energy` is the leading one of the two.
+ - **No `motion-energy` key at all**: the run has neither of those surfaces, so movement was never asked. Say nothing about it, and do not fill the gap from the interview's Q3 or from the register. The next Scan-mode run reads the real transitions out of the code.
+- **Colors**: the four roles with their picked hex, noting the cue they came from. `color-strategy` becomes the Named Rule. When surfaces differ (`color-strategy-` keys), state each surface's strategy and which surface leads (the bare key's owner).
+- **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**: `layout-structure` (how pages are composed) and `boundary-style` (how sections separate), per surface when the `-` keys differ. No invented grids beyond what the answer states.
+- **Elevation & Depth**: `depth-style` per surface, stated directly; the questionnaire answered this, so do not re-infer it from motion energy.
+- **Shapes**: `corner-style` per surface.
+- **Components**: still omit; nothing exists yet.
+- **Do's and Don'ts**: the interview-only guidance, plus a Do fixing the icon source: every icon comes from the chosen pack (`icon-pack-name`, license, URL), no mixed sets.
+
+Per-surface answers come back for every chosen surface, defaults included, and a difference between surfaces is a decision the picker enforced, not an inconsistency to smooth over (the option lists differ per surface). Where all surfaces agree, state the answer once for the product. `motion-energy` is the one key that can be missing entirely, since only a landing page and a portfolio are asked about movement; [visual-cues.md](visual-cues.md) has the full contract.
+
+Both seeds skip the `.impeccable/design.json` sidecar: nothing to render yet. Real tokens for sizes, spacing, and components land on the next Scan-mode run.
### Step 6: Confirm
@@ -441,6 +462,8 @@ Seed mode writes a minimal frontmatter with `name` and `description` only; no co
Your own write is the freshest source; no reload needed.
+When the questionnaire ran, the confirm is not the end of the turn: the design context document in the user's tab is live for edits through the session the picker forked. Follow the document edit loop in [visual-cues.md](visual-cues.md): poll, apply `edit_request`s to this same DESIGN.md, reply. A color the user changed in the tab before your seed write is already in `answers.json`; one changed after lands in DESIGN.md without you (the session swaps the hex itself, journaled in `.impeccable/design-interview/doc-edits.jsonl` for the name-reconciliation pass at exit).
+
## Style guidelines
- **Frontmatter first, prose second.** Tokens go in the YAML frontmatter; prose contextualizes them. Don't redefine a token value in two places; the frontmatter is normative.
diff --git a/skill/reference/visual-cues.md b/skill/reference/visual-cues.md
index f70c22071..8d8131c98 100644
--- a/skill/reference/visual-cues.md
+++ b/skill/reference/visual-cues.md
@@ -453,11 +453,31 @@ Done when: `fonts.json` is parseable, contains exactly six ranked pairs, every f
Before launching, write the surface set from Step 6 into `cues.json` as a top-level `modes` array: any of `persuade`, `operate`, `read`, `experience`. Do not re-derive it; the font pairs were composed against that reading, and a second judgment here would hand the user tiles the shortlist never answered to. The picker's first question pre-checks those tiles as its starting point; the user corrects the set by hand, and the final selection returns in the answers as `surface-modes`. Omit the field when the product gave no clear signal; the picker then starts from `persuade` alone.
-Four of the questions are then answered per surface rather than once for the whole run, because the answer that suits a marketing page rarely suits the tool it sells: `color-strategy`, `boundary-style` (how sections are separated), `corner-style` (how round shapes are), and `depth-style` (how far off the page things sit). Each of the four comes back twice over. The bare key holds the leading surface's answer, which is the first chosen tile in tile order and the one every later screen previews. Alongside it is one `-` key for every surface chosen, `` being `persuade`, `operate`, `read`, or `experience`. Surfaces the user never opened are included too, holding the default for their kind; a surface nobody chose returns nothing at all.
+In the same write, add a top-level `context` object carrying the chat half of the run, because after the last question the picker shows the user a design context document assembled from everything the interview learned, and the browser only knows what it asked itself. Every field is optional and the document renders whatever arrives, so fill what the run actually established and leave out the rest:
-The picker does not offer every option on every surface. A landing page can take any answer to all four questions, and the other three surfaces have options withheld from them: a page people work in or read at length is not offered the loudest color or the deepest shadow, a tool is not offered separation by spacing alone, and a portfolio is not offered four working colors or fully round controls. So a value that comes back is one that suits the surface it came from, and a difference between two surfaces is a decision rather than an inconsistency to reconcile.
+```json
+"context": {
+ "product": { "name": "[product name]", "purpose": "[one-sentence purpose from PRODUCT.md]" },
+ "audience": { "primary": "[who]", "secondary": "[who]", "emotion": "[emotional goal on landing]", "needs": ["[need]"] },
+ "brand": { "words": ["[word]"], "personality": "[one sentence from PRODUCT.md Brand Personality]" },
+ "assets": ["[asset name: what Step 2 read off it]"],
+ "interview": {
+ "colorStrategy": "[Q1 pick]", "hueAnchor": "[Q1 anchor]",
+ "typeDirection": "[Q2 pick]", "motionEnergy": "[Q3 pick]",
+ "references": ["[Q4, all three]"], "antiReference": "[Q5]"
+ }
+}
+```
-When more than one surface comes back, DESIGN.md says what each of them does with color, section separation, corner radius, and depth, instead of stating one answer for the product.
+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.
+
+Five of the questions are then answered per surface rather than once for the whole run, because the answer that suits a marketing page rarely suits the tool it sells: `color-strategy`, `motion-energy` (how much movement there is), `boundary-style` (how sections are separated), `corner-style` (how round shapes are), and `depth-style` (how far off the page things sit). Each of the five comes back twice over. The bare key holds the leading surface's answer, which is the first chosen tile in tile order and the one every later screen previews. Alongside it is one `-` key for every surface chosen, `` being `persuade`, `operate`, `read`, or `experience`. Surfaces the user never opened are included too, holding the default for their kind; a surface nobody chose returns nothing at all.
+
+`motion-energy` is the one exception to that shape, because the question is only put to two of the four surfaces. A landing page and a portfolio are watched, so how much they move is a house decision; a tool and a document are worked in, and their movement follows the interface. So the motion keys cover the chosen surfaces among `persuade` and `experience` only, and the bare key holds the first of those two in tile order rather than the run's leading surface: on an app UI plus portfolio run, `motion-energy` is the portfolio's answer. **When a run chooses neither of those surfaces the question is never asked, and no `motion-energy` key comes back at all.** Read it as absent rather than defaulted, and say nothing about movement in DESIGN.md; a default written as a decision is a decision the user never made.
+
+The picker does not offer every option on every surface. A landing page can take any answer to all five questions, and the other three surfaces have options withheld from them: a page people work in or read at length is not offered the loudest color or the deepest shadow, a tool is not offered separation by spacing alone, and a portfolio is not offered four working colors or fully round controls. So a value that comes back is one that suits the surface it came from, and a difference between two surfaces is a decision rather than an inconsistency to reconcile.
+
+When more than one surface comes back, DESIGN.md says what each of them does with color, movement, section separation, corner radius, and depth, instead of stating one answer for the product.
Tell the user in one line that the visual cues are ready at `.impeccable/visual-cues/` (name the count), then run `node {{scripts_path}}/picker-server.mjs` from the project root as a foreground command and parse its `PICKER_URL` line.
@@ -466,5 +486,32 @@ Tell the user in one line that the visual cues are ready at `.impeccable/visual-
The server process exiting is the completion signal; never poll or watch the answers file while it runs.
-- **Exit 0**: read the `ANSWERS` path, tell the user the answers were received in one line, then stop. Do not show or describe the cues, ask for a pick in chat, or write DESIGN.md in this turn.
+- **Exit 0**: read the `ANSWERS` path, tell the user the answers were received in one line, then return to [document.md](document.md) Steps 5-6 and write the seed DESIGN.md from that file (its questionnaire-seed mapping owns which key lands where). Do not show or describe the cues or ask for a pick in chat; the picker already settled the pick. The user's tab is meanwhile showing the design context document the picker built from the run, and that document is now a working surface: on submit the server forked a detached edit session (`picker-doc-session.mjs`) that keeps the tab connected. After the seed DESIGN.md is written, enter the edit loop below.
- **Exit 2**: tell the user the picker closed unanswered and that they can relaunch it with the same command. Never restart it unprompted.
+
+## The document edit loop
+
+The revealed document is editable in place, on live mode's division of labor:
+
+- **Simple edits never reach you.** A palette color change is applied by the session process itself: it rewrites `answers.json`, swaps the old hex for the new one across DESIGN.md, and journals the change to `.impeccable/design-interview/doc-edits.jsonl`. If the color edit landed before your seed write, the answers file you seed from already carries it.
+- **Complex edits queue for you.** Font changes (including uploaded faces, saved under `.impeccable/design-interview/fonts/`) and freeform asks arrive as `edit_request` events.
+
+After writing the seed DESIGN.md, tell the user in one line that the document in their tab is live for edits, then poll:
+
+```
+node {{scripts_path}}/picker-doc-poll.mjs
+```
+
+One-shot, exactly like live mode's poll: it blocks until one event and prints it as JSON. Run it on live mode's harness policy: on Claude Code as a background task; on Cursor as a one-shot poll in a background terminal with notify on `"type":"(edit_request|exit)"`; on Codex as a yielded foreground exec; elsewhere one-shot foreground. Never `--timeout` it short.
+
+- `{"type":"edit_request", "id", "kind", "prompt", "category", "payload"}`: do the work. Apply the change to DESIGN.md (and `answers.json` where a questionnaire key names the same fact, so the tab re-renders it), move any uploaded font files where the project keeps assets, then reply and poll again:
+
+ ```
+ node {{scripts_path}}/picker-doc-poll.mjs --reply done "One line the user sees in the tab"
+ ```
+
+ Reply `error` with a reason when the ask cannot be applied; reply `retry` to put it back in the queue untouched.
+- `{"type":"timeout"}`: nothing arrived in the budget; poll again.
+- `{"type":"exit"}`: the session ended (tab closed or timed out). Before moving on, read `doc-edits.jsonl` and reconcile any prose the deterministic edits left stale: a swapped hex whose descriptive color name in DESIGN.md no longer matches its value gets a fresh name. Then stop polling; the loop is over.
+
+The user may keep working in chat while the document sits open; treat an `edit_request` like any other user instruction, just delivered through the tab.
diff --git a/skill/scripts/picker-doc-poll.mjs b/skill/scripts/picker-doc-poll.mjs
new file mode 100644
index 000000000..e880af3ba
--- /dev/null
+++ b/skill/scripts/picker-doc-poll.mjs
@@ -0,0 +1,99 @@
+#!/usr/bin/env node
+/** Agent poll CLI for the design-document edit session.
+ *
+ * The picker forks picker-doc-session.mjs on submit; this is how the agent
+ * hears from it, on the live-poll.mjs contract: one-shot by default, block
+ * until one event arrives, print it as JSON on stdout, exit.
+ *
+ * node picker-doc-poll.mjs # block, print one event
+ * node picker-doc-poll.mjs --timeout=600000 # total budget in ms
+ * node picker-doc-poll.mjs --reply [message]
+ *
+ * Events printed: {"type":"edit_request","id","kind","prompt","category",
+ * "payload"} for work, {"type":"timeout"} when the budget runs out (poll
+ * again), {"type":"exit"} when the session ended (stop polling).
+ *
+ * Reply statuses: done (change applied; message shown to the user in the
+ * document), error (could not apply; message explains), retry (release the
+ * request back to pending).
+ *
+ * Session discovery: .impeccable/design-interview/doc-session.json, written
+ * by the session process and removed when it exits; a missing file prints
+ * {"type":"exit"} so a finished session never hangs the loop.
+ */
+
+import { readFile } from 'node:fs/promises';
+import path from 'node:path';
+
+const sessionPath = path.resolve(process.cwd(), '.impeccable/design-interview/doc-session.json');
+/* Sliced under undici's 300s header timeout, same as live-poll. */
+const PER_REQUEST_MS = 270_000;
+const DEFAULT_TOTAL_MS = 600_000;
+
+async function session() {
+ try {
+ return JSON.parse(await readFile(sessionPath, 'utf8'));
+ } catch {
+ return null;
+ }
+}
+
+const args = process.argv.slice(2);
+
+function readFlag(name, fallback) {
+ const exact = args.find((arg) => arg.startsWith(`${name}=`));
+ if (exact) return exact.slice(name.length + 1);
+ const at = args.indexOf(name);
+ if (at !== -1 && args[at + 1]) return args[at + 1];
+ return fallback;
+}
+
+const info = await session();
+if (!info) {
+ console.log(JSON.stringify({ type: 'exit', reason: 'no-session' }));
+ process.exit(0);
+}
+const base = `http://127.0.0.1:${info.port}`;
+
+if (args.includes('--reply')) {
+ const at = args.indexOf('--reply');
+ const [id, status, ...rest] = args.slice(at + 1).filter((arg) => !arg.startsWith('--'));
+ if (!id || !status) {
+ console.error('usage: picker-doc-poll.mjs --reply [message]');
+ process.exit(1);
+ }
+ const response = await fetch(`${base}/doc/reply`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ token: info.token, id, status, message: rest.join(' ') }),
+ }).catch(() => null);
+ if (!response?.ok) {
+ console.error(`Reply failed: ${response ? response.status : 'session unreachable'}`);
+ process.exit(1);
+ }
+ console.log(JSON.stringify(await response.json()));
+ process.exit(0);
+}
+
+const totalBudget = Number(readFlag('--timeout', DEFAULT_TOTAL_MS));
+const deadline = Date.now() + (Number.isFinite(totalBudget) && totalBudget > 0 ? totalBudget : DEFAULT_TOTAL_MS);
+
+for (;;) {
+ const slice = Math.min(deadline - Date.now(), PER_REQUEST_MS);
+ if (slice <= 0) {
+ console.log(JSON.stringify({ type: 'timeout' }));
+ process.exit(0);
+ }
+ let payload;
+ try {
+ const response = await fetch(`${base}/doc/poll?token=${encodeURIComponent(info.token)}&timeout=${slice}`);
+ payload = await response.json();
+ } catch {
+ /* The session process exited between polls. */
+ console.log(JSON.stringify({ type: 'exit', reason: 'session-gone' }));
+ process.exit(0);
+ }
+ if (payload.type === 'timeout') continue;
+ console.log(JSON.stringify(payload));
+ process.exit(0);
+}
diff --git a/skill/scripts/picker-doc-session.mjs b/skill/scripts/picker-doc-session.mjs
new file mode 100644
index 000000000..8af204113
--- /dev/null
+++ b/skill/scripts/picker-doc-session.mjs
@@ -0,0 +1,355 @@
+#!/usr/bin/env node
+/** Design-document edit session (self-contained, zero dependencies).
+ *
+ * The picker server forks this detached sibling the moment the questionnaire
+ * submits, so the review tab's design context document stays connected after
+ * the picker itself exits 0 (the agent's completion signal). It runs on its
+ * own pre-scanned port with CORS open to the picker origin, and it mediates
+ * three parties the way the live server does, scaled down to polling:
+ *
+ * browser --POST /doc/edit-----------> applied here (simple edits)
+ * browser --POST /doc/request-------> queue --GET /doc/poll--> agent
+ * agent --POST /doc/reply---------> queue status + version bump
+ * browser --GET /doc/state (poll)--> { version, requests } -> re-render
+ *
+ * Simple edits (a palette color) are deterministic: this process rewrites
+ * answers.json and swaps the value in DESIGN.md itself, no model involved.
+ * Anything needing judgment queues for the agent, which long-polls through
+ * picker-doc-poll.mjs exactly like live mode's live-poll.mjs.
+ *
+ * Session discovery for the agent CLI: .impeccable/design-interview/
+ * doc-session.json { pid, port, token }. Removed on exit. Every applied
+ * simple edit is journaled to doc-edits.jsonl in the same directory so the
+ * agent can reconcile prose (a renamed color's description) at session end.
+ *
+ * Usage (spawned by picker-server.mjs, not by hand):
+ * node picker-doc-session.mjs --port 8501 --timeout 60
+ * with IMPECCABLE_DOC_TOKEN in the environment.
+ */
+
+import http from 'node:http';
+import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
+import path from 'node:path';
+
+const interviewDir = path.resolve(process.cwd(), '.impeccable/design-interview');
+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 designPath = path.resolve(process.cwd(), 'DESIGN.md');
+
+const MAX_BODY_BYTES = 1024 * 1024;
+const FONT_EXTENSIONS = new Set(['.woff2', '.woff', '.ttf', '.otf']);
+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
+ 270s ceiling live-poll uses. */
+const MAX_POLL_MS = 270_000;
+/* The tab polls /doc/state every couple of seconds while open; when it has
+ been quiet this long the session is over and the agent's poll gets exit. */
+const BROWSER_GONE_MS = 10 * 60_000;
+/* A tab adopts the session within seconds of the submit that forked it. If
+ no poll ever arrives (a test harness, a closed tab), die young instead of
+ holding a port for the full ceiling. */
+const ADOPT_GRACE_MS = 90_000;
+
+const args = process.argv.slice(2);
+const readArg = (name, fallback) => {
+ const at = args.indexOf(name);
+ return at !== -1 && args[at + 1] ? args[at + 1] : fallback;
+};
+const port = Number(readArg('--port', '0'));
+const timeoutMinutes = Number(readArg('--timeout', '60'));
+const token = process.env.IMPECCABLE_DOC_TOKEN || '';
+if (!port || !token) {
+ console.error('picker-doc-session is spawned by picker-server.mjs and needs --port plus IMPECCABLE_DOC_TOKEN.');
+ process.exit(1);
+}
+
+let version = 1;
+let requestSeq = 0;
+const requests = [];
+let lastBrowserSeen = Date.now();
+let adopted = false;
+const parkedPolls = [];
+
+function sendJson(response, statusCode, body) {
+ response.writeHead(statusCode, {
+ 'Content-Type': 'application/json; charset=utf-8',
+ 'Access-Control-Allow-Origin': '*',
+ });
+ response.end(JSON.stringify(body));
+}
+
+function httpError(statusCode, message) {
+ const error = new Error(message);
+ error.statusCode = statusCode;
+ return error;
+}
+
+async function readJsonBody(request) {
+ const chunks = [];
+ let size = 0;
+ for await (const chunk of request) {
+ size += chunk.length;
+ if (size > MAX_BODY_BYTES) throw httpError(413, 'Request body exceeds 1 MB');
+ chunks.push(chunk);
+ }
+ let value;
+ try {
+ value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
+ } catch {
+ throw httpError(400, 'Body must be valid JSON');
+ }
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw httpError(400, 'Body must be a JSON object');
+ return value;
+}
+
+const summarize = (entry) => ({
+ id: entry.id,
+ kind: entry.kind,
+ prompt: entry.prompt,
+ category: entry.category,
+ status: entry.status,
+ message: entry.message || '',
+});
+
+async function appendLedger(entry) {
+ await mkdir(interviewDir, { recursive: true });
+ await writeFile(ledgerPath, `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`, { flag: 'a' });
+}
+
+/* ============================================================
+ Simple edits — deterministic, applied here.
+ ============================================================ */
+
+async function applyColorEdit({ role, value }) {
+ if (!ROLES.has(role)) throw httpError(400, 'Unknown palette role');
+ if (!/^#[0-9a-fA-F]{6}$/.test(value || '')) throw httpError(400, 'Value must be a #rrggbb hex color');
+ const hex = value.toUpperCase();
+
+ const answers = JSON.parse(await readFile(answersPath, 'utf8'));
+ const previous = String(answers[`palette-${role}`] || '').toUpperCase();
+ answers[`palette-${role}`] = hex;
+ await writeFile(answersPath, `${JSON.stringify(answers, null, 2)}\n`);
+
+ /* DESIGN.md may not exist yet (the agent writes the seed while the user
+ reads the document); the answers file is the source it will seed from,
+ so an early edit is already carried. */
+ let designTouched = false;
+ if (previous && previous !== hex) {
+ try {
+ const source = await readFile(designPath, 'utf8');
+ /* A hex value is regex-safe: a literal # and hex digits. */
+ const swapped = source.replace(new RegExp(previous, 'gi'), hex);
+ if (swapped !== source) {
+ await writeFile(designPath, swapped);
+ designTouched = true;
+ }
+ } catch {
+ /* No DESIGN.md yet. */
+ }
+ }
+
+ await appendLedger({ type: 'color', role, from: previous, to: hex, designTouched });
+ return { role, from: previous, to: hex, designTouched };
+}
+
+const SIMPLE_EDITS = { color: applyColorEdit };
+
+/* ============================================================
+ Complex edits — queued for the agent.
+ ============================================================ */
+
+function wakeParkedPolls() {
+ while (parkedPolls.length) {
+ const parked = parkedPolls.shift();
+ clearTimeout(parked.timer);
+ parked.resolve();
+ }
+}
+
+function nextPending() {
+ return requests.find((entry) => entry.status === 'pending');
+}
+
+async function handleDocPoll(response, query) {
+ const budget = Math.min(Number(query.get('timeout')) || MAX_POLL_MS, MAX_POLL_MS);
+ const deadline = Date.now() + budget;
+
+ for (;;) {
+ if (Date.now() - lastBrowserSeen > BROWSER_GONE_MS) {
+ sendJson(response, 200, { type: 'exit', reason: 'browser-gone' });
+ return;
+ }
+ const entry = nextPending();
+ if (entry) {
+ entry.status = 'working';
+ bumpVersion();
+ sendJson(response, 200, { type: 'edit_request', ...summarize(entry), payload: entry.payload });
+ return;
+ }
+ const remaining = deadline - Date.now();
+ if (remaining <= 0) {
+ sendJson(response, 200, { type: 'timeout' });
+ return;
+ }
+ await new Promise((resolve) => {
+ const parked = { resolve, timer: setTimeout(resolve, Math.min(remaining, 5_000)) };
+ parkedPolls.push(parked);
+ });
+ }
+}
+
+function bumpVersion() {
+ version += 1;
+}
+
+/* ============================================================
+ Server
+ ============================================================ */
+
+const server = http.createServer((request, response) => {
+ void handleRequest(request, response).catch((error) => {
+ if (!response.headersSent) sendJson(response, error.statusCode || 500, { error: error.message });
+ else response.destroy();
+ });
+});
+
+async function handleRequest(request, response) {
+ const url = new URL(request.url, 'http://localhost');
+ const requestPath = url.pathname;
+
+ if (request.method === 'OPTIONS') {
+ response.writeHead(204, {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
+ 'Access-Control-Allow-Headers': 'Content-Type, X-Font-Filename',
+ 'Access-Control-Max-Age': '600',
+ });
+ response.end();
+ return;
+ }
+
+ /* Font uploads carry bytes, not JSON; token rides the query string. */
+ if (request.method === 'POST' && requestPath === '/font-upload') {
+ if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
+ const name = path.basename(request.headers['x-font-filename'] || '');
+ if (!name || !FONT_EXTENSIONS.has(path.extname(name).toLowerCase())) {
+ throw httpError(400, 'Expected a .woff2, .woff, .ttf, or .otf filename');
+ }
+ const chunks = [];
+ let size = 0;
+ for await (const chunk of request) {
+ size += chunk.length;
+ if (size > MAX_BODY_BYTES) throw httpError(413, 'Font exceeds 1 MB');
+ chunks.push(chunk);
+ }
+ await mkdir(fontsDir, { recursive: true });
+ await writeFile(path.join(fontsDir, name), Buffer.concat(chunks));
+ sendJson(response, 200, { ok: true, path: path.join('.impeccable/design-interview/fonts', name) });
+ return;
+ }
+
+ if (request.method === 'GET' && requestPath === '/doc/state') {
+ if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
+ lastBrowserSeen = Date.now();
+ adopted = true;
+ sendJson(response, 200, {
+ ok: true,
+ version,
+ requests: requests.map(summarize),
+ agentWaiting: parkedPolls.length > 0,
+ });
+ return;
+ }
+
+ if (request.method === 'GET' && requestPath === '/doc/answers') {
+ if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
+ const answers = JSON.parse(await readFile(answersPath, 'utf8'));
+ sendJson(response, 200, { ok: true, version, answers });
+ return;
+ }
+
+ if (request.method === 'GET' && requestPath === '/doc/poll') {
+ if (url.searchParams.get('token') !== token) throw httpError(403, 'Bad token');
+ await handleDocPoll(response, url.searchParams);
+ return;
+ }
+
+ if (request.method !== 'POST') throw httpError(404, 'Not found');
+ const body = await readJsonBody(request);
+ if (body.token !== token) throw httpError(403, 'Bad token');
+
+ if (requestPath === '/doc/edit') {
+ const apply = SIMPLE_EDITS[body.kind];
+ if (!apply) throw httpError(400, `No simple edit named ${String(body.kind)}; complex changes go through /doc/request`);
+ const applied = await apply(body);
+ bumpVersion();
+ sendJson(response, 200, { ok: true, version, applied });
+ return;
+ }
+
+ if (requestPath === '/doc/request') {
+ if (!REQUEST_KINDS.has(body.kind)) throw httpError(400, 'kind must be font or freeform');
+ const prompt = String(body.prompt || '').trim();
+ if (!prompt || prompt.length > 4000) throw httpError(400, 'prompt is required, 4000 characters max');
+ requestSeq += 1;
+ const entry = {
+ id: `req-${String(requestSeq).padStart(3, '0')}`,
+ kind: body.kind,
+ prompt,
+ category: String(body.category || ''),
+ payload: body.payload && typeof body.payload === 'object' ? body.payload : {},
+ status: 'pending',
+ message: '',
+ };
+ requests.push(entry);
+ bumpVersion();
+ wakeParkedPolls();
+ sendJson(response, 200, { ok: true, id: entry.id, version });
+ return;
+ }
+
+ if (requestPath === '/doc/reply') {
+ const entry = requests.find((item) => item.id === body.id);
+ if (!entry) throw httpError(404, 'Unknown request id');
+ if (!['done', 'error', 'retry'].includes(body.status)) throw httpError(400, 'status must be done, error, or retry');
+ entry.status = body.status === 'retry' ? 'pending' : body.status;
+ entry.message = String(body.message || '');
+ bumpVersion();
+ if (entry.status === 'pending') wakeParkedPolls();
+ sendJson(response, 200, { ok: true, version });
+ return;
+ }
+
+ throw httpError(404, 'Not found');
+}
+
+server.listen(port, '127.0.0.1', async () => {
+ await mkdir(interviewDir, { recursive: true });
+ await writeFile(sessionPath, `${JSON.stringify({ pid: process.pid, port, token }, null, 2)}\n`);
+});
+
+server.on('error', () => process.exit(1));
+
+/* The session dies with its audience: no browser poll for BROWSER_GONE_MS,
+ or the hard ceiling, whichever lands first. */
+const reaper = setInterval(() => {
+ const quiet = Date.now() - lastBrowserSeen;
+ if (quiet > BROWSER_GONE_MS || (!adopted && quiet > ADOPT_GRACE_MS)) shutdown();
+}, 15_000);
+const ceiling = setTimeout(shutdown, timeoutMinutes * 60_000);
+
+async function shutdown() {
+ clearInterval(reaper);
+ clearTimeout(ceiling);
+ wakeParkedPolls();
+ await rm(sessionPath, { force: true }).catch(() => {});
+ server.close(() => process.exit(0));
+ server.closeAllConnections?.();
+ setTimeout(() => process.exit(0), 1_000).unref();
+}
+
+process.once('SIGINT', shutdown);
+process.once('SIGTERM', shutdown);
diff --git a/skill/scripts/picker-server.mjs b/skill/scripts/picker-server.mjs
index a7b2cd90b..2c5c8008b 100644
--- a/skill/scripts/picker-server.mjs
+++ b/skill/scripts/picker-server.mjs
@@ -6,6 +6,8 @@
*/
import http from 'node:http';
+import { spawn } from 'node:child_process';
+import { randomUUID } from 'node:crypto';
import { readFile, mkdir, stat, writeFile } from 'node:fs/promises';
import net from 'node:net';
import path from 'node:path';
@@ -203,11 +205,19 @@ async function handleRequest(request, response) {
await writeFile(answersPath, `${JSON.stringify(answers, null, 2)}\n`);
completed = true;
clearTimeout(timeout);
+
+ /* The document the review tab is about to reveal stays editable through a
+ detached sibling: it owns the edit endpoints on its own port, so this
+ process can still exit as the agent's completion signal. The tab learns
+ where to reach it from this response; the agent learns from
+ doc-session.json, which the sibling writes at boot. */
+ const doc = await spawnDocSession();
response.once('finish', () => {
console.log(`ANSWERS ${answersPath}`);
server.close(() => process.exit(0));
+ server.closeAllConnections?.();
});
- sendJson(response, 200, { ok: true });
+ sendJson(response, 200, { ok: true, doc });
return;
}
@@ -275,6 +285,28 @@ async function handleRequest(request, response) {
await serveFile(response, pickerDir, assetPath);
}
+async function spawnDocSession() {
+ try {
+ const docPort = await findOpenPort(port + 1);
+ const docToken = randomUUID();
+ const child = spawn(process.execPath, [
+ path.join(scriptDir, 'picker-doc-session.mjs'),
+ '--port', String(docPort),
+ '--timeout', String(options.timeoutMinutes),
+ ], {
+ cwd: process.cwd(),
+ detached: true,
+ stdio: 'ignore',
+ env: { ...process.env, IMPECCABLE_DOC_TOKEN: docToken },
+ });
+ child.unref();
+ return { base: `http://127.0.0.1:${docPort}`, token: docToken };
+ } catch {
+ /* The document still renders read-only; only the edit loop is lost. */
+ return null;
+ }
+}
+
function stopWithoutSubmission(message) {
if (completed) return;
clearTimeout(timeout);
diff --git a/tests/picker-server.test.mjs b/tests/picker-server.test.mjs
index 56e93d49d..9e582d0b8 100644
--- a/tests/picker-server.test.mjs
+++ b/tests/picker-server.test.mjs
@@ -51,11 +51,12 @@ const fontManifestFixture = {
'chosen the morning it ships.',
],
sectionLink: 'Our growers',
+ // Three, the count the artboard draws and the manifest validator requires:
+ // a fourth card makes the whole file fall back to the default pairs.
gallery: [
{ title: 'Market bunch', meta: 'From $38' },
{ title: 'Table vase', meta: 'From $52' },
{ title: 'Ceremony', meta: 'From $120' },
- { title: 'Workshop', meta: 'Next Sat' },
],
footerLinks: ['Care guide', 'Delivery', 'Contact', 'Instagram'],
footerMark: '© Hanazono',
@@ -245,7 +246,12 @@ test('serves picker and cues, writes submission, prints answers, and exits 0', a
body: JSON.stringify(answers),
});
assert.equal(submitResponse.status, 200);
- assert.deepEqual(await submitResponse.json(), { ok: true });
+ const submitBody = await submitResponse.json();
+ assert.equal(submitBody.ok, true);
+ // Submit forks the detached doc-session sibling and hands the tab its
+ // address; the picker itself still exits 0 as the completion signal.
+ assert.match(submitBody.doc?.base || '', /^http:\/\/127\.0\.0\.1:\d+$/);
+ assert.equal(typeof submitBody.doc?.token, 'string');
assert.equal((await exitPromise)[0], 0);
const answersPath = path.join(
@@ -254,6 +260,20 @@ test('serves picker and cues, writes submission, prints answers, and exits 0', a
);
assert.deepEqual(JSON.parse(await readFile(answersPath, 'utf8')), answers);
assert.match(server.stdout(), new RegExp(`ANSWERS ${answersPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
+
+ // Reap the doc session so the fixture directory can be removed.
+ const sessionPath = path.join(fixture.cwd, '.impeccable/design-interview/doc-session.json');
+ for (let attempt = 0; attempt < 20; attempt += 1) {
+ try {
+ const session = JSON.parse(await readFile(sessionPath, 'utf8'));
+ assert.equal(session.token, submitBody.doc.token);
+ process.kill(session.pid);
+ break;
+ } catch (error) {
+ if (error.code === 'ERR_ASSERTION') throw error;
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+ }
});
test('fonts endpoint returns 404 when fonts.json is absent', async (t) => {