Skip to content

a11y(4.1.3): Toaster role="log" → per-toast role="status"/"alert" for correct AT announcement semantics#3560

Merged
ardiewen merged 12 commits into
mainfrom
wcag/4.1.3-toaster-role-status-alert
Jun 24, 2026
Merged

a11y(4.1.3): Toaster role="log" → per-toast role="status"/"alert" for correct AT announcement semantics#3560
ardiewen merged 12 commits into
mainfrom
wcag/4.1.3-toaster-role-status-alert

Conversation

@rosanusi

@rosanusi rosanusi commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes WCAG 2.2 SC 4.1.3 (Status Messages) for the toast notification surface by introducing a small, placeable live-region primitive and decoupling the screen-reader announcement from the visual toast.

The earlier approach in this PR put role="status"/role="alert" + aria-atomic on each per-toast <div>. That inverts the one rule a live region must obey: the region has to exist in the DOM, empty, before its content is inserted. A node that is simultaneously the region and its content (inserted in a single DOM mutation) does not reliably announce — and the polite case (status: success/info/warning/primary, the majority of toasts) is exactly the unreliable one. So the original change likely regressed the SC it set out to fix. This rework replaces it.

Approach

A persistent sr-only live region, fed by the toaster store — one announcement per toast, independent of the animated visual node:

File Change
src/lib/stores/announcer.ts (new) createAnnouncer() — a buffer of { id, message, politeness }. announce() appends a uniquely-keyed node (append, not clear-and-reset); each is removed after max(toast duration, 7s). clear() empties the buffer and cancels pending timers.
src/lib/holocene/live-region.svelte (new) Two always-rendered sr-only regions — aria-live="polite" and aria-live="assertive", both aria-relevant="additions"; messages routed by politeness, keyed by id. Optional data-testid applied to both regions (-polite/-assertive).
src/lib/stores/toaster.ts push() calls announce(message, variant === 'error' ? 'assertive' : 'polite', duration); exposes announcements; clear() also clears the announcer.
src/lib/holocene/toaster.svelte Mounts <LiveRegion> (optional self-subscribing announcements prop, defaults to the singleton store); container keeps no role.
src/lib/holocene/toast.svelte Reverted to purely visual — per-toast role/aria-atomic removed.

Why this shape

Test plan

  • Screen-reader smoke test (VoiceOver + NVDA) — success/info/warning/primary toast announces politely (no interrupt); error toast announces assertively (interrupts). (Note: under a rapid burst, screen readers coalesce same-region polite updates to latest-wins; this matches react-aria/Radix and is expected.)
  • DOM: two .sr-only[aria-live="polite"] / [aria-live="assertive"] regions exist before any toast fires; the visible container <div> and each toast <div> carry no role.
  • Workflow client-action confirmations (Terminate / Cancel / Reset / Signal) fire a toast that renders and announces.
  • axe-core: no new violations on a page with a live toast.

Automated: unit tests for createAnnouncer (politeness mapping, lifetime/timeout, distinct ids, clear) and toaster store wiring; an integration assertion that the live regions pre-exist and a toast message routes to the polite region.

Downstream (cloud-ui)

announcements is an optional prop defaulting to the singleton, so cloud-ui's two <Toaster> mounts compile and work unchanged after the @temporalio/ui pack bump — no cloud-ui edits required.

A11y-Audit-Ref: 4.1.3-toaster-role-status-alert

🤖 Generated with Claude Code

…ert"

- Remove role="log" from the Toaster container; live-region semantics
  move entirely to individual Toast instances
- Add getRole() to Toast: error variant → role="alert" (assertive);
  all others → role="status" (polite)
- Add aria-atomic="true" so the full toast content re-announces on
  each insertion, even when consecutive toasts share similar markup

Screen readers can now distinguish urgency between error and
informational toasts (WCAG 2.2 SC 4.1.3 Status Messages).

A11y-Audit-Ref: 4.1.3-toaster-role-status-alert

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 12, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
holocene Ready Ready Preview, Comment Jun 24, 2026 4:21pm

Request Review

@github-actions github-actions Bot added a11y Accessibility audit PR a11y:bucket-3 Bucket 3: engineer required a11y:sc-4.1.3 labels Jun 12, 2026
@temporal-cicd

temporal-cicd Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor
Warnings
⚠️

📊 Strict Mode: 3 errors in 2 files (0.3% of 892 total)

src/lib/stores/toaster.ts (1)
  • L38:8: Argument of type 'string | undefined' is not assignable to parameter of type 'string'.
