Skip to content

a11y(board): add an opt-in polite live region so a message change is announced - #209

Merged
jeffredodd merged 3 commits into
mainfrom
a11y/board-live-region-206
Aug 12, 2026
Merged

a11y(board): add an opt-in polite live region so a message change is announced#209
jeffredodd merged 3 commits into
mainfrom
a11y/board-live-region-206

Conversation

@jeffredodd

Copy link
Copy Markdown
Member

Closes #206.

The barrier

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. 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, MessageTransition and SplitFlapAnimation all 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 an sr-only aria-live="polite" aria-atomic="true" region beside the role="img".

  • Off by default, as the issue requires: 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 would be intolerable. polite, never assertive — a board update is informational. Same opt-in shape as EmptyState's announce (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).
  • The region carries only what changed, not a mirror of the current text. A mirror would be read a second time right after the 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.
  • The region sits outside the role="img" — an image's subtree is not exposed, so a region inside it would never be read.
  • announceUpdates is added to the 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. There is a test for exactly that failure mode.
  • ScaledBoardDisplay spreads its props into BoardDisplay, 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.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; a live region is DOM state rather than paint, so release:test's browserless automation job can host it (same harness shape as board-flap-cascade.test.mjs).

  1. silent by default, before and after a message change
  2. opted in: exactly one region, polite, aria-atomic, sr-only
  3. …and empty on mount — a board that has not changed has nothing to say
  4. a message change puts the new board text in it
  5. a re-render with the same message announces nothing new
  6. loading → message is announced
  7. toggling only announceUpdates takes effect (the memo-comparator trap)

Scope note — static-board-display.tsx

The issue lists it as also affected. It is deliberately not changed: its whole contract is "no useState, useEffect, or useRef" — 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: BoardDisplay with isStatic renders the same cheap static tiles and takes announceUpdates. A live board should use that; StaticBoardDisplay stays the hook-free renderer for previews, which are exactly the boards that must not announce.

Visual impact

None — sr-only adds no rendered pixels and no story was added. CyclingMessages turns the prop on as the story where it earns itself; Playground gets the control. No VRT baseline reseed.

🤖 Generated with Claude Code

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Board shows "BUS 33 IN 2 MIN" → region is empty (correct, first render never announces).
  2. Data refresh: isLoading: trueboardText = "Loading board display", announced.of is still "BUS 33 IN 2 MIN" → region announces "Loading board display".
  3. 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.

jeffredodd added a commit that referenced this pull request Aug 12, 2026
…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>
@jeffredodd

Copy link
Copy Markdown
Member Author

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: boardText resolves to loadingLabel mid-refetch, so message → loading → message announced "Loading board display" and then the message. The state update is now gated on !isLoading, which — as you noted — also leaves announced.of holding the pre-refresh text, so the message ending the refresh is still correctly detected as a change and the cold-start path still announces. New test fails on the previous code.

2. On emptyLabel, I went the other way — deliberately, not by oversight. 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. So message → null still announces, and there's now a test pinning that it announces the consumer's own string ("The board is now empty"), not a built-in. Happy to make it symmetric if you'd rather, but silence on a board that emptied seems like the worse failure.

3. VRT caught my "visually neutral" claim being wrong about one line. The sentence I added to CyclingMessages' caption about announceUpdates is rendered text — ~2,400–2,700 pixels differing across all four scopes. The live region itself paints nothing; the caption did. Reverted, and the story's docstring carries the explanation instead, which lands in autodocs rather than in the shot. No baseline reseed.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Parent mounts <BoardDisplay message="BUS 33 IN 2 MIN" announceUpdates={false} />
  2. Message changes to "BUS 42 IN 8 MIN" — board re-renders, but announced.of stays "BUS 33 IN 2 MIN" (the if guard is false)
  3. Parent sets announceUpdates={true} — guard fires, setAnnounced called 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.

jeffredodd and others added 3 commits August 11, 2026 20:38
…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>
@jeffredodd

Copy link
Copy Markdown
Member Author

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 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.

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:

enabling the prop announced "Board display: BUS 33 IN 2 MIN" in the same commit that added the region

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.

@jeffredodd
jeffredodd force-pushed the a11y/board-live-region-206 branch from 77a4425 to 22a7caa Compare August 12, 2026 03:39

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@jeffredodd
jeffredodd added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit 5e0fffe Aug 12, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

a11y: board-display.tsx — message updates are silent to screen readers (no live-region option)

1 participant