# =============================================================================
# check_contrast.py — WCAG contrast checker for CSS design-token files
# -----------------------------------------------------------------------------
# Why this exists: every theme (light, dark, and any custom theme block) must
# keep foreground/background pairs above the WCAG contrast floor. This tool
# parses the custom properties out of a CSS file, groups them by theme block,
# auto-pairs each `--X-foreground` with its `--X` background, and reports the
# contrast ratio with PASS/FAIL flags for the four WCAG thresholds.
# Stdlib only (Python 3) — no pip installs, so it runs anywhere and in CI.
# python3 check_contrast.py <css-file> [--min 4.5] [--pair FG:BG ...]
# --min N Fail the run (exit 1) if any checked pair is below N.
# Default 4.5 (normal-text AA).
# --pair FG:BG Check a specific pair. FG and BG may be token names
# (with or without the leading --) or raw color values
# ("#fff", "rgb(0 0 0)", "168 79% 25%"). Repeatable.
# Manual pairs REPLACE auto-pairing — use them when the
# naming convention can't express the relationship.
# Exit: 1 if any checked pair is below --min, else 0.
# =============================================================================
# =============================================================================
# Color parsing — every supported syntax converts to sRGB (r, g, b) in 0..255
# -----------------------------------------------------------------------------
# We deliberately ignore alpha for the ratio math: WCAG contrast is defined on
# opaque colors, and a token's "real" rendered alpha depends on what sits
# behind it (which we can't know statically). We parse alpha so the value is
# accepted, then drop it.
# =============================================================================
# Bare HSL channel triple, e.g. "168 79% 25%". Only this exact shape (a number,
# then two percentages) is treated as HSL — a bare "r g b" triple is ambiguous
# (could be sRGB or something else) so we intentionally do NOT guess at it.
_HSL_TRIPLE_RE = re.compile(
(-?[\d.]+) # hue (degrees; we mod 360)
# rgb()/rgba() — channels separated by commas OR spaces, alpha optional.
_RGB_FUNC_RE = re.compile(
r"^\s*rgba?\(\s*([\d.]+)\s*[, ]\s*([\d.]+)\s*[, ]\s*([\d.]+)"
r"(?:\s*[,/]\s*[\d.]+%?)?\s*\)\s*$",
# hsl()/hsla() — same separator flexibility.
_HSL_FUNC_RE = re.compile(
r"^\s*hsla?\(\s*(-?[\d.]+)(?:deg)?\s*[, ]\s*([\d.]+)%\s*[, ]\s*([\d.]+)%"
r"(?:\s*[,/]\s*[\d.]+%?)?\s*\)\s*$",
def _hsl_to_rgb(h, s, lum):
"""HSL (h in degrees, s/lum as 0..1) → (r, g, b) in 0..255."""
def hue_to_channel(p, q, t):
return p + (q - p) * 6 * t
return p + (q - p) * (2 / 3 - t) * 6
q = lum * (1 + s) if lum < 0.5 else lum + s - lum * s
r = hue_to_channel(p, q, h + 1 / 3)
g = hue_to_channel(p, q, h)
b = hue_to_channel(p, q, h - 1 / 3)
return (round(r * 255), round(g * 255), round(b * 255))
"""Parse a CSS color string into (r, g, b) 0..255, or None if unparseable.
Supports: #rgb, #rrggbb, #rrggbbaa, rgb()/rgba(), hsl()/hsla(),
and bare HSL channel triples like "168 79% 25%".
# ── hex: #rgb, #rrggbb, #rrggbbaa ───────────────────────────────────────
if len(hexv) == 3 and all(c in "0123456789abcdefABCDEF" for c in hexv):
r, g, b = (int(c * 2, 16) for c in hexv)
if len(hexv) in (6, 8) and all(c in "0123456789abcdefABCDEF" for c in hexv):
return (r, g, b) # alpha (chars 6:8) intentionally dropped
# ── rgb()/rgba() ────────────────────────────────────────────────────────
m = _RGB_FUNC_RE.match(v)
return tuple(min(255, max(0, round(float(x)))) for x in m.groups())
# ── hsl()/hsla() ────────────────────────────────────────────────────────
m = _HSL_FUNC_RE.match(v)
return _hsl_to_rgb(float(m.group(1)), float(m.group(2)) / 100, float(m.group(3)) / 100)
# ── bare HSL triple "H S% L%" ───────────────────────────────────────────
m = _HSL_TRIPLE_RE.match(v)
return _hsl_to_rgb(float(m.group(1)), float(m.group(2)) / 100, float(m.group(3)) / 100)
# =============================================================================
# WCAG math — relative luminance and contrast ratio (per WCAG 2.x definition)
# =============================================================================
def _linearize(channel_0_255):
"""sRGB channel (0..255) → linear-light value, per WCAG."""
c = channel_0_255 / 255.0
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
def relative_luminance(rgb):
r, g, b = (_linearize(c) for c in rgb)
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def contrast_ratio(rgb1, rgb2):
l1 = relative_luminance(rgb1)
l2 = relative_luminance(rgb2)
lighter, darker = max(l1, l2), min(l1, l2)
return (lighter + 0.05) / (darker + 0.05)
# =============================================================================
# CSS parsing — pull custom properties out, grouped by their theme block
# -----------------------------------------------------------------------------
# We track brace depth so a `--name: value;` is attributed to the selector that
# opened its block. Comments are stripped first so a `/* --x: y; */` never
# registers as a real declaration.
# =============================================================================
_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL)
def _block_label(selector):
"""Human label for a theme block based on the selector that opened it."""
# :root → the default (usually light) theme.
if ":root" in sel and "[data-theme" not in sel:
return "light/default (:root)"
# [data-theme="dark"] / [data-theme=dark] → label by the theme name.
m = re.search(r'\[data-theme\s*=\s*["\']?([A-Za-z0-9_-]+)["\']?\]', sel)
return f'{m.group(1)} ([data-theme="{m.group(1)}"])'
# .dark convenience class.
if re.search(r"(^|[\s,>+~])\.dark(\b|[\s,>+~{])", sel):
"""Return {block_label: {token_name: value}} for theme-bearing blocks only.
Tokens declared in a non-theme block (e.g. a component rule) are ignored
here — this tool checks themed token sets, not arbitrary declarations.
We strip comments first, then walk the CSS tracking brace depth so each
`--name: value;` is attributed to the selector that opened its block.
css = _COMMENT_RE.sub("", css)
decl_buf = "" # text accumulated for the current (possibly partial) declaration
selector_stack.append(buf.strip())
_flush_decl(decl_buf, selector_stack, themes)
_flush_decl(decl_buf, selector_stack, themes)
def _flush_decl(decl_text, selector_stack, themes):
"""If decl_text is a `--name: value` and we're inside a theme block, record it."""
m = re.match(r"\s*--([A-Za-z0-9_-]+)\s*:\s*(.+?)\s*$", decl_text, re.DOTALL)
# Attribute to the nearest enclosing block that is a recognized theme block.
for selector in reversed(selector_stack):
label = _block_label(selector)
themes.setdefault(label, {})[m.group(1)] = m.group(2).strip()
# =============================================================================
# Pairing — derive foreground/background pairs from token naming conventions
# =============================================================================
"""Return [(fg_name, bg_name)] derived from the --X-foreground / --X-fg rule.
Rules (within one theme block):
* --X-foreground → --X (e.g. --card-foreground vs --card)
* --foreground → --background
* fallback: --X-foreground → --background when --X is not a color itself
return name in tokens and parse_color(tokens[name]) is not None
elif fg.endswith("-foreground"):
base = fg[: -len("-foreground")]
if base in names and bg_is_color(base):
elif "background" in names:
pairs.append((fg, "background")) # fallback: pair against the page bg
# =============================================================================
# =============================================================================
("AA text", 4.5), # normal text, AA
("AAA text", 7.0), # normal text, AAA
("AA large", 3.0), # large text / UI components, AA
("AAA large", 4.5), # large text, AAA
def _flag(ratio, threshold):
return "PASS" if ratio >= threshold else "FAIL"
def _resolve(spec, tokens):
"""Resolve a --pair side to (display_name, rgb). Accepts token name or raw color."""
name = spec[2:] if spec.startswith("--") else spec
return name, parse_color(tokens[name])
# Not a known token — try to read it as a raw color value.
return spec, parse_color(spec)
def report_pair(block, fg_name, fg_rgb, bg_name, bg_rgb, min_ratio):
"""Print one pair's row and return (ratio_or_None, failed_min: bool)."""
if fg_rgb is None or bg_rgb is None:
missing = fg_name if fg_rgb is None else bg_name
print(f" {fg_name:<24} on {bg_name:<24} SKIP — '{missing}' is not a parseable color")
ratio = contrast_ratio(fg_rgb, bg_rgb)
flags = " ".join(f"{label} {_flag(ratio, t)}" for label, t in THRESHOLDS)
print(f" {fg_name:<24} on {bg_name:<24} {ratio:6.2f}:1 {flags}")
return ratio, ratio < min_ratio
parser = argparse.ArgumentParser(
description="WCAG contrast checker for CSS design-token files.",
parser.add_argument("css_file", help="Path to the CSS file containing token blocks.")
parser.add_argument("--min", type=float, default=4.5,
help="Minimum acceptable ratio; run fails below it (default 4.5).")
parser.add_argument("--pair", action="append", default=[], metavar="FG:BG",
help="Check a specific FG:BG pair (token names or raw colors). Repeatable.")
args = parser.parse_args(argv)
with open(args.css_file, "r", encoding="utf-8") as fh:
print(f"error: cannot read {args.css_file}: {exc}", file=sys.stderr)
themes = parse_themes(css)
print("No theme blocks found (looked for :root, [data-theme=...], .dark).")
for block, tokens in themes.items():
print(f"\nTheme: {block}")
# Manual pairs override auto-pairing for the whole run.
print(f" invalid --pair '{spec}' (expected FG:BG)", file=sys.stderr)
fg_spec, bg_spec = spec.split(":", 1)
fg_name, fg_rgb = _resolve(fg_spec.strip(), tokens)
bg_name, bg_rgb = _resolve(bg_spec.strip(), tokens)
ratio, failed = report_pair(block, fg_name, fg_rgb, bg_name, bg_rgb, args.min)
failures += 1 if failed else 0
pairs = auto_pairs(tokens)
print(" (no foreground/background token pairs found)")
for fg_name, bg_name in pairs:
fg_rgb = parse_color(tokens.get(fg_name))
bg_rgb = parse_color(tokens.get(bg_name))
ratio, failed = report_pair(block, fg_name, fg_rgb, bg_name, bg_rgb, args.min)
failures += 1 if failed else 0
print(f"\nChecked {total} pair(s); {failures} below the {args.min}:1 minimum.")
return 1 if failures else 0
if __name__ == "__main__":