| 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 _to_oklab(h): |
| 80 |
r, g, b = (_linearize(c) for c in hex_to_srgb(h)) |
| 81 |
l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b |
| 82 |
m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b |
| 83 |
s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b |
| 84 |
l_, m_, s_ = (v ** (1 / 3) if v >= 0 else -((-v) ** (1 / 3)) for v in (l, m, s)) |
| 85 |
return (0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_, |
| 86 |
1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_, |
| 87 |
0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_) |
| 88 |
|
| 89 |
def _from_oklab(lab): |
| 90 |
L, A, B = lab |
| 91 |
l_ = L + 0.3963377774 * A + 0.2158037573 * B |
| 92 |
m_ = L - 0.1055613458 * A - 0.0638541728 * B |
| 93 |
s_ = L - 0.0894841775 * A - 1.2914855480 * B |
| 94 |
l, m, s = l_ ** 3, m_ ** 3, s_ ** 3 |
| 95 |
def _delin(c): |
| 96 |
c = 12.92 * c if c <= 0.0031308 else 1.055 * (max(c, 0.0) ** (1 / 2.4)) - 0.055 |
| 97 |
return max(0, min(255, round(c * 255))) |
| 98 |
return "#{:02x}{:02x}{:02x}".format( |
| 99 |
_delin(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), |
| 100 |
_delin(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), |
| 101 |
_delin(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s)) |
| 102 |
|
| 103 |
def oklab_mix(a_hex, b_hex, t): |
| 104 |
|
| 105 |
x, y = _to_oklab(a_hex), _to_oklab(b_hex) |
| 106 |
return _from_oklab(tuple(x[i] + (y[i] - x[i]) * t for i in range(3))) |
| 107 |
|
| 108 |
|
| 109 |
|
| 110 |
|
| 111 |
|
| 112 |
|
| 113 |
|
| 114 |
STEP_RATIO = {"secondary": 0.12, "muted": 0.42} |
| 115 |
STEP_FLOOR = 1.21 |
| 116 |
PROBE = 0.005 |
| 117 |
|
| 118 |
def tonal_steps(ink, page): |
| 119 |
|
| 120 |
|
| 121 |
They are not authored. A theme file names `content.primary` and nothing |
| 122 |
else in that family, so this reproduces `makeover::derive_tonal_steps` |
| 123 |
rather than reading keys that are no longer in the file. The two must agree; |
| 124 |
makeover is the source of truth and this is the port. |
| 125 |
|
| 126 |
out, reached = {}, 0.0 |
| 127 |
for key in ("secondary", "muted"): |
| 128 |
ratio = max(STEP_RATIO[key], reached) |
| 129 |
while True: |
| 130 |
color = oklab_mix(ink, page, min(max(ratio, 0.0), 1.0)) |
| 131 |
if contrast(color, ink) >= STEP_FLOOR or ratio >= 1.0: |
| 132 |
break |
| 133 |
ratio += PROBE |
| 134 |
out[key], reached = color, ratio |
| 135 |
return out |
| 136 |
|
| 137 |
def derive(theme): |
| 138 |
|
| 139 |
|
| 140 |
makeover ships one border tone (line.border); Alloy renders |
| 141 |
three tiers via mix. Formula lives here (not in the theme file) |
| 142 |
so any makeover .toml downloaded from the wild gets a full |
| 143 |
Alloy-shaped token map. |
| 144 |
|
| 145 |
border = theme["line"]["border"] |
| 146 |
surface = theme["surface"]["page"] |
| 147 |
primary = theme["content"]["primary"] |
| 148 |
|
| 149 |
|
| 150 |
|
| 151 |
|
| 152 |
|
| 153 |
out = { |
| 154 |
"border-subtle": mix_hex(border, surface, 0.60), |
| 155 |
"border-strong": mix_hex(border, primary, 0.65), |
| 156 |
} |
| 157 |
out.update(tonal_steps(primary, surface)) |
| 158 |
return out |
| 159 |
|
| 160 |
|
| 161 |
|
| 162 |
TEXT_TARGET = 4.5 |
| 163 |
UI_TARGET = 3.0 |
| 164 |
|
| 165 |
def _tag(ratio): |
| 166 |
if ratio >= 7.0: return "AAA" |
| 167 |
if ratio >= 4.5: return "AA-text" |
| 168 |
if ratio >= 3.0: return "AA-UI" |
| 169 |
return "sub-3" |
| 170 |
|
| 171 |
def _row(name, ratio, target): |
| 172 |
status = "PASS" if ratio >= target else "FAIL" |
| 173 |
print(f" {status} {ratio:6.2f}:1 [{_tag(ratio):8s}] {name}") |
| 174 |
|
| 175 |
def audit(theme_path): |
| 176 |
theme = _load_toml(theme_path) |
| 177 |
meta = theme.get("meta", {}) |
| 178 |
surf = theme["surface"] |
| 179 |
text = theme["content"] |
| 180 |
line = theme["line"] |
| 181 |
act = theme["action"] |
| 182 |
stat = theme["status"] |
| 183 |
derived = derive(theme) |
| 184 |
|
| 185 |
name = meta.get("name", os.path.basename(theme_path)) |
| 186 |
variant = meta.get("variant", "?") |
| 187 |
print(f"\n============ {name} ({variant}) ============\n") |
| 188 |
|
| 189 |
|
| 190 |
print("Text on surfaces (target >= 4.5 for text; muted target 3.0)") |
| 191 |
for key in ("primary", "secondary", "muted"): |
| 192 |
target = UI_TARGET if key == "muted" else TEXT_TARGET |
| 193 |
ink = text[key] if key == "primary" else derived[key] |
| 194 |
for s_key in ("page", "raised", "sunken", "overlay"): |
| 195 |
r = contrast(ink, surf[s_key]) |
| 196 |
_row(f"content.{key} on surface.{s_key}", r, target) |
| 197 |
|
| 198 |
|
| 199 |
print("\nBorders on surfaces (border-strong target 3.0; others decorative)") |
| 200 |
for b_name, b_hex in ( |
| 201 |
("border-strong", derived["border-strong"]), |
| 202 |
("line.border", line["border"]), |
| 203 |
("border-subtle", derived["border-subtle"]), |
| 204 |
): |
| 205 |
target = UI_TARGET if b_name == "border-strong" else 0.0 |
| 206 |
for s_key in ("page", "raised", "overlay"): |
| 207 |
r = contrast(b_hex, surf[s_key]) |
| 208 |
_row(f"{b_name} on surface.{s_key}", r, target) |
| 209 |
|
| 210 |
|
| 211 |
print("\nAccents on surfaces (target >= 4.5 text, or >= 3.0 for glyphs)") |
| 212 |
accents = [("action.primary", act["primary"])] |
| 213 |
accents += [(f"status.{k}", stat[k]) for k in ("danger", "success", "warning", "info")] |
| 214 |
for a_name, a_hex in accents: |
| 215 |
for s_key in ("page", "raised", "sunken", "overlay"): |
| 216 |
r = contrast(a_hex, surf[s_key]) |
| 217 |
_row(f"{a_name} on surface.{s_key}", r, TEXT_TARGET) |
| 218 |
|
| 219 |
|
| 220 |
print("\nSurface elevation deltas (perceptual; not WCAG)") |
| 221 |
tiers = ("sunken", "page", "raised", "overlay") |
| 222 |
for a, b in zip(tiers, tiers[1:]): |
| 223 |
Ya = relative_luminance_hex(surf[a]) |
| 224 |
Yb = relative_luminance_hex(surf[b]) |
| 225 |
r = contrast(surf[a], surf[b]) |
| 226 |
print(f" surface.{a:8s} -> surface.{b:8s} ratio {r:5.2f} dY {Yb-Ya:+.4f}") |
| 227 |
|
| 228 |
|
| 229 |
print(f"\nDerived tokens:") |
| 230 |
print(f" border-subtle = {derived['border-subtle']} (mix border, surface.page 60%)") |
| 231 |
print(f" border-strong = {derived['border-strong']} (mix border, content.primary 65%)") |
| 232 |
|
| 233 |
if __name__ == "__main__": |
| 234 |
if len(sys.argv) != 2: |
| 235 |
sys.exit("usage: wcag_audit.py <path/to/theme.toml>") |
| 236 |
audit(sys.argv[1]) |
| 237 |
|