color — OKLCH palette, verified not eyeballed
Stage: Phase 3 — Foundation (first system skill, right after direction) - Reads: design/DIRECTION.md, design/BRIEF.md - Writes: design/SYSTEM.md §color (the full :root/.dark value table ultraweb:tokens encodes)
Standard
- Everything is
oklch(L C H), L 0–1. Perceptually uniform: equal L reads as equal lightness across hues, so ramps and contrast become arithmetic. Never hex, hsl, or stock Tailwind palette names.
- One tinted neutral family + ONE accent doing real work (taste requirement). A second accent needs an explicit DIRECTION.md justification.
- Neutrals carry hue: C 0.005–0.025 across the ramp.
oklch(x 0 0) pure gray is the smell of no decision.
- Dark theme is re-decided surface by surface. Lightness inversion is a defect, not a theme.
- Every foreground/surface pair passes WCAG AA computationally — script output pasted into SYSTEM.md, both themes. "Looks fine" is not a result.
Process
- Read DIRECTION.md: the archetype fixes a temperature (warm/cool), an accent hue neighborhood, and an energy level → choose accent hue H and peak chroma.
- Derive the neutral hue: reuse the accent hue for harmony, or rotate 150–210° for tension. Set neutral C in 0.005–0.025.
- Build both ramps on the canonical L steps below; check gamut (identical-looking neighbors = clipping).
- Assign semantic tokens for light; RE-DECIDE every value for dark using the rules below.
- Run the contrast script on every pair, both themes; tune L (not C) until green; record the value table + script output in SYSTEM.md §color.
Ramp construction
Canonical lightness steps (same for neutral and accent ramps):
step 50 100 200 300 400 500 600 700 800 900 950
L 0.985 0.96 0.92 0.86 0.77 0.67 0.58 0.49 0.40 0.31 0.22
- Accent chroma tapers: ≤0.04 at 50/100, climbing to a peak of 0.12–0.19 at 500–600, back to ≤0.08 at 950. Constant chroma reads neon at the ends and clips out of gamut.
- Neutral chroma stays flat 0.005–0.025, with the peak (≈0.02) in the middle steps where tint is most visible.
- Hue holds within ±5° across the accent ramp — EXCEPT yellows/oranges (H 70–110): shift hue 10–20° toward red as L drops, or the dark steps turn muddy-green.
- sRGB gamut: C above ~0.15 clips at high or low L for many hues. Browsers gamut-map silently; the symptom is two neighboring steps rendering identically. Fix by lowering C, never L.
- Ramps are the quarry, not the API — components consume only the semantic tokens below.
Semantic tokens
Name roles, not colors. The canonical set, as FULL oklch values in :root/.dark (never HSL triplets):
background/foreground · card/card-foreground · popover/popover-foreground · primary/primary-foreground · secondary/secondary-foreground · muted/muted-foreground · accent/accent-foreground · destructive · border · input · ring plus --shadow-color for ultraweb:depth. Extend (--success, --warning) only when BRIEF.md has flows that need them — defined in BOTH themes or not at all. Contrast is verified on PAIRS: every *-foreground against its own surface, plus foreground on background/muted/card.
Dark re-decision rules
- Background: L 0.14–0.22, tinted (keep C ≥0.01). Never
oklch(0 0 0).
- Elevation flips: surfaces get LIGHTER as they rise — card ≈ background L +0.03–0.05. In light mode elevation is shadow; in dark it is lightness.
- Accent: raise L by +0.10–0.18 and cut C by 10–25% vs the light-mode accent. The light-mode value on a dark ground either glows or fails contrast.
- Borders: background L +0.06–0.10 — lighter than the surface, doing the separation work shadows did in light mode.
muted-foreground still needs ≥4.5:1 — it is the pair that fails most often in dark themes.
- Re-verify every pair. A green light theme proves nothing about dark.
Verify AA computationally
Thresholds: ≥4.5:1 body text and muted-foreground; ≥3:1 large text (≥24px, or ≥18.66px bold — hero display qualifies) and non-text UI (input borders, ring vs background, icon strokes — WCAG 1.4.11).
Playwright MCP: browser_navigate to about:blank, then browser_evaluate. The canvas resolves oklch to gamut-mapped sRGB, so the math runs on what browsers actually paint:
() => {
const pairs = [ // [name, fg, bg] — every *-foreground/surface pair, BOTH themes
["fg/bg light", "oklch(0.24 0.02 60)", "oklch(0.98 0.008 85)"],
["primary-fg/primary light", "oklch(0.98 0.01 85)", "oklch(0.53 0.16 45)"],
];
const ctx = Object.assign(document.createElement("canvas"), { width: 1, height: 1 }).getContext("2d");
const rgb = c => { ctx.fillStyle = c; ctx.fillRect(0, 0, 1, 1); return [...ctx.getImageData(0, 0, 1, 1).data]; };
const lum = ([r, g, b]) => { const f = v => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); };
return pairs.map(([n, fg, bg]) => { const [hi, lo] = [lum(rgb(fg)), lum(rgb(bg))].sort((a, b) => b - a); return `${n}: ${((hi + 0.05) / (lo + 0.05)).toFixed(2)}:1`; });
}
Offline alternative: culori and its WCAG contrast helper — verify against current docs first. Aiming heuristics (the script decides; these only place the first guess): on an L≈0.98 ground, text needs L ≤≈0.55; on an L≈0.17 ground, text needs L ≥≈0.72.
CVD safety — same values, three more times
Contrast is lightness; color-vision deficiency is hue. The canvas step above already resolved every token to gamut-mapped sRGB — reuse those triplets, never re-derive. Push each one through the deuteranopia, protanopia, and tritanopia simulation matrices (Machado/Brettel coefficients — verify against a current source before pasting), then re-check separation on the simulated values.
- Check the palette as a MATRIX, not pair-by-pair: every semantically-opposed pair against the others, under all three deficiencies. A pair that survives in isolation can still collapse into its neighbor.
- The pairs that must stay distinguishable:
destructive vs success (the classic red/green collapse), warning vs destructive, primary vs destructive on the same surface, and every adjacent series color ultraweb:data-display hands to a chart.
- A pair that merges under any deficiency is fixed with a LIGHTNESS gap (ΔL ≥ 0.15) or a second channel — icon, pattern, position, label. A hue nudge usually fixes one deficiency and breaks another.
- This is where WCAG 1.4.1 is really tested: the check finds the places color quietly became the only carrier of meaning.
Anti-patterns
from-purple-, to-blue-, from-pink-, to-violet- — the banned gradient aesthetic (taste), regardless of how it is smuggled in.
bg-gray-, text-slate-, bg-zinc- — stock untinted palette instead of tokens.
#-hex, rgb(, hsl( anywhere; oklch( outside globals.css and SYSTEM.md.
- Zero-chroma ramps (
oklch(0.5 0 0) and friends) — gray pretending to be a decision.
- Dark theme produced by flipping L only, or
dark:invert.
- Any AA or CVD claim without script output recorded in SYSTEM.md.
- Accent as decoration: chroma-0.19 background washes, gradient heroes. The accent marks actions and emphasis; surfaces belong to the neutrals.
Worked example — Framewalk, "Hollow Cartographer" Steam launch
Moved to references/example.md — read only when this build's case is genuinely ambiguous; the sections above are the decision material.
Composes with
Moved to references/composes.md — the handoff map; load it when orchestrating this skill against its neighbors.
1---2name: color3description: Construct the site's OKLCH palette from the chosen direction — a hue-tinted neutral ramp plus ONE working accent, built on disciplined lightness steps and chroma limits, named as semantic tokens (background/foreground/primary/muted/border/ring…), with a dark theme re-decided surface by surface (never inverted) and every foreground/surface pair verified for WCAG AA contrast computationally, not by eye. Invoke in the Foundation phase right after ultraweb:direction, whenever a palette must be built or rebuilt, or when the user says "pick the colors", "build the palette", "fix the contrast", "the colors feel off", "add dark mode", or "design the dark theme".4---56# color — OKLCH palette, verified not eyeballed78**Stage:** Phase 3 — Foundation (first system skill, right after direction) - **Reads:** design/DIRECTION.md, design/BRIEF.md - **Writes:** design/SYSTEM.md §color (the full :root/.dark value table ultraweb:tokens encodes)910## Standard1112- Everything is `oklch(L C H)`, L 0–1. Perceptually uniform: equal L reads as equal lightness across hues, so ramps and contrast become arithmetic. Never hex, hsl, or stock Tailwind palette names.13- One tinted neutral family + ONE accent doing real work (taste requirement). A second accent needs an explicit DIRECTION.md justification.14- Neutrals carry hue: C 0.005–0.025 across the ramp. `oklch(x 0 0)` pure gray is the smell of no decision.15- Dark theme is re-decided surface by surface. Lightness inversion is a defect, not a theme.16- Every foreground/surface pair passes WCAG AA computationally — script output pasted into SYSTEM.md, both themes. "Looks fine" is not a result.1718## Process19201. Read DIRECTION.md: the archetype fixes a temperature (warm/cool), an accent hue neighborhood, and an energy level → choose accent hue H and peak chroma.212. Derive the neutral hue: reuse the accent hue for harmony, or rotate 150–210° for tension. Set neutral C in 0.005–0.025.223. Build both ramps on the canonical L steps below; check gamut (identical-looking neighbors = clipping).234. Assign semantic tokens for light; RE-DECIDE every value for dark using the rules below.245. Run the contrast script on every pair, both themes; tune L (not C) until green; record the value table + script output in SYSTEM.md §color.2526## Ramp construction2728Canonical lightness steps (same for neutral and accent ramps):2930```31step 50 100 200 300 400 500 600 700 800 900 95032L 0.985 0.96 0.92 0.86 0.77 0.67 0.58 0.49 0.40 0.31 0.2233```3435- Accent chroma tapers: ≤0.04 at 50/100, climbing to a peak of 0.12–0.19 at 500–600, back to ≤0.08 at 950. Constant chroma reads neon at the ends and clips out of gamut.36- Neutral chroma stays flat 0.005–0.025, with the peak (≈0.02) in the middle steps where tint is most visible.37- Hue holds within ±5° across the accent ramp — EXCEPT yellows/oranges (H 70–110): shift hue 10–20° toward red as L drops, or the dark steps turn muddy-green.38- sRGB gamut: C above ~0.15 clips at high or low L for many hues. Browsers gamut-map silently; the symptom is two neighboring steps rendering identically. Fix by lowering C, never L.39- Ramps are the quarry, not the API — components consume only the semantic tokens below.4041## Semantic tokens4243Name roles, not colors. The canonical set, as FULL oklch values in `:root`/`.dark` (never HSL triplets):44`background/foreground · card/card-foreground · popover/popover-foreground · primary/primary-foreground · secondary/secondary-foreground · muted/muted-foreground · accent/accent-foreground · destructive · border · input · ring` plus `--shadow-color` for ultraweb:depth. Extend (`--success`, `--warning`) only when BRIEF.md has flows that need them — defined in BOTH themes or not at all. Contrast is verified on PAIRS: every `*-foreground` against its own surface, plus foreground on background/muted/card.4546## Dark re-decision rules4748- Background: L 0.14–0.22, tinted (keep C ≥0.01). Never `oklch(0 0 0)`.49- Elevation flips: surfaces get LIGHTER as they rise — card ≈ background L +0.03–0.05. In light mode elevation is shadow; in dark it is lightness.50- Accent: raise L by +0.10–0.18 and cut C by 10–25% vs the light-mode accent. The light-mode value on a dark ground either glows or fails contrast.51- Borders: background L +0.06–0.10 — lighter than the surface, doing the separation work shadows did in light mode.52- `muted-foreground` still needs ≥4.5:1 — it is the pair that fails most often in dark themes.53- Re-verify every pair. A green light theme proves nothing about dark.5455## Verify AA computationally5657Thresholds: **≥4.5:1** body text and muted-foreground; **≥3:1** large text (≥24px, or ≥18.66px bold — hero display qualifies) and non-text UI (input borders, ring vs background, icon strokes — WCAG 1.4.11).5859Playwright MCP: `browser_navigate` to `about:blank`, then `browser_evaluate`. The canvas resolves oklch to gamut-mapped sRGB, so the math runs on what browsers actually paint:6061```js62() => {63 const pairs = [ // [name, fg, bg] — every *-foreground/surface pair, BOTH themes64 ["fg/bg light", "oklch(0.24 0.02 60)", "oklch(0.98 0.008 85)"],65 ["primary-fg/primary light", "oklch(0.98 0.01 85)", "oklch(0.53 0.16 45)"],66 ];67 const ctx = Object.assign(document.createElement("canvas"), { width: 1, height: 1 }).getContext("2d");68 const rgb = c => { ctx.fillStyle = c; ctx.fillRect(0, 0, 1, 1); return [...ctx.getImageData(0, 0, 1, 1).data]; };69 const lum = ([r, g, b]) => { const f = v => { v /= 255; return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); };70 return pairs.map(([n, fg, bg]) => { const [hi, lo] = [lum(rgb(fg)), lum(rgb(bg))].sort((a, b) => b - a); return `${n}: ${((hi + 0.05) / (lo + 0.05)).toFixed(2)}:1`; });71}72```7374Offline alternative: `culori` and its WCAG contrast helper — verify against current docs first. Aiming heuristics (the script decides; these only place the first guess): on an L≈0.98 ground, text needs L ≤≈0.55; on an L≈0.17 ground, text needs L ≥≈0.72.7576## CVD safety — same values, three more times7778Contrast is lightness; color-vision deficiency is hue. The canvas step above already resolved every token to gamut-mapped sRGB — reuse those triplets, never re-derive. Push each one through the deuteranopia, protanopia, and tritanopia simulation matrices (Machado/Brettel coefficients — verify against a current source before pasting), then re-check separation on the simulated values.7980- Check the palette as a MATRIX, not pair-by-pair: every semantically-opposed pair against the others, under all three deficiencies. A pair that survives in isolation can still collapse into its neighbor.81- The pairs that must stay distinguishable: `destructive` vs `success` (the classic red/green collapse), `warning` vs `destructive`, `primary` vs `destructive` on the same surface, and every adjacent series color ultraweb:data-display hands to a chart.82- A pair that merges under any deficiency is fixed with a LIGHTNESS gap (ΔL ≥ 0.15) or a second channel — icon, pattern, position, label. A hue nudge usually fixes one deficiency and breaks another.83- This is where WCAG 1.4.1 is really tested: the check finds the places color quietly became the only carrier of meaning.8485## Anti-patterns8687- `from-purple-`, `to-blue-`, `from-pink-`, `to-violet-` — the banned gradient aesthetic (taste), regardless of how it is smuggled in.88- `bg-gray-`, `text-slate-`, `bg-zinc-` — stock untinted palette instead of tokens.89- `#`-hex, `rgb(`, `hsl(` anywhere; `oklch(` outside globals.css and SYSTEM.md.90- Zero-chroma ramps (`oklch(0.5 0 0)` and friends) — gray pretending to be a decision.91- Dark theme produced by flipping L only, or `dark:invert`.92- Any AA or CVD claim without script output recorded in SYSTEM.md.93- Accent as decoration: chroma-0.19 background washes, gradient heroes. The accent marks actions and emphasis; surfaces belong to the neutrals.9495## Worked example — Framewalk, "Hollow Cartographer" Steam launch9697Moved to `references/example.md` — read only when this build's case is genuinely ambiguous; the sections above are the decision material.9899## Composes with100101Moved to `references/composes.md` — the handoff map; load it when orchestrating this skill against its neighbors.