Color Systems skill for Codex

Even OKLCH ramps, semantic roles, contrast that passes. Codex builds palettes in OKLCH so steps look even and different hues at the same lightness have similar contrast. A bundled Python script generates gamut-mapped 50 to 950 ramps, converts brand hex values and checks contrast with a CI-friendly exit code, while the workflow maps ramps to semantic text, surface, action and status roles for light and dark themes.

  • Design & UX
  • $color-systems
  • 201 lines
  • 3 files
  • ~2,000 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- color-systems

Installs to ~/.agents/skills/color-systems/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $color-systems, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Builds UI color systems in OKLCH - tinted neutral ramps, brand and status ramps with even perceptual steps, sRGB gamut mapping, semantic roles for text, surfaces, borders, actions and status, state colors via color-mix or relative color syntax, and WCAG contrast pairs checked with a bundled script. Use when creating or fixing a palette, converting hex or HSL colors to OKLCH, defining semantic color roles, picking accessible status colors, or when colors look muddy, uneven or fail contrast.

Use it when

  • Creating a palette or ramps for a product UI
  • Colors look muddy, uneven or inconsistent across hues
  • Defining semantic color roles for components and themes
  • Text or UI elements fail contrast checks

Use something else when

How to check the result

Every text and background role pair passes the bundled contrast check with --min 4.5 in each theme, and no hex values remain in component code.

The mistake it prevents

Shipping OKLCH values outside the sRGB gamut, which browsers clip per channel and visibly shift the hue.

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

--- name: color-systems description: Builds UI color systems in OKLCH - tinted neutral ramps, brand and status ramps with even perceptual steps, sRGB gamut mapping, semantic roles for… license: MIT ---

A color system is done when every color in the UI comes from a ramp with even perceptual steps, components reference semantic roles rather than ramp steps, every text/background pair used in the product passes contrast in every theme, and adding a new hue follows a recipe instead of a guess.

