Build in public

The Alt Text Was Perfect: Accessibility Checks That Measure Tags, Not Shape

By Arun Jalota · · ~6 min read

My test suite runs eight checks on every page of this site that could reasonably be called accessibility checks. Exactly one <h1>. A viewport meta tag. An html[lang] attribute. No horizontal overflow at 390px. Every <img> has an alt. No console errors. Valid JSON-LD. A meta description in a sane length range.

Twenty-two essay pages. All eight, both viewports, green for months.

Last night I opened one of them and pressed Tab.

What Tab found

Nothing happened that should have happened. There was no skip link, so the first Tab landed on the back-link in the header and the second landed in the body copy — fine on an essay, because an essay is mostly prose. Then I turned on a screen reader's landmark list, which is how a lot of people actually navigate a page: not by reading it top to bottom, but by jumping between regions.

The list was empty. Every one of these pages was a single <article class="wrap"> and nothing else. No <main>. No <nav>. No <footer>. There was no "skip to content" affordance of any kind, which is WCAG 2.4.1, Bypass Blocks — a Level A criterion, the floor, the one you're failing before you get to argue about contrast ratios.

And it was worse than absent structure. The structure that existed was wrong. The back-link to the home page was inside the <article>. So was the author bio at the bottom, and the "more writing" list. To assistive technology, "the article" began with a navigation link and ended with my email address and two social links. If you asked it to read you the article, that's what you got.

Twenty-two pages. Every assertion green.

Why every check I owned missed it

Here's the pattern I didn't see until I lined the checks up next to the failure:

What I assertedShape of the claim
Exactly one <h1>count of a tag
Viewport meta presentpresence of a tag
html[lang] setattribute on a tag
Every <img> has altattribute on a tag
Meta description 50–165 charslength of an attribute
No horizontal overflowone number about the viewport

Every single one is a property of a single element, answerable by looking at that element alone. None of them is a claim about how elements relate to each other.

That's not a coincidence, and it isn't laziness. Tag-property checks are the ones that are easy to write. document.querySelectorAll("img") and then .every(i => i.alt) — that's a one-liner, it never flakes, and it produces a satisfying green line in the output. "Is this page navigable by landmark?" doesn't decompose into a one-liner, because the answer depends on which elements exist, which contain which, and whether the containment matches the meaning.

So a checklist that grows organically drifts toward tag properties. You add the cheap check, it passes, you feel covered, and the count goes up. Three hundred and ninety-six assertions is a number that feels like thoroughness. It was mostly the same eight easy questions asked about forty-four page-loads.

A count of assertions measures how many times you asked, not how many different things you asked about.

The fix, and the part of it that matters

The markup change was the boring part. A skip link as the first focusable element, a <main id="content"> as its target, the back-link moved into a <nav>, the bio moved out of the article into a real <footer>, and the related-links list turned into a labelled <nav>. Twenty-two files, applied by a script, because doing it by hand twenty-two times is how you get twenty-one correct pages and one that nobody notices for a year.

I made that script strict in a specific way: it parses every file first, refuses to write anything at all if any file doesn't match the expected shape, then re-checks its own output before saving. A partial rewrite across twenty-two pages is much worse than no rewrite, because "some of them are fixed" is a state nobody can reason about later.

The part that matters is the new assertion, and it is deliberately not a tag-presence check. It runs against the rendered DOM and asks structural questions:

Is there exactly one <main>, and is the skip link's href pointing at something that exists? Is the skip link actually the first element in the body — not merely present, but reachable first, which is the only version of a skip link that does anything? Is the <article> inside the <main>? And the one I care most about, phrased as a negative: is the author bio no longer inside the article?

That last check is the interesting one. It can't be satisfied by adding a tag. You can add <main>, <nav>, and <footer> to a page and still leave the bio nested inside the article — every element present, every landmark listed, and the article still ends with my email address. The only way to pass it is to get the containment right. It is a claim about shape, and shape is what was broken.

Then I broke it three times

Same discipline as always: an assertion nobody has watched fail is a hypothesis. I removed the <main> from one essay, pointed one canonical URL at the wrong host, and drifted one visible byline date away from its JSON-LD. Three failures, and each one named the file and the exact difference:

FAIL a11y landmarks /writing/the-3am-test.html — 0 <main> elements; <article> not inside <main>

FAIL date consistency /writing/the-eval-is-the-spec.html — visible 2026-07-21 != datePublished 2026-07-31

That second one came from a different hole I found on the way. The publication date of every essay is written down three times — in the visible byline, in the JSON-LD datePublished, and in the sitemap's lastmod — and nothing had ever compared them to each other. They happened to all agree, which is luck, not correctness. There's now a <time datetime> element carrying the machine-readable version, and a check that all three copies match.

The suite didn't run at all

One more thing, and it's the one that actually unsettles me.

Before any of this, the suite threw before its first assertion. The runner had no browser binary, and after downloading one it was missing a shared library the host couldn't install without root. Not a failure — an exception, three seconds in, zero checks executed.

Think about what that looks like in an unattended run at 1 a.m. A suite that cannot start reports nothing. And "0 failed" is the same string whether you checked four hundred things or none of them. The only reason it didn't read as a quiet green night is that the process exited non-zero and somebody was watching.

So the launch path now provisions what it needs and retries, and when it genuinely can't, it says so in a sentence that names the fix instead of printing an ASCII box that assumes you have sudo. The test harness is software too. It has uptime, dependencies, and failure modes, and nothing in the suite was testing the suite.

What I'd ask of any checklist now

How many of these assertions are about one element in isolation? If it's nearly all of them, the checklist is measuring vocabulary rather than grammar. Every tag can be correct while the page has no usable shape.

Is there at least one check that can't be satisfied by adding something? Additive checks are easy to pass and easy to pass accidentally. A check phrased as "X is no longer inside Y" forces a real structural decision.

Does the number of assertions reflect distinct questions, or repetition? Mine was forty-four page-loads times eight questions and it read as three hundred and ninety-six. The big number was doing rhetorical work it hadn't earned.

What does this suite do when it can't run? If the answer is "throws, and the log looks like every other quiet night," that's a gap in the thing you're using to find gaps.

None of this is advanced. It's just that accessibility, in particular, punishes the checklist mindset harder than most things do — because the failures aren't missing attributes, they're missing relationships, and relationships are exactly what a per-element assertion can't see.

The alt text was perfect on every page you couldn't navigate.