Skip to main content

max / alloy

Dark-mode L-stops: WCAG audit, one token bump, accent rule rewrite Computed WCAG 2.1 relative-luminance contrast for every token pair that carries an affordance in dark mode. Method: OKLCH -> OKLab -> linear sRGB (Bjorn Ottosson matrix) -> clamp to gamut -> ratios. Script committed at tools/wcag_audit.py so future adjustments and the deferred light-mode / calibrated-display passes reuse the same plumbing. Findings: - border-strong at L=48 failed AA-UI against surface-overlay (2.82:1 < 3.0). Bumped to L=52 -> 3.35:1 on overlay, 3.57 on raised, 3.68 on surface. No disturbance to the token's position between border (L=30) and text-muted (L=60). - accent-error and accent-syntax at L=55 land 3.5-3.9:1 on dark surfaces — AA-UI (glyphs) but not AA-text (body). Instead of bumping to L=60 (which would swap the failure to the light side), rewrote the accent-on-surface rule to match DESIGN-LANGUAGE.md's posture: accents live on glyphs, severity indicators, and edge markers, never as running body text. Body stays at text-primary. - Dark-mode surface tiers are 1.02-1.07:1 apart in raw luminance (near-imperceptible). Elevation is border-carried in dark mode where it's surface-carried in light. Documented as a design property, not a bug — it's what sRGB gamma at low L allows. Follow-ups noted in TOKENS.md, not addressed here: - Light-mode border-strong at L=65 vs surface(L=96) = 2.88:1; same-shape bump to L=62 will resolve. - Several light-mode accents fall below 3.0 as text; same "glyph, not body text" rule applies. - Calibrated-display verification stays as pre-1.0 gate. Deliverables: - docs/TOKENS.md: border-strong row updated; new Verified contrast section with three ratio tables + method; accent rule rewritten; What's deferred trimmed to the remaining pre-1.0 items. - tools/wcag_audit.py: reproducible pass, ~130 lines stdlib Python. Runs against DARK and LIGHT dicts inline; extend to test any proposed ramp change.
Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 15:31 UTC
Signed with PGP, not checked
Commit: 5351a125ec89cb57fc0e09b06133a9df08f755ae
Parent: 71d5103
2 files changed, +183 insertions, -7 deletions
M docs/TOKENS.md +61 -7
@@ -49,12 +49,16 @@
49 49 | `surface-overlay` | 19 | (popovers, dialogs) |
50 50 | `border-subtle` | 22 | |
51 51 | `border` | 30 | |
52 - | `border-strong` | 48 | |
52 + | `border-strong` | 52 | |
53 53 | `text-muted` | 60 | |
54 54 | `text-secondary` | 78 | |
55 55 | `text-primary` | 94 | |
56 56
57 - The exact L values are calibrated so contrast ratios between text-primary/surface, text-secondary/surface, and border/surface match within ±0.3 across modes in WCAG 2.1 terms. Verify with a contrast checker on first implementation. The app selects `Mode` at startup, from a `--theme` flag, or by querying the terminal (`$COLORFGBG` / OSC background query).
57 + `border-strong` sits at L=52 (not L=48 as originally proposed) — the [Verified contrast](#verified-contrast) pass showed that L=48 failed AA-UI (≥3.0) against `surface-overlay` at 2.82:1. L=52 clears it at 3.35:1 without disturbing the token's position relative to `border` (L=30) or `text-muted` (L=60).
58 +
59 + The exact L values are calibrated so contrast ratios for text-primary/surface land within ±0.3 across modes; text-secondary drifts slightly wider (dark 10.1:1 vs light 8.9:1) because of the sRGB gamma curve, still well above AA-text (4.5) in both. Full ratio audit below. The app selects `Mode` at startup, from a `--theme` flag, or by querying the terminal (`$COLORFGBG` / OSC background query).
60 +
61 + Dark-mode surface tiers land at 1.02–1.07:1 apart in raw relative luminance — near-imperceptible on their own. The **elevation affordance is border-carried in dark mode** (a `border-strong` block edge signals "this surface is elevated"), where in light mode the surface luminance delta itself carries elevation (1.04–1.13:1). This is a property of sRGB gamma at low L, not a bug: the token L values are what the eye can distinguish given the constraint that dark chrome must stay dark.
58 62
59 63 ## Focus, selected, disabled
60 64
@@ -87,9 +91,13 @@
87 91
88 92 Accents never appear on chrome: no accent-tinted pane backgrounds, no accent borders on inactive controls, no accent focus. They appear in data, on glyphs that encode severity, and in syntax highlighting. In a terminal, an accent is a `.fg(...)` on the glyph and label, never a filled `.bg(...)` behind chrome.
89 93
90 - ### Accent-on-surface text rule
94 + ### Accent-on-surface rule
91 95
92 - When accent color is rendered as text directly on a surface, use the accent value as-is on `surface-raised` or higher. On `surface-sunken` in dark mode, accents at L≈55 can lose contrast; promote to L+5 for that pairing or move the message to a higher surface tier.
96 + Accents live on **glyphs, severity indicators, and the 3px accent-edge marker on inline messages** — never as running body text. Message body stays at `text-primary`; the accent tints only the glyph and the leading-edge stripe. This is the DESIGN-LANGUAGE.md posture ("color reserved for information"), and the [Verified contrast](#verified-contrast) pass shows why it's also the WCAG-honest answer:
97 +
98 + - `accent-error` and `accent-syntax` at L=55 land at 3.5–3.9:1 on dark surfaces. Enough for a glyph or a small severity label under WCAG AA-UI (≥3.0), not enough for message-body text under AA-text (≥4.5).
99 + - `accent-warn` (L=70), `accent-healthy` (L=62), and `accent-info` (L=60) all clear 4.5:1 against every dark-mode surface tier — they can be used freely as small colored labels next to values (e.g., an amber "3 warnings" count).
100 + - Light mode is inverted: the three high-L accents fall below 3.0 as text on light chrome, while `accent-error` and `accent-syntax` at L=55 pass 4.7:1 there. Same "glyph, not body text" discipline resolves it either way; the audit table calls out which is which.
93 101
94 102 ## Typography
95 103
@@ -134,11 +142,57 @@
134 142
135 143 `radius-*`, `border-width-*`, and `focus-ring-offset` from the GUI design are dropped: terminals cannot round corners, vary border width, or offset a ring outside a cell. The square-vs-bordered distinction (data flush and borderless, controls bordered) carries the same affordance the radius did in the GUI.
136 144
145 + ## Verified contrast
146 +
147 + Computed WCAG 2.1 relative-luminance contrast ratios for every token pair that participates in an affordance (text, border, accent-on-surface). Method: OKLCH → OKLab → linear sRGB (Björn Ottosson's matrix) → clamp to gamut → `0.2126·R + 0.7152·G + 0.0722·B` → `(L_max + 0.05) / (L_min + 0.05)`. Targets: **AA-text ≥ 4.5**, **AA-UI ≥ 3.0** (WCAG 2.1). Ratios below are dark-mode with `border-strong` at its landed value of L=52.
148 +
149 + **Text on surfaces (target ≥ 4.5):**
150 +
151 + | Pair | vs sunken (L=8) | vs surface (L=12) | vs raised (L=15) | vs overlay (L=19) |
152 + |---|---:|---:|---:|---:|
153 + | `text-primary` (L=94) | 17.43 | 17.02 | 16.50 | 15.49 |
154 + | `text-secondary` (L=78) | 10.38 | 10.14 | 9.83 | 9.22 |
155 + | `text-muted` (L=60) | 5.26 | 5.14 | 4.98 | 4.68 |
156 +
157 + All twelve pairings clear AA-text; the three text-primary columns clear AAA-text (≥7). `text-muted` was targeted at AA-UI (3.0) as a disabled/placeholder color; it overshoots into AA-text territory, which is fine — the muting is L-driven and reads as tonal, not contrast-starved.
158 +
159 + **Borders on surfaces (target ≥ 3.0 for affordance-carrying borders):**
160 +
161 + | Pair | vs surface (L=12) | vs raised (L=15) | vs overlay (L=19) |
162 + |---|---:|---:|---:|
163 + | `border-strong` (L=52) | 3.68 | 3.57 | 3.35 |
164 + | `border` (L=30) | 1.49 | 1.44 | 1.35 |
165 + | `border-subtle` (L=22) | 1.17 | 1.14 | 1.07 |
166 +
167 + `border-strong` (focus rings, selection edges, focused-block borders) clears AA-UI on every dark surface tier. `border` and `border-subtle` are **decorative dividers** — not required to meet 3.0 under WCAG. They exist to segment surfaces perceptually where the surface-tier delta itself is too small (see the elevation observation above); the affordance-signaling job belongs to `border-strong`.
168 +
169 + **Accents on surfaces:**
170 +
171 + Only reporting the pairings that shift a design decision. Full grid available by running [`tools/wcag_audit.py`](../tools/wcag_audit.py) — no dependencies, ~130 lines of stdlib Python; reused for any future ramp adjustment.
172 +
173 + | Accent (L) | vs surface (L=12) | vs raised (L=15) | vs overlay (L=19) | Result |
174 + |---|---:|---:|---:|---|
175 + | `accent-warn` (70) | 7.52 | 7.29 | 6.85 | AA-text on all dark surfaces |
176 + | `accent-healthy` (62) | 5.95 | 5.76 | 5.41 | AA-text on all dark surfaces |
177 + | `accent-info` (60) | 5.35 | 5.19 | 4.87 | AA-text on all dark surfaces |
178 + | `accent-error` (55) | 3.82 | 3.70 | 3.47 | AA-UI only — glyph, not body text |
179 + | `accent-syntax` (55) | 3.82 | 3.71 | 3.48 | AA-UI only — glyph, not body text |
180 +
181 + The last two rows are why the accent-on-surface rule (above) restricts `accent-error` and `accent-syntax` to glyphs and edge markers rather than tinted body text. Bumping either accent to L=60 would clear AA-text on three of four dark surfaces but drop them below AA-text in light mode (from 4.7 to 3.8 vs light `surface`) — the trade doesn't win, and the DESIGN-LANGUAGE.md posture already reserves accents for glyphs.
182 +
183 + **Follow-up (light mode):**
184 +
185 + The audit surfaced two light-mode issues that are out of scope for this pass but recorded here:
186 +
187 + - `border-strong` at L=65 vs light `surface` (L=96) is 2.88:1, just under AA-UI. Fixable by dropping to L≈62, deferred until the pass is done against a calibrated display.
188 + - `accent-warn`, `accent-healthy`, and `accent-info` all fall below 3.0:1 as text on light chrome (2.13–3.72). Same "glyph, not body text" discipline resolves it; the audit is the authority against ad-hoc tinted-label usage.
189 +
137 190 ## What's deferred
138 191
139 - - Validating the dark-mode L stops against measured WCAG contrast on a calibrated display and in real terminals (truecolor and 256-color).
140 - - The 256-color downgrade table generated from the OKLCH values, for terminals that report no truecolor.
141 - - Figlet/Nerd-Font recipes for the large readout numerics that Departure used to provide.
192 + - **Calibrated-display verification.** The audit above is mathematically sound but hasn't been validated on real display hardware in real terminals (truecolor and 256-color). Pre-1.0 gate.
193 + - **Light-mode L-stop pass** with the same method, to close the two follow-ups noted above.
194 + - **The 256-color downgrade table** generated from the OKLCH values, for terminals that report no truecolor.
195 + - **Figlet / Nerd-Font recipes** for the large readout numerics that Departure used to provide.
142 196
143 197 ## How this file is consumed
144 198
@@ -1,0 +1,122 @@
1 + #!/usr/bin/env python3
2 + """WCAG 2.1 contrast audit for the Alloy token palette.
3 +
4 + Runs the ratio math against every affordance-carrying token pair in
5 + both modes, and reports pass/fail against AA-text (>= 4.5) and AA-UI
6 + (>= 3.0). No dependencies beyond the stdlib.
7 +
8 + Method: OKLCH -> OKLab -> linear sRGB (Bjorn Ottosson matrix) ->
9 + clamp to [0, 1] gamut -> WCAG 2.1 relative luminance
10 + (0.2126*R + 0.7152*G + 0.0722*B) -> (L_max + 0.05) / (L_min + 0.05).
11 +
12 + Run: `python3 tools/wcag_audit.py`
13 + Extend by editing DARK, LIGHT, or ACCENTS below. Reproduces the audit
14 + table documented in docs/TOKENS.md#verified-contrast.
15 + """
16 + import math
17 +
18 + # ---------------------------------------------------------------- math
19 +
20 + def oklab_to_linear_srgb(L, a, b):
21 + l_ = L + 0.3963377774*a + 0.2158037573*b
22 + m_ = L - 0.1055613458*a - 0.0638541728*b
23 + s_ = L - 0.0894841775*a - 1.2914855480*b
24 + l, m, s = l_**3, m_**3, s_**3
25 + r = +4.0767416621*l - 3.3077115913*m + 0.2309699292*s
26 + g = -1.2684380046*l + 2.6097574011*m - 0.3413193965*s
27 + bl = -0.0041960863*l - 0.7034186147*m + 1.7076147010*s
28 + return r, g, bl
29 +
30 + def oklch_to_linear_srgb(L_pct, C, h_deg):
31 + L = L_pct / 100.0
32 + h = math.radians(h_deg)
33 + return oklab_to_linear_srgb(L, C*math.cos(h), C*math.sin(h))
34 +
35 + def relative_luminance(L_pct, C=0.012, h_deg=80):
36 + r, g, b = oklch_to_linear_srgb(L_pct, C, h_deg)
37 + r = max(0.0, min(1.0, r))
38 + g = max(0.0, min(1.0, g))
39 + b = max(0.0, min(1.0, b))
40 + return 0.2126*r + 0.7152*g + 0.0722*b
41 +
42 + def contrast(Ya, Yb):
43 + lo, hi = sorted((Ya, Yb))
44 + return (hi + 0.05) / (lo + 0.05)
45 +
46 + # ---------------------------------------------------------------- data
47 +
48 + # Ramp L values per docs/TOKENS.md (H=80, C=0.012 throughout).
49 + DARK = {
50 + "surface-sunken": 8, "surface": 12, "surface-raised": 15, "surface-overlay": 19,
51 + "border-subtle": 22, "border": 30, "border-strong": 52,
52 + "text-muted": 60, "text-secondary": 78, "text-primary": 94,
53 + }
54 + LIGHT = {
55 + "surface-sunken": 92, "surface": 96, "surface-raised": 98, "surface-overlay": 99.5,
56 + "border-subtle": 88, "border": 80, "border-strong": 65,
57 + "text-muted": 55, "text-secondary": 38, "text-primary": 18,
58 + }
59 +
60 + # Information palette (OKLCH). Same in both modes.
61 + ACCENTS = {
62 + "accent-error": (55, 0.18, 25),
63 + "accent-warn": (70, 0.15, 85),
64 + "accent-healthy": (62, 0.16, 145),
65 + "accent-info": (60, 0.16, 240),
66 + "accent-syntax": (55, 0.18, 310),
67 + }
68 +
69 + TEXT_TARGET = 4.5
70 + UI_TARGET = 3.0
71 +
72 + # ---------------------------------------------------------------- reporting
73 +
74 + def tag(ratio):
75 + if ratio >= 7.0: return "AAA"
76 + if ratio >= 4.5: return "AA-text"
77 + if ratio >= 3.0: return "AA-UI"
78 + return "sub-3"
79 +
80 + def row(name, ratio, target):
81 + ok = "PASS" if ratio >= target else "FAIL"
82 + print(f" {ok} {ratio:6.2f}:1 [{tag(ratio):8s}] {name}")
83 +
84 + def pair_ramp(ramp, a_L, b_L, a_C=0.012, a_h=80):
85 + Ya = relative_luminance(a_L, a_C, a_h)
86 + Yb = relative_luminance(b_L)
87 + return contrast(Ya, Yb)
88 +
89 + def audit(mode_name, RAMP):
90 + print(f"\n============ {mode_name} ============\n")
91 +
92 + print("Text on surfaces (target >= 4.5 text; text-muted target 3.0)")
93 + for text_key in ["text-primary", "text-secondary", "text-muted"]:
94 + target = UI_TARGET if text_key == "text-muted" else TEXT_TARGET
95 + for surf in ["surface", "surface-raised", "surface-sunken", "surface-overlay"]:
96 + r = pair_ramp(RAMP, RAMP[text_key], RAMP[surf])
97 + row(f"{text_key} on {surf}", r, target)
98 +
99 + print("\nBorders on surfaces (border-strong target >= 3.0; others decorative)")
100 + for b_key in ["border-strong", "border", "border-subtle"]:
101 + target = UI_TARGET if b_key == "border-strong" else 0.0
102 + for surf in ["surface", "surface-raised", "surface-overlay"]:
103 + r = pair_ramp(RAMP, RAMP[b_key], RAMP[surf])
104 + row(f"{b_key} on {surf}", r, target)
105 +
106 + print("\nAccents on surfaces (target >= 4.5 text, or >= 3.0 for glyphs)")
107 + for a_name, (L, C, h) in ACCENTS.items():
108 + for surf in ["surface", "surface-raised", "surface-sunken", "surface-overlay"]:
109 + Ya = relative_luminance(L, C, h)
110 + Yb = relative_luminance(RAMP[surf])
111 + row(f"{a_name} on {surf}", contrast(Ya, Yb), TEXT_TARGET)
112 +
113 + print("\nSurface elevation deltas (perceptual; not WCAG)")
114 + tiers = ["surface-sunken", "surface", "surface-raised", "surface-overlay"]
115 + for a, b in zip(tiers, tiers[1:]):
116 + Ya = relative_luminance(RAMP[a])
117 + Yb = relative_luminance(RAMP[b])
118 + print(f" {a:16s} -> {b:16s} ratio {contrast(Ya, Yb):5.2f} dY {Yb-Ya:+.4f}")
119 +
120 + if __name__ == "__main__":
121 + audit("DARK MODE", DARK)
122 + audit("LIGHT MODE", LIGHT)