Skip to main content

max / alloy

9.2 KB · 237 lines History Blame Raw
1 #!/usr/bin/env python3
2 """WCAG 2.1 contrast audit for makeover theme files.
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 # ---------------------------------------------------------------- toml load
23
24 def _load_toml(path):
25 try:
26 import tomllib
27 except ImportError:
28 try:
29 import tomli as tomllib # noqa
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 # ---------------------------------------------------------------- color math
36
37 def hex_to_srgb(h):
38 """#rrggbb -> (r, g, b) in [0, 1] sRGB (gamma-encoded)."""
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 """sRGB gamma -> linear sRGB per WCAG 2.1."""
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 """Linear-sRGB mix. t=0 => a, t=1 => b. Returns #rrggbb."""
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 # ---------------------------------------------------------------- oklab
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 """Perceptual mix, the one makeover derives with. t=0 => a, t=1 => b."""
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 # ---------------------------------------------------------------- derivation
109
110 # makeover's tonal-step constants (makeover `src/emphasis.rs`). The ratios are
111 # a starting point: each step is pushed further toward the page until it clears
112 # STEP_FLOOR against the ink, so a theme gets a step that can be seen rather
113 # than a step of the agreed size.
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 """content.secondary and content.muted, as makeover derives them at load.
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 """Compute Alloy's extended tokens from a makeover theme.
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 # border-strong needs 3.0:1 against page for focus rings / selected
149 # rows. Themes vary widely in border softness; mixing 65% toward
150 # text gets there on both crisp (dark border) and soft (Akari-tier)
151 # borders. border-subtle is decorative — 60% toward surface reads
152 # as "hint of a divider" without adding contrast.
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 # ---------------------------------------------------------------- reporting
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 # Text on surfaces
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 # Borders on surfaces
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 # Accents on surfaces
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 # Surface elevation deltas (perceptual)
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 # Derived tokens (for downstream consumers wanting to eyeball)
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