Add screen 02 palette picker: cue deck, sampling rings, band tools

Deck of cue cards + seed palettes with scroll-snap browsing, draggable
color-sampling rings with loupe, four-band panel (copy hex+OKLCH, tint
strip, native custom color, card-local reset), /palettes.json route.

AI-assisted (agent-implemented, maintainer-directed).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Abdul Wahab
2026-09-01 10:02:05 +05:00
co-authored by Cursor
parent ba7f3c8891
commit c19c349fc6
7 changed files with 1839 additions and 172 deletions
+232 -59
View File
@@ -1,5 +1,12 @@
---
import Picker from '../layouts/Picker.astro';
const roles = [
['primary', 'Primary'],
['secondary', 'Secondary'],
['tertiary', 'Tertiary'],
['neutral', 'Neutral'],
];
---
<Picker>
@@ -40,15 +47,179 @@ import Picker from '../layouts/Picker.astro';
</div>
</section>
<!-- Phase 6 replaces this placeholder with the first question screen. -->
<section class="picker-screen" data-screen="02" aria-hidden="true" aria-labelledby="picker-placeholder-copy">
<section class="picker-screen" data-screen="02" aria-hidden="true" aria-labelledby="picker-palette-title">
<div class="picker-container">
<div class="picker-placeholder">
<p id="picker-placeholder-copy">Next screen arrives with its spec</p>
<button class="ks-button ks-button-primary" type="submit">Finish</button>
<div class="picker-palette">
<h1 id="picker-palette-title" class="picker-palette-title">Choose a color palette</h1>
<div class="picker-palette-grid">
<div class="picker-deck-column" aria-label="Palette sources">
<div class="picker-deck-stage">
<div class="picker-deck-scroller" data-deck-scroll aria-label="Browse palette cards">
<div data-deck-points></div>
</div>
<div class="picker-card-layer" data-deck-cards></div>
<div class="picker-loupe" data-loupe aria-hidden="true">
<canvas width="80" height="80"></canvas>
</div>
</div>
<template data-cue-card>
<article class="picker-card">
<div class="picker-card-face picker-cue-face">
<img alt="" />
{roles.map(([role, label]) => (
<button class="picker-ring" type="button" data-role={role} role="slider" aria-label={`${label} color picker`}></button>
))}
</div>
</article>
</template>
<template data-seed-card>
<article class="picker-card">
<div class="picker-card-face picker-seed-face">
{roles.map(() => <span></span>)}
</div>
</article>
</template>
<div class="picker-deck-controls">
<button class="picker-icon-button" type="button" data-deck-prev aria-label="Previous card">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m12.5 4.5-5 5.5 5 5.5"></path></svg>
</button>
<p class="picker-deck-count" data-deck-count aria-live="polite">Loading palettes</p>
<button class="picker-icon-button" type="button" data-deck-next aria-label="Next card">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m7.5 4.5 5 5.5-5 5.5"></path></svg>
</button>
</div>
<p class="picker-ring-guide" data-ring-guide>
<span>Drag the color rings to sample from the image.</span>
<span>Use arrow keys for precise adjustments.</span>
</p>
</div>
<fieldset class="picker-palette-panel">
<legend>Chosen colors</legend>
<button class="picker-icon-button picker-tip picker-reset" type="button" data-reset data-tip="Reset to this card's colors" aria-label="Reset to this card's colors">
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M4.6 7.2A6.2 6.2 0 1 1 4 11.4M4.6 7.2V3.8m0 3.4H8"></path></svg>
</button>
<div class="picker-bands">
{roles.map(([role, label]) => (
<div class="picker-band-item" data-band-item={role}>
<div class="picker-band" data-band={role} tabindex="0">
<output data-band-hex={role}>#000000</output>
<div class="picker-band-tools" role="group" aria-label={`${label} color actions`}>
<button class="picker-band-tool picker-tip" type="button" data-copy-color={role} data-tip="Copy hex + OKLCH" aria-label={`Copy ${label} hex and OKLCH`}>
<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="6.5" y="6.5" width="9" height="9"></rect><path d="M4.5 13.5h-1v-10h10v1"></path></svg>
</button>
<button class="picker-band-tool picker-tip" type="button" data-edit-tints={role} data-tip="Edit tints" aria-label={`Edit ${label} tints`}>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="m4 14.5-.4 2 2-.4L15 6.7 13.3 5 4 14.5Z"></path><path d="m11.9 6.4 1.7 1.7"></path></svg>
</button>
<button class="picker-band-tool picker-tip" type="button" data-custom-color={role} data-tip="Custom color" aria-label={`Choose custom ${label} color`}>
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 4v12M4 10h12"></path></svg>
</button>
</div>
<input class="picker-native-color" type="color" value="#000000" data-color-input={role} tabindex="-1" aria-label={`Custom ${label} color`} />
</div>
<div class="picker-band-foot">
<h2>{label}</h2>
<div class="picker-tint-strip" data-tints={role} hidden>
{Array.from({ length: 7 }, () => <button class="picker-tint" type="button" data-tint></button>)}
<button class="picker-tint-close" type="button" data-close-tints aria-label="Close tints">×</button>
</div>
</div>
</div>
))}
</div>
<div class="picker-preview" data-register="brand" aria-hidden="true">
<div class="pv-brand">
<div class="pv-chrome"></div>
<div class="pv-brand-page">
<div class="pv-brand-nav">
<div class="pv-logo"></div>
<div class="pv-nav-bars"><i></i><i></i><i></i><i></i></div>
<div class="pv-primary-pill"></div>
</div>
<div class="pv-brand-hero">
<div class="pv-hero-copy">
<div class="pv-eyebrow"></div>
<div class="pv-headline"></div>
<div class="pv-copy-bars"></div>
<div class="pv-actions"><i></i><i></i></div>
</div>
<div class="pv-image"></div>
</div>
<div class="pv-features">
<div><i></i><span></span></div>
<div><i></i><span></span></div>
<div><i></i><span></span></div>
</div>
</div>
</div>
<div class="pv-product">
<div class="pv-chrome"></div>
<div class="pv-app">
<div class="pv-sidebar">
<div class="pv-logo"></div>
<div class="pv-active-nav"></div>
<div class="pv-side-bars"><i></i><i></i><i></i><i></i></div>
</div>
<div class="pv-app-main">
<div class="pv-topbar">
<div class="pv-search"></div>
<div class="pv-alert"></div>
<div class="pv-avatar"></div>
</div>
<div class="pv-stats">
<div><i></i><span></span></div>
<div><i></i><span></span></div>
<div><i></i><span></span></div>
</div>
<div class="pv-app-lower">
<div class="pv-chart"><i></i><i></i><i></i><i></i><i></i><i></i><i></i></div>
<div class="pv-app-aside"><span></span><i></i></div>
</div>
</div>
</div>
</div>
</div>
<p
class="picker-palette-hint"
data-palette-hint
data-idle="Drag the rings on the image to pull colors from it, or edit any swatch directly."
data-primary="Your main brand color: buttons, links, the color people remember. Pick something with presence."
data-secondary="Supports the primary: section accents, hovers, secondary buttons. Adjacent, not identical."
data-tertiary="The rare accent: badges, highlights, one detail per screen. The most saturated of the four."
data-neutral="Backgrounds and large surfaces: most of every page. Quiet, near-white or near-black."
aria-live="polite"
>Drag the rings on the image to pull colors from it, or edit any swatch directly.</p>
<button class="ks-button ks-button-primary picker-select" type="button" data-select-palette data-advance="next" disabled>Select this palette</button>
</fieldset>
</div>
<input type="hidden" name="palette-source" />
{roles.map(([role]) => <input type="hidden" name={`palette-${role}`} />)}
</div>
</div>
</section>
<section class="picker-screen" data-screen="03" aria-hidden="true" aria-labelledby="picker-finish-copy">
<div class="picker-container">
<div class="picker-placeholder">
<p id="picker-finish-copy">Your palette is ready.</p>
<div class="picker-actions">
<button class="ks-button ks-button-ghost" type="button" data-advance="prev">Back</button>
<button class="ks-button ks-button-primary" type="submit">Finish</button>
</div>
</div>
</div>
</section>
<div class="picker-progress" aria-hidden="true"><span></span></div>
</form>
</main>
@@ -74,24 +245,27 @@ import Picker from '../layouts/Picker.astro';
</aside>
<script>
const form = document.querySelector('#picker-form');
const copyUrlButton = document.querySelector('[data-copy-url]');
const copyUrlValue = document.querySelector('[data-copy-url-value]');
const copyStatus = document.querySelector('[data-copy-status]');
import '../scripts/palette-picker.js';
const $ = (selector, root = document) => root.querySelector(selector);
const form = $('#picker-form');
const copyButton = $('[data-copy-url]');
const status = $('[data-copy-status]');
const screens = [...form.querySelectorAll('.picker-screen')];
let current = screens.findIndex((screen) => screen.hasAttribute('data-active'));
const browserUrl = new URL(window.location.href);
browserUrl.hostname = 'localhost';
const shareUrl = browserUrl.href.replace(/\/$/, '');
if (copyUrlValue) copyUrlValue.textContent = shareUrl;
const url = new URL(location.href);
url.hostname = 'localhost';
const link = url.href.replace(/\/$/, '');
$('[data-copy-url-value]').textContent = link;
const activeControls = () => [...screens[current].querySelectorAll(
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href]',
const controls = () => [...screens[current].querySelectorAll(
'button:not([disabled]), input:not([disabled]):not([type="hidden"]):not([tabindex="-1"]), select:not([disabled]), textarea:not([disabled]), a[href]',
)];
const goTo = (index) => {
if (!screens[index]) return;
const target = screens[index];
if (!target) return;
screens.forEach((screen, screenIndex) => {
const active = screenIndex === index;
screen.toggleAttribute('data-active', active);
@@ -99,48 +273,44 @@ import Picker from '../layouts/Picker.astro';
else screen.setAttribute('aria-hidden', 'true');
});
current = index;
activeControls()[0]?.focus();
const id = target.dataset.screen;
form.dataset.current = id;
form.style.setProperty('--progress', (index + 1) / screens.length);
document.dispatchEvent(new CustomEvent('picker:screenchange', {
detail: { screen: id },
}));
controls()[0]?.focus();
};
const next = () => goTo(current + 1);
const prev = () => goTo(current - 1);
form.onclick = (e) => {
const control = e.target.closest('[data-advance]');
if (control) goTo(current + (control.dataset.advance === 'prev' ? -1 : 1));
};
form.addEventListener('click', (event) => {
const control = event.target.closest('[data-advance]');
if (!control) return;
control.dataset.advance === 'prev' ? prev() : next();
});
document.addEventListener('keydown', (e) => {
if (e.defaultPrevented) return;
if (e.target instanceof Element && e.target.closest('input, textarea, select, [role="slider"]')) return;
document.addEventListener('keydown', (event) => {
if (event.defaultPrevented) return;
if (event.target instanceof Element && event.target.closest('input, textarea, select, [role="slider"]')) return;
const directions = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 };
if (current === 0 && event.key === 'Enter') {
if (window.matchMedia('(max-width: 1199px)').matches) return;
event.preventDefault();
screens[0].querySelector('[data-advance="next"]')?.click();
return;
}
if (event.key === 'Enter') {
const control = document.activeElement;
if (activeControls().includes(control)) {
event.preventDefault();
if (e.key === 'Enter') {
if (current === 0 && matchMedia('(max-width: 1199px)').matches) return;
const control = current === 0 ? $('[data-advance="next"]', screens[0]) : document.activeElement;
if (controls().includes(control)) {
e.preventDefault();
control.click();
}
return;
}
if (!(event.key in directions)) return;
event.preventDefault();
const controls = activeControls();
const focused = controls.indexOf(document.activeElement);
const index = focused < 0 ? 0 : (focused + directions[event.key] + controls.length) % controls.length;
controls[index]?.focus();
const d = { ArrowUp: -1, ArrowLeft: -1, ArrowDown: 1, ArrowRight: 1 };
if (!(e.key in d)) return;
e.preventDefault();
const items = controls();
const focused = items.indexOf(document.activeElement);
const index = focused < 0 ? 0 : (focused + d[e.key] + items.length) % items.length;
items[index]?.focus();
});
form.addEventListener('submit', async (event) => {
event.preventDefault();
form.onsubmit = async (e) => {
e.preventDefault();
const answers = {};
for (const [name, value] of new FormData(form)) {
if (!(name in answers)) answers[name] = value;
@@ -151,21 +321,24 @@ import Picker from '../layouts/Picker.astro';
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(answers),
});
});
};
copyUrlButton?.addEventListener('click', async () => {
copyButton.onclick = async () => {
try {
await navigator.clipboard.writeText(shareUrl);
copyUrlButton.classList.add('is-copied');
copyUrlButton.setAttribute('aria-label', 'Link copied');
copyStatus.textContent = 'Copied. Paste it into your browser to continue.';
window.setTimeout(() => {
copyUrlButton.classList.remove('is-copied');
copyUrlButton.setAttribute('aria-label', 'Copy link');
await navigator.clipboard.writeText(link);
copyButton.classList.add('is-copied');
copyButton.setAttribute('aria-label', 'Link copied');
status.textContent = 'Copied. Paste it into your browser to continue.';
setTimeout(() => {
copyButton.classList.remove('is-copied');
copyButton.setAttribute('aria-label', 'Copy link');
}, 1200);
} catch {
copyStatus.textContent = 'Copy failed. Select the link above and copy it instead.';
status.textContent = 'Copy failed. Select the link above and copy it instead.';
}
});
};
form.dataset.current = screens[current].dataset.screen;
form.style.setProperty('--progress', (current + 1) / screens.length);
</script>
</Picker>
+64
View File
@@ -0,0 +1,64 @@
const clamp = (value) => Math.min(1, Math.max(0, value));
const linearize = (value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
const gamma = (value) => value <= 0.0031308 ? 12.92 * value : 1.055 * value ** (1 / 2.4) - 0.055;
const parseHex = (hex) => hex.match(/[\da-f]{2}/gi).map((value) => Number.parseInt(value, 16) / 255);
function toLinearRgb([L, C, H]) {
const angle = H * Math.PI / 180;
const a = C * Math.cos(angle);
const b = C * Math.sin(angle);
const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
return [
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
];
}
export function oklchToHex([lightness, chroma, hue]) {
const L = clamp(lightness);
let C = Math.max(0, chroma);
let rgb = toLinearRgb([L, C, hue]);
while (C > 0 && rgb.some((channel) => channel < 0 || channel > 1)) {
C = Math.max(0, C - 0.005);
rgb = toLinearRgb([L, C, hue]);
}
return `#${rgb.map((channel) => Math.round(clamp(gamma(channel)) * 255).toString(16).padStart(2, '0')).join('').toUpperCase()}`;
}
export function hexToOklch(hex) {
const [red, green, blue] = parseHex(hex).map(linearize);
const l = Math.cbrt(0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue);
const m = Math.cbrt(0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue);
const s = Math.cbrt(0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue);
const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
const b = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
const C = Math.hypot(a, b);
return [L, C, C < 0.00001 ? 0 : (Math.atan2(b, a) * 180 / Math.PI + 360) % 360];
}
export function formatOklch(hex) {
const [L, C, H] = hexToOklch(hex);
return `oklch(${(L * 100).toFixed(1)}% ${C.toFixed(3)} ${H.toFixed(1)})`;
}
/** Expand one curated seed into the four questionnaire roles. */
export function seedToRoles(seed) {
const [L, C, H] = seed.oklch;
return {
primary: oklchToHex([L, C, H]),
secondary: oklchToHex([L < 0.5 ? L + 0.18 : L - 0.18, C * 0.6, H]),
tertiary: oklchToHex([0.62, Math.min(0.23, Math.max(C, 0.15)), (H + 60) % 360]),
neutral: oklchToHex([L < 0.55 ? 0.96 : 0.2, 0.01, H]),
};
}
export function contrastInk(hex) {
const [red, green, blue] = parseHex(hex).map(linearize);
return 0.2126 * red + 0.7152 * green + 0.0722 * blue > 0.22
? 'var(--ks-champagne)'
: 'var(--ks-lacquer-raised)';
}
+342
View File
@@ -0,0 +1,342 @@
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 states = new Map();
const canvases = new WeakMap();
let cards = [];
let current = 0;
let openTint;
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');
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 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 [currentL, C, H] = hexToOklch(state().colors[role]);
const nearest = Math.max(0, Math.min(6, Math.round((0.92 - currentL) / (0.74 / 6))));
$$('[data-tint]', strip).forEach((button, index) => {
const L = 0.92 - index * (0.74 / 6);
const hex = oklchToHex([L, C * (0.55 + 0.45 * Math.sin(Math.PI * index / 6)), H]);
button.dataset.tint = hex;
button.style.setProperty('--tint-color', hex);
button.setAttribute('aria-label', hex);
button.toggleAttribute('data-current', index === nearest);
});
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'));
try {
const get = (url) => fetch(url).then((response) => response.ok ? response.json() : Promise.reject());
const context = get('/context.json').catch(() => ({ register: 'brand' }));
const [cueData, seedData, contextData] = await Promise.all([get('/cues.json'), get('/palettes.json'), context]);
preview.dataset.register = contextData.register === 'product' ? 'product' : 'brand';
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.';
}
File diff suppressed because it is too large Load Diff
+118 -111
View File
@@ -27,6 +27,7 @@
*/
import crypto from 'node:crypto';
import { pathToFileURL } from 'node:url';
// Seeds are inlined (129 entries, hand-curated via a tinder review of
// ~400 candidates from ColorHunt + synthesis + Radix/brand/Pantone anchors).
@@ -419,6 +420,8 @@ const SEEDS = [
strategy: "Pure white surface so the rose-pink primary carries all the brand warmth, paired with a near-black ink and a desaturated mauve accent for editorial restraint." },
];
export { SEEDS };
function parseArgs(argv) {
const args = { id: null, from: null };
for (let i = 0; i < argv.length; i++) {
@@ -494,135 +497,139 @@ function hueWord(H) {
// ---------------------------------------------------------------
const args = parseArgs(process.argv.slice(2));
const seed = pickSeed(SEEDS, args);
const [L, C, H] = seed.oklch;
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const args = parseArgs(process.argv.slice(2));
const seed = pickSeed(SEEDS, args);
const [L, C, H] = seed.oklch;
// The mood + strategy on each seed were derived by the model that
// originally judged it. We surface them as *hints*, not commands —
// the brief should still drive what the seed becomes.
const moodHint = seed.mood ? ` (one read: "${seed.mood}")` : '';
const strategyHint = seed.strategy ? `\n - one example strategy: ${seed.strategy}` : '';
// The mood + strategy on each seed were derived by the model that
// originally judged it. We surface them as *hints*, not commands —
// the brief should still drive what the seed becomes.
const moodHint = seed.mood ? ` (one read: "${seed.mood}")` : '';
const strategyHint = seed.strategy ? `\n - one example strategy: ${seed.strategy}` : '';
// ---------------------------------------------------------------
// Fat tool-exit response — what the model sees on stdout.
// ---------------------------------------------------------------
// ---------------------------------------------------------------
// Fat tool-exit response — what the model sees on stdout.
// ---------------------------------------------------------------
process.stdout.write(`BRAND SEED · ${seed.id}
process.stdout.write(`BRAND SEED · ${seed.id}
Seed color (anchor for your primary brand color):
${fmtOklch(seed.oklch)}${hueWord(H)}${moodHint}
Seed color (anchor for your primary brand color):
${fmtOklch(seed.oklch)}${hueWord(H)}${moodHint}
This is the brand's anchor — a single beautiful color. Compose the rest of
the palette around it using YOUR judgment, the brief (PRODUCT.md /
DESIGN.md / the user's prompt), and the color-strategy guidance already in
SKILL.md.
This is the brand's anchor — a single beautiful color. Compose the rest of
the palette around it using YOUR judgment, the brief (PRODUCT.md /
DESIGN.md / the user's prompt), and the color-strategy guidance already in
SKILL.md.
How to use:
How to use:
1. Read the brief. Write one specific phrase describing the mood this
product calls for. Be granular. Good: "1970s travel poster — sun-baked
warmth, considered", "midnight jazz club — smoky brass, saxophone
light", "Scandinavian winter morning — quiet light through frost". Bad:
"modern and clean", "warm and inviting". The first lets you compose; the
second is generic and will produce generic palettes.
1. Read the brief. Write one specific phrase describing the mood this
product calls for. Be granular. Good: "1970s travel poster — sun-baked
warmth, considered", "midnight jazz club — smoky brass, saxophone
light", "Scandinavian winter morning — quiet light through frost". Bad:
"modern and clean", "warm and inviting". The first lets you compose; the
second is generic and will produce generic palettes.
2. The seed's hue (${H.toFixed(0)}°) anchors your primary brand color. You
choose L and C to match the mood. The same hue can be deep-and-velvet,
bright-and-confident, or pale-and-faded — pick the one the mood demands.
Primary's hue should stay within ±10° of the seed.${strategyHint}
2. The seed's hue (${H.toFixed(0)}°) anchors your primary brand color. You
choose L and C to match the mood. The same hue can be deep-and-velvet,
bright-and-confident, or pale-and-faded — pick the one the mood demands.
Primary's hue should stay within ±10° of the seed.${strategyHint}
3. Now compose the full palette in OKLCH (5 more roles):
• bg — the most important architectural choice.
CORE PRINCIPLE: the mood lives in the BRAND COLORS
(primary + accent) and typography, NOT in the surface.
A warm brand puts the warmth in its primary against a
pure surface. Putting warmth in BOTH primary AND bg is
the AI cliché.
3. Now compose the full palette in OKLCH (5 more roles):
• bg — the most important architectural choice.
CORE PRINCIPLE: the mood lives in the BRAND COLORS
(primary + accent) and typography, NOT in the surface.
Stripe is warm — its purple does that, bg is pure
white. Linear is cool — its blue does that, bg is
pure. Notion is warm — its accents do that, bg is
near-pure-white. Putting warmth in BOTH primary AND
bg is the AI cliché.
DEFAULT A — PURE white: exactly oklch(1.000 0.000 0).
Not 0.99, not chroma 0.002. The most confident
brands in every field — fashion houses, galleries,
publishers, tool makers — use literal #ffffff.
Don't add hidden warmth.
DEFAULT A — PURE white: exactly oklch(1.000 0.000 0).
Not 0.99, not chroma 0.002. Stripe / Notion / Apple
use literal #ffffff. Don't add hidden warmth.
Refs: Stripe, Notion, Linear (light), Apple.com,
Vercel docs, Figma marketing, Loom, Substack.
DEFAULT B — PURE black/near-black: L 0.04-0.12,
chroma exactly 0.000. No hue tint. Pick L for the
mood (cinema dark, gallery dark, instrument-panel
dark); C stays 0.
DEFAULT B — PURE black/near-black: L 0.04-0.12,
chroma exactly 0.000. No hue tint. Vercel is
roughly oklch(0.08 0 0). Pick L for mood; C is 0.
Refs: Vercel, A24, Acne, Apple dark, MUBI.
ALT 2 — TINTED: chroma 0.015-0.05.
Use ONLY when:
(a) the mood is EXPLICITLY environmental — the surface
IS part of the brand (1920s lacquered interior,
leather library, ceramic studio, hotel lobby), or
(b) the seed itself is desaturated (chroma < 0.10) and
needs a tinted surface to read as a brand.
NOT for "feels warm" / "modern + warm" / "moody". If
your mood says "warm" but doesn't name a specific
environment, use PURE white and let primary carry
the warmth.
ALT 2 — TINTED: chroma 0.015-0.05.
Use ONLY when:
(a) the mood is EXPLICITLY environmental — the surface
IS part of the brand (1920s lacquered interior,
leather library, ceramic studio, hotel lobby), or
(b) the seed itself is desaturated (chroma < 0.10) and
needs a tinted surface to read as a brand.
NOT for "feels warm" / "modern + warm" / "moody". If
your mood says "warm" but doesn't name a specific
environment, use PURE white and let primary carry
the warmth.
HEURISTIC: if the seed's chroma > 0.10 and the mood
doesn't name a specific environment, it's almost
always PURE white. Target distribution across many
palettes: ~50% pure white, ~25% pure black, ~25%
tinted.
• surface — bg pulled slightly toward ink (10-15% mix). Same hue
family as bg. Used for cards, panels, sections.
• ink — body text color. Must reach ≥7:1 contrast vs bg.
Can carry the brand hue at low chroma in light mode
(slight warmth or coolness toward the brand).
• accent — a SECOND brand color, distinct from primary in BOTH
hue AND lightness. Picked to complement the mood (not
default-complementary across the wheel). Used for
badges, status pills, links, accent rules.
• muted — secondary text. Ink pulled 40% toward bg, keeping ink's
hue. Must reach ≥3.5:1 contrast vs bg.
HEURISTIC: if seed chroma > 0.10 AND mood is product-
focused (not environment-focused), it's almost always
PURE white. Target distribution across many palettes:
~50% pure white, ~25% pure black, ~25% tinted.
• surface — bg pulled slightly toward ink (10-15% mix). Same hue
family as bg. Used for cards, panels, sections.
• ink — body text color. Must reach ≥7:1 contrast vs bg.
Can carry the brand hue at low chroma in light mode
(slight warmth or coolness toward the brand).
• accent — a SECOND brand color, distinct from primary in BOTH
hue AND lightness. Picked to complement the mood (not
default-complementary across the wheel). Used for
badges, status pills, links, accent rules.
• muted — secondary text. Ink pulled 40% toward bg, keeping ink's
hue. Must reach ≥3.5:1 contrast vs bg.
4. Pick a color STRATEGY (the four steps from SKILL.md):
• Restrained: tinted neutrals + accent ≤10% — product default
• Committed: one saturated color carries 30-60% — identity-driven
• Full palette: 3-4 named roles each used deliberately — brand work
• Drenched: the surface IS the color — campaign, hero, statement
The brief picks the strategy. A startup dashboard ≠ a perfume brand.
4. Pick a color STRATEGY (the four steps from SKILL.md):
• Restrained: tinted neutrals + accent ≤10% — product default
• Committed: one saturated color carries 30-60% — identity-driven
• Full palette: 3-4 named roles each used deliberately — brand work
• Drenched: the surface IS the color — campaign, hero, statement
The brief picks the strategy. A startup dashboard ≠ a perfume brand.
Hard rules (already in SKILL.md, recapped because the seed step is where
they actually bite):
Hard rules (already in SKILL.md, recapped because the seed step is where
they actually bite):
- OKLCH only — never hex. Never #RRGGBB.
- ink-vs-bg WCAG contrast ≥ 7 (body text must be readable)
- primary chroma ≤ 0.23 (above this, primary glows perceptually and
no text on it is readable — acid-bright is a UI failure)
- if primary L > 0.78, primary chroma ≤ 0.18 (the fluorescent zone)
- primary-vs-accent contrast ≥ 1.7 (they must be visually distinct,
not two variants of the same hue at similar lightness)
- accent must carry readable text on a filled badge/pill: EITHER
saturated (chroma ≥ 0.10) OR clearly light (L ≥ 0.85) OR clearly
dark (L ≤ 0.30). Never a muddy mid-tone (L 0.45-0.72 + chroma < 0.10)
— taupe/mushroom/dusty-grey accents read as weak and can't hold text
either way. Saturate it or push its lightness to a clear light/dark.
- avoid the saturated AI attractor zones: claude-beige (warm-cream bg
+ dusty brown primary), forest-green-on-cream, AI-purple-on-white,
navy-cream-with-orange-accent
- OKLCH only — never hex. Never #RRGGBB.
- ink-vs-bg WCAG contrast ≥ 7 (body text must be readable)
- primary chroma ≤ 0.23 (above this, primary glows perceptually and
no text on it is readable — acid-bright is a UI failure)
- if primary L > 0.78, primary chroma ≤ 0.18 (the fluorescent zone)
- primary-vs-accent contrast ≥ 1.7 (they must be visually distinct,
not two variants of the same hue at similar lightness)
- accent must carry readable text on a filled badge/pill: EITHER
saturated (chroma ≥ 0.10) OR clearly light (L ≥ 0.85) OR clearly
dark (L ≤ 0.30). Never a muddy mid-tone (L 0.45-0.72 + chroma < 0.10)
— taupe/mushroom/dusty-grey accents read as weak and can't hold text
either way. Saturate it or push its lightness to a clear light/dark.
- avoid the saturated AI attractor zones: claude-beige (warm-cream bg
+ dusty brown primary), forest-green-on-cream, AI-purple-on-white,
navy-cream-with-orange-accent
TEXT-ON-COLOR FILLS — pick by perceptual contrast, not just WCAG. The
rule applies to ANY element where text sits on a saturated color fill:
primary buttons, accent buttons, badges, status pills, tag highlights,
filled callouts. Don't only think "primary button" — apply consistently.
TEXT-ON-COLOR FILLS — pick by perceptual contrast, not just WCAG. The
rule applies to ANY element where text sits on a saturated color fill:
primary buttons, accent buttons, badges, status pills, tag highlights,
filled callouts. Don't only think "primary button" — apply consistently.
For any saturated mid-luminance color (L between 0.42 and 0.78, chroma ≥
0.08), use WHITE text (or near-white from your bg), not dark text — even
if WCAG says dark technically passes. The Helmholtz-Kohlrausch effect
makes saturated colors appear brighter than their luminance suggests,
and dark text on a warm-or-cool-saturated fill reads as muddy.
For any saturated mid-luminance color (L between 0.42 and 0.78, chroma ≥
0.08), use WHITE text (or near-white from your bg), not dark text — even
if WCAG says dark technically passes. The Helmholtz-Kohlrausch effect
makes saturated colors appear brighter than their luminance suggests,
and dark text on a warm-or-cool-saturated fill reads as muddy.
Convention: saturated action fills in the wild, from fast-food reds to
status pills to filled badges, near-universally carry white text.
Convention: Stripe orange CTAs, McDonald's red, every fintech orange
button, Vercel's filled badges, Linear's status pills — all use white
text on saturated bg fills.
Dark text is correct only on PALE fills (L > 0.85) or PURE-NEUTRAL fills
(chroma near 0). Everything else: white text.
Dark text is correct only on PALE fills (L > 0.85) or PURE-NEUTRAL fills
(chroma near 0). Everything else: white text.
Return your composed palette in CSS custom properties using OKLCH, then
build with it. The seed is the start, not the recipe.
`);
Return your composed palette in CSS custom properties using OKLCH, then
build with it. The seed is the start, not the recipe.
`);
}
+20
View File
@@ -10,6 +10,8 @@ import { readFile, mkdir, stat, writeFile } from 'node:fs/promises';
import net from 'node:net';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { extractRegister } from './context.mjs';
import { SEEDS } from './palette.mjs';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const pickerDir = path.join(scriptDir, 'picker');
const answersPath = path.resolve(process.cwd(), '.impeccable/design-interview/answers.json');
@@ -168,6 +170,14 @@ if (options.help) {
process.exit(0);
}
let register = 'brand';
try {
const product = await readFile(path.resolve(process.cwd(), 'PRODUCT.md'), 'utf8');
register = extractRegister(product) || register;
} catch {
// A missing or unreadable PRODUCT.md keeps the brand preview default.
}
const port = await findOpenPort(options.port);
let completed = false;
let timeout;
@@ -212,6 +222,16 @@ async function handleRequest(request, response) {
await serveFile(response, options.cuesDir, 'cues.json', ['.json']);
return;
}
if (requestPath === '/palettes.json') {
sendJson(response, 200, {
seeds: SEEDS.map(({ id, oklch, mood }) => ({ id, oklch, mood })),
});
return;
}
if (requestPath === '/context.json') {
sendJson(response, 200, { register });
return;
}
if (requestPath.startsWith('/cues/')) {
const cueName = requestPath.slice('/cues/'.length);
if (!cueName || cueName.includes('/')) {
+76 -2
View File
@@ -11,11 +11,13 @@ import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promi
import http from 'node:http';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { before, test } from 'node:test';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const serverScript = path.join(root, 'skill/scripts/picker-server.mjs');
const paletteScript = path.join(root, 'skill/scripts/palette.mjs');
const colorModule = pathToFileURL(path.join(root, 'picker/scripts/color.js')).href;
const pickerIndex = path.join(root, 'skill/scripts/picker/index.html');
const portBase = 18_500 + (process.pid % 500);
const cueManifestFixture = {
@@ -38,7 +40,7 @@ before(() => {
});
});
async function createFixture() {
async function createFixture(register = null) {
const cwd = await realpath(await mkdtemp(path.join(tmpdir(), 'impeccable-picker-')));
const cuesDir = path.join(cwd, '.impeccable/visual-cues');
await mkdir(cuesDir, { recursive: true });
@@ -47,6 +49,9 @@ async function createFixture() {
path.join(cuesDir, 'cues.json'),
`${JSON.stringify(cueManifestFixture)}\n`,
);
if (register) {
await writeFile(path.join(cwd, 'PRODUCT.md'), `# Product\n\n## Register\n\n${register}\n`);
}
return { cwd, cuesDir };
}
@@ -164,6 +169,18 @@ test('serves picker and cues, writes submission, prints answers, and exits 0', a
assert.equal(cueManifest.status, 200);
assert.deepEqual(await cueManifest.json(), cueManifestFixture);
const palettesResponse = await fetch(`${server.url}/palettes.json`);
assert.equal(palettesResponse.status, 200);
assert.match(palettesResponse.headers.get('content-type'), /^application\/json/);
const { seeds } = await palettesResponse.json();
assert.ok(seeds.length > 100);
for (const seed of seeds) {
assert.deepEqual(Object.keys(seed), ['id', 'oklch', 'mood']);
assert.equal(typeof seed.id, 'string');
assert.equal(seed.oklch.length, 3);
assert.equal(typeof seed.mood, 'string');
}
const exitPromise = waitForExit(server.processHandle);
const answers = { register: 'brand', direction: 'kinpaku' };
const submitResponse = await fetch(`${server.url}/submit`, {
@@ -183,6 +200,63 @@ test('serves picker and cues, writes submission, prints answers, and exits 0', a
assert.match(server.stdout(), new RegExp(`ANSWERS ${answersPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
});
test('palette CLI still prints a seed', () => {
const output = execFileSync(process.execPath, [paletteScript], {
cwd: root,
encoding: 'utf8',
});
assert.match(output, /^BRAND SEED · seed-\d+/);
assert.match(output, /Seed color \(anchor for your primary brand color\):/);
});
test('serves the product register from PRODUCT.md', async (t) => {
const fixture = await createFixture('product');
const server = await startPicker(fixture.cwd, ['--port', String(portBase + 10)]);
await cleanup(t, fixture, server);
const response = await fetch(`${server.url}/context.json`);
assert.equal(response.status, 200);
assert.match(response.headers.get('content-type'), /^application\/json/);
assert.deepEqual(await response.json(), { register: 'product' });
});
test('defaults picker context to brand without PRODUCT.md', async (t) => {
const fixture = await createFixture();
const server = await startPicker(fixture.cwd, ['--port', String(portBase + 11)]);
await cleanup(t, fixture, server);
const response = await fetch(`${server.url}/context.json`);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { register: 'brand' });
});
test('picker color math round-trips sRGB and clips out-of-gamut OKLCH', async () => {
const {
contrastInk,
formatOklch,
hexToOklch,
oklchToHex,
seedToRoles,
} = await import(colorModule);
const channels = (hex) => hex.match(/[\dA-F]{2}/gi).map((pair) => Number.parseInt(pair, 16));
for (const hex of ['#FFFFFF', '#1E4A42', '#D7A930']) {
const expected = channels(hex);
const actual = channels(oklchToHex(hexToOklch(hex)));
actual.forEach((channel, index) => assert.ok(Math.abs(channel - expected[index]) <= 1));
}
const clipped = oklchToHex([0.7, 0.4, 40]);
assert.match(clipped, /^#[\dA-F]{6}$/);
assert.ok(hexToOklch(clipped)[1] < 0.4);
assert.match(formatOklch('#1E4A42'), /^oklch\(\d+\.\d% \d+\.\d{3} \d+\.\d\)$/);
assert.deepEqual(Object.keys(seedToRoles({ oklch: [0.62, 0.15, 210] })), [
'primary', 'secondary', 'tertiary', 'neutral',
]);
assert.equal(contrastInk('#FFFFFF'), 'var(--ks-champagne)');
assert.equal(contrastInk('#000000'), 'var(--ks-lacquer-raised)');
});
test('rejects raw, encoded, and double-encoded path traversal', async (t) => {
const fixture = await createFixture();
const server = await startPicker(fixture.cwd, ['--port', String(portBase + 20)]);