Accessibility assurance
Some projects have to make guarantees — a Section 508 clause, EN 301 549, a procurement requirement. data-a11y-assurance is how a spoke opts into defaults that are confirmed accessible instead of merely intended to be. It is not a theme, and it does not cost you your brand.
It is a third axis, not a theme
A theme is a brand re-skin. Assurance is orthogonal to it, so the two compose — a
project is entitled to be on-brand and assured. There are three independent
attributes on <html>:
| Attribute | Answers | Who owns it |
|---|---|---|
data-theme | Which brand is this? | the spoke |
data-scheme | Light or dark? | the reader |
data-a11y-assurance | Which conformance profile? | the project |
<html data-theme="cb-fish" data-scheme="light" data-a11y-assurance="wcag-aa">
That is the entire opt-in. The profile ships inside @esa/tokens and is
inert until the attribute is present, so there is nothing to import
and nothing to copy. It is deliberately not an opt-in import: spokes were measured
overriding 3 of 26 brand-derived roles, so any rule of the form “you must also
import X” gets forgotten.
Try it live — the toolbar above has both switchers. Set a brand and a profile together.
What the profile actually changes
| Concern | Under wcag-aa |
|---|---|
| Text contrast (SC 1.4.3) | A solid fill that carries text moves from Radix step 9 to step 11. Step 9 is engineered for 3:1 — UI components and large text — not the 4.5:1 body text needs. This clears all seven failures in the hub defaults. |
| Target size (SC 2.5.8) | Nothing is resized. The profile never changes component geometry — see “What happens to my xs components” below. It is reported instead, and you change the call sites. |
| Minimum type size | Nothing is resized. Raising the smallest type rung was tried and pulled — it moved layout on 9 of 25 pages, because a box follows its contents. Reported alongside target size instead. |
| Focus not obscured (SC 2.4.11) | --focus-scroll-margin gets a real default, so a Tab-focused element is not parked under sticky chrome. |
Nothing here is a rule — every line is a re-pointed token. That constraint is structural, not stylistic: half the kit renders into shadow roots that no page-level stylesheet can reach, and inherited custom properties are the only channel that crosses a shadow boundary in every browser engine.
What happens to my xs components?
They keep rendering at xs. Nothing about them changes, and
that is deliberate. The profile does not touch component geometry at all.
Two versions tried to bend that, and both were pulled. The first carried a 24px minimum
hit area that every control read: it worked — 33 measured failures went to zero —
and under it xs and sm rendered at the same height on
chip-group, checkbox-group and radio-group. The bottom of the ramp quietly collapsed and
the developer who wrote size="xs" was never told.
The second is the instructive one, because it looked harmless: raise the smallest
type rung to kill 8px text. No size token anywhere in it. But a box follows its
contents — esa-chip-group went from {xs: 20, sm: 22} to
{xs: 22, sm: 22}, and layout moved on 9 of 25 pages.
“It is only typography” is not a defence. The test is not
which token you touched; it is whether any component renders differently.
A colour role is a value, and re-pointing a value is what a theme layer is for. A size step is a contract — four deliberate steps that call sites choose between. A profile that redefines one is lying to every call site that chose it. So the profile reports, and you decide:
npm run a11y:sizes
It lists every call site using a step that renders under the floor, and what to use
instead. You change size="xs" to size="sm" in your own source,
the ramp keeps its shape at every step, and the change shows up in your diff rather
than hiding in a stylesheet you never opened.
There are only ever two answers, and “quietly make the existing option bigger” is not one of them:
- A compliant option already exists → the report names it.
- It does not exist → it gets built, as a real variant. The report flags that case separately, because no call-site edit can fix it.
The map it checks against is measured, not declared — regenerated from a
real browser run, so it stays correct when a component’s padding or type rung
changes. It also catches the case a grep cannot: every component defaults
to md, so a bare <esa-checkbox> with no size attribute is
a finding too.
And it surfaces something a silent floor would have buried. Three
components — esa-checkbox, esa-input-tag and
esa-range-slider — are under the floor at xs,
sm and their default md. Only lg clears
it, so that is what the report tells you to use, and every call site that omits a size
is a finding too. A floor would have patched over all of that and left it invisible.
The part everyone gets wrong: your theme still wins
[data-theme] and [data-a11y-assurance] have identical
specificity, and your theme stylesheet loads afterwards. So on any role your
theme re-points — your brand colour, above all — the theme wins, profile or
not.
That is the right outcome. The hub cannot know your brand ramp, and silently repainting your brand with hub green would be a worse failure than the contrast number it fixes. But it does mean the attribute alone proves nothing about your colours. Run the check:
node ../ecology/scripts/check-contrast.mjs src/styles/theme-<brand>.css --assurance wcag-aa
It composes them in the browser’s own order and fails on any pair under 4.5:1.
Measured against the demo themes, beacon still fails
content-on-brand-muted at 3.64:1 with the profile on. That failure
is the feature — it is telling the spoke which of its own colours to darken.
What it cannot do, and why that matters
A profile is a token scope. It re-points values; it cannot add behaviour, markup, or a rule inside a shadow root. Setting the attribute is a statement about the defaults, never a conformance certificate. It does not fix:
- The non-modal overlays.
esa-popover,esa-dropdown-menu,esa-filter-dropdownandesa-combobox(inmode="select") strand focus at<body>when they close, so a keyboard user is returned to the top of the document. The modals are fixed — all six moved to native<dialog>+showModal()on 2026-08-18, which supplies inertness, focus containment and focus return that no amount of JS was getting right. - Custom keyboard models.
esa-entity-searchbinds Tab to cycling its facets, which leaves its per-row action buttons reachable by no key at all — SC 2.1.1, Level A. - Anything in Windows Contrast Themes, which overrides at the used-value layer, downstream of every token.
Those are component fixes, tracked in the improvement ledger. What makes the profile
more than a promise is the gate — npm run a11y:assured composes the
contrast graph, measures rendered target geometry in a real browser, and runs the axe
and live-region audits. An assurance claim sourced from anything but a passing gate
is a guess.