Accessibility model
The framework's rule, learned the hard way across three design reviews: never-color-alone has two audiences. Every state signal ships a visible non-color cue (glyph, text) for sighted colorblind users AND a programmatic channel (visually-hidden text, ARIA state) for assistive tech. Satisfying one audience is a defect.
The two-channel pattern
<!-- BOTH channels, every state signal: -->
<li class="bo-timeline__step" data-state="rejected">
<span class="bo-timeline__marker" aria-hidden="true">✕</span> <!-- visible, non-color -->
<div class="bo-timeline__title">
<span class="bo-visually-hidden">Rejected: </span>Finance release <!-- programmatic -->
</div>
</li> Same pattern everywhere: stat deltas pair a visible ⚠ with hidden
"increase, worse," text; error rows pair a background tint with an inset border
and expect a badge/message in the row; badges are decoration — the status
text is the signal.
Live regions: arrival decides, severity only refines
A live region announces changes. Content that is already in the HTML when the page is parsed has not changed, so screen readers commonly do not announce it — and where one does, it interrupts whatever the user was reading. So the first question is never "how bad is this?" — it is "was this here when the page loaded?"
<!-- ARRIVES after load (htmx swap, fetch, client validation):
a live region is right. Severity picks WHICH one. -->
<div class="bo-alert bo-alert--danger" role="alert">Posting failed.</div>
<div class="bo-alert bo-alert--success" role="status">Saved.</div>
<!-- PRESENT at load (server re-rendered the form after a failed POST):
no live region. Wire it to the field, and move focus to a summary. -->
<div class="bo-form-field">
<label class="bo-form-field__label" for="cc">Cost center</label>
<input class="bo-input" id="cc" aria-invalid="true" aria-describedby="cc-err">
<span class="bo-form-field__message" id="cc-err">Unknown cost center code</span>
</div>
<!-- The one shape that is BOTH: present in the markup, hidden until it
applies. Unhiding it is the arrival, so the role is correct. -->
<div class="bo-alert bo-alert--danger" data-validation-summary-box hidden role="alert"> Two questions, asked in this order:
- Does it arrive after load? No → no live region at all. Yes → live region.
- Only then, how urgent? An error interrupts:
role="alert"(assertive). Anything else waits its turn:role="status"(polite).
This page states the rule because the framework used to carry
two of them. alert.css and the alerts page said
the role was picked by severity; form-field.css and the table
below said it was picked by arrival. Both were describing something real —
severity is the right answer to question 2 — but it was being used to answer
question 1, which is how eight static demos ended up shipping an assertive
live region for text that was never going to arrive.
The ERP case that makes this matter: a server
re-renders a form with errors after a failed POST. The error genuinely is new
information, but it arrives as a page load, not a DOM mutation — so
the mechanism is focus management and a summary the user lands on, which is
what validation summary
is for. Enforced by check:live-regions.
The framework's job vs yours
| The framework guarantees | Your markup must supply |
|---|---|
| AA contrast for every token-role pair, both themes — computed and CI-gated | aria-invalid + aria-describedby on form errors (+ role="alert" if dynamic) |
Visible :focus-visible rings everywhere; Tab loop in dialogs; roving tabindex in tabs | aria-labelledby on dialogs; aria-label on icon-only buttons and search inputs |
Heights as minimums — WCAG 1.4.12 verified in CI on every page at both widths, comparing clipping before and after the user spacing override; SC 2.5.8 target size verified in CI by the real spec test (a 24px circle on each undersized control must reach no other target) — met via the spacing exception, NOT a 24px floor; state styled from ARIA (aria-sort, aria-current, aria-busy) | The two-channel state text (Completed:/Rejected:…); role="list" on styled lists; name/value on selection checkboxes |
Live "n selected" count via initDataTables(); reduced-motion zeroing on all animations | Keeping compaction-hidden columns reachable (the data-table page's reflow requirement, WCAG 1.4.10); never user-scalable=no |
Forced colors (Windows High Contrast)
Under forced-colors: active the system repaints text,
borders, and controls in its own palette — most components survive
automatically because state rides on text, glyphs, and borders (the
two-channel rule again). Explicit @media (forced-colors: active)
rules exist exactly where a state's only visible channel was a background
or a mask — icons (mask repainted CanvasText),
skeleton (bars become outlined boxes instead of
vanishing), combobox (active option repaints with
SelectedItem), stepper (current marker gains
a ring) — across these 18 components:
That list is generated from
the shipped CSS (api.json), not maintained by hand — it had
drifted to 10 of 18 while still claiming to be
exhaustive, which is exactly the failure mode generating it removes.
Each rule is verified under CDP forced-colors emulation against a
control run in normal mode, so a rule that no longer changes anything
is caught rather than assumed.
Honest status
Contrast is verified per token pair in both themes on every build. Screen-reader runtime passes (VoiceOver/NVDA verbalization) sit on an open ledger in the public review trail — designed to WCAG 2.2 AA, with the unverified parts named rather than claimed. The Accessibility Conformance Report turns this into a per-criterion table, generated from the same evidence.