Replaces FAIL-level (<3:1) and WEAK (<4.5:1) --text-muted/--text-dim/ --text-faint/--text-dimmer/--accent-dim/--gold-dim values across all 33 affected sites. Targets: text-muted >=4.6:1 (WCAG AA), text-dim/text-faint >=7:1 (WCAG AAA). Shared palette pattern (15 sites: deinesei, fragina, ichbinaufbali, ichbinaufbarley, insain, kainstress, kinowhow, knzlmgmt, kopffrai, legalais, martinsiebels, schulfrai, smartin3, sorgenfrai, vonschraitter): --text-muted #44444f -> #7a7a8e (2.06 -> 4.71) --text-dim #6e6e7a -> #9a9aab (3.93 -> 7.14) Otto/mai-otto pattern (purple-tinted): #404068 -> #7373bb, #7070a0 -> #9797d8. Per-site fixes for kainco, kainefrage, kilitaer (olive), killegal, killionaer, killuminati, killusion, killions, orakil (warm), paragraphenraiter (gold), keinefreun, julietensity, billableaua, allaisonme, allainallain, slopschild — each preserves its tint while crossing the AA threshold. Audit before: 31 FAIL, 2 WEAK / 33 sites flagged. Audit after: 0 FAIL, 0 WEAK / 0 sites flagged. Adds tools/contrast-audit.py (lifted from /tmp) so future edits can re-run the regression check from the repo.
126 lines
3.9 KiB
Python
Executable File
126 lines
3.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Quick contrast audit across all onepager sites.
|
|
|
|
Extracts CSS custom properties for backgrounds and text colors,
|
|
computes WCAG contrast ratio for likely text-on-bg pairs, flags
|
|
violations.
|
|
"""
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
SITES_DIR = Path(__file__).resolve().parent.parent / "sites"
|
|
|
|
HEX_RE = re.compile(r"#([0-9a-fA-F]{3,8})\b")
|
|
VAR_DECL_RE = re.compile(r"--([\w-]+)\s*:\s*([^;]+);")
|
|
|
|
def hex_to_rgb(h):
|
|
h = h.lstrip("#")
|
|
if len(h) == 3:
|
|
h = "".join(c * 2 for c in h)
|
|
if len(h) == 8:
|
|
h = h[:6]
|
|
if len(h) != 6:
|
|
return None
|
|
try:
|
|
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
|
|
except ValueError:
|
|
return None
|
|
|
|
def relative_luminance(rgb):
|
|
def channel(c):
|
|
c /= 255
|
|
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
|
|
r, g, b = (channel(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)
|
|
|
|
# Variable names that suggest "background" or "text"
|
|
BG_KEYS = ("bg", "background", "surface")
|
|
TEXT_KEYS = ("text", "fg", "foreground", "color", "muted", "dim", "subtle", "secondary")
|
|
|
|
def is_bg_var(name):
|
|
n = name.lower()
|
|
return any(k in n for k in BG_KEYS) and "border" not in n
|
|
|
|
def is_text_var(name):
|
|
n = name.lower()
|
|
return any(k in n for k in TEXT_KEYS) and "border" not in n and "bg" not in n.split("-")[0]
|
|
|
|
def audit(site):
|
|
html = (SITES_DIR / site / "index.html")
|
|
if not html.exists():
|
|
return None
|
|
css = html.read_text(errors="ignore")
|
|
# Only look at the <style> block(s)
|
|
style_blocks = re.findall(r"<style.*?>(.*?)</style>", css, re.DOTALL | re.IGNORECASE)
|
|
if not style_blocks:
|
|
return None
|
|
css_only = "\n".join(style_blocks)
|
|
|
|
vars_ = {}
|
|
for m in VAR_DECL_RE.finditer(css_only):
|
|
name, val = m.group(1), m.group(2).strip()
|
|
# only resolve to hex
|
|
hm = HEX_RE.search(val)
|
|
if hm:
|
|
rgb = hex_to_rgb(hm.group(0))
|
|
if rgb:
|
|
vars_[name] = rgb
|
|
|
|
bg_vars = {n: c for n, c in vars_.items() if is_bg_var(n)}
|
|
text_vars = {n: c for n, c in vars_.items() if is_text_var(n)}
|
|
|
|
if not bg_vars or not text_vars:
|
|
return None
|
|
|
|
# Find primary bg (the darkest one is usually --bg)
|
|
primary_bg_name = min(bg_vars, key=lambda n: relative_luminance(bg_vars[n]))
|
|
bg_rgb = bg_vars[primary_bg_name]
|
|
bg_lum = relative_luminance(bg_rgb)
|
|
|
|
# Only audit dark backgrounds (lum < 0.05 = near-black)
|
|
if bg_lum > 0.1:
|
|
return None # not a dark site
|
|
|
|
findings = []
|
|
for tname, trgb in text_vars.items():
|
|
ratio = contrast_ratio(trgb, bg_rgb)
|
|
if ratio < 4.5: # WCAG AA for body text
|
|
findings.append((tname, trgb, ratio))
|
|
|
|
if not findings:
|
|
return None
|
|
return {
|
|
"site": site,
|
|
"bg_name": primary_bg_name,
|
|
"bg_rgb": bg_rgb,
|
|
"findings": findings,
|
|
}
|
|
|
|
results = []
|
|
for site in sorted(p.name for p in SITES_DIR.iterdir() if p.is_dir()):
|
|
r = audit(site)
|
|
if r:
|
|
results.append(r)
|
|
|
|
print(f"Sites with sub-AA text on dark bg: {len(results)}/59\n")
|
|
for r in results:
|
|
bg = r["bg_rgb"]
|
|
print(f"{r['site']} (bg --{r['bg_name']} = #{bg[0]:02x}{bg[1]:02x}{bg[2]:02x})")
|
|
for name, rgb, ratio in sorted(r["findings"], key=lambda x: x[2]):
|
|
flag = "FAIL" if ratio < 3.0 else ("WEAK" if ratio < 4.5 else "OK")
|
|
print(f" {flag:4} ratio {ratio:5.2f} --{name:24} #{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}")
|
|
print()
|
|
|
|
# Summary: how many distinct sites have FAIL (< 3.0) somewhere
|
|
fails = [r for r in results if any(ratio < 3.0 for _, _, ratio in r["findings"])]
|
|
print(f"\n=== SUMMARY ===")
|
|
print(f"Dark-bg sites with at least one FAIL (<3:1): {len(fails)}")
|
|
print(f"Dark-bg sites with WEAK (<4.5 but >=3): {len(results) - len(fails)}")
|