A clean accessibility scan tells you about the pages you scanned.

This site has claimed WCAG 2.2 AA for months. The claim was not dishonest — I had run axe, it had come back clean, and I had fixed the things it found. What I had not noticed was the scope of what I ran it against: the homepage and the About page. Two routes out of thirty-four.

Extending the sweep to the whole site found nineteen failures on a single page, and then a second pass at phone width found two more that a desktop sweep is structurally incapable of seeing.

The claim and the coverage

The gap between “axe reports no violations” and “this site meets AA” is entirely coverage, and coverage is the part nobody reports.

Thirty-four routes. Two themes, because light and dark resolve completely different colour tokens and a contrast pass in one proves nothing about the other. That is sixty-eight page-theme combinations, and I had tested four of them.

Worse, the two pages I had tested were the two oldest and most carefully built. They were the pages whose CSS I had written while thinking about contrast. The failures were all going to be somewhere else, by construction.

Building a sweep instead of clicking through

Sixty-eight manual runs is not going to happen more than once, and a check that only happens once is not a check.

The harness is deliberately unremarkable: a page that loads every route into a same-origin iframe at a fixed width, injects axe into each iframe document, and collects the results.

async function scanRoute(path, theme, width) {
  const frame = document.createElement('iframe');
  frame.style.width = `${width}px`;
  frame.style.height = '2400px';

  // Set the mode the site's own bootstrap reads, before it runs. Flipping
  // data-theme from outside skips the bootstrap and diverges from reality.
  frame.src = path;
  document.body.append(frame);
  await new Promise((resolve) => frame.addEventListener('load', resolve));

  const win = frame.contentWindow;
  win.localStorage.setItem('newcombe-theme-mode', theme);
  win.location.reload();
  await new Promise((resolve) => frame.addEventListener('load', resolve));

  await injectAxe(frame.contentDocument);
  return win.axe.run(frame.contentDocument);
}

The detail that matters is the theme. My first attempt set data-theme on the iframe’s root element directly, which is not what a real visitor does. The site has an inline pre-paint script that reads a stored preference and resolves the theme before the first paint. Setting the attribute from outside bypasses that script, so I would have been testing a state the site never actually produces. Writing the stored preference and reloading means the site’s own code decides, which is the thing under test.

The production CSP blocked the scan, correctly

Running this against the live site failed immediately. The Content Security Policy is script-src 'self' 'unsafe-inline' plus the analytics host, so injecting axe from anywhere else is refused.

That is the CSP doing exactly its job, and the right response is not to weaken it. I built the site for production and served the build locally instead, then verified that the HTML and the minified CSS the local server returned matched what production was serving. The scan ran against the real artifact; only the origin differed.

It is worth saying plainly: an accessibility harness that requires you to relax a security header is testing a configuration you do not ship.

What the sweep found

Nineteen violations on /speaking/. All of them colour contrast, all of them light mode only, all of them on a page I had added recently.

The cause was consistent and is the transferable part:

/* What the new page did. */
.eyebrow { opacity: 0.7; }
.appearance-meta { opacity: 0.72; }
.appearance-mark { background: color-mix(in srgb, currentColor 5%, transparent); }
/* What the rest of the site does. */
.eyebrow { color: var(--muted); }
.appearance-meta { color: var(--muted); }
.appearance-mark { background: var(--surface); }

Opacity is not a colour. Using it to make text quieter has three problems: the result depends on whatever happens to be behind the element, it is invisible to the design tokens so no amount of token discipline constrains it, and it compounds — an element at 70% inside a container at 80% lands somewhere nobody chose.

Every failure on that page came from reaching for opacity or a currentColor mix instead of the token that already existed and had already been measured. Nineteen violations, one habit.

The remaining fixes elsewhere were small and worth naming because they are easy to repeat:

  • aria-label on a plain <div> is prohibited — an element with no role cannot carry an accessible name. Adding role="group" made the label legal.
  • Two <aside> elements nested inside other content triggered landmark-complementary-is-top-level. Both were styled entirely by class, so changing them to <div> fixed the landmark structure and changed nothing visually — including in the print stylesheet the CV PDF depends on.

