Add color strategy screen and font-pair pipeline step.

Screen 03 lets operators compare restrained through drenched strategies on the dual-artboard preview; visual-cues now composes fonts.json before launch, and the picker server exposes it.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Abdul Wahab
2026-09-01 10:02:39 +05:00
co-authored by Cursor
parent f7751fad0c
commit 626bfbb0a8
6 changed files with 1345 additions and 7 deletions
+203 -2
View File
@@ -185,10 +185,211 @@ const roles = [
</div>
</section>
<section class="picker-screen" data-screen="03" aria-hidden="true" aria-labelledby="picker-finish-copy">
<section class="picker-screen" data-screen="03" aria-hidden="true" aria-labelledby="picker-strategy-title">
<div class="picker-container">
<div class="picker-strategy">
<h1 id="picker-strategy-title" class="picker-palette-title">How much color should this design use?</h1>
<div class="picker-strategy-grid">
<fieldset class="picker-strategy-options">
<legend>Color strategy</legend>
<label class="picker-strategy-option">
<input type="radio" name="color-strategy" value="restrained" checked />
<span class="picker-strategy-indicator" aria-hidden="true"></span>
<span class="picker-strategy-copy">
<strong>Restrained</strong>
<span>Mostly neutral. The primary appears in a few deliberate places.</span>
</span>
</label>
<label class="picker-strategy-option">
<input type="radio" name="color-strategy" value="committed" />
<span class="picker-strategy-indicator" aria-hidden="true"></span>
<span class="picker-strategy-copy">
<strong>Committed</strong>
<span>One strong color does real work across the page.</span>
</span>
</label>
<label class="picker-strategy-option">
<input type="radio" name="color-strategy" value="full-palette" />
<span class="picker-strategy-indicator" aria-hidden="true"></span>
<span class="picker-strategy-copy">
<strong>Full palette</strong>
<span>All four colors on duty, each with a clear job.</span>
</span>
</label>
<label class="picker-strategy-option">
<input type="radio" name="color-strategy" value="drenched" />
<span class="picker-strategy-indicator" aria-hidden="true"></span>
<span class="picker-strategy-copy">
<strong>Drenched</strong>
<span>The page itself is the color. Everything else sits on top.</span>
</span>
</label>
</fieldset>
<div class="picker-strategy-preview" data-strategy-preview aria-hidden="true">
<div class="ps-desktop">
<div class="ps-nav">
<div class="ps-brand-block"></div>
<div class="ps-nav-bars"><i></i><i></i><i></i><i></i></div>
<div class="ps-nav-action"></div>
</div>
<div class="ps-hero">
<div class="ps-hero-copy">
<div class="ps-eyebrow"></div>
<div class="ps-headline"><i></i><i></i></div>
<div class="ps-copy"><i></i></div>
<div class="ps-actions"><i></i><i></i></div>
</div>
<div class="ps-image"></div>
</div>
<div class="ps-proof">
<div class="ps-proof-item"><i></i><span></span></div>
<div class="ps-proof-item"><i></i><span></span></div>
<div class="ps-proof-item"><i></i><span></span></div>
<div class="ps-proof-item"><i></i><span></span></div>
</div>
<div class="ps-editorial">
<div class="ps-editorial-copy">
<strong></strong>
<span><i></i><i></i></span>
<em></em>
</div>
<div class="ps-gallery">
<div class="ps-gallery-item"><i></i><span><b></b><b></b></span></div>
<div class="ps-gallery-item"><i></i><span><b></b><b></b></span></div>
<div class="ps-gallery-item"><i></i><span><b></b><b></b></span></div>
<div class="ps-gallery-item"><i></i><span><b></b><b></b></span></div>
</div>
</div>
<div class="ps-footer">
<div class="ps-footer-links"><i></i><i></i><i></i><i></i></div>
<div class="ps-footer-mark"></div>
</div>
</div>
<div class="ps-phone">
<div class="ps-phone-top">
<div class="ps-brand-block"></div>
<div class="ps-nav-action"></div>
</div>
<div class="ps-phone-body">
<div class="ps-image"></div>
<div class="ps-headline"><i></i><i></i></div>
<div class="ps-copy"><i></i><i></i></div>
<div class="ps-actions"><i></i><i></i></div>
<div class="ps-proof">
<div class="ps-proof-item"><i></i><span></span></div>
<div class="ps-proof-item"><i></i><span></span></div>
</div>
<div class="ps-editorial-copy">
<strong></strong>
<span><i></i><i></i></span>
</div>
<div class="ps-gallery">
<div class="ps-gallery-item"><i></i><span><b></b><b></b></span></div>
<div class="ps-gallery-item"><i></i><span><b></b><b></b></span></div>
</div>
</div>
<div class="ps-phone-footer"><i></i><i></i><i></i></div>
</div>
</div>
</div>
<div class="picker-actions picker-strategy-actions">
<button class="ks-button ks-button-ghost" type="button" data-advance="prev">Back</button>
<button class="ks-button ks-button-primary" type="button" data-advance="next">Select this strategy</button>
</div>
</div>
</div>
</section>
<section class="picker-screen" data-screen="04" aria-hidden="true" aria-labelledby="picker-type-title">
<div class="picker-container">
<div class="picker-type">
<div class="picker-type-heading">
<p class="picker-type-kicker">Typography</p>
<h1 id="picker-type-title" class="picker-palette-title">Choose a font pair</h1>
</div>
<div class="picker-type-grid">
<fieldset class="picker-strategy-options picker-type-options" data-font-options>
<legend>Font pairs</legend>
</fieldset>
<div class="picker-preview picker-strategy-preview picker-preview-type" data-type-preview aria-hidden="true">
<div class="ps-desktop">
<div class="ps-nav">
<div class="ps-brand-block"></div>
<div class="ps-nav-bars"><i></i><i></i><i></i><i></i></div>
<div class="ps-nav-action"></div>
</div>
<div class="ps-hero">
<div class="ps-hero-copy">
<p class="pt-eyebrow" data-type-eyebrow>Typography</p>
<h2 class="pt-headline" data-type-headline></h2>
<p class="pt-body" data-type-body></p>
<div class="ps-actions"><i></i><i></i></div>
</div>
<div class="ps-image"></div>
</div>
<div class="ps-proof">
<div class="ps-proof-item"><i></i><span></span></div>
<div class="ps-proof-item"><i></i><span></span></div>
<div class="ps-proof-item"><i></i><span></span></div>
<div class="ps-proof-item"><i></i><span></span></div>
</div>
<div class="ps-editorial">
<div class="ps-editorial-copy">
<strong></strong>
<span><i></i><i></i></span>
<em></em>
</div>
<div class="ps-gallery">
<div class="ps-gallery-item"><i></i><span><b></b><b></b></span></div>
<div class="ps-gallery-item"><i></i><span><b></b><b></b></span></div>
<div class="ps-gallery-item"><i></i><span><b></b><b></b></span></div>
<div class="ps-gallery-item"><i></i><span><b></b><b></b></span></div>
</div>
</div>
<div class="ps-footer">
<div class="ps-footer-links"><i></i><i></i><i></i><i></i></div>
<div class="ps-footer-mark"></div>
</div>
</div>
</div>
</div>
<template data-pair-card>
<label class="picker-strategy-option picker-type-option">
<input type="radio" name="font-pair" />
<span class="picker-strategy-indicator" aria-hidden="true"></span>
<span class="picker-type-copy">
<strong data-pair-name></strong>
<span class="picker-type-with">with <b data-pair-body></b></span>
<span class="picker-type-why" data-pair-why></span>
</span>
</label>
</template>
<input type="hidden" name="font-heading" />
<input type="hidden" name="font-body" />
<div class="picker-actions picker-strategy-actions">
<button class="ks-button ks-button-ghost" type="button" data-advance="prev">Back</button>
<button class="ks-button ks-button-primary" type="button" data-advance="next">Select this pair</button>
</div>
</div>
</div>
</section>
<section class="picker-screen" data-screen="05" aria-hidden="true" aria-labelledby="picker-finish-copy">
<div class="picker-container">
<div class="picker-placeholder">
<p id="picker-finish-copy">Your palette is ready.</p>
<p id="picker-finish-copy">Your visual direction is ready.</p>
<div class="picker-actions">
<button class="ks-button ks-button-ghost" type="button" data-advance="prev">Back</button>
<button class="ks-button ks-button-primary" type="submit">Finish</button>
+164 -1
View File
@@ -13,16 +13,75 @@ const hint = $('[data-palette-hint]');
const ringGuide = $('[data-ring-guide]');
const loupe = $('[data-loupe]');
const preview = $('.picker-preview');
const strategyPreview = document.querySelector('[data-strategy-preview]');
const typePreview = document.querySelector('[data-type-preview]');
const fontOptions = document.querySelector('[data-font-options]');
const pairTemplate = document.querySelector('[data-pair-card]');
const states = new Map();
const canvases = new WeakMap();
let cards = [];
let current = 0;
let openTint;
let fontManifest;
const FALLBACK_FONTS = {
version: 1,
specimen: {
headline: 'Shape a clear visual direction',
body: 'Choose a type system that gives the product a distinct and usable voice.',
},
pairs: [
{
id: 'source-editorial',
name: 'Source editorial',
heading: { family: 'Source Serif 4', weight: 600 },
body: { family: 'Source Sans 3', weight: 400 },
why: 'Source Serif 4 gives the questionnaire an editorial voice while Source Sans 3 keeps guidance easy to scan.',
},
{
id: 'literary-clarity',
name: 'Literary clarity',
heading: { family: 'Libre Baskerville', weight: 700 },
body: { family: 'Libre Franklin', weight: 400 },
why: 'Libre Baskerville adds measured character while Libre Franklin supports the pickers longer instructional copy.',
},
{
id: 'warm-structure',
name: 'Warm structure',
heading: { family: 'Bitter', weight: 600 },
body: { family: 'Cabin', weight: 400 },
why: 'Bitter brings sturdy detail to key choices while Cabin remains open at the pickers compact text sizes.',
},
{
id: 'bold-utility',
name: 'Bold utility',
heading: { family: 'Archivo Black', weight: 400 },
body: { family: 'Archivo', weight: 400 },
why: 'Archivo Black makes decisions unmistakable while Archivo keeps the surrounding interface practical.',
},
{
id: 'technical-signal',
name: 'Technical signal',
heading: { family: 'Azeret Mono', weight: 600 },
body: { family: 'Noto Sans', weight: 400 },
why: 'Azeret Mono echoes the system-building task while Noto Sans carries the explanatory reading load.',
},
{
id: 'classical-poise',
name: 'Classical poise',
heading: { family: 'Marcellus', weight: 400 },
body: { family: 'Karla', weight: 400 },
why: 'Marcellus gives the visual direction a composed signature while Karla keeps controls direct.',
},
],
};
const roleMap = (value) => Object.fromEntries(ROLES.map((role) => [role, value(role)]));
const card = () => cards[current];
const state = () => states.get(card().id);
const dismissRingGuide = () => ringGuide.setAttribute('aria-hidden', 'true');
const serifFamily = /serif|mincho|baskerville|bitter|marcellus|slab|antiqua|garamond|didot|bodoni/i;
const fontStack = (family) => `"${family.replaceAll('"', '\\"')}", ${serifFamily.test(family) ? 'serif' : 'sans-serif'}`;
function createState(item) {
const colors = item.type === 'cue'
@@ -93,6 +152,92 @@ function renderPreview() {
preview.style.setProperty('--pv-n-ink', contrastInk(state().colors.neutral));
}
function syncCommittedPalette(target) {
const committed = roleMap((role) => $(`[name="palette-${role}"]`).value);
if (!target || Object.values(committed).some((hex) => !hex)) return;
for (const role of ROLES) target.style.setProperty(`--pv-${role}`, committed[role]);
target.style.setProperty('--pv-n-ink', contrastInk(committed.neutral));
target.style.setProperty('--pv-p-ink', contrastInk(committed.primary));
}
function isFontManifest(value) {
return value?.version === 1
&& typeof value.specimen?.headline === 'string'
&& typeof value.specimen?.body === 'string'
&& value.pairs?.length === 6
&& value.pairs.every((pair) => (
typeof pair.id === 'string'
&& typeof pair.name === 'string'
&& typeof pair.heading?.family === 'string'
&& Number.isFinite(pair.heading?.weight)
&& typeof pair.body?.family === 'string'
&& Number.isFinite(pair.body?.weight)
&& typeof pair.why === 'string'
));
}
function loadFontStylesheet(pairs) {
const families = new Map();
const addWeight = (family, weight) => {
if (!families.has(family)) families.set(family, new Set());
families.get(family).add(weight);
};
for (const pair of pairs) {
addWeight(pair.heading.family, pair.heading.weight);
addWeight(pair.body.family, pair.body.weight);
addWeight(pair.body.family, 700);
}
const query = [...families].map(([family, weights]) => {
const name = encodeURIComponent(family).replaceAll('%20', '+');
return `family=${name}:wght@${[...weights].sort((a, b) => a - b).join(';')}`;
}).join('&');
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = `https://fonts.googleapis.com/css2?${query}&display=swap`;
link.dataset.pickerFonts = '';
document.head.append(link);
}
function syncFontPair(pair) {
const specimen = { ...fontManifest.specimen, ...pair.specimen };
typePreview.style.setProperty('--pt-heading', fontStack(pair.heading.family));
typePreview.style.setProperty('--pt-body', fontStack(pair.body.family));
typePreview.style.setProperty('--pt-heading-weight', pair.heading.weight);
document.querySelector('[data-type-headline]').textContent = specimen.headline;
document.querySelector('[data-type-body]').textContent = specimen.body;
document.querySelector('[name="font-heading"]').value = pair.heading.family;
document.querySelector('[name="font-body"]').value = pair.body.family;
}
function renderFontPairs(manifest, fallback) {
fontManifest = manifest;
fontOptions.toggleAttribute('data-fallback', fallback);
const fragment = document.createDocumentFragment();
manifest.pairs.forEach((pair, index) => {
const node = pairTemplate.content.firstElementChild.cloneNode(true);
const input = node.querySelector('input');
input.value = pair.id;
input.checked = index === 0;
node.style.setProperty('--pair-heading', fontStack(pair.heading.family));
node.style.setProperty('--pair-body', fontStack(pair.body.family));
node.style.setProperty('--pair-heading-weight', pair.heading.weight);
node.style.setProperty('--pair-body-weight', pair.body.weight);
node.querySelector('[data-pair-name]').textContent = pair.name;
node.querySelector('[data-pair-body]').textContent = pair.body.family;
node.querySelector('[data-pair-why]').textContent = pair.why;
fragment.append(node);
});
fontOptions.append(fragment);
loadFontStylesheet(manifest.pairs);
syncFontPair(manifest.pairs[0]);
}
fontOptions.onchange = ({ target }) => {
if (!target.matches('input[name="font-pair"]')) return;
const pair = fontManifest.pairs.find(({ id }) => id === target.value);
if (pair) syncFontPair(pair);
};
function setActiveRole(role) {
if (hint.textContent === hint.dataset[role]) return;
hint.classList.add('is-changing');
@@ -320,7 +465,11 @@ scroller.addEventListener('scroll', () => {
render();
}
}, { passive: true });
document.addEventListener('picker:screenchange', (event) => activate(event.detail.screen === '02'));
document.addEventListener('picker:screenchange', (event) => {
activate(event.detail.screen === '02');
const target = { '03': strategyPreview, '04': typePreview }[event.detail.screen];
if (target) syncCommittedPalette(target);
});
try {
const get = (url) => fetch(url).then((response) => response.ok ? response.json() : Promise.reject());
@@ -338,3 +487,17 @@ try {
} catch {
count.textContent = 'Palette sources could not be loaded.';
}
let manifest = FALLBACK_FONTS;
let usingFallback = true;
try {
const response = await fetch('/fonts.json');
const candidate = response.ok ? await response.json() : null;
if (isFontManifest(candidate)) {
manifest = candidate;
usingFallback = false;
}
} catch {
// The built-in pairs keep older and incomplete runs moving.
}
renderFontPairs(manifest, usingFallback);
+872 -2
View File
@@ -42,6 +42,11 @@ html.light .ks-button.ks-button-primary:active {
color: oklch(14% 0.018 95);
}
/* On paper, ghost buttons lift toward deep patina, not fill gold. */
html.light .ks-button.ks-button-ghost:hover {
color: var(--ks-patina-deep);
}
.picker-shell {
position: relative;
min-height: 100vh;
@@ -154,7 +159,9 @@ html.light .ks-button.ks-button-primary:active {
line-height: 1.5;
}
.picker-shell:has(#picker-form[data-current="02"]) .picker-hero-art {
.picker-shell:has(#picker-form[data-current="02"]) .picker-hero-art,
.picker-shell:has(#picker-form[data-current="03"]) .picker-hero-art,
.picker-shell:has(#picker-form[data-current="04"]) .picker-hero-art {
opacity: 0;
}
@@ -779,6 +786,7 @@ html.light .ks-button.ks-button-primary:active {
--pv-tertiary: var(--ks-link-on-paper);
--pv-neutral: var(--ks-lacquer-raised);
--pv-n-ink: var(--ks-champagne);
--pv-p-ink: var(--ks-champagne);
--pv-surface-2: color-mix(in oklab, var(--pv-neutral) 92%, var(--pv-n-ink));
--pv-bone: color-mix(in oklab, var(--pv-neutral) 62%, var(--pv-n-ink));
--pv-strong: color-mix(in oklab, var(--pv-neutral) 22%, var(--pv-n-ink));
@@ -1038,6 +1046,841 @@ html.light .ks-button.ks-button-primary:active {
background: var(--pv-primary);
}
/* Screen 03 mirrors screen 02's rhythm: the same centered headline class,
the same vertical gaps, options left and preview right. */
.picker-strategy {
display: grid;
gap: 48px;
padding-block: clamp(32px, 5svh, 48px) clamp(24px, 3.5svh, 48px);
}
.picker-strategy-grid {
display: grid;
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
gap: 50px;
align-items: center;
}
.picker-strategy-options {
min-width: 0;
display: grid;
gap: 10px;
padding: 0;
border: 0;
}
.picker-strategy-options > legend,
.picker-strategy-option input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
.picker-strategy-option {
position: relative;
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
gap: 14px;
align-items: start;
padding: 16px 18px;
background: var(--ks-lacquer-raised);
border: 1px solid color-mix(in oklab, var(--ks-patina-deep) 32%, var(--ks-rule));
border-radius: 2px;
cursor: pointer;
transition:
background-color 180ms var(--ks-ease),
border-color 180ms var(--ks-ease);
}
.picker-strategy-option:hover {
border-color: var(--ks-patina-deep);
}
.picker-strategy-indicator {
position: relative;
width: 17px;
height: 17px;
margin-top: 2px;
color: var(--ks-patina-deep);
border: 1px solid currentColor;
border-radius: 50%;
}
.picker-strategy-indicator::after {
content: "";
position: absolute;
inset: 4px;
background: currentColor;
border-radius: 50%;
opacity: 0;
transform: scale(0.5);
transition:
opacity 160ms var(--ks-ease),
transform 160ms var(--ks-ease);
}
.picker-strategy-copy {
display: grid;
gap: 5px;
}
.picker-strategy-copy strong {
color: var(--ks-champagne);
font-size: var(--ks-type-title-size);
font-weight: 600;
line-height: 1.2;
}
.picker-strategy-copy span {
color: var(--ks-text-muted);
font-size: 0.82rem;
line-height: 1.45;
}
.picker-strategy-option:has(input:checked) {
background: color-mix(in oklab, var(--ks-patina-deep) 7%, var(--ks-lacquer-raised));
border-color: var(--ks-patina-deep);
}
.picker-strategy-option:has(input:checked) .picker-strategy-indicator {
color: var(--ks-patina-deep);
}
.picker-strategy-option:has(input:checked) .picker-strategy-indicator::after {
opacity: 1;
transform: scale(1);
}
.picker-strategy-option:has(input:focus-visible) {
outline: 2px solid var(--ks-patina-deep);
outline-offset: 3px;
}
/* Screen 03 shows one editorial landing page at desktop and phone sizes.
Geometry stays fixed while the strategy radios remap its color slots. */
.picker-strategy-preview {
--pv-primary: var(--ks-champagne);
--pv-secondary: var(--ks-patina);
--pv-tertiary: var(--ks-link-on-paper);
--pv-neutral: var(--ks-lacquer-raised);
--pv-n-ink: var(--ks-champagne);
--pv-p-ink: var(--ks-champagne);
--pv-surface-2: color-mix(in oklab, var(--pv-neutral) 92%, var(--pv-n-ink));
--pv-bone: color-mix(in oklab, var(--pv-neutral) 62%, var(--pv-n-ink));
--pv-strong: color-mix(in oklab, var(--pv-neutral) 22%, var(--pv-n-ink));
--pvs-ground: var(--pv-neutral);
--pvs-headline: var(--pv-strong);
--pvs-eyebrow: var(--pv-bone);
--pvs-copy: var(--pv-bone);
--pvs-title: var(--pv-strong);
--pvs-cta: var(--pv-primary);
--pvs-signal: var(--pv-primary);
--pvs-ghost: var(--pv-bone);
--pvs-image: var(--pv-surface-2);
--pvs-image-edge: var(--pv-bone);
--pvs-bars: var(--pv-bone);
--pvs-accent-a: var(--pv-strong);
--pvs-accent-b: var(--pv-bone);
--pvs-accent-c: var(--pv-primary);
--pvs-accent-d: var(--pv-surface-2);
width: 100%;
aspect-ratio: 1.865 / 1;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 5.25%;
}
#picker-form:has(input[name="color-strategy"][value="committed"]:checked) .picker-strategy-preview {
--pvs-headline: color-mix(in oklab, var(--pv-primary) 80%, var(--pv-strong));
--pvs-eyebrow: var(--pv-primary);
--pvs-ghost: var(--pv-primary);
--pvs-image: var(--pv-primary);
--pvs-image-edge: var(--pv-primary);
--pvs-accent-a: var(--pv-primary);
--pvs-accent-b: color-mix(in oklab, var(--pv-primary) 70%, var(--pv-neutral));
--pvs-accent-c: var(--pv-primary);
--pvs-accent-d: color-mix(in oklab, var(--pv-primary) 18%, var(--pv-neutral));
}
#picker-form:has(input[name="color-strategy"][value="full-palette"]:checked) .picker-strategy-preview {
--pvs-headline: color-mix(in oklab, var(--pv-primary) 80%, var(--pv-strong));
--pvs-eyebrow: var(--pv-strong);
--pvs-ghost: var(--pv-secondary);
--pvs-image: var(--pv-primary);
--pvs-image-edge: var(--pv-primary);
--pvs-signal: var(--pv-tertiary);
--pvs-accent-a: var(--pv-secondary);
--pvs-accent-b: var(--pv-surface-2);
--pvs-accent-c: var(--pv-primary);
--pvs-accent-d: var(--pv-tertiary);
}
#picker-form:has(input[name="color-strategy"][value="drenched"]:checked) .picker-strategy-preview {
--pvs-ground: var(--pv-primary);
--pvs-headline: var(--pv-p-ink);
--pvs-eyebrow: color-mix(in oklab, var(--pv-p-ink) 70%, var(--pv-primary));
--pvs-copy: color-mix(in oklab, var(--pv-p-ink) 55%, var(--pv-primary));
--pvs-title: var(--pv-p-ink);
--pvs-cta: var(--pv-p-ink);
--pvs-ghost: var(--pv-p-ink);
--pvs-image: color-mix(in oklab, var(--pv-p-ink) 20%, var(--pv-primary));
--pvs-image-edge: color-mix(in oklab, var(--pv-p-ink) 40%, var(--pv-primary));
--pvs-signal: var(--pv-tertiary);
--pvs-bars: color-mix(in oklab, var(--pv-p-ink) 45%, var(--pv-primary));
--pvs-accent-a: var(--pv-p-ink);
--pvs-accent-b: var(--pv-tertiary);
--pvs-accent-c: var(--pv-p-ink);
--pvs-accent-d: color-mix(in oklab, var(--pv-p-ink) 24%, var(--pv-primary));
}
.ps-desktop,
.ps-phone {
min-width: 0;
min-height: 0;
overflow: hidden;
background-color: var(--pvs-ground);
border: 1px solid var(--ks-rule);
}
.ps-desktop {
display: grid;
grid-template-rows: 9.4% 50.6% 9% 22.7% 8.3%;
border-radius: 4px;
}
.ps-phone {
height: 100%;
aspect-ratio: 8 / 19;
align-self: stretch;
display: grid;
grid-template-rows: 7.2% 1fr 7.5%;
border-radius: 5px;
}
.ps-nav {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 24px;
align-items: center;
padding: 0 5.5%;
border-bottom: 1px solid var(--ks-rule);
}
.ps-brand-block {
width: clamp(28px, 3.35vw, 48px);
height: clamp(6px, 22%, 11px);
background-color: var(--pvs-cta);
border-radius: 1px;
}
.ps-nav-bars {
display: flex;
justify-content: flex-end;
gap: 27px;
}
.ps-nav-bars i {
display: block;
width: clamp(18px, 7%, 34px);
height: 3px;
background-color: var(--pvs-bars);
border-radius: 2px;
}
.ps-nav-action {
width: clamp(34px, 3.4vw, 48px);
height: clamp(7px, 26%, 13px);
background-color: var(--pvs-signal);
border-radius: 1px;
}
.ps-hero {
min-height: 0;
display: grid;
grid-template-columns: 35.8% minmax(0, 1fr);
gap: 11.3%;
align-items: center;
padding: 2% 3.2% 2% 5.5%;
}
.ps-hero-copy {
display: grid;
gap: 0;
}
.ps-eyebrow {
width: 54%;
height: 9px;
margin-bottom: 16px;
background-color: var(--pvs-eyebrow);
border-radius: 2px;
}
.ps-headline {
display: grid;
gap: clamp(4px, 0.65vw, 7px);
margin: 0 0 15px;
}
.ps-headline i {
display: block;
width: 100%;
height: 12px;
background-color: var(--pvs-headline);
border-radius: 2px;
}
.ps-headline i:last-child {
width: 100%;
}
.ps-copy {
display: grid;
gap: 5px;
margin-bottom: 20px;
}
.ps-copy i {
display: block;
width: 70%;
height: 6px;
background-color: var(--pvs-copy);
border-radius: 2px;
}
.ps-copy i:last-child {
width: 70%;
}
.ps-actions {
display: flex;
gap: clamp(5px, 1vw, 10px);
margin-top: 0;
}
.ps-actions i {
width: clamp(42px, 42%, 86px);
height: clamp(12px, 2.35vw, 29px);
background-color: var(--pvs-cta);
border-radius: 1px;
}
.ps-actions i:last-child {
background-color: var(--pvs-ghost);
}
.ps-image {
width: 100%;
height: 100%;
min-height: 0;
background-color: var(--pvs-image);
border: 1px solid var(--pvs-image-edge);
border-radius: 2px;
}
.ps-proof {
min-width: 0;
display: grid;
grid-template-columns: 1.05fr 1fr 1fr 1.15fr;
align-items: center;
padding-inline: 0;
border-block: 1px solid var(--ks-rule);
}
.ps-proof-item {
min-width: 0;
display: flex;
align-items: center;
gap: 9%;
height: 46%;
padding: 0 8% 0 42px;
}
.ps-proof-item:nth-child(2) {
padding-left: 40px;
}
.ps-proof-item:nth-child(3) {
padding-left: 39px;
}
.ps-proof-item:nth-child(4) {
padding-left: 37px;
}
.ps-proof-item + .ps-proof-item {
border-left: 1px solid var(--ks-rule);
}
.ps-proof-item i {
flex: none;
width: clamp(7px, 1.5vw, 13px);
aspect-ratio: 1;
background-color: var(--pvs-accent-a);
border-radius: 50%;
}
.ps-proof-item span {
width: 46%;
height: 4px;
background-color: var(--pvs-bars);
border-radius: 2px;
}
.ps-editorial {
min-height: 0;
display: grid;
grid-template-columns: 27.9% minmax(0, 1fr);
gap: 6%;
align-items: center;
padding: 2.2% 5.5%;
}
.ps-editorial-copy {
display: grid;
gap: clamp(3px, 0.55vw, 6px);
}
.ps-editorial-copy strong {
width: 78%;
height: 9px;
background-color: var(--pvs-title);
border-radius: 2px;
}
.ps-editorial-copy span {
display: grid;
gap: 3px;
}
.ps-editorial-copy span i {
display: block;
width: 90%;
height: 4px;
background-color: var(--pvs-bars);
border-radius: 2px;
}
.ps-editorial-copy span i:last-child {
width: 80%;
}
.ps-editorial-copy > em {
width: 30%;
height: 5px;
margin-top: 3px;
background-color: var(--pvs-signal);
border-radius: 2px;
}
.ps-gallery {
min-width: 0;
height: 100%;
display: grid;
grid-template-columns: 1.12fr 0.9fr 0.9fr 0.9fr;
gap: 3%;
}
.ps-gallery-item {
min-width: 0;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
gap: 12px;
}
.ps-desktop .ps-editorial-copy {
gap: 8px;
}
.ps-desktop .ps-editorial-copy strong {
height: 9px;
}
.ps-desktop .ps-editorial-copy > em {
height: 5px;
margin-top: 9px;
}
.ps-gallery-item > i {
display: block;
min-height: 0;
background-color: var(--pvs-accent-a);
border-radius: 1px;
}
.ps-gallery-item:nth-child(2) > i {
background-color: var(--pvs-accent-b);
}
.ps-gallery-item:nth-child(3) > i {
background-color: var(--pvs-accent-c);
}
.ps-gallery-item:nth-child(4) > i {
background-color: var(--pvs-accent-d);
}
.ps-gallery-item span {
display: grid;
gap: 3px;
}
.ps-gallery-item b {
display: block;
width: 76%;
height: 4px;
background-color: var(--pvs-bars);
border-radius: 2px;
}
.ps-gallery-item b:last-child {
width: 58%;
}
.ps-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-inline: 5.5%;
border-top: 1px solid var(--ks-rule);
}
.ps-footer-links {
display: flex;
gap: clamp(8px, 2vw, 18px);
}
.ps-footer-links i,
.ps-phone-footer i {
width: clamp(12px, 3vw, 28px);
height: 3px;
background-color: var(--pvs-bars);
border-radius: 2px;
}
.ps-footer-mark {
width: 7%;
height: 5px;
background-color: var(--pvs-bars);
border-radius: 2px;
}
/* Phone artboard: the same site on mobile. */
.ps-phone-top {
display: flex;
justify-content: space-between;
align-items: center;
min-height: 0;
padding: 0 6%;
border-bottom: 1px solid var(--ks-rule);
}
.ps-phone-top .ps-brand-block {
width: 22%;
height: 8px;
}
.ps-phone-top .ps-nav-action {
width: 16%;
height: 6px;
}
.ps-phone-body {
min-height: 0;
display: grid;
grid-template-rows: 29.5% auto auto auto 36px auto minmax(0, 1fr);
gap: 5px;
padding: 6%;
}
.ps-phone-body .ps-image {
margin: 0;
}
.ps-phone-body .ps-headline i {
height: clamp(4px, 0.65vw, 7px);
}
.ps-phone-body .ps-headline {
margin: 8px 0 0;
}
.ps-phone-body .ps-headline i:first-child {
width: 57%;
height: 7px;
}
.ps-phone-body .ps-headline i:last-child {
width: 86%;
height: 6px;
}
.ps-phone-body .ps-copy {
margin: 4px 0 0;
}
.ps-phone-body .ps-copy i {
width: 70%;
height: 4px;
}
.ps-phone-body .ps-copy i:last-child {
display: none;
}
.ps-phone-body .ps-actions {
gap: 7px;
margin-top: 8px;
}
.ps-phone-body .ps-actions i {
width: 43%;
height: 20px;
}
.ps-phone-body .ps-proof {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-inline: -6.5%;
margin-top: 6.5px;
}
.ps-phone-body .ps-proof-item {
height: 100%;
padding: 0 6% 0 14%;
}
.ps-phone-body .ps-proof-item i {
width: 11px;
}
.ps-phone-body .ps-proof-item span {
width: 56%;
height: 4px;
}
.ps-phone-body .ps-editorial-copy strong {
width: 33%;
height: 5px;
}
.ps-phone-body .ps-editorial-copy {
margin-top: 9.5px;
}
.ps-phone-body .ps-editorial-copy span i {
height: 4px;
}
.ps-phone-body .ps-gallery {
grid-template-columns: 1.1fr 0.95fr;
gap: 12.5%;
margin-left: 1px;
margin-top: 8px;
}
.ps-phone-body .ps-gallery-item {
grid-template-rows: 31px auto;
gap: 6px;
}
.ps-phone-body .ps-gallery-item b {
height: 3px;
}
.ps-phone-body .ps-gallery-item span {
align-content: start;
gap: 5px;
}
.ps-phone-footer {
display: flex;
align-items: center;
justify-content: space-around;
min-height: 0;
padding-inline: 6%;
border-top: 1px solid var(--ks-rule);
}
.picker-strategy-preview :is(
.ps-desktop,
.ps-phone,
.ps-brand-block,
.ps-nav-bars i,
.ps-nav-action,
.ps-eyebrow,
.ps-headline i,
.ps-copy i,
.ps-actions i,
.ps-image,
.ps-proof-item i,
.ps-proof-item span,
.ps-editorial-copy strong,
.ps-editorial-copy span i,
.ps-editorial-copy > em,
.ps-gallery-item > i,
.ps-gallery-item b,
.ps-footer-links i,
.ps-footer-mark,
.ps-phone-footer i,
.pt-eyebrow,
.pt-headline,
.pt-body
) {
transition:
background-color 280ms var(--ks-ease),
border-color 280ms var(--ks-ease),
color 280ms var(--ks-ease);
}
.picker-strategy-actions {
justify-content: center;
margin-top: 0;
}
/* Screen 04 keeps the Phase 9 runway and replaces only its type samples. */
.picker-type {
display: grid;
gap: 34px;
padding-block: clamp(32px, 4.5svh, 44px) clamp(24px, 3.5svh, 40px);
}
.picker-type-heading {
display: grid;
justify-items: center;
gap: 8px;
}
.picker-type-kicker {
color: var(--ks-patina-deep);
font-family: var(--ks-mono);
font-size: var(--ks-type-mono-size);
font-weight: 600;
line-height: 1;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.picker-type-grid {
display: grid;
grid-template-columns: minmax(330px, 390px) minmax(0, 1fr);
gap: 50px;
align-items: center;
}
.picker-type-options {
gap: 7px;
}
.picker-type-option {
grid-template-columns: 17px minmax(0, 1fr);
gap: 12px;
min-height: 64px;
padding: 9px 14px;
}
.picker-type-option .picker-strategy-indicator {
margin-top: 3px;
}
.picker-type-copy {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, auto) 1fr;
column-gap: 7px;
row-gap: 2px;
align-items: baseline;
}
.picker-type-copy > strong {
overflow: hidden;
color: var(--ks-champagne);
font-family: var(--pair-heading, var(--ks-font-display));
font-size: 1.02rem;
font-weight: var(--pair-heading-weight, 600);
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.picker-type-with {
overflow: hidden;
color: var(--ks-text);
font-family: var(--pair-body, var(--ks-font));
font-size: 0.75rem;
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
.picker-type-with b {
font: inherit;
font-weight: var(--pair-body-weight, 400);
}
.picker-type-why {
grid-column: 1 / -1;
overflow: hidden;
color: var(--ks-text-muted);
font-family: var(--ks-font);
font-size: 0.68rem;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.picker-preview-type {
--pt-heading: var(--ks-font-display);
--pt-body: var(--ks-font);
--pt-heading-weight: 600;
display: block;
aspect-ratio: 1.865 / 1;
}
.picker-preview-type > .ps-desktop {
width: 100%;
height: 100%;
}
.picker-preview-type .ps-hero-copy {
align-content: center;
}
.pt-eyebrow {
margin-bottom: 12px;
color: var(--pvs-eyebrow);
font-family: var(--pt-body);
font-size: clamp(0.48rem, 0.58vw, 0.64rem);
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.pt-headline {
max-width: 11ch;
margin-bottom: 12px;
color: var(--pvs-headline);
font-family: var(--pt-heading);
font-size: clamp(1.5rem, 2.25vw, 2.5rem);
font-weight: var(--pt-heading-weight);
line-height: 0.98;
letter-spacing: -0.025em;
text-wrap: balance;
}
.pt-body {
max-width: 31ch;
margin-bottom: 16px;
color: var(--pvs-copy);
font-family: var(--pt-body);
font-size: clamp(0.64rem, 0.82vw, 0.82rem);
line-height: 1.45;
}
.picker-palette-hint {
min-height: 3.2em;
padding-inline-end: 48px;
@@ -1074,7 +1917,34 @@ html.light .ks-button.ks-button-primary:active {
.picker-icon-button,
.picker-band-tools,
.picker-tip::after,
.picker-palette-hint {
.picker-palette-hint,
.picker-strategy-option,
.picker-strategy-indicator::after,
.picker-strategy-preview :is(
.ps-desktop,
.ps-phone,
.ps-brand-block,
.ps-nav-bars i,
.ps-nav-action,
.ps-eyebrow,
.ps-headline i,
.ps-copy i,
.ps-actions i,
.ps-image,
.ps-proof-item i,
.ps-proof-item span,
.ps-editorial-copy strong,
.ps-editorial-copy span i,
.ps-editorial-copy > em,
.ps-gallery-item > i,
.ps-gallery-item b,
.ps-footer-links i,
.ps-footer-mark,
.ps-phone-footer i,
.pt-eyebrow,
.pt-headline,
.pt-body
) {
transition: none;
}
+56 -1
View File
@@ -349,7 +349,62 @@ The script copies the hero untouched to `[slug].png` (removing a `[slug]-hero.pn
Done when: `cues.json` lists one entry per completed palette and every listed slug has its hero PNG on disk.
## Step 6: Launch the picker
## Step 6: Compose the font pairs
Run this pass yourself after compiling the cues and before launching the picker. Do not spawn specialists; six pairs need one editor holding the same brand facts and ranking them together.
Build the composition context from exactly these inputs:
- **Q2 typography direction** from the seed interview. Treat it as the anchor; execute the direction instead of asking for it again.
- **Q4's three named references** and **Q5's anti-reference**. The anti-reference is a hard constraint on every pair.
- From PRODUCT.md, only `## Users`, `## Product Purpose`, `## Positioning`, and `## Brand Commitments`.
- The seed Step 2 asset observations when they exist, with the logo's letterforms as the strongest evidence.
- **Q1's hue anchor and color strategy** as mood only. Never use either to select a family.
Do **not** read PRODUCT.md wholesale into this task or add any other section to the composition context. The chosen palette does not exist yet; the picker joins it to the pairs later.
Compose six distinct territories, then resolve each into one heading and body pair:
- Keep at least five pairs inside Q2's chosen direction, varied by classification and voice within it. No two pairs may share a heading family or read as the same voice.
- Let the sixth bend the direction only when Q4, Q5, or asset letterforms argue for it, and name that reason in its `why`.
- Apply [new-work.md](new-work.md)'s `rule:skill-typo-reflex-faces` as the canonical denylist and subject-world test. A family the user named in the interview or supplied assets is the only exception.
- Follow [typeset.md](typeset.md)'s workhorse discipline. Give the heading a point of view; give the body a real text face that stays legible at 15px and provides regular and bold weights. A display face in the body slot fails the pair.
- Verify every family exists on Google Fonts under the exact current name. Spelling is part of correctness; use `Source Sans 3`, never a retired family name.
- Write one sentence in `why` that names the Q4 reference, Users fact, positioning line, commitment, or letterform observation the pair serves. Replace a sentence that could describe any brand.
- Order the pairs best-first. `pairs[0]` is the recommendation and reaches the picker pre-selected.
Choose two specimen strings from the product's own world: a headline of at most six words and one honest body sentence. Do not invent a claim or use placeholder prose.
Write `.impeccable/visual-cues/fonts.json` with this shape:
```json
{
"version": 1,
"specimen": {
"headline": "Six words from the product's world",
"body": "One honest sentence in the product's voice for the body face."
},
"pairs": [
{
"id": "kebab-slug",
"name": "Short human label",
"heading": { "family": "Exact Google Fonts Name", "weight": 600 },
"body": { "family": "Exact Google Fonts Name", "weight": 400 },
"why": "One sentence tying this pair to a named brand fact."
}
]
}
```
Write exactly six pair entries. Each role carries the single weight it needs; the picker also loads weight 700 for each body family. A per-pair `specimen` override may replace the shared strings when the brand evidence warrants it.
If Q2 is missing because the interview was skipped, say in one line that the typography set is direction-neutral, then compose all six from the four allowed PRODUCT.md sections alone. Still write the file.
Parse the finished file as JSON and verify its version, specimen, six unique ids, six unique heading families, role names, weights, and one-sentence `why` fields before continuing.
Done when: `fonts.json` is parseable, contains exactly six ranked pairs, and every family name has been checked against Google Fonts.
## Step 7: Launch the picker
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.
+4
View File
@@ -213,6 +213,10 @@ async function handleRequest(request, response) {
await serveFile(response, options.cuesDir, 'cues.json', ['.json']);
return;
}
if (requestPath === '/fonts.json') {
await serveFile(response, options.cuesDir, 'fonts.json', ['.json']);
return;
}
if (requestPath === '/palettes.json') {
sendJson(response, 200, {
seeds: SEEDS.map(({ id, oklch, mood }) => ({ id, oklch, mood })),
+46 -1
View File
@@ -31,6 +31,29 @@ const cueManifestFixture = {
},
},
};
const fontManifestFixture = {
version: 1,
specimen: {
headline: 'Flowers shaped by hand',
body: 'Seasonal stems become arrangements made for one room and one moment.',
},
pairs: [
{
id: 'marcellus-karla',
name: 'Atelier Classic',
heading: { family: 'Marcellus', weight: 400 },
body: { family: 'Karla', weight: 400 },
why: 'Marcellus echoes the high-contrast lettering observed in the atelier mark.',
},
{
id: 'bitter-cabin',
name: 'Garden Ledger',
heading: { family: 'Bitter', weight: 600 },
body: { family: 'Cabin', weight: 400 },
why: 'Bitter gives the seasonal catalog the practical character named in Positioning.',
},
],
};
before(() => {
if (existsSync(pickerIndex)) return;
@@ -40,7 +63,7 @@ before(() => {
});
});
async function createFixture() {
async function createFixture({ fonts = true } = {}) {
const cwd = await realpath(await mkdtemp(path.join(tmpdir(), 'impeccable-picker-')));
const cuesDir = path.join(cwd, '.impeccable/visual-cues');
await mkdir(cuesDir, { recursive: true });
@@ -49,6 +72,12 @@ async function createFixture() {
path.join(cuesDir, 'cues.json'),
`${JSON.stringify(cueManifestFixture)}\n`,
);
if (fonts) {
await writeFile(
path.join(cuesDir, 'fonts.json'),
`${JSON.stringify(fontManifestFixture)}\n`,
);
}
return { cwd, cuesDir };
}
@@ -166,6 +195,11 @@ test('serves picker and cues, writes submission, prints answers, and exits 0', a
assert.equal(cueManifest.status, 200);
assert.deepEqual(await cueManifest.json(), cueManifestFixture);
const fontsResponse = await fetch(`${server.url}/fonts.json`);
assert.equal(fontsResponse.status, 200);
assert.match(fontsResponse.headers.get('content-type'), /^application\/json/);
assert.deepEqual(await fontsResponse.json(), fontManifestFixture);
const palettesResponse = await fetch(`${server.url}/palettes.json`);
assert.equal(palettesResponse.status, 200);
assert.match(palettesResponse.headers.get('content-type'), /^application\/json/);
@@ -197,6 +231,17 @@ test('serves picker and cues, writes submission, prints answers, and exits 0', a
assert.match(server.stdout(), new RegExp(`ANSWERS ${answersPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
});
test('fonts endpoint returns 404 when fonts.json is absent', async (t) => {
const fixture = await createFixture({ fonts: false });
const server = await startPicker(fixture.cwd, ['--port', String(portBase + 10)]);
await cleanup(t, fixture, server);
const response = await fetch(`${server.url}/fonts.json`);
assert.equal(response.status, 404);
assert.match(response.headers.get('content-type'), /^application\/json/);
assert.deepEqual(await response.json(), { error: 'Not found' });
});
test('palette CLI still prints a seed', () => {
const output = execFileSync(process.execPath, [paletteScript], {
cwd: root,