WCAG 2.2 AAA is the top accessibility standard. Material, Chakra, Mantine and Radix all stop at AA. I'd been saying for months that inclusive.io met AAA, and I believed it, because I'd done the work: 7:1 contrast targets and 44px touch targets and reduced motion treated as a requirement rather than a toggle.
Then I ran an independent pass over my own repository on one standing assumption. Every prior document is an unverified assertion, so nothing was restated. Every verdict had to come back from a command's output, or a file and a line number.
The axe suite had never loaded the rule
The first finding was the one that mattered: the end-to-end accessibility suite ran with AA tags only. Enhanced contrast, 1.4.6, the 7:1 rule that's the whole difference between AA and AAA, was never in the tag list. The suite had been green for months, it had never been asked the question.
That's worth sitting with, because it isn't a bug in the ordinary sense and it doesn't behave like one. Nothing was broken and every test passed. The system was doing exactly what it had been told to do, and what it had been told to do didn't include checking the claim I was making about it in public.
A suite that has never been asked the question will pass forever.
Fifteen percent coverage was hiding Level A failures
The second finding compounded the first: route coverage was 25 of 167, which is fifteen percent. I'd been reading a green result off a sample and treating it as a result about the system.
Expanding coverage to every route is what made the real position visible. The unscanned 85% was sitting on twelve unlabelled form controls and six buttons with no accessible name, and every one of those had been there the whole time, and the suite had been green through all of it. Those are Level A failures, not AAA and not AA. The most basic tier of the standard, sitting underneath a system I was advertising as meeting the top one.
At audit start
- Routes scanned
- 25 of 167 (15%)
- Tests
- 50
- Failing
- 33
After token and component fixes
- Routes scanned
- 25
- Tests
- 50
- Failing
- 0
After expanding to full route coverage
- Routes scanned
- all
- Tests
- 330
- Failing
- 57
After the colour migration
- Routes scanned
- all
- Tests
- 344
- Failing
- 14
After the nested-surface contrast model
- Routes scanned
- all
- Tests
- 344
- Failing
- 0
| Stage | Routes scanned | Tests | Failing |
|---|---|---|---|
| At audit start | 25 of 167 (15%) | 50 | 33 |
| After token and component fixes | 25 | 50 | 0 |
| After expanding to full route coverage | all | 330 | 57 |
| After the colour migration | all | 344 | 14 |
| After the nested-surface contrast model | all | 344 | 0 |
The contrast gate had the wrong model of the UI
I wrote a contrast gate that reads the token source directly and computes ratios through OKLCH rather than sampling rendered pages. It modelled a tint over a surface, which is what the specification describes.
It isn't what the UI does. In practice a tinted control sits inside a card that sits inside another card, and because the card's a translucent overlay, nesting pushes the composite further from the page than a single application does. Teaching the gate to model a tint over a card over a card took it from 344 to 360 sanctioned pairings, and immediately failed six text tokens sitting at 6.71 to 6.85:1 that the flat model had passed.
That gate only works because the palette is in OKLCH. It's worth being concrete about why, because “perceptually uniform” is the kind of phrase that sounds like marketing until you see it fail.
Two colours, one lightness value
Both swatches below are set to the same lightness. Drag the slider and watch what happens in each colour space.
hsl(60 90% 60%)hsl(240 90% 60%)HSL: the yellow is glaring and the blue is dark, and the numbers claim they are identical. HSL lightness describes the maths, not what you see, so contrast built on it is guesswork.
I could have patched the call sites instead, and a grep found that pairing in well over a hundred places, so patching them would have fixed the symptom everywhere a route happened to get scanned and left the guarantee false everywhere else, which is not a fix. Re-solving the six tokens closed the fourteen residual runtime failures as a consequence rather than as a target.
Nine defects only a gate could find
Once the gates existed they started returning things no amount of reading the code would have surfaced. These are the reason to write a gate rather than assert a criterion.
- Every skip link in the product rendered as an invisible 1x1 box. A hand-written sr-only rule with !important beat the focus:not-sr-only that was meant to reveal it, and after that was removed, cascade layers beat it again, because an unlayered rule wins over a layered one regardless of specificity. This was a Level AA failure sitting underneath an AAA investigation.
- Every sheet in the system produced an unnamed dialog. role=dialog with aria-modal and no aria-labelledby, and the title component wired to nothing. That includes the mobile navigation.
- A mobile menu marked the current destination by colour alone whenever items were driven by onClick rather than href, because aria-current was on the link branch only.
- A rich text editor's toolbar active states had never worked. It called queryCommandState during render, held no state and ran no effects, so nothing re-rendered when the selection moved.
- The high contrast themes never changed the page backdrop. Everything on top switched. The largest area on screen didn't.
The skip link one is the one I think about. It's a Level AA criterion, 2.4.7 Focus Visible, it had been broken on 37 routes, and I found it while investigating AAA.
What I would tell you to do
I'm not going to pretend this generalises into a methodology. It's three things, and they're small.
Write the test that can take the claim away from you
Not the test that confirms it. The distinction sounds like wordplay and isn't. A test written to confirm gets written against what the code does. A test written to fail gets written against what the standard says, and then you find out.
Check the tag list, not the result
A green suite tells you the rules it loaded passed, it says nothing at all about the rules it didn't. If you assert a level, assert it in the tag configuration where a reviewer can see it, and keep the two in the same file if you can.
Coverage before depth
I spent effort on enhanced contrast while unlabelled inputs sat on unscanned routes. Fifteen percent coverage of a hard standard is worth less than full coverage of an easy one, because the failures you haven't looked for aren't distributed like the ones you have.
Where it landed
The conformance register now reads 4 criteria enforced by an automated test, 13 met, 11 not applicable, 3 published exceptions, 0 outstanding. The exceptions are published on the conformance page rather than omitted, because an exception you disclose is a decision and one you hide is a defect.
npm run contrast:check # 360 pairings across 4 themes
npx turbo test -- --run # 806 tests in 111 files
PLAYWRIGHT_USE_PROD=1 npx playwright test --project=chromium
# 1,564 passed, 0 failed, --retries=0The badge was real in intent the whole time. It was unenforceable in fact, and the distance between those two things was invisible from the inside. The work wasn't earning the badge, it was building the thing that could take it back off me.