src/lib/holocene/toaster.svelte (2)
  • L50:40: Type 'ToastVariant | undefined' is not assignable to type 'ToastVariant'.
  • L50:50: Type 'string | undefined' is not assignable to type 'string'.

Generated by 🚫 dangerJS against f3bf7da

@rosanusi rosanusi marked this pull request as ready for review June 12, 2026 16:30
@rosanusi rosanusi requested a review from a team as a code owner June 12, 2026 16:30
ardiewen and others added 5 commits June 19, 2026 16:18
Implement framework-agnostic Svelte store factory that manages announcement
messages with auto-removal timeout, supporting polite and assertive politeness
levels for WCAG 4.1.3 live-region announcements.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@ardiewen

This comment was marked as outdated.

ardiewen and others added 2 commits June 19, 2026 20:35
…on contract

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comment thread src/lib/stores/toaster.ts
toastWithDefaults.variant === 'error' ? 'assertive' : 'polite',
toastWithDefaults.duration,
);
const timeoutId = setTimeout(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

  • ⚠️ Argument of type 'string | undefined' is not assignable to parameter of type 'string'.

<div class={toast({ position: $position })} role="log">
<div class={toast({ position: $position })}>
<LiveRegion messages={$announcements} data-testid="toast-live-region" />
{#each $toasts as { message, variant, id, link } (id)}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

  • ⚠️ Type 'ToastVariant | undefined' is not assignable to type 'ToastVariant'.
  • ⚠️ Type 'string | undefined' is not assignable to type 'string'.

Add an optional `politeness` prop ('polite' | 'assertive' | 'both',
default 'both') so consumers that only ever emit one politeness level
(e.g. status chips, filter counts, the session-warning countdown) don't
render an unused live region. Zone selection must be declared at
instantiation because a live region must pre-exist empty before content
is inserted — it cannot be derived from current message content.

Toaster keeps the default 'both' (success=polite, error=assertive), so
its behavior and the existing integration contract are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ardiewen

Copy link
Copy Markdown
Contributor

Update — reworked onto a shared LiveRegion/announcer primitive

What changed since the original PR

The original approach (per-toast role="status"/"alert" set on mount) regressed SC 4.1.3 — a polite role announced on insertion is unreliable. Reworked to the always-rendered-live-region pattern.

Before After
Role applied per-toast at mount Toast is purely visual (no ARIA)
Region created with its content Persistent empty aria-live regions pre-exist; toasts announced as dynamic content
Toaster-only Reusable createAnnouncer() store + placeable LiveRegion component

Final architecture

  • stores/announcer.tscreateAnnouncer(): announce(message, politeness?, duration?) + clear(); persists each message max(duration, 7000ms) then removes.
  • holocene/live-region.svelte — two persistent sr-only regions (polite + assertive), keyed nodes.
  • stores/toaster.ts — holds a private announcer; push() routes error → assertive, else polite.
  • holocene/toaster.svelte — mounts LiveRegion; container/toast carry no role.

Decisions

  • ✅ Announcer kept private to the toaster — it's a standalone module, so layering is already clean and a future shared announcer forecloses nothing here.
  • ↩️ A politeness zone-selection prop was added then reverted — speculative; no current consumer needs it.

Out of scope (follow-up: "ambient SR announcements")

Audited ui + cloud-ui for the same transient-no-visible-host gap. Not part of this PR:

Class Surfaces Fix
Copy-to-clipboard (silent icon swap) ui ×4, cloud-ui ×3 — all via shared copyToClipboard() Announce inside the shared util → covers both repos
Inline action confirms ("Success!"/"Saved") cloud-ui ×3 (sink-verify ×2, internal-roles) Per-surface announce()

Banners/modals/status-chips/counts excluded (persistent or in-place → element-level live regions).

Remaining gate

Manual NVDA + VoiceOver verification (polite no-interrupt, error interrupts, both regions pre-exist) before leaving draft. Note: VoiceOver coalesces rapid polite updates (latest-wins) — expected SR behavior, not a regression.

clear: () => void;
}

const DEFAULT_TIMEOUT = 7000;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Am I understanding correctly that we are defaulting to a 7 second delay before starting the announcement? Is that normal? IMO it feels like a long time, if something is 'polite' does it need a manual delay at all?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It's not a delay on the announcement itself, but rather how long the announcement text stays in the DOM. This gives the screen reader a chance to actually pick up the announcement, while also ensuring it gets automatically cleared out of the dom after a certain amount of time.

The default of 7000ms is just directly ripped from other popular implementations, e.g. react-aria

Comment thread src/lib/stores/announcer.ts Outdated
Comment on lines +11 to +16
const polite = $derived(
messages.filter((message) => message.politeness === 'polite'),
);
const assertive = $derived(
messages.filter((message) => message.politeness === 'assertive'),
);

@tegan-temporal tegan-temporal Jun 24, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Feel free to ignore this suggestion, but it seems like a good use case for the newish Object.groupBy.

Suggested change
const polite = $derived(
messages.filter((message) => message.politeness === 'polite'),
);
const assertive = $derived(
messages.filter((message) => message.politeness === 'assertive'),
);
const { polite, assertive } = $derived(
Object.groupBy(messages, (message) => message.politeness)
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

pnpm check fails when we try to implement this:

1782322397445 ERROR "src/lib/holocene/live-region.svelte" 19:12 "Property 'groupBy' does not exist on type 'ObjectConstructor'. Do you need to change your target library? Try changing the 'lib' compiler option to 'es2024' or later."

Looks like it's an ES2024 and beyond feature 😞

@tegan-temporal tegan-temporal left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

couple comments, no blockers

Addresses review feedback — replaces the hand-rolled { subscribe }
wrapper with svelte/store's readonly() helper.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ardiewen ardiewen merged commit feab0d9 into main Jun 24, 2026
19 checks passed
@ardiewen ardiewen deleted the wcag/4.1.3-toaster-role-status-alert branch June 24, 2026 17:37
temporal-cicd Bot pushed a commit that referenced this pull request Jul 2, 2026
Auto-generated version bump from 2.51.1 to 2.52.0

Bump type: minor

Changes included:
- [`9c6fcd54`](9c6fcd5) Add direct SSO redirect and backend logout (#3590)
- [`feab0d90`](feab0d9) a11y(4.1.3): Toaster role="log" → per-toast role="status"/"alert" for correct AT announcement semantics (#3560)
- [`d7bbfb01`](d7bbfb0) a11y(2.5.8): toast — replace bare close button with IconButton (36×36 px) (#3553)
- [`4efd6ab6`](4efd6ab) a11y(3.3.1): Holocene input primitives — add aria-invalid, aria-describedby, and uniform live-region error announcement (#3554)
- [`0600b700`](0600b70) Use green only (#3599)
- [`94107549`](9410754) Clarify activity pause timeout behavior (#3600)
- [`9f88d5a8`](9f88d5a) Bump node version to 22.23.1 or newer to fix issue from previous security release (#3604)
- [`ca4104ae`](ca4104a) Update reason copy and inputs (#3588)
- [`51db73fa`](51db73f) Update batch operation reason input hint (#3605)
- [`836d7c55`](836d7c5) a11y(2.4.3): route navigation — move focus to the main landmark on afterNavigate, not just scroll (#3538)
- [`4ee95696`](4ee9569) Improve re-renders for large encoded event histories (#3592)
- [`fa5e7aa1`](fa5e7aa) feature/schedules (#3603)
- [`0d9467fd`](0d9467f) Default includeHeartbeatDetails and includeLastFailure to true for getActivityExecution and pollActivityExecution (#3521)
- [`e5bc514e`](e5bc514) fix schedules: duplicate key (#3612)
- [`f1eb489f`](f1eb489) Remove preview badge (#3581)
- [`1e5173b4`](1e5173b) Add existing versions to worker create deployment version form (#3601)
- [`a8552bae`](a8552ba) fix schedules -e.trim not a function (#3613)
- [`0442f847`](0442f84) Fix width of ComputeBadge in VersionTableRow (#3615)
- [`ddbeb54f`](ddbeb54) fix(cloud-nav): animate and stabilize side-nav collapse (#3606)
- [`3cfc684b`](3cfc684) fix - schedules recent/upcoming runs - sort issue (#3616)
- [`92b8875b`](92b8875) Move timestamp out of translate (#3617)
- [`901258af`](901258a) Fix SAA and WF consistency (#3614)
- [`99593d97`](99593d9) DT-4001 - standalone nexus operations (#3496)
- [`f10d1bf1`](f10d1bf) Clamp codeblock to container width (#3621)
- [`0a9d93ce`](0a9d93c) Remove unnecessary bottom-0 (#3623)
- [`9dabb522`](9dabb52) chore(deps-dev): bump vite from 6.4.2 to 6.4.3 (#3610)
- [`5c1854d3`](5c1854d) News Feed (#3596)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a11y:bucket-3 Bucket 3: engineer required a11y:sc-4.1.3 a11y Accessibility audit PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants