Skip to content

Reference · craft

Color

craft/references/color.md125 linesupdated 16 Sept 2026

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-brandhover: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-accents and design/palette-sprawl catch 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 #000 canvas is a brand choice, not a default. Allowed only with surfaces ≥ L 18% and visible hairlines (the owner's portfolio uses #000 + #0e0e11 cards with a #272730 hairline).

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):

ts
// 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 58

Step 2: generate the scale with the void generator (validates sRGB gamut and prints WCAG + APCA contrast per role):

bash
# 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 58

It 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:

css
@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.