Previously, in Part 7, the staff pages got density with dignity, and every surface of ClinicLive finally looks designed. This part asks the question no screenshot can answer: what does the app sound like? Because our proudest feature — a board that repaints the instant anything changes — is precisely the thing a screen reader cannot see.

The proudest feature is the invisible one

Think about what ClinicLive's live board actually does. Reception clicks "call next," a SignalR message crosses the network, and the biggest text in the building changes to a new name — in under a second, with no refresh. Two seasons of work are in that repaint.

Now sit in that waiting room with a screen reader. The board updates and… nothing. A DOM mutation makes no sound. Assistive tech reads what you navigate to, not what quietly changed behind your focus — so the more "live" a surface is, the more completely a blind user is locked out of it. The kiosk's "You're in!" celebration, the chat's incoming bubbles, the typing indicator: every real-time feature we've built is, by default, a silent movie.

That's the theme of this pass, and the title of it: announce, don't just paint.

The audit prompt: findings first, again

Season one's hardening pass taught us the move: don't ask the AI to "make it accessible" — commission an audit, in a specific persona, and read the findings before any fix lands. The prompt, verbatim from the polish-08 commit in the companion repo:

"Audit ClinicLive the way a screen-reader user experiences it, then
fix what you find. Think especially about the real-time parts: a board
that updates silently is invisible to assistive tech."

The second sentence is the load-bearing one. A generic accessibility prompt returns a generic checklist — alt text, labels, heading order — and most of that we'd already covered in earlier parts. Pointing the auditor at the real-time parts aims it at exactly the class of problem automated checkers score 100 on and real users hit first. Here's what came back:

The screen-reader audit: findings and fixes
SurfaceWhat a screen-reader user experiencedThe fix
Board Names painted, never announced — a called patient hears nothing aria-live="polite" on the serving and up-next sections
Kiosk Check-in succeeds or fails invisibly role="status" on both outcome panels
Chat history New messages arrive as anonymous divs role="log" on the message list
Typing line "reception typing…" appears and vanishes silently role="status" on the typing paragraph
Standard shell No way past the top bar without tabbing through it A skip link, first in tab order

Model pick: same split as season one's hostile review — Opus for the audit itself, because reading an app as someone else is exactly the kind of adversarial thinking cheap models hedge on. The fixes are attributes and one-liners; any model can type those once the findings exist.

Live regions: making updates audible

The board fix is two attributes, and understanding them is the whole lesson. An aria-live region tells assistive tech: watch this part of the DOM, and when it changes, speak the change — even though focus is somewhere else entirely. Here's the real markup from Board.razor:

<section class="board-serving" aria-live="polite">
    <p class="board-label">Now serving</p>
    @if (_snapshot?.NowServing is { } serving)
    {
        <p class="board-name" @key="serving.AppointmentId">@serving.DisplayName</p>
    }
</section>

<section class="board-next" aria-live="polite">
    <p class="board-label">Up next</p>
    ...
</section>

When the SignalR handler swaps the snapshot and Blazor re-renders, the screen reader now says the new name out loud. A called patient is announced, not just painted — the feature finally works for everyone it was built for.

Why polite and not assertive? Polite waits for the current speech to finish; assertive interrupts mid-sentence. A clinic board should tap you on the shoulder, not shout over whatever you were reading. Assertive is for "the building is on fire," and almost nothing you build is that.

The other three fixes are the same idea wearing role hats. role="status" is a built-in polite live region — perfect for the kiosk, where the outcome of a check-in is the entire point of the surface:

<div class="kiosk kiosk-success" role="status">
    <div class="kiosk-emoji" aria-hidden="true">🪑</div>
    <h1>You're in!</h1>
    <p class="kiosk-position">Number <strong>@_result.Position</strong> in the queue</p>
    ...

The gentle error panel carries the same role, so a failed code is spoken too. And the chat gets role="log" — a live region that assumes additions at the end and reads only the new entry, which is exactly what a chat is — plus a status role on the typing line:

<div class="chat-log" role="log" aria-label="Messages" @ref="_log">
    ...each message...
</div>

<p class="chat-typing" role="status">
    ...who's typing...
