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.

  • Design & UX
  • $color-contrast-accessibility
  • 162 lines
  • 3 files
  • ~2,000 tokens
  • MIT
  • Updated
  • Editor's pick

Install

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

Installs to ~/.agents/skills/color-contrast-accessibility/ 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-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

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.

~/.agents/skills/color-contrast-accessibility/SKILL.md

--- name: color-contrast-accessibility description: 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,… license: MIT ---

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

WhatCriterionAAAAA
Body text, labels, placeholder, link text1.4.34.5:17:1 (1.4.6)
Large text: at least 24px regular or 18.66px (14pt) bold1.4.33:14.5:1
UI component boundaries needed to identify the control (input border, toggle track, checkbox)1.4.113:1 against adjacent colors-
Meaningful graphics (chart lines, icon-only buttons, focus indicators)1.4.113:1-
Information carried by color1.4.1Needs 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.md and 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

FailureFix
Muted text slightly under 4.5:1Darken the token in OKLCH by lowering L in 0.02 steps; keep hue and chroma
Brand color fails as text on whiteKeep brand for fills; add a darker brand-text token for text and links
White label on brand button failsDarken the button token, or switch label to near-black if the brand is light
Placeholder failsRaise to the muted-text token; never rely on placeholder as the label anyway
Input border at 1.5:1Darken to 3:1, or keep the light border and add a 3:1 background fill difference
Text over photosScrim (linear-gradient overlay) measured at the worst pixel under the text, or move text off the image
Dark mode text too bright and vibratingUse off-white (around #e5e7eb) on not-quite-black; APCA helps tune this
Focus ring invisible on primary buttonTwo-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)

PatternRequired second cue
Links inside paragraphsUnderline (default) or 3:1 contrast with surrounding text plus underline on hover and focus. Underline is the safe default
Form errorIcon plus error text near the field, aria-invalid="true", aria-describedby to the message
Required fieldText “(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 seriesDirect labels, distinct markers or dash patterns; legend order matches line order
Selected tab or nav itemWeight, 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

  1. python3 scripts/contrast_audit.py --pairs contrast-pairs.csv exits 0 in both themes.
  2. axe run on key pages shows no color-contrast violations; “needs review” items were checked by hand at the worst pixel.
  3. A grayscale screenshot (DevTools vision emulation “achromatopsia”) still shows every error, status and selection state.
  4. 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.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 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.py128 lines, 4.5 KB
#!/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.

esc