The failures a 1280px sweep cannot see

My sixty-eight-combination sweep ran every iframe at 1280px. I reported the site clean. It was not.

Two rules are invisible at desktop width:

summary-name. The mobile menu is a <summary> whose accessible name comes from a text label. Below 560px that label was display: none, so the control had no name at all — the exact width where it is the only navigation available. The fix is the clip technique rather than display: none, which keeps the text in the accessibility tree while removing it visually:

@media (max-width: 560px) {
  .site-nav__name {
    position: absolute;
    width: 1px;
    height: 1px;
    clip: rect(0 0 0 0);
    white-space: nowrap;
    overflow: hidden;
  }
}

scrollable-region-focusable. One code block on one project page overflows horizontally on a phone. Every other <pre> on the site carries tabindex="0" because the syntax highlighter emits it — all twenty-five generated blocks had it. This one was hand-authored ASCII, so it inherited nothing, and a keyboard user could not scroll it at all.

Both are the same class of mistake: responsive CSS hides failures from a fixed-width sweep, and a hand-authored element inherits none of the guarantees a generator provides. I re-ran twenty routes at 390px and 1280px in both themes to close it.

Two lessons about measurement, not accessibility

These cost me more time than any of the actual fixes.

Do not sample mid-transition. My first scripted theme flips produced contrast failures that vanished on a second look. The site animates colour changes, so axe was sampling partway through a transition and computing ratios against intermediate values that exist for 200 milliseconds and are not what anyone sees. Every settled-state run was clean. I nearly reported a regression that did not exist.

file:// taints a canvas. I wanted real pixel values rather than arithmetic, which means drawing the rendered page and sampling it. Reading pixels back from a canvas on a file:// page throws a security error, so the whole harness has to be served over HTTP — even locally, even reading only your own files.

Contrast has less headroom than it looks

The measurement that changed how I make these decisions: --muted on pure white is 4.995:1. The AA floor for body text is 4.5:1, so the site’s secondary text sits comfortably above it — but any tint placed behind muted text spends that margin.

That is what turned a small visual change into a real one. Brightening the topic-card tint from 8% to 16% left the worst card at 4.50:1 measured — passing, by four thousandths. The arithmetic worst case, before pixel sampling, came out below the floor entirely.

So I dialled it to 12%, which measures 4.57 to 4.66 across the six channel colours. It is a smaller visual improvement and it is a control rather than a coincidence.

The same thing happened with a footer tint: 4% measured 4.613:1 and 5% measured 4.504:1. Both pass. Only one of them would survive somebody adjusting a neighbouring token.

A control that passes by 0.004 has not passed. It has not yet failed.

What I verify now

  • Thirty-four routes, both themes, 1280px, against the production build.
  • Twenty routes, both themes, 390px and 1280px.
  • Zero violations across all of it.
  • Every one of the 74 card images and 22 in-content images carries non-empty alt text, proved by grep rather than by sampling.

That last line exists because image alt text is the one thing a scan cannot fully judge. axe can tell you the attribute is present. It cannot tell you the text is any good.

Lessons

A scan is scoped to what it visited. “axe reports no violations” and “this site meets AA” are different claims, and the difference is coverage nobody publishes.

Test the oldest pages last. They are the ones you were careful with. The failures are in whatever you added most recently.

Never use opacity to make text quieter. It composites against an unknown background, escapes your design tokens, and compounds through nesting.

Fixed-width sweeps miss responsive failures. Anything that changes at a breakpoint needs scanning at that breakpoint.

display: none removes an accessible name. Use the clip technique when the text must stay available.

Hand-authored elements inherit nothing. If a generator gives every instance an attribute, the one you wrote yourself does not have it.

Let colours settle before measuring them. A transition will report failures that no human ever sees.


This describes work on this site, whose source is my own. No employer material is involved.