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

629 lines
24 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, formatOklch, hexToOklch, oklchToHex, 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]');
const preview = $('.picker-preview');
const strategyPreview = document.querySelector('[data-strategy-preview]');
const typePreview = document.querySelector('[data-type-preview]');
const fontOptions = document.querySelector('[data-font-options]');
const pairTemplate = document.querySelector('[data-pair-card]');
const states = new Map();
const canvases = new WeakMap();
let cards = [];
let current = 0;
let openTint;
let fontManifest;
const FALLBACK_FONTS = {
version: 1,
specimen: {
headline: 'Shape a clear visual direction',
body: 'Choose a type system that gives the product a distinct and usable voice.',
},
preview: {
brand: 'Im',
nav: ['Skills', 'Detect', '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',
gallery: [
{ title: 'Audit', meta: 'Quality pass' },
{ title: 'Polish', meta: 'Ship ready' },
{ title: 'Bolder', meta: 'Turn it up' },
{ title: 'Quieter', meta: 'Pull it back' },
],
footerLinks: ['GitHub', 'Changelog', 'CLI', 'Extension'],
footerMark: '© Impeccable',
},
pairs: [
{
id: 'source-editorial',
name: 'Source editorial',
heading: { family: 'Source Serif 4', weight: 600 },
body: { family: 'Source Sans 3', weight: 400 },
why: 'Source Serif 4 gives the questionnaire an editorial voice while Source Sans 3 keeps guidance easy to scan.',
},
{
id: 'literary-clarity',
name: 'Literary clarity',
heading: { family: 'Libre Baskerville', weight: 700 },
body: { family: 'Libre Franklin', weight: 400 },
why: 'Libre Baskerville adds measured character while Libre Franklin supports the pickers longer instructional copy.',
},
{
id: 'warm-structure',
name: 'Warm structure',
heading: { family: 'Bitter', weight: 600 },
body: { family: 'Cabin', weight: 400 },
why: 'Bitter brings sturdy detail to key choices while Cabin remains open at the pickers compact text sizes.',
},
{
id: 'bold-utility',
name: 'Bold utility',
heading: { family: 'Archivo Black', weight: 400 },
body: { family: 'Archivo', weight: 400 },
why: 'Archivo Black makes decisions unmistakable while Archivo keeps the surrounding interface practical.',
},
{
id: 'technical-signal',
name: 'Technical signal',
heading: { family: 'Azeret Mono', weight: 600 },
body: { family: 'Noto Sans', weight: 400 },
why: 'Azeret Mono echoes the system-building task while Noto Sans carries the explanatory reading load.',
},
{
id: 'classical-poise',
name: 'Classical poise',
heading: { family: 'Marcellus', weight: 400 },
body: { family: 'Karla', weight: 400 },
why: 'Marcellus gives the visual direction a composed signature while Karla keeps controls direct.',
},
],
};
const roleMap = (value) => Object.fromEntries(ROLES.map((role) => [role, value(role)]));
const card = () => cards[current];
const state = () => states.get(card().id);
const dismissRingGuide = () => ringGuide.setAttribute('aria-hidden', 'true');
const serifFamily = /serif|mincho|baskerville|bitter|marcellus|slab|antiqua|garamond|didot|bodoni/i;
const fontStack = (family) => `"${family.replaceAll('"', '\\"')}", ${serifFamily.test(family) ? 'serif' : 'sans-serif'}`;
function createState(item) {
const colors = item.type === 'cue'
? 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() {
for (const role of ROLES) preview.style.setProperty(`--pv-${role}`, state().colors[role]);
preview.style.setProperty('--pv-n-ink', contrastInk(state().colors.neutral));
}
function syncCommittedPalette(target) {
const committed = roleMap((role) => $(`[name="palette-${role}"]`).value);
if (!target || Object.values(committed).some((hex) => !hex)) return;
for (const role of ROLES) target.style.setProperty(`--pv-${role}`, committed[role]);
target.style.setProperty('--pv-n-ink', contrastInk(committed.neutral));
target.style.setProperty('--pv-p-ink', contrastInk(committed.primary));
}
function 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'
))
&& Array.isArray(value.footerLinks)
&& value.footerLinks.length === 4
&& value.footerLinks.every((entry) => typeof entry === 'string')
&& typeof value.footerMark === 'string';
}
function normalizeFontManifest(manifest) {
return {
...manifest,
preview: { ...FALLBACK_FONTS.preview, ...manifest.preview },
};
}
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) => (
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');
typePreview.style.setProperty('--pt-heading', fontStack(pair.heading.family));
typePreview.style.setProperty('--pt-body', fontStack(pair.body.family));
typePreview.style.setProperty('--pt-heading-weight', pair.heading.weight);
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 = specimen.body;
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;
});
fillIndexed(desktop, '[data-type-section-body]', preview.sectionBody);
fillIndexed(phoneBody, '[data-type-section-body]', preview.sectionBody);
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;
}
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);
loadFontStylesheet(manifest.pairs);
syncFontPair(manifest.pairs[0]);
}
fontOptions.onchange = ({ target }) => {
if (!target.matches('input[name="font-pair"]')) return;
const pair = fontManifest.pairs.find(({ id }) => id === target.value);
if (pair) syncFontPair(pair);
};
function setActiveRole(role) {
if (hint.textContent === hint.dataset[role]) return;
hint.classList.add('is-changing');
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 item = $(`[data-band-item="${openTint}"]`, panel);
delete item.dataset.tintOpen;
$('[data-tints]', item).hidden = true;
openTint = null;
}
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;
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 = '';
$('button', strip)?.focus();
}
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) 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];
}
};
$('[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');
const target = { '03': strategyPreview, '04': typePreview }[event.detail.screen];
if (target) syncCommittedPalette(target);
});
try {
const get = (url) => fetch(url).then((response) => response.ok ? response.json() : Promise.reject());
const [cueData, seedData] = await Promise.all([get('/cues.json'), get('/palettes.json')]);
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);