a11y(board): add an opt-in polite live region so a message change is announced - #209
Conversation
There was a problem hiding this comment.
Performance review — PR #209
Default path (announceUpdates = false): clean. The if (announceUpdates && ...) guard means the setAnnounced branch is never entered, the live-region div is never mounted, and the only new overhead is one extra useState hook call plus a discarded { text: '', of: boardText } object allocation per render. Both are noise.
Opt-in path: one real cost, proportionate.
src/components/board/board-display.tsx:1359–1362 — the state-update-during-render pattern causes two render passes every time boardText changes with announceUpdates on. On the first pass React detects announced.of !== boardText, calls setAnnounced, and immediately re-renders before committing to the DOM. That's the documented idiom (vs useEffect, which would commit a frame with a stale region and then re-commit), so the tradeoff is correct — tiles and the live region land in the same paint. But the cost is real: the board's full subtree is reconciled twice per message change.
At transit-board update rates (seconds to minutes) this is imperceptible, and since the feature is deliberately off in editor/preview contexts (the chatty path), the double render never fires where it would matter. Worth knowing about, not a blocker.
VRT: no exposure. No baselines are touched. The sr-only region adds no rendered pixels, so the CyclingMessages story's baseline is unaffected. The three entries in vrt/skip.json (ui-aurora--*, board-boarddisplay--loading, board-boarddisplay--loading-transition) don't overlap with anything this PR touches, and no new skips were added.
Bundle: clean. No new imports, no new dependencies.
There was a problem hiding this comment.
Accessibility review — one finding
Live region announces loadingLabel/emptyLabel strings on state transitions, not only message text
src/components/board/board-display.tsx:1333–1363
boardText is computed as loadingLabel ("Loading board display") when isLoading is true and emptyLabel ("Empty board display") when the message is null. The live region tracks every change to boardText, so a board that has a message and then enters a loading state (routine data refresh) will announce "Loading board display" politely before announcing the new message.
Concrete sequence a transit board consumer hits with announceUpdates: true:
- Board shows "BUS 33 IN 2 MIN" → region is empty (correct, first render never announces).
- Data refresh:
isLoading: true→boardText = "Loading board display",announced.ofis still "BUS 33 IN 2 MIN" → region announces "Loading board display". - Data arrives:
isLoading: false, message: "BUS 33 IN 1 MIN"→ region announces "BUS 33 IN 1 MIN".
A screen reader user hears step 2 as if the board content changed to something called "Loading board display" — an internal component label, not a transit status. The prop description says "Announce message changes"; the loading and empty labels are not message changes.
WCAG 4.1.3 (Status Messages, AA) requires status messages to be programmatically determinable so AT can present them without focus — that part is satisfied. The criterion's intent, and the scope called out in this review, is that live-region content should carry meaningful information: "Loading board display" fails that bar for a user who reads bus times, not source code.
The test suite covers only the cold-start path (null + isLoading → message). The refresh path (message → isLoading → message) is untested; the announcement of "Loading board display" on step 2 is emergent, not pinned.
Who is affected: A screen reader user on any page that uses announceUpdates: true on a board whose data source refreshes in place (transit ETAs, weather, alert rotations) — which is exactly the described use case for the feature.
Suggested direction: Gate the state update on announceUpdates && !isLoading && !!message so the region exclusively carries message text and the fallback strings never reach it. The cold-start test (loading → message announces the message) continues to pass because announced.of is never overwritten during loading, so the delta is correctly detected when the message arrives. The emptyLabel path is symmetric.
…aption out of VRT Two fixes from review on #209. **The loading label must not be announced.** `boardText` resolves to `loadingLabel` while a board refetches, so the path a live board actually takes — has a message, refetches, gets a new one — announced "Loading board display" and then the message: twice the speech per refresh, half of it about an internal phase rather than about buses. The state update is now gated on `!isLoading`. That also leaves `announced.of` holding the pre-refresh text, so the message ending the refresh is still correctly seen as a change, and the cold-start path (loading -> first message) still announces. A board going *empty* is deliberately not treated the same way, which is where this departs from the review's suggestion. Clearing a board is a content change, not a transient phase: silence would leave a screen-reader user believing the old message still stands, and `emptyLabel` is a consumer-supplied, localizable string rather than an internal one. Both behaviours are now pinned by tests — the refresh test fails on the previous code, and the empty test asserts a custom `emptyLabel` is what gets announced. **The caption stays as it was.** VRT caught that the sentence added to `CyclingMessages` about `announceUpdates` differed by ~2,400-2,700 pixels across all four scopes — it is rendered text, so "visually neutral" was wrong about that one line even though the live region itself paints nothing. Rewriting four baselines to document a feature with no pixels is the wrong trade; the story's docstring says it instead and lands in autodocs, which VRT does not shoot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Both review points addressed — the a11y one was a real bug, and VRT caught a second thing. 1. The loading label no longer reaches the region. You're right about the refresh path: 2. On 3. VRT caught my "visually neutral" claim being wrong about one line. The sentence I added to |
There was a problem hiding this comment.
Performance review — PR #209
Clean. One cost worth knowing; nothing actionable on the default path.
Double render per message change — opt-in path only
src/components/board/board-display.tsx, state-during-render block (~line 1371)
When announceUpdates=true and boardText changes, the setAnnounced call fires during the render function. React immediately discards that render pass and re-runs BoardDisplay synchronously — so the full render body (tile layout, character iteration, all the useMemo cache reads) executes twice before a single commit. Cost is paid per message change, only when the consumer has opted in.
This is unavoidable: it is the React-documented pattern for updating state in response to a previous render value. The useMemos hit cache on the second pass so work is reduced — but the component function does run twice. A useEffect alternative would be strictly worse (fires post-commit, forces a second paint frame). There is no ref-based escape that survives Strict Mode double-invocation correctly (a ref written during the first discarded pass persists, making the announced text empty on the second pass when it should carry the new message).
Default path (announceUpdates=false): the if guard short-circuits on the boolean immediately — zero cost for every existing consumer.
VRT coverage gap (pre-existing, not introduced here)
vrt/skip.json is unchanged — no coverage is reduced by this PR. The skip list already excludes board-boarddisplay--loading and board-boarddisplay--loading-transition, the two stories that exercise the loading-to-message transition this PR handles. A green visual-regression check proves nothing for those stories. That gap predates this PR and is harmless for visual correctness here (the live region is sr-only — it paints nothing), but worth keeping in mind if those stories ever gain rendered content.
CyclingMessages is not skipped and now has announceUpdates enabled; the region is sr-only, so no pixel change and no baseline update needed.
No new imports, no bundle weight, no observers or timers without teardown, no inline object identity passed to memoized children, no layout thrash. The custom memo comparator correctly adds announceUpdates — a prop omitted from it would have made the feature silently inert.
There was a problem hiding this comment.
Accessibility review — PR #209
The implementation is well-reasoned: polite + atomic is the right politeness pair for informational board updates, the region sits outside role="img" (correct — an image's subtree is not exposed to AT), it starts empty to avoid the double-read on first encounter, and the memo comparator is updated so announceUpdates is not silently inert. No findings on the normal mount-with-option path.
Finding: live region mounted with content when announceUpdates is toggled on mid-lifecycle — WCAG 4.1.3 (AA)
board-display.tsx:1372
const [announced, setAnnounced] = useState({ text: "", of: boardText });
if (announceUpdates && !isLoading && announced.of !== boardText) {
setAnnounced({ text: boardText, of: boardText });
}useState captures boardText at mount time into announced.of. The if guard only runs when announceUpdates is true, so while the feature is off, announced.of stays fixed at the mount-time text even as the board message changes. When announceUpdates is then switched to true, the guard fires (announced.of !== boardText), the state-during-render pattern kicks in, and React commits the region into the DOM with announced.text already populated on its first appearance.
The concrete path:
- Parent mounts
<BoardDisplay message="BUS 33 IN 2 MIN" announceUpdates={false} /> - Message changes to
"BUS 42 IN 8 MIN"— board re-renders, butannounced.ofstays"BUS 33 IN 2 MIN"(theifguard isfalse) - Parent sets
announceUpdates={true}— guard fires,setAnnouncedcalled during render, React re-renders with{ text: "BUS 42 IN 8 MIN", of: "BUS 42 IN 8 MIN" }, region is committed into the DOM with content already set
JAWS and NVDA observe the DOM via mutation observers. When they see a new [aria-live] node that already contains text at insertion time, they treat the content as "was always there" and do not announce it. The well-documented requirement from both vendors is that a live region must be present in the DOM (even empty) before content is populated for announcements to fire reliably. Inserting a region and populating it in the same commit is the pattern that fails. A screen reader user in this scenario hears nothing, believing the old message still stands — WCAG 4.1.3.
The comment acknowledges the mid-life toggle behavior ("switching announceUpdates on mid-life announces the board once, which is the right behaviour anyway") but not the reliability risk.
Who is affected: Screen reader users, primarily JAWS and NVDA on Windows with Firefox or Chrome, in any consuming app that doesn't set announceUpdates at mount (e.g. starts it false while determining board liveness, then enables it).
Fix: Decouple region presence from region content. Render the region unconditionally while announceUpdates is true (so it lands in the DOM empty on mount regardless of message state), and let the content update be the only mutation AT observes:
{announceUpdates && (
<div className="sr-only" aria-live="polite" aria-atomic="true" data-slot="board-display-announcer">
{announced.text}
</div>
)}This is what the code already does — the issue is that the state-during-render path bypasses it on the mid-life toggle. One way to close the gap: initialize announced.of to null rather than boardText, and then only treat a message change (not the first message itself) as something to announce. That would make the initial announced.of !== boardText guard always true at first render, causing the region to mount empty and announce only on subsequent genuine changes — but that also means the first message change after a mid-life toggle would be announced as a proper update rather than silently pre-populated.
Alternatively, accept the current behaviour and document that announceUpdates should be set at mount rather than toggled — but note that the CyclingMessages story is the only reference for downstream consumers, and it always has the prop true, so they may not realize the gap.
The test suite covers the happy path thoroughly. The mid-life toggle test (test("toggling only announceUpdates takes effect")) uses the same message before and after the toggle, which avoids this path — announced.of === boardText so no state update fires and the region mounts empty. A test that changes the message while announceUpdates is false and then enables it would reproduce the issue.
…announced Issue #206 — `BoardDisplay` exposes the board as one `role="img"` and recomputes its `aria-label` when the message changes. A changed `aria-label` on a static `role="img"` is not announced by any screen reader; only a change *inside* an `aria-live` region is. The component had no live region and no way to ask for one, so on a live board — the component's namesake use case — a sighted user watched the board flip and a screen-reader user heard nothing. The `CyclingMessages`, `LoadingTransition`, `MessageTransition` and `SplitFlapAnimation` stories all mutate the message at runtime; all four were silent. `announceUpdates?: boolean`, default **off**, renders an `sr-only` `aria-live="polite" aria-atomic="true"` region beside the `role="img"`. Off by default because only the consuming app knows whether its board is live: in the page editor's `ScaledBoardDisplay` or a thumbnail the message changes on every keystroke, and a live region there would be intolerable. `polite`, never `assertive` — a board update is informational. Same opt-in shape as `EmptyState`'s `announce` (#120). The region carries only what *changed*, never a mirror of the current text. A mirror would be read a second time immediately after the `role="img"` name on first encounter and, mounted with content, risks announcing on arrival — the page-load chatter #120 exists to avoid. So it renders empty and fills in on the first change. The state is adjusted during render (React's documented pattern for deriving state from props) rather than in an effect, so the region and the tiles commit in the same paint, and the comparison is skipped entirely when the feature is off — the default path pays no extra render. The region sits outside the `role="img"`, since an image's subtree is not exposed and a region inside it would never be read. `announceUpdates` is also added to BoardDisplay's hand-written `memo` comparator. That comparator, not React's shallow default, decides whether the board re-renders at all, so a prop missing from it is silently inert — a test below covers exactly that failure. `ScaledBoardDisplay` spreads its props into `BoardDisplay`, so it forwards the new prop with no change. Guard: `scripts/ci/tests/board-live-region.test.mjs` mounts the real component in jsdom, drives message changes and reads the DOM — this is behaviour over time, which no static check can see, and a live region is DOM state rather than paint, so `release:test`'s browserless job can host it. Six tests: silent by default before and after a change; opted in the region is polite, atomic, `sr-only` and empty on mount; a change is announced; an unchanged re-render is not; loading -> message is announced; toggling only `announceUpdates` takes effect. Five were red before this change. `CyclingMessages` turns it on as the story where it earns itself, and `Playground` gets the control. Visually neutral — `sr-only` adds no rendered pixels and no story was added, so no VRT baseline reseed. Closes #206 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…aption out of VRT Two fixes from review on #209. **The loading label must not be announced.** `boardText` resolves to `loadingLabel` while a board refetches, so the path a live board actually takes — has a message, refetches, gets a new one — announced "Loading board display" and then the message: twice the speech per refresh, half of it about an internal phase rather than about buses. The state update is now gated on `!isLoading`. That also leaves `announced.of` holding the pre-refresh text, so the message ending the refresh is still correctly seen as a change, and the cold-start path (loading -> first message) still announces. A board going *empty* is deliberately not treated the same way, which is where this departs from the review's suggestion. Clearing a board is a content change, not a transient phase: silence would leave a screen-reader user believing the old message still stands, and `emptyLabel` is a consumer-supplied, localizable string rather than an internal one. Both behaviours are now pinned by tests — the refresh test fails on the previous code, and the empty test asserts a custom `emptyLabel` is what gets announced. **The caption stays as it was.** VRT caught that the sentence added to `CyclingMessages` about `announceUpdates` differed by ~2,400-2,700 pixels across all four scopes — it is rendered text, so "visually neutral" was wrong about that one line even though the live region itself paints nothing. Rewriting four baselines to document a feature with no pixels is the wrong trade; the story's docstring says it instead and lands in autodocs, which VRT does not shoot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…on the next change Second review finding on #209, and a real one. AT announces a mutation *inside* a region that was already in the DOM; a region that appears already holding content is not reliably spoken at all. A consumer that flips `announceUpdates` on after its first fetch — deciding only then that its board is live — hit exactly that: the message had changed while the feature was off, so the render that added the region also filled it, and the announcement was left to whatever the screen reader happens to do with a pre-populated region. The state now carries `armed`. The render where the region arrives only arms it: the region lands empty and `of` resyncs, so a change that happened while the feature was off is not replayed, and the next real change is a mutation the region is present for. Mounting with `announceUpdates` already true arms in the `useState` initializer, so the common case pays no extra render. This supersedes the previous comment's claim that announcing once on a mid-life toggle was "the right behaviour anyway" — an announcement that may or may not be spoken is not a behaviour worth defending. Test first, and it is the case the reviewer pointed out the old suite could not reach: change the message while off, enable, assert the region mounts empty, then change again and assert *that* is announced. It fails on the previous code with `enabling the prop announced "Board display: BUS 33 IN 2 MIN" in the same commit that added the region`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Second finding fixed — you're right that a region arriving already populated is not a reliable announcement, and my earlier comment defending the mid-life toggle behaviour was wrong on exactly that point: an announcement that some screen readers speak and others drop is not a behaviour worth keeping. The state now carries Test first, and it's the exact case you pointed out the old suite couldn't reach (my toggle test used the same message either side, which sidesteps the path): change the message while off → enable → assert the region mounts empty → change again → assert that is announced. It fails on the previous code with:
Also rebased onto main now that #200 and #207/#208 have landed. Re-verified after the rebase: 217 hermetic tests green, 55 storybook tests green, and in live Chromium the announcer is empty at mount, fills after a cycle, and the default board still has zero live regions. |
77a4425 to
22a7caa
Compare
There was a problem hiding this comment.
Performance: clean. No action required.
The implementation makes all the right calls on the hot path:
Default path (all existing consumers, announceUpdates=false): Zero extra renders, zero extra DOM nodes. Both setAnnounced branches short-circuit on announceUpdates && before any comparison runs. The only overhead is one small state object per BoardDisplay mount ({ text: "", of: boardText, armed: false } — ~60 bytes), unavoidable without conditional hooks.
Opted-in path: The "state adjustment during render" pattern at board-display.tsx:1384–1389 is the correct choice over a useEffect. When boardText changes, React discards the first render and immediately re-renders (two render-function invocations, one DOM commit). A useEffect would cost two full commits instead of one. The grid useMemo deps (message, deviceType) don't change between the two passes, so it returns cached — the second pass is cheap. On a live transit board refreshing every ~30 s this is two render passes per refresh: negligible.
No effects, no listeners, no timers, no observers — nothing to leak. The lazy state initializer runs once on mount and captures the already-computed boardText string.
Bundle weight: No new imports. useState was already in scope.
VRT: board-boarddisplay--cycling-messages.png has a baseline and is not in vrt/skip.json. The announceUpdates prop adds a sr-only div (Tailwind: position: absolute; width: 1px; height: 1px; overflow: hidden; …). Absolutely-positioned elements don't participate in normal flow and add no visible pixels, so the baseline holds without an update. The diff correctly leaves the baselines unchanged.
Memo comparator (board-display.tsx:1501): announceUpdates correctly added. Without it the prop would be silently inert — the test suite covers exactly this.
There was a problem hiding this comment.
Clean. The region is correctly placed outside the role="img" subtree (content inside an image role is never exposed to AT), aria-live="polite" is the right level for informational board updates, aria-atomic="true" ensures the whole message is spoken as one unit, and the region mounts empty — the armed guard prevents the arrival-with-content race where some screen readers speak a region the moment it appears with text and others don't, making the announcement unreliable. The isLoading exclusion is also correct: suppressing the transient loadingLabel from the region means a refresh cycle announces one thing (the new message), not two. Opt-in design is appropriate given editor/preview consumers. The memo comparator update is present and correct. No axe rules disabled, no coverage reduced.
Closes #206.
The barrier
BoardDisplayexposes the board as onerole="img"and recomputes itsaria-labelwhen the message changes. A changedaria-labelon a staticrole="img"is not announced by any screen reader — only a change inside anaria-liveregion is. There was no live region and no way to ask for one, so on a live board (the component's namesake use case) a sighted user watched the board flip and a screen-reader user heard nothing.CyclingMessages,LoadingTransition,MessageTransitionandSplitFlapAnimationall mutate the message at runtime; all four were silent.axe can't see it: it inspects one static snapshot and has no rule for "this content updates but is not in a live region".
The fix
announceUpdates?: boolean, default off, renders ansr-onlyaria-live="polite" aria-atomic="true"region beside therole="img".ScaledBoardDisplayor a thumbnail the message changes on every keystroke and a live region would be intolerable.polite, neverassertive— a board update is informational. Same opt-in shape asEmptyState'sannounce(a11y: ui/empty-state.tsx — permanently mounted aria-live region makes AT diff the whole subtree on every mutation and announces static empty states on page load #120).role="img"name on first encounter and, mounted with content, risks announcing on arrival — the page-load chatter a11y: ui/empty-state.tsx — permanently mounted aria-live region makes AT diff the whole subtree on every mutation and announces static empty states on page load #120 exists to avoid. So it renders empty and fills in on the first change. State is adjusted during render (React's documented derive-from-props pattern), not in an effect, so the region and the tiles commit in the same paint; the comparison is skipped entirely when the feature is off, so the default path pays no extra render.role="img"— an image's subtree is not exposed, so a region inside it would never be read.announceUpdatesis added to the hand-writtenmemocomparator. That comparator, not React's shallow default, decides whether the board re-renders at all, so a prop missing from it is silently inert. There is a test for exactly that failure mode.ScaledBoardDisplayspreads its props intoBoardDisplay, so it forwards the new prop with no change.Guard (TDD — 5 of 6 red before the change)
scripts/ci/tests/board-live-region.test.mjsmounts the real component in jsdom, drives message changes and reads the DOM. This is behaviour over time, which no static check can see; a live region is DOM state rather than paint, sorelease:test's browserlessautomationjob can host it (same harness shape asboard-flap-cascade.test.mjs).polite,aria-atomic,sr-onlyannounceUpdatestakes effect (the memo-comparator trap)Scope note —
static-board-display.tsxThe issue lists it as also affected. It is deliberately not changed: its whole contract is "no
useState,useEffect, oruseRef" — that is why it exists and what makes it cheap at thumbnail scale — and announce-on-change needs state. The design system already has the answer without a second implementation:BoardDisplaywithisStaticrenders the same cheap static tiles and takesannounceUpdates. A live board should use that;StaticBoardDisplaystays the hook-free renderer for previews, which are exactly the boards that must not announce.Visual impact
None —
sr-onlyadds no rendered pixels and no story was added.CyclingMessagesturns the prop on as the story where it earns itself;Playgroundgets the control. No VRT baseline reseed.🤖 Generated with Claude Code