Some checks failed
CI/CD / detect-changes (pull_request) Successful in 3s
CI/CD / test-frontend (pull_request) Successful in 4s
CI/CD / build-and-deploy (pull_request) Has been cancelled
CI/CD / test-sqlite (pull_request) Has been cancelled
CI/CD / test-go-pg (pull_request) Has been cancelled
Machine-auditable UI quality gate: four audit scripts, structured survey prompt, WCAG contrast fixes, mobile touch targets, focus indicators, and Docker Hub documentation correction. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
226 lines
9.1 KiB
Bash
Executable File
226 lines
9.1 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# check-contrast.sh — WCAG AA contrast ratio checker for theme tokens.
|
|
# Parses variables.css for dark/light token pairs, checks text-on-background contrast.
|
|
# Usage: bash scripts/check-contrast.sh [variables.css]
|
|
|
|
set -euo pipefail
|
|
CSS_FILE="${1:-src/css/variables.css}"
|
|
|
|
# Verify python3 is available
|
|
if ! command -v python3 &>/dev/null; then
|
|
echo "ERROR: python3 required for luminance computation" >&2
|
|
exit 1
|
|
fi
|
|
|
|
# ── Extract tokens from a CSS block ─────────────────────
|
|
# Usage: extract_tokens <label> <start-pattern>
|
|
# Populates an associative array via eval
|
|
extract_block() {
|
|
local label="$1" start_pat="$2"
|
|
local in_block=0 depth=0
|
|
declare -gA "$label"
|
|
while IFS= read -r line; do
|
|
if [[ "$line" =~ $start_pat ]]; then
|
|
in_block=1; depth=0
|
|
fi
|
|
if (( in_block )); then
|
|
# Count braces
|
|
opens="${line//[^\{]/}"; depth=$((depth + ${#opens}))
|
|
closes="${line//[^\}]/}"; depth=$((depth - ${#closes}))
|
|
# Extract token definitions
|
|
if [[ "$line" =~ --([a-zA-Z0-9_-]+):[[:space:]]*([^;]+)\; ]]; then
|
|
local key="${BASH_REMATCH[1]}" val="${BASH_REMATCH[2]}"
|
|
val="${val%"${val##*[![:space:]]}"}" # trim trailing ws
|
|
eval "${label}[${key}]=\"${val}\""
|
|
fi
|
|
(( depth <= 0 && in_block )) && in_block=0
|
|
fi
|
|
done < "$CSS_FILE"
|
|
}
|
|
|
|
extract_block "DARK" "^:root"
|
|
extract_block "LIGHT" '^\[data-theme="light"\]'
|
|
|
|
# ── Semantic pairings to check ──────────────────────────
|
|
# Format: "foreground_token:background_token:context:threshold"
|
|
# threshold: 4.5 = AA normal text, 3.0 = AA large text (>=18px bold or >=24px)
|
|
PAIRINGS=(
|
|
"text:bg:body text:4.5"
|
|
"text:bg-surface:surface text:4.5"
|
|
"text:bg-raised:raised text:4.5"
|
|
"text:bg-hover:hover text:4.5"
|
|
"text:bg-active:active text:4.5"
|
|
"text-2:bg:secondary text on body:4.5"
|
|
"text-2:bg-surface:secondary text on surface:4.5"
|
|
"text-2:bg-raised:secondary text on raised:4.5"
|
|
"text-3:bg:muted text on body:3.0"
|
|
"text-3:bg-surface:muted text on surface:3.0"
|
|
"text-3:bg-raised:muted text on raised:3.0"
|
|
"accent:bg:accent on body:3.0"
|
|
"accent:bg-surface:accent on surface:3.0"
|
|
"accent:bg-raised:accent on raised:3.0"
|
|
"accent-light:accent-dim:badge accent:3.0"
|
|
"danger-light:danger-dim:badge danger:3.0"
|
|
"success-light:success-dim:badge success:3.0"
|
|
"warning-light:warning-dim:badge warning:3.0"
|
|
"text-on-color:accent:text on accent btn:3.0"
|
|
"text-on-color:danger:text on danger btn:3.0"
|
|
"text-on-color:success:text on success btn:3.0"
|
|
"text-on-warning:warning:text on warning:4.5"
|
|
"text:input-bg:input text:4.5"
|
|
"text-3:input-bg:placeholder on input:3.0"
|
|
)
|
|
|
|
# ── Python script for contrast computation ──────────────
|
|
CONTRAST_PY=$(cat <<'PYEOF'
|
|
import sys, re, json
|
|
|
|
def parse_color(val, base_bg=None):
|
|
"""Parse hex or rgba() color to (r,g,b) tuple, 0-255."""
|
|
val = val.strip()
|
|
m = re.match(r'^#([0-9a-fA-F]{6})$', val)
|
|
if m:
|
|
h = m.group(1)
|
|
return (int(h[0:2],16), int(h[2:4],16), int(h[4:6],16))
|
|
m = re.match(r'^#([0-9a-fA-F]{3})$', val)
|
|
if m:
|
|
h = m.group(1)
|
|
return (int(h[0]*2,16), int(h[1]*2,16), int(h[2]*2,16))
|
|
m = re.match(r'^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)$', val)
|
|
if m:
|
|
r,g,b = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
|
a = float(m.group(4)) if m.group(4) else 1.0
|
|
if a < 1.0 and base_bg:
|
|
br, bg_, bb = base_bg
|
|
r = int(r * a + br * (1 - a))
|
|
g = int(g * a + bg_ * (1 - a))
|
|
b = int(b * a + bb * (1 - a))
|
|
return (r, g, b)
|
|
return None
|
|
|
|
def relative_luminance(rgb):
|
|
"""WCAG 2.1 relative luminance."""
|
|
def linearize(c):
|
|
s = c / 255.0
|
|
return s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4
|
|
r, g, b = rgb
|
|
return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
|
|
|
|
def contrast_ratio(c1, c2):
|
|
l1, l2 = relative_luminance(c1), relative_luminance(c2)
|
|
if l1 < l2: l1, l2 = l2, l1
|
|
return (l1 + 0.05) / (l2 + 0.05)
|
|
|
|
# Read input: JSON with dark/light tokens + pairings
|
|
data = json.loads(sys.stdin.read())
|
|
results = []
|
|
for theme_name, tokens in [("dark", data["dark"]), ("light", data["light"])]:
|
|
base_bg = parse_color(tokens.get("bg", "#000000"))
|
|
for p in data["pairings"]:
|
|
fg_key, bg_key, ctx = p[0], p[1], p[2]
|
|
fg_val = tokens.get(fg_key)
|
|
bg_val = tokens.get(bg_key)
|
|
if not fg_val or not bg_val:
|
|
results.append({"theme": theme_name, "fg": fg_key, "bg": bg_key, "context": ctx,
|
|
"ratio": "N/A", "result": "SKIP", "note": "token missing"})
|
|
continue
|
|
fg_rgb = parse_color(fg_val, base_bg)
|
|
bg_rgb = parse_color(bg_val, base_bg)
|
|
if not fg_rgb or not bg_rgb:
|
|
results.append({"theme": theme_name, "fg": fg_key, "bg": bg_key, "context": ctx,
|
|
"ratio": "N/A", "result": "SKIP", "note": "unparseable color"})
|
|
continue
|
|
ratio = contrast_ratio(fg_rgb, bg_rgb)
|
|
threshold = p[3] if len(p) > 3 else 4.5
|
|
passed = ratio >= threshold
|
|
level = "AA" if threshold >= 4.5 else "AA-lg"
|
|
results.append({"theme": theme_name, "fg": fg_key, "bg": bg_key, "context": ctx,
|
|
"fg_val": fg_val, "bg_val": bg_val,
|
|
"ratio": f"{ratio:.2f}:1", "result": "PASS" if passed else "FAIL",
|
|
"level": level})
|
|
|
|
json.dump(results, sys.stdout)
|
|
PYEOF
|
|
)
|
|
|
|
# ── Build JSON input for Python ─────────────────────────
|
|
build_tokens_json() {
|
|
local -n arr=$1
|
|
echo "{"
|
|
local first=true
|
|
for key in "${!arr[@]}"; do
|
|
$first && first=false || echo ","
|
|
# Escape any quotes in value
|
|
local val="${arr[$key]}"
|
|
printf ' "%s": "%s"' "$key" "$val"
|
|
done
|
|
echo ""
|
|
echo "}"
|
|
}
|
|
|
|
build_pairings_json() {
|
|
echo "["
|
|
local first=true
|
|
for p in "${PAIRINGS[@]}"; do
|
|
IFS=':' read -r fg bg ctx threshold <<< "$p"
|
|
threshold="${threshold:-4.5}"
|
|
$first && first=false || echo ","
|
|
printf ' ["%s", "%s", "%s", %s]' "$fg" "$bg" "$ctx" "$threshold"
|
|
done
|
|
echo ""
|
|
echo "]"
|
|
}
|
|
|
|
INPUT_JSON=$(cat <<JSONEOF
|
|
{
|
|
"dark": $(build_tokens_json DARK),
|
|
"light": $(build_tokens_json LIGHT),
|
|
"pairings": $(build_pairings_json)
|
|
}
|
|
JSONEOF
|
|
)
|
|
|
|
RESULTS=$(echo "$INPUT_JSON" | python3 -c "$CONTRAST_PY")
|
|
|
|
# ── Format output ───────────────────────────────────────
|
|
echo "╔══════════════════════════════════════════════════════════════════════════════════╗"
|
|
echo "║ WCAG AA Contrast Ratio Report ║"
|
|
echo "╠══════════════════════════════════════════════════════════════════════════════════╣"
|
|
printf "║ %-6s │ %-20s │ %-16s │ %-10s │ %-6s │ %-5s │ %-6s ║\n" "Theme" "Context" "FG Token" "BG Token" "Ratio" "Level" "Result"
|
|
echo "╠══════════════════════════════════════════════════════════════════════════════════╣"
|
|
|
|
pass=0 fail=0 skip=0
|
|
while IFS= read -r row; do
|
|
theme=$(echo "$row" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['theme'])")
|
|
ctx=$(echo "$row" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['context'])")
|
|
fg=$(echo "$row" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['fg'])")
|
|
bg=$(echo "$row" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['bg'])")
|
|
ratio=$(echo "$row" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['ratio'])")
|
|
result=$(echo "$row" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['result'])")
|
|
level=$(echo "$row" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('level','AA'))")
|
|
|
|
case "$result" in
|
|
PASS) pass=$((pass + 1));;
|
|
FAIL) fail=$((fail + 1));;
|
|
SKIP) skip=$((skip + 1));;
|
|
esac
|
|
printf "║ %-6s │ %-20s │ %-16s │ %-10s │ %-6s │ %-5s │ %-6s ║\n" "$theme" "$ctx" "$fg" "$bg" "$ratio" "$level" "$result"
|
|
done < <(echo "$RESULTS" | python3 -c "
|
|
import sys, json
|
|
for item in json.loads(sys.stdin.read()):
|
|
print(json.dumps(item))
|
|
")
|
|
|
|
echo "╚══════════════════════════════════════════════════════════════════════════════════╝"
|
|
echo ""
|
|
echo "Summary: $pass PASS, $fail FAIL, $skip SKIP"
|
|
echo ""
|
|
|
|
if (( fail > 0 )); then
|
|
echo "RESULT: FAIL — $fail contrast violation(s) found"
|
|
exit 1
|
|
else
|
|
echo "RESULT: PASS — all checked pairings meet WCAG AA (4.5:1)"
|
|
exit 0
|
|
fi
|