Color Contrast Accessibility skill for Codex
Measured ratios for every color pair you ship. Codex inventories your color tokens, pairs each text and UI role with every surface it sits on in light and dark themes, and measures them with a bundled script that reports the WCAG ratio and APCA Lc. Failures get fixed once at the token level, and links, errors, charts and status badges gain a second cue so meaning never rides on hue alone.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- color-contrast-accessibility Installs to ~/.agents/skills/color-contrast-accessibility/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install color-contrast-accessibility 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-contrast-accessibility Installs to .agents/skills/color-contrast-accessibility/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/color-contrast-accessibility.zipunzip -o color-contrast-accessibility.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-contrast-accessibility 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-contrast-accessibility, 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
Audits and fixes color contrast against WCAG 2.2 (1.4.3 text, 1.4.11 non-text, 1.4.1 use of color), reports APCA Lc as an advisory second opinion, and adds non-color cues to links, form errors, charts and status badges. Ships a script that checks every token pair in one run. Use when text or icons look faint, a page failed an accessibility scan, placeholder, disabled or border colors need checking, dark mode pairs are untested, or status is shown by color alone.
Use it when
- An accessibility scan reported color-contrast violations
- You are adding or changing color tokens, hover states or a dark theme
- Placeholder, disabled, border or focus-ring colors look faint
- Status, errors or chart series are distinguished by hue only
Use something else when
- You need a new palette built from scratch in OKLCH: use color-systems
- You need a full WCAG audit covering keyboard, ARIA and structure: use wcag-accessibility-audit
How to check the result
The pairs CSV passes contrast_audit.py with exit code 0 in both themes, and a grayscale screenshot still shows every error, status and selected state.
The mistake it prevents
Measuring only default states: hover, pressed and translucent overlay colors are generated later and are where most failures hide.
Done well means every foreground/background pair that ships has a measured ratio that meets its WCAG 2.2 threshold, the fix changes the token rather than one component, and no meaning depends on hue alone. “Looks fine on my monitor” is not a measurement.
Thresholds that decide pass or fail
| What | Criterion | AA | AAA |
|---|---|---|---|
| Body text, labels, placeholder, link text | 1.4.3 | 4.5:1 | 7:1 (1.4.6) |
| Large text: at least 24px regular or 18.66px (14pt) bold | 1.4.3 | 3:1 | 4.5:1 |
| UI component boundaries needed to identify the control (input border, toggle track, checkbox) | 1.4.11 | 3:1 against adjacent colors | - |
| Meaningful graphics (chart lines, icon-only buttons, focus indicators) | 1.4.11 | 3:1 | - |
| Information carried by color | 1.4.1 | Needs a second cue | - |
Exempt: disabled controls, pure decoration, logos and brand names, text inside photos that is incidental. Placeholder text is not exempt; it is real text. Ratios are not rounded: 4.48:1 fails 4.5:1.
APCA (the candidate method for a future WCAG 3) is perceptually better, especially for dark mode and thin fonts, but it is not a conformance requirement today. Report it as advice; never mark a WCAG-passing pair as a failure on APCA alone unless the user asked for APCA conformance. Rough APCA guides: Lc 90 preferred for long body text, Lc 75 minimum for body text, Lc 60 for other content text, Lc 45 for large headings, Lc 30 for placeholder or incidental text, Lc 15 for non-text that must be discernible.
Workflow
- Read
AGENTS.mdand find where colors live: CSS custom properties, Tailwind@theme, a tokens JSON, a theme file. Audit tokens, not hard-coded instances. - Build the pair list: every text role on every surface it appears on, both themes.
- Run the script over the pairs; record failures with ratio and APCA Lc.
- Check non-text: input borders, focus rings, chart series, icons, toggles.
- Check 1.4.1: links in body text, form errors, status, charts, required fields.
- Fix at the token level; re-run until zero failures.
- Spot-check rendered pages (overlays, images, gradients) the token list cannot see.
1. Inventory color pairs
rg -n --no-heading -o '#[0-9a-fA-F]{3,8}\b|oklch\([^)]*\)|rgba?\([^)]*\)|hsla?\([^)]*\)' \
--glob '!**/node_modules/**' --glob '*.{css,scss,ts,tsx,js,jsx,vue,svelte,json}' | sort | uniq -c | sort -rn | head -50
rg -n -- '--color-[a-z0-9-]+\s*:' src styles app 2>/dev/null # custom properties
Pair by usage, not by permutation: text-muted on surface, surface-raised and
surface-sunken; on-primary on primary, primary-hover and primary-active. A
hover background that darkens can drop the label below 4.5:1 while the default passes.
The script reads hex and rgb(). Convert OKLCH or HSL values first (browser devtools,
or getComputedStyle in a Playwright run returns rgb()/oklch() per engine), and
for translucent colors give the 8-digit hex so it is composited over the real surface.
2. Measure
python3 scripts/contrast_audit.py "#6b7280" "#ffffff"
python3 scripts/contrast_audit.py "#ffffff" "#2563eb" --kind large
python3 scripts/contrast_audit.py --pairs contrast-pairs.csv # exits 1 on any failure
name,fg,bg,kind
body on surface,#1f2937,#ffffff,text
muted on raised,#6b7280,#f9fafb,text
placeholder,#9ca3af,#ffffff,text
input border,#d1d5db,#ffffff,ui
focus ring on surface,#2563eb,#ffffff,ui
dark body,#e5e7eb,#111827,text
kind is text (4.5), large (3.0) or ui (3.0). Keep contrast-pairs.csv in the
repo next to the tokens so the check can run in CI.
For rendered pages, axe-core catches text contrast it can compute (it skips text on images and gradients and reports those as “needs review”):
npx @axe-core/cli http://localhost:3000 --tags wcag2aa # needs network for npx on first run
3. Fix
| Failure | Fix |
|---|---|
| Muted text slightly under 4.5:1 | Darken the token in OKLCH by lowering L in 0.02 steps; keep hue and chroma |
| Brand color fails as text on white | Keep brand for fills; add a darker brand-text token for text and links |
| White label on brand button fails | Darken the button token, or switch label to near-black if the brand is light |
| Placeholder fails | Raise to the muted-text token; never rely on placeholder as the label anyway |
| Input border at 1.5:1 | Darken to 3:1, or keep the light border and add a 3:1 background fill difference |
| Text over photos | Scrim (linear-gradient overlay) measured at the worst pixel under the text, or move text off the image |
| Dark mode text too bright and vibrating | Use off-white (around #e5e7eb) on not-quite-black; APCA helps tune this |
| Focus ring invisible on primary button | Two-tone ring: outline: 2px solid plus box-shadow in the surface color as offset |
Change the token once and let components inherit. If a component hard-codes a color, replace it with the token in the same change.
Non-color cues (1.4.1)
| Pattern | Required second cue |
|---|---|
| Links inside paragraphs | Underline (default) or 3:1 contrast with surrounding text plus underline on hover and focus. Underline is the safe default |
| Form error | Icon plus error text near the field, aria-invalid="true", aria-describedby to the message |
| Required field | Text “(required)” or an asterisk explained at the top of the form |
| Status badge (ok/warn/fail) | Icon shape or text label, not just green/amber/red |
| Chart series | Direct labels, distinct markers or dash patterns; legend order matches line order |
| Selected tab or nav item | Weight, underline bar or background shape, plus aria-selected/aria-current |
| Diff views | +/- gutter markers as well as green/red |
Simulate color vision deficiency in Chrome DevTools (Rendering panel, “Emulate vision deficiencies”) and confirm every state is still distinguishable.
Gotchas
- Opacity-based text (
opacity: 0.6,text-white/70) changes contrast per surface. Measure it composited on each surface it sits on, or replace it with solid tokens. color-mix()and relative color syntax generate hover states that nobody measured. Resolve them and add them to the pairs file.- Anti-aliasing makes thin weights (300 and below) read lighter than the ratio implies. Treat light-weight text as needing more than the minimum; APCA reflects this.
- Focus indicators need 3:1 against the adjacent background, which differs between the page surface and a colored button. Check both.
- Dark mode is a separate audit. A palette that passes in light often fails on muted text and borders in dark.
- Brand guidelines do not override WCAG. Offer an accessible variant token and state the ratio of the brand original so the owner can decide with numbers.
Verification
python3 scripts/contrast_audit.py --pairs contrast-pairs.csvexits 0 in both themes.- axe run on key pages shows no
color-contrastviolations; “needs review” items were checked by hand at the worst pixel. - A grayscale screenshot (DevTools vision emulation “achromatopsia”) still shows every error, status and selection state.
- Keyboard-tab through one page: the focus ring is visible on every control type.
Report template
## Contrast audit: <scope> (<date>)
Standard: WCAG 2.2 AA. APCA Lc reported as advisory.
| Pair | Fg | Bg | Ratio | Needs | APCA Lc | Result | Fix |
|---|---|---|---|---|---|---|---|
| Muted text on raised surface | #8a8f98 | #f9fafb | 3.21 | 4.5 | 58 | FAIL | `--text-muted` -> #5f6672 (5.9:1) |
### Non-color cues
- <component>: <problem> -> <cue added>
### Not checked
- <text on user-uploaded images, third-party widgets, PDFs>
Related: build or rework a whole palette with $color-systems; full WCAG page audits
beyond color with $wcag-accessibility-audit.
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 Contrast Accessibility"
short_description: "Measured ratios for every color pair you ship"
default_prompt: "$color-contrast-accessibility Check all our text and border colors for WCAG AA contrast in both themes" scripts/contrast_audit.py
#!/usr/bin/env python3
"""Check foreground/background color pairs against WCAG 2.2 contrast and APCA Lc.
Usage:
python3 scripts/contrast_audit.py "#767676" "#ffffff"
python3 scripts/contrast_audit.py --pairs pairs.csv [--min 4.5]
pairs.csv columns: name,fg,bg,kind (kind = text | large | ui ; header row optional)
Colors: #rgb, #rrggbb, #rrggbbaa (alpha is composited over the background), rgb(r g b).
Exit code 1 if any pair fails its WCAG threshold (or --min when given).
Standard library only.
"""
import argparse
import csv
import re
import sys
THRESHOLDS = {"text": 4.5, "large": 3.0, "ui": 3.0}
def parse_color(value):
v = value.strip().lower()
m = re.fullmatch(r"rgba?\(\s*(\d+)[\s,]+(\d+)[\s,]+(\d+)(?:[\s,/]+([\d.]+%?))?\s*\)", v)
if m:
r, g, b = (int(m.group(i)) for i in (1, 2, 3))
a = m.group(4)
alpha = 1.0 if a is None else (float(a[:-1]) / 100 if a.endswith("%") else float(a))
return (r, g, b, alpha)
v = v.lstrip("#")
if len(v) in (3, 4):
v = "".join(ch * 2 for ch in v)
if not re.fullmatch(r"[0-9a-f]{6}([0-9a-f]{2})?", v):
raise ValueError(f"unsupported color: {value!r}")
r, g, b = (int(v[i:i + 2], 16) for i in (0, 2, 4))
alpha = int(v[6:8], 16) / 255 if len(v) == 8 else 1.0
return (r, g, b, alpha)
def composite(fg, bg):
"""Blend a translucent foreground over an opaque background."""
r, g, b, a = fg
return tuple(round(a * c + (1 - a) * bc) for c, bc in zip((r, g, b), bg[:3]))
def wcag_luminance(rgb):
def lin(c):
c /= 255
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
r, g, b = (lin(c) for c in rgb)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def wcag_ratio(fg, bg):
l1, l2 = sorted((wcag_luminance(fg), wcag_luminance(bg)), reverse=True)
return (l1 + 0.05) / (l2 + 0.05)
def apca_lc(txt, bg):
"""APCA-W3 0.0.98G-4g constants. Positive = dark text on light, negative = light on dark."""
def y(rgb):
r, g, b = ((c / 255) ** 2.4 for c in rgb)
val = 0.2126729 * r + 0.7151522 * g + 0.0721750 * b
return val + (0.022 - val) ** 1.414 if val < 0.022 else val
yt, yb = y(txt), y(bg)
if abs(yb - yt) < 0.0005:
return 0.0
if yb > yt:
sapc = (yb ** 0.56 - yt ** 0.57) * 1.14
out = 0.0 if sapc < 0.1 else sapc - 0.027
else:
sapc = (yb ** 0.65 - yt ** 0.62) * 1.14
out = 0.0 if sapc > -0.1 else sapc + 0.027
return out * 100
def check(name, fg_s, bg_s, kind, min_ratio):
bg = parse_color(bg_s)
if bg[3] < 1:
raise ValueError(f"{name}: background must be opaque; composite it first")
fg = composite(parse_color(fg_s), bg)
ratio = wcag_ratio(fg, bg[:3])
lc = apca_lc(fg, bg[:3])
need = min_ratio if min_ratio else THRESHOLDS.get(kind, 4.5)
ok = ratio + 1e-9 >= need
aaa = {"text": 7.0, "large": 4.5}.get(kind)
aaa_s = "" if aaa is None else (" AAA" if ratio >= aaa else "")
status = "PASS" if ok else "FAIL"
print(f"{status} {name:<24} {fg_s:>10} on {bg_s:<10} {ratio:5.2f}:1 (need {need}){aaa_s} APCA Lc {lc:6.1f} [{kind}]")
return ok
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("fg", nargs="?")
ap.add_argument("bg", nargs="?")
ap.add_argument("--kind", default="text", choices=sorted(THRESHOLDS))
ap.add_argument("--pairs", help="CSV file with name,fg,bg,kind")
ap.add_argument("--min", type=float, help="override the WCAG threshold for every pair")
args = ap.parse_args()
rows = []
if args.pairs:
with open(args.pairs, newline="") as fh:
for row in csv.reader(fh):
if not row or row[0].strip().startswith("#") or row[0].strip().lower() == "name":
continue
name, fg, bg = (c.strip() for c in row[:3])
kind = row[3].strip() if len(row) > 3 and row[3].strip() else "text"
rows.append((name, fg, bg, kind))
elif args.fg and args.bg:
rows.append(("pair", args.fg, args.bg, args.kind))
else:
ap.error("give FG BG or --pairs FILE")
failures = 0
for name, fg, bg, kind in rows:
try:
if not check(name, fg, bg, kind, args.min):
failures += 1
except ValueError as exc:
print(f"ERROR {exc}")
failures += 1
print(f"\n{len(rows) - failures}/{len(rows)} pairs pass.")
sys.exit(1 if failures else 0)
if __name__ == "__main__":
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.