Skip to main content

max / alloy_tui

6.5 KB · 176 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 # ---------------------------------------------------------------- derivation
78
79 def derive(theme):
80 """Compute Alloy's extended tokens from a makeover theme.
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 # border-strong needs 3.0:1 against page for focus rings / selected
91 # rows. Themes vary widely in border softness; mixing 65% toward
92 # text gets there on both crisp (dark border) and soft (Akari-tier)
93 # borders. border-subtle is decorative — 60% toward surface reads
94 # as "hint of a divider" without adding contrast.
95 return {
96 "border-subtle": mix_hex(border, surface, 0.60),
97 "border-strong": mix_hex(border, primary, 0.65),
98 }
99
100 # ---------------------------------------------------------------- reporting
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 # Text on surfaces
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 # Borders on surfaces
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 # Accents on surfaces
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 # Surface elevation deltas (perceptual)
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 # Derived tokens (for downstream consumers wanting to eyeball)
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