Skip to content

PWA/shell: verify the inert-swallowed announcements with a real screen reader #501

Description

@mforce

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 element is false and clicks pass straight through an inert subtree. 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:

  1. Update banner appears with no dialog open → announced once, not twice.
  2. Update banner appears while a dialog is open → silent until the dialog closes, then announced. (This is PWA: the update banner's announcement is swallowed while a dialog is open #485's actual bug.)
  3. Same, with two dialogs — announced only after the last one closes.
  4. Farm-load warning fails while a dialog is open → same pattern.
  5. A standing banner while dialogs open and close repeatedly → not re-announced each time (the anti-nag rule).
  6. Message raised in the same commit that opens a dialog, and again in the one that closes the last dialog. fix(web): announce the update and farm warnings a dialog made inert #499 errs toward a possible duplicate here rather than risking silence; confirm which actually happens.

The design question this should settle

useMissedAnnouncement works 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:

  • 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-exempt because 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.
  • Let the offscreen region be the only announcer, with the visible banners keeping role="alert" for the E2E vocabulary but carrying aria-live="off" so they never speak. Rests on aria-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

  • The visible banners must keep role="alert" / role="status". ~20 error banners across the app use role="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-only is a 1px box, not display:none, so anything wearing it counts as visible to Playwright.

Activity

  1. mforce commented on Aug 10, 2026

    @mforce
    OwnerAuthor

    Automated half landed — the AT pass is what remains

    PR #504 adds tools/simulation/ui/specs/a11y-live-regions.spec.ts plus
    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

    1. 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.
    2. 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.
    3. Closing the dialog returns them.
    4. 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 of role="alert"
      in Chromium.
      A plain role="alert" reports live=assertive; the same
      element with aria-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 in waiting (Playwright cannot provoke
      it), and the farm warning needs /account to 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.

  2. added
    blockedWaiting on another issue or an unbuilt surface
    on Sep 13, 2026
  3. mforce commented on Sep 13, 2026

    @mforce
    OwnerAuthor

    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 model inert (#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-live inside inert subtrees 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.

  4. mforce commented on Sep 13, 2026

    @mforce
    OwnerAuthor

    Handoff — #501, verifying the inert-swallowed announcements with a real screen reader

    Written 2026-09-13 for a host that has adb and 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.ts gates on isSecureContext. Off HTTPS-or-localhost, navigator.serviceWorker is 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>:8081 therefore 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 4 is 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 same useMissedAnnouncement inference the issue exists to settle. If time is short, do scenario 4 first.

    The setup that removes blocker 1

    adb reverse makes the phone reach the desktop's stack as its own localhost, which is a secure context. No tunnel, no certificate, no unsafely-treat-insecure-origin-as-secure flag.

    # 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: in chrome://inspect DevTools on the desktop, isSecureContext must be true and navigator.serviceWorker must exist. If either is false, stop — every update-banner result after that point is meaningless.

    Remote DevTools via chrome://inspect also 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)

    UpdatePrompt holds 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 dialog Silence while open, then announced on close. This is the actual bug #485 fixed.
    3 Open two dialogs → __cwFireUpdate() → close both Announced only after the last one closes
    4 Open a dialog → block /api/v1/account in DevTools (or stop the API) → trigger a refresh → close the dialog Same 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. useMissedAnnouncement infers 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-exempt because 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 carrying aria-live="off" so they never speak. Rests on aria-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 use role="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-only is a 1px box, not display: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.

  5. mforce commented on Sep 13, 2026

    @mforce
    OwnerAuthor

    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 at http://localhost:8081 through adb reverse, so isSecureContext was 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 own Actors: 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=1 on the role="status" node) but TalkBack's ttsOutput for 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 that useMissedAnnouncement is 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 ttsSkipDuplicate flag, 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> muted 0 (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. Real Dialog stack 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.

  6. mforce commented on Sep 13, 2026

    @mforce
    OwnerAuthor

    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 in useMissedAnnouncement is 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:frontendReact/Vite web clientblockedWaiting on another issue or an unbuilt surfacepriority:tier3Real product weight, real cost

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions