A two-second accessibility gate that runs on every push
I already had a vitest harness that renders my Svelte components and mocks the calls into Rust. Adding accessibility checks turned out to be almost free: point axe-core at each rendered screen and assert it finds nothing. So now every push runs axe over the onboarding wizard, the dashboard both empty and full, and the two dialogs, and fails the build if any of them grows a missing label or a broken ARIA attribute. It takes about two seconds.
I scoped it to the actual WCAG A and AA rules on purpose. Axe ships a pile of “best practice” rules on top of the standard, and some of them, like “every bit of content must sit inside a landmark region,” fire constantly when you render a single dialog in isolation instead of a whole page. That’s noise, not a real problem, so I told axe to run only the WCAG-tagged rules. The gate flags things that are actually wrong and stays quiet about things that are only wrong out of context.
Here’s the part I want to be honest about, because it’s easy to oversell this. The tests run in jsdom, which builds a DOM but never lays anything out or paints it. That means the one accessibility check everyone thinks of first, color contrast, cannot run here. Axe tries, fails to get a canvas to measure pixels, and quietly reports the result as “incomplete” rather than pass or fail. So a contrast regression sails straight through this gate. Same story for focus-visible styles. What I’ve actually got is a structural gate: names, roles, labels, headings, form associations. Valuable, and the bulk of what regresses day to day, but not the whole picture.
Rather than pretend otherwise, I wrote the gap into the code comment and filed the other half against my dark-mode issue. Contrast only really starts breaking when you start swapping theme colors, and that’s exactly what dark mode is. When I build it, I’ll add a real-browser axe pass that checks contrast in both themes. Right tool, right moment.
Two small traps on the way in. The matcher library’s TypeScript types were written for an older vitest and simply didn’t attach to the current one, so my type-checker insisted the assertion didn’t exist even though it ran fine. A four-line type declaration of my own fixed it. And before trusting a green result, I fed axe a deliberately broken snippet, an image with no alt text and an empty button, to confirm it actually reports violations. A passing accessibility test that can’t fail is worse than no test, because it tells you you’re covered when you aren’t.
The nice outcome: my existing screens came back clean, so there was nothing to fix, just a gate to keep them that way.
Related reading
Fixing Greenhouse's focus-management bug taught me the difference between a dialog and a page
The ticket said restore focus to the trigger. The trigger doesn't exist anymore - it was destroyed the instant the view opened. The right pattern was a page navigation's, not a modal's.
The cancel button that didn't cancel
Escape looked like it worked in every manual click-through. Writing the regression test forced the real event order - and the stray blur that saved what should have been thrown away.
Six small UI items, and the two near-misses hiding inside them
A CSS block that grep said was dead but a test depended on, and a single line of localStorage that broke thirty-nine unrelated tests because of a Node upgrade.