Skip to content
All work

03Analytics dashboard

Ledger

Ledger exists to answer the question that separates one React freelancer from another: can you do dense, complex UI without abandoning accessibility. Anyone can score 100 in Lighthouse on a landing page. Automated tooling catches roughly a third of real WCAG issues, so the only way to know is to listen to the thing.

Measured on the finished build

Tests
101 passing
Screen reader
20 NVDA scenes, none empty, transcripts in docs/nvda/
Axe violations
0 at 1280 and at 360, 7 pages
Contrast
13 pairings, all holding
CLS
0 across 20 runs, after the fallback heights were fixed
Lighthouse performance
98 / 97 / 98 / 99, lowest of five runs per route
Lighthouse, other categories
100 throughout, bar the documented SEO 90 on the detail page
Filtering
Server-side over 4,000 rows, with name search and first/last paging

The hard part

The defects a clean axe run cannot see

Every automated tool reads the same DOM, and the DOM was fine. Driving NVDA through twenty scenes with real keystrokes — through the OS keyboard hook, not the DevTools protocol, because CDP-synthesised keys never reach NVDA and browse mode never engages — found five defects that no tree walk would have reported.

The best of them: the customer table's caption announced as “Customers, sorted by Mrrdescending. Showing50 of 4,000.” A whitespace-only text node between two elements survives layout and is dropped when Chrome computes the accessibility text. Reading line by line does not catch it either, because a line break supplies the missing space. It only appears when a whole region is announced at once. It was in nine places.

What I did

Drive it, log it, check the transcript in

npm run nvda starts NVDA against a scratch profile with the silence synthesiser — it logs every utterance without speaking any of it — and drives Chrome through the twenty scenes. Every transcript is committed, one file per scene, verbatim. A claim about a screen reader that you cannot read back is not evidence.

The other four: a column whose caption and header disagreed about its own name; plan filters whose counts sat inside the label, so the checkbox announced “Starter1355” and never said what it counted; an aria-live region that could never once have fired, because applying a filter submits a GET form and loads a new document; and sorting that was completely silent — eight seconds of nothing in the transcript while four thousand rows re-sorted, because next/link navigates without tearing the document down. Every link is a plain anchor now.

Decisions, with the price attached

What was chosen, and what it cost

Stated rather than left to be found

Known limits