Files
claude-skills-reference/engineering-team/a11y-audit/scripts/contrast_checker.py
Alireza Rezvani a059113c96 Dev (#377)
* fix: add missing plugin.json files and restore trailing newlines

- Add plugin.json for review-fix-a11y skill
- Add plugin.json for free-llm-api skill
- Restore POSIX-compliant trailing newlines in JSON index files

* feat(engineering): add review-fix-a11y skill (WCAG 2.2 a11y audit + fix) (#375)

Adds review-fix-a11y (WCAG 2.2 a11y audit + fix) and free-llm-api skills.

Includes:
- review-fix-a11y: WCAG 2.2 audit workflow, a11y_audit.py scanner, contrast_checker.py
- free-llm-api: ChatAnywhere, Groq, Cerebras, OpenRouter, llm-mux, One API setup
- secret_scanner.py upgrade with secrets-patterns-db integration (1,600+ patterns)

Co-authored-by: ivanopenclaw223-alt <ivanopenclaw223-alt@users.noreply.github.com>

* chore: sync codex skills symlinks [automated]

* Revert "feat(engineering): add review-fix-a11y skill (WCAG 2.2 a11y audit + fix) (#375)"

This reverts commit 49c9f2109f.

* chore: sync codex skills symlinks [automated]

* Revert "feat(engineering): add review-fix-a11y skill (WCAG 2.2 a11y audit + fix) (#375)"

This reverts commit 49c9f2109f.

* feat(engineering-team): add a11y-audit skill — WCAG 2.2 accessibility audit & fix (#376)

Built from scratch (replaces reverted PR #375 contribution).

Skill package:
- SKILL.md: 1132 lines, 3-phase workflow (scan → fix → verify),
  per-framework fix patterns (React, Next.js, Vue, Angular, Svelte, HTML),
  CI/CD integration guide, 20+ issue type coverage
- scripts/a11y_scanner.py: static scanner detecting 20+ violation types
  across HTML/JSX/TSX/Vue/Svelte/CSS — severity-ranked, CI-friendly exit codes
- scripts/contrast_checker.py: WCAG contrast calculator with AA/AAA checks,
  --suggest mode, --batch CSS scanning, named color support
- references/wcag-quick-ref.md: WCAG 2.2 Level A/AA criteria table
- references/aria-patterns.md: ARIA roles, live regions, keyboard interaction
- references/framework-a11y-patterns.md: React, Vue, Angular, Svelte fix patterns
- assets/sample-component.tsx: sample file with intentional violations
- expected_outputs/: scan report, contrast output, JSON output samples
- /a11y-audit slash command, settings.json, plugin.json, README.md

Validation: 97.6/100 (EXCELLENT), quality 73.9/100 (B-), scripts 2/2 PASS

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: sync codex skills symlinks [automated]

---------

Co-authored-by: Leo <leo@openclaw.ai>
Co-authored-by: ivanopenclaw223-alt <ivanopenclaw223@gmail.com>
Co-authored-by: ivanopenclaw223-alt <ivanopenclaw223-alt@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 08:42:53 +01:00

500 lines
16 KiB
Python

#!/usr/bin/env python3
"""WCAG 2.2 Color Contrast Checker.
Checks foreground/background color pairs against WCAG 2.2 contrast ratio
thresholds for normal text, large text, and UI components. Supports hex,
rgb(), and named CSS colors.
Usage:
python contrast_checker.py "#ffffff" "#000000"
python contrast_checker.py --suggest "#336699"
python contrast_checker.py --batch styles.css
python contrast_checker.py --demo
"""
import argparse
import json
import re
import sys
# ---------------------------------------------------------------------------
# Named CSS colors (25 common ones)
# ---------------------------------------------------------------------------
NAMED_COLORS = {
"black": (0, 0, 0),
"white": (255, 255, 255),
"red": (255, 0, 0),
"green": (0, 128, 0),
"blue": (0, 0, 255),
"yellow": (255, 255, 0),
"cyan": (0, 255, 255),
"magenta": (255, 0, 255),
"gray": (128, 128, 128),
"grey": (128, 128, 128),
"orange": (255, 165, 0),
"purple": (128, 0, 128),
"pink": (255, 192, 203),
"brown": (165, 42, 42),
"navy": (0, 0, 128),
"teal": (0, 128, 128),
"olive": (128, 128, 0),
"maroon": (128, 0, 0),
"lime": (0, 255, 0),
"aqua": (0, 255, 255),
"silver": (192, 192, 192),
"gold": (255, 215, 0),
"coral": (255, 127, 80),
"salmon": (250, 128, 114),
"tomato": (255, 99, 71),
}
# WCAG thresholds: (label, required_ratio)
WCAG_THRESHOLDS = [
("AA Normal Text", 4.5),
("AA Large Text", 3.0),
("AA UI Components", 3.0),
("AAA Normal Text", 7.0),
("AAA Large Text", 4.5),
]
# ---------------------------------------------------------------------------
# Color parsing
# ---------------------------------------------------------------------------
def parse_color(color_str: str) -> tuple:
"""Parse a color string into an (R, G, B) tuple.
Accepts:
- #RRGGBB or #RGB hex
- rgb(r, g, b) with values 0-255
- Named CSS colors
"""
s = color_str.strip().lower()
# Named color
if s in NAMED_COLORS:
return NAMED_COLORS[s]
# Hex: #RGB or #RRGGBB
hex_match = re.match(r"^#([0-9a-f]{3}|[0-9a-f]{6})$", s)
if hex_match:
h = hex_match.group(1)
if len(h) == 3:
r, g, b = int(h[0] * 2, 16), int(h[1] * 2, 16), int(h[2] * 2, 16)
else:
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
return (r, g, b)
# rgb(r, g, b)
rgb_match = re.match(r"^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$", s)
if rgb_match:
r, g, b = int(rgb_match.group(1)), int(rgb_match.group(2)), int(rgb_match.group(3))
if not all(0 <= c <= 255 for c in (r, g, b)):
raise ValueError(f"RGB values must be 0-255, got rgb({r},{g},{b})")
return (r, g, b)
raise ValueError(
f"Invalid color format: '{color_str}'. "
"Use #RRGGBB, #RGB, rgb(r,g,b), or a named color."
)
def color_to_hex(rgb: tuple) -> str:
"""Convert an (R, G, B) tuple to #RRGGBB."""
return f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}"
# ---------------------------------------------------------------------------
# WCAG luminance and contrast
# ---------------------------------------------------------------------------
def relative_luminance(rgb: tuple) -> float:
"""Calculate relative luminance per WCAG 2.2 (sRGB).
https://www.w3.org/TR/WCAG22/#dfn-relative-luminance
"""
channels = []
for c in rgb:
s = c / 255.0
channels.append(s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4)
return 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]
def contrast_ratio(rgb1: tuple, rgb2: tuple) -> float:
"""Return the WCAG contrast ratio between two colors (>= 1.0)."""
l1 = relative_luminance(rgb1)
l2 = relative_luminance(rgb2)
lighter = max(l1, l2)
darker = min(l1, l2)
return (lighter + 0.05) / (darker + 0.05)
def evaluate_contrast(ratio: float) -> list:
"""Return pass/fail results for each WCAG threshold."""
results = []
for label, threshold in WCAG_THRESHOLDS:
results.append({
"level": label,
"required": threshold,
"ratio": round(ratio, 2),
"pass": ratio >= threshold,
})
return results
# ---------------------------------------------------------------------------
# Suggest accessible backgrounds
# ---------------------------------------------------------------------------
def suggest_backgrounds(fg_rgb: tuple, target_ratio: float = 4.5, count: int = 8) -> list:
"""Given a foreground color, suggest background colors passing AA normal text.
Strategy: walk luminance in both directions (lighter / darker) from the
foreground and collect the first colors that meet the target ratio.
"""
suggestions = []
# Try a spread of grays and tinted variants
candidates = []
for v in range(0, 256, 1):
candidates.append((v, v, v)) # grays
# Also try tinted versions toward the complement
fr, fg, fb = fg_rgb
for v in range(0, 256, 2):
candidates.append((v, min(255, v + 20), min(255, v + 40)))
candidates.append((min(255, v + 40), v, min(255, v + 20)))
candidates.append((min(255, v + 20), min(255, v + 40), v))
seen = set()
scored = []
for c in candidates:
cr = contrast_ratio(fg_rgb, c)
if cr >= target_ratio and c not in seen:
seen.add(c)
scored.append((cr, c))
# Sort by ratio closest to target (prefer minimal-change backgrounds)
scored.sort(key=lambda x: x[0])
for cr, c in scored[:count]:
suggestions.append({"hex": color_to_hex(c), "rgb": list(c), "ratio": round(cr, 2)})
return suggestions
# ---------------------------------------------------------------------------
# Batch CSS parsing
# ---------------------------------------------------------------------------
_COLOR_RE = re.compile(
r"(#[0-9a-fA-F]{3,6}|rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\))"
)
def extract_css_pairs(css_text: str) -> list:
"""Extract color / background-color pairs from CSS declarations.
Returns a list of dicts with selector, foreground, and background strings.
"""
pairs = []
# Split into rule blocks
block_re = re.compile(r"([^{}]+)\{([^}]+)\}", re.DOTALL)
for m in block_re.finditer(css_text):
selector = m.group(1).strip()
body = m.group(2)
fg = bg = None
# Match color: ... (but not background-color)
fg_match = re.search(
r"(?<![-])color\s*:\s*([^;]+);", body, re.IGNORECASE
)
bg_match = re.search(
r"background(?:-color)?\s*:\s*([^;]+);", body, re.IGNORECASE
)
if fg_match:
val = fg_match.group(1).strip()
c = _COLOR_RE.search(val)
if c:
fg = c.group(1)
elif val.lower() in NAMED_COLORS:
fg = val.lower()
if bg_match:
val = bg_match.group(1).strip()
c = _COLOR_RE.search(val)
if c:
bg = c.group(1)
elif val.lower() in NAMED_COLORS:
bg = val.lower()
if fg and bg:
pairs.append({"selector": selector, "foreground": fg, "background": bg})
return pairs
# ---------------------------------------------------------------------------
# Output formatting
# ---------------------------------------------------------------------------
def format_result_human(fg_str: str, bg_str: str, ratio: float, results: list) -> str:
"""Format a contrast check result for the terminal."""
lines = [
f"Foreground : {fg_str}",
f"Background : {bg_str}",
f"Contrast : {ratio:.2f}:1",
"",
]
for r in results:
status = "PASS" if r["pass"] else "FAIL"
lines.append(f" [{status}] {r['level']:20s} (requires {r['required']}:1)")
return "\n".join(lines)
def format_suggestions_human(fg_str: str, suggestions: list) -> str:
"""Format suggested backgrounds for the terminal."""
lines = [f"Foreground: {fg_str}", "Suggested accessible backgrounds (AA Normal Text):"]
if not suggestions:
lines.append(" No suggestions found.")
for s in suggestions:
lines.append(f" {s['hex']} ratio={s['ratio']}:1")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Demo
# ---------------------------------------------------------------------------
DEMO_PAIRS = [
("#ffffff", "#000000"),
("#336699", "#ffffff"),
("#ff6600", "#ffffff"),
("navy", "white"),
("rgb(100,100,100)", "#eeeeee"),
]
def run_demo(as_json: bool) -> None:
"""Run demo checks and print results."""
all_results = []
for fg_str, bg_str in DEMO_PAIRS:
fg_rgb = parse_color(fg_str)
bg_rgb = parse_color(bg_str)
ratio = contrast_ratio(fg_rgb, bg_rgb)
results = evaluate_contrast(ratio)
entry = {
"foreground": fg_str,
"background": bg_str,
"foreground_hex": color_to_hex(fg_rgb),
"background_hex": color_to_hex(bg_rgb),
"ratio": round(ratio, 2),
"results": results,
}
all_results.append(entry)
if as_json:
print(json.dumps({"demo": True, "checks": all_results}, indent=2))
else:
print("=" * 60)
print("WCAG 2.2 Contrast Checker - Demo")
print("=" * 60)
for entry in all_results:
print()
print(
format_result_human(
entry["foreground"], entry["background"],
entry["ratio"], entry["results"],
)
)
print()
print("-" * 60)
print("Suggestion demo for foreground #336699:")
suggestions = suggest_backgrounds(parse_color("#336699"))
print(format_suggestions_human("#336699", suggestions))
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="WCAG 2.2 Color Contrast Checker. "
"Checks foreground/background pairs against AA and AAA thresholds.",
epilog="Examples:\n"
" %(prog)s '#ffffff' '#000000'\n"
" %(prog)s --suggest '#336699'\n"
" %(prog)s --batch styles.css\n"
" %(prog)s --demo\n",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"foreground",
nargs="?",
help="Foreground (text) color: #RRGGBB, #RGB, rgb(r,g,b), or named color",
)
parser.add_argument(
"background",
nargs="?",
help="Background color: #RRGGBB, #RGB, rgb(r,g,b), or named color",
)
parser.add_argument(
"--suggest",
metavar="COLOR",
help="Suggest accessible background colors for the given foreground color",
)
parser.add_argument(
"--batch",
metavar="CSS_FILE",
help="Extract color pairs from a CSS file and check each",
)
parser.add_argument(
"--json",
action="store_true",
dest="json_output",
help="Output results as JSON",
)
parser.add_argument(
"--demo",
action="store_true",
help="Show example output with sample color pairs",
)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
# --demo mode
if args.demo:
run_demo(args.json_output)
return 0
# --suggest mode
if args.suggest:
try:
fg_rgb = parse_color(args.suggest)
except ValueError as exc:
print(f"Error: {exc}", file=sys.stderr)
return 1
suggestions = suggest_backgrounds(fg_rgb)
if args.json_output:
print(json.dumps({
"foreground": args.suggest,
"foreground_hex": color_to_hex(fg_rgb),
"suggestions": suggestions,
}, indent=2))
else:
print(format_suggestions_human(args.suggest, suggestions))
return 0
# --batch mode
if args.batch:
try:
with open(args.batch, "r", encoding="utf-8") as fh:
css_text = fh.read()
except FileNotFoundError:
print(f"Error: file not found: {args.batch}", file=sys.stderr)
return 1
except OSError as exc:
print(f"Error reading file: {exc}", file=sys.stderr)
return 1
pairs = extract_css_pairs(css_text)
if not pairs:
msg = "No color/background-color pairs found in the CSS file."
if args.json_output:
print(json.dumps({"batch": args.batch, "pairs": [], "message": msg}, indent=2))
else:
print(msg)
return 0
all_results = []
has_failure = False
for pair in pairs:
try:
fg_rgb = parse_color(pair["foreground"])
bg_rgb = parse_color(pair["background"])
except ValueError as exc:
entry = {
"selector": pair["selector"],
"foreground": pair["foreground"],
"background": pair["background"],
"error": str(exc),
}
all_results.append(entry)
continue
ratio = contrast_ratio(fg_rgb, bg_rgb)
results = evaluate_contrast(ratio)
if not results[0]["pass"]: # AA Normal Text
has_failure = True
entry = {
"selector": pair["selector"],
"foreground": pair["foreground"],
"background": pair["background"],
"foreground_hex": color_to_hex(fg_rgb),
"background_hex": color_to_hex(bg_rgb),
"ratio": round(ratio, 2),
"results": results,
}
all_results.append(entry)
if args.json_output:
print(json.dumps({"batch": args.batch, "pairs": all_results}, indent=2))
else:
print(f"Batch check: {args.batch}")
print("=" * 60)
for entry in all_results:
print(f"\nSelector: {entry['selector']}")
if "error" in entry:
print(f" Error: {entry['error']}")
else:
print(
format_result_human(
entry["foreground"], entry["background"],
entry["ratio"], entry["results"],
)
)
print()
summary_pass = sum(1 for e in all_results if "ratio" in e and e["results"][0]["pass"])
summary_total = sum(1 for e in all_results if "ratio" in e)
print(f"Summary: {summary_pass}/{summary_total} pairs pass AA Normal Text")
return 1 if has_failure else 0
# Default: check a single pair
if not args.foreground or not args.background:
parser.error(
"Provide foreground and background colors, or use --suggest, --batch, or --demo."
)
try:
fg_rgb = parse_color(args.foreground)
except ValueError as exc:
print(f"Error (foreground): {exc}", file=sys.stderr)
return 1
try:
bg_rgb = parse_color(args.background)
except ValueError as exc:
print(f"Error (background): {exc}", file=sys.stderr)
return 1
ratio = contrast_ratio(fg_rgb, bg_rgb)
results = evaluate_contrast(ratio)
if args.json_output:
print(json.dumps({
"foreground": args.foreground,
"background": args.background,
"foreground_hex": color_to_hex(fg_rgb),
"background_hex": color_to_hex(bg_rgb),
"ratio": round(ratio, 2),
"results": results,
}, indent=2))
else:
print(format_result_human(args.foreground, args.background, ratio, results))
return 0 if results[0]["pass"] else 1
if __name__ == "__main__":
sys.exit(main())