From 44936358037f8bfd0fb54419054420e69c9abe99 Mon Sep 17 00:00:00 2001 From: Jeffrey Johnson Date: Tue, 11 Aug 2026 20:17:38 -0700 Subject: [PATCH 1/3] a11y(board): add an opt-in polite live region so a message change is announced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scripts/ci/tests/board-live-region.test.mjs | 264 ++++++++++++++++++ .../board/board-display.stories.tsx | 19 +- src/components/board/board-display.tsx | 49 +++- 3 files changed, 329 insertions(+), 3 deletions(-) create mode 100644 scripts/ci/tests/board-live-region.test.mjs diff --git a/scripts/ci/tests/board-live-region.test.mjs b/scripts/ci/tests/board-live-region.test.mjs new file mode 100644 index 0000000..aaefa35 --- /dev/null +++ b/scripts/ci/tests/board-live-region.test.mjs @@ -0,0 +1,264 @@ +/** + * Behavioral regression test for issue #206: a live board's message change must + * be announceable, and silent by default. + * + * `BoardDisplay` exposes the board as one `role="img"` whose `aria-label` is + * recomputed 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 — so on a genuinely live board (transit times, alerts) + * a sighted user watched the board flip and a screen-reader user heard nothing. + * axe cannot see this: it inspects one static snapshot and has no rule for + * "this content updates but is not in a live region". + * + * The capability is opt-in and must stay that way. In editor and preview + * contexts the message changes on every keystroke, and a live region there + * would be intolerably chatty; only the consuming app knows whether its board + * is live. + * + * Only running the component over time can check any of this, so this mounts + * the real `BoardDisplay` (React + react-dom in jsdom), changes `message`, and + * reads the region. jsdom rather than a browser because `release:test` runs in + * CI's `automation` job, which does `npm ci` but never `npx playwright install` + * — and a live region is DOM state, not paint. Same harness shape as + * board-flap-cascade.test.mjs. + * + * What it pins down: + * 1. nothing announces by default, before or after a message change; + * 2. opted in, the region exists from the first render, is polite and atomic, + * and is visually hidden; + * 3. it is *empty* on mount — a board that has not changed has not got + * anything to say, and a region that mirrored the current text would be + * read twice on first encounter; + * 4. a message change puts the new board text in it; + * 5. a re-render with the same message announces nothing new; + * 6. the loading -> message transition announces the message; + * 7. toggling only `announceUpdates` takes effect — BoardDisplay has a custom + * `memo` comparator, and a prop missing from it is silently inert. + */ + +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { after, before, test } from "node:test"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { build } from "esbuild"; +import { JSDOM } from "jsdom"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const boardDir = path.resolve(here, "../../../src/components/board"); + +const HARNESS = ` + import { createElement } from "react"; + import { createRoot } from "react-dom/client"; + import { BoardDisplay } from "./board-display"; + + export function mount(container) { + const root = createRoot(container); + return { + render: (props) => root.render(createElement(BoardDisplay, props)), + unmount: () => root.unmount(), + }; + } +`; + +const FIRST = "FIRST MESSAGE"; +const SECOND = "BUS 33 IN 2 MIN"; + +let tmp; +let bundleUrl; +let runCounter = 0; + +before(async () => { + tmp = await mkdtemp(path.join(tmpdir(), "board-live-region-")); + const outfile = path.join(tmp, "harness.mjs"); + await build({ + stdin: { contents: HARNESS, resolveDir: boardDir, sourcefile: "harness.js", loader: "js" }, + outfile, + bundle: true, + format: "esm", + platform: "browser", + jsx: "automatic", + define: { "process.env.NODE_ENV": '"development"' }, + logLevel: "silent", + }); + bundleUrl = pathToFileURL(outfile).href; +}); + +after(async () => { + if (tmp) await rm(tmp, { recursive: true, force: true }); +}); + +/** Install a jsdom window as the global environment (see board-flap-cascade). */ +function installDom() { + const dom = new JSDOM("
", { + pretendToBeVisual: true, + }); + const { window } = dom; + window.matchMedia = (query) => ({ + media: query, + matches: false, + onchange: null, + addEventListener() {}, + removeEventListener() {}, + addListener() {}, + removeListener() {}, + dispatchEvent: () => false, + }); + + const globals = [ + "window", + "document", + "navigator", + "HTMLElement", + "Element", + "Node", + "Event", + "MessageChannel", + "MessagePort", + "requestAnimationFrame", + "cancelAnimationFrame", + "getComputedStyle", + ]; + const saved = new Map(); + const put = (key, value) => { + Object.defineProperty(globalThis, key, { value, configurable: true, writable: true }); + }; + for (const key of globals) { + saved.set(key, Reflect.get(globalThis, key)); + put(key, key === "window" ? window : window[key]); + } + globalThis.IS_REACT_ACT_ENVIRONMENT = false; + + return { + window, + restore() { + for (const [key, value] of saved) put(key, value); + dom.window.close(); + }, + }; +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 20)); + +/** Everything a screen reader could pick up, as the DOM currently stands. */ +function readAria(container) { + const regions = [...container.querySelectorAll("[aria-live]")]; + const img = container.querySelector('[role="img"]'); + return { + regionCount: regions.length, + live: regions[0]?.getAttribute("aria-live") ?? null, + atomic: regions[0]?.getAttribute("aria-atomic") ?? null, + className: regions[0]?.className ?? null, + announced: regions[0]?.textContent ?? null, + name: img?.getAttribute("aria-label") ?? null, + }; +} + +/** + * Mount a board, run `steps` against it in order, and return one reading of the + * DOM after each step. `animationsEnabled: false` keeps the tiles from + * cascading: this is about what is announced, and the cascade is timed + * elsewhere (board-flap-cascade.test.mjs). + */ +async function run(steps, base = {}) { + const dom = installDom(); + try { + const harness = await import(`${bundleUrl}?run=${++runCounter}`); + const container = dom.window.document.getElementById("root"); + const root = harness.mount(container); + + const readings = []; + for (const props of steps) { + root.render({ size: "sm", deviceType: "note", animationsEnabled: false, ...base, ...props }); + await settle(); + readings.push(readAria(container)); + } + + root.unmount(); + // React schedules through MessageChannel; let unmount drain before the + // window closes, or a stray callback lands on a dead document. + await settle(); + return readings; + } finally { + dom.restore(); + } +} + +test("a board announces nothing by default, before or after a message change", async () => { + const [initial, changed] = await run([{ message: FIRST }, { message: SECOND }]); + + assert.equal( + initial.regionCount, + 0, + "a board must not mount a live region unless asked: in an editor or a thumbnail the message changes on " + + "every keystroke, and only the consuming app knows whether its board is live (issue #206)", + ); + assert.equal(changed.regionCount, 0, "a message change must not conjure a live region either"); + assert.match(changed.name, /BUS 33 IN 2 MIN/, "the role=img name must still track the message"); +}); + +test("opted in, the region is polite, atomic, visually hidden — and silent on mount", async () => { + const [initial] = await run([{ message: FIRST, announceUpdates: true }]); + + assert.equal(initial.regionCount, 1, "announceUpdates must render exactly one live region"); + assert.equal(initial.live, "polite", "a board update is informational, not urgent — polite, never assertive"); + assert.equal(initial.atomic, "true", "the whole message reads as one announcement, not word by word"); + assert.match(initial.className, /\bsr-only\b/, "the region must add no rendered pixels"); + assert.equal( + initial.announced, + "", + "a board that has not changed yet has nothing to announce; a region mirroring the current text would " + + "also be read straight after the role=img name on first encounter (issue #206)", + ); +}); + +test("a message change is announced", async () => { + const [, changed] = await run([ + { message: FIRST, announceUpdates: true }, + { message: SECOND, announceUpdates: true }, + ]); + + assert.match( + changed.announced, + /BUS 33 IN 2 MIN/, + `the live region should carry the new board text; it says ${JSON.stringify(changed.announced)} (issue #206)`, + ); + assert.equal(changed.regionCount, 1, "the region must persist across the change, or AT sees no mutation"); +}); + +test("a re-render with the same message announces nothing new", async () => { + const [, same] = await run([ + { message: FIRST, announceUpdates: true }, + { message: FIRST, announceUpdates: true }, + ]); + + assert.equal(same.announced, "", "an unchanged board must not re-announce itself"); +}); + +test("the loading -> message transition is announced", async () => { + const [, arrived] = await run([ + { message: null, isLoading: true, announceUpdates: true }, + { message: SECOND, isLoading: false, announceUpdates: true }, + ]); + + assert.match(arrived.announced, /BUS 33 IN 2 MIN/, "the message arriving after a load is the announcement"); +}); + +test("toggling only announceUpdates takes effect", async () => { + // BoardDisplay is memoized with a hand-written comparator: a prop missing + // from it is silently inert, which is exactly how this feature would ship + // broken. + const [off, on] = await run([ + { message: FIRST, announceUpdates: false }, + { message: FIRST, announceUpdates: true }, + ]); + + assert.equal(off.regionCount, 0); + assert.equal( + on.regionCount, + 1, + "turning announceUpdates on did nothing — BoardDisplay's memo comparator must compare it (issue #206)", + ); +}); diff --git a/src/components/board/board-display.stories.tsx b/src/components/board/board-display.stories.tsx index ec62b7f..67629a3 100644 --- a/src/components/board/board-display.stories.tsx +++ b/src/components/board/board-display.stories.tsx @@ -86,6 +86,11 @@ const meta = { control: false, description: "Builds the accessible label for a shown message", }, + announceUpdates: { + control: "boolean", + description: + "Announce message changes through a polite, visually hidden live region. Off by default — a changed aria-label is silent to screen readers, but announcing is only correct for a genuinely live board, not for an editor preview that changes on every keystroke (issue #206).", + }, className: { control: "text", description: "Additional CSS classes on the board bezel", @@ -339,6 +344,7 @@ export const Playground: Story = { emitCellMetadata: false, loadingLabel: "Loading board display", emptyLabel: "Empty board display", + announceUpdates: false, }, }; @@ -376,6 +382,14 @@ export const Colors = () => ( * hands-free. Honors prefers-reduced-motion: when set, the interval still * swaps messages but tiles snap instead of flipping (animationsEnabled=false), * matching how the app wires its reduce-motion setting into the board. + * + * This is also the story that shows `announceUpdates` doing its job (issue + * #206): a board that changes on its own is exactly the case where a + * screen-reader user otherwise hears nothing, because a changed `aria-label` + * on a `role="img"` is not announced. With it on, each new message is read once + * politely. It stays **off** everywhere else in this file — an editor preview + * or a thumbnail that re-announced on every keystroke would be unusable, which + * is why the capability is opt-in and the consuming app decides. */ export const CyclingMessages = () => { const cycle = [simpleMessage, weatherMessage, coloredMessage]; @@ -398,10 +412,11 @@ export const CyclingMessages = () => { return (
- +

A new message arrives every 4 seconds; each changed tile flips forward through the character set until it - reaches its target. + reaches its target. announceUpdates is on here, so each new message is also announced once, + politely, through a visually hidden live region.

); diff --git a/src/components/board/board-display.tsx b/src/components/board/board-display.tsx index 5fceff4..8269f6a 100644 --- a/src/components/board/board-display.tsx +++ b/src/components/board/board-display.tsx @@ -1232,6 +1232,18 @@ export interface BoardDisplayProps { emptyLabel?: string; /** Builds the accessible label for a shown message (color markup already stripped). */ messageLabel?: (message: string) => string; + /** Announce message changes to assistive tech through a polite live region + * (issue #206). + * + * Off by default, and it has to be: this board's `aria-label` changing is + * silent to a screen reader — only a change *inside* a live region is + * announced — but a live region is only correct for a genuinely live + * display, which only the consuming app knows. In an editor or a thumbnail + * the message changes on every keystroke and the announcements would be + * intolerable. Turn it on for a mirrored board (transit times, weather, + * alerts) where a change is news. `polite`, never `assertive`: a board + * update is informational. Same opt-in shape as `EmptyState`'s `announce`. */ + announceUpdates?: boolean; } // Module-scope default so the aria-label memo below keeps a stable dependency. @@ -1257,6 +1269,7 @@ export const BoardDisplay = memo( loadingLabel = "Loading board display", emptyLabel = "Empty board display", messageLabel = defaultMessageLabel, + announceUpdates = false, }: BoardDisplayProps) { // Reduced motion, decided here rather than left to CSS (issue #180). // @@ -1334,6 +1347,28 @@ export const BoardDisplay = memo( return text ? messageLabel(text) : NO_TEXT_LABEL; }, [message, deviceType, isLoading, loadingLabel, emptyLabel, messageLabel]); + // What the live region says, when one is asked for (issue #206). + // + // It 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 being + // announced on arrival, which is precisely the page-load chatter that made + // EmptyState's announcement opt-in too (#120). So the region renders empty + // and fills in on the first change: `announced.of` is the board text this + // component last reconciled, and the state is adjusted during render (the + // documented React pattern) rather than in an effect, so the region and the + // tiles commit in the same paint. + // + // The comparison is skipped entirely when the feature is off, which is the + // default: no extra render on the path every existing consumer is on. The + // cost of that is that switching `announceUpdates` on mid-life announces + // the board once, which is the right behaviour anyway — a consumer turning + // it on is asking to hear the board. + const [announced, setAnnounced] = useState({ text: "", of: boardText }); + if (announceUpdates && announced.of !== boardText) { + setAnnounced({ text: boardText, of: boardText }); + } + return (
{/* Split-flap keyframes — only the animated path uses them. React 19 @@ -1343,6 +1378,14 @@ export const BoardDisplay = memo( {FLAP_KEYFRAMES} )} + {/* The live region sits outside the `role="img"`, not inside it: an + image's subtree is not exposed, so a region in there would never be + read. `sr-only` — it adds no rendered pixels. */} + {announceUpdates && ( +
+ {announced.text} +
+ )} {/* No max-width on the bezel (issue #200). Its tiles are fixed-width and cannot shrink, so the grid's min-content width *is* the whole board — but a max-width caps the used width below that anyway, and @@ -1435,7 +1478,11 @@ export const BoardDisplay = memo( resolveFlapSpeed(prevProps.flapSpeed ?? "standard") === resolveFlapSpeed(nextProps.flapSpeed ?? "standard") && prevProps.loadingLabel === nextProps.loadingLabel && prevProps.emptyLabel === nextProps.emptyLabel && - prevProps.messageLabel === nextProps.messageLabel + prevProps.messageLabel === nextProps.messageLabel && + // Every prop must be listed here: one left out is silently inert, since + // this comparator — not React's shallow default — decides whether the + // board re-renders at all. + prevProps.announceUpdates === nextProps.announceUpdates ); }, ); From 884df58722ee005724c662e276cc374b4ab58036 Mon Sep 17 00:00:00 2001 From: Jeffrey Johnson Date: Tue, 11 Aug 2026 20:27:29 -0700 Subject: [PATCH 2/3] a11y(board): keep the loading label out of the live region, and the caption out of VRT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scripts/ci/tests/board-live-region.test.mjs | 31 +++++++++++++++++++ .../board/board-display.stories.tsx | 7 +++-- src/components/board/board-display.tsx | 12 ++++++- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/scripts/ci/tests/board-live-region.test.mjs b/scripts/ci/tests/board-live-region.test.mjs index aaefa35..f3289e1 100644 --- a/scripts/ci/tests/board-live-region.test.mjs +++ b/scripts/ci/tests/board-live-region.test.mjs @@ -246,6 +246,37 @@ test("the loading -> message transition is announced", async () => { assert.match(arrived.announced, /BUS 33 IN 2 MIN/, "the message arriving after a load is the announcement"); }); +test("a refresh does not announce the loading label, only the message that follows", async () => { + // The path a live board actually takes: it has a message, refetches, and gets + // a new one. `loadingLabel` is an internal, transient state — announcing + // "Loading board display" mid-cycle tells a user reading bus times nothing + // about buses, and it doubles the announcements per refresh. + const [, loading, refreshed] = await run([ + { message: FIRST, announceUpdates: true }, + { message: FIRST, isLoading: true, announceUpdates: true }, + { message: SECOND, isLoading: false, announceUpdates: true }, + ]); + + assert.equal( + loading.announced, + "", + `a refresh announced ${JSON.stringify(loading.announced)}; the loading label must never reach the region`, + ); + assert.match(refreshed.announced, /BUS 33 IN 2 MIN/, "the message that ends the refresh is what gets announced"); +}); + +test("a board going empty is announced — that is a content change, not a phase", async () => { + // Deliberately unlike the loading label: a board that clears has *changed*, + // and silence would leave a screen-reader user believing the old message + // still stands. `emptyLabel` is a consumer-supplied, localizable string. + const [, cleared] = await run([ + { message: FIRST, announceUpdates: true }, + { message: null, announceUpdates: true, emptyLabel: "The board is now empty" }, + ]); + + assert.equal(cleared.announced, "The board is now empty"); +}); + test("toggling only announceUpdates takes effect", async () => { // BoardDisplay is memoized with a hand-written comparator: a prop missing // from it is silently inert, which is exactly how this feature would ship diff --git a/src/components/board/board-display.stories.tsx b/src/components/board/board-display.stories.tsx index 67629a3..b19348b 100644 --- a/src/components/board/board-display.stories.tsx +++ b/src/components/board/board-display.stories.tsx @@ -413,10 +413,13 @@ export const CyclingMessages = () => { return (
+ {/* Caption text left exactly as it was: it is rendered, so a sentence + about `announceUpdates` here would rewrite four VRT baselines to + document a feature that paints nothing. The docstring above says it + instead, and lands in autodocs rather than in the shot. */}

A new message arrives every 4 seconds; each changed tile flips forward through the character set until it - reaches its target. announceUpdates is on here, so each new message is also announced once, - politely, through a visually hidden live region. + reaches its target.

); diff --git a/src/components/board/board-display.tsx b/src/components/board/board-display.tsx index 8269f6a..a3078e3 100644 --- a/src/components/board/board-display.tsx +++ b/src/components/board/board-display.tsx @@ -1364,8 +1364,18 @@ export const BoardDisplay = memo( // cost of that is that switching `announceUpdates` on mid-life announces // the board once, which is the right behaviour anyway — a consumer turning // it on is asking to hear the board. + // + // `isLoading` is excluded, and that is the point of the guard rather than a + // shortcut: `boardText` resolves to `loadingLabel` while a board refetches, + // so a live board that refreshes in place would otherwise announce + // "Loading board display" and then the message — twice the speech, half of + // it about an internal phase rather than about buses. Skipping it also + // leaves `announced.of` holding the pre-refresh text, so the message that + // ends the refresh is still correctly seen as a change. A board going + // *empty* is deliberately not skipped: that is a content change, and + // silence would leave the user believing the old message still stands. const [announced, setAnnounced] = useState({ text: "", of: boardText }); - if (announceUpdates && announced.of !== boardText) { + if (announceUpdates && !isLoading && announced.of !== boardText) { setAnnounced({ text: boardText, of: boardText }); } From 22a7caae9e78299c23d18b61e1a161d0444f506d Mon Sep 17 00:00:00 2001 From: Jeffrey Johnson Date: Tue, 11 Aug 2026 20:38:00 -0700 Subject: [PATCH 3/3] =?UTF-8?q?a11y(board):=20never=20announce=20on=20arri?= =?UTF-8?q?val=20=E2=80=94=20arm=20the=20region=20empty,=20speak=20on=20th?= =?UTF-8?q?e=20next=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- scripts/ci/tests/board-live-region.test.mjs | 25 +++++++++++++++++++++ src/components/board/board-display.tsx | 24 ++++++++++++++------ 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/scripts/ci/tests/board-live-region.test.mjs b/scripts/ci/tests/board-live-region.test.mjs index f3289e1..a13572c 100644 --- a/scripts/ci/tests/board-live-region.test.mjs +++ b/scripts/ci/tests/board-live-region.test.mjs @@ -277,6 +277,31 @@ test("a board going empty is announced — that is a content change, not a phase assert.equal(cleared.announced, "The board is now empty"); }); +test("turning announceUpdates on mid-life mounts the region empty, and the next change announces", async () => { + // A region inserted already holding content is not reliably announced — AT + // announces *mutations inside* a region that was already there. A consumer + // that decides its board is live only after the first fetch would otherwise + // get an announcement that may or may not be spoken, depending on the + // screen reader. So arriving is never an announcement; the next real change + // is, and that one is a mutation the region is present for. + const [, changedWhileOff, justEnabled, changedAfter] = await run([ + { message: FIRST, announceUpdates: false }, + { message: SECOND, announceUpdates: false }, + { message: SECOND, announceUpdates: true }, + { message: "TRAIN 4 IN 6 MIN", announceUpdates: true }, + ]); + + assert.equal(changedWhileOff.regionCount, 0); + assert.equal(justEnabled.regionCount, 1, "enabling the prop must mount the region"); + assert.equal( + justEnabled.announced, + "", + `enabling the prop announced ${JSON.stringify(justEnabled.announced)} in the same commit that added the ` + + "region — AT cannot be relied on to speak that (issue #206)", + ); + assert.match(changedAfter.announced, /TRAIN 4 IN 6 MIN/, "the first change after enabling must be announced"); +}); + test("toggling only announceUpdates takes effect", async () => { // BoardDisplay is memoized with a hand-written comparator: a prop missing // from it is silently inert, which is exactly how this feature would ship diff --git a/src/components/board/board-display.tsx b/src/components/board/board-display.tsx index a3078e3..5aa0c1d 100644 --- a/src/components/board/board-display.tsx +++ b/src/components/board/board-display.tsx @@ -1360,10 +1360,16 @@ export const BoardDisplay = memo( // tiles commit in the same paint. // // The comparison is skipped entirely when the feature is off, which is the - // default: no extra render on the path every existing consumer is on. The - // cost of that is that switching `announceUpdates` on mid-life announces - // the board once, which is the right behaviour anyway — a consumer turning - // it on is asking to hear the board. + // default: no extra render on the path every existing consumer is on. + // + // `armed` is what makes arriving never an announcement. 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. Without + // this, a consumer that flips `announceUpdates` on after its first fetch + // would add the region and its text in the same commit and get an + // announcement that some screen readers speak and others drop. So the + // render where the region arrives only arms it — the region lands empty, + // and the next real change is a mutation it is present for. // // `isLoading` is excluded, and that is the point of the guard rather than a // shortcut: `boardText` resolves to `loadingLabel` while a board refetches, @@ -1374,9 +1380,13 @@ export const BoardDisplay = memo( // ends the refresh is still correctly seen as a change. A board going // *empty* is deliberately not skipped: that is a content change, and // silence would leave the user believing the old message still stands. - const [announced, setAnnounced] = useState({ text: "", of: boardText }); - if (announceUpdates && !isLoading && announced.of !== boardText) { - setAnnounced({ text: boardText, of: boardText }); + const [announced, setAnnounced] = useState(() => ({ text: "", of: boardText, armed: announceUpdates })); + if (announceUpdates && !announced.armed) { + // The region is arriving in this commit. Arm it, empty, and resync `of` + // so a change that happened while the feature was off is not replayed. + setAnnounced({ text: "", of: boardText, armed: true }); + } else if (announceUpdates && !isLoading && announced.of !== boardText) { + setAnnounced({ text: boardText, of: boardText, armed: true }); } return (