This commit is contained in:
Abdul Wahab
2026-09-01 10:02:39 +05:00
parent 7af6e831fb
commit 5602cdfc7e
9 changed files with 931 additions and 195 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

+4 -1
View File
@@ -24,7 +24,10 @@ const {
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Albert+Sans:wght@300;400;500;600;700&family=Alumni+Sans:wght@100;300;400;500;600;700&display=swap" rel="stylesheet" />
</head>
<body class="picker-page">
<!-- Declared here, not in picker.css, so Vite leaves the path alone. It is
server-absolute because a url() inside var() resolves against the
stylesheet that consumes it, which lives one directory down. -->
<body class="picker-page" style="--pk-foil: url('/assets/kinpaku-gold-leaf.jpg')">
<slot />
</body>
</html>
+139 -24
View File
@@ -17,6 +17,16 @@ const roles = [
<section class="picker-screen" data-screen="01" data-active aria-labelledby="picker-start-title">
<div class="picker-container">
<div class="picker-start">
<div class="picker-brand">
<span class="picker-brand-logo" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M5 2.5 L13.5 2.5 L5.5 21.5 L5 21.5 Q2.5 21.5 2.5 19 L2.5 5 Q2.5 2.5 5 2.5 Z"></path>
<path d="M16.5 2.5 L19 2.5 Q21.5 2.5 21.5 5 L21.5 19 Q21.5 21.5 19 21.5 L8.5 21.5 Z"></path>
</svg>
</span>
<span class="picker-brand-name">Impeccable</span>
</div>
<h1 id="picker-start-title" class="picker-title">Let's build your<br />design system.</h1>
<div class="picker-actions">
@@ -47,10 +57,10 @@ const roles = [
</div>
</section>
<section class="picker-screen" data-screen="02" aria-hidden="true" aria-labelledby="picker-palette-title">
<section class="picker-screen" data-screen="02" data-step="Palette" aria-hidden="true" aria-labelledby="picker-palette-title">
<div class="picker-container">
<div class="picker-palette">
<h1 id="picker-palette-title" class="picker-palette-title">Choose a color palette</h1>
<h2 id="picker-palette-title" class="picker-question-title">Choose a color palette</h2>
<div class="picker-palette-grid">
<div class="picker-deck-column" aria-label="Palette sources">
@@ -175,7 +185,17 @@ const roles = [
</div>
</div>
<button class="ks-button ks-button-primary picker-select" type="button" data-select-palette data-advance="next" disabled>Select this palette</button>
<div class="picker-actions-stack">
<button class="ks-button ks-button-primary picker-select" type="button" data-select-palette data-advance="next" disabled>
Select this palette
<span class="ks-button-arrow" aria-hidden="true">
<svg viewBox="0 0 16 8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="square">
<path d="M0 4h14M10 0l4 4-4 4"></path>
</svg>
</span>
</button>
<button class="picker-back" type="button" data-advance="prev">Back</button>
</div>
</fieldset>
</div>
@@ -185,14 +205,15 @@ const roles = [
</div>
</section>
<section class="picker-screen" data-screen="03" aria-hidden="true" aria-labelledby="picker-strategy-title">
<section class="picker-screen" data-screen="03" data-step="Color strategy" 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>
<h2 id="picker-strategy-title" class="picker-question-title">How much color<br />should this design use?</h2>
<div class="picker-strategy-grid">
<fieldset class="picker-strategy-options picker-strategy-choices">
<legend>Color strategy</legend>
<div class="picker-type-rail">
<fieldset class="picker-strategy-options picker-strategy-choices picker-type-options">
<legend>Color strategy</legend>
<label class="picker-strategy-option">
<input type="radio" name="color-strategy" value="restrained" checked />
@@ -225,7 +246,8 @@ const roles = [
<span class="picker-strategy-desc">The page itself is the color. Everything else sits on top.</span>
</span>
</label>
</fieldset>
</fieldset>
</div>
<div class="picker-strategy-preview" data-strategy-preview aria-hidden="true">
<div class="ps-desktop">
@@ -295,24 +317,47 @@ const roles = [
</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 class="picker-actions-stack">
<button class="ks-button ks-button-primary" type="button" data-advance="next">
Select this strategy
<span class="ks-button-arrow" aria-hidden="true">
<svg viewBox="0 0 16 8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="square">
<path d="M0 4h14M10 0l4 4-4 4"></path>
</svg>
</span>
</button>
<button class="picker-back" type="button" data-advance="prev">Back</button>
</div>
</div>
</div>
</section>
<section class="picker-screen" data-screen="04" aria-hidden="true" aria-labelledby="picker-type-title">
<section class="picker-screen" data-screen="04" data-step="Typeface" aria-hidden="true" aria-labelledby="picker-type-title">
<div class="picker-container">
<div class="picker-type">
<h1 id="picker-type-title" class="picker-palette-title">Choose a font pair</h1>
<h2 id="picker-type-title" class="picker-question-title">Choose a font pair</h2>
<div class="picker-type-grid">
<div class="picker-type-rail">
<fieldset class="picker-strategy-options picker-type-options" data-font-options>
<legend>Font pairs</legend>
</fieldset>
<div class="picker-type-controls">
<div class="picker-type-scroll">
<button class="picker-icon-button" type="button" data-font-scroll="-1" aria-label="Scroll font pairs up">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m4.5 12.5 5.5-5 5.5 5"></path></svg>
</button>
<button class="picker-icon-button" type="button" data-font-scroll="1" aria-label="Scroll font pairs down">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m4.5 7.5 5.5 5 5.5-5"></path></svg>
</button>
</div>
<button class="picker-type-custom" type="button" data-font-custom-open>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 4.5v11M4.5 10h11"></path></svg>
Custom font
</button>
</div>
</div>
<div class="picker-strategy-preview picker-preview-type" data-type-preview aria-hidden="true">
@@ -387,38 +432,100 @@ const roles = [
<input type="radio" name="font-pair" />
<span class="picker-type-copy">
<span class="picker-type-sample-heading" data-pair-heading></span>
<span class="picker-type-desc">Paired with <b data-pair-body></b>. <span data-pair-why></span></span>
<span class="picker-type-sample-body" data-pair-body></span>
<span class="picker-type-desc" data-pair-why></span>
</span>
</label>
</template>
<input type="hidden" name="font-heading" />
<input type="hidden" name="font-body" />
<input type="hidden" name="font-heading-source" />
<input type="hidden" name="font-body-source" />
<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 class="picker-actions-stack">
<button class="ks-button ks-button-primary" type="button" data-advance="next">
Select this pair
<span class="ks-button-arrow" aria-hidden="true">
<svg viewBox="0 0 16 8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="square">
<path d="M0 4h14M10 0l4 4-4 4"></path>
</svg>
</span>
</button>
<button class="picker-back" type="button" data-advance="prev">Back</button>
</div>
</div>
</div>
</section>
<section class="picker-screen" data-screen="05" aria-hidden="true" aria-labelledby="picker-finish-copy">
<section class="picker-screen" data-screen="05" data-step="Review" aria-hidden="true" aria-labelledby="picker-finish-copy">
<div class="picker-container">
<div class="picker-placeholder">
<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>
<div class="picker-actions-stack">
<button class="ks-button ks-button-primary" type="submit">
Finish
<span class="ks-button-arrow" aria-hidden="true">
<svg viewBox="0 0 16 8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="square">
<path d="M0 4h14M10 0l4 4-4 4"></path>
</svg>
</span>
</button>
<button class="picker-back" type="button" data-advance="prev">Back</button>
</div>
</div>
</div>
</section>
<div class="picker-progress" aria-hidden="true"><span></span></div>
<div class="picker-progress" aria-hidden="true">
<div class="picker-progress-track">
<i></i><i></i><i></i><i></i>
</div>
<p class="picker-progress-label">
<span class="picker-progress-index"><b data-progress-index>1</b> / 4</span>
<span class="picker-progress-name" data-progress-name></span>
</p>
</div>
</form>
</main>
<dialog class="picker-modal" data-font-modal aria-labelledby="picker-font-modal-title">
<div class="picker-modal-inner">
<header class="picker-modal-head">
<h2 id="picker-font-modal-title">Add a custom font</h2>
<p>Link a hosted stylesheet or upload a face. Nothing is checked until you finish.</p>
</header>
{[['heading', 'Heading'], ['body', 'Body']].map(([role, label]) => (
<fieldset class="picker-modal-field">
<legend>{label}</legend>
<label class="picker-modal-label" for={`custom-${role}-url`}>Stylesheet or font URL</label>
<input
class="picker-modal-input"
id={`custom-${role}-url`}
type="url"
inputmode="url"
placeholder="https://example.com/typeface.css"
data-custom-url={role}
/>
<p class="picker-modal-or">or</p>
<label class="picker-modal-file">
<input type="file" accept=".woff2,.woff,.ttf,.otf" data-custom-file={role} />
<span class="picker-modal-file-button">Choose a file</span>
<span class="picker-modal-file-name" data-custom-file-name={role}>No file chosen</span>
</label>
</fieldset>
))}
<p class="picker-modal-status" data-custom-status aria-live="polite"></p>
<div class="picker-modal-actions">
<button class="ks-button ks-button-primary" type="button" data-font-custom-save>Use these fonts</button>
<button class="picker-back" type="button" data-font-custom-close>Cancel</button>
</div>
</div>
</dialog>
<aside class="picker-width-gate" aria-labelledby="picker-width-title">
<div>
<h2 id="picker-width-title">This window is too narrow.</h2>
@@ -459,6 +566,14 @@ const roles = [
'button:not([disabled]), input:not([disabled]):not([type="hidden"]):not([tabindex="-1"]), select:not([disabled]), textarea:not([disabled]), a[href]',
)];
// The start screen is not a step, so the stepper counts from screen 02.
const progress = $('.picker-progress');
const setProgress = (index, name) => {
progress.dataset.step = String(index);
$('[data-progress-index]').textContent = String(Math.max(index, 1));
$('[data-progress-name]').textContent = name || '';
};
const goTo = (index) => {
const target = screens[index];
if (!target) return;
@@ -471,7 +586,7 @@ const roles = [
current = index;
const id = target.dataset.screen;
form.dataset.current = id;
form.style.setProperty('--progress', (index + 1) / screens.length);
setProgress(index, target.dataset.step);
document.dispatchEvent(new CustomEvent('picker:screenchange', {
detail: { screen: id },
}));
@@ -535,6 +650,6 @@ const roles = [
};
form.dataset.current = screens[current].dataset.screen;
form.style.setProperty('--progress', (current + 1) / screens.length);
setProgress(current, screens[current].dataset.step);
</script>
</Picker>
+183 -70
View File
@@ -24,34 +24,40 @@ let current = 0;
let openTint;
let fontManifest;
/* Headlines and labels come from the product so the display face is judged on
words it will really set. Running text stays lorem on purpose: a body face
is judged on texture, and real prose pulls the eye into reading it. */
const LOREM = {
sentence: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore.',
lines: ['Ut enim ad minim veniam, quis nostrud exercitation', 'ullamco laboris nisi ut aliquip ex ea commodo.'],
};
/* The desktop artboard sets three cards and the phone two, so a fourth would
be words the agent writes and nobody ever reads. */
const GALLERY_CARDS = 3;
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.',
headline: 'Built for the work at hand',
},
preview: {
brand: 'Im',
nav: ['Skills', 'Detect', 'Docs', 'About'],
brand: 'Ab',
nav: ['Product', 'Pricing', 'Docs', 'About'],
navAction: 'Sign in',
menuAction: 'Menu',
ctaPrimary: 'Get started',
ctaSecondary: 'Learn more',
proof: ['23 commands', 'Open source', 'Any harness', 'Free to install'],
sectionTitle: 'Design laws, not vibes',
sectionBody: [
'Shared vocabulary your agent can follow',
'when shaping interfaces.',
],
sectionLink: 'Read the skill',
proof: ['Fast to set up', 'Works anywhere', 'No lock-in', 'Free to try'],
sectionTitle: 'Everything in one place',
sectionLink: 'Read the guide',
gallery: [
{ title: 'Audit', meta: 'Quality pass' },
{ title: 'Polish', meta: 'Ship ready' },
{ title: 'Bolder', meta: 'Turn it up' },
{ title: 'Quieter', meta: 'Pull it back' },
{ title: 'Overview', meta: 'Start here' },
{ title: 'Library', meta: 'Browse all' },
{ title: 'Reports', meta: 'See results' },
],
footerLinks: ['GitHub', 'Changelog', 'CLI', 'Extension'],
footerMark: Impeccable',
footerLinks: ['Product', 'Company', 'Support', 'Legal'],
footerMark: Your product',
},
pairs: [
{
@@ -59,42 +65,42 @@ const FALLBACK_FONTS = {
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.',
why: 'Considered and editorial',
},
{
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.',
why: 'Bookish and plain-spoken',
},
{
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.',
why: 'Sturdy slab warmth',
},
{
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.',
why: 'Headlines that shout',
},
{
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.',
why: 'Machined and precise',
},
{
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.',
why: 'Inscriptional and formal',
},
],
};
@@ -183,34 +189,30 @@ function syncCommittedPalette(target) {
target.style.setProperty('--pv-p-ink', contrastInk(committed.primary));
}
/* Every field is checked only when it is present. The manifest merges over
FALLBACK_FONTS, so a partial file is legal by design: the spec asks for the
full set on the shared block, and a per-pair override is allowed to carry
the one string it changes. Demanding the full shape anywhere it appears
would reject that override and drop the whole manifest, six chosen pairs
included, back to defaults. */
function isPreviewCopy(value) {
return value
&& typeof value.brand === 'string'
&& Array.isArray(value.nav)
&& value.nav.length === 4
&& value.nav.every((entry) => typeof entry === 'string')
&& typeof value.navAction === 'string'
&& typeof value.menuAction === 'string'
&& typeof value.ctaPrimary === 'string'
&& typeof value.ctaSecondary === 'string'
&& Array.isArray(value.proof)
&& value.proof.length === 4
&& value.proof.every((entry) => typeof entry === 'string')
&& typeof value.sectionTitle === 'string'
&& Array.isArray(value.sectionBody)
&& value.sectionBody.length === 2
&& value.sectionBody.every((entry) => typeof entry === 'string')
&& typeof value.sectionLink === 'string'
&& Array.isArray(value.gallery)
&& value.gallery.length === 4
&& value.gallery.every((entry) => (
typeof entry.title === 'string'
&& typeof entry.meta === 'string'
if (!value || typeof value !== 'object') return false;
const strings = ['brand', 'navAction', 'menuAction', 'ctaPrimary', 'ctaSecondary', 'sectionTitle', 'sectionLink', 'footerMark'];
const lists = { nav: 4, proof: 4, footerLinks: 4 };
return strings.every((key) => value[key] === undefined || typeof value[key] === 'string')
&& Object.entries(lists).every(([key, length]) => value[key] === undefined || (
Array.isArray(value[key])
&& value[key].length === length
&& value[key].every((entry) => typeof entry === 'string')
))
&& Array.isArray(value.footerLinks)
&& value.footerLinks.length === 4
&& value.footerLinks.every((entry) => typeof entry === 'string')
&& typeof value.footerMark === 'string';
&& (value.gallery === undefined || (
Array.isArray(value.gallery)
&& value.gallery.length === GALLERY_CARDS
&& value.gallery.every((entry) => (
typeof entry?.title === 'string'
&& typeof entry?.meta === 'string'
))
));
}
function normalizeFontManifest(manifest) {
@@ -223,7 +225,6 @@ function normalizeFontManifest(manifest) {
function isFontManifest(value) {
return value?.version === 1
&& typeof value.specimen?.headline === 'string'
&& typeof value.specimen?.body === 'string'
&& (!value.preview || isPreviewCopy(value.preview))
&& value.pairs?.length === 6
&& value.pairs.every((pair) => (
@@ -296,7 +297,7 @@ function syncFontPair(pair) {
for (const node of document.querySelectorAll('[data-type-nav-action]')) node.textContent = preview.navAction;
for (const node of document.querySelectorAll('[data-type-menu-action]')) node.textContent = preview.menuAction;
for (const node of document.querySelectorAll('[data-type-headline]')) node.textContent = specimen.headline;
for (const node of document.querySelectorAll('[data-type-body]')) node.textContent = specimen.body;
for (const node of document.querySelectorAll('[data-type-body]')) node.textContent = LOREM.sentence;
document.querySelectorAll('[data-type-cta-primary]').forEach((node) => {
node.textContent = preview.ctaPrimary;
});
@@ -308,8 +309,8 @@ function syncFontPair(pair) {
document.querySelectorAll('[data-type-section-title]').forEach((node) => {
node.textContent = preview.sectionTitle;
});
fillIndexed(desktop, '[data-type-section-body]', preview.sectionBody);
fillIndexed(phoneBody, '[data-type-section-body]', preview.sectionBody);
fillIndexed(desktop, '[data-type-section-body]', LOREM.lines);
fillIndexed(phoneBody, '[data-type-section-body]', LOREM.lines);
document.querySelectorAll('[data-type-section-link]').forEach((node) => {
node.textContent = preview.sectionLink;
});
@@ -322,28 +323,32 @@ function syncFontPair(pair) {
});
document.querySelector('[name="font-heading"]').value = pair.heading.family;
document.querySelector('[name="font-body"]').value = pair.body.family;
document.querySelector('[name="font-heading-source"]').value = pair.heading.source || '';
document.querySelector('[name="font-body-source"]').value = pair.body.source || '';
}
function addPairCard(pair, { checked = false, prepend = false } = {}) {
const node = pairTemplate.content.firstElementChild.cloneNode(true);
const input = node.querySelector('input');
input.value = pair.id;
input.checked = checked;
input.setAttribute('aria-label', `${pair.heading.family} with ${pair.body.family}`);
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-heading]').textContent = pair.heading.family;
node.querySelector('[data-pair-body]').textContent = pair.body.family;
node.querySelector('[data-pair-why]').textContent = pair.why;
if (prepend) fontOptions.prepend(node);
else fontOptions.append(node);
return node;
}
function renderFontPairs(manifest, fallback) {
fontManifest = normalizeFontManifest(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;
input.setAttribute('aria-label', `${pair.heading.family} with ${pair.body.family}`);
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-heading]').textContent = pair.heading.family;
node.querySelector('[data-pair-body]').textContent = pair.body.family;
node.querySelector('[data-pair-why]').textContent = pair.why;
fragment.append(node);
});
fontOptions.append(fragment);
manifest.pairs.forEach((pair, index) => addPairCard(pair, { checked: index === 0 }));
loadFontStylesheet(manifest.pairs);
syncFontPair(manifest.pairs[0]);
}
@@ -354,6 +359,114 @@ fontOptions.onchange = ({ target }) => {
if (pair) syncFontPair(pair);
};
// Scroll by whole cards so a row never ends up half in frame.
const scrollButtons = [...document.querySelectorAll('[data-font-scroll]')];
for (const button of scrollButtons) {
button.onclick = () => {
const step = fontOptions.querySelector('.picker-type-option')?.offsetHeight || 100;
fontOptions.scrollBy({ top: step * Number(button.dataset.fontScroll), behavior: 'smooth' });
};
}
function syncScrollButtons() {
const room = fontOptions.scrollHeight - fontOptions.clientHeight;
for (const button of scrollButtons) {
const down = button.dataset.fontScroll === '1';
const spent = down ? fontOptions.scrollTop >= room - 1 : fontOptions.scrollTop <= 1;
button.disabled = room < 2 || spent;
}
}
fontOptions.addEventListener('scroll', syncScrollButtons, { passive: true });
new ResizeObserver(syncScrollButtons).observe(fontOptions);
/* Custom fonts. A URL is carried through as-is; an uploaded face is handed to
the server, which stores the bytes and returns the path the answers record.
Neither is parsed here: the questionnaire validates at the end. */
const fontModal = document.querySelector('[data-font-modal]');
const customStatus = fontModal.querySelector('[data-custom-status]');
const customFile = (role) => fontModal.querySelector(`[data-custom-file="${role}"]`);
const customUrl = (role) => fontModal.querySelector(`[data-custom-url="${role}"]`);
document.querySelector('[data-font-custom-open]').onclick = () => {
customStatus.textContent = '';
fontModal.showModal();
};
document.querySelector('[data-font-custom-close]').onclick = () => fontModal.close();
for (const role of ['heading', 'body']) {
customFile(role).onchange = ({ target }) => {
const label = fontModal.querySelector(`[data-custom-file-name="${role}"]`);
label.textContent = target.files[0]?.name || 'No file chosen';
};
}
async function resolveCustomFace(role) {
const file = customFile(role).files[0];
if (file) {
const response = await fetch('/font-upload', {
method: 'POST',
headers: { 'X-Font-Filename': file.name, 'Content-Type': 'application/octet-stream' },
body: file,
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || 'Upload failed');
return { family: file.name.replace(/\.[^.]+$/, ''), source: result.path };
}
const url = customUrl(role).value.trim();
return url ? { family: url.split('/').pop().replace(/\.[^.]+$/, '') || 'Custom', source: url } : null;
}
document.querySelector('[data-font-custom-save]').onclick = async () => {
customStatus.textContent = 'Saving…';
let heading;
let body;
try {
[heading, body] = await Promise.all([resolveCustomFace('heading'), resolveCustomFace('body')]);
} catch (error) {
customStatus.textContent = error.message;
return;
}
if (!heading && !body) {
customStatus.textContent = 'Add a URL or a file for at least one role.';
return;
}
const current = fontManifest.pairs.find(({ id }) => id === fontOptions.querySelector('input:checked')?.value);
const pair = {
id: 'custom',
name: 'Custom',
heading: heading || current.heading,
body: body || current.body,
why: 'Your own faces',
};
fontManifest.pairs = [pair, ...fontManifest.pairs.filter(({ id }) => id !== 'custom')];
addPairCard(pair, { prepend: true });
loadCustomFace(pair);
fontOptions.querySelector('input[value="custom"]').checked = true;
syncFontPair(pair);
fontOptions.scrollTo({ top: 0 });
fontModal.close();
};
// A hosted stylesheet is linked; an uploaded file is registered as a face so
// the specimen and the artboards can render it immediately.
function loadCustomFace({ heading, body }) {
for (const face of [heading, body]) {
if (!face?.source) continue;
if (/\.(css)(\?|$)/i.test(face.source) || !/\.(woff2?|ttf|otf)(\?|$)/i.test(face.source)) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = face.source;
document.head.append(link);
continue;
}
const src = face.source.startsWith('http') ? face.source : `/fonts/${face.source.split('/').pop()}`;
document.fonts.add(new FontFace(face.family, `url(${src})`));
document.fonts.load(`16px "${face.family}"`);
}
}
function setActiveRole(role) {
if (hint.textContent === hint.dataset[role]) return;
hint.classList.add('is-changing');
+537 -84
View File
@@ -45,6 +45,62 @@ body.picker-page {
text-rendering: optimizeLegibility;
}
/* The gold-leaf face, as the site sets it: a flat kinpaku fill with the leaf
asset over it at 38%, not the leaf itself as the fill. The texture is a
pseudo-element because a background-image cannot carry its own opacity, and
the fixed 340px render keeps the crack structure readable at button size
where `cover` would average the asset into flat gold.
Kept here rather than in the vendored kit for one reason: the picker serves
its own compressed copy of the leaf from its own assets directory, so the
URL has to come from --pk-foil (set on <body> in layouts/Picker.astro). The
rest matches `.ks-button.ks-button-primary` in the site's kinpaku-kit.css,
including the hover that lifts to vivid gold rather than the pale beige the
vendored copy still carries. */
.picker-page .ks-button.ks-button-primary {
position: relative;
isolation: isolate;
color: var(--ks-dark-ink);
background: var(--ks-kinpaku);
border-color: var(--ks-kinpaku);
}
.picker-page .ks-button.ks-button-primary::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
background: var(--pk-foil) 68% 44% / 340px auto no-repeat;
opacity: 0.38;
pointer-events: none;
transition: opacity 180ms var(--ks-ease);
}
.picker-page .ks-button.ks-button-primary:hover {
color: var(--ks-dark-ink);
background: var(--ks-kinpaku-vivid);
border-color: var(--ks-kinpaku-vivid);
transform: translateY(-1px);
}
.picker-page .ks-button.ks-button-primary:hover::before {
opacity: 0.26;
}
.picker-page .ks-button.ks-button-primary:active {
background: var(--ks-kinpaku-rich);
border-color: var(--ks-kinpaku-rich);
transform: translateY(0);
}
/* Select this palette waits for a committed color, and the kit's disabled state
drops the fill to transparent. The leaf has to go with it, or the texture
floats on the page with no gold under it. */
.picker-page .ks-button.ks-button-primary[disabled]::before {
opacity: 0;
}
.picker-shell {
position: relative;
min-height: 100vh;
@@ -59,7 +115,8 @@ body.picker-page {
background-position: left center;
background-size: cover;
background-repeat: no-repeat;
filter: saturate(1.12) contrast(1.04);
/* Same grade the site gives .hero-rebuild-art over this asset. */
filter: saturate(1.2) contrast(1.08);
pointer-events: none;
transition: opacity 180ms var(--ks-ease);
}
@@ -96,6 +153,37 @@ body.picker-page {
gap: 28px;
}
/* Brand lockup, matching the site header: carved-tile mark plus tracked
wordmark, both in kinpaku gold. */
.picker-brand {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--ks-kinpaku);
margin-bottom: 4px;
}
.picker-brand-logo {
display: grid;
place-items: center;
width: 34px;
height: 34px;
}
.picker-brand-logo svg {
width: 32px;
height: 32px;
}
.picker-brand-name {
font-family: var(--ks-font-wordmark);
font-size: 1.3rem;
font-weight: 400;
line-height: 1;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.picker-title {
color: var(--ks-champagne);
font-family: var(--ks-font-display);
@@ -163,26 +251,56 @@ body.picker-page {
opacity: 0;
}
/* One hairline per step, gold once passed, plus the count and the name of the
step in hand so the position is legible without counting ticks. */
.picker-progress {
position: fixed;
top: 32px;
left: max(56px, calc((100vw - 1500px) / 2 + 56px));
z-index: 5;
width: 160px;
height: 3px;
overflow: hidden;
background: var(--ks-rule);
display: grid;
gap: 10px;
transition: opacity 180ms var(--ks-ease);
}
.picker-progress span {
display: block;
width: 100%;
height: 100%;
background: var(--ks-champagne);
transform: scaleX(var(--progress, 0));
transform-origin: left;
transition: transform 180ms var(--ks-ease);
.picker-progress-track {
display: grid;
grid-template-columns: repeat(4, 34px);
gap: 6px;
}
.picker-progress-track i {
height: 2px;
background: var(--ks-rule);
transition: background-color 260ms var(--ks-ease);
}
.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),
.picker-progress[data-step="4"] .picker-progress-track i {
background: var(--ks-kinpaku);
}
.picker-progress-label {
display: flex;
align-items: baseline;
gap: 10px;
font-family: var(--ks-font);
font-size: 0.68rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--ks-text-muted);
}
.picker-progress-index b {
color: var(--ks-kinpaku);
font-weight: 500;
}
.picker-progress-name {
color: var(--ks-champagne);
letter-spacing: 0.1em;
}
#picker-form[data-current="01"] .picker-progress {
@@ -196,13 +314,20 @@ body.picker-page {
padding-block: clamp(32px, 5svh, 48px) clamp(24px, 3.5svh, 48px);
}
.picker-palette-title {
/* Every question wears the site's section-heading treatment: the same thin
display face `.slop-teaser-title` gets inside `.slop-pattern-head` on the
homepage. Centered rather than set in a 15ch column, because a question owns
the top of the screen instead of leading a left-hand text block. */
.picker-question-title {
margin: 0;
color: var(--ks-champagne);
font-family: var(--ks-font-display);
font-size: var(--ks-type-headline-size);
font-weight: var(--ks-type-headline-weight);
line-height: var(--ks-type-headline-line);
font-size: clamp(3rem, 5.6vw, 5.5rem);
font-weight: 100;
line-height: 1.04;
letter-spacing: -0.005em;
text-align: center;
text-wrap: balance;
}
.picker-palette-grid {
@@ -1059,6 +1184,7 @@ body.picker-page {
}
.picker-strategy-grid {
max-height: calc(100svh - 346px);
display: grid;
grid-template-columns: minmax(340px, 420px) minmax(0, 1fr);
gap: 50px;
@@ -1120,13 +1246,18 @@ body.picker-page {
transition: background-color 180ms var(--ks-ease);
}
/* Mixes are keyed to --ks-patina, not --ks-patina-deep: the deep oxide sits at
49% lightness and a single-digit mix of it into an 11% panel is invisible. */
.picker-strategy-option:hover {
background: color-mix(in oklab, var(--ks-patina-deep) 4%, var(--ks-lacquer-raised));
background: color-mix(in oklab, var(--ks-patina) 9%, var(--ks-lacquer-raised));
}
.picker-strategy-option:hover::before {
background: color-mix(in oklab, var(--ks-patina) 55%, transparent);
}
.picker-strategy-option:hover::before,
.picker-strategy-option:has(input:checked)::before {
background: var(--ks-patina-deep);
background: var(--ks-patina);
}
.picker-strategy-copy {
@@ -1150,7 +1281,7 @@ body.picker-page {
}
.picker-strategy-option:has(input:checked) {
background: color-mix(in oklab, var(--ks-patina-deep) 7%, var(--ks-lacquer-raised));
background: color-mix(in oklab, var(--ks-patina) 14%, var(--ks-lacquer-raised));
}
.picker-strategy-option:has(input:checked) .picker-strategy-title {
@@ -1163,42 +1294,64 @@ body.picker-page {
z-index: 1;
}
/* Screen 03: copy-only rows inside the shared options panel. */
.picker-strategy-choices .picker-strategy-option {
padding: 22px 26px 22px 28px;
/* Screen 03 uses the same rail shell as screen 04: scrollable fieldset inside
a height-capped column. Row rhythm, hover, and checked states follow the
type list; only the copy scale differs because these are strategy labels,
not font specimens. Four rows share the preview column's height equally so
a one-line description does not leave a short band beside a taller neighbor. */
.picker-strategy-grid > .picker-type-rail {
min-height: 0;
height: 100%;
grid-template-rows: minmax(0, 1fr);
}
.picker-strategy-choices .picker-strategy-option::before {
content: none;
.picker-strategy-choices.picker-type-options {
height: 100%;
align-content: stretch;
grid-template-rows: repeat(4, minmax(0, 1fr));
}
.picker-strategy-choices .picker-strategy-option:hover {
background: color-mix(in oklab, var(--ks-patina-deep) 5%, var(--ks-lacquer-raised));
.picker-strategy-choices.picker-type-options .picker-strategy-option {
align-content: start;
}
.picker-strategy-choices .picker-strategy-option:has(input:checked) {
background: color-mix(in oklab, var(--ks-patina-deep) 9%, var(--ks-lacquer-raised));
.picker-strategy-choices.picker-type-options .picker-strategy-option:hover {
background: transparent;
}
.picker-strategy-choices .picker-strategy-option:has(input:checked) .picker-strategy-title {
color: var(--ks-patina);
.picker-strategy-choices.picker-type-options .picker-strategy-option:hover::before {
background: color-mix(in oklch, var(--ks-state-ink) 55%, transparent);
}
.picker-strategy-choices.picker-type-options .picker-strategy-option:hover .picker-strategy-title {
color: var(--ks-state-ink);
}
.picker-strategy-choices.picker-type-options .picker-strategy-option:has(input:checked)::before {
background: var(--ks-state-ink);
}
.picker-strategy-choices.picker-type-options .picker-strategy-option:has(input:checked) .picker-strategy-title {
color: var(--ks-state-ink);
}
.picker-strategy-choices .picker-strategy-copy {
gap: 6px;
gap: 2px;
}
.picker-strategy-choices .picker-strategy-title {
color: var(--ks-champagne);
font-family: var(--ks-font);
font-size: 24px;
font-weight: 400;
line-height: 1.1;
line-height: 1.15;
letter-spacing: -0.015em;
transition: color 180ms var(--ks-ease);
}
.picker-strategy-choices .picker-strategy-desc {
font-size: 16px;
line-height: 1.55;
font-size: 15px;
line-height: 1.4;
max-width: 34ch;
}
@@ -1900,9 +2053,33 @@ body.picker-page {
color 280ms var(--ks-ease);
}
.picker-strategy-actions {
justify-content: center;
margin-top: 0;
/* Every screen past the start ends the same way: the commitment centered and
foiled, retreat parked underneath it and deliberately quiet. */
.picker-actions-stack {
display: grid;
justify-items: center;
gap: 14px;
}
.picker-back {
padding: 4px 8px;
border: 0;
background: none;
color: var(--ks-text-muted);
font-family: var(--ks-font);
font-size: 0.86rem;
letter-spacing: 0.02em;
cursor: pointer;
transition: color 180ms var(--ks-ease);
}
.picker-back:hover {
color: var(--ks-champagne);
}
.picker-back:focus-visible {
outline: 1px solid var(--ks-kinpaku);
outline-offset: 3px;
}
/* Screen 04 mirrors screen 03's frame exactly: same centered headline, same
@@ -1915,7 +2092,12 @@ body.picker-page {
padding-block: clamp(32px, 5svh, 48px) clamp(24px, 3.5svh, 48px);
}
/* Six pair cards are taller than four strategy rows, and left to themselves
they set the row height and push Select this pair below the fold. The row
is capped against the viewport instead, leaving the list to scroll inside
it; the subtraction covers the title above and the CTA stack below. */
.picker-type-grid {
max-height: calc(100svh - 346px);
display: grid;
grid-template-columns: minmax(340px, 420px) minmax(0, 1fr);
gap: 50px;
@@ -1923,74 +2105,276 @@ body.picker-page {
}
/* 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. */
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. */
.picker-type-rail {
position: relative;
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: minmax(0, 1fr) auto;
gap: 14px;
}
.picker-type-options {
position: absolute;
inset: 0;
min-height: 0;
overflow-y: auto;
align-content: start;
padding-right: 10px;
scrollbar-width: thin;
scrollbar-color: color-mix(in oklab, var(--ks-patina-deep) 35%, transparent) transparent;
}
/* Screen 04 mirrors screen 03's options panel: copy-only rows, no spine. */
.picker-type-controls {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.picker-type-scroll {
display: flex;
gap: 8px;
}
.picker-type-custom {
display: inline-flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 16px;
background: transparent;
border: 1px solid var(--ks-rule);
border-radius: 2px;
color: var(--ks-text-muted);
font-family: var(--ks-font);
font-size: 0.8rem;
letter-spacing: 0.04em;
cursor: pointer;
transition:
color 180ms var(--ks-ease),
border-color 180ms var(--ks-ease),
background-color 180ms var(--ks-ease);
}
.picker-type-custom svg {
width: 14px;
height: 14px;
fill: none;
stroke: currentColor;
stroke-width: 1.4;
stroke-linecap: square;
}
.picker-type-custom:hover {
color: var(--ks-champagne);
border-color: color-mix(in oklab, var(--ks-patina) 45%, transparent);
background: color-mix(in oklab, var(--ks-patina) 8%, transparent);
}
.picker-type-custom:focus-visible {
outline: 1px solid var(--ks-kinpaku);
outline-offset: 2px;
}
/* Custom font sheet. A native <dialog> carries the modal behaviour, so the
styling only has to dress it. */
.picker-modal {
width: min(520px, calc(100vw - 48px));
margin: auto;
padding: 0;
background: var(--ks-lacquer-raised);
border: 1px solid var(--ks-rule);
border-radius: 2px;
color: var(--ks-text);
}
.picker-modal::backdrop {
background: oklch(2% 0.004 95 / 0.72);
backdrop-filter: blur(3px);
}
.picker-modal-inner {
display: grid;
gap: 24px;
padding: 32px;
}
.picker-modal-head {
display: grid;
gap: 8px;
}
.picker-modal-head h2 {
color: var(--ks-champagne);
font-family: var(--ks-font-display);
font-size: 1.7rem;
font-weight: 400;
line-height: 1.1;
}
.picker-modal-head p {
color: var(--ks-text-muted);
font-size: 0.9rem;
line-height: 1.5;
}
.picker-modal-field {
display: grid;
gap: 8px;
padding: 20px;
border: 1px solid var(--ks-rule);
border-radius: 2px;
}
.picker-modal-field legend {
padding-inline: 8px;
margin-inline-start: -8px;
color: var(--ks-kinpaku);
font-size: 0.68rem;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.picker-modal-label {
color: var(--ks-text-muted);
font-size: 0.78rem;
letter-spacing: 0.02em;
}
.picker-modal-input {
height: 40px;
padding: 0 12px;
background: var(--ks-lacquer-deep);
border: 1px solid var(--ks-rule);
border-radius: 2px;
color: var(--ks-champagne);
font-family: var(--ks-font);
font-size: 0.88rem;
}
.picker-modal-input::placeholder {
color: color-mix(in oklab, var(--ks-text-muted) 65%, transparent);
}
.picker-modal-input:focus-visible {
outline: 1px solid var(--ks-patina);
outline-offset: 1px;
border-color: transparent;
}
.picker-modal-or {
color: var(--ks-text-muted);
font-size: 0.68rem;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.picker-modal-file {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.picker-modal-file input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.picker-modal-file-button {
flex: none;
padding: 8px 14px;
border: 1px solid var(--ks-rule);
border-radius: 2px;
color: var(--ks-champagne);
font-size: 0.8rem;
cursor: pointer;
transition: border-color 180ms var(--ks-ease), color 180ms var(--ks-ease);
}
.picker-modal-file:hover .picker-modal-file-button {
border-color: color-mix(in oklab, var(--ks-patina) 45%, transparent);
color: var(--ks-patina);
}
.picker-modal-file input:focus-visible + .picker-modal-file-button {
outline: 1px solid var(--ks-kinpaku);
outline-offset: 2px;
}
.picker-modal-file-name {
min-width: 0;
overflow: hidden;
color: var(--ks-text-muted);
font-size: 0.8rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.picker-modal-status:empty {
display: none;
}
.picker-modal-status {
color: var(--ks-patina);
font-size: 0.82rem;
}
.picker-modal-actions {
display: grid;
justify-items: center;
gap: 12px;
}
/* Screen 04 mirrors screen 03's options panel. */
.picker-type-options .picker-strategy-option {
gap: 0;
padding: 22px 26px 22px 28px;
padding: 18px 26px 18px 28px;
}
.picker-type-options .picker-strategy-option::before {
content: none;
}
.picker-type-options .picker-strategy-option:hover {
background: color-mix(in oklab, var(--ks-patina-deep) 5%, var(--ks-lacquer-raised));
}
.picker-type-options .picker-strategy-option:has(input:checked) {
background: color-mix(in oklab, var(--ks-patina-deep) 9%, var(--ks-lacquer-raised));
}
.picker-type-options .picker-strategy-option:has(input:checked) .picker-type-sample-heading {
.picker-type-options .picker-strategy-option:has(input:checked) .picker-type-sample-heading,
.picker-type-options .picker-strategy-option:hover .picker-type-sample-heading {
color: var(--ks-patina);
}
.picker-type-copy {
min-width: 0;
display: grid;
gap: 6px;
gap: 2px;
}
/* The heading renders in its pair's display face; the description renders
in the body face so both are judged as type, not as labels. */
/* Each face names itself in its own glyphs: the display family large, the
text family under it at reading size. The row is a specimen, not a label,
so the descriptor stays to a few words in tracked caps. */
.picker-type-sample-heading {
color: var(--ks-champagne);
font-family: var(--pair-heading, var(--ks-font-display));
font-size: 24px;
font-size: 26px;
font-weight: var(--pair-heading-weight, 400);
line-height: 1.1;
line-height: 1.15;
letter-spacing: -0.015em;
transition: color 180ms var(--ks-ease);
}
.picker-type-sample-body {
color: var(--ks-text);
font-family: var(--pair-body, var(--ks-font));
font-size: 15px;
font-weight: var(--pair-body-weight, 400);
line-height: 1.4;
}
.picker-type-desc {
margin-top: 0;
max-width: 34ch;
margin-top: 6px;
color: var(--ks-text-muted);
font-family: var(--pair-body, var(--ks-font));
font-size: 16px;
font-weight: var(--pair-body-weight, 400);
line-height: 1.55;
}
.picker-type-desc b {
font: inherit;
font-weight: 700;
font-family: var(--ks-font);
font-size: 0.66rem;
font-weight: 400;
line-height: 1.4;
letter-spacing: 0.14em;
text-transform: uppercase;
}
/* One golden-ratio ladder rules every piece of text on the artboards:
@@ -2003,15 +2387,22 @@ body.picker-page {
--pt-body: var(--ks-font);
--pt-heading-weight: 600;
--pt-ratio: 1.618;
--pt-base: clamp(0.58rem, 0.75vw, 0.72rem);
--pt-base: clamp(0.72rem, 0.86vw, 0.84rem);
/* 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. */
--pvs-copy: color-mix(in oklab, var(--pv-neutral) 34%, var(--pv-n-ink));
--pvs-bars: color-mix(in oklab, var(--pv-neutral) 42%, var(--pv-n-ink));
}
/* The derived steps are declared on both artboard roots because custom
properties resolve their var() references where they are declared: the
phone re-bases --pt-base, so it needs its own ladder computation. */
phone re-bases --pt-base, so it needs its own ladder computation. The
micro step is floored rather than divided: strict scale fidelity puts it
under 8px, and this preview exists to be read. */
.picker-preview-type,
.picker-preview-type .ps-phone {
--pt-micro: calc(var(--pt-base) / var(--pt-ratio));
--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));
}
@@ -2028,6 +2419,51 @@ body.picker-page {
gap: 7%;
}
/* Real copy sets the hero's height, so the image has to state its own shape
instead of stretching to whatever the text column happens to need. */
.picker-preview-type .ps-hero > .ps-image {
height: auto;
aspect-ratio: 5 / 4;
align-self: center;
}
/* The artboard's height comes from the frame's aspect ratio, so the bands stay
proportional and each one has to hold its text rather than push the next
one down. Real copy needs more of the page than bar glyphs did: the hero
gives height to the editorial band, which now carries a wrapping title. */
.picker-preview-type .ps-desktop {
grid-template-rows: 9.4% 46% 9% 27.3% 8.3%;
}
/* The editorial band splits differently once its bars are words. Four cards
left the copy column at 147px, under the 169px the widest pairing needs to
set a four-word title in two lines, and left the last card a pixel short of
its label. Three cards, in even columns, clears both with room to spare. */
.picker-preview-type .ps-editorial {
grid-template-columns: 45% minmax(0, 1fr);
align-items: start;
}
.picker-preview-type .ps-desktop .ps-gallery {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.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
the one that would clip. */
.picker-preview-type .ps-phone-body {
grid-template-rows: 22% auto auto auto 34px auto;
}
.picker-preview-type .ps-phone-body .ps-gallery {
display: none;
}
.pt-headline {
max-width: 12ch;
margin: 0 0 12px;
@@ -2050,9 +2486,10 @@ body.picker-page {
line-height: 1.55;
}
/* Phone artboard type: the same ladder re-based to handset scale. */
/* 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 {
--pt-base: clamp(0.42rem, 0.55vw, 0.52rem);
--pt-base: clamp(0.66rem, 0.72vw, 0.74rem);
}
.ps-phone .pt-headline {
@@ -2112,7 +2549,7 @@ body.picker-page {
line-height: 1;
}
/* Buttons keep their fills; the label borrows the page ground as ink. */
/* Buttons keep their fills; a filled label borrows the page ground as ink. */
.picker-preview-type .ps-nav-action,
.picker-preview-type .ps-actions i {
width: auto;
@@ -2128,24 +2565,35 @@ body.picker-page {
white-space: nowrap;
}
/* The ghost button sits on the ground, so ground-colored ink would erase it. */
.picker-preview-type .ps-actions i:last-child {
color: var(--pvs-title);
}
.picker-preview-type .ps-proof-item span {
color: var(--pvs-bars);
}
/* Wraps rather than truncates: a section title is the product's own words and
its length is not ours to guarantee. */
.picker-preview-type .ps-editorial-copy strong {
width: auto;
height: auto;
overflow: visible;
background: none;
color: var(--pvs-title);
font-family: var(--pt-heading);
font-size: var(--pt-title);
font-weight: var(--pt-heading-weight);
line-height: 1.2;
white-space: nowrap;
line-height: 1.25;
white-space: normal;
text-wrap: balance;
}
.picker-preview-type .ps-editorial-copy span i {
overflow: visible;
color: var(--pvs-copy);
white-space: normal;
}
.picker-preview-type .ps-editorial-copy > em {
@@ -2156,8 +2604,12 @@ body.picker-page {
color: var(--pvs-signal);
}
/* Card labels wrap instead of truncating: a two-line label still reads, and a
clipped word is exactly the kind of flaw this preview exists to rule out. */
.picker-preview-type .ps-gallery-item b {
overflow: visible;
color: var(--pvs-bars);
white-space: normal;
}
.picker-preview-type .ps-gallery-item b:first-child {
@@ -2179,7 +2631,8 @@ body.picker-page {
}
.picker-select {
justify-self: end;
width: 100%;
max-width: 320px;
}
.picker-placeholder {
+9 -1
View File
@@ -24,15 +24,20 @@
/* Kinpaku gold. The rich, warm, vibrant primary. */
--ks-kinpaku: oklch(84% 0.19 80.46); /* primary accent, wordmark, icon */
--ks-kinpaku-pale: oklch(86% 0.07 84); /* hover lift, pale fills */
--ks-kinpaku-vivid: oklch(87% 0.20 85); /* hover on gold fills: lit, not washed */
--ks-kinpaku-pale: oklch(86% 0.07 84); /* pale fills, tints on dark */
--ks-kinpaku-rich: oklch(77% 0.13 82); /* active CTA, severity-medium */
--ks-kinpaku-deep: oklch(61% 0.085 78); /* borders against the brand */
--ks-dark-ink: oklch(14% 0.018 95); /* foreground on gold; never theme-remapped */
/* Verdigris patina. The secondary accent — state, signal, selection. */
--ks-patina: oklch(70% 0.12 188); /* secondary accent */
--ks-patina-pale: oklch(82% 0.07 188); /* hover lift on patina */
--ks-patina-deep: oklch(49% 0.08 188); /* deep oxide, dark variants */
/* Semantic foregrounds for state, selection, and interactive labels. */
--ks-state-ink: oklch(70% 0.12 188);
/* Warning — vermilion. Used sparingly, only for failures and warnings. */
--ks-vermilion: oklch(58% 0.15 35);
@@ -160,6 +165,7 @@ html.light {
For decorative small text on paper, use patina. Use --ks-link-on-paper
for body-sized links that need stronger contrast. */
--ks-kinpaku: oklch(84% 0.19 80.46);
--ks-kinpaku-vivid: oklch(87% 0.20 85);
--ks-kinpaku-pale: oklch(86% 0.07 84);
--ks-kinpaku-rich: oklch(77% 0.13 82);
--ks-kinpaku-deep: oklch(61% 0.085 78);
@@ -176,6 +182,8 @@ html.light {
--ks-patina-pale: oklch(82% 0.07 188);
--ks-patina-deep: oklch(49% 0.08 188);
--ks-state-ink: var(--ks-patina-deep);
--ks-vermilion: oklch(52% 0.16 35);
/* Surfaces — warm paper and cream panels. */
+12 -5
View File
@@ -10,10 +10,15 @@ const buildDir = path.join(root, 'build-picker');
const outputDir = path.join(root, 'skill/scripts/picker');
// Static assets vendored under picker/assets/ (the site now lives in the
// private impeccable-site repo, so the picker carries its own copies).
const faviconSource = path.join(root, 'picker/assets/favicon.svg');
// Static assets vendored under picker/assets/ (the site now lives in the
// private impeccable-site repo, so the picker carries its own copies). These
// are referenced from markup at runtime, so Vite never sees them.
const assetsSource = path.join(root, 'picker/assets');
const assetsOutput = path.join(outputDir, 'assets');
const runtimeAssets = ['hero-dark.jpg', 'kinpaku-gold-leaf.jpg'];
// The page links ./favicon.svg, so it is also served from the output root.
const faviconSource = path.join(assetsSource, 'favicon.svg');
const faviconOutput = path.join(outputDir, 'favicon.svg');
const heroSource = path.join(root, 'picker/assets/hero-dark.jpg');
const heroOutput = path.join(outputDir, 'assets/hero-dark.jpg');
await rm(buildDir, { recursive: true, force: true });
execFileSync(
@@ -24,9 +29,11 @@ execFileSync(
await rm(outputDir, { recursive: true, force: true });
await cp(buildDir, outputDir, { recursive: true });
await mkdir(path.dirname(heroOutput), { recursive: true });
await mkdir(assetsOutput, { recursive: true });
await copyFile(faviconSource, faviconOutput);
await copyFile(heroSource, heroOutput);
for (const asset of runtimeAssets) {
await copyFile(path.join(assetsSource, asset), path.join(assetsOutput, asset));
}
await rm(buildDir, { recursive: true, force: true });
console.log(`Built ${path.relative(root, outputDir)}/`);
+9 -10
View File
@@ -378,13 +378,15 @@ Compose six distinct territories, then resolve each into one heading and body pa
- 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.
- Write `why` as three to five words naming the pair's voice, not a sentence about the brand. The picker sets it in tracked caps under the two family names, so anything longer wraps and stops scanning. `Considered and editorial`, not `Source Serif 4 gives the questionnaire an editorial voice while Source Sans 3 keeps guidance easy to scan`.
- Order the pairs best-first. `pairs[0]` is the recommendation and reaches the picker pre-selected.
Choose specimen strings and wireframe copy from the product's own world. Do not invent claims or use placeholder prose that could describe any brand.
Choose the headline and every wireframe label from the product's own world. Do not invent claims or use placeholder prose that could describe any brand.
- **Hero**: a headline of at most six words and one honest body sentence (`specimen.headline`, `specimen.body`).
- **Wireframe**: every other label the type-preview artboard shows (`preview`): a short brand mark, four nav labels, nav and menu actions, two CTA labels, four proof chips, a section title, two section body lines, one section link, four gallery cards (`title` + `meta`), four footer links, and a footer mark. Pull each string from PRODUCT.md, the interview, or supplied assets. Keep labels short enough to fit the artboard.
- **Hero**: a headline of at most six words (`specimen.headline`).
- **Wireframe**: every other label the type-preview artboard shows (`preview`): a short brand mark, four nav labels, nav and menu actions, two CTA labels, four proof chips, a section title, one section link, three gallery cards (`title` + `meta`), four footer links, and a footer mark. Pull each string from PRODUCT.md, the interview, or supplied assets. Keep labels short enough to fit the artboard.
**Running text is not yours to write.** The picker sets every paragraph in lorem, because a body face is judged on texture and real prose pulls the eye into reading it instead. Leave `specimen.body` and `preview.sectionBody` out of the file.
Write `.impeccable/visual-cues/fonts.json` with this shape:
@@ -392,8 +394,7 @@ Write `.impeccable/visual-cues/fonts.json` with this shape:
{
"version": 1,
"specimen": {
"headline": "Six words from the product's world",
"body": "One honest sentence in the product's voice for the body face."
"headline": "Six words from the product's world"
},
"preview": {
"brand": "Ab",
@@ -404,13 +405,11 @@ Write `.impeccable/visual-cues/fonts.json` with this shape:
"ctaSecondary": "Secondary action",
"proof": ["Proof one", "Proof two", "Proof three", "Proof four"],
"sectionTitle": "Section title",
"sectionBody": ["First body line.", "Second body line."],
"sectionLink": "Section link",
"gallery": [
{ "title": "Card one", "meta": "Detail one" },
{ "title": "Card two", "meta": "Detail two" },
{ "title": "Card three", "meta": "Detail three" },
{ "title": "Card four", "meta": "Detail four" }
{ "title": "Card three", "meta": "Detail three" }
],
"footerLinks": ["Link one", "Link two", "Link three", "Link four"],
"footerMark": "© Brand"
@@ -431,7 +430,7 @@ Write exactly six pair entries. Each role carries the single weight it needs; th
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, preview (every field above), six unique ids, six unique heading families, role names, weights, and one-sentence `why` fields before continuing.
Parse the finished file as JSON and verify its version, specimen, preview (every field above), six unique ids, six unique heading families, role names, weights, and short `why` fields before continuing.
Done when: `fonts.json` is parseable, contains exactly six ranked pairs, every family name has been checked against Google Fonts, and the preview copy reads as this product, not generic SaaS filler.
+38
View File
@@ -14,7 +14,9 @@ import { SEEDS } from './palette.mjs';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const pickerDir = path.join(scriptDir, 'picker');
const answersPath = path.resolve(process.cwd(), '.impeccable/design-interview/answers.json');
const fontsDir = path.resolve(process.cwd(), '.impeccable/design-interview/fonts');
const MAX_BODY_BYTES = 1024 * 1024;
const FONT_EXTENSIONS = new Set(['.woff2', '.woff', '.ttf', '.otf']);
const MIME = new Map([
['.html', 'text/html; charset=utf-8'],
['.css', 'text/css; charset=utf-8'],
@@ -23,6 +25,10 @@ const MIME = new Map([
['.png', 'image/png'],
['.svg', 'image/svg+xml'],
['.json', 'application/json; charset=utf-8'],
['.woff2', 'font/woff2'],
['.woff', 'font/woff'],
['.ttf', 'font/ttf'],
['.otf', 'font/otf'],
]);
function printHelp() {
console.log(`Usage: node picker-server.mjs [options]
@@ -205,6 +211,28 @@ async function handleRequest(request, response) {
return;
}
// Uploaded faces are stored, not parsed: the questionnaire defers validation
// to the end, so the server only needs to put the bytes where the agent can
// reach them and hand back the path the answers will carry.
if (request.method === 'POST' && requestPath === '/font-upload') {
const name = path.basename(request.headers['x-font-filename'] || '');
if (!name || !FONT_EXTENSIONS.has(path.extname(name).toLowerCase())) {
sendJson(response, 400, { error: 'Expected a .woff2, .woff, .ttf, or .otf filename' });
return;
}
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, { path: path.join('.impeccable/design-interview/fonts', name) });
return;
}
if (request.method !== 'GET') {
sendJson(response, 405, { error: 'Method not allowed' });
return;
@@ -232,6 +260,16 @@ async function handleRequest(request, response) {
await serveFile(response, options.cuesDir, cueName, ['.png']);
return;
}
// Uploaded faces are read back so the specimen can render in them.
if (requestPath.startsWith('/fonts/')) {
const fontName = requestPath.slice('/fonts/'.length);
if (!fontName || fontName.includes('/')) {
sendJson(response, 404, { error: 'Not found' });
return;
}
await serveFile(response, fontsDir, fontName, [...FONT_EXTENSIONS]);
return;
}
const assetPath = requestPath === '/' ? 'index.html' : requestPath.slice(1);
await serveFile(response, pickerDir, assetPath);