Files
pbakaus_impeccable/picker/scripts/palette-picker.js
T
2026-09-01 10:02:40 +05:00

1874 lines
75 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { contrastInk, contrastInkHex, formatOklch, hexToOklch, oklchToHex, readableOn, seedToRoles } from './color.js';
const ROLES = ['primary', 'secondary', 'tertiary', 'neutral'];
const screen = document.querySelector('[data-screen="02"]');
const $ = (selector, root = screen) => root.querySelector(selector);
const $$ = (selector, root = screen) => root.querySelectorAll(selector);
const scroller = $('[data-deck-scroll]');
const points = $('[data-deck-points]');
const layer = $('[data-deck-cards]');
const count = $('[data-deck-count]');
const panel = $('.picker-palette-panel');
const hint = $('[data-palette-hint]');
const ringGuide = $('[data-ring-guide]');
const loupe = $('[data-loupe]');
let preview = $('.picker-preview');
const typePreview = document.querySelector('[data-type-preview]');
const fontOptions = document.querySelector('[data-font-options]');
const pairTemplate = document.querySelector('[data-pair-card]');
const scaleOptions = document.querySelector('[data-scale-options]');
const scaleSheet = document.querySelector('[data-scale-sheet]');
const states = new Map();
const canvases = new WeakMap();
let cards = [];
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.',
paragraph: '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: 'Built for the work at hand',
},
preview: {
brand: 'Ab',
nav: ['Product', 'Pricing', 'Docs', 'About'],
navAction: 'Sign in',
menuAction: 'Menu',
ctaPrimary: 'Get started',
ctaSecondary: 'Learn more',
proof: ['Fast to set up', 'Works anywhere', 'No lock-in', 'Free to try'],
sectionTitle: 'Everything in one place',
sectionLink: 'Read the guide',
gallery: [
{ title: 'Overview', meta: 'Start here' },
{ title: 'Library', meta: 'Browse all' },
{ title: 'Reports', meta: 'See results' },
],
footerLinks: ['Product', 'Company', 'Support', 'Legal'],
footerMark: '© Your product',
},
pairs: [
{
id: 'source-editorial',
name: 'Source editorial',
heading: { family: 'Source Serif 4', weight: 600 },
body: { family: 'Source Sans 3', weight: 400 },
why: 'Considered and editorial',
},
{
id: 'literary-clarity',
name: 'Literary clarity',
heading: { family: 'Libre Baskerville', weight: 700 },
body: { family: 'Libre Franklin', weight: 400 },
why: 'Bookish and plain-spoken',
},
{
id: 'warm-structure',
name: 'Warm structure',
heading: { family: 'Bitter', weight: 600 },
body: { family: 'Cabin', weight: 400 },
why: 'Sturdy slab warmth',
},
{
id: 'bold-utility',
name: 'Bold utility',
heading: { family: 'Archivo Black', weight: 400 },
body: { family: 'Archivo', weight: 400 },
why: 'Headlines that shout',
},
{
id: 'technical-signal',
name: 'Technical signal',
heading: { family: 'Azeret Mono', weight: 600 },
body: { family: 'Noto Sans', weight: 400 },
why: 'Machined and precise',
},
{
id: 'classical-poise',
name: 'Classical poise',
heading: { family: 'Marcellus', weight: 400 },
body: { family: 'Karla', weight: 400 },
why: 'Inscriptional and formal',
},
],
};
const roleMap = (value) => Object.fromEntries(ROLES.map((role) => [role, value(role)]));
const card = () => cards[current];
const state = () => states.get(card().id);
const dismissRingGuide = () => ringGuide.setAttribute('aria-hidden', 'true');
const serifFamily = /serif|mincho|baskerville|bitter|marcellus|slab|antiqua|garamond|didot|bodoni/i;
const fontStack = (family) => `"${family.replaceAll('"', '\\"')}", ${serifFamily.test(family) ? 'serif' : 'sans-serif'}`;
function createState(item) {
const colors = item.type === 'cue'
? roleMap((role) => (item.palette[role].snapped || item.palette[role].hex).toUpperCase())
: seedToRoles(item);
return {
colors,
detached: roleMap(() => false),
rings: roleMap(() => [50, 50]),
};
}
function sourceCanvas(image) {
let canvas = canvases.get(image);
if (canvas) return canvas;
canvas = document.createElement('canvas');
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
canvas.getContext('2d', { willReadFrequently: true }).drawImage(image, 0, 0);
canvases.set(image, canvas);
return canvas;
}
function syncRings(item) {
if (item.type !== 'cue') return;
const itemState = states.get(item.id);
$$('.picker-ring', item.node).forEach((ring) => {
const role = ring.dataset.role;
const [x, y] = itemState.rings[role];
ring.style.setProperty('--x', `${x}%`);
ring.style.setProperty('--y', `${y}%`);
ring.style.setProperty('--marker-color', itemState.colors[role]);
ring.setAttribute('aria-valuetext', itemState.colors[role]);
ring.toggleAttribute('data-detached', itemState.detached[role]);
});
}
function drawLoupe(ring, item, image) {
const [x, y] = states.get(item.id).rings[ring.dataset.role];
const source = sourceCanvas(image);
const canvas = $('canvas', loupe);
const context = canvas.getContext('2d');
const px = x / 100 * (source.width - 1);
const py = y / 100 * (source.height - 1);
const crop = Math.max(8, Math.min(source.width, source.height) / 128);
context.clearRect(0, 0, 80, 80);
context.imageSmoothingEnabled = false;
context.drawImage(source, px - crop / 2, py - crop / 2, crop, crop, 0, 0, 80, 80);
const stage = loupe.parentElement.getBoundingClientRect();
const box = ring.getBoundingClientRect();
loupe.style.left = `${box.left - stage.left + box.width / 2}px`;
loupe.style.top = `${box.top - stage.top}px`;
loupe.dataset.visible = '';
}
function renderBand(role) {
const hex = state().colors[role];
const band = $(`[data-band="${role}"]`, panel);
band.style.setProperty('--band-color', hex);
band.style.setProperty('--band-ink', contrastInk(hex));
$('output', band).textContent = hex;
$('input', band).value = hex;
renderPreview();
}
function renderPreview() {
// The preview is swapped for the chosen mode's variant before the deck is
// dealt, so this runs at least once with no card to read a color off.
if (!cards.length) return;
for (const role of ROLES) preview.style.setProperty(`--pv-${role}`, state().colors[role]);
preview.style.setProperty('--pv-n-ink', contrastInk(state().colors.neutral));
}
/* The prefix exists for the strategy stage, which needs the committed colors
under names its own CSS never rewrites: the remap there reads these to know
what was chosen, and reading the live --pv-* would read its own output. */
function syncCommittedPalette(target, prefix = 'pv') {
const committed = roleMap((role) => $(`[name="palette-${role}"]`).value);
if (!target || Object.values(committed).some((hex) => !hex)) return;
const set = (name, value) => target.style.setProperty(`--${prefix}-${name}`, value);
for (const role of ROLES) set(role, committed[role]);
// One ink per fill a preview can paint a label on: the strategy decides
// which of the three carries the button on any given artboard.
set('n-ink', contrastInk(committed.neutral));
set('p-ink', contrastInk(committed.primary));
set('t-ink', contrastInk(committed.tertiary));
// And one reading version of each accent that the type artboards set words
// in, per ground it can land on: the neutral page, or the primary once the
// strategy drenches the page in it.
set('p-on-n', readableOn(committed.primary, committed.neutral));
set('t-on-n', readableOn(committed.tertiary, committed.neutral));
set('t-on-p', readableOn(committed.tertiary, committed.primary));
// Labels on filled buttons: hue is the role that paints the fill, ground is
// that same fill, not the page neutral that produced the 1.63:1 regression.
set('p-on-p', readableOn(committed.primary, committed.primary));
set('t-on-t', readableOn(committed.tertiary, committed.tertiary));
set('p-on-i', readableOn(committed.primary, contrastInkHex(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) {
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')
))
&& (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) {
return {
...manifest,
preview: { ...FALLBACK_FONTS.preview, ...manifest.preview },
};
}
function isFontManifest(value) {
return value?.version === 1
&& typeof value.specimen?.headline === 'string'
&& (!value.preview || isPreviewCopy(value.preview))
&& value.pairs?.length === 6
&& value.pairs.every((pair) => (
typeof pair.id === 'string'
&& typeof pair.name === 'string'
&& typeof pair.heading?.family === 'string'
&& Number.isFinite(pair.heading?.weight)
&& typeof pair.body?.family === 'string'
&& Number.isFinite(pair.body?.weight)
&& typeof pair.why === 'string'
&& (!pair.preview || isPreviewCopy(pair.preview))
));
}
function loadFontStylesheet(pairs) {
const families = new Map();
const addWeight = (family, weight) => {
if (!families.has(family)) families.set(family, new Set());
families.get(family).add(weight);
};
for (const pair of pairs) {
addWeight(pair.heading.family, pair.heading.weight);
addWeight(pair.body.family, pair.body.weight);
addWeight(pair.body.family, 700);
}
const query = [...families].map(([family, weights]) => {
const name = encodeURIComponent(family).replaceAll('%20', '+');
return `family=${name}:wght@${[...weights].sort((a, b) => a - b).join(';')}`;
}).join('&');
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = `https://fonts.googleapis.com/css2?${query}&display=swap`;
link.dataset.pickerFonts = '';
document.head.append(link);
}
function fillIndexed(root, selector, values) {
if (!root) return;
root.querySelectorAll(selector).forEach((node, index) => {
node.textContent = values[index] ?? '';
});
}
function fillGallery(root, gallery) {
if (!root) return;
root.querySelectorAll('.ps-gallery-item').forEach((item, index) => {
const title = item.querySelector('[data-type-gallery-title]');
const meta = item.querySelector('[data-type-gallery-meta]');
if (title) title.textContent = gallery[index]?.title ?? '';
if (meta) meta.textContent = gallery[index]?.meta ?? '';
});
}
function syncFontPair(pair) {
const manifest = fontManifest;
const preview = {
...FALLBACK_FONTS.preview,
...manifest.preview,
...pair.preview,
};
const specimen = { ...manifest.specimen, ...pair.specimen };
const desktop = typePreview.querySelector('.ps-desktop');
const phoneBody = typePreview.querySelector('.ps-phone-body');
const phoneFooter = typePreview.querySelector('.ps-phone-footer');
// The scale sheet is set in the pair chosen here, so it travels with it.
for (const target of [typePreview, scaleSheet]) {
target.style.setProperty('--pt-heading', fontStack(pair.heading.family));
target.style.setProperty('--pt-body', fontStack(pair.body.family));
target.style.setProperty('--pt-heading-weight', pair.heading.weight);
}
for (const node of document.querySelectorAll('[data-type-brand]')) node.textContent = preview.brand;
fillIndexed(desktop, '[data-type-nav]', preview.nav);
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 = LOREM.sentence;
document.querySelectorAll('[data-type-cta-primary]').forEach((node) => {
node.textContent = preview.ctaPrimary;
});
document.querySelectorAll('[data-type-cta-secondary]').forEach((node) => {
node.textContent = preview.ctaSecondary;
});
fillIndexed(desktop, '[data-type-proof]', preview.proof);
fillIndexed(phoneBody, '[data-type-proof]', preview.proof);
document.querySelectorAll('[data-type-section-title]').forEach((node) => {
node.textContent = preview.sectionTitle;
});
for (const node of document.querySelectorAll('[data-type-section-body]')) node.textContent = LOREM.paragraph;
document.querySelectorAll('[data-type-section-link]').forEach((node) => {
node.textContent = preview.sectionLink;
});
fillGallery(desktop, preview.gallery);
fillGallery(phoneBody, preview.gallery);
fillIndexed(desktop.querySelector('.ps-footer'), '[data-type-footer-link]', preview.footerLinks);
fillIndexed(phoneFooter, '[data-type-footer-link]', preview.footerLinks);
document.querySelectorAll('[data-type-footer-mark]').forEach((node) => {
node.textContent = preview.footerMark;
});
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 || '';
}
/* Where each pair belongs when it is not the chosen one. The rail renders a
rotation of this list rather than the list itself, so hoisting the answer
to the top never loses the order the rest were dealt in. An uploaded pair
joins at the front: it is the one the user made. */
const pairOrder = [];
function addPairCard(pair, { checked = false, first = 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} for headings with ${pair.body.family} for body text. ${pair.why}`,
);
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 (first) pairOrder.unshift(node);
else pairOrder.push(node);
fontOptions.append(node);
return node;
}
function removePairCard(node) {
const index = pairOrder.indexOf(node);
if (index !== -1) pairOrder.splice(index, 1);
node.remove();
}
function renderFontPairs(manifest, fallback) {
fontManifest = normalizeFontManifest(manifest);
fontOptions.toggleAttribute('data-fallback', fallback);
manifest.pairs.forEach((pair, index) => addPairCard(pair, { checked: index === 0 }));
loadFontStylesheet(manifest.pairs);
syncFontPair(manifest.pairs[0]);
applyHoist();
}
fontOptions.onchange = ({ target }) => {
if (!target.matches('input[name="font-pair"]')) return;
const pair = fontManifest.pairs.find(({ id }) => id === target.value);
if (pair) syncFontPair(pair);
requestHoist();
};
/* Scroll by whole rows so an option never ends up half in frame, and disable
an arrow at the end it points to, since a live arrow that does nothing is
the reason the list looked unscrollable in the first place. */
function wireListScroll(list) {
const buttons = [...list.closest('.picker-type-rail').querySelectorAll('[data-list-scroll]')];
const sync = () => {
const room = list.scrollHeight - list.clientHeight;
for (const button of buttons) {
const down = button.dataset.listScroll === '1';
const spent = down ? list.scrollTop >= room - 1 : list.scrollTop <= 1;
button.disabled = room < 2 || spent;
}
};
for (const button of buttons) {
button.onclick = () => {
const step = list.querySelector('.picker-strategy-option')?.offsetHeight || 100;
list.scrollBy({ top: step * Number(button.dataset.listScroll), behavior: 'smooth' });
};
}
list.addEventListener('scroll', sync, { passive: true });
new ResizeObserver(sync).observe(list);
return sync;
}
const syncScrollButtons = wireListScroll(fontOptions);
wireListScroll(scaleOptions);
/* Screens where the cursor previews and the click commits need one rule for
what the preview falls back to, and `focusout` on its own is not it. Clicking
a row blurs whatever held focus before the browser focuses that row's input,
and the intermediate event carries no relatedTarget, so a listener that
trusts it shows the old answer for a frame at the exact moment the user picks
a new one. Waiting a frame lets the pointer settle the question: still inside
the list means the user is browsing and the preview is already right. */
function restWhenIdle(list, rest) {
let queued;
return () => {
cancelAnimationFrame(queued);
queued = requestAnimationFrame(() => {
if (list.matches(':hover')) return;
rest(list.contains(document.activeElement) ? document.activeElement : null);
});
};
}
/* The chosen pair takes the top of the rail, so the answer is the first thing
the list shows and the rest keep their dealt order underneath it.
The move is not made at the moment of choosing, and that is the whole
design. Reordering under a live cursor drags the row the user just clicked
out from under the pointer and parks a different pair where the next click
is already aimed. Reordering on a radio group's arrow keys is worse: every
press both moves focus and commits, so a list that re-sorts per press
re-sorts between presses and the group cannot be crossed at all.
So the rail reorders only while nobody is working it. A selection sets the
request; the pointer leaving, or focus leaving, spends it. Arriving on the
screen spends it too, which is the backstop if a settle is ever missed. */
const typeRail = fontOptions.closest('.picker-type-rail');
const RAIL_NAV_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', ' ']);
let hoistPending = false;
let pointerInRail = false;
let keyboardInRail = false;
let hoistFlash;
/* :hover covers the one case the pointer events miss: the cursor already
resting where the rail appears, which fires no enter of its own. */
const railBusy = () => pointerInRail
|| typeRail.matches(':hover')
|| (keyboardInRail && typeRail.contains(document.activeElement));
/* Nothing is under the cursor when the rows move, so the move itself needs no
transition to be readable. What it needs is somewhere for the eye to land
after it: the row that just took the top comes up bright and settles to its
resting checked state. Color only, so the acknowledgement cannot disturb
the list it is pointing at. */
function flashHoisted(node) {
clearTimeout(hoistFlash);
for (const other of pairOrder) other.removeAttribute('data-hoisted');
// Reading a layout property between the two writes is what restarts the
// animation on a row that is still marked from the previous hoist.
void node.offsetWidth;
node.dataset.hoisted = '';
hoistFlash = setTimeout(() => node.removeAttribute('data-hoisted'), 700);
}
function applyHoist({ force = false } = {}) {
hoistPending = false;
const chosen = fontOptions.querySelector('input[name="font-pair"]:checked')?.closest('.picker-type-option');
if (!chosen) return;
const wanted = [chosen, ...pairOrder.filter((node) => node !== chosen)];
const shown = [...fontOptions.querySelectorAll('.picker-type-option')];
const moved = wanted.some((node, index) => node !== shown[index]);
if (moved) {
// Rows move as real nodes so tab order, reading order, and what is on
// screen stay one order. Re-parenting can drop focus on the way, which
// would strand a keyboard user outside the group they were just in.
const focused = document.activeElement;
for (const node of wanted) fontOptions.append(node);
if (fontOptions.contains(focused) && document.activeElement !== focused) {
focused.focus({ preventScroll: true });
}
}
if (!moved && !force) return;
fontOptions.scrollTo({ top: 0 });
syncScrollButtons();
if (moved) flashHoisted(chosen);
}
function requestHoist() {
hoistPending = true;
if (!railBusy()) applyHoist();
}
function settleHoist() {
if (hoistPending && !railBusy()) applyHoist();
}
typeRail.addEventListener('pointerenter', () => {
pointerInRail = true;
});
// A frame of slack so :hover has resolved before the guard reads it.
typeRail.addEventListener('pointerleave', () => {
pointerInRail = false;
requestAnimationFrame(settleHoist);
});
typeRail.addEventListener('pointerdown', () => {
keyboardInRail = false;
});
typeRail.addEventListener('keydown', ({ key }) => {
if (RAIL_NAV_KEYS.has(key)) keyboardInRail = true;
});
typeRail.addEventListener('focusout', ({ relatedTarget }) => {
if (typeRail.contains(relatedTarget)) return;
keyboardInRail = false;
settleHoist();
});
/* Type scale.
The numbers are the real ones: step n is 16px * ratio^n, and a Golden Ratio
H1 really is 287px. The specimen cannot be, because 287px of "This is the
Golden Ratio scale" is eight times the width of the sheet, and a sheet fitted
to that H1 would set its paragraph at 2px.
So the rendering compresses the exponent by half, which keeps every scale in
its own character (a Minor Second sheet still reads as nearly flat, a Golden
Ratio one as dramatic) while bringing the range from 18x down to about 4x.
The measured fit below then scales the whole sheet if even that overflows.
The quoted px and rem stay untouched, which is the point of showing them. */
const SCALE_BASE = 16;
const SCALE_BODY_PX = 13;
const SCALE_COMPRESSION = 0.5;
const scaleRows = [...scaleSheet.querySelectorAll('[data-scale-row]')];
const scaleRatioInput = document.querySelector('[name="type-scale-ratio"]');
const trimZeros = (value) => value.replace(/\.0+$/, '').replace(/(\.\d*[1-9])0+$/, '$1');
/* One pass is exact: every size is linear in the fit factor, and the row boxes
the text has to fit inside do not move when it changes. */
function fitScaleSheet() {
scaleSheet.style.setProperty('--ts-fit', '1');
if (!scaleSheet.clientHeight) return;
let over = 1;
for (const row of scaleRows) {
const sample = row.querySelector('[data-scale-sample]');
over = Math.max(
over,
sample.scrollWidth / Math.max(sample.clientWidth, 1),
sample.scrollHeight / Math.max(row.clientHeight, 1),
);
}
if (over > 1.001) scaleSheet.style.setProperty('--ts-fit', (1 / over).toFixed(4));
}
function syncTypeScale(input) {
const ratio = Number(input.dataset.ratio);
const name = input.dataset.scaleName;
for (const row of scaleRows) {
const step = Number(row.dataset.scaleRow);
const px = SCALE_BASE * ratio ** step;
row.style.setProperty('--ts-size', (SCALE_BODY_PX * ratio ** (step * SCALE_COMPRESSION)).toFixed(3));
row.querySelector('[data-scale-sample]').textContent = `This is the ${name} scale`;
row.querySelector('[data-scale-px]').textContent = `${Math.round(px)}px`;
row.querySelector('[data-scale-rem]').textContent = `${trimZeros((px / SCALE_BASE).toFixed(2))}rem`;
}
scaleRatioInput.value = ratio.toFixed(3);
fitScaleSheet();
}
scaleOptions.onchange = ({ target }) => {
if (target.matches('input[name="type-scale"]')) syncTypeScale(target);
};
new ResizeObserver(fitScaleSheet).observe(scaleSheet);
// Every face swap changes the width of the same string, fit included.
document.fonts?.addEventListener('loadingdone', fitScaleSheet);
syncTypeScale(scaleOptions.querySelector('input:checked'));
/* Screen 11: the icon specimen. Every pack draws the same twenty-four concepts,
so the sheet compares hands rather than catalogs. The drawings are vendored
at build time and fetched on first arrival: eleven packs of markup is more
than the first paint should carry for a screen most runs reach late.
The cursor previews and the click commits, the same contract screen 03 uses
for color strategies. What differs is that no CSS can swap a drawing, so the
preview is painted here rather than remapped through custom properties. */
const iconOptions = document.querySelector('[data-icon-options]');
const iconSheet = document.querySelector('[data-icon-sheet]');
const iconField = iconSheet.querySelector('[data-icon-field]');
const iconStrip = iconSheet.querySelector('[data-icon-strip]');
const iconPackInputs = ['name', 'license', 'url'].map((key) => [
key,
document.querySelector(`[name="icon-pack-${key}"]`),
]);
let iconPacks;
let iconRequest;
let iconPainted;
wireListScroll(iconOptions);
const checkedIconPack = () => iconOptions.querySelector('input[name="icon-pack"]:checked');
const iconRowInput = (node) => node?.closest('.picker-icon-row')?.querySelector('input');
/* Packs disagree on grid size and on whether they are drawn as strokes or as
filled paths, so the wrapper carries the pack's own viewBox and paint rather
than one house style the drawings were never made for. */
function iconSvg(pack, glyph) {
const attrs = Object.entries(pack.attrs)
.map(([key, value]) => `${key}="${value}"`)
.join(' ');
return `<svg viewBox="${pack.viewBox}" ${attrs} aria-hidden="true">${glyph.body}</svg>`;
}
function paintIconPack(id) {
const pack = iconPacks?.get(id);
if (!pack || iconPainted === id) return;
iconPainted = id;
delete iconField.dataset.empty;
iconField.innerHTML = pack.glyphs
.map((glyph) => `<span class="picker-icon-cell">${iconSvg(pack, glyph)}</span>`)
.join('');
iconStrip.innerHTML = pack.glyphs.map((glyph) => iconSvg(pack, glyph)).join('');
}
/* The answer carries the pack's name, license, and home page, not just its
slug: whoever reads the answers has to credit it without looking it up. */
function commitIconPack(input) {
for (const [key, field] of iconPackInputs) field.value = input.dataset[`pack${key[0].toUpperCase()}${key.slice(1)}`];
paintIconPack(input.value);
}
function loadIconPacks() {
iconRequest ??= fetch('/icon-packs.json')
.then((response) => (response.ok ? response.json() : Promise.reject()))
.then((data) => {
iconPacks = new Map(data.packs.map((pack) => [pack.id, pack]));
paintIconPack(checkedIconPack().value);
})
.catch(() => {
iconField.dataset.empty = '';
iconField.textContent = 'Icon sets could not be loaded.';
});
return iconRequest;
}
iconOptions.addEventListener('pointerover', (event) => {
const input = iconRowInput(event.target);
if (input) paintIconPack(input.value);
});
iconOptions.addEventListener('focusin', (event) => {
const input = iconRowInput(event.target);
if (input) paintIconPack(input.value);
});
/* With the pointer away, the keyboard is driving: the row it is on is the one
being asked about, and only once focus has left too is the answer itself. */
const restIconPreview = restWhenIdle(iconOptions, (focused) => {
paintIconPack(iconRowInput(focused)?.value ?? checkedIconPack().value);
});
iconOptions.addEventListener('pointerleave', restIconPreview);
iconOptions.addEventListener('focusout', restIconPreview);
iconOptions.onchange = ({ target }) => {
if (target.matches('input[name="icon-pack"]')) commitIconPack(target);
};
commitIconPack(checkedIconPack());
/* Screen 06: the motion scene. Its three energies are told apart by amplitude
on one shared 8s timeline, and the loudest difference between them, whether
the page arrives or is simply there, is over inside the first fifth of it.
A visitor who hovers a row five seconds into the loop is therefore comparing
two energies on press depth alone, which is why hovering restarts the scene:
the answer to "what does this one look like" is the scene from its first
frame. CSS cannot rewind an animation, so this is the one part of the screen
that is not a custom property.
Only a change of energy restarts it. Sliding the pointer across a row it is
already previewing would otherwise keep the page in a permanent entrance. */
const motionOptions = document.querySelector('[data-question="motion"] .picker-strategy-choices');
const motionScene = document.querySelector('.picker-preview-motion');
const checkedMotion = () => motionOptions.querySelector('input:checked').value;
let motionShown;
function replayMotion(energy) {
if (energy === motionShown) return;
motionShown = energy;
// The hover rules resolve on their own; this only puts the timeline back to
// its first frame, pseudo-elements and all.
for (const animation of motionScene.getAnimations({ subtree: true })) {
animation.cancel();
animation.play();
}
}
/* The restrained scene's pointer route is written in container units, but the
nav bars space themselves in fixed pixels, so where a given bar sits as a
fraction of the frame changes with the frame's size. The route is measured
off the live layout instead: one custom property per stop, re-resolved
whenever the frame resizes, so the pointer lands on the element that
reacts at every viewport. offsetLeft rather than getBoundingClientRect,
because the entrance animations translate the bands, and a route measured
mid-entrance would aim below the nav. */
const motionDesk = motionScene.querySelector('.ps-desktop');
function plotMotionRoute() {
if (!motionDesk.clientWidth) return;
// Summed up the offsetParent chain rather than read once: an element's
// offsets are relative to its nearest positioned ancestor, which for the
// buttons is not the frame.
const center = (el) => {
let x = el.offsetWidth / 2;
let y = el.offsetHeight / 2;
for (let node = el; node && node !== motionDesk; node = node.offsetParent) {
x += node.offsetLeft;
y += node.offsetTop;
}
return { x: (x / motionDesk.clientWidth) * 100, y: (y / motionDesk.clientHeight) * 100 };
};
const stop = (name, el) => {
const c = center(el);
motionScene.style.setProperty(name, `${c.x.toFixed(2)}cqw ${c.y.toFixed(2)}cqh`);
};
const nav1 = motionDesk.querySelector('.ps-nav-bars i:nth-child(1)');
stop('--mtr-nav1', nav1);
stop('--mtr-nav2', motionDesk.querySelector('.ps-nav-bars i:nth-child(2)'));
stop('--mtr-cta1', motionDesk.querySelector('.ps-actions i:first-child'));
stop('--mtr-cta2', motionDesk.querySelector('.ps-actions i:last-child'));
stop('--mtr-card1', motionDesk.querySelector('.ps-gallery-item:nth-child(1) > i'));
// The entry and exit point: straight above the first nav item, off-frame.
motionScene.style.setProperty('--mtr-entry', `${center(nav1).x.toFixed(2)}cqw -8cqh`);
}
new ResizeObserver(plotMotionRoute).observe(motionDesk);
const motionRowValue = (node) => node?.closest('.picker-strategy-option')?.querySelector('input').value;
motionOptions.addEventListener('pointerover', (event) => {
const value = motionRowValue(event.target);
if (value) replayMotion(value);
});
motionOptions.addEventListener('focusin', (event) => {
const value = motionRowValue(event.target);
if (value) replayMotion(value);
});
const restMotionPreview = restWhenIdle(motionOptions, (focused) => {
replayMotion(motionRowValue(focused) ?? checkedMotion());
});
motionOptions.addEventListener('pointerleave', restMotionPreview);
motionOptions.addEventListener('focusout', restMotionPreview);
motionOptions.addEventListener('change', ({ target }) => {
if (target.matches('input[name="motion-energy"]')) replayMotion(target.value);
});
/* 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}"]`);
const customSave = fontModal.querySelector('[data-font-custom-save]');
const customHint = fontModal.querySelector('[data-custom-hint]');
const FONT_SOURCE_SEP = '\n';
const FONT_FILE_EXTENSIONS = new Set(['.woff2', '.woff', '.ttf', '.otf']);
const customFilesByRole = { heading: [], body: [] };
function fontFileExtension(name) {
const index = name.lastIndexOf('.');
return index === -1 ? '' : name.slice(index).toLowerCase();
}
function isFontFile(file) {
return FONT_FILE_EXTENSIONS.has(fontFileExtension(file.name));
}
function setChosenFontFiles(input, files) {
const transfer = new DataTransfer();
for (const file of files) transfer.items.add(file);
input.files = transfer.files;
}
function sameFontFile(a, b) {
return a.name === b.name && a.size === b.size && a.lastModified === b.lastModified;
}
function mergeFontFiles(existing, incoming) {
const merged = [...existing];
for (const file of incoming) {
if (!merged.some((entry) => sameFontFile(entry, file))) merged.push(file);
}
return merged;
}
function renderCustomFileList(role) {
const list = fontModal.querySelector(`[data-custom-file-list="${role}"]`);
const files = customFilesByRole[role];
list.replaceChildren();
list.hidden = files.length === 0;
for (const file of files) {
const item = document.createElement('li');
item.className = 'picker-modal-file-item';
const name = document.createElement('span');
name.textContent = file.name;
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'picker-modal-file-remove';
remove.setAttribute('aria-label', `Remove ${file.name}`);
remove.textContent = '×';
remove.onclick = () => {
customFilesByRole[role] = customFilesByRole[role].filter((entry) => !sameFontFile(entry, file));
setChosenFontFiles(customFile(role), customFilesByRole[role]);
renderCustomFileList(role);
syncCustomSave();
customStatus.textContent = '';
};
item.append(name, remove);
list.append(item);
}
}
/* Either kind of source satisfies a role: a URL or at least one file. */
const roleHasSource = (role) => customFilesByRole[role].length > 0 || customUrl(role).value.trim() !== '';
/* Both roles are required. A heading-only sheet used to commit and let the
body fall back to whichever pair happened to be selected, which is a
substitution the user never asked for and never saw.
The hint carries the reason. A commit that is dead with no explanation
leaves the user clicking an inert control to find out why, so the footer
names the role that is still open and the button is never the only
feedback. It reads as instruction rather than correction: nothing here is
wrong yet, the sheet is just unfinished. */
function syncCustomSave() {
const missing = ['heading', 'body'].filter((role) => !roleHasSource(role));
customSave.disabled = missing.length > 0;
if (missing.length === 2) {
customHint.textContent = 'Heading and body each need a URL or a file.';
} else if (missing.length === 1) {
const role = missing[0] === 'heading' ? 'Heading' : 'Body';
customHint.textContent = `${role} still needs a URL or a file.`;
} else {
customHint.textContent = '';
}
}
function parseFontSources(source) {
if (!source) return [];
if (source.includes(FONT_SOURCE_SEP)) return source.split(FONT_SOURCE_SEP).filter(Boolean);
return [source];
}
document.querySelector('[data-font-custom-open]').onclick = () => {
customStatus.textContent = '';
for (const role of ['heading', 'body']) {
customFilesByRole[role] = [];
customFile(role).value = '';
setChosenFontFiles(customFile(role), []);
// The URL goes with the files. Clearing half the sheet leaves a field
// holding an address whose companion upload is already gone, and the
// commit would then read as available for work the user did last time.
customUrl(role).value = '';
renderCustomFileList(role);
}
syncCustomSave();
fontModal.showModal();
};
document.querySelector('[data-font-custom-close]').onclick = () => fontModal.close();
for (const role of ['heading', 'body']) {
customFile(role).onchange = ({ target }) => {
const incoming = [...target.files].filter(isFontFile);
if (incoming.length !== target.files.length) {
customStatus.textContent = 'Only .woff2, .woff, .ttf, and .otf files are accepted.';
} else {
customStatus.textContent = '';
}
customFilesByRole[role] = mergeFontFiles(customFilesByRole[role], incoming);
setChosenFontFiles(target, customFilesByRole[role]);
target.value = '';
renderCustomFileList(role);
syncCustomSave();
};
customUrl(role).oninput = syncCustomSave;
}
async function uploadFontFile(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 result.path;
}
async function resolveCustomFace(role) {
const files = customFilesByRole[role].filter(isFontFile);
if (files.length) {
const paths = await Promise.all(files.map((file) => uploadFontFile(file)));
return {
family: files[0].name.replace(/\.[^.]+$/, ''),
source: paths.join(FONT_SOURCE_SEP),
};
}
const url = customUrl(role).value.trim();
return url ? { family: url.split('/').pop().replace(/\.[^.]+$/, '') || 'Custom', source: url } : null;
}
customSave.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;
}
// The button is already gated on both roles; this is the same rule stated
// where the pair is built, so no path can assemble a half pair.
if (!heading || !body) {
customStatus.textContent = 'Add a URL or a file for both the heading and the body.';
return;
}
const pair = {
id: 'custom',
name: 'Custom',
heading,
body,
why: 'Your own faces',
};
fontManifest.pairs = [pair, ...fontManifest.pairs.filter(({ id }) => id !== 'custom')];
// A second upload replaces the first rather than stacking a second row of
// the same id, which the rail would then have to order against itself.
for (const node of [...pairOrder]) {
if (node.querySelector('input').value === 'custom') removePairCard(node);
}
addPairCard(pair, { checked: true, first: true });
loadCustomFace(pair);
syncFontPair(pair);
// The dialog holds the pointer and focus, so the rail is free to reorder.
applyHoist({ force: true });
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) || (!face.source.includes(FONT_SOURCE_SEP) && !/\.(woff2?|ttf|otf)(\?|$)/i.test(face.source))) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = face.source;
document.head.append(link);
continue;
}
for (const entry of parseFontSources(face.source)) {
const src = entry.startsWith('http') ? entry : `/fonts/${entry.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');
setTimeout(() => {
hint.textContent = hint.dataset[role];
hint.classList.remove('is-changing');
}, 90);
}
function setColor(role, hex, detached = true) {
const itemState = state();
itemState.colors[role] = hex.toUpperCase();
itemState.detached[role] = detached;
renderBand(role);
syncRings(card());
setActiveRole(role);
}
function sample(ring, item, image, x, y) {
const saved = states.get(item.id);
const source = sourceCanvas(image);
const role = ring.dataset.role;
x = Math.min(100, Math.max(0, x));
y = Math.min(100, Math.max(0, y));
const pixel = source.getContext('2d').getImageData(
Math.round(x / 100 * (source.width - 1)),
Math.round(y / 100 * (source.height - 1)),
1,
1,
).data;
const hex = `#${[pixel[0], pixel[1], pixel[2]].map((value) => value.toString(16).padStart(2, '0')).join('').toUpperCase()}`;
saved.rings[role] = [x, y];
saved.colors[role] = hex;
saved.detached[role] = false;
syncRings(item);
if (item === card()) renderBand(role);
setActiveRole(role);
drawLoupe(ring, item, image);
}
function wireRing(ring, item, image) {
const move = (e) => {
const box = image.getBoundingClientRect();
sample(ring, item, image, (e.clientX - box.left) / box.width * 100, (e.clientY - box.top) / box.height * 100);
};
ring.onpointerdown = (e) => {
if (e.button !== 0) return;
ring.focus();
ring.setPointerCapture(e.pointerId);
ring.dataset.dragging = '';
move(e);
};
ring.onpointermove = (e) => {
if (ring.hasPointerCapture(e.pointerId)) {
dismissRingGuide();
move(e);
}
};
ring.onpointerup = (e) => {
move(e);
ring.releasePointerCapture(e.pointerId);
delete ring.dataset.dragging;
if (document.activeElement !== ring) delete loupe.dataset.visible;
};
ring.onfocus = () => image.complete && drawLoupe(ring, item, image);
ring.onblur = () => {
if (!('dragging' in ring.dataset)) delete loupe.dataset.visible;
};
ring.onkeydown = (e) => {
const moves = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
if (!moves[e.key]) return;
e.preventDefault();
dismissRingGuide();
const step = e.shiftKey ? 5 : 1;
const [x, y] = states.get(item.id).rings[ring.dataset.role];
sample(ring, item, image, x + moves[e.key][0] * step, y + moves[e.key][1] * step);
};
}
function buildCard(item) {
const node = $(`[data-${item.type}-card]`).content.firstElementChild.cloneNode(true);
node.dataset.id = item.id;
const face = $('.picker-card-face', node);
if (item.type === 'seed') {
$$('span', face).forEach((stripe, index) => {
stripe.style.setProperty('--seed-color', states.get(item.id).colors[ROLES[index]]);
});
} else {
const image = $('img', face);
image.alt = `Visual cue ${item.id}`;
image.src = `/cues/${encodeURIComponent(item.id)}.png`;
$$('.picker-ring', face).forEach((ring) => {
const role = ring.dataset.role;
ring.setAttribute('aria-valuetext', states.get(item.id).colors[role]);
wireRing(ring, item, image);
});
image.addEventListener('load', () => {
item.defaultRings = roleMap((role) => {
const [x, y] = item.palette[role].at;
return [x / image.naturalWidth * 100, y / image.naturalHeight * 100];
});
states.get(item.id).rings = structuredClone(item.defaultRings);
syncRings(item);
if (item === card()) sourceCanvas(image);
});
}
item.node = node;
return node;
}
function closeTints() {
if (!openTint) return;
const role = openTint;
const item = $(`[data-band-item="${role}"]`, panel);
const strip = $('[data-tints]', item);
// Focus cannot stay on a button that is about to be hidden, and the control
// that opened the strip is the one the user is back to deciding about.
const held = strip.contains(document.activeElement);
delete item.dataset.tintOpen;
strip.hidden = true;
openTint = null;
const toggle = $(`[data-edit-tints="${role}"]`, panel);
toggle.setAttribute('aria-expanded', 'false');
toggle.dataset.tip = 'Edit tints';
if (held) toggle.focus();
}
function render() {
const active = card();
cards.forEach(({ node }, index) => {
const delta = index - current;
node.dataset.pos = Math.max(-2, Math.min(2, delta));
node.classList.toggle('is-far', Math.abs(delta) > 2);
node.setAttribute('aria-hidden', delta !== 0);
});
$('[data-deck-prev]').disabled = current === 0;
$('[data-deck-next]').disabled = current === cards.length - 1;
count.textContent = `${current + 1} / ${cards.length}`;
for (const role of ROLES) renderBand(role);
syncRings(active);
if (active.type === 'cue') {
const image = $('img', active.node);
if (image.complete && image.naturalWidth) sourceCanvas(image);
}
closeTints();
}
function browse(index) {
const next = Math.min(cards.length - 1, Math.max(0, index));
const behavior = matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth';
points.children[next].scrollIntoView({ behavior, block: 'start' });
}
function deckKeys(e) {
if (e.key === 'Escape' && openTint) {
e.preventDefault();
closeTints();
return;
}
const delta = { ArrowLeft: -1, ArrowRight: 1 }[e.key];
if (!delta) return;
if (e.target instanceof Element && e.target.closest('[role="slider"], input')) return;
// A band held by the keyboard owns the arrows until it is dropped.
if (drag?.keyboard) return;
e.preventDefault();
browse(current + delta);
}
const activate = (value) => document[value ? 'addEventListener' : 'removeEventListener']('keydown', deckKeys, true);
function openTints(role) {
closeTints();
openTint = role;
setActiveRole(role);
const item = $(`[data-band-item="${role}"]`, panel);
const strip = $('[data-tints]', item);
const current = state().colors[role];
const [L, C, H] = hexToOklch(current);
// The strip pivots on the band's color: the middle swatch is that color
// exactly, the three to its left step toward near-white, the three to its
// right toward near-black. Chroma eases off toward the ends so the
// extremes stay in gamut instead of clipping to a different hue.
$$('[data-tint]', strip).forEach((button, index) => {
const offset = index - 3;
const tintL = offset < 0
? L + (0.96 - L) * (-offset / 3)
: L - (L - 0.16) * (offset / 3);
const hex = offset === 0
? current
: oklchToHex([tintL, C * (1 - 0.3 * (Math.abs(offset) / 3)), H]);
button.dataset.tint = hex;
button.style.setProperty('--tint-color', hex);
button.setAttribute('aria-label', hex);
button.toggleAttribute('data-current', offset === 0);
});
strip.hidden = false;
item.dataset.tintOpen = '';
const toggle = $(`[data-edit-tints="${role}"]`, panel);
toggle.setAttribute('aria-expanded', 'true');
toggle.dataset.tip = 'Close tints';
$('button', strip)?.focus();
}
/* Reordering the palette.
The four slots keep their roles. Their labels sit in the feet and hold still;
what a drag carries is the color, so dropping the neutral band in second
place is what makes that color the secondary.
The bands travel and the feet do not, which is also what makes the swap
invisible. When a band lands, every slot on screen is already showing the
color it is about to be given, so the colors can be rearranged and the
transforms dropped in the same frame with nothing to see.
Pointer events rather than HTML5 drag and drop: the native API cannot be
animated and behaves badly by touch. */
const REORDER_MS = 220;
const LIFT_SCALE = 1.02;
const EASE = getComputedStyle(document.documentElement).getPropertyValue('--ks-ease').trim() || 'ease';
const REORDER_KEYS = new Set([' ', 'Enter', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'Escape']);
const moves = new WeakMap();
// The editable strip on the palette screen, and its reading copy under the
// strategy screen's choices. Which one a drag started in decides how far the
// result has to be carried.
const paletteBands = $('.picker-bands', panel);
const strategyBands = document.querySelector('[data-band-scope="strategy"]');
const reduceMotion = () => matchMedia('(prefers-reduced-motion: reduce)').matches;
const bandNodes = (scope) => ROLES.map((role) => $(`[data-band="${role}"]`, scope));
const gripNodes = (scope) => ROLES.map((role) => $(`[data-grip="${role}"]`, scope));
const roleLabel = (index, scope) => $(`[data-band-item="${ROLES[index]}"] .picker-band-foot h2`, scope).textContent;
const announceReorder = (scope, message) => {
const status = $('[data-reorder-status]', scope.closest('.picker-screen') ?? scope);
if (status) status.textContent = message;
};
let drag = null;
let landing = null;
/* Which band stands in which slot while the lifted one is headed for `to`:
the entry at slot n is the index of the band that belongs there. */
function slotOrder(from, to) {
const order = ROLES.map((_, index) => index);
order.splice(to, 0, ...order.splice(from, 1));
return order;
}
/* FLIP: the node is put where it belongs first and then played back from where
it was, so nothing downstream ever measures a half-finished position. */
function travel(node, previous, offset, animate) {
node.style.transform = offset ? `translateX(${offset}px)` : '';
moves.get(node)?.cancel();
if (!animate) return;
moves.set(node, node.animate(
[{ transform: `translateX(${previous}px)` }, { transform: `translateX(${offset}px)` }],
{ duration: REORDER_MS, easing: EASE },
));
}
function lift(offset, animate) {
const node = drag.nodes[drag.from];
const previous = drag.offsets[drag.from];
const at = (value) => `translateX(${value}px) scale(${LIFT_SCALE})`;
drag.offsets[drag.from] = offset;
node.style.transform = at(offset);
moves.get(node)?.cancel();
if (!animate) return;
moves.set(node, node.animate(
[{ transform: at(previous) }, { transform: at(offset) }],
{ duration: REORDER_MS, easing: EASE },
));
}
function openGap(to, animate) {
slotOrder(drag.from, to).forEach((index, slot) => {
if (index === drag.from) return;
const offset = drag.homes[slot].left - drag.homes[index].left;
if (offset === drag.offsets[index]) return;
travel(drag.nodes[index], drag.offsets[index], offset, animate);
drag.offsets[index] = offset;
});
drag.to = to;
}
function nearestSlot(shift) {
const { homes, from } = drag;
const center = homes[from].left + homes[from].width / 2 + shift;
const away = (index) => Math.abs(homes[index].left + homes[index].width / 2 - center);
return homes.reduce((best, home, index) => (away(index) < away(best) ? index : best), 0);
}
/* The rearranged palette goes back through the same path a reset takes: the
card's saved colors are rewritten and the screen is rendered from them, so
the readouts, the rings, the preview, and the values the select button copies
into the form all still come off one source. */
function commitOrder(from, to, scope) {
const saved = state();
const colors = {};
const detached = {};
const rings = {};
slotOrder(from, to).forEach((index, slot) => {
const role = ROLES[slot];
const source = ROLES[index];
colors[role] = saved.colors[source];
detached[role] = saved.detached[source];
// The ring that sampled the color travels with it, so no marker is left
// sitting on a pixel it no longer matches.
rings[role] = saved.rings[source];
});
Object.assign(saved, { colors, detached, rings });
render();
// `render` reaches the palette screen, which draws itself from the deck. The
// rest of the run reads the palette out of the committed fields instead, so a
// reorder made on the strategy screen has to rewrite those too or it would
// last exactly as long as the screen it happened on.
if (scope !== paletteBands) recommitPalette();
}
/* The fields the select button writes, rewritten from the state the bands were
just rearranged into, and everything that reads them repainted. Silent before
a palette has been chosen: there is nothing to keep in step yet. */
function recommitPalette() {
if (!$('[name="palette-source"]').value) return;
for (const role of ROLES) $(`[name="palette-${role}"]`).value = state().colors[role];
paintStrategyBands();
paintStage();
for (const artboard of document.querySelectorAll('.picker-screen[data-active] [data-artboard]')) {
syncCommittedPalette(artboard);
}
}
/* The strategy screen's band is a reading of the committed palette rather than
of the deck, which is also what makes it correct after a reorder on either
screen: both end in the fields this paints from. */
function paintStrategyBands() {
const committed = roleMap((role) => $(`[name="palette-${role}"]`).value);
if (Object.values(committed).some((hex) => !hex)) return;
for (const role of ROLES) {
const band = $(`[data-band="${role}"]`, strategyBands);
band.style.setProperty('--band-color', committed[role]);
band.style.setProperty('--band-ink', contrastInk(committed[role]));
$('output', band).textContent = committed[role];
}
}
function reorderedSummary(from, to) {
const { colors } = state();
return slotOrder(from, to)
.map((index, slot) => `${ROLES[slot]} ${colors[ROLES[index]]}`)
.join(', ');
}
function beginDrag(index, keyboard, scope) {
// A second grab with one still in the air puts the first back first, so no
// measurement is taken off a row that is standing somewhere temporary.
if (drag) endDrag(true);
landing?.();
closeTints();
const nodes = bandNodes(scope);
for (const node of nodes) {
moves.get(node)?.cancel();
node.style.transform = '';
}
drag = {
from: index,
to: index,
keyboard,
scope,
nodes,
grips: gripNodes(scope),
homes: nodes.map((node) => node.getBoundingClientRect()),
offsets: ROLES.map(() => 0),
id: card().id,
pointerId: null,
startX: 0,
};
nodes[index].dataset.dragging = '';
drag.grips[index].dataset.dragging = '';
lift(0, false);
}
function endDrag(cancel, after) {
const { nodes, grips, from, to, homes, id, scope } = drag;
const target = cancel ? 0 : homes[to].left - homes[from].left;
if (cancel) openGap(from, !reduceMotion());
drag = null;
delete nodes[from].dataset.dragging;
delete grips[from].dataset.dragging;
landing = () => {
landing = null;
// The deck can be browsed under a drag, and another card's colors are not
// the ones that were picked up.
if (!cancel && to !== from && card().id === id) commitOrder(from, to, scope);
for (const node of nodes) {
moves.get(node)?.cancel();
node.style.transform = '';
}
after?.();
};
if (reduceMotion()) {
landing();
return;
}
const node = nodes[from];
const start = node.style.transform;
const end = `translateX(${target}px) scale(1)`;
node.style.transform = end;
const animation = node.animate(
[{ transform: start }, { transform: end }],
{ duration: REORDER_MS, easing: EASE },
);
moves.set(node, animation);
animation.finished.then(() => landing?.(), () => {});
}
function finishDrag(keyboard) {
const { from, to, scope } = drag;
if (to === from) {
endDrag(true);
return;
}
const summary = `Palette reordered. ${reorderedSummary(from, to)}`;
// Focus follows the color rather than the slot it left, and only once the
// band has landed: a focus ring drawn on a band in flight points at nothing.
endDrag(false, keyboard ? () => gripNodes(scope)[to].focus() : null);
announceReorder(scope, summary);
}
const gripOf = (target) => (target instanceof Element ? target.closest('[data-grip]') : null);
const scopeOf = (grip) => grip.closest('.picker-bands');
/* Both strips reorder, and they reorder the same palette. The handlers are
written once against whichever strip the grip that was grabbed belongs to. */
function wireReorder(root) {
root.addEventListener('pointerdown', (event) => {
const grip = gripOf(event.target);
if (!grip || event.button !== 0 || !cards.length) return;
// Keeps the press off the focus ring and out of a text selection. The grip is
// reached by keyboard through Tab, and the pointer drag needs neither.
event.preventDefault();
beginDrag(ROLES.indexOf(grip.dataset.grip), false, scopeOf(grip));
drag.pointerId = event.pointerId;
drag.startX = event.clientX;
grip.setPointerCapture(event.pointerId);
});
root.addEventListener('pointermove', (event) => {
if (!drag || drag.keyboard || event.pointerId !== drag.pointerId) return;
const { homes, from } = drag;
// Held inside the strip: a band that can be flown across the screen covers
// the image the colors were pulled from and says nothing more than this does.
const shift = Math.min(
homes.at(-1).left - homes[from].left,
Math.max(homes[0].left - homes[from].left, event.clientX - drag.startX),
);
lift(shift, false);
const to = nearestSlot(shift);
if (to !== drag.to) openGap(to, !reduceMotion());
});
root.addEventListener('pointerup', (event) => {
if (!drag || drag.keyboard || event.pointerId !== drag.pointerId) return;
finishDrag(false);
});
root.addEventListener('pointercancel', (event) => {
if (!drag || drag.keyboard || event.pointerId !== drag.pointerId) return;
endDrag(true);
});
root.addEventListener('keydown', (event) => {
const grip = gripOf(event.target);
if (!grip || !REORDER_KEYS.has(event.key) || !cards.length) return;
const scope = scopeOf(grip);
const held = Boolean(drag?.keyboard);
const grab = event.key === ' ' || event.key === 'Enter';
if (!held && (drag || !grab)) return;
event.preventDefault();
if (!held) {
beginDrag(ROLES.indexOf(grip.dataset.grip), true, scope);
announceReorder(scope, `${roleLabel(drag.from, scope)} color lifted, position ${drag.from + 1} of ${ROLES.length}. Arrow keys move it, space drops it, escape puts it back.`);
return;
}
if (grab) {
finishDrag(true);
return;
}
if (event.key === 'Escape') {
endDrag(true);
announceReorder(scope, 'Reorder cancelled.');
return;
}
const step = { ArrowLeft: -1, ArrowRight: 1, Home: -ROLES.length, End: ROLES.length }[event.key];
const to = Math.min(ROLES.length - 1, Math.max(0, drag.to + step));
if (to === drag.to) return;
openGap(to, !reduceMotion());
lift(drag.homes[to].left - drag.homes[drag.from].left, !reduceMotion());
announceReorder(scope, `Position ${to + 1} of ${ROLES.length}.`);
});
// A lifted band with nobody holding it would keep answering arrow keys aimed
// at whatever took the focus.
root.addEventListener('focusout', (event) => {
if (!drag?.keyboard || event.target !== drag.grips[drag.from]) return;
const scope = drag.scope;
endDrag(true);
announceReorder(scope, 'Reorder cancelled.');
});
}
wireReorder(panel);
wireReorder(strategyBands);
panel.onpointerover = panel.onfocusin = ({ target }) => {
const band = target.closest('[data-band]');
if (band) setActiveRole(band.dataset.band);
};
panel.oninput = ({ target }) => {
if (target.matches('[data-color-input]')) setColor(target.dataset.colorInput, target.value);
};
panel.onclick = async (e) => {
const data = e.target.closest('button')?.dataset;
if (!data) return;
if (data.copyColor) {
const hex = state().colors[data.copyColor];
await navigator.clipboard.writeText(`${hex}\n${formatOklch(hex)}`);
const tip = data.tip;
data.tip = 'Copied';
setTimeout(() => data.tip = tip, 1200);
} else if (data.editTints) {
// The button that opened the strip closes it, so the × is a second way out
// rather than the only one.
if (openTint === data.editTints) closeTints();
else openTints(data.editTints);
} else if (data.customColor) $(`[data-color-input="${data.customColor}"]`, panel).click();
else if (data.tint) setColor(openTint, data.tint);
else if ('closeTints' in data) closeTints();
else if ('reset' in data) {
const item = card();
const fresh = createState(item);
if (item.type === 'cue' && item.defaultRings) fresh.rings = structuredClone(item.defaultRings);
states.set(item.id, fresh);
render();
} else if ('selectPalette' in data) {
const item = card();
$('[name="palette-source"]').value = item.id;
for (const role of ROLES) $(`[name="palette-${role}"]`).value = state().colors[role];
// Painted before the screen changes rather than on arrival: the next screen
// is captured for the transition as it is handed over, and a strip still
// holding last run's colors is what would be captured.
paintStrategyBands();
paintStage();
}
};
/* The select button lives with the screen's actions rather than inside the
fieldset, so the delegated handler above never reached it and the palette
left the run as four empty fields. It commits the same state the bands are
rendered from, reordered or not, so it is that handler and not a copy. */
$('[data-select-palette]').addEventListener('click', panel.onclick);
$('[data-deck-prev]').onclick = () => browse(current - 1);
$('[data-deck-next]').onclick = () => browse(current + 1);
scroller.addEventListener('scroll', () => {
const height = points.firstElementChild?.offsetHeight || 1;
const next = Math.min(cards.length - 1, Math.round(scroller.scrollTop / height));
if (next !== current) {
const node = card().node;
node.dataset.exit = next > current ? 'left' : 'right';
setTimeout(() => delete node.dataset.exit, 280);
current = next;
delete card().node.dataset.exit;
render();
}
}, { passive: true });
document.addEventListener('picker:screenchange', (event) => {
activate(event.detail.screen === '02');
// Every artboard on the screen being shown, whatever question it belongs to,
// gets the committed palette. The scale sheet is deliberately not one: it is
// picker chrome in the picker's own theme, not a page in the user's palette.
const screenNode = document.querySelector(`.picker-screen[data-screen="${event.detail.screen}"]`);
for (const artboard of screenNode?.querySelectorAll('[data-artboard]') ?? []) {
syncCommittedPalette(artboard);
}
// Coming back to the strategy screen from further along, where the palette may
// have been reordered on the screen it was left on.
if (event.detail.screen === '03') {
paintStrategyBands();
paintStage();
} else {
/* Everywhere else previews the leading surface's answer, so the radio the
later screens read is parked there whenever 03 is off screen. Without
this the run would carry whichever surface was last on the tab, and a
strategy switched off for that surface would leave the answer empty. */
showSurface(chosenSurfaces()[0]?.value);
}
// Arriving is the quietest moment there is, so the rail settles here even
// if it is already in order: the chosen pair is the row you land on.
if (event.detail.screen === '04') {
keyboardInRail = false;
applyHoist({ force: true });
}
// A hidden screen has no animations to rewind, so the scene starts on the
// frame after the one that revealed it, not on the frame that asked.
if (event.detail.screen === '06') {
requestAnimationFrame(() => {
plotMotionRoute();
motionShown = null;
replayMotion(checkedMotion());
});
}
// A hidden sheet measures zero, so the fit can only be resolved on arrival.
// The scroll waits a frame: the screen change focuses the first control after
// this event, and that scrolls the list back to the top.
if (event.detail.screen === '05') {
fitScaleSheet();
requestAnimationFrame(() => {
scaleOptions.querySelector('input:checked')?.parentElement.scrollIntoView({ block: 'center' });
});
}
// The specimen is fetched the first time the screen is asked for, and the
// scroll waits a frame for the same reason screen 05's does.
if (event.detail.screen === '11') {
void loadIconPacks();
requestAnimationFrame(() => {
checkedIconPack()?.parentElement.scrollIntoView({ block: 'center' });
});
}
});
// Screen 01b: the surface tiles. Multi-select with a floor of one, so the
// answer can never arrive empty; the continue button holds that line while
// the markup's default (persuade) keeps scripted runs moving.
const modeInputs = [...document.querySelectorAll('input[name="surface-modes"]')];
const modesNext = document.querySelector('[data-modes-next]');
const syncModesNext = () => {
if (modesNext) modesNext.disabled = !modeInputs.some((input) => input.checked);
};
/* The palette is judged on a page, and which page that should be is answered
here: the palette screen shows the preview of the surface being designed.
Each tile already holds a finished drawing of its mode, so the chosen one is
lifted out of the tile rather than restated on screen 02, which is the only
way a new variant lands on both screens at once.
First in tile order, not first clicked. A multi-select answer has no other
stable primary, and click order would move the palette's test page around
for reasons the visitor cannot see. */
const modePreviews = modeInputs.map((input) => (
input.closest('.picker-mode-tile')?.querySelector('.picker-preview')
));
const landingPreview = preview.cloneNode(true);
let previewSource;
function syncModePreview() {
// Screen 03 draws every chosen surface, not just the leading one, so it is
// rebuilt from here: every path that changes the tiles already runs this.
syncSurfaces();
const chosen = modeInputs.findIndex((input) => input.checked);
// A tile drawn in something other than this component keeps the landing page,
// which is also the floor for the empty answer the continue button blocks.
const source = (chosen === -1 ? null : modePreviews[chosen]) ?? landingPreview;
if (source === previewSource) return;
previewSource = source;
const clone = source.cloneNode(true);
// Decorative on both screens, but the marker sits on the tile's wrapper
// rather than on the component, so it does not survive the lift by itself.
clone.setAttribute('aria-hidden', 'true');
for (const node of [clone, ...clone.querySelectorAll('[id]')]) node.removeAttribute('id');
preview.replaceWith(clone);
preview = clone;
renderPreview();
}
/* Screen 03 colors the surfaces that were chosen rather than one fixed page,
and it colors each of them separately: the answer that suits the marketing
page rarely suits the tool it sells. Every chosen tile's drawing is mounted
on the stage, one is shown, and a tab in the frame's corner carries between
them when there is more than one to carry between. */
const stage = document.querySelector('[data-strategy-stage]');
const surfaceTabs = document.querySelector('[data-surface-tabs]');
const strategyInputs = [...document.querySelectorAll('input[name="color-strategy"]')];
const strategyRows = new Map(strategyInputs.map((input) => [input.value, input.closest('.picker-strategy-option')]));
/* Why a strategy is out belongs to the strategy, not to the pairing, so it is
written once here rather than once per surface that rules it out. */
const BLOCKED_BECAUSE = {
drenched: 'Too loud for a page people work in or read at length.',
'full-palette': 'Four colors on duty compete with the work on show.',
};
const chosenSurfaces = () => modeInputs.filter((input) => input.checked);
const surfaceInput = (value) => modeInputs.find((input) => input.value === value);
const allowedFor = (value) => (surfaceInput(value)?.dataset.strategies ?? '').split(' ').filter(Boolean);
const defaultFor = (value) => surfaceInput(value)?.dataset.strategyDefault ?? 'restrained';
const strategyField = (value) => document.querySelector(`[data-surface-strategy="${value}"]`);
const strategyTitle = (value) => strategyRows.get(value)?.querySelector('.picker-strategy-title').textContent ?? value;
let activeSurface = null;
/* One drawing per chosen surface, painted with the committed palette. All of
them stay mounted and one is shown, so a tab switch costs a hidden attribute
rather than a rebuild and the frame never blinks. */
function syncSurfaces() {
if (!stage) return;
const chosen = chosenSurfaces();
for (const node of stage.querySelectorAll('[data-surface]')) node.remove();
for (const input of chosen) {
const source = modePreviews[modeInputs.indexOf(input)];
if (!source) continue;
const clone = source.cloneNode(true);
// Decorative here as on the tile, but the marker sits on the tile's
// wrapper rather than on the drawing, so it does not survive the lift.
clone.setAttribute('aria-hidden', 'true');
for (const node of [clone, ...clone.querySelectorAll('[id]')]) node.removeAttribute('id');
clone.dataset.surface = input.value;
stage.append(clone);
}
paintStage();
/* Every chosen surface leaves an answer whether or not it was ever opened,
so the field is filled with the default the moment the tile is chosen and
the tab reports it as unset until someone says otherwise. */
for (const input of modeInputs) {
const field = strategyField(input.value);
if (!field) continue;
field.disabled = !input.checked;
if (!input.checked) {
field.value = '';
delete field.dataset.chosen;
} else if (!field.value) {
field.value = defaultFor(input.value);
}
}
buildTabs(chosen);
showSurface(chosen.some((input) => input.value === activeSurface) ? activeSurface : chosen[0]?.value);
}
/* Painted once on the frame rather than on each drawing inside it, so the
strategy layer keeps a fixed reading of what was chosen and the drawings
themselves carry no inline color for it to argue with. */
function paintStage() {
syncCommittedPalette(stage, 'pkc');
}
/* One surface needs no tabs: the frame is already showing the only answer
there is. The dot is the whole report on state, filled once the surface has
been answered deliberately and hollow while it is still holding a default. */
function buildTabs(chosen) {
if (!surfaceTabs) return;
surfaceTabs.hidden = chosen.length < 2;
surfaceTabs.replaceChildren(...chosen.map((input) => {
const tab = document.createElement('button');
tab.type = 'button';
tab.className = 'picker-surface-tab';
tab.dataset.surfaceTab = input.value;
tab.innerHTML = '<span class="picker-surface-dot"></span>';
tab.append(input.dataset.surfaceLabel ?? input.value);
tab.onclick = () => showSurface(input.value);
return tab;
}));
markTabs();
}
function markTabs() {
for (const tab of surfaceTabs?.children ?? []) {
const value = tab.dataset.surfaceTab;
const field = strategyField(value);
const set = Boolean(field?.dataset.chosen);
const on = value === activeSurface;
tab.dataset.set = set ? 'yes' : 'no';
tab.setAttribute('aria-pressed', on ? 'true' : 'false');
tab.tabIndex = on ? 0 : -1;
tab.setAttribute('aria-label', set
? `${tab.textContent}, colored ${strategyTitle(field.value).toLowerCase()}`
: `${tab.textContent}, no color strategy chosen yet`);
}
}
function showSurface(value) {
if (!value || !stage) return;
activeSurface = value;
for (const clone of stage.querySelectorAll('[data-surface]')) {
clone.hidden = clone.dataset.surface !== value;
}
applyApplicability();
const field = strategyField(value);
const wanted = field?.value || defaultFor(value);
const input = strategyInputs.find((radio) => radio.value === wanted);
if (input) input.checked = true;
markTabs();
}
/* A strategy a surface cannot carry is left in place and turned off rather
than removed: the list keeps its shape as you move between surfaces, and the
row says why it is out instead of vanishing without a reason. */
function applyApplicability() {
const allowed = new Set(allowedFor(activeSurface));
for (const [value, row] of strategyRows) {
if (!row) continue;
const ok = allowed.has(value);
const desc = row.querySelector('.picker-strategy-desc');
desc.dataset.copy ??= desc.textContent;
desc.textContent = ok ? desc.dataset.copy : BLOCKED_BECAUSE[value] ?? desc.dataset.copy;
row.classList.toggle('is-blocked', !ok);
row.querySelector('input').disabled = !ok;
}
}
for (const input of strategyInputs) {
input.addEventListener('change', () => {
const field = strategyField(activeSurface);
if (!input.checked || !field) return;
field.value = input.value;
field.dataset.chosen = 'yes';
markTabs();
});
}
/* Arrow keys walk the group, which is the one thing a row of buttons owes a
keyboard once only the current tab is in the tab order. */
surfaceTabs?.addEventListener('keydown', (event) => {
const step = { ArrowLeft: -1, ArrowRight: 1 }[event.key];
if (!step) return;
const tabs = [...surfaceTabs.children];
const next = tabs[(tabs.findIndex((tab) => tab.dataset.surfaceTab === activeSurface) + step + tabs.length) % tabs.length];
event.preventDefault();
showSurface(next.dataset.surfaceTab);
next.focus();
});
for (const input of modeInputs) {
input.addEventListener('change', () => {
syncModesNext();
syncModePreview();
});
}
syncModesNext();
syncModePreview();
try {
const get = (url) => fetch(url).then((response) => response.ok ? response.json() : Promise.reject());
const [cueData, seedData] = await Promise.all([get('/cues.json'), get('/palettes.json')]);
// The agent's reading of PRODUCT.md arrives as cues.modes and pre-checks
// the surface tiles. Applied only when it names at least one real tile, so
// a bad hint cannot uncheck everything.
if (Array.isArray(cueData.modes)) {
const wanted = new Set(cueData.modes);
if (modeInputs.some((input) => wanted.has(input.value))) {
for (const input of modeInputs) input.checked = wanted.has(input.value);
syncModesNext();
syncModePreview();
}
}
cards = [
...cueData.cues.map((id) => ({ id, type: 'cue', palette: cueData.palette[id] })),
...seedData.seeds.map((seed) => ({ ...seed, type: 'seed' })),
];
for (const item of cards) states.set(item.id, createState(item));
layer.append(...cards.map(buildCard));
points.innerHTML = '<div class="picker-snap-point"></div>'.repeat(cards.length);
$('[data-select-palette]').disabled = false;
render();
activate(screen.hasAttribute('data-active'));
} catch {
count.textContent = 'Palette sources could not be loaded.';
}
let manifest = FALLBACK_FONTS;
let usingFallback = true;
try {
const response = await fetch('/fonts.json');
const candidate = response.ok ? await response.json() : null;
if (isFontManifest(candidate)) {
manifest = normalizeFontManifest(candidate);
usingFallback = false;
}
} catch {
// The built-in pairs keep older and incomplete runs moving.
}
renderFontPairs(manifest, usingFallback);