Skip to content
milan.swiss Halbton v0.1.0
Concept project

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.

Every token measured against its declared backdrop, in both themes.
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.

Measured ratios in the light theme. Cells below 3:1 are surface-only — never set text in them.
Foreground canvascanvas-sunkensurfacesurface-raisedinformationalpositivecautioncriticalneutral
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
Measured ratios in the dark theme. Cells below 3:1 are surface-only — never set text in them.
Foreground canvascanvas-sunkensurfacesurface-raisedinformationalpositivecautioncriticalneutral
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.

--ht-focus-ring against each surface, both themes. The worst case is what counts.
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

Every interactive component in the system.
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.
run it yourself
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.