max / alloy
- Co-Authored-By
- Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2 files changed,
+183 insertions,
-7 deletions
| @@ -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) |