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
+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('/')) {