Three layers of accessibility checks (and what each one caught)

6 min readaccessibility, testing, astro

Rebuilding this site on Astro was a good excuse to actually take accessibility seriously, instead of treating it as an afterthought. I ended up combining three different checks — static analysis, a runtime audit, and manual testing — and what stuck with me is that there was no overlap between them: each layer caught something the other two had no way of seeing.

Layer 1: static linting

The cheapest layer, catching issues before code even runs. This project mixes .astro and .vue files, and JSX-style accessibility linting doesn’t understand Vue templates at all — so two separate ESLint plugins were needed:

The .astro side had a real gotcha: jsx-a11y-recommended doesn’t ship its own rules, it just re-exports eslint-plugin-jsx-a11y’s. That package is an optional peer dependency — without it installed, the config resolves to a no-op, silently. A deliberate test file (<img> with no alt, a <div onclick>) produced zero errors, which is exactly the kind of gap you don’t notice until it’s too late. Installing eslint-plugin-jsx-a11y explicitly fixed it.

Once it was actually linting, running it against the real codebase surfaced 9 findings — 8 of them false positives, all the same shape:

<ul role="list">...</ul>

The rule flags role="list" on a <ul> as redundant, since a <ul> already has list semantics. Except this project’s reset stylesheet does list-style: none everywhere, and removing bullets also strips the implicit list role in Safari/VoiceOver — a well-known modern-reset gotcha. The explicit role="list" puts the semantics back. Removing it, as the linter effectively suggested, would have reintroduced a real bug. Rather than scattering a disable comment across all 8 sites, the fix went into the rule config itself — no-redundant-roles takes a per-tag allowlist of roles to treat as non-redundant:

// eslint.config.mjs
'astro/jsx-a11y/no-redundant-roles': [
'error',
{ ul: ['list'], ol: ['list'] },
],

One rule-level exception instead of eight scattered ones, and it stays correct for any <ul role="list"> added later too.

The 9th finding was real: a placeholder <a href="#">link</a> left over from early style-guide work — on /style-guide itself, a dev-only route that never ships to production — fixed by pointing it at an actual destination.

In the end, none of Layer 1’s findings were actually critical — unsurprising, since the AI tooling used to build the site had already been instructed to write accessible markup along the way, so linting mostly confirmed that rather than catching real breakage. Its real value is forward-looking rather than retrospective — configured once, it now runs on every future change automatically, catching regressions before they ever reach production.

Layer 2: runtime audit

Static linting only sees markup as written — it can’t tell you what actually gets rendered in the browser.

For that, I ran axe-core (the same engine behind Lighthouse’s accessibility score) via Puppeteer, against the real built and previewed site — not dev mode — across 7 representative pages: home, about, blog index, three different post types, and 404.

Two distinct, real issues turned up — one present on every page, the other specific to the homepage:

No <main> landmark. Page content lived in a plain <div class="page-content">. Screen reader users had no way to jump straight to it. One-line fix:

<div class="page-content">
<main class="page-content">

Zero styling impact — the CSS targets the class, not the tag.

Missing <h1> on the homepage. The homepage’s intro section never had one — just paragraphs, no heading. Fixed with a visually-hidden <h1> — the page keeps its intended visual design, but has a real, correctly-worded heading for anyone not looking at it.

Re-running the audit after both fixes: zero issues, across all 7 pages.

Layer 3: manual keyboard testing

This is the layer neither of the first two can substitute for, and it’s where the most interesting bugs turned up — all missed by every automated check I had configured, some because they only exist once a real person interacts with the page.

No visible focus indicator on a filter control. The blog index has an All/TIL/Articles filter built from three visually-hidden radio inputs (a zero-JavaScript filter using CSS :has()). Tab reached it fine, arrow keys moved between options fine, native radio semantics were all correct — but there was no visible focus ring anywhere in that interaction. The label was only styled for :hover and :has(:checked); nothing for focus, so the native ring rendered around the 1px-clipped input itself, invisible regardless. A real WCAG 2.4.7 (Focus Visible) failure — screen reader users were unaffected, since focus and state are still announced correctly, but a sighted keyboard user had no way to tell where they were:

.type-filter label:has(:focus-visible) {
outline: var(--focus-ring);
outline-offset: var(--focus-ring-offset);
}

This is exactly the class of bug static and automated tooling tend to miss — it requires observing actual rendered focus state, not just DOM/ARIA structure.

Wrong tab order on the scroll-to-top button. It sat as the very last element in the DOM, after the entire footer — so reaching it meant tabbing through the whole page first, defeating its entire purpose as a shortcut. Worse, the button becomes visible as soon as you scroll past one viewport — but Tab still wouldn’t reach it until cycling through everything else on the page, so it was visible long before it was actually focusable. Fixed by moving it right after the header, before <main> — safe to do since it’s position: fixed, so DOM order has zero effect on where it renders.

No warning before opening a new tab. External links (GitHub, LinkedIn) open in a new tab with no signal to screen reader users. Not a WCAG AA requirement — more of an AAA-level nicety — but trivial enough to add:

aria-label={href.startsWith('http') ? `${label} (opens in new tab)` : label}

Added along the way: a “skip to main content” link — genuinely missing, not a bug fix. First Tab stop on every page, invisible until it receives keyboard focus, jumping straight into <main>.

What’s still left

Manual VoiceOver testing still needs to be done. The rotor navigation and VO-key combinations have a learning curve, and I don’t know them well enough yet to test properly — that’ll be done in a future iteration.

The takeaway

Three types of check, three different bug classes, no overlap:

None of them substitutes for the others. If I’d stopped after the linter came back clean, the invisible-focus-ring bug and the scroll-button tab order would still be live today.