Skip to content

Below lg, notices queue: the newest on screen, "+N" opens the rest - #986

Merged
NewWorldVenture merged 3 commits into
mainfrom
claude/mobile-toast-queue-etzqg7
Oct 10, 2026
Merged

NewWorldVenture merged 3 commits into
mainfrom
claude/mobile-toast-queue-etzqg7

Conversation

@NewWorldVenture

@NewWorldVenture NewWorldVenture commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

This implements the mobile compact notification queue (A11Y-001) from plan 5972218163, approved on #778 in 5972876311. It is a separate draft from main 36a516d. Nothing is merged or deployed, and there are no production, security or config changes.

The defect

Below lg, every notice showed at once. At 360px, three notices stacked over the page covered the page's own controls:

  • all of "Add First Contact", centre included;
  • with the contact dialog open: Friend, Other and Phone.

The change

Below lg ((max-width: 1023px)):

  • One card is on screen: the newest notice not yet dismissed.
  • A +N button above it (accessible name "2 more notices", aria-expanded) opens every card.

From lg: nothing changes.

Behaviour Below lg
Clocks Only the card on screen counts down. A queued notice has no clock, and gets its whole window when it shows.
Hold Hover or focus on the stack holds it. An opened list holds every notice until pointer and focus have both left it. A notice that arrives while the stack is held waits.
Keyboard +N takes focus; Enter opens the list; each Dismiss/Undo takes focus in turn. Escape closes the list and returns focus to +N.
Crossing lg Either way, every notice is kept and none is doubled.
Focus A notice whose control holds focus stays on screen even when it is not the newest (a notice arriving, or crossing below lg), so focus is never left on a hidden control. +N counts what is hidden.
Focus taken with a removed control When a focused Dismiss, Undo or +N is removed, focus goes to the notice that took its place (else the newest on screen). With none left, it goes back to where it came from and the hold is released. A removed control fires no blur, so before this the stack stayed held (reproduced on 7fba590).
Escape Closing the +N list consumes the Escape, so a dialog underneath stays open. With the list closed, Escape passes.
Hold (below lg, or list open) Held while either the pointer or focus is in the stack. From lg, either leaving resumes, as before this PR.

One deviation from the approved plan: how notices are announced

The plan used two hidden live regions written on push, with the visible card dropping its role.

What I built instead (a proposal): each notice stays one live element from the moment it is pushed. A queued notice is visually hidden (sr-only, data-queued) and removed from the tab order (tabIndex=-1), but not from the accessibility tree. Coming on screen later only changes its class.

Why: the E2E run of the plan's version found every message in the accessibility tree twice: once as the hidden copy, once as the card.

Not verified: what a screen reader announces from this markup, and whether it can act on a queued Undo. Element and accessibility-tree counts do not establish either, so neither is claimed. This is a material choice and yours to accept; if you prefer the plan's two hidden live regions, I can switch back.

Files

