Accessibility
Checked by the build, not asserted in a paragraph
Every component is audited against WCAG 2.1 Level AA on every change, and a violation fails the test suite. The audit runs twice: once per story, where a component stands alone, and once over the served site in both themes, where the page's landmarks and heading order exist for the first time. The keyboard and focus rules below are assertions, not guidance.
Coverage
Read from the component source rather than claimed.
What the build enforces
Four gates, all in the same run as everything else. None of them is a separate ritual somebody has to remember.
| Gate | What it proves | When |
|---|---|---|
Component audit | axe runs against every story in headless Chromium. Every variant of every component is audited, not a sample. | Story tests, on every change |
Page audit | axe runs again over the served build in both themes, on the WCAG 2.1 A and AA rule set, where landmark structure and heading order are visible for the first time. A component alone cannot show either. | After the website build, in CI and local verify |
Theme contrast | Every shipped theme has its action colour and text resolved through the var() chain and held to the AA threshold. | The validate-registry chain |
Behaviour assertions | Focus movement, Escape handling and overlay stacking, asserted as story play functions rather than described in a doc. | Story tests, on every change |
Themes start from an AA baseline
The shipped themes are examples, and the expectation is that you build your own. What travels is the baseline they are built to: an action colour and the text on it are resolved through the var() chain and held to the AA threshold in light and dark. Every shipped theme clears it, so whichever one you start from is a passing starting point.
What every component inherits
The interactive components are built on this repo's own behaviour layer rather than a third-party primitives library, so the keyboard and focus rules are written once and shared. 5 modules carry them.
| Behaviour | What a consumer gets |
|---|---|
Focus scope | Tab and Shift+Tab cycle inside an open panel and nowhere else, focus returns to the element that opened it, and the background is marked inert while it is open. |
Layer stack | Escape and an outside click reach only the topmost overlay, so a dialog opened over a dialog closes one at a time instead of both at once. |
Counted scroll lock | The page unlocks when the last holder releases it, so two overlays never unlock the body under each other. Published as ./behaviors/useScrollLock so host chrome joins the same lock rather than fights it. |
Shared focusable query | One definition of what counts as focusable, used by the overlays and by roving arrow-key navigation, so two components never disagree about what Tab should reach. |
Reduced-motion guard | A reduced-motion preference collapses every duration token at the token layer, so a component honours the preference by using the scale rather than by remembering to check. |
The overlay keyboard contract
Alert dialog, App sidebar, Command palette, Dialog, Drawer and Lightbox share one contract. Story assertions hold them to it, so a regression in any of these rows fails the suite.
| Key | Behaviour |
|---|---|
On open | Focus moves into the panel. In AlertDialog it lands on the cancel button, so the safe action is the one under the first keypress and the destructive one is never a stray Enter away. |
Tab / Shift+Tab | Moves through the focusable elements inside the panel, and cannot leave it while the overlay is open. |
Escape | Dismisses the topmost overlay only. In AlertDialog it cancels, never confirms. |
Arrow keys | Move through the list in CommandPalette, and between images in Lightbox. |
On close | Focus returns to the trigger, the body scroll lock releases its hold, and the background stops being inert. |
Open the dialog, then press Tab a few times: focus cycles through the three controls and never reaches the page behind. Press Escape and focus lands back on the button you opened it with.