Skip to content

Commit db25f0d

Browse files
committed
Tooling: Add design-time WCAG contrast checker
New Go tool at `scripts/check-a11y-contrast/` that parses `app.css` + every Svelte `<style>` block, resolves CSS variables (including nested `color-mix(in srgb|oklch, ...)` with proper sRGB/OKLCH math and premultiplied alpha), computes WCAG 2.2 contrast ratios in light and dark modes separately, and flags pairs below 4.5:1 (normal text) or 3:1 (large text). Runs in ~300 ms on 85 files. Replaces the `color-contrast` rule in axe-core E2E tests, which was flaky: webkit2gtk and macOS WebKit disagreed on how to compute effective fg/bg through `color-mix()` chains, producing different ratios for the same CSS. The checker avoids the whole class of flakes by computing ratios deterministically from CSS tokens, not from rendered pixels. Wired into the check runner as `a11y-contrast` (dependency of stylelint). Follows the `scripts/check-css-unused/` pattern. Scope bounds for v1: - Translucent bgs composite over the nearest ancestor's solid bg, or `--color-bg-primary` if indeterminable. - Only flags pairs where the same element (class) sets both `color` and `background` — no full-tree inheritance walk. - Handles `::placeholder`; skips `::before`/`::after` for v1. - `currentColor`/`inherit` skipped with a warning. Docs: `scripts/check-a11y-contrast/README.md` covers scope, limitations, and how to extend. `docs/design-system.md` has a pointer from the a11y section.
1 parent 2666db8 commit db25f0d

17 files changed

Lines changed: 2675 additions & 9 deletions

docs/design-system.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,12 @@ Gold was chosen because it reads as "marked" rather than "active," and contrasts
117117
(the gold is applied only to names, never to standalone labels), but worth revisiting if we get accessibility
118118
complaints.
119119

120+
**Automated contrast checks:** `scripts/check-a11y-contrast/` runs at build time via
121+
`./scripts/check.sh --check a11y-contrast`. It parses `app.css` design tokens and every scoped `<style>` block, resolves
122+
`var()` chains + `color-mix(in srgb, ...)` + `color-mix(in oklch, ...)`, and flags fg/bg pairs that fail WCAG 2.2 in
123+
light OR dark mode. This is intentionally deterministic — no browser, no axe-core — so it doesn't flake on color-mix
124+
rendering quirks. See `scripts/check-a11y-contrast/README.md` for scope and limitations.
125+
120126
### Search highlight colors
121127

