Accessibility
[v0.2]Accessibility is part of the system, not a pass at the end of it. A colour is only a token here once its pairings clear WCAG 2.2 AA in both registers; a component is only done once it can be read, named and reached by keyboard.
Nothing on this page is a claim typed by hand. The ratios are measured from the CSS that ships, by the same functions that fail the build — so the page cannot say pass while the build says fail.
How it is enforced
Gate
npm run a11y
Before every build — locally and on Vercel. A failure stops the deploy.
Measures every sanctioned colour pairing in both registers, every text-colour utility in the source, faded text, removed focus rings, saturated accents set as text, the generated-asset palettes and the group marks. Checks the TypeScript token copy against the CSS.
Audit
npm run a11y:audit
On every pull request, in CI, against the production build.
Opens every page in Chromium in both registers and runs axe-core (WCAG 2.0–2.2 A/AA and best practice): rendered contrast, names and labels, landmarks, headings, target size. Then emulates reduced motion and fails any page where CSS still animates.
Review
the checklist below
When a new component, diagram or generator is added.
What a machine cannot judge: whether colour is the only carrier, whether canvas motion can be paused, whether a posted asset has alt text.
The rules
01
Text contrast
WCAG 1.4.3
Text clears 4.5:1 against what it sits on, in both registers; 3:1 only from 24px regular or 18.66px bold. Saturated accents — dusk, ocean, teal, the domain colours — are never text: their -ink forms are.
- [gate]Every sanctioned pairing is measured in both registers from the shipped CSS.
- [gate]Every text-xco-* utility in the source is measured on every text surface, including opacity modifiers and placeholder text.
- [gate]Inline colour styles may not use a saturated domain colour.
- [audit]axe color-contrast on every rendered page, both registers.
02
Non-text contrast
WCAG 1.4.11
Marks, input borders, focus rings and any graphic needed to understand the page clear 3:1 against their ground.
- [gate]Domain and meaning colours on paper, field borders and the focus ring are sanctioned pairings.
- [gate]Every group mark's glyph clears 3:1 on its ground.
03
Never colour alone
WCAG 1.4.1
Colour reinforces; something else carries. Semantic meanings pair every colour with a shape, group marks pair every ground with an aperture angle, links are underlined or otherwise distinct from body text.
- [gate]The group-mark pair audit fails any two marks weak on both colour and angle.
- [audit]axe link-in-text-block.
- [review]A new diagram or status reads correctly in greyscale.
04
Focus is always visible
WCAG 2.4.7, 2.4.11
Every focusable element shows the system focus ring — 2px of full ink, offset 2px — when reached by keyboard. Components may restyle focus; they may not remove it.
- [gate]outline-none fails unless the same class list gives a focus-visible outline or ring.
- [gate]The focus ring token is a sanctioned 3:1 pairing on every surface.
05
Everything has a name
WCAG 1.1.1, 4.1.2, 1.3.1
Images have alt text; decorative ones have empty alt or aria-hidden. Every control has an accessible name — a visible label first, aria-label only for icon-only controls. SVG and canvas that carry meaning take role="img" and a label.
- [audit]axe image-alt, svg-img-alt, button-name, label, select-name, link-name, aria-*.
06
Structure
WCAG 1.3.1, 2.4.6, 3.1.1
One h1 per page, headings in order, a main landmark, the page language set. The document outline reads as the page does.
- [audit]axe page-has-heading-one, heading-order, landmark-one-main, html-has-lang, region.
07
Targets
WCAG 2.5.8
Pointer targets are at least 24×24px, or spaced as if they were. Primary controls — buttons, toggles, the nav — are 44px high.
- [audit]axe target-size (24px).
- [review]Primary controls at 44px.
08
Motion
WCAG 2.3.3, 2.2.2
Motion respects prefers-reduced-motion. Anything that moves for more than five seconds can be paused. Animation never carries information that a still frame does not.
- [audit]With reduced motion emulated, no CSS animation or transition runs on any page.
- [review]Canvas animation (the event-series cards) starts paused under reduced motion and has a pause control.
09
Exported assets
WCAG 1.4.3, 1.1.1
Text inside generated images meets the same contrast bars as the page, since it is read on phones at a fraction of its size. When an asset is posted, its text goes in the alt text or the post itself.
- [gate]Every event-series palette's text pairings are measured.
- [review]Alt text written when posting to Luma, LinkedIn or Instagram.
Text colours
The six colours text may be set in, on the three surfaces text may sit on. Every cell clears 4.5:1. Structural paper is for rules and borders, never text; saturated dusk, ocean, teal and the domain colours are fills and strokes — their -ink forms are the text.
| Text | paper | raised | quiet |
|---|---|---|---|
| xco-inkBody text, headings, UI | Aa✓ 14.45:1 | Aa✓ 16.29:1 | Aa✓ 12.98:1 |
| xco-ink-mutedSecondary text, labels, hints | Aa✓ 7.23:1 | Aa✓ 8.15:1 | Aa✓ 6.50:1 |
| xco-ink-weakCaptions and annotations | Aa✓ 5.08:1 | Aa✓ 5.72:1 | Aa✓ 4.56:1 |
| xco-dusk-inkAccent text — the ink-safe dusk | Aa✓ 5.12:1 | Aa✓ 5.77:1 | Aa✓ 4.60:1 |
| xco-ocean-inkStructural accent text | Aa✓ 7.18:1 | Aa✓ 8.09:1 | Aa✓ 6.45:1 |
| xco-teal-inkOpen-register accent text | Aa✓ 5.12:1 | Aa✓ 5.77:1 | Aa✓ 4.60:1 |
| Text | paper | raised | quiet |
|---|---|---|---|
| xco-inkBody text, headings, UI | Aa✓ 14.45:1 | Aa✓ 12.37:1 | Aa✓ 15.44:1 |
| xco-ink-mutedSecondary text, labels, hints | Aa✓ 9.38:1 | Aa✓ 8.03:1 | Aa✓ 10.02:1 |
| xco-ink-weakCaptions and annotations | Aa✓ 5.89:1 | Aa✓ 5.04:1 | Aa✓ 6.29:1 |
| xco-dusk-inkAccent text — the ink-safe dusk | Aa✓ 5.38:1 | Aa✓ 4.60:1 | Aa✓ 5.75:1 |
| xco-ocean-inkStructural accent text | Aa✓ 5.38:1 | Aa✓ 4.60:1 | Aa✓ 5.75:1 |
| xco-teal-inkOpen-register accent text | Aa✓ 5.40:1 | Aa✓ 4.62:1 | Aa✓ 5.77:1 |
Every pairing
The registry in lib/a11y/pairings.ts. Adding a colour to the system means adding its pairings there. A pairing that cannot pass is not an exception — it is not a pairing.
| Pairing | Where | Bar | Paper | Ink |
|---|---|---|---|---|
| xco-ink/xco-paper | Body text, headings, UI | text · 4.5:1 | ✓ 14.45:1 | ✓ 14.45:1 |
| xco-ink/xco-paper-raised | Body text, headings, UI | text · 4.5:1 | ✓ 16.29:1 | ✓ 12.37:1 |
| xco-ink/xco-paper-quiet | Body text, headings, UI | text · 4.5:1 | ✓ 12.98:1 | ✓ 15.44:1 |
| xco-ink-muted/xco-paper | Secondary text, labels, hints | text · 4.5:1 | ✓ 7.23:1 | ✓ 9.38:1 |
| xco-ink-muted/xco-paper-raised | Secondary text, labels, hints | text · 4.5:1 | ✓ 8.15:1 | ✓ 8.03:1 |
| xco-ink-muted/xco-paper-quiet | Secondary text, labels, hints | text · 4.5:1 | ✓ 6.50:1 | ✓ 10.02:1 |
| xco-ink-weak/xco-paper | Captions and annotations | text · 4.5:1 | ✓ 5.08:1 | ✓ 5.89:1 |
| xco-ink-weak/xco-paper-raised | Captions and annotations | text · 4.5:1 | ✓ 5.72:1 | ✓ 5.04:1 |
| xco-ink-weak/xco-paper-quiet | Captions and annotations | text · 4.5:1 | ✓ 4.56:1 | ✓ 6.29:1 |
| xco-dusk-ink/xco-paper | Accent text — the ink-safe dusk | text · 4.5:1 | ✓ 5.12:1 | ✓ 5.38:1 |
| xco-dusk-ink/xco-paper-raised | Accent text — the ink-safe dusk | text · 4.5:1 | ✓ 5.77:1 | ✓ 4.60:1 |
| xco-dusk-ink/xco-paper-quiet | Accent text — the ink-safe dusk | text · 4.5:1 | ✓ 4.60:1 | ✓ 5.75:1 |
| xco-ocean-ink/xco-paper | Structural accent text | text · 4.5:1 | ✓ 7.18:1 | ✓ 5.38:1 |
| xco-ocean-ink/xco-paper-raised | Structural accent text | text · 4.5:1 | ✓ 8.09:1 | ✓ 4.60:1 |
| xco-ocean-ink/xco-paper-quiet | Structural accent text | text · 4.5:1 | ✓ 6.45:1 | ✓ 5.75:1 |
| xco-teal-ink/xco-paper | Open-register accent text | text · 4.5:1 | ✓ 5.12:1 | ✓ 5.40:1 |
| xco-teal-ink/xco-paper-raised | Open-register accent text | text · 4.5:1 | ✓ 5.77:1 | ✓ 4.62:1 |
| xco-teal-ink/xco-paper-quiet | Open-register accent text | text · 4.5:1 | ✓ 4.60:1 | ✓ 5.77:1 |
| paper/ink | Inverse text — buttons, badges, the active register toggle | text · 4.5:1 | ✓ 14.45:1 | ✓ 14.45:1 |
| on-accent/ocean | Selected chips and toggles on ocean | text · 4.5:1 | ✓ 7.18:1 | ✓ 7.18:1 |
| on-accent/navy | Text on the blueprint ground | text · 4.5:1 | ✓ 15.71:1 | ✓ 15.71:1 |
| ink/dusk | Text on a dusk ground — never paper | text · 4.5:1 | ✓ 5.21:1 | ✓ 5.21:1 |
| wip-badge | The [v0.2] / [wip] badge | text · 4.5:1 | ✓ 4.59:1 | ✓ 4.78:1 |
| bio-ink/paper | Kicker, stat and callout labels in the bio domain | text · 4.5:1 | ✓ 5.00:1 | ✓ 5.40:1 |
| bio-ink/raised | bio labels on cards | text · 4.5:1 | ✓ 5.63:1 | ✓ 4.62:1 |
| bio-ink/tint | The label of a bio callout, on its tint | text · 4.5:1 | ✓ 4.61:1 | ✓ 4.57:1 |
| ink/bio-pale | Text on a bio ground | text · 4.5:1 | ✓ 11.36:1 | ✓ 11.51:1 |
| bio/paper | bio marks, rules and callout bars | non-text · 3:1 | ✓ 3.05:1 | ✓ 4.73:1 |
| inst-ink/paper | Kicker, stat and callout labels in the inst domain | text · 4.5:1 | ✓ 5.04:1 | ✓ 5.63:1 |
| inst-ink/raised | inst labels on cards | text · 4.5:1 | ✓ 5.68:1 | ✓ 4.81:1 |
| inst-ink/tint | The label of a inst callout, on its tint | text · 4.5:1 | ✓ 4.64:1 | ✓ 4.76:1 |
| ink/inst-pale | Text on a inst ground | text · 4.5:1 | ✓ 11.67:1 | ✓ 10.92:1 |
| inst/paper | inst marks, rules and callout bars | non-text · 3:1 | ✓ 3.05:1 | ✓ 4.73:1 |
| tech-ink/paper | Kicker, stat and callout labels in the tech domain | text · 4.5:1 | ✓ 5.03:1 | ✓ 5.42:1 |
| tech-ink/raised | tech labels on cards | text · 4.5:1 | ✓ 5.67:1 | ✓ 4.64:1 |
| tech-ink/tint | The label of a tech callout, on its tint | text · 4.5:1 | ✓ 4.61:1 | ✓ 4.67:1 |
| ink/tech-pale | Text on a tech ground | text · 4.5:1 | ✓ 10.79:1 | ✓ 11.49:1 |
| tech/paper | tech marks, rules and callout bars | non-text · 3:1 | ✓ 3.75:1 | ✓ 3.85:1 |
| culture-ink/paper | Kicker, stat and callout labels in the culture domain | text · 4.5:1 | ✓ 4.99:1 | ✓ 5.68:1 |
| culture-ink/raised | culture labels on cards | text · 4.5:1 | ✓ 5.62:1 | ✓ 4.86:1 |
| culture-ink/tint | The label of a culture callout, on its tint | text · 4.5:1 | ✓ 4.60:1 | ✓ 4.80:1 |
| ink/culture-pale | Text on a culture ground | text · 4.5:1 | ✓ 11.36:1 | ✓ 11.65:1 |
| culture/paper | culture marks, rules and callout bars | non-text · 3:1 | ✓ 3.05:1 | ✓ 4.73:1 |
| meaning-continuity/paper | The continuity marker in diagrams — paired with its shape | non-text · 3:1 | ✓ 4.55:1 | — |
| meaning-system/paper | The system marker in diagrams — paired with its shape | non-text · 3:1 | ✓ 5.07:1 | — |
| meaning-risk/paper | The risk marker in diagrams — paired with its shape | non-text · 3:1 | ✓ 4.50:1 | — |
| meaning-agency/paper | The agency marker in diagrams — paired with its shape | non-text · 3:1 | ✓ 4.53:1 | — |
| meaning-contested/paper | The contested marker in diagrams — paired with its shape | non-text · 3:1 | ✓ 9.33:1 | — |
| meaning-critical/paper | The critical marker in diagrams — paired with its shape | non-text · 3:1 | ✓ 10.66:1 | — |
| field-border | Input and select borders (1.4.11) | non-text · 3:1 | ✓ 14.45:1 | ✓ 14.45:1 |
| focus-ring | The focus outline on every surface (2.4.7, 2.4.11) | non-text · 3:1 | ✓ 14.45:1 | ✓ 14.45:1 |
| focus-ring/raised | Focus on cards | non-text · 3:1 | ✓ 16.29:1 | ✓ 12.37:1 |
| shadcn-foreground | shadcn body | text · 4.5:1 | ✓ 14.45:1 | ✓ 14.45:1 |
| shadcn-card | shadcn card | text · 4.5:1 | ✓ 16.29:1 | ✓ 12.37:1 |
| shadcn-popover | shadcn popover | text · 4.5:1 | ✓ 16.29:1 | ✓ 12.37:1 |
| shadcn-primary | shadcn primary button | text · 4.5:1 | ✓ 14.45:1 | ✓ 14.45:1 |
| shadcn-secondary | shadcn secondary button | text · 4.5:1 | ✓ 14.45:1 | ✓ 14.45:1 |
| shadcn-muted | shadcn muted text | text · 4.5:1 | ✓ 7.23:1 | ✓ 9.38:1 |
| shadcn-accent | shadcn accent | text · 4.5:1 | ✓ 5.21:1 | ✓ 5.21:1 |
Not held to a ratio
- The xCO logotype, including the dusk x
- WCAG 1.4.3 exempts text that is part of a logo or brand name.
- --rule and the border tokens
- Decorative dividers. Nothing is lost if they are not seen; controls carry their own ink border.
- Grain
- Texture, not information.
- Disabled controls
- WCAG 1.4.3 exempts inactive components. They still read as disabled by shape, not colour alone.
Before it ships
- Never colour alone — A new diagram or status reads correctly in greyscale.
- Targets — Primary controls at 44px.
- Motion — Canvas animation (the event-series cards) starts paused under reduced motion and has a pause control.
- Exported assets — Alt text written when posting to Luma, LinkedIn or Instagram.