Accessibility
Most component libraries claim accessibility and ship an audit once. Kovo treats it as a standing contract: every primitive family is run through axe-core in a real Chromium browser, not at initial render only but in the end-state of each interaction tier — open, expanded, checked, selected, pressed, complete, error. If a primitive can reach a state, that state is asserted axe-clean in CI. This is the framework's own guarantee, so apps inherit it without writing accessibility tests of their own.
What "axe-clean across state tiers" means#
A primitive isn't one DOM — it's a small state machine. A dialog has a closed state and an open,
focus-trapped, aria-modal top-layer state. A checkbox has unchecked, checked, and
aria-checked="mixed" indeterminate states. A progress bar has a determinate value, a completed
value, and a valueless indeterminate state. An accessibility check that only sees the initial render
proves nothing about the states a user actually interacts with.
Kovo's gallery browser suite drives each primitive into its terminal awaited active state and runs axe there. Concretely, the suite asserts axe-clean for, among others:
- Disclosure tier — accordion (expanded), disclosure (open panel), collapsible (native
<details open>). - Overlay / top-layer tier — dialog and alert-dialog (open, focus-trapped
<dialog>), sheet and drawer (open side dialog), popover (:popover-open), tooltip (open,aria-describedbywired), hover-card, command palette, context menu, dropdown menu, menubar, navigation menu. - Selection / toggle tier — toggle (
aria-pressed), switch (checked), checkbox (checked andaria-checked="mixed"indeterminate), checkbox-group, radio-group, toggle-group, toolbar, tabs. - Value tier — slider, number-field, OTP field (filled / complete aggregate, plus delete and paste), progress (complete and indeterminate), meter (optimum band), select, combobox, autocomplete.
- Validation tier — field / fieldset error states (
aria-invalid, visible error message wired by IDREF). - Static styled tier — alert, avatar, badge, breadcrumb, button, card, kbd, separator, skeleton, table, all axe-clean as rendered.
For native top-layer content (<dialog> promoted via showModal, popover-backed content), the
suite verifies that axe descends into the promoted subtree rather than passing vacuously against a
hidden node — the assertion is anchored to a genuinely visible, active element.
States that are intentionally not asserted#
A few states cannot be represented as an axe-stable DOM, and the suite documents each exclusion rather than writing a test that would pass without proving anything:
- Transient transition frames. The suite zeroes transitions and asserts terminal states; the in-between closing/dismissing frames are not stable DOM to assert against.
- Toast auto-dismiss countdown. A live region mid-countdown is a moving target; the open and dismissed end-states are asserted instead.
- Hover-only visual states with no ARIA/DOM delta. A purely visual
:hoverstyle with no attribute or structure change has nothing for axe to evaluate that the resting state doesn't already cover.
What this means for your app#
You don't need to re-prove primitive accessibility. The styled families you compose from are already held to the axe-clean-across-states bar by the framework's suite. Your accessibility work is the part only you can know: meaningful labels and copy, correct heading order in your own layouts, and the semantics of content you author around the primitives.
That part is plain HTML. A primitive can be flawless and the surrounding region still fail an audit
if the labels are missing or the heading levels skip. Concretely — give the form region an
accessible name, wire each control to a real <label>, and nest headings without gaps:
/** @jsxImportSource @kovojs/server */
import { Select } from '@kovojs/ui/select';
export function ShippingSection() {
return (
<section aria-labelledby="shipping-heading">
<h2 id="shipping-heading">Shipping</h2>
{/* h2 → h3, no skipped level */}
<h3 id="speed-label">Delivery speed</h3>
<Select
labelledBy="speed-label"
items={[
{ value: 'standard', label: 'Standard (5–7 days)' },
{ value: 'express', label: 'Express (1–2 days)' },
]}
/>
<label for="zip">ZIP code</label>
<input id="zip" name="zip" inputmode="numeric" autocomplete="postal-code" />
</section>
);
}The framework proves Select emits a correct listbox contract; only you can know that it labels
delivery speed, sits under a Shipping heading, and that the heading order around it is h2 → h3
rather than h2 → h4. axe can't infer that intent, and the gallery suite never sees your layout — so
this is the slice that stays yours.
Spec & evidence
The accessibility conformance contract — that every claimed primitive family is axe-clean across its
interactive state tiers (open/expanded/checked/selected/pressed/complete/error end-states), with
documented exclusions only where a state cannot be represented in an axe-stable DOM — is SPEC §12.1. The proving suite is the gallery browser axe suite
(examples/gallery/src/interactive-gallery.browser.test.ts), which runs axe-core in Chromium at each
primitive's terminal awaited state and on the static styled fixtures. Run it with
pnpm --filter @kovojs/example-gallery run test:browser.