</p>
The ClinicLive waiting-room board: dark teal signage palette, a huge 'Now serving' name, an up-next list of five and a clock
The Part 6 board, visually unchanged by this whole part — the work is in attributes a screenshot can't show. When that big name changes now, it's spoken.

Chrome-free is not landmark-free

Part 2 gave the kiosk and board a chrome-free BareLayout — appliances don't need nav bars. The audit's job was to check that stripping the chrome didn't strip the semantics, and here the news was good. The layout had kept its landmark, and the comment in the real file says why:

@* Chrome-free layout for "appliance" surfaces: the check-in kiosk and the
   waiting-room board. A TV on a wall does not need a nav bar — but it is
   still a document: <main> keeps the landmark for assistive tech. *@
@inherits LayoutComponentBase

<main class="appliance">
    @Body
</main>

Landmarks — main, header, nav — are how screen-reader users jump around a page instead of arrow-keying through it. Deleting the visual chrome is a design decision; deleting the document structure would be a regression. Chrome-free is not landmark-free.

The standard shell got the classic companion: a skip link, the first focusable thing on every page, invisible until keyboard focus lands on it:

<a class="skip-link" href="#main-content">Skip to content</a>
.skip-link {
    position: absolute; left: var(--s-4); top: -4rem; z-index: 50;
    background: var(--primary); color: var(--on-primary);
    padding: var(--s-2) var(--s-4); border-radius: 0 0 var(--radius) var(--radius);
    transition: top 120ms ease;
}
.skip-link:focus { top: 0; text-decoration: none; }

Parked above the viewport, it slides down on focus. Mouse users never meet it; keyboard users meet it first. That asymmetry is the entire trick.

The contrast audit: the tokens pay their rent

Because every color in ClinicLive is a token, auditing contrast means checking a short list of pairs once — not hunting hex codes across forty files. The numbers, from the audit:

  • Muted text on porcelain — the riskiest pair, since "muted" tempts designers below the line — sits at ≈ 4.7:1, clear of the 4.5:1 AA threshold for body text.
  • The board pairs — pale cyan on deep teal, read from five meters — all land at 6:1 or better. Signage gets headroom, not minimums.
  • Danger red on surface — the color that appears when something is already going wrong — measures 6.3:1. Error text is the worst possible place to be barely legible.

And a choice made in Part 1 pays off here: the site's typeface is Atkinson Hyperlegible, developed by the Braille Institute specifically for low-vision readers — unambiguous letterforms, generous counters. We picked it back then partly because its story fit a clinic. In the accessibility part, the story turns out to be load-bearing: the type was doing audit work all along.

Accessibility is a thread, not a part

Here's the quiet good news from the audit: most of the list was already done, because it never belonged to a single part. Part 4 added autocomplete and type="tel"/type="email" to the booking form — phone-keyboard ergonomics that are also assistive-tech semantics. Part 2 built the focus-visible rings and the reduced-motion guard into the foundations — and its very first screenshot catch, the h1 wearing a focus ring, was accessibility plumbing (FocusOnNavigate) surfacing in the visual design. A dedicated accessibility part that discovers fifty problems is a symptom; a pass that mostly verifies is the goal. Treat accessibility as a thread through every part, and the audit becomes a checkpoint instead of a rescue.

What the AI got wrong: nothing in this part's diff — and that's the trap worth logging. The same AI that fixed every finding in one session had also built every silent surface across seven parts without once volunteering "screen readers can't see this." Asked to build, it builds; accessibility arrives only when you commission it, exactly like season one's security pass. If the audit isn't on your checklist, it isn't happening — the model won't put it there for you.

The meter:$3.20 total for the redesign so far. The audit conversation and the attribute fixes added about $0.30 — live regions are close to free once someone thinks to ask for them. The expensive part was building surfaces worth announcing; the announcing itself is a rounding error.

Checkpoint: git checkout polish-08, run the app, and turn on a screen reader — NVDA is free on Windows, VoiceOver is built into macOS. Open the board, call the next patient from the staff queue in another tab, and listen. The name should be spoken without you touching a key. Then Tab once on any staff page and watch the skip link drop in.

The app now announces its changes to ears. Next, we make it explain them to eyes — with motion that has three hard rules, a Blazor @key trick that turns plain CSS into an update cue, and the only eight lines of JavaScript in the entire redesign: Part 9: micro-interactions — motion that explains change.