The deliverable is a PDF, and the part of it people actually look at is a screenshot of their own homepage with the failing elements outlined and numbered. Everything else in the document is a table.
A headless reviewer reads the finished PDF, including looking at those screenshots, before the send. On 2026-07-20 it rejected three drafts in a row. Two of its complaints were about the same code: the visible numbering started at #2, and some flags were outside the frame.
What the annotation pass is working with
The crawl stores axe-core violations per page, keeping up to three sample nodes per rule, each with the CSS selector axe recorded. The evidence pass reopens the worst pages at 1440x1500, resolves those selectors, outlines every match (red for critical, amber for serious), scrolls to wherever the failures cluster, and takes one viewport shot. Not fullPage: a tall municipal page rendered into a PDF page is unreadably small.

CivicBinder accessibility audit report with metrics (78 issues, 5 defects) and HTML code examples — Tool shows both quantified findings and actual HTML snippets from the analyzed website side-by-side.
Marking during the walk is what broke it
The original code did the outline, the badge, and the number in one page.evaluate over every failing element in the document, then scrolled and shot. Two things fall out of that ordering.
The counter is document-wide. Element 1 is in the masthead, the densest cluster is 900px down, so the first badge a reader sees is #4 and the sequence has holes in it.
And the badges were position: absolute at scrollY + r.top, a document coordinate. For a fixed or sticky element that number describes where the element happened to be sitting at scroll 0, and the element does not stay there. The sticky header travels with the viewport, the badge does not, and municipal sites ship sticky headers and floating chat bubbles as a matter of course.
The caption number was wrong separately. It printed the count of every outlined element in the document, while shown came from the cluster search: how many recorded Y positions fell inside a 1500px window. That is not a count of things a reader can see. Zero-width elements, horizontally off-screen elements and anything inside a collapsed accordion all counted.
Two passes, with the scroll between them
Pass 1 now only outlines and tags, returning sorted Y positions for the cluster search. After the scroll settles, pass 2 does the numbering:
const shown = await page.evaluate(() => {
let n = 0;
document.querySelectorAll('[data-cb-mark]').forEach((el) => {
const r = el.getBoundingClientRect();
if (r.bottom < 24 || r.top > innerHeight - 24 || r.width === 0) return; // off-screen
n++;
const badge = document.createElement('div');
badge.textContent = n;
badge.style.cssText = `position:fixed; z-index:2147483647;` +
`left:${Math.max(4, r.left - 12)}px; top:${Math.max(4, r.top - 12)}px; ...`;
document.body.appendChild(badge);
});
return n;
});
if (!shown) { console.error(`skip (no failing element visible after scroll): ${p.url}`); continue; }position: fixed is correct here precisely because nothing scrolls again before the shutter. The clamps stop a badge on a flush-left element from landing at x = -12.
| before (rejected 2026-07-20) | after | |
|---|---|---|
| badge coordinates | absolute, scrollX/scrollY + rect | fixed, viewport rect, clamped to 4px |
| numbering | during the document-wide walk | after the final scroll, over in-frame elements only |
| first visible badge | whatever survived the crop | always 1 |
fixed/sticky elements | badge pinned to a coordinate the element leaves | placed after the last scroll, so it cannot diverge |
| caption count | every outlined element on the page | the number of badges actually drawn |
| nothing in frame | shot taken anyway | page skipped with continue |
Three numbers, none of them interchangeable
One page in a run has 42 failing nodes. The outline set is smaller, because the scan keeps three sample nodes per rule. Eleven badges land in frame. The caption prints the first and the last of those, and the manifest now derives both marked and shown from the same return value, so they cannot drift apart again.
If zero badges land, the page is dropped from the binder rather than shipped with an empty red rectangle. The reviewer would have failed it anyway: its fourth check is that every screenshot shows numbered markers and that the caption agrees with them. Any error inside the reviewer itself returns pass: false, so a broken gate blocks the send instead of waving it through.
How we built this, and what the finished binder looks like: CivicBinder.
One shipped product, taken apart, once a month. What it does, what it cost to build, what the pipeline behind it looks like, and what the numbers did — read off the repository and the live site, not written from memory. Join the list.
← All writing