Design & UX No. 348

Color Systems skill for Claude Code

OKLCH palettes where every pair passes contrast. Instead of picking hex codes by eye or defaulting to an indigo gradient, Claude generates perceptually even OKLCH scales, assigns them to semantic roles like muted text and input borders, and runs a bundled Python script that checks every pair against WCAG 2.2 thresholds. Dark mode gets its own mapping with lighter accents and stepped surfaces rather than an inversion.

  • namecolor-systems
  • size192 lines · ≈1,900 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Builds color palettes in OKLCH with perceptually even lightness steps, maps them to semantic roles (surface, text, border, accent, status), checks every pair against WCAG contrast, and derives a dark-mode mapping. Includes a stdlib script for contrast ratios, hex-OKLCH conversion, and gamut-safe scale generation. Use when choosing brand or UI colors, fixing low-contrast text, replacing a default purple gradient, or extending a palette.

Try asking

  • Build a color palette from our brand blue that passes AA everywhere
  • Our gray text fails contrast audits. Fix the neutral scale without changing the look much
  • Replace this purple gradient landing page palette with something that feels like our product

Use it when

  • Choosing brand or product UI colors
  • Accessibility audit flagged low-contrast text
  • Extending a palette with status or chart colors
  • Planning the color side of a dark theme

Skip it when

  • You need the full token architecture across type, space and radius, use design-system-tokens instead
  • The palette is fine and you need theme toggling mechanics, use dark-mode instead

How to check the result

Run scripts/color_tool.py contrast on every used text and background pair in both themes; it exits 0 only when all pairs meet the minimum ratio.

The common mistake it prevents

Using semi-transparent gray for muted text, which passes on white but drops below 4.5:1 on tinted surfaces and cards.

~/.claude/skills/color-systems/SKILL.md

--- name: color-systems description: Builds color palettes in OKLCH with perceptually even lightness steps, maps them to semantic roles (surface, text, border, accent, status),… license: MIT ---

A palette is done when every hue has evenly stepped lightness, every text/background pair used in the product passes WCAG 2.2 AA, colors are referenced by role rather than by hue, and one accent does the talking. A palette chosen by eyeballing hex codes in a color picker is not done.

Ground rules

  • Build scales in OKLCH. Equal steps in L look equal to people; equal steps in HSL do not (HSL yellow at 50% lightness is far brighter than blue at 50%).
  • One accent hue for interactive elements. A second hue only with a job (e.g. data visualization, a distinct brand secondary used in illustration, never in buttons).
  • Neutrals carry 80–90% of the interface. Tint them slightly toward the accent hue (chroma 0.005–0.02) so they do not look dead.
  • Status colors (danger, warning, success, info) are semantic, not decorative. Never use red for a non-destructive accent.
  • Color is never the only signal: pair it with an icon, text, or shape (WCAG 1.4.1).

Avoid the generic look

