| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
Reads any makeover .toml (surface/content/action/status/line/ |
| 5 |
category sections), converts hex to WCAG 2.1 relative luminance, |
| 6 |
and reports pass/fail against AA-text (>= 4.5) and AA-UI (>= 3.0) |
| 7 |
for every affordance-carrying token pair. |
| 8 |
|
| 9 |
Also computes Alloy's derived tokens (border-subtle, border-strong) |
| 10 |
via mix formulas and audits those too, so a makeover file that |
| 11 |
was authored without Alloy's discipline still gets a full report. |
| 12 |
|
| 13 |
Usage: |
| 14 |
python3 tools/wcag_audit.py <path/to/theme.toml> |
| 15 |
|
| 16 |
Example: |
| 17 |
python3 tools/wcag_audit.py https://git.sr.ht/~maxmj/makeover/tree/main/item/themes/akari-dawn.toml |
| 18 |
|
| 19 |
import sys |
| 20 |
import os |
| 21 |
|
| 22 |
|
| 23 |
|
| 24 |
def _load_toml(path): |
| 25 |
try: |
| 26 |
import tomllib |
| 27 |
except ImportError: |
| 28 |
try: |
| 29 |
import tomli as tomllib |
| 30 |
except ImportError: |
| 31 |
sys.exit("need tomllib (Python 3.11+) or `pip install --user tomli`") |
| 32 |
with open(path, "rb") as f: |
| 33 |
return tomllib.load(f) |
| 34 |
|
| 35 |
|
| 36 |
|
| 37 |
def hex_to_srgb(h): |
| 38 |
|
| 39 |
h = h.strip().lstrip("#") |
| 40 |
if len(h) != 6: |
| 41 |
raise ValueError(f"expected 6-hex color, got {h!r}") |
| 42 |
r = int(h[0:2], 16) / 255.0 |
| 43 |
g = int(h[2:4], 16) / 255.0 |
| 44 |
b = int(h[4:6], 16) / 255.0 |
| 45 |
return r, g, b |
| 46 |
|
| 47 |
def _linearize(c): |
| 48 |
|
| 49 |
return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 |
| 50 |
|
| 51 |
def relative_luminance_hex(h): |
| 52 |
r, g, b = (_linearize(c) for c in hex_to_srgb(h)) |
| 53 |
return 0.2126 * r + 0.7152 * g + 0.0722 * b |
| 54 |
|
| 55 |
def contrast(a_hex, b_hex): |
| 56 |
Ya = relative_luminance_hex(a_hex) |
| 57 |
Yb = relative_luminance_hex(b_hex) |
| 58 |
lo, hi = sorted((Ya, Yb)) |
| 59 |
return (hi + 0.05) / (lo + 0.05) |
| 60 |
|
| 61 |
def mix_hex(a_hex, b_hex, t): |
| 62 |
|
| 63 |
ar, ag, ab = (_linearize(c) for c in hex_to_srgb(a_hex)) |
| 64 |
br, bg, bb = (_linearize(c) for c in hex_to_srgb(b_hex)) |
| 65 |
mr = ar + (br - ar) * t |
| 66 |
mg = ag + (bg - ag) * t |
| 67 |
mb = ab + (bb - ab) * t |
| 68 |
def _delinearize(c): |
| 69 |
return 12.92 * c if c <= 0.0031308 else 1.055 * (c ** (1 / 2.4)) - 0.055 |
| 70 |
r = round(_delinearize(mr) * 255) |
| 71 |
g = round(_delinearize(mg) * 255) |
| 72 |
b = round(_delinearize(mb) * 255) |
| 73 |
return "#{:02x}{:02x}{:02x}".format(max(0, min(255, r)), |
| 74 |
max(0, min(255, g)), |
| 75 |
max(0, min(255, b))) |
| 76 |
|
| 77 |
|
| 78 |
|
| 79 |
def derive(theme): |
| 80 |
|
| 81 |
|
| 82 |
makeover ships one border tone (line.border); Alloy renders |
| 83 |
three tiers via mix. Formula lives here (not in the theme file) |
| 84 |
so any makeover .toml downloaded from the wild gets a full |
| 85 |
Alloy-shaped token map. |
| 86 |
|
| 87 |
border = theme["line"]["border"] |
| 88 |
surface = theme["surface"]["page"] |
| 89 |
primary = theme["content"]["primary"] |
| 90 |
|
| 91 |
|
| 92 |
|
| 93 |
|
| 94 |
|
| 95 |
return { |
| 96 |
"border-subtle": mix_hex(border, surface, 0.60), |
| 97 |
"border-strong": mix_hex(border, primary, 0.65), |
| 98 |
} |
| 99 |
|
| 100 |
|
| 101 |
|
| 102 |
TEXT_TARGET = 4.5 |
| 103 |
UI_TARGET = 3.0 |
| 104 |
|
| 105 |
def _tag(ratio): |
| 106 |
if ratio >= 7.0: return "AAA" |
| 107 |
if ratio >= 4.5: return "AA-text" |
| 108 |
if ratio >= 3.0: return "AA-UI" |
| 109 |
return "sub-3" |
| 110 |
|
| 111 |
def _row(name, ratio, target): |
| 112 |
status = "PASS" if ratio >= target else "FAIL" |
| 113 |
print(f" {status} {ratio:6.2f}:1 [{_tag(ratio):8s}] {name}") |
| 114 |
|
| 115 |
def audit(theme_path): |
| 116 |
theme = _load_toml(theme_path) |
| 117 |
meta = theme.get("meta", {}) |
| 118 |
surf = theme["surface"] |
| 119 |
text = theme["content"] |
| 120 |
line = theme["line"] |
| 121 |
act = theme["action"] |
| 122 |
stat = theme["status"] |
| 123 |
derived = derive(theme) |
| 124 |
|
| 125 |
name = meta.get("name", os.path.basename(theme_path)) |
| 126 |
variant = meta.get("variant", "?") |
| 127 |
print(f"\n============ {name} ({variant}) ============\n") |
| 128 |
|
| 129 |
|
| 130 |
print("Text on surfaces (target >= 4.5 for text; muted target 3.0)") |
| 131 |
for key in ("primary", "secondary", "muted"): |
| 132 |
target = UI_TARGET if key == "muted" else TEXT_TARGET |
| 133 |
for s_key in ("page", "raised", "sunken", "overlay"): |
| 134 |
r = contrast(text[key], surf[s_key]) |
| 135 |
_row(f"content.{key} on surface.{s_key}", r, target) |
| 136 |
|
| 137 |
|
| 138 |
print("\nBorders on surfaces (border-strong target 3.0; others decorative)") |
| 139 |
for b_name, b_hex in ( |
| 140 |
("border-strong", derived["border-strong"]), |
| 141 |
("line.border", line["border"]), |
| 142 |
("border-subtle", derived["border-subtle"]), |
| 143 |
): |
| 144 |
target = UI_TARGET if b_name == "border-strong" else 0.0 |
| 145 |
for s_key in ("page", "raised", "overlay"): |
| 146 |
r = contrast(b_hex, surf[s_key]) |
| 147 |
_row(f"{b_name} on surface.{s_key}", r, target) |
| 148 |
|
| 149 |
|
| 150 |
print("\nAccents on surfaces (target >= 4.5 text, or >= 3.0 for glyphs)") |
| 151 |
accents = [("action.primary", act["primary"])] |
| 152 |
accents += [(f"status.{k}", stat[k]) for k in ("danger", "success", "warning", "info")] |
| 153 |
for a_name, a_hex in accents: |
| 154 |
for s_key in ("page", "raised", "sunken", "overlay"): |
| 155 |
r = contrast(a_hex, surf[s_key]) |
| 156 |
_row(f"{a_name} on surface.{s_key}", r, TEXT_TARGET) |
| 157 |
|
| 158 |
|
| 159 |
print("\nSurface elevation deltas (perceptual; not WCAG)") |
| 160 |
tiers = ("sunken", "page", "raised", "overlay") |
| 161 |
for a, b in zip(tiers, tiers[1:]): |
| 162 |
Ya = relative_luminance_hex(surf[a]) |
| 163 |
Yb = relative_luminance_hex(surf[b]) |
| 164 |
r = contrast(surf[a], surf[b]) |
| 165 |
print(f" surface.{a:8s} -> surface.{b:8s} ratio {r:5.2f} dY {Yb-Ya:+.4f}") |
| 166 |
|
| 167 |
|
| 168 |
print(f"\nDerived tokens:") |
| 169 |
print(f" border-subtle = {derived['border-subtle']} (mix border, surface.page 60%)") |
| 170 |
print(f" border-strong = {derived['border-strong']} (mix border, content.primary 65%)") |
| 171 |
|
| 172 |
if __name__ == "__main__": |
| 173 |
if len(sys.argv) != 2: |
| 174 |
sys.exit("usage: wcag_audit.py <path/to/theme.toml>") |
| 175 |
audit(sys.argv[1]) |
| 176 |
|