Repository navigation
PWA/shell: verify the inert-swallowed announcements with a real screen reader #501
Description
Activity
Automated half landed — the AT pass is what remains
PR #504 adds
tools/simulation/ui/specs/a11y-live-regions.spec.tsplus
docs/runbooks/screen-reader-verification.md. This issue stays open: the
spec covers the preconditions, not the utterance.What the automated spec now proves, in real Chromium
- Both offscreen announcers are mounted, empty, and in the accessibility tree
on a healthy screen — and Chromium agrees they are live regions
(live=assertive/polite,atomic=true) with no live role. - Opening a dialog removes them from the accessibility tree entirely, while
they stay in the DOM with their attributes intact — PWA: the update banner's announcement is swallowed while a dialog is open #485's premise, until
now only inferred from fix(web): one page, one scroll lock and one live dialog (#482) #483's source. Control: the dialog's own controls
stay in the tree, so an absence is not an instrument failure. - Closing the dialog returns them.
- A standing farm warning is carried by the visible
role="alert"banner with
the offscreen announcer empty, and stays that way across two dialog
open/close cycles — the anti-nag rule.
Mutation-checked with the harness's first two DOM-level mutants
(a11y-inert-sweep-removed,a11y-announcer-duplicates-banner); each turns the
covering test red.Two of this issue's open questions are now settled — by the browser, not by an AT
aria-live="off"does suppress the implicit politeness ofrole="alert"
in Chromium. A plainrole="alert"reportslive=assertive; the same
element witharia-live="off"reports no live property at all. (Careful: a
non-live element also reports none, so the pair is the evidence — the first
version of this assertion would have passed even if the attribute were being
ignored.)- Un-inerting one subtree while a dialog is open does return it to the
accessibility tree, so the inert-exemption design is mechanically viable.
Both are recorded as assertions, so a future Chromium change says so.
Still not covered by anything automated
- Whether any assistive technology speaks. Tree presence is the
precondition, not the utterance. - The missed-then-delivered path — the core of fix(web): announce the update and farm warnings a dialog made inert #499's fix. No product
affordance makes a message arrive while a dialog is open: the update banner
needs a second service worker to park inwaiting(Playwright cannot provoke
it), and the farm warning needs/accountto fail on a re-read, whose only
two callers are the banner's own retry and the settings save — neither of
which runs with a dialog open. A test-only trigger was considered and
rejected.
The manual pass
Procedure:
docs/runbooks/screen-reader-verification.md. Paste results here.# Scenario AT + browser Result Speech heard 1 Update banner, no dialog 2 Update banner while a dialog is open 3 As 2, two dialogs stacked 4 Farm warning while a dialog is open 5 Standing banner, repeated dialog cycles 6 Same-commit raise (open, and close) Scenario 6 has no expected answer by design — #499 errs toward a duplicate over
silence there, and this measures which actually happens.A row nobody ran is not run, never blank.
- Both offscreen announcers are mounted, empty, and in the accessibility tree
- added 8 commits that reference this issue
on Aug 10, 2026 - addedpriority:tier3Real product weight, real costReal product weight, real cost
on Aug 31, 2026 - addedblockedWaiting on another issue or an unbuilt surfaceWaiting on another issue or an unbuilt surface
on Sep 13, 2026 Reviewed in the 2026-09-13 issue cleanup. Kept open, now labelled
blocked.The blocker is not code and not another issue — it is a human with NVDA, JAWS or VoiceOver. No agent can do this, and the repo has no route to it, so the issue has sat unscheduled for a month while reading as available work. The label fixes that.
What is already covered: the machine-verifiable half shipped in #504 — a Playwright test driving CDP (
src/ax.ts) proves in a real browser that an open dialog hides the live regions, because Playwright's own APIs do not modelinert(#501/#277). That establishes the DOM and accessibility-tree state.What is not covered, and should not be claimed: whether a screen-reader user actually misses the announcement. The accessibility tree saying a region is hidden is strong evidence, not the observation itself — different screen readers handle
aria-liveinsideinertsubtrees differently, and that difference is exactly what this issue exists to check.So: no accessibility claim beyond #504's should be made on this basis. Unblocks the moment someone with assistive technology is available for one session.
- added a commit that references this issue
on Sep 13, 2026 Handoff — #501, verifying the inert-swallowed announcements with a real screen reader
Written 2026-09-13 for a host that has
adband an Android phone. Everything below is read from the source or measured against the running sim stack. Nothing here is started.The AT pass itself is the whole deliverable. A human listens; no test in this repo can.
Three blockers, two of which are not in the issue body
1 — On plain HTTP, five of the six scenarios cannot fire at all.
registerServiceWorker.tsgates onisSecureContext. Off HTTPS-or-localhost,navigator.serviceWorkeris undefined, the function no-ops, the activator never arrives, and the update banner can never appear. The source says so directly: "Barn phones currently reach this app over plain http, where the property is simply undefined."A phone pointed at
http://<desktop-lan-ip>:8081therefore gets nothing, and it looks like a broken feature rather than an untestable setup. Do not spend time debugging that.2 — There is no way to fire the update banner on demand. It appears only when a genuinely new service worker is
waiting. Several scenarios need it fired at an exact moment relative to a dialog opening. Rebuilding and redeploying between each is not workable by hand, so a temporary trigger has to be built first (below).3 —
scenario 4is the exception and the cheapest real answer. The farm-load warning (AppLayout.tsx:43-46) is independent of the service worker. It runs over plain HTTP on any device and still exercises the sameuseMissedAnnouncementinference the issue exists to settle. If time is short, do scenario 4 first.The setup that removes blocker 1
adb reversemakes the phone reach the desktop's stack as its own localhost, which is a secure context. No tunnel, no certificate, nounsafely-treat-insecure-origin-as-secureflag.# desktop: the prod-like stack that actually ships a service worker # (the Vite dev server emits none — registerServiceWorker no-ops there) # confirm before continuing: curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8081/sw.js # must be 200 adb devices # phone listed, USB debugging on adb reverse tcp:8081 tcp:8081
On the phone, open
http://localhost:8081— not the LAN IP. Verify the secure context before testing anything: inchrome://inspectDevTools on the desktop,isSecureContextmust betrueandnavigator.serviceWorkermust exist. If either is false, stop — every update-banner result after that point is meaningless.Remote DevTools via
chrome://inspectalso means the trigger below can be a console call rather than a button added to the UI.The temporary trigger (build this first, never merge it)
UpdatePromptholds the activator in state and its presence is the "update ready" condition. Expose a way to set it, gated behind a build-time flag so it cannot reach a real build by accident:// UpdatePrompt.tsx, inside the existing useEffect — TEMPORARY, do not commit if (import.meta.env.VITE_A11Y_PROBE === "1") { (window as Window & { __cwFireUpdate?: () => void }).__cwFireUpdate = () => { setActivate(() => async () => {}); setDismissed(false); }; }
Build the image with
VITE_A11Y_PROBE=1, serve it as the sim stack, and fire each scenario with__cwFireUpdate()from the remote console. Use a throwaway branch; this is a probe, not a feature.The six scenarios
TalkBack on, phone at
http://localhost:8081, signed in as any cast persona. Any modal will do for "a dialog" — Sales → New order is the easiest.# Do this Should hear 1 No dialog open → __cwFireUpdate()Announced once, not twice 2 Open a dialog → __cwFireUpdate()→ close the dialogSilence while open, then announced on close. This is the actual bug #485 fixed. 3 Open two dialogs → __cwFireUpdate()→ close bothAnnounced only after the last one closes 4 Open a dialog → block /api/v1/accountin DevTools (or stop the API) → trigger a refresh → close the dialogSame pattern as 2, for the farm warning. Needs no service worker. 5 Leave the banner standing → open and close dialogs repeatedly Not re-announced each time (the anti-nag rule) 6 Fire the message in the same commit that opens a dialog; then again in the one that closes the last Record whether you hear a duplicate or silence — #499 deliberately errs toward duplicate. Confirm which actually happens. Scenario 6 is the awkward one to stage by hand. Getting 1–5 is a complete, reportable result; say so rather than guessing at 6.
What the result has to decide
Not just pass/fail.
useMissedAnnouncementinfers whether the visible banner already announced, from whether the page was inert when the message appeared. Five of PR #499's six review rounds each found a real defect in the previous round's version of that inference — which argues the shape is wrong, not the details.Two alternatives were deferred, and both delete the inference rather than sharpen it:
- Exempt the offscreen region from the inert sweep. PWA: the update banner's announcement is swallowed while a dialog is open #485 rejected
data-modal-exemptbecause a reachable control outside the modal defeats containment — but this region contains no controls, so that objection does not apply. It would never be inert, would announce immediately, and every same-commit race disappears. - Make the offscreen region the only announcer, with the visible banners keeping
role="alert"for the E2E vocabulary but carryingaria-live="off"so they never speak. Rests onaria-live="off"reliably suppressing an implicit live role — worth confirming with the AT while you are there.
Report which of the three the observed behaviour favours. That is the decision this pass exists to unblock.
Constraints that must survive
- The visible banners keep
role="alert"/role="status". ~20 error banners userole="alert"and the E2E suite reads its absence as "nothing has gone wrong". A permanently-mounted element holding one of those roles turns 10 Playwright assertions into tautologies. That regression shipped once and is now guarded by unit tests. .sr-onlyis a 1px box, notdisplay:none, so anything wearing it counts as visible to Playwright.
The gap this pass will NOT close, and must not be written up as closed
The issue asks for Safari/VoiceOver specifically, because the weakness that shaped the fix is a populated-on-insert WebKit behaviour. Android Chrome is Blink; Firefox for Android is Gecko; no WebKit browser exists on Android — every other browser there wraps the system Blink WebView.
So a TalkBack pass settles the inert/live-region question on the app's real target device and real-world majority platform, and leaves the WebKit case untested. Record it as a partial result: what TalkBack showed, and that VoiceOver remains outstanding. Do not let "verified with a screen reader" stand in for "verified on the engine the bug was about".
What to leave behind
A comment on this issue with the six rows filled in (or five, honestly labelled), which design the result favours, and the WebKit gap named as still open. Then either close this issue and file the follow-up that deletes the inference, or keep it open solely for the VoiceOver half — whichever the owner prefers.
- Exempt the offscreen region from the inert sweep. PWA: the update banner's announcement is swallowed while a dialog is open #485 rejected
TalkBack pass (Android) — result, and the decision it favours
Run 2026-09-13 on a Samsung SM-F971U1, Android 17, Chrome 153, Google TalkBack (verbose log output on). Stack: the sim stack rebuilt from a throwaway branch (
probe/501-a11y-talkback, never pushed) carrying two build-time-gated hooks,__cwFireUpdate()and__cwRefreshFarm(). The phone reached the stack athttp://localhost:8081throughadb reverse, soisSecureContextwas true and the service worker path was live. Every scenario was driven over CDP from the desktop; a marker was stamped into the phone's logcat before and after each step, and TalkBack's ownActors: act() action=SPEAK text="…"lines are the evidence of what it spoke. No human ear was needed to count utterances, and the DOM state was captured beside every step.The six rows
# Scenario Heard DOM 1 Banner appears, no dialog open The sentence was never spoken. TalkBack spoke status,Reload. Button,Later. Button. Reproduced three times (S1, S5a, S6 prep).banner present, offscreen region empty 2 Banner appears while a dialog is open, then close Silent while open. On close, spoken once. offscreen polite region filled on close 3 Same under two stacked dialogs Silent under both; silent after closing the top one; spoken once after the last one closed. as expected 4 Farm re-read fails under a dialog, then close Silent while open. On close, the warning was spoken, plus Try again. Button.assertive offscreen region filled on close 5 Standing banner, dialog opened and closed three times Never re-announced. region stays empty 6 Same-commit races 6a open+fire in one task: silent (banner inert). 6c close+fire in one task: spoken once, no duplicate. as expected Row 1 is the finding. On Chrome + TalkBack the visible banner never announces its text, dialog or no dialog. The live-region event arrives (
nodeLiveRegion=1on therole="status"node) but TalkBack'sttsOutputfor it is{status}: the node has no accessible name of its own, the sentence sits in a child<span>, and TalkBack does not iterate children for a live-region change. The "ordinary path announces itself" premise thatuseMissedAnnouncementis built on is false on this engine.The three spoken "on close" events in rows 2 and 6 were all from one node and carry TalkBack's
ttsSkipDuplicateflag, so they are one utterance, not three.Which node speaks: the A/B
Same scenario 2, muting one region over CDP (
aria-live="off") before the close, no rebuild:Muted before close Sentence spoken visible banner muted 1 (the offscreen <p>spoke)offscreen <p>muted0 (the visible banner said nothing) control, nothing muted 1 The offscreen region is the only thing that has ever spoken this sentence on this device. It speaks because its text is the node's own text content, set by an effect after the element is mounted and not inert.
What this decides
Of the three shapes in the issue body:
- Keep the inference (current): loses on row 1. It only fills the offscreen region when it infers the banner missed, so on the ordinary path nothing speaks.
- Exempt the offscreen region from the inert sweep: not tested directly, but it would announce during the dialog, which row 2 says is the wrong moment, and it does nothing for row 1 unless the region also always carries the text.
- Offscreen region as the only announcer, banners keep
role="status"/role="alert"for the E2E vocabulary but never speak: this is what the device already does in practice. It needs the current "do not write while inert, write on un-inert" deferral (rows 2, 3, 6 depend on it) and needs the inference deleted so the region always carries the message.
Recommendation: the third. Keep the deferral, drop the inference. The
aria-live="off"question the issue raised about the banners is moot on TalkBack: they never spoke with it on either.Not settled here
- Scenario 3 is staged: the product has no user-reachable dialog-over-dialog today (no nested
<Dialog>, no confirm raised from inside one, checked by a scan of every route). The second dialog was the BottomNav "More" sheet opened by scripted click on an inert button. RealDialogstack at depth two, not a real user path. - VoiceOver/WebKit remains open. No WebKit browser exists on Android. This pass settles TalkBack on the app's real target device; the populated-on-insert behaviour that shaped fix(web): announce the update and farm warnings a dialog made inert #499 is a Safari one and is still untested.
- Row 4 was tested only under a dialog. Whether the visible
role="alert"farm warning speaks on the ordinary path was not measured; one of the four post-close events did come from a non-live node, which suggests it might, but that is a guess.
Artifacts: driver, logcat capture script, raw logs and per-step summaries live in a scratch dir on the desktop, not in the repo. Nothing from the probe branch is to be merged.
Closing on the TalkBack pass above.
Decided. The visible banners never announce their text on Chrome + TalkBack; the offscreen region is the only node that ever speaks the message. The design this issue was opened to settle is therefore the third option in the body: the offscreen region becomes the only announcer, the visible banners keep
role="status"/role="alert"for the E2E vocabulary but stop speaking, the "do not write while inert, write on un-inert" deferral stays, and the missed-announcement inference inuseMissedAnnouncementis deleted. That fix goes in its own issue.Not covered, deliberately. Safari/VoiceOver was not run; there is no WebKit browser on Android and no iPhone on hand. It is now a confirmation pass rather than a decision input, because the chosen design is the mount-first, write-later shape WebKit's populated-on-insert behaviour requires anyway. It belongs as an acceptance line on the fix issue, checked against the corrected code, not as a reason to hold this one open.
Nothing from the probe branch is merged; the hooks existed only to fire the banner on demand.
Follow-up to #485 (PR #499).
Why this exists
#499 fixed the announcement that #483's dialog inertness swallowed, and its tests are thorough — 14 mutants, each driven red. But they pin who holds the message and when, not that anyone hears it.
jsdom implements neither live regions nor
inert. Verified directly during that work:'inert' in elementisfalseand clicks pass straight through aninertsubtree. So no test in this repo can observe an announcement, and none of #499's green is evidence that a screen reader speaks.The verification
Against the built SPA in a real browser, with a real AT — NVDA and/or JAWS on Windows, VoiceOver on Safari, since the populated-on-insert weakness that shaped the fix is specifically a Safari/VoiceOver behaviour:
The design question this should settle
useMissedAnnouncementworks by inferring whether the visible banner already announced, from whether the page was inert when the message appeared. Five of PR #499's six review rounds were spent refining that one inference, each finding a real defect in the last round's answer — which argues the shape may be wrong, not just the details.Two alternatives were raised and deferred, both of which delete the inference rather than sharpen it:
data-modal-exemptbecause a reachable control outside the modal defeats containment and fights the focus trap — but the region contains no controls, so that objection does not apply to it. It would then never be inert, announce immediately, and every same-commit race disappears.role="alert"for the E2E vocabulary but carryingaria-live="off"so they never speak. Rests onaria-live="off"reliably suppressing an implicit live role, which is itself worth confirming with an AT.Both trade the current untestable assumption for a different one, which is why neither was taken blind. The manual pass above is what should decide it.
Constraints to respect
role="alert"/role="status". ~20 error banners across the app userole="alert", and the E2E suite reads its absence as "nothing has gone wrong" — a permanently-mounted element holding one of those roles turns 10 Playwright assertions into tautologies. That regression shipped once in fix(web): announce the update and farm warnings a dialog made inert #499 and was caught by CI, and is now guarded by unit tests..sr-onlyis a 1px box, notdisplay:none, so anything wearing it counts as visible to Playwright.