Default reflexReplace with
Purple-to-blue gradient hero (#667eea → #764ba2)A flat, confident canvas with one saturated accent; gradients only if they are subtle (ΔL ≤ 0.05) or carry brand meaning
Pure #000 text on #fffNear-black tinted neutral, e.g. oklch(0.21 0.015 H) on oklch(0.99 0.005 H)
Gray text at 40% opacityAn opaque muted neutral that still hits 4.5:1
Rainbow of category colors on cardsNeutrals plus one accent; categories distinguished by label or icon
Neon accent on dark background everywhereLower chroma in dark mode (C × 0.8–0.9), raised lightness
Tailwind default indigo-500 as the brandChoose a hue from the product’s context; pick chroma deliberately

Workflow

- [ ] 1. Collect constraints (brand colors, existing UI, audience, dark mode needed?)
- [ ] 2. Choose accent hue and chroma
- [ ] 3. Generate 11-step scales (accent, neutral, 4 status hues)
- [ ] 4. Map semantic roles for light theme
- [ ] 5. Map semantic roles for dark theme
- [ ] 6. Check every used pair; adjust lightness, not hue
- [ ] 7. Emit tokens and document usage

2. Choose the accent

  • Start from the brand color if one exists; convert it to OKLCH and keep its hue.
  • Chroma guide: 0.12–0.16 for calm product UI, 0.18–0.24 for expressive marketing. Very high chroma (>0.25) falls outside sRGB for most hues; it will clip.
  • Hue sanity: blue 240–265, teal 180–200, green 140–155, amber 70–85, red 20–30, magenta 330–350. Yellow/amber hues cannot be both light and saturated with dark text contrast on white; use them as fills with dark text, not as text color.

3. Generate scales

Use the bundled script (stdlib only):

python3 scripts/color_tool.py scale 255 --chroma 0.16 --name blue
python3 scripts/color_tool.py scale 255 --chroma 0.015 --name neutral
python3 scripts/color_tool.py scale 25 --chroma 0.19 --name red

It prints steps 50–950 with OKLCH, hex, and contrast against white and black, reduces chroma until each step fits sRGB, and emits a :root block. Lightness targets: 50 = 0.975 … 500 = 0.62 … 950 = 0.24. Tweak by hand when a hue looks off; keep lightness, adjust chroma.

Convert existing colors before deciding:

python3 scripts/color_tool.py convert "#3b82f6" "oklch(0.62 0.21 260)"

4. Semantic mapping (light)

RoleStepTarget
bg.canvasneutral-50 or white—
bg.surfacewhite or neutral-50Distinct from canvas by L 0.01–0.03 or by a border
border.defaultneutral-200Decorative; no contrast requirement
border.strong (inputs)neutral-400/500≥ 3:1 against surface (WCAG 1.4.11)
text.defaultneutral-900≥ 12:1 preferred
text.mutedneutral-600≥ 4.5:1 on every surface it appears on
accent.default (fill)accent-600White text on it ≥ 4.5:1
accent.hoveraccent-700—
accent.text (links)accent-600/700≥ 4.5:1 on canvas
accent.subtle (selected row)accent-50/100Text on it still ≥ 4.5:1
danger.text / danger.bgred-700 / red-50≥ 4.5:1

5. Dark mapping

Do not invert. Re-map:

  • Canvas oklch(0.16–0.20 0.01 H), not #000. Surfaces step up in lightness for elevation (0.20 → 0.24 → 0.28) because shadows barely read on dark.
  • Text default ≈ neutral-100 (L 0.93–0.95), not pure white, to reduce halation.
  • Accent fill shifts lighter (accent-400/500) and slightly less chroma; check text on it — dark text may now be the correct pairing.
  • Status backgrounds become low-L, low-chroma tints (L 0.25, C 0.04–0.06).

Full theme mechanics (toggles, flash prevention, images) live in dark-mode.

6. Check every pair

List the pairs the UI actually uses and check them in one run:

python3 scripts/color_tool.py contrast \
  "#1f2937" "#ffffff" \
  "#6b7280" "#f9fafb" \
  "#ffffff" "oklch(0.54 0.154 255)" --min 4.5

Exit code 1 means a pair failed. Fix by moving lightness one step; keep hue so the palette stays coherent.

Thresholds (WCAG 2.2):

ContentMinimum (AA)Enhanced (AAA)
Body text < 24px (or < 18.66px bold)4.5:17:1
Large text ≥ 24px or ≥ 18.66px bold3:14.5:1
UI component boundaries, focus rings, icons that convey meaning3:1—
Disabled controls, decorative borders, logosexempt—

APCA (proposed for WCAG 3) is useful as a second opinion for very light or very dark pairs where WCAG 2 ratios mislead, but AA compliance today is judged on WCAG 2 ratios.

7. Emit tokens

:root {
  --blue-600: oklch(0.54 0.154 255);
  --accent: var(--blue-600);
  --accent-hover: oklch(0.46 0.138 255);
  --text: oklch(0.21 0.015 255);
  --text-muted: oklch(0.45 0.015 255);
}
/* hex fallback only if you must support browsers older than ~2023 */
@supports not (color: oklch(0 0 0)) {
  :root { --accent: #236ec5; }
}

Derived states without new tokens (modern browsers):

.button:hover { background: oklch(from var(--accent) calc(l - 0.06) c h); }
.badge { background: color-mix(in oklch, var(--accent) 12%, transparent); }

Gotchas

  • Opacity-based colors (rgba(…, 0.6)) produce different contrast on every surface; compute against the real background or use an opaque token.
  • Text over images or gradients: check contrast at the lightest point of the image, or add a scrim (linear-gradient(to top, oklch(0 0 0 / 0.6), transparent)).
  • Focus rings must hit 3:1 against both the component and the surrounding background; a 2px ring plus 2px offset in the accent color usually works.
  • P3 wide-gamut OKLCH values look different on sRGB screens; gamut-map with the script and treat the clipped hex as the real color for contrast checks.
  • Charts need their own categorical palette with similar lightness so no series dominates; see the dataviz guidance in dashboard-ui.

Verification

  • Every pair in the semantic table passes the contrast command in both themes.
  • Grayscale screenshot test: the hierarchy (primary action, headings, muted text) still reads with color removed (DevTools rendering emulation: achromatopsia).
  • Deuteranopia/protanopia emulation: errors and success remain distinguishable via icon or text.

Output contract

## Palette
Accent: oklch(0.54 0.154 255) — chosen because …
Scales: blue, neutral (tinted 255), red, amber, green, sky (50–950, CSS attached)

## Semantic roles
| Role | Light | Dark | Contrast (light / dark) |
|---|---|---|---|

## Rules
- One accent; status colors only for status
- Muted text never below neutral-600 on light surfaces

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/color_tool.py 169 lines · 5.9 KB
#!/usr/bin/env python3
"""Color utilities for building contrast-safe palettes. Standard library only.

Commands:
  contrast FG BG [FG BG ...] [--min 4.5]   WCAG 2.x contrast ratio for each pair
  convert COLOR [COLOR ...]                 hex <-> oklch conversion
  scale HUE [--chroma 0.16]                 11-step OKLCH scale (50..950), gamut-mapped

Colors: #rgb, #rrggbb, or oklch(L C H) with L as 0-1 or a percentage.
Exit code 1 if any contrast pair falls below --min.
"""
import argparse
import math
import re
import sys

STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
LIGHTNESS = [0.975, 0.94, 0.88, 0.80, 0.71, 0.62, 0.54, 0.46, 0.38, 0.30, 0.24]
CHROMA_MULT = [0.12, 0.28, 0.52, 0.78, 0.94, 1.0, 0.96, 0.86, 0.72, 0.58, 0.46]


def parse(color):
    c = color.strip().lower()
    m = re.fullmatch(r"#([0-9a-f]{3}|[0-9a-f]{6})", c)
    if m:
        h = m.group(1)
        if len(h) == 3:
            h = "".join(ch * 2 for ch in h)
        return tuple(int(h[i:i + 2], 16) / 255 for i in (0, 2, 4))
    m = re.fullmatch(r"oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)(?:deg)?\s*(?:/\s*[\d.%]+)?\s*\)", c)
    if m:
        L = float(m.group(1)) / (100 if m.group(2) else 1)
        r, g, b = oklch_to_srgb(L, float(m.group(3)), float(m.group(4)))
        return clamp(r), clamp(g), clamp(b)
    raise ValueError(f"unsupported color: {color!r} (use #rrggbb or oklch(L C H))")


def clamp(x):
    return min(1.0, max(0.0, x))


def to_linear(x):
    return x / 12.92 if x <= 0.04045 else ((x + 0.055) / 1.055) ** 2.4


def from_linear(x):
    return 12.92 * x if x <= 0.0031308 else 1.055 * (x ** (1 / 2.4)) - 0.055


def oklch_to_srgb(L, C, H):
    a, b = C * math.cos(math.radians(H)), C * math.sin(math.radians(H))
    l_ = L + 0.3963377774 * a + 0.2158037573 * b
    m_ = L - 0.1055613458 * a - 0.0638541728 * b
    s_ = L - 0.0894841775 * a - 1.2914855480 * b
    l, m, s = l_ ** 3, m_ ** 3, s_ ** 3
    r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s
    g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s
    bl = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s
    return tuple(from_linear(v) if v >= 0 else -from_linear(-v) for v in (r, g, bl))


def srgb_to_oklch(rgb):
    r, g, b = (to_linear(v) for v in rgb)
    l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b
    m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b
    s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b
    l_, m_, s_ = (math.copysign(abs(v) ** (1 / 3), v) for v in (l, m, s))
    L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_
    A = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_
    B = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_
    C = math.hypot(A, B)
    H = math.degrees(math.atan2(B, A)) % 360
    return L, C, H


def in_gamut(rgb, eps=1e-4):
    return all(-eps <= v <= 1 + eps for v in rgb)


def to_hex(rgb):
    return "#" + "".join(f"{round(clamp(v) * 255):02x}" for v in rgb)


def luminance(rgb):
    r, g, b = (to_linear(v) for v in rgb)
    return 0.2126 * r + 0.7152 * g + 0.0722 * b


def ratio(fg, bg):
    a, b = luminance(fg), luminance(bg)
    hi, lo = max(a, b), min(a, b)
    return (hi + 0.05) / (lo + 0.05)


def verdict(r):
    tags = []
    tags.append("AAA" if r >= 7 else "AA" if r >= 4.5 else "AA-large/UI" if r >= 3 else "FAIL")
    return tags[0]


def cmd_contrast(args):
    vals = args.colors
    if len(vals) % 2:
        sys.exit("contrast needs FG BG pairs")
    failed = 0
    print(f"{'foreground':<26}{'background':<26}{'ratio':>7}  level")
    for fg, bg in zip(vals[0::2], vals[1::2]):
        r = ratio(parse(fg), parse(bg))
        flag = "" if r >= args.min else f"  < {args.min}"
        failed += r < args.min
        print(f"{fg:<26}{bg:<26}{r:>7.2f}  {verdict(r)}{flag}")
    print(f"\n{failed} pair(s) below {args.min}:1" if failed else f"\nall pairs >= {args.min}:1")
    return 1 if failed else 0


def cmd_convert(args):
    for c in args.colors:
        rgb = parse(c)
        L, C, H = srgb_to_oklch(rgb)
        note = ""
        m = re.match(r"\s*oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)", c.lower())
        if m:
            raw = oklch_to_srgb(float(m.group(1)) / (100 if m.group(2) else 1), float(m.group(3)), float(m.group(4)))
            if not in_gamut(raw):
                note = "  (outside sRGB: hex is clipped)"
        print(f"{c:<28} {to_hex(rgb)}  oklch({L:.3f} {C:.3f} {H:.1f}){note}")
    return 0


def cmd_scale(args):
    white, black = (1.0, 1.0, 1.0), (0.0, 0.0, 0.0)
    print(f"{'step':<6}{'oklch':<26}{'hex':<10}{'vs white':>9}{'vs black':>9}")
    css = []
    for step, L, k in zip(STEPS, LIGHTNESS, CHROMA_MULT):
        C = args.chroma * k
        rgb = oklch_to_srgb(L, C, args.hue)
        while not in_gamut(rgb) and C > 0:
            C = max(0.0, C - 0.002)
            rgb = oklch_to_srgb(L, C, args.hue)
        rgb = tuple(clamp(v) for v in rgb)
        ok = f"oklch({L:.3f} {C:.3f} {args.hue:g})"
        print(f"{step:<6}{ok:<26}{to_hex(rgb):<10}{ratio(rgb, white):>9.2f}{ratio(rgb, black):>9.2f}")
        css.append(f"  --{args.name}-{step}: {ok};")
    print("\n:root {\n" + "\n".join(css) + "\n}")
    return 0


def main():
    p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = p.add_subparsers(dest="cmd", required=True)
    c = sub.add_parser("contrast")
    c.add_argument("colors", nargs="+")
    c.add_argument("--min", type=float, default=4.5)
    v = sub.add_parser("convert")
    v.add_argument("colors", nargs="+")
    s = sub.add_parser("scale")
    s.add_argument("hue", type=float)
    s.add_argument("--chroma", type=float, default=0.16)
    s.add_argument("--name", default="hue")
    args = p.parse_args()
    try:
        return {"contrast": cmd_contrast, "convert": cmd_convert, "scale": cmd_scale}[args.cmd](args)
    except ValueError as e:
        print(f"error: {e}", file=sys.stderr)
        return 2


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc