Accessibility
Accessibility
Not a compliance badge. This is what the system checks, what it measures, and what it refuses to build if the numbers come out wrong.
What is enforced, not just intended
The build fails if any token misses the threshold it claims. That is the difference between an accessibility statement and an accessibility guarantee: this page cannot describe a passing system unless the system passes, because the same function produces both the gate and these tables.
| Check | Count | Result |
|---|---|---|
| Tokens with a declared backdrop | 25 | each measured in both themes |
| Failing their declared threshold | 15 | build would fail |
| Text tiers clearing AA at body size | 3 / 3 | content, content-muted, content-subtle |
The contrast matrix
Every text and UI token against every surface it could be placed on — including combinations the system does not currently use, because the question a person adopting this asks is "can I put X on Y", not "did you". Ratios are for the active theme; switch it and the numbers change.
| Foreground | canvas | canvas-sunken | surface | surface-raised | informational | positive | caution | critical | neutral |
|---|---|---|---|---|---|---|---|---|---|
| content | 13.16:1 against canvas: passes AA for body text | 12.16:1 against canvas-sunken: passes AA for body text | 13.27:1 against surface: passes AA for body text | 13.51:1 against surface-raised: passes AA for body text | 11.72:1 against informational-surface: passes AA for body text | 11.80:1 against positive-surface: passes AA for body text | 11.71:1 against caution-surface: passes AA for body text | 11.61:1 against critical-surface: passes AA for body text | 11.71:1 against neutral-surface: passes AA for body text |
| content-muted | 8.47:1 against canvas: passes AA for body text | 7.83:1 against canvas-sunken: passes AA for body text | 8.54:1 against surface: passes AA for body text | 8.69:1 against surface-raised: passes AA for body text | 7.54:1 against informational-surface: passes AA for body text | 7.59:1 against positive-surface: passes AA for body text | 7.54:1 against caution-surface: passes AA for body text | 7.48:1 against critical-surface: passes AA for body text | 7.54:1 against neutral-surface: passes AA for body text |
| content-subtle | 5.43:1 against canvas: passes AA for body text | 5.02:1 against canvas-sunken: passes AA for body text | 5.48:1 against surface: passes AA for body text | 5.57:1 against surface-raised: passes AA for body text | 4.83:1 against informational-surface: passes AA for body text | 4.87:1 against positive-surface: passes AA for body text | 4.83:1 against caution-surface: passes AA for body text | 4.79:1 against critical-surface: passes AA for body text | 4.83:1 against neutral-surface: passes AA for body text |
| border-interactive | 3.65:1 against canvas: large text only | 3.37:1 against canvas-sunken: large text only | 3.68:1 against surface: large text only | 3.74:1 against surface-raised: large text only | 3.25:1 against informational-surface: large text only | 3.27:1 against positive-surface: large text only | 3.24:1 against caution-surface: large text only | 3.22:1 against critical-surface: large text only | 3.24:1 against neutral-surface: large text only |
| focus-ring | 5.51:1 against canvas: passes AA for body text | 5.09:1 against canvas-sunken: passes AA for body text | 5.56:1 against surface: passes AA for body text | 5.66:1 against surface-raised: passes AA for body text | 4.91:1 against informational-surface: passes AA for body text | 4.94:1 against positive-surface: passes AA for body text | 4.90:1 against caution-surface: passes AA for body text | 4.86:1 against critical-surface: passes AA for body text | 4.90:1 against neutral-surface: passes AA for body text |
| informational-content | 8.55:1 against canvas: passes AA for body text | 7.90:1 against canvas-sunken: passes AA for body text | 8.63:1 against surface: passes AA for body text | 8.78:1 against surface-raised: passes AA for body text | 7.62:1 against informational-surface: passes AA for body text | 7.67:1 against positive-surface: passes AA for body text | 7.61:1 against caution-surface: passes AA for body text | 7.55:1 against critical-surface: passes AA for body text | 7.61:1 against neutral-surface: passes AA for body text |
| positive-content | 8.23:1 against canvas: passes AA for body text | 7.61:1 against canvas-sunken: passes AA for body text | 8.31:1 against surface: passes AA for body text | 8.45:1 against surface-raised: passes AA for body text | 7.33:1 against informational-surface: passes AA for body text | 7.38:1 against positive-surface: passes AA for body text | 7.33:1 against caution-surface: passes AA for body text | 7.27:1 against critical-surface: passes AA for body text | 7.33:1 against neutral-surface: passes AA for body text |
| caution-content | 8.65:1 against canvas: passes AA for body text | 7.99:1 against canvas-sunken: passes AA for body text | 8.72:1 against surface: passes AA for body text | 8.88:1 against surface-raised: passes AA for body text | 7.70:1 against informational-surface: passes AA for body text | 7.75:1 against positive-surface: passes AA for body text | 7.69:1 against caution-surface: passes AA for body text | 7.63:1 against critical-surface: passes AA for body text | 7.70:1 against neutral-surface: passes AA for body text |
| critical-content | 8.82:1 against canvas: passes AA for body text | 8.15:1 against canvas-sunken: passes AA for body text | 8.89:1 against surface: passes AA for body text | 9.05:1 against surface-raised: passes AA for body text | 7.85:1 against informational-surface: passes AA for body text | 7.91:1 against positive-surface: passes AA for body text | 7.85:1 against caution-surface: passes AA for body text | 7.78:1 against critical-surface: passes AA for body text | 7.85:1 against neutral-surface: passes AA for body text |
| neutral-content | 8.47:1 against canvas: passes AA for body text | 7.83:1 against canvas-sunken: passes AA for body text | 8.54:1 against surface: passes AA for body text | 8.69:1 against surface-raised: passes AA for body text | 7.54:1 against informational-surface: passes AA for body text | 7.59:1 against positive-surface: passes AA for body text | 7.54:1 against caution-surface: passes AA for body text | 7.48:1 against critical-surface: passes AA for body text | 7.54:1 against neutral-surface: passes AA for body text |
| Foreground | canvas | canvas-sunken | surface | surface-raised | informational | positive | caution | critical | neutral |
|---|---|---|---|---|---|---|---|---|---|
| content | 16.24:1 against canvas: passes AA for body text | 16.85:1 against canvas-sunken: passes AA for body text | 15.27:1 against surface: passes AA for body text | 13.99:1 against surface-raised: passes AA for body text | 7.61:1 against informational-surface: passes AA for body text | 7.33:1 against positive-surface: passes AA for body text | 7.70:1 against caution-surface: passes AA for body text | 7.85:1 against critical-surface: passes AA for body text | 7.54:1 against neutral-surface: passes AA for body text |
| content-muted | 12.04:1 against canvas: passes AA for body text | 12.49:1 against canvas-sunken: passes AA for body text | 11.31:1 against surface: passes AA for body text | 10.37:1 against surface-raised: passes AA for body text | 5.64:1 against informational-surface: passes AA for body text | 5.43:1 against positive-surface: passes AA for body text | 5.70:1 against caution-surface: passes AA for body text | 5.81:1 against critical-surface: passes AA for body text | 5.58:1 against neutral-surface: passes AA for body text |
| content-subtle | 9.53:1 against canvas: passes AA for body text | 9.89:1 against canvas-sunken: passes AA for body text | 8.96:1 against surface: passes AA for body text | 8.21:1 against surface-raised: passes AA for body text | 4.47:1 against informational-surface: large text only | 4.30:1 against positive-surface: large text only | 4.51:1 against caution-surface: passes AA for body text | 4.60:1 against critical-surface: passes AA for body text | 4.42:1 against neutral-surface: large text only |
| border-interactive | 7.06:1 against canvas: passes AA for body text | 7.33:1 against canvas-sunken: passes AA for body text | 6.64:1 against surface: passes AA for body text | 6.08:1 against surface-raised: passes AA for body text | 3.31:1 against informational-surface: large text only | 3.18:1 against positive-surface: large text only | 3.34:1 against caution-surface: large text only | 3.41:1 against critical-surface: large text only | 3.27:1 against neutral-surface: large text only |
| focus-ring | 9.46:1 against canvas: passes AA for body text | 9.82:1 against canvas-sunken: passes AA for body text | 8.89:1 against surface: passes AA for body text | 8.15:1 against surface-raised: passes AA for body text | 4.43:1 against informational-surface: large text only | 4.27:1 against positive-surface: large text only | 4.48:1 against caution-surface: large text only | 4.57:1 against critical-surface: passes AA for body text | 4.39:1 against neutral-surface: large text only |
| informational-content | 14.30:1 against canvas: passes AA for body text | 14.83:1 against canvas-sunken: passes AA for body text | 13.44:1 against surface: passes AA for body text | 12.32:1 against surface-raised: passes AA for body text | 6.70:1 against informational-surface: passes AA for body text | 6.45:1 against positive-surface: passes AA for body text | 6.77:1 against caution-surface: passes AA for body text | 6.91:1 against critical-surface: passes AA for body text | 6.63:1 against neutral-surface: passes AA for body text |
| positive-content | 14.58:1 against canvas: passes AA for body text | 15.12:1 against canvas-sunken: passes AA for body text | 13.70:1 against surface: passes AA for body text | 12.56:1 against surface-raised: passes AA for body text | 6.83:1 against informational-surface: passes AA for body text | 6.58:1 against positive-surface: passes AA for body text | 6.91:1 against caution-surface: passes AA for body text | 7.04:1 against critical-surface: passes AA for body text | 6.76:1 against neutral-surface: passes AA for body text |
| caution-content | 14.25:1 against canvas: passes AA for body text | 14.78:1 against canvas-sunken: passes AA for body text | 13.39:1 against surface: passes AA for body text | 12.27:1 against surface-raised: passes AA for body text | 6.68:1 against informational-surface: passes AA for body text | 6.43:1 against positive-surface: passes AA for body text | 6.75:1 against caution-surface: passes AA for body text | 6.88:1 against critical-surface: passes AA for body text | 6.61:1 against neutral-surface: passes AA for body text |
| critical-content | 14.18:1 against canvas: passes AA for body text | 14.71:1 against canvas-sunken: passes AA for body text | 13.32:1 against surface: passes AA for body text | 12.21:1 against surface-raised: passes AA for body text | 6.64:1 against informational-surface: passes AA for body text | 6.40:1 against positive-surface: passes AA for body text | 6.72:1 against caution-surface: passes AA for body text | 6.85:1 against critical-surface: passes AA for body text | 6.58:1 against neutral-surface: passes AA for body text |
| neutral-content | 14.36:1 against canvas: passes AA for body text | 14.90:1 against canvas-sunken: passes AA for body text | 13.50:1 against surface: passes AA for body text | 12.37:1 against surface-raised: passes AA for body text | 6.73:1 against informational-surface: passes AA for body text | 6.48:1 against positive-surface: passes AA for body text | 6.80:1 against caution-surface: passes AA for body text | 6.94:1 against critical-surface: passes AA for body text | 6.66:1 against neutral-surface: passes AA for body text |
Each cell states its ratio as text. The dot is redundant reinforcement — remove it and the table still says everything it needs to.
Focus
The focus ring is the one signal in the system that is never subtle. It uses a dedicated token checked against every surface it can land on rather than just the page canvas — a ring that clears 3:1 on the canvas and fails on a raised panel is not a passing ring.
| Surface | Light | Dark |
|---|---|---|
| --ht-surface | 5.56:1 pass | 8.89:1 pass |
| --ht-canvas | 5.51:1 pass | 9.46:1 pass |
| --ht-canvas-sunken | 5.09:1 pass | 9.82:1 pass |
| --ht-surface-raised | 5.66:1 pass | 8.15:1 pass |
Focus management rules
- Focus is never removed, only restyled. outline: none without a replacement is the single most damaging line in a stylesheet.
- Order follows the DOM. No positive tabindex anywhere — it creates an order nobody can predict from reading the markup.
- Anything that traps focus restores it. The Modal returns focus to the element that opened it, after making the page interactive again. Restoring while the trigger is still inert silently fails.
- Roving tabindex for composites. Tab reaches a tab list once; arrows move within it.
- Nothing steals focus on load. Not the search field, not a tab, not a toast.
Motion policy
Every animation resolves to its end state under prefers-reduced-motion, never to a shortened version. The theme flip is never animated at all. The full argument, with demonstrations, is on Motion.
Keyboard contract
| Component | Keys | Notes |
|---|---|---|
| Button | Space, Enter | Native. Nothing reimplemented. |
| Text field | Tab | Native. Errors linked with aria-describedby. |
| Select | Space, arrows, type-ahead, Escape | Entirely the platform's — the reason it is a real <select>. |
| Modal | Tab, Shift+Tab, Escape | Focus trapped, restored to the trigger on close. |
| Tabs | Tab, → ←, Home, End | Roving tabindex: the list is one stop, not one per tab. |
| Toast | Tab to the dismiss button | Never takes focus itself. |
| Data table | Tab into the scroll region | Focusable, so overflowing content is reachable. |
| Empty state | Tab to the action | Ordinary content in its natural position. |
How this is verified
Against the built output, not the dev server, on every page of this site:
- axe-core, WCAG 2.1 A and AA — every route × both themes × 375/768/1440, plus the states that only exist after interaction: modal open, playground toggled, sidebar drawer open, islands hydrated.
- Open states are audited separately, because a closed dialog is display: none and axe skips it entirely — passing there proves nothing.
- The keyboard contracts are driven in a browser, not asserted. Tab cycles counted, Escape pressed, focus target checked after close.
- No-flash is proved under the opposite OS preference, so a pass means the stored choice won rather than the default happening to match.
- Zero horizontal overflow, exactly one <h1>, no console errors.
npm run build:halbton
npx --yes -p playwright -p axe-core node scripts/a11y-check.mjs What is not claimed
This has not been tested with real assistive technology users, which is the only test that finds the problems automated checking cannot. axe catches perhaps a third of WCAG issues in practice; the rest need a human. Halbton is a concept project, and saying it is "accessible" full stop would be exactly the kind of unearned claim the rest of this site is built to avoid.
What is true: every published colour pairing is measured and enforced at build time, every interactive component has a keyboard contract exercised in a browser, and every page passes axe in both themes at three viewports.