03 — Analytics 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.
- Next.js
- React
- Postgres
- Drizzle
- Tailwind
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
The CLS zeros were luck, and re-measuring said so
A second critique caught the footer moving on two routes. Every route's streamed fallback had been under-reserving height — the overview by 1,242px — and the zeros had been the race going the right way rather than the fallback being right. Heights are measured and set per route now.
The cost — A number I had already published had to be withdrawn and re-earned. CLS 0 across twenty runs since, and this paragraph stays on the page.
One page scores SEO 90, on purpose
The customer detail page must look a customer up before it can title itself, which makes its metadata async, which makes Next stream it out of the head. The same trade was measured on the customers index and reverted the other way.
The cost — Ten points on one route, stated in the README and in the score table rather than left off it.
Composed for a phone reader who has not decided to stay
At 390px the filter form was 1,247px tall with the first customer row at 1,622px — nearly two screens below the heading, on the page the build most wants read. Two columns from the smallest width, a skip link past the panel, and a form action that lands on the rows: 799px, first row at 1,174px.
The cost — A denser filter panel on mobile than a desktop-first design would have chosen.
Stated rather than left to be found
Known limits
- The cohort grid is the thinnest margin in the build — 83ms inside Postgres against a 100ms budget. It passes. It is named in the README as the next thing to fix, and the fix is a different query shape rather than another index.
- Two design-note snapshots remain reachable in git history. Removing them needs a history rewrite and a force-push; that was weighed and declined.