|
| 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