GitHub

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>:

AttributeAnswersWho owns it
data-themeWhich brand is this?the spoke
data-schemeLight or dark?the reader
data-a11y-assuranceWhich 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

ConcernUnder 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:

  1. A compliant option already exists → the report names it.
  2. 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-dropdown and esa-combobox (in mode="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-search binds 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.