Accessibility
Operators work this product all day. The system has to be usable by keyboard, by screen reader, at low contrast, at low motion. These are the rules every component obeys — measured, not asserted.
Keyboard navigation
Every interactive element is reachable and operable with the keyboard alone. Patterns follow WAI-ARIA Authoring Practices.
| Key | Action |
|---|---|
| Tab | Move focus to the next focusable element in document order. |
| Shift + Tab | Move focus to the previous focusable element. |
| Enter | Activate the focused button, link, or default action (e.g. submit a form). |
| Space | Activate the focused button, toggle a checkbox or switch, scroll a region. |
| Esc | Close the topmost overlay — popover, modal, sheet, dropdown, command palette. |
| ↑ ↓ | Move within a composite widget: dropdown menu, listbox, combobox, segmented control (vertical), radio group. |
| ← → | Move within tabs, horizontal segmented control, calendar grid (day-by-day), date picker. |
| Home / End | Jump to the first / last option in a listbox, menu, or calendar row. |
| PageUp / PageDown | Move by month in calendars; by viewport in long lists. |
| Type-ahead | In menus, listboxes, and selects, typing a letter jumps to the first matching option. |
Every component page has its own keyboard table covering its specific shortcuts. If a key is documented there, it is wired.
Focus management
Focus is never invisible. It moves predictably, returns predictably, and never traps the operator anywhere they didn't ask to be.
- :focus-visible. Focus rings render only on keyboard focus — never on mouse click. The ring is a 2 px solid
--brandoutline with a 3 px--focus-ring-softhalo. - Programmatic focus on overlays. Opening a modal, sheet, or alert dialog moves focus to the first focusable control inside. Closing it returns focus to the trigger.
- Focus trap inside dialogs. While a modal is open, Tab cycles within it. Esc closes and restores focus.
- Roving tabindex. Composite widgets (tabs, menus, listboxes, segmented control) expose exactly one tab stop; arrow keys move focus within.
- Skip links. Page shells expose a "Skip to content" link as the first focusable element.
Don't suppress the outline
outline: none without an equivalent :focus-visible replacement is a release blocker. Audit lints will fail.
Screen-reader patterns
Status, name, role, and value reach AT users. Visual-only state is a bug.
Live regions
Asynchronous updates announce themselves through aria-live:
aria-live="polite"— toasts, saved confirmations, sync results, validation that doesn't block.aria-live="assertive"— destructive errors, connection loss, permission denial. Reserve for moments the operator must hear immediately.role="status"— implicit polite region for progress and "Saved" messages.role="alert"— implicit assertive region for error banners.
Hidden vs visible text
- Visually hidden, screen-reader accessible — use
.sr-onlyfor labels that are obvious visually but need a name for AT (e.g. icon-only buttons). - Hidden from everyone — use
hiddenordisplay: none. - Decorative — apply
aria-hidden="true"to elements that add no information (decorative icons, the chevron next to a label).
Naming and grouping
Form controls always have a <label for> or aria-labelledby. Sections use aria-labelledby pointing at their heading. Buttons that only contain an icon use aria-label.
Contrast ratios
Measured against WCAG 2.2. AA requires 4.5:1 for body text, 3:1 for large (18 pt+ or 14 pt bold) and UI components. AAA requires 7:1 / 4.5:1.
| Foreground | Background | Pair | Ratio | WCAG |
|---|---|---|---|---|
| --text-strong | --canvas | Headings on page | 16.71 : 1 | AAA |
| --text-strong | --surface | Headings on cards | 17.76 : 1 | AAA |
| --text-body | --canvas | Body text on page | 13.52 : 1 | AAA |
| --text-body | --surface | Body text on cards | 14.37 : 1 | AAA |
| --text-muted | --canvas | Descriptions, helpers | 6.31 : 1 | AA |
| --text-muted | --surface | Descriptions on cards | 6.71 : 1 | AA |
| --text-subtle | --surface | Metadata, timestamps | 3.16 : 1 | AA Large only |
| --brand-strong / --text-link | --surface | Links, current nav | 8.02 : 1 | AAA |
| --brand-strong | --brand-soft | Active sidebar item | 6.96 : 1 | AA |
| #FFFFFF | --brand | Primary button text | 5.47 : 1 | AA |
| #FFFFFF | --tone-danger | Destructive button | 5.71 : 1 | AA |
| --tone-success | --surface | Success badge text | 3.84 : 1 | AA Large / UI |
| --tone-warn | --surface | Warn badge text | 3.84 : 1 | AA Large / UI |
Known limitations: --text-subtle on --canvas falls just under 3:1 (2.98:1) and is reserved for timestamps and metadata at 14 px+ on white only. Success and warn tones meet UI-component contrast (3:1) but should never be the sole indicator of state — pair with an icon.
Reduced motion
When the OS reports prefers-reduced-motion: reduce, the system suppresses non-essential animation globally.
The token sheet (tokens.css) ships this rule unconditionally:
What this means in practice:
- Entrance animations (
.anim-fade-up,.anim-scale-in) snap to their final frame. - State transitions (hover, focus, popover open) become instantaneous.
- Auto-scroll behaviour (e.g. anchor jumps) becomes a hard jump.
- The pulse / spinner indicators still rotate — they communicate liveness — but at a single tick rather than a continuous loop.
Do not author bespoke @keyframes animation without an equivalent reduced-motion fallback. If the animation conveys meaning (e.g. a progress bar filling), provide a static alternative such as a numeric percentage.
Target sizes
WCAG 2.2 SC 2.5.5 (AA) requires interactive targets to be at least 24×24 CSS pixels, with exemptions for inline targets and equivalents.
The default 36 px control height is a deliberate choice for a dense ERP context — every operator screen carries dozens of controls, and 44 px everywhere would cost a meaningful amount of vertical density. The system meets WCAG 2.5.5 (24 px AA floor) at every size and exceeds the Material 3 recommendation (40 px target, 48 px with spacing) at --h-control-lg, which mobile-first surfaces use.
Color is not a label
Never rely on color alone to communicate state. Pair every tonal signal with an icon, text, or shape.
Pair the success tone with a check icon and the word "Posted". Three reinforcing signals.
Show a green dot alone. A red-green colorblind operator sees no difference between "Posted" and "Failed".
Decorative vs semantic icons
If an icon adds information, name it. If it decorates a label, hide it.
| Case | Markup | Why |
|---|---|---|
| Icon next to a text label | <span data-icon="check" aria-hidden="true"> |
The label already names the action; the icon is decoration. |
| Icon-only button | <button aria-label="Close"><span data-icon="x" aria-hidden="true"></button> |
The button needs a name; the icon itself stays hidden from AT to avoid a duplicate announcement. |
| Status icon (e.g. "verified") | <span data-icon="check" role="img" aria-label="Verified"> |
The icon carries information that no nearby text duplicates. |
| Dotted state indicator | <span class="dot" aria-hidden="true"></span> Posted |
The visible word names the state; the dot is decoration. |
The rule: a screen reader user must hear exactly one name per control. Never zero, never two.
Do & don't
Wire every interactive element to the keyboard before it ships. The keyboard table on each component page is the contract.
Trap focus accidentally. Always provide Esc and a visible close affordance for any overlay.
Pair every color-coded state with an icon and a word.
Assert "WCAG AA" without measurement. Use the contrast table above as proof.