122128
| Token | Light | Dark | Role |
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# check-a11y-contrast
2+
3+
Design-time WCAG 2.2 contrast checker for the Cmdr desktop app.
4+
5+
## Why
6+
7+
Our E2E axe-core tests flake on `color-contrast` rules because axe + webkit2gtk + chained `color-mix(var(...))`
8+
sometimes disagree on the effective pixel color. The design tokens themselves are deterministic, though — we can verify
9+
contrast at build time without a browser.
10+
11+
This tool is tier 1 of a three-tier a11y strategy:
12+
13+
1. **tier 1 (this tool)**: Static analysis of design tokens + scoped CSS. Millisecond runtime, no browser.
14+
2. **tier 2**: Visual regression snapshots (future).
15+
3. **tier 3**: E2E axe-core for structural a11y (ARIA, focus, labels).
16+
17+
## Run
18+
19+
```bash
20+
# Direct
21+
go run ./scripts/check-a11y-contrast
22+
23+
# Via check runner
24+
./scripts/check.sh --check a11y-contrast
25+
26+
# Verbose (show warnings from unresolvable values)
27+
go run ./scripts/check-a11y-contrast -- --verbose
28+
```
29+
30+
Exit code 0 on clean, 1 on any violation.
31+
32+
## What it checks
33+
34+
For every element selector in every `.svelte` `<style>` block (and global rules in `app.css`), when the selector sets
35+
both a text color and a background, the tool:
36+
37+
1. Resolves `var(--foo)` chains (including fallbacks `var(--x, #fff)`).
38+
2. Evaluates `color-mix(in srgb, ...)` and `color-mix(in oklch, ...)` exactly.
39+
3. Composites translucent colors over `--color-bg-primary`.
40+
4. Computes WCAG 2.2 contrast ratio for light and dark mode separately.
41+
5. Flags pairs below 4.5:1 (normal text) or 3:1 (large text: ≥24px, or ≥18.66px with weight ≥700).
42+
43+
## Output
44+
45+
```
46+
apps/desktop/src/lib/ui/Button.svelte:97 .btn-danger:hover:not(:disabled) mode=light fg=#d32f2f bg=#fbeaea ratio=4.28 need=4.5 delta=-0.22
47+
```
48+
49+
Each line is: `file:line`, selector, mode, resolved fg hex, resolved bg hex, actual ratio, required threshold, and the
50+
delta (how much contrast is missing).
51+
52+
## Scope and limitations
53+
54+
- **Translucent backgrounds**: composited over `--color-bg-primary`. If the real runtime ancestor is something else, the
55+
reported bg is wrong by a known-small amount. For most color-mix-with-transparent cases the ancestor IS the page
56+
background, so this works.
57+
- **OKLCH mixing**: implemented (via OKLab round-trip). The sibling spaces `hsl`, `oklab`, `lch`, `lab`, `xyz` are
58+
approximated as OKLCH with a warning.
59+
- **Cascade inheritance**: each unique compound-class set is a distinct state. `.foo.bar.baz` inherits from all subset
60+
entries (`.foo`, `.foo.bar`, `.foo.baz`, `.bar.baz`, etc.) in source order, but ONLY their direct contributions —
61+
sibling compound rules don't leak each other's inherited defaults.
62+
- **Pseudo-elements**: `::placeholder` is handled. `::before`, `::after`, `::selection` are skipped.
63+
- **Pseudo-classes** (`:hover`, `:focus`, `:not(...)`) share state with the base class — hover is a transient state, not
64+
a parallel configuration.
65+
- **`currentColor` / `inherit` / `unset` / `initial` / `revert`**: skipped with a warning (no fixed value to check).
66+
- **Specificity across files**: not modeled. We trust source order within one `<style>` block. Global rules in `app.css`
67+
are evaluated separately.
68+
- **Modes**: rules inside `@media (prefers-color-scheme: dark)` are tagged so they only contribute in dark-mode
69+
evaluation.
70+
71+
## Architecture
72+
73+
```
74+
main.go Entry, walks `apps/desktop/src/**/*.svelte`, orchestrates.
75+
parser.go Parses app.css (light + dark var tables) and Svelte <style>
76+
blocks into Rule structs.
77+
resolver.go Resolves a CSS value string (literal / var() / color-mix()) to
78+
RGBA per mode.
79+
contrast.go sRGB parsing, hex/rgb()/named literals, sRGB + OKLCH mixing
80+
(premultiplied alpha), WCAG 2.2 contrast math.
81+
analyzer.go Walks parsed rules per mode, tracks cascade state by compound
82+
class set, emits Finding per (selector, mode) pair.
83+
reporter.go Pretty prints violations and optional warnings.
84+
```
85+
86+
Tests:
87+
88+
- `contrast_test.go` — WCAG math, color-mix, OKLCH round-trip, compositing.
89+
- `resolver_test.go` — var() fallbacks, nested color-mix, dark overrides.
90+
- `parser_test.go` — app.css + Svelte parsing, selector extraction.
91+
- `analyzer_test.go` — cascade inheritance, known false-positive cases.
92+
93+
## Extending
94+
95+
### Add support for a new CSS color function
96+
97+
Edit `resolver.go``Resolver.Resolve`. Add a prefix check (for example `rgb-to-hsl(...)`), then implement the
98+
evaluator.
99+
100+
### Add a new CSS named color
101+
102+
Edit `namedColors` map in `contrast.go`.
103+
104+
### Tune thresholds
105+
106+
WCAG AA (current) uses 4.5:1 / 3:1. For AAA, change the constants in `analyzer.evaluate` (7:1 / 4.5:1).
107+
108+
### Add an allowlist for intentional violations
109+
110+
Not built yet. If the team decides certain findings are acceptable (marketing badges, subtle hover tints), add a JSON
111+
allowlist alongside this README and filter in `main.go` before calling `Report`.
112+
113+
## Known trade-offs
114+
115+
- No support for `light-dark()` — if Cmdr adopts that token pattern later, add parsing in resolver.go.
116+
- Alpha compositing uses straight RGB (spec-correct for opaque-on-opaque). For chained translucent layers (transparent
117+
on transparent on solid), we composite once against `--color-bg-primary`. Sufficient for current patterns.

0 commit comments

Comments
 (0)