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.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- color-systems Installs to ~/.agents/skills/color-systems/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install color-systems The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project color-systems Installs to .agents/skills/color-systems/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/color-systems.zipunzip -o color-systems.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) color-systems PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
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
- A formal contrast and non-color-cue accessibility audit: use color-contrast-accessibility
- Choosing categorical or sequential palettes for charts: use data-visualization-design
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.
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,:rootvariables, 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 (#1e60d5vs#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
| Ramp | Hue guide (OKLCH H) | Peak chroma |
|---|---|---|
| Neutral | Brand hue or warm 60–90 / cool 240–260 | 0.005–0.02 (never exactly 0: tinted grays look intentional) |
| Brand | From the brand color (convert below) | Brand’s own chroma |
| Danger | 25–30 | 0.18–0.22 |
| Warning | 70–85 | 0.15–0.18 (yellows only stay vivid at high L) |
| Success | 145–155 | 0.14–0.17 |
| Info | Reuse brand if blue; else 240–255 | 0.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):
| Steps | Use |
|---|---|
| 50–100 | Subtle backgrounds, selected rows, status banners |
| 200–300 | Borders, dividers, disabled fills |
| 400–500 | Decorative fills, icons on light, large text only |
| 600–700 | Solid action backgrounds with white text, links, body-size colored text |
| 800–950 | Text 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):
| Element | Minimum |
|---|---|
| Body text, icons that convey meaning without a label | 4.5:1 |
| Large text (24 px+, or 18.66 px+ bold) | 3:1 |
| UI component boundaries, focus indicators, chart marks | 3:1 against adjacent colors |
| Disabled controls, decoration, logos | No 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 --minfor 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.yaml
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.py
#!/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.