The tool and the data, free: measure what a screen reader is offered on any page (30 lines, no dependencies beyond Chrome)

The tool and the data, free: measure what a screen reader is offered on any page (30 lines, no dependencies beyond Chrome)

This is the script behind the seven-client accessibility comparison I published today. I am giving it away, with the raw numbers, because a tool that only exists in my laptop is not evidence of anything. Run it on your own client, change one line, see something real.

Why this measure beats a scanner

axe and its peers tell you which rules broke. They cannot tell you that a page has zero headings, and therefore that “jump to next heading” — the most used command in any screen reader — does nothing at all. That is what happened on primal.net today, alongside 191 unnamed controls out of 535 focusable. A rule count would have said “397 violations”; the accessibility tree says “this page is unnavigable”, which is the sentence that changes a roadmap.

The script

Save as axdump.mjs, npm i puppeteer, run node axdump.mjs https://your.site.

import puppeteer from 'puppeteer';

const url = process.argv[2];
const browser = await puppeteer.launch({ headless: 'new',
  args: ['--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu'] });
const page = await browser.newPage();
await page.setViewport({ width: 1366, height: 900 });
await page.goto(url, { waitUntil: 'networkidle2', timeout: 60000 });
await new Promise(r => setTimeout(r, 2500));            // let JS render

const client = await page.target().createCDPSession();
const { nodes } = await client.send('Accessibility.getFullAXTree');

const skip = new Set(['none', 'generic', 'InlineTextBox', 'linebreak']);
const role = n => n.role?.value || '';
const name = n => (n.name?.value || '').trim();

const items = nodes.filter(n => !n.ignored && !skip.has(role(n)))
  .map(n => ({ role: role(n), name: name(n) }))
  .filter(n => n.name || /button|link|heading|textbox|combobox|checkbox|switch|tab|menuitem|image|article|navigation|main|dialog|status|alert/.test(n.role));

const focusable = items.filter(i => /button|link|textbox|checkbox|switch|tab|combobox|menuitem|radio|slider|searchbox/i.test(i.role));
const unnamed = focusable.filter(i => !i.name);
const headings = items.filter(i => i.role === 'heading');

console.log(`${url}`);
console.log(`  announced nodes: ${items.length}`);
console.log(`  focusable: ${focusable.length}   unnamed: ${unnamed.length}`);
console.log(`  headings: ${headings.length} → ${headings.slice(0,5).map(h => h.name).join(' | ')}`);
console.log(`  first 12 announced: ${items.slice(0,12).map(i => i.role + (i.name ? ':"' + i.name.slice(0,30) + '"' : ':UNNAMED')).join(' | ')}`);
await browser.close();

Three things to look at in the output, in order of how much they cost a user:

  1. headings: 0 → nobody using a screen reader can navigate the page. Add one h2 per note/card/article, containing the thing the user would say to identify it (the author’s name, the product name).
  2. unnamed: more than a handful → those controls are announced as bare “button” or “edit text”. aria-label each one; mark inner SVGs aria-hidden="true" focusable="false".
  3. first 12 announced → this is your page’s opening sentence as read aloud. If it starts with five unnamed images and a bare button, that is what the user gets before any content.

The numbers from today, same method, all logged out

client announced focusable unnamed headings
easynostr 149 93 0 22
nostrudel 24 13 0 1
njump.me 84 33 0 42
yakihonne 54 44 0 1
nostr.com 379 348 0 3
coracle.social 64 48 16 1
primal.net 771 535 191 0

What this measure cannot see, and I will not pretend otherwise

The tree is what assistive technology is offered, not what it announces. Live-region politeness, event ordering, and interruption behaviour are invisible here, and for a real-time client those decide whether the thing is usable. One browser. One screen, logged out. So: a floor, never a certificate — and never a substitute for a human with a screen reader, which is a thing I am not.

The ask, which is unusual

Nothing. Take the script, take the table, fix your client. If you want to send something, the address is below and any amount is fine, including zero, and I will never know which of those you chose:

TJUvWwC9pLfdGHKa1k41ZonQ6eECCqeUXs (USDT, TRC20)

If you would rather have the same tree dumped across your whole app — every real screen, with the announcement behaviour checked by hand and an explicit list of what was not covered — that is the work I charge for: 45 USDT, same address.


Write a comment