1. Roles (what components may use)
| Role | Utility | Rule |
|---|---|---|
| Page | bg-bg |
set on html/body by base; don't repeat |
| Band / sidebar / code block | bg-bg-subtle |
alternate section bands sparingly (≤ 2 per page) |
| Card / panel | bg-surface |
in light mode often pure white on a tinted page |
| Popover / menu / dialog | bg-surface-raised |
+ shadow-2 / shadow-3 |
| Hover / pressed fill | hover:bg-surface-hover, active:bg-surface-active |
one step per state |
| Hairline | border-line-subtle |
dividers, card outlines on dark |
| Default border | border-line |
secondary buttons, cards that need an outline |
| Input boundary | border-line-strong |
form fields: the boundary must reach 3:1 against the surface (WCAG 1.4.11, see a11y) |
| Emphasis rule | border-line-strong |
section-head rules, hovered input |
| Focus | outline-line-focus |
base layer already applies it |
| Text | text-fg / text-fg-muted / text-fg-subtle |
all three pass AA 4.5:1; subtle never for paragraphs (meta, captions, placeholders only) |
| Disabled / decorative text | text-fg-faint |
3:1 only — disabled controls and aria-hidden decoration, never text someone must read |
| Text on brand solid | text-fg-on-brand |
amber/lime brands get dark text automatically |
| Brand solid | bg-brand → hover:bg-brand-hover |
primary CTA only (one per viewport) |
| Brand tint | bg-brand-subtle + text-brand-text + border-brand-line |
callouts, active nav item, selected row |
| Brand text | text-brand-text |
links, $ prompts, one highlighted word max |
| Status | text-success bg-success-subtle, same for warning, danger |
status only, with icon or label |
Opacity modifiers work on all roles (bg-bg/80, border-brand/40) because v4 uses color-mix.
2. Accent budget
- 1 hue. Measured: Vercel blue on 1–2 text elements; basement orange on 1 element + selection; Anthropic clay on 1 background; the owner's DevEx amber at 1–2% coverage.
- Where it may appear: primary CTA, links, focus ring, active nav/tab, selection, caret, one data highlight, one ambient light (≤ 14% mix). Everything else is neutral.
- Where it may not: section backgrounds, icon fills in feature lists, headings, borders of every card, badges for decoration.
- Test: squint at the 1440 screenshot. If you can count more than ~5 brand-colored spots in the fold, remove some.
design/multiple-accentsanddesign/palette-sprawlcatch the rest. - Status colors must differ from the brand hue by ≥ 30° or in lightness (terminal's amber brand vs yellower warning). Editorial and swiss have red brands: pair danger with an icon and text.
3. Neutrals
- Tinted toward the mood hue with chroma 0.002–0.02 (the directions already do). Warm (hue 60–90) for editorial/warm, cool (255–275) for precision/instrument, 0 for swiss.
- Three text tiers, measured: Linear #f7f8f8 / #8a8f98 / #62666d; Cursor fg at 100 / 60 / 40%.
- Pure
#000canvas is a brand choice, not a default. Allowed only with surfaces ≥ L 18% and visible hairlines (the owner's portfolio uses#000+#0e0e11cards with a#272730hairline).
4. Dark mode (separate scale)
| Property | Light | Dark |
|---|---|---|
| Page L | 98–100% | 14–18% |
| Elevation | shadow grows | surface gets lighter (+3% L per level) |
| Lines | fg at 7–20% alpha | white at 6–17% alpha |
| Text | L 19–23% | L 93–96% (never #fff) |
| Muted text | L 45–50% | L 76–79% |
| Brand solid | same L as dark | same L as light (recognition) |
| Brand text | darker than solid | lighter than solid |
| Shadows | 2–3 soft layers | inset top highlight + 1 dark layer on overlays only |
Rules: never write dark:bg-… on semantic utilities (duplicated logic); set color-scheme (base does); theme-color meta per scheme (template layout reads tokens.ts); saturated brand colors vibrate on near-black, so lift L for text use (--brand-text does); dim non-photo images dark:brightness-90.
5. Custom brand accent from a hex
Use when the user gives a brand color. Keep the direction's neutrals; replace only the brand roles.
Step 1: hex → OKLCH. Paste into a file and run with Bun (or read the value from oklch.com):
// hex2oklch.ts — bun hex2oklch.ts "#d83831"
const hex = (process.argv[2] ?? "").replace("#", "");
const [r, g, b] = [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
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), H = ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360;
console.log(`oklch(${(L * 100).toFixed(1)}% ${C.toFixed(3)} ${H.toFixed(1)}) --accent-hue ${H.toFixed(0)} --accent-chroma ${C.toFixed(2)} --s9 ${(L * 100).toFixed(0)}`);
// "#d83831" → oklch(58.4% 0.198 27.7) --accent-hue 28 --accent-chroma 0.20 --s9 58Step 2: generate the scale with the void generator (validates sRGB gamut and prints WCAG + APCA contrast per role):
# neutral: keep the direction's (read hue/chroma from its --bg); accent: values from step 1
bun packages/tokens/scripts/generate.ts scale \
--neutral-hue 275 --neutral-chroma 0.018 \
--accent-hue 28 --accent-chroma 0.20 --s9 58It prints :root, .light and .dark blocks with 12-step scales and a semantic mapping (--brand, --brand-hover, --brand-subtle, --brand-line, --brand-text, plus neutrals) annotated with contrast. Outside the void repo, run it from the plugin root: bun ${CLAUDE_PLUGIN_ROOT}/packages/tokens/scripts/generate.ts scale ….
Step 3: override only the brand roles in src/app/globals.css, after the direction import:
@import "../styles/void/directions/precision.css";
/* Brand override: Acme red #d83831. Generated with generate.ts scale (hue 28, C .20, s9 58). */
:root, .light, [data-theme="light"] {
--brand: oklch(58% 0.2 28); /* keep the user's exact L for the solid */
--brand-hover: oklch(55.3% 0.19 28);
--brand-subtle: oklch(96% 0.018 28);
--brand-line: oklch(81% 0.08 28);
--brand-text: oklch(51% 0.17 28); /* ≥ 4.5:1 on --bg */
--line-focus: oklch(55% 0.19 28);
}
.dark, [data-theme="dark"] {
--brand: oklch(58% 0.2 28);
--brand-hover: oklch(62% 0.18 28);
--brand-subtle: oklch(27% 0.06 28);
--brand-line: oklch(47% 0.12 28);
--brand-text: oklch(76% 0.12 28);
--line-focus: oklch(70% 0.14 28);
}Step 4: text on the solid. If the brand solid L ≥ 70% (amber, yellow, lime, cyan), set --fg-on-brand to the direction's near-black --bg value; otherwise keep white. Check it: white on the solid ≥ 4.5:1 for 13–16px button text.
Step 5: collisions. If the new hue is within 30° of a status hue (red brand vs danger, amber vs warning, green vs success), shift the status hue (warning → hue 85–95) or rely on icon + label. Then run void lint and void design ; design/multiple-accents must stay clean.
Brand gradients: only same-hue, OKLCH-interpolated, ≤ 60° hue travel: bg-linear-to-b from-brand to-brand-hover is fine on one element; purple→pink is not.
6. Selection, caret, native controls
Base layer: ::selection = brand at 32%, accent-color and caret-color = brand. Don't override per component. Terminal direction may use a solid selection (::selection { background: var(--brand); color: var(--fg-on-brand) }), which is the basement/hellohello signature.
7. Hex mirrors
Canvas, WebGL, next/og images and the manifest can't read var()/oklch(). Use the generated src/styles/void/tokens.ts (from packages/tokens/scripts/sync-template.ts) or resolve at runtime (motion/references/webgl-canvas.md). Never hand-maintain a second palette.