Token foundations
Build or audit semantic design tokens in OKLCH, light and dark.
$ npx skills add tonygorb/skills --skill token-foundationsPick your agents from a list.
$ npx skills add tonygorb/skills --skill token-foundations -g -a claude-code -yInstalls into ~/.claude/skills/.
Open CustomizeSkills and upload it.
$ npx skills add tonygorb/skills --skill token-foundations -g -a codex -yInstalls into ~/.agents/skills/.
$ npx skills add tonygorb/skills --skill token-foundations -g -a cursor -yInstalls into ~/.agents/skills/.
$ npx skills add tonygorb/skills --skill token-foundations -g -a github-copilot -yInstalls into ~/.agents/skills/.
$ npx skills add tonygorb/skills --skill token-foundations -g -a gemini-cli -yInstalls into ~/.agents/skills/.
$ npx skills add tonygorb/skills --skill token-foundations -g -a opencode -yInstalls into ~/.agents/skills/.
$ npx skills add tonygorb/skills --skill token-foundations -g -a windsurf -yInstalls into ~/.codeium/windsurf/skills/.
Your agent uses it when you say
- “set up design tokens”
- “audit the token foundation”
- “OKLCH colour scales”
What it does
Build the token layer a product's UI stands on, or audit the one it has. Colour in OKLCH, a semantic layer over raw scales, light and dark from day one, small scales for type, space and radius. Structure stays boring so every screen built on it comes out consistent.
How it works
- 1Look before you write
- 2Ask for the brand inputs
- 3Generate the colour primitives in OKLCH
- 4Set the other primitive scales
- 5Map the semantic layer
- 6Add component tokens only where needed
- 7Write the theme
- 8Audit: compute contrast, then score
- 9Report
Token foundations
Build the token layer a product's UI stands on, or audit the one it has. Colour in OKLCH, a semantic layer over raw scales, light and dark from day one, small scales for type, space and radius. Structure stays boring so every screen built on it comes out consistent.
This skill works on the tokens themselves. To sweep components for hard-coded colours and swap in
tokens, use polish-design-system after this.
The rule underneath
Three layers, strictly separate.
- Primitives. Raw values with no meaning:
color.brand.500,spacing.4,radius.md. - Semantic tokens. Say what a value is for and point at a primitive:
background,surface.default,text.primary,action.primary. Every colour role declares a light and a dark value. - Component tokens. Only when a reusable component needs one:
button.height.md,card.radius. Never per page.
Components use semantic and component tokens only. Never primitives, never raw values.
Keep one source of truth. It can be a tokens JSON file that generates the theme CSS, or the theme CSS itself. Everything else is derived from it. Never hand-maintain two copies.
Steps
1. Look before you write
Find what the project already has:
- The stack and the theme file (
app/globals.css,src/styles/global.css,src/index.css, or wherever the global CSS lives). - Tailwind and its version: v4 uses an
@themeblock in CSS, v3 atailwind.configfile. - Existing tokens: JSON files,
:rootcustom properties, a@themeblock. - How dark mode switches today: a
.darkclass, adata-themeattribute, the system setting.
Then decide the job with the user: set up (steps 2 to 7) or audit (step 8, read-only).
2. Ask for the brand inputs
Ask only what the brief doesn't answer. Three to six questions at most:
- Brand seed colour. Take it as OKLCH (
oklch(60% 0.18 250)), or convert the hex they give you. - An accent seed, if there is one.
- Neutrals: tinted toward the brand hue, or pure grey.
- Fonts: sans, and mono if needed.
- Personality and audience, in a few words.
- Anti-references: what it must not look like.
3. Generate the colour primitives in OKLCH
OKLCH is perceptually even: the same lightness step looks the same on every hue, so contrast is predictable. Never put hex or HSL in the source of truth.
Every scale has eleven steps, 50 to 950, with one hue and a fixed lightness ramp. Generate it; never hand-pick eleven colours.
node -e '
const [L, C, H] = process.argv[1].split(" ").map(Number); // seed "60 0.18 250"
function linear(l, c) { // OKLCH (l in %) to linear sRGB (Ottosson)
const a = c * Math.cos(H * Math.PI / 180), b = c * Math.sin(H * Math.PI / 180), Lp = l / 100;
const x = (Lp + 0.3963377774 * a + 0.2158037573 * b) ** 3;
const y = (Lp - 0.1055613458 * a - 0.0638541728 * b) ** 3;
const z = (Lp - 0.0894841775 * a - 1.2914855480 * b) ** 3;
return [4.0767416621 * x - 3.3077115913 * y + 0.2309699292 * z,
-1.2684380046 * x + 2.6097574011 * y - 0.3413193965 * z,
-0.0041960863 * x - 0.7034186147 * y + 1.7076147010 * z];
}
const inGamut = (l, c) => linear(l, c).every(v => v >= -1e-4 && v <= 1 + 1e-4);
const steps = [[50,97,.10],[100,94,.18],[200,88,.35],[300,80,.55],[400,70,.80],
[500,60,1.0],[600,52,.95],[700,44,.85],[800,35,.65],[900,25,.45],[950,16,.30]];
const out = {}, clamped = [];
for (const [step, l, cMul] of steps) {
let c = C * cMul;
while (c > 0 && !inGamut(l, c)) c = Math.max(0, c - 0.005); // lower chroma until it fits sRGB
if (c < C * cMul) clamped.push(step);
out[step] = `oklch(${l}% ${c.toFixed(3)} ${H})`;
}
console.log(JSON.stringify(out, null, 2));
console.log("clamped to fit sRGB:", clamped.join(", ") || "none");
' "60 0.18 250"
The scales to make:
- brand from the brand seed.
- neutral with the brand hue and chroma at 0.05 times the seed's (tinted), or chroma 0 (pure
grey). Add an
850step between 800 and 900 for raised surfaces in dark mode, and0for pure white (oklch(100% 0 0)), so no semantic token has to hold a rawwhite. - accent from the accent seed, only if there is one.
- State scales at fixed hues, whatever the brand: success 145, warning 75, danger 25, info 240. Move info if the brand itself is blue.
The script prints which steps it had to clamp to stay in sRGB. List them in the report.
4. Set the other primitive scales
Keep them small and reuse them hard. A sensible default set:
| Family | Steps |
|---|---|
| Spacing | 0, 0.5, 1, 1.5, 2, 3, 4, 5, 6, 8, 10, 12, 16, 20, 24, 32 (on a 4px unit: 4 = 16px) |
| Radius | none, xs, sm, md, lg, xl, 2xl, full |
| Font size | xs 12, sm 14, base 16, lg 18, xl 20, 2xl 24, 3xl 30, 4xl 36, 5xl 48, 6xl 60, 7xl 72 |
| Font weight | regular, medium, semibold, bold |
| Line height | tight 1.1, snug 1.25, normal 1.5, relaxed 1.625, loose 1.75 |
| Letter spacing | tighter, tight, normal, wide |
| Shadow | xs, sm, md, lg, xl |
| Motion | durations instant, fast, base, slow, slower; easings linear, standard, emphasized, decelerated, accelerated |
| Breakpoint | sm, md, lg, xl, 2xl |
| Z-index | base, dropdown, sticky, fixed, modal, popover, tooltip, toast |
On top of the font sizes, name the text styles the UI uses: display (large, medium), heading (xl, lg, md, sm), body (lg, md, sm), caption and label. Each one fixes size, line height, weight and letter spacing together.
On an existing product, radius and the type scale are brand decisions. Ask before you change them.
5. Map the semantic layer
Every role below exists, and every colour role has both modes. A good starting map:
| Role | Light | Dark |
|---|---|---|
background |
neutral.50 | neutral.950 |
foreground |
neutral.950 | neutral.50 |
surface.default |
neutral.0 | neutral.900 |
surface.muted |
neutral.100 | neutral.800 |
surface.elevated |
neutral.0 | neutral.850 |
surface.inverse |
neutral.950 | neutral.50 |
border.default |
neutral.200 | neutral.800 |
border.strong |
neutral.500 | neutral.600 |
border.focus |
brand.500 | brand.400 |
text.primary |
neutral.950 | neutral.50 |
text.secondary |
neutral.700 | neutral.300 |
text.muted |
neutral.600 | neutral.400 |
text.inverse |
neutral.50 | neutral.950 |
action.primary |
brand.600 | brand.500 |
action.primary-hover |
brand.700 | brand.400 |
action.primary-foreground |
neutral.0 | neutral.950 |
action.secondary |
neutral.100 | neutral.800 |
action.secondary-hover |
neutral.200 | neutral.700 |
action.secondary-foreground |
neutral.950 | neutral.50 |
action.disabled |
neutral.200 | neutral.800 |
state.{success,warning,danger,info} |
that scale's 600 | that scale's 600 |
state.{role}-foreground |
neutral.0 | neutral.0 |
Add accent and accent-foreground only if there is an accent seed. accent is never the
primary call to action: that is action.primary.
border.default is for dividers and card edges, which are decorative; border.strong is for
input and control outlines, which must reach 3:1.
State colours mean system state only, never decoration, and always come with an icon or text.
6. Add component tokens only where needed
Button and input heights, card padding, section padding, container widths, and component radii
that point at the radius scale (button.radius = radius.md). Nothing named after a page or a
section: no hero.bg, no pricing.card.shadow.
7. Write the theme
Naming: dot notation in JSON (action.primary), kebab-case as CSS variables
(--color-action-primary).
- Tailwind v4: primitives and the light semantic values go in
@theme, so utilities likebg-backgroundexist. Dark values override the semantic variables only. - Tailwind v3 or no Tailwind: write
:root { … }and.dark { … }custom properties, and extend the Tailwind config through those variables if Tailwind is there.
@theme {
--color-neutral-50: oklch(97% 0.001 250);
/* … every primitive … */
--color-background: var(--color-neutral-50);
--color-text-primary: var(--color-neutral-950);
}
:root.dark, :root[data-theme="dark"] {
--color-background: var(--color-neutral-950);
--color-text-primary: var(--color-neutral-50);
}
@media (prefers-color-scheme: dark) {
:root:not(.light):not([data-theme="light"]) { /* same dark overrides */ }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
}
:root { color-scheme: light dark; } /* follows the system when no theme is forced */
:root.dark, :root[data-theme="dark"] { color-scheme: dark; }
:root.light, :root[data-theme="light"] { color-scheme: light; }
Match the dark switch to the one the project already uses. Wrap the generated block in marker comments so a later run replaces only that block. If the file has other CSS, merge; never overwrite it.
Then run step 8 on what you wrote.
8. Audit: compute contrast, then score
Read-only. Change nothing in this step.
Contrast. Resolve every pair to its OKLCH values and compute real WCAG ratios, in both modes. Never estimate by eye.
node -e '
function toRgb(L, C, H) { // OKLCH (L in %) to gamma-encoded sRGB 0-1 (Ottosson)
const Lp = L / 100, a = C * Math.cos(H * Math.PI / 180), b = C * Math.sin(H * Math.PI / 180);
const l = (Lp + 0.3963377774 * a + 0.2158037573 * b) ** 3;
const m = (Lp - 0.1055613458 * a - 0.0638541728 * b) ** 3;
const s = (Lp - 0.0894841775 * a - 1.2914855480 * b) ** 3;
const lin = [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.7076147010 * s];
const enc = v => v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
return lin.map(v => Math.max(0, Math.min(1, enc(v))));
}
function lum(rgb) {
const f = c => c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
const [r, g, b] = rgb.map(f); return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
const pairs = JSON.parse(process.argv[1]); // [{name, fg:[L,C,H], bg:[L,C,H], min}]
for (const p of pairs) {
const [x, y] = [lum(toRgb(...p.fg)), lum(toRgb(...p.bg))];
const ratio = (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
console.log(`${ratio >= p.min ? "pass" : "FAIL"} ${ratio.toFixed(2)} (min ${p.min}) ${p.name}`);
}
' '[{"name":"text.primary on background (light)","fg":[16,0.003,250],"bg":[97,0.001,250],"min":4.5}]'
Pairs to check, light and dark:
| Pair | Minimum |
|---|---|
text.primary on background, surface.default, surface.muted |
4.5 |
text.secondary on background |
4.5 |
text.muted on background |
4.5 (3 only if it is never used below 24px, or 18.66px bold) |
action.primary-foreground on action.primary |
4.5 |
action.secondary-foreground on action.secondary |
4.5 |
state.{role}-foreground on state.{role} |
4.5 |
border.strong on background |
3 |
border.focus on background |
3 |
To fix a failure, move the semantic role one primitive step (for example text.muted light from
neutral.600 to neutral.700). Never retune the primitive scale by hand to rescue one pair.
Score each area 1 to 10:
- Tokens: all colour in OKLCH, eleven steps per scale (neutral adds 0 and 850), constant hue per scale, no page tokens, the non-colour families present.
- Semantic layer: every required role, both modes for every colour role, semantic values point at primitives instead of holding raw values.
- Contrast: the table above, both modes.
- Maintainability: one source of truth, no copy-paste between the JSON and the CSS, re-runs replace a marked block only.
- Design-tool readiness: every semantic token has a code name and a design-file name
(
--color-action-primaryandColor/Action/Primary).
For a quick count of components that bypass the tokens, grep for raw palette classes and bracket
values (bg-blue-500, bg-[#…]). Report the count; hand the sweep to polish-design-system.
9. Report
## Token foundation: <set up | audit>
| Area | Score |
|---|---:|
| Tokens | X/10 |
| Semantic layer | X/10 |
| Contrast | X/10 |
| Maintainability | X/10 |
| Design-tool readiness | X/10 |
Contrast, light: <pair, ratio, pass or fail, one per line>
Contrast, dark: <same>
Gamut clamps: <list, or "none">
Critical issues: <list>
Recommended fixes: <list>
Files written: <list, or "none (audit)">
Below 7 overall, recommend fixes before anything is built on top or pushed to a design file. On an audit, propose the fixes and wait for a yes before you apply any.
Never
- Hex or HSL in the source of truth.
- A hue that shifts across the steps of one scale.
- A semantic colour with only a light value. Retrofitting dark later is painful.
- A token for one element or one page ("just for this hero gradient"). Use a semantic role or drop it.
- Components that read primitives or raw values directly.
- Different names for the same token in code and in the design file. Fix the name map instead.
- Overwriting hand-written CSS. Merge, or back the file up first and say where.
- Building pages, heroes or marketing sections here. This skill makes the foundation only.
- Contrast numbers you did not compute.
Red flags: you're about to cut a corner
- "Dark mode can wait for v1." It can't. Map both modes now.
- "These eleven colours look right." Generate the scale; looking right isn't a ramp.
- "The muted text is probably fine." Run the contrast script.
- "I'll just add one more token." Check whether an existing semantic role already says it.