mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
update
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 picker’s 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 picker’s 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
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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)}/`);
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user