Skip to content

Check_contrast

FieldValue
TypeSkill Resource
Source~/.copilot/skills/design/scripts/check_contrast.py
DescriptionNot specified

Source Content

#!/usr/bin/env python3
# =============================================================================
# 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.
#
# Usage:
# 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.
# =============================================================================
import argparse
import re
import sys
# =============================================================================
# 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(
r"""^\s*
(-?[\d.]+) # hue (degrees; we mod 360)
\s+
([\d.]+)% # saturation %
\s+
([\d.]+)% # lightness %
\s*$
""",
re.VERBOSE,
)
# 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*$",
re.IGNORECASE,
)
# 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*$",
re.IGNORECASE,
)
def _hsl_to_rgb(h, s, lum):
"""HSL (h in degrees, s/lum as 0..1) → (r, g, b) in 0..255."""
h = (h % 360) / 360.0
if s == 0:
v = round(lum * 255)
return (v, v, v)
def hue_to_channel(p, q, t):
if t < 0:
t += 1
if t > 1:
t -= 1
if t < 1 / 6:
return p + (q - p) * 6 * t
if t < 1 / 2:
return q
if t < 2 / 3:
return p + (q - p) * (2 / 3 - t) * 6
return p
q = lum * (1 + s) if lum < 0.5 else lum + s - lum * s
p = 2 * lum - q
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))
def parse_color(value):
"""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%".
"""
if value is None:
return None
v = value.strip()
# ── hex: #rgb, #rrggbb, #rrggbbaa ───────────────────────────────────────
if v.startswith("#"):
hexv = v[1:]
if len(hexv) == 3 and all(c in "0123456789abcdefABCDEF" for c in hexv):
r, g, b = (int(c * 2, 16) for c in hexv)
return (r, g, b)
if len(hexv) in (6, 8) and all(c in "0123456789abcdefABCDEF" for c in hexv):
r = int(hexv[0:2], 16)
g = int(hexv[2:4], 16)
b = int(hexv[4:6], 16)
return (r, g, b) # alpha (chars 6:8) intentionally dropped
return None
# ── rgb()/rgba() ────────────────────────────────────────────────────────
m = _RGB_FUNC_RE.match(v)
if m:
return tuple(min(255, max(0, round(float(x)))) for x in m.groups())
# ── hsl()/hsla() ────────────────────────────────────────────────────────
m = _HSL_FUNC_RE.match(v)
if m:
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)
if m:
return _hsl_to_rgb(float(m.group(1)), float(m.group(2)) / 100, float(m.group(3)) / 100)
return None
# =============================================================================
# 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."""
sel = selector.strip()
if not sel:
return None
# :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)
if m:
return f'{m.group(1)} ([data-theme="{m.group(1)}"])'
# .dark convenience class.
if re.search(r"(^|[\s,>+~])\.dark(\b|[\s,>+~{])", sel):
return "dark (.dark)"
return None
def parse_themes(css):
"""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)
themes = {}
themes = {}
selector_stack = []
buf = ""
decl_buf = "" # text accumulated for the current (possibly partial) declaration
for ch in css:
if ch == "{":
selector_stack.append(buf.strip())
buf = ""
decl_buf = ""
elif ch == "}":
_flush_decl(decl_buf, selector_stack, themes)
decl_buf = ""
if selector_stack:
selector_stack.pop()
buf = ""
elif ch == ";":
_flush_decl(decl_buf, selector_stack, themes)
decl_buf = ""
buf = ""
else:
buf += ch
decl_buf += ch
return 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."""
if not selector_stack:
return
m = re.match(r"\s*--([A-Za-z0-9_-]+)\s*:\s*(.+?)\s*$", decl_text, re.DOTALL)
if not m:
return
# Attribute to the nearest enclosing block that is a recognized theme block.
for selector in reversed(selector_stack):
label = _block_label(selector)
if label:
themes.setdefault(label, {})[m.group(1)] = m.group(2).strip()
return
# =============================================================================
# Pairing — derive foreground/background pairs from token naming conventions
# =============================================================================
def auto_pairs(tokens):
"""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)
* --X-fg → --X
* --foreground → --background
* --fg → --bg
* fallback: --X-foreground → --background when --X is not a color itself
"""
names = set(tokens)
pairs = []
def bg_is_color(name):
return name in tokens and parse_color(tokens[name]) is not None
for fg in sorted(names):
base = None
if fg == "foreground":
base = "background"
elif fg == "fg":
base = "bg"
elif fg.endswith("-foreground"):
base = fg[: -len("-foreground")]
elif fg.endswith("-fg"):
base = fg[: -len("-fg")]
else:
continue
if base in names and bg_is_color(base):
pairs.append((fg, base))
elif "background" in names:
pairs.append((fg, "background")) # fallback: pair against the page bg
return pairs
# =============================================================================
# Reporting
# =============================================================================
THRESHOLDS = [
("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
if name in tokens:
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")
return None, False
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
def main(argv=None):
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)
try:
with open(args.css_file, "r", encoding="utf-8") as fh:
css = fh.read()
except OSError as exc:
print(f"error: cannot read {args.css_file}: {exc}", file=sys.stderr)
return 2
themes = parse_themes(css)
if not themes:
print("No theme blocks found (looked for :root, [data-theme=...], .dark).")
return 0
total = 0
failures = 0
for block, tokens in themes.items():
print(f"\nTheme: {block}")
print(" " + "-" * 88)
if args.pair:
# Manual pairs override auto-pairing for the whole run.
for spec in args.pair:
if ":" not in spec:
print(f" invalid --pair '{spec}' (expected FG:BG)", file=sys.stderr)
continue
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)
if ratio is not None:
total += 1
failures += 1 if failed else 0
else:
pairs = auto_pairs(tokens)
if not pairs:
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)
if ratio is not None:
total += 1
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__":
sys.exit(main())