File Change
components/ui/toast.tsx The queue, as above. LIFETIME, ToastApi, tones, roles, haptics and desktop placement are unchanged.
lib/i18n/messages/*.json (7) toast.more.one / toast.more.other.
tests/toast-mobile-queue.test.ts New. 20 unit tests: clocks, hold (both modalities), +N, Escape, crossing lg, focus kept on screen, one live element per notice. These are element and accessibility-tree checks, not announcement evidence.
tests/a-toast-you-can-still-reach-waits.test.ts Its React mock gains useEffect. Assertions unchanged.
tests/e2e/form-error-semantics.spec.ts New queue cases at 390. The collapsed 360/390 stacked rows are re-measured. The old stacked rows are kept as "+2 opened" rows.
tests/e2e/{bill-schedule-repair,messaging-preserve-access,quick-capture-task,recurring-bill-payment}.spec.ts Outside the claimed paths. These fixtures mount the toast provider. tests/medications-fixture-module-graph.test.ts requires them to load lib/hooks/use-media-query.ts and mock usePlural. One line each, nothing else.
tests/e2e/weekly-meal-planner.spec.ts Outside the claimed paths. Its realToast fixture's locale-provider mock gains usePlural, one line. Its absence was CI run 38024675477's failure at 04dbd25.

Measured (Chromium, local production build, local Supabase)

Every pointer-intercepting surface is measured: the visible cards and +N (data-toast-more). Queued cards take no pointer and are not counted.

Case Before (stack, #778, cards) After (queue, collapsed, cards + +N)
360x780, three notices: what they cover Add First Contact, Change language Change language (as one short notice does)
360x780, three notices: "Add First Contact" whole 184x44 box, centre taken 0x0 overlap, centre clear
390x844, three notices Change language nothing
Contact dialog open, 360 Friend, Other, Phone (all taken) Phone (its centre taken by the +2 above the card)
Contact dialog open, 390 Alternate phone, Phone Alternate phone (taken), Phone (box only, centre clear)

With +2 opened (CORNER_EXPANDED, UNDER_NOTICE_EXPANDED, DIALOG_OPEN_EXPANDED, now each exercised, the dialog rows with the dialog open), the cards are where the old stack was and +2 sits above them:

  • At 360, Add First Contact is still wholly covered.
  • At 390, +2 (42×32) crosses the top 21px of Add First Contact; its centre stays clear.

Opening the list is the reader's choice, and those rows are what it costs. The replaced-placement control at 390 with +2 opened also gains Add First Contact. That is recorded as BEFORE_OPENED, because #778's BEFORE rows counted cards only.

One width differs locally. The "whole button" check compares the overlap with the button's own box rather than a fixed number: locally the button measures 181px wide, against 184px on #778.

Validation

Run Result
form-error-semantics.spec.ts, both halves (7fba590) 73/74. The one failure is the existing 360x780 long (single notice) row, off by 4px in width (225 vs 229). It fails identically on unchanged main (36a516d) built and run here (see #985). A single notice renders exactly as before in this branch. The new queue, focus and dialog cases also passed 60/60 with --repeat-each=3.
The five fixture specs above, including weekly-meal-planner all pass (the meal planner 68/68)
Unit: queue, guard, module graph 81/81 (20 + 8 + 53). The 4 behaviour tests added in 7fba590 (focus × 2, Escape, hold) fail on 04dbd25's toast.tsx.
Focus taken with a removed control (b6c551f), E2E, keyboard only Three cases: a focused Dismiss (three times, then back to origin, then a new notice drains); a focused +N resized away; a focused Undo. On 7fba590 the +N and Undo cases leave the stack held. All pass here: 9/9 with --repeat-each=3.
Full unit suite 30,373 passed. Of the 10 failures, the 7 module-graph ones were fixed and re-run (53/53). The other 3 come from the local Node 22: node-version-is-pinned and two stream-cancellation-runtime tests.
tsc --noEmit 0
eslint on changed files 0
3-way merge with #985 (667ac5e, the other form-error-semantics edit), #984 (8ec73c4), #981 and #982 heads clean

All of the above is Chromium coverage only. Safari and Firefox were not run.

Open choice

The +N button's visible text is +2; its accessible name is "2 more notices", treating "+" as a symbol (WCAG 2.5.3). If you want the name to contain "+2" verbatim, it is a one-key change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TRY21ZKsFrfB3qtoP972A4


Generated by Claude Code

A11Y-001, the compact queue approved on #778. Three notices stacked over a
phone's page covered its own controls: at 360 px all of "Add First
Contact", centre included, and in the contact dialog Friend, Other and
Phone. Below lg (max-width: 1023px) the provider now shows one card, the
newest not yet dismissed, and a "+N" button above it ("2 more notices",
aria-expanded) that opens every card. From lg nothing changes.

- Clocks: only the card on screen counts down; a queued notice has no
  clock and gets its whole window when it shows; hover or focus on the
  stack holds it, and an opened list holds every notice until pointer
  and focus have both left it. Escape closes the list and returns focus
  to "+N".
- One element per notice: a queued notice keeps its live region and its
  actions in the accessibility tree, visually hidden (sr-only) and out
  of the tab order. It is announced once, when pushed, and not again
  when it comes on screen; a screen reader can still reach a queued
  Undo.
- Crossing lg either way keeps every notice and doubles none.

Measured (Chromium, local production build): collapsed at 360x780 the
queue leaves "Add First Contact" clear (0x0 overlap) and covers the
language bar as one short notice does; at 390x844 it covers nothing.
With the contact dialog open, the "+2" above the card takes Phone's
centre at 360 (Alternate phone at 390, as one notice did). The stack's
old rows are kept as the "+2 opened" rows: what that choice costs.

toast.more plural in the seven locales; the four fixture specs that
mount the toast provide the media-query hook and usePlural.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRY21ZKsFrfB3qtoP972A4
@vercel

vercel Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
bubaly Ignored Ignored Oct 10, 2026 9:16am UTC

Request Review

Copy link
Copy Markdown
Owner Author

TOAST-QUEUE-FOCUS-BOUNDARY at04dbd258: bounded source/test revision before accessibility acceptance.

  1. Preserve visible focus when a new notice arrives or desktop changes to mobile while an older Undo/Dismiss is focused; that control currently becomes sr-only/tabIndex=-1 without recovery.
  2. Consume queue-handled Escape so collapsing it does not also close the underlying dialog's document Escape handler. Test with the contact dialog still open, not after closing it.
  3. Keep collapsed-card timers held while either focus or hover remains; leaving one modality must not resume while the other is still inside.
  4. Measure all pointer-intercepting queue surfaces, including +N, in overlap/hit tests. DIALOG_OPEN_EXPANDED is currently declared but unused; exercise actual expanded-dialog geometry and correct that coverage claim.
    The per-notice live-region approach remains a proposal: element/AX-tree counts do not establish once-only announcements or screen-reader actionability. Keep those claims unverified rather than treating historical Contacts and paperwork forms: what a failed submit exposes (A11Y-001); contact focus and paperwork composer refusal, blank paste, focus and draft fixed #778 references as new acceptance. These source/test fixes can proceed within the audit without a user architecture decision. Coordinate form-error-semantics.spec.ts with The assistant composer has room from lg: two columns with the context below, three from 2xl #985 and locale files with Authorization and child safety: family membership, child XP, Guardian numbers, gift links, wallets, guests, stored files, reward requests, votes and chores (held 0495–0502) #981/Production schema fallbacks: messaging, AI usage, provider sync, calendar feeds and bills work before their migrations #982/A child's proof photo goes straight to Storage; the action gets the path (1 MB server-action limit) #984 before composing changes; preserve their tests. No merge/deploy or production changes.

Copy link
Copy Markdown
Owner Author

Current-head CI blocker at04dbd258: CI38024675477 E2E job114132866087 failed at smoke step20; Core/DB/Mobile passed. Cause is not established from step metadata, so do not equate it with the earlier local geometry failure without matching evidence. Add this exact-run diagnosis to the existing toast revision: identify the failing assertion/artifact, reproduce with an isolated fixture, and repair without removing acceptance assertions or weakening the new focus/dialog cases6093866729. Preserve failed/skipped phases and exact commit/run evidence; no production action or merge requested.

Review of 04dbd25 on #986, and its CI blocker.

CI run 38024675477 (job 114132866087): two weekly-meal-planner cases
failed their page-error check with "usePlural is not a function". With
realToast that fixture mounts the real toast, and its locale-provider
mock had no usePlural, which the "+N" label now calls; the module-graph
test that caught four other fixtures does not cover it. Reproduced alone
on 04dbd25, fixed by the same one-line mock the others use: 68/68.

- A notice whose control holds focus stays on screen even when it is not
  the newest: a notice arriving, or the window crossing below lg, no
  longer leaves focus on a visually hidden, untabbable button. "+N"
  counts what is hidden.
- The Escape that closes the "+N" list stops there (stopPropagation and
  stopImmediatePropagation), so the contact dialog's document listener
  underneath does not also close; with the list closed Escape passes.
- Below lg, and with the list open, the stack stays held while either the
  pointer or focus is in it. From lg, either leaving resumes, as before.
- "+N" is a measured surface (data-toast-more): corner, dialog,
  under-notice and controls-under reports count it beside the visible
  cards, and no longer count queued cards. Re-measured: at 390 the
  collapsed dialog row also covers Phone (centre clear); with "+2" opened
  "+N" crosses the top 21px of Add First Contact (centre clear), and the
  replaced-placement control gains it (BEFORE_OPENED). The 360 rows hold.
- DIALOG_OPEN_EXPANDED is exercised: dialog open, "+2" opened, at 360
  and 390. Escape is tested with the dialog still open.
- The queue's E2E cases run with service workers blocked, as the corner
  cases do: once, public/sw.js let a refused contact write through.
- Announcement wording: one live element per notice from its push; what
  a screen reader announces is stated as unverified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRY21ZKsFrfB3qtoP972A4

Copy link
Copy Markdown
Owner Author

Re 6093866729 and 6094147200, both addressed in 7fba590.

CI blocker at 04dbd25: run 38024675477, job 114132866087

Failing assertion. From the job log, not the step metadata: 2 failed, 8 skipped, 2,562 passed. Both failures are in tests/e2e/weekly-meal-planner.spec.ts:

  • :746 "acknowledges a hovered save notification before choosing the next dinner";
  • :769 "dismisses a hovered save notification after reopening a dinner…".

Each fails its afterEach page-error check with Uncaught TypeError: (0 , locale_provider_1.usePlural) is not a function, and then never shows its h1.

Cause. With realToast, that fixture mounts the real components/ui/toast.tsx. Its locale-provider mock had no usePlural, which the queue's +N label now calls. The module-graph test that caught the same gap in four other fixtures does not cover this spec. This is not the 360px geometry row.

Reproduced in isolation. I ran that spec alone on 04dbd25's toast with the old mock: both tests fail with the same error. Then I added usePlural to the mock (one line, the same shape as the other four fixtures): 68/68. No assertion was removed or loosened.

The four review points

  1. Focus is never left on a hidden control. A notice whose control holds focus stays on screen even when it is not the newest. This covers a newer notice arriving and the window crossing below lg. +N counts only what is hidden.

    • Unit: a notice arrives under a focused Dismiss; cross below lg with focus on the oldest.
    • E2E, contacts dialog open, 390: the refusal is held until focus is on the visible notice's Dismiss, then released. The focused notice stays on screen, still focused, takes its own tap, and +N disappears.
    • E2E, 1280 → 390: focus on the oldest of three notices, then resize. That notice stays on screen, focused, and takes its own tap, with "1 more notice".
  2. The queue's Escape is consumed. When it closes the +N list, the stack's handler calls stopPropagation and nativeEvent.stopImmediatePropagation. The contact dialog's Escape is a document listener added after React's own, so it no longer fires. With the list closed, Escape is untouched.

    • Unit: stopped while open, not touched while closed.
    • E2E at 360 and 390, dialog still open: click +2, press Escape. The list closes, focus returns to +2, and the dialog stays open. The next Escape closes the dialog.
  3. Held while either hover or focus remains (below lg, or with the list open). The pointer leaving no longer resumes while focus is in the stack, and blur no longer resumes while the pointer rests on it.

    • Unit: both orders.
    • From lg, the earlier behaviour (either one leaving resumes) is unchanged and pinned by a test. That desktop behaviour predates this PR. I left it alone rather than widen scope, and can change it if you want.
  4. Every pointer-intercepting surface is measured.

    • +N is a surface. It carries data-toast-more. The corner, dialog, under-notice and controlsUnderToasts reports now measure the visible cards and +N. Queued cards are excluded: they are visually hidden and take no pointer. Before this, controlsUnderToasts counted queued cards' 1px boxes.
    • Rows re-measured with +N (local Chromium):
      • 390 collapsed, dialog open: covered gains Phone; its centre stays clear.
      • 390 with +2 opened: covered gains Add First Contact. +2 (42×32) crosses the top 21px of that button; its centre stays clear. New UNDER_NOTICE_EXPANDED row.
      • The 360 rows were unchanged.
    • DIALOG_OPEN_EXPANDED is now exercised. A new case at 360 and 390 has the dialog open and +2 opened. It asserts the row, and that each Dismiss takes its tap.

Announcement claims, kept unverified. The PR body and the tests now say "one live element per notice, from its push" and stop short of "announced once". What a screen reader announces from that markup, and whether it can act on a queued Undo, is not verified.

Validation (Chromium only, local production build)

Run Result
Unit: queue, desktop guard, module graph 81/81. The 4 new behaviour tests fail on 04dbd25's toast.tsx.
E2E: form-error-semantics (both widths), weekly-meal-planner, and the four other fixtures that mount the toast form-error-semantics 73/74: the one failure is the 360x780 long row, which fails identically on unchanged main here. New queue, focus and dialog cases 60/60 with --repeat-each=3. The five toast fixtures all pass (meal planner 68/68). The queue cases now run with service workers blocked, as the corner cases do: in one full run public/sw.js let a refused contact write through page.route.
tsc 0
eslint 0
3-way merge with #985 (667ac5e, the other form-error-semantics edit), #984 (8ec73c4), #981 and #982 clean

#985's and #984's tests are untouched by this branch.

No merge, deploy or production changes.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

At7fba590/ACK6095308706, the prior arrival/resize focus, handled Escape, mobile mixed-hold and actual-dialog/+N geometry cases are source-corrected. One bounded keyboard-removal case remains: dismiss() removes a focused Undo/Dismiss without reconciling paused/focusIn/focusedId. If removal emits no blur, the activeElement check inside leave() is never reached, so later notices can remain paused indefinitely with the pointer outside. Add a real-DOM focused Dismiss/Undo removal→focus recovery→queue drain regression, plus resizing away a focused +N control. Reconcile the hold on removal while preserving hover/focus protections. This is source-derived, not an executed reproduction. Keep screen-reader announcement/actionability claims unverified; update remaining 'announced once' wording and stale unit-test count rather than treating element counts as announcement evidence. Preserve existing acceptance assertions and exact-head CI separation.

… the hold lets go

Review of 7fba590 on #986. A focused Dismiss or Undo takes its notice
with it, and a focused "+N" goes with the mode; neither fires a blur, so
the stack kept believing it held focus and its clocks never resumed.
Reproduced in Chromium on 7fba590: after a focused "+N" was resized away,
or a focused Undo removed its notice, no notice left on its own (8 s).

Now, when a control holding focus is removed (and only then), focus goes
on to the notice that took the removed one's place, else the newest on
screen; with none left, back to where it came from, and the hold is
released (an open list closes; clocks resume unless the pointer is on the
stack). Recovery is set off only by such a removal: an ordinary focus
move, whose blur React handles while focus is briefly on the page, is
left alone (the first version mistook one for a loss and pulled focus
back to the newest notice).

E2E, keyboard only: a focused Dismiss, three times, hands focus to each
next notice and finally back to its origin, and a new notice then leaves
on its own; a focused "+N" removed by crossing lg hands focus to the
newest notice and, focus tabbed out, all three leave; a focused Undo with
nothing left on screen hands focus back and leaves nothing held. 9/9 with
--repeat-each=3; the spec and the toast fixtures 240/242 (the 360 long
row, local; a fixture-account setup error, passing on re-run).

Remaining "announced once" wording is reworded: the markup is the
intent, and no screen reader verified it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRY21ZKsFrfB3qtoP972A4

Copy link
Copy Markdown
Owner Author

Re 6095395366, addressed in b6c551f.

Executed reproduction, on 7fba590 in Chromium. A removed control fires no blur. After a focused +N was resized away, or a focused Undo removed its notice, focus fell to the page while the stack kept its hold, and no notice left on its own within 8 s. So the source-derived case is real, not just possible.

Fix

  • When it runs. Only when a control holding focus is removed: a dismissed notice that contained document.activeElement, or a focused +N that unmounted.
  • Where focus goes. To the notice that took the removed one's place, else the newest on screen.
  • With no notice left. Focus goes back to where it entered the stack from, an open list closes, and the hold is released. The clocks resume unless the pointer rests on the stack, so the hover and focus protections are kept.
  • Why it waits for a removal. My first version checked "focus is outside the stack" after every change. React flushes a focusout's update while focus is briefly on the page, so that version pulled focus back on an ordinary Shift+Tab. The real-DOM test caught it, and recovery now waits for an actual removal.

Real-DOM regressions (form-error-semantics.spec.ts, 390, keyboard only)

Case What it checks
Focused Dismiss Enter three times. Each next notice's Dismiss takes focus and its own tap. After the last, focus is back on the element it came from. A new notice then leaves on its own, with the pointer away.
Focused +N resized away (390 → 1280) Focus goes to the newest notice. Once focus is tabbed out, all three notices leave on their own.
Focused Undo, nothing else on screen Focus goes back to where it came from. The Undo's confirmation, which arrives later from the server, is not held and leaves on its own.

Results

  • 9/9 with --repeat-each=3.
  • On 7fba590, the +N and Undo cases fail on the hold ("the stack is not held"), and every case fails its focus check.
  • Full form-error-semantics plus the five toast fixtures: 240/242. One failure is the 360x780 long row, which fails identically on unchanged main locally. The other is a fixture-account setup error before any test body ran, which passed 4/4 on re-run.
  • Unit 81/81; tsc 0; eslint 0.

Wording. The remaining "announced once" comment in toast.tsx and the unit test title are reworded, and the PR body's stale unit count (15) is now 20. Element and accessibility-tree counts are described as such, not as announcement evidence; screen-reader behaviour stays unverified. The existing acceptance assertions are unchanged. CI on b6c551f is separate from the earlier heads' results.


Generated by Claude Code

@NewWorldVenture
NewWorldVenture marked this pull request as ready for review October 10, 2026 12:39
@NewWorldVenture
NewWorldVenture merged commit 91eb5a7 into main Oct 10, 2026
9 checks passed
NewWorldVenture pushed a commit that referenced this pull request Oct 10, 2026
…integration

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
NewWorldVenture pushed a commit that referenced this pull request Oct 10, 2026
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.

2 participants