Before changing anything

  • Read AGENTS.md; find existing color sources: token files, @theme, Tailwind config, :root variables, SCSS maps, hard-coded hex in components.
  • List the brand constraints: fixed brand hex values, legal or print colors, logo colors.
  • Inventory what is in use: rg -o '#[0-9a-fA-F]{6}\b' src | sort | uniq -c | sort -nr. Near-duplicates (#1e60d5 vs #1d5fd6) collapse into one ramp step.

Why OKLCH

oklch(L C H): L is perceptual lightness 0–1, C is chroma (0 gray, ~0.37 max, under 0.25 for most sRGB colors), H is hue in degrees. Equal L across hues means roughly equal perceived lightness, so a blue 600 and a red 600 at the same L have similar contrast against white; HSL cannot promise that (HSL yellow and blue at 50% lightness differ wildly). OKLCH is supported in all current evergreen browsers.

Workflow

- [ ] 1. Decide the hue set (neutral, brand, status, optional accent)
- [ ] 2. Generate ramps with the script; adjust brand step to the real brand color
- [ ] 3. Define semantic roles per theme
- [ ] 4. Check every used text/bg pair; fix by moving L, not by eyeballing
- [ ] 5. Define interaction states from roles (hover, active, subtle)
- [ ] 6. Wire into tokens and replace raw values

1. Hue set

RampHue guide (OKLCH H)Peak chroma
NeutralBrand hue or warm 60–90 / cool 240–2600.005–0.02 (never exactly 0: tinted grays look intentional)
BrandFrom the brand color (convert below)Brand’s own chroma
Danger25–300.18–0.22
Warning70–850.15–0.18 (yellows only stay vivid at high L)
Success145–1550.14–0.17
InfoReuse brand if blue; else 240–2550.13–0.16

2. Ramps

Use the bundled generator. It produces 11 steps (50–950) at fixed perceptual lightness, tapers chroma toward the ends, maps each step into sRGB by reducing chroma only (hue and lightness stay put), and prints contrast against white and black:

python3 scripts/oklch_palette.py convert "#1d5fd6"          # brand hex -> oklch(0.519 0.195 261.2)
python3 scripts/oklch_palette.py ramp --hue 261 --chroma 0.19 --name brand
python3 scripts/oklch_palette.py ramp --hue 261 --chroma 0.012 --name neutral

Then pin the real brand color to the step whose L is closest (0.519 sits between 600 at 0.55 and 700 at 0.47; replace one step with the exact brand value and keep its neighbours). Never shift the whole ramp to fit one value.

Ramp usage conventions (keep them consistent across hues):

StepsUse
50–100Subtle backgrounds, selected rows, status banners
200–300Borders, dividers, disabled fills
400–500Decorative fills, icons on light, large text only
600–700Solid action backgrounds with white text, links, body-size colored text
800–950Text on tinted backgrounds, dark-mode surfaces (neutral)

3. Semantic roles

Components use roles only. Minimum set:

:root {
  --color-text: var(--color-neutral-900);
  --color-text-muted: var(--color-neutral-600);
  --color-text-on-action: oklch(0.99 0 0);
  --color-bg: var(--color-neutral-50);
  --color-bg-surface: oklch(1 0 0);
  --color-bg-subtle: var(--color-neutral-100);
  --color-border: var(--color-neutral-200);
  --color-border-strong: var(--color-neutral-400);
  --color-action: var(--color-brand-600);
  --color-focus: var(--color-brand-500);
  --color-danger-text: var(--color-danger-700);
  --color-danger-bg: var(--color-danger-50);
  --color-danger-solid: var(--color-danger-600);
}
[data-theme="dark"] {
  --color-text: var(--color-neutral-100);
  --color-text-muted: var(--color-neutral-400);
  --color-bg: var(--color-neutral-950);
  --color-bg-surface: var(--color-neutral-900);
  --color-border: var(--color-neutral-800);
  --color-action: var(--color-brand-400);
  --color-text-on-action: var(--color-neutral-950);
  --color-danger-text: var(--color-danger-300);
  --color-danger-bg: oklch(0.28 0.06 27);
}

Every background role has a paired text role. Status uses three roles (text, subtle bg, solid) so banners, badges and buttons all have a correct pair. Full token naming and build tooling belong to design-tokens; dark theme design rules to dark-mode-theming.

4. Contrast

WCAG 2.2 AA thresholds (the legal baseline in most jurisdictions):

ElementMinimum
Body text, icons that convey meaning without a label4.5:1
Large text (24 px+, or 18.66 px+ bold)3:1
UI component boundaries, focus indicators, chart marks3:1 against adjacent colors
Disabled controls, decoration, logosNo requirement (keep them legible anyway)
python3 scripts/oklch_palette.py contrast "oklch(0.99 0 0)" "oklch(0.55 0.16 255)" --min 4.5

The script exits 1 below --min, so a loop over role pairs works as a CI check. To fix a failing pair, move L of one color (text darker or bg lighter) while keeping H and C; in OKLCH this keeps the color’s identity. APCA is a useful second opinion for dark themes and thin type but is not a WCAG 2 conformance measure; see color-contrast-accessibility for that.

5. States without new hues

.button {
  background: var(--color-action);
}
.button:hover {
  background: oklch(from var(--color-action) calc(l - 0.06) c h);
}
.button:active {
  background: color-mix(in oklch, var(--color-action), black 18%);
}
.row:hover {
  background: color-mix(in oklch, var(--color-action) 8%, var(--color-bg-surface));
}

Relative color syntax (oklch(from ...)) works in current Chrome, Edge, Safari and Firefox; color-mix() has slightly wider support. In dark themes hover usually goes lighter (l + 0.06), so define hover per theme or as its own role.

6. Wide gamut (optional)

P3 displays can show more chroma. Keep the sRGB value as the base and enhance:

:root { --color-action: oklch(0.55 0.16 255); }
@media (color-gamut: p3) {
  :root { --color-action: oklch(0.55 0.2 255); }
}

Re-check contrast after enhancing; chroma changes luminance slightly.

Gotchas

  • Out-of-gamut OKLCH values are clipped by the browser per channel, which can shift hue visibly. Gamut-map in the token build (the script does this) rather than shipping impossible values.
  • Pure gray neutrals next to a saturated brand look dead; a chroma of 0.01 in the brand hue fixes it.
  • Yellow and orange cannot be both dark and vivid. For warning text, use a dark amber (L about 0.45, H about 60) and keep bright yellow for fills with dark text.
  • Red/green as the only signal fails for about 8% of men with color-vision deficiency; pair status color with an icon or label.
  • Brand teams often supply a brand color that fails 4.5:1 with white text. Keep it for large surfaces and logos; use a darker step for buttons and links, and document why.
  • Opacity-based colors (rgba(0,0,0,.5) text) change contrast on every background; use solid roles for text.

Verification

  • Run contrast --min for every text/bg role pair in each theme; all pass.
  • Grep components for hex, rgb( and palette utility classes: zero outside token files.
  • View a swatch page of all ramps: steps look evenly spaced, no sudden hue jumps.
  • Toggle themes and check status banners, badges and focus rings in both.

Output contract

**Ramps:** neutral (H 261, C 0.012), brand (H 261, C 0.19, brand pinned at 600), danger, warning, success
**Roles:** <n> roles x <n> themes, defined in <file>
**Contrast:** <n> pairs checked, all >= 4.5:1 (lowest: text-muted on bg-subtle 4.7:1)
**Changes:** <n> hard-coded colors replaced; brand color kept for <use>, darker step for buttons
**Open questions:** <brand approvals, P3 enhancement>

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Color Systems"
  short_description: "Even OKLCH ramps, semantic roles, contrast that passes"
  default_prompt: "$color-systems Build a color palette in OKLCH from our brand blue #1d5fd6 with light and dark roles"
scripts/oklch_palette.py178 lines, 7.0 KB
#!/usr/bin/env python3
"""OKLCH palette helper: build gamut-safe ramps, convert colors, check WCAG contrast.

Standard library only (Python 3.10+).

  python3 oklch_palette.py ramp --hue 255 --chroma 0.16 --name blue
  python3 oklch_palette.py contrast "#ffffff" "oklch(0.55 0.16 255)" --min 4.5
  python3 oklch_palette.py convert "#1d5fd6"
"""
from __future__ import annotations

import argparse
import math
import re
import sys

STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950]
# Perceptual lightness targets and chroma multipliers per step (mid steps carry most chroma).
LIGHTNESS = [0.975, 0.945, 0.89, 0.82, 0.72, 0.63, 0.55, 0.47, 0.39, 0.31, 0.25]
CHROMA_FACTOR = [0.22, 0.38, 0.6, 0.82, 0.96, 1.0, 1.0, 0.92, 0.8, 0.66, 0.52]


def srgb_to_linear(c: float) -> float:
    return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4


def linear_to_srgb(c: float) -> float:
    return 12.92 * c if c <= 0.0031308 else 1.055 * (c ** (1 / 2.4)) - 0.055


def oklch_to_linear_rgb(L: float, C: float, H: float) -> tuple[float, float, float]:
    a = C * math.cos(math.radians(H))
    b = 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
    return (
        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,
    )


def linear_rgb_to_oklch(r: float, g: float, b: float) -> tuple[float, float, float]:
    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 if C > 1e-6 else 0.0
    return L, C, H


def in_gamut(rgb: tuple[float, float, float], eps: float = 1e-4) -> bool:
    return all(-eps <= c <= 1 + eps for c in rgb)


def gamut_map(L: float, C: float, H: float) -> tuple[float, float, float]:
    """Reduce chroma (keeping L and H) until the color fits in sRGB."""
    if in_gamut(oklch_to_linear_rgb(L, C, H)):
        return L, C, H
    lo, hi = 0.0, C
    for _ in range(30):
        mid = (lo + hi) / 2
        if in_gamut(oklch_to_linear_rgb(L, mid, H)):
            lo = mid
        else:
            hi = mid
    return L, lo, H


def to_hex(L: float, C: float, H: float) -> str:
    rgb = oklch_to_linear_rgb(*gamut_map(L, C, H))
    chans = [round(min(1, max(0, linear_to_srgb(min(1, max(0, c))))) * 255) for c in rgb]
    return "#" + "".join(f"{c:02x}" for c in chans)


def parse_color(text: str) -> tuple[float, float, float]:
    """Return linear sRGB for '#rgb', '#rrggbb' or 'oklch(L C H)' (L as 0-1 or %)."""
    t = text.strip().lower()
    m = re.fullmatch(r"#?([0-9a-f]{3}|[0-9a-f]{6})", t)
    if m:
        h = m.group(1)
        if len(h) == 3:
            h = "".join(ch * 2 for ch in h)
        return tuple(srgb_to_linear(int(h[i:i + 2], 16) / 255) for i in (0, 2, 4))  # type: ignore[return-value]
    m = re.fullmatch(r"oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)(?:deg)?\s*(?:/\s*[\d.%]+\s*)?\)", t)
    if m:
        L = float(m.group(1)) / (100 if m.group(2) else 1)
        rgb = oklch_to_linear_rgb(*gamut_map(L, float(m.group(3)), float(m.group(4))))
        return tuple(min(1.0, max(0.0, c)) for c in rgb)  # type: ignore[return-value]
    raise ValueError(f"unrecognized color: {text!r} (use #rrggbb or oklch(L C H))")


def luminance(rgb: tuple[float, float, float]) -> float:
    r, g, b = rgb
    return 0.2126 * r + 0.7152 * g + 0.0722 * b


def contrast(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
    la, lb = sorted((luminance(a), luminance(b)), reverse=True)
    return (la + 0.05) / (lb + 0.05)


def fmt_oklch(L: float, C: float, H: float) -> str:
    return f"oklch({L:.3f} {C:.3f} {H:.1f})"


def cmd_ramp(args: argparse.Namespace) -> int:
    white, black = parse_color("#ffffff"), parse_color("#000000")
    print(f"/* {args.name}: hue {args.hue}, peak chroma {args.chroma} (gamut-mapped to sRGB) */")
    print(":root {")
    rows = []
    for step, L, f in zip(STEPS, LIGHTNESS, CHROMA_FACTOR):
        L2, C2, H2 = gamut_map(L, args.chroma * f, args.hue)
        hexv = to_hex(L2, C2, H2)
        rgb = parse_color(hexv)
        rows.append((step, fmt_oklch(L2, C2, H2), hexv, contrast(rgb, white), contrast(rgb, black)))
        print(f"  --color-{args.name}-{step}: {fmt_oklch(L2, C2, H2)}; /* {hexv} */")
    print("}")
    print(f"\n{'step':>5}  {'oklch':<28} {'hex':<8} {'vs white':>9} {'vs black':>9}")
    for step, ok, hexv, cw, cb in rows:
        flag_w = "AA" if cw >= 4.5 else ("lg" if cw >= 3 else "  ")
        flag_b = "AA" if cb >= 4.5 else ("lg" if cb >= 3 else "  ")
        print(f"{step:>5}  {ok:<28} {hexv:<8} {cw:>6.2f} {flag_w} {cb:>6.2f} {flag_b}")
    print("\nAA = 4.5:1 body text, lg = 3:1 large text / UI boundaries only")
    return 0


def cmd_contrast(args: argparse.Namespace) -> int:
    ratio = contrast(parse_color(args.fg), parse_color(args.bg))
    verdict = "AA body" if ratio >= 4.5 else ("AA large/UI only" if ratio >= 3 else "FAIL")
    if ratio >= 7:
        verdict = "AAA body"
    print(f"{args.fg} on {args.bg}: {ratio:.2f}:1 ({verdict})")
    if args.min is not None and ratio < args.min:
        print(f"below required minimum {args.min}:1", file=sys.stderr)
        return 1
    return 0


def cmd_convert(args: argparse.Namespace) -> int:
    rgb = parse_color(args.color)
    L, C, H = linear_rgb_to_oklch(*rgb)
    print(f"{args.color} -> {to_hex(L, C, H)}  {fmt_oklch(L, C, H)}")
    return 0


def main() -> int:
    p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = p.add_subparsers(dest="cmd", required=True)
    r = sub.add_parser("ramp", help="generate an 11-step ramp (50-950)")
    r.add_argument("--hue", type=float, required=True, help="OKLCH hue in degrees")
    r.add_argument("--chroma", type=float, default=0.15, help="peak chroma (0.02 neutral to 0.25 vivid)")
    r.add_argument("--name", default="brand", help="custom property name segment")
    r.set_defaults(func=cmd_ramp)
    c = sub.add_parser("contrast", help="WCAG 2 contrast ratio of two colors")
    c.add_argument("fg")
    c.add_argument("bg")
    c.add_argument("--min", type=float, help="exit 1 if the ratio is below this")
    c.set_defaults(func=cmd_contrast)
    v = sub.add_parser("convert", help="convert hex or oklch to both forms")
    v.add_argument("color")
    v.set_defaults(func=cmd_convert)
    args = p.parse_args()
    try:
        return args.func(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 Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc