Skip to content

UI: Remove automatic Notice announcements - #82737

Merged
ciampo merged 10 commits into
trunkfrom
codex/remove-ui-notice-speak
Sep 24, 2026
Merged

ciampo merged 10 commits into
trunkfrom
codex/remove-ui-notice-speak

Conversation

@ciampo

@ciampo ciampo commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

See #82701

What?

Remove automatic announcements and the spokenMessage and politeness props from @wordpress/ui Notice.

Why?

Static notices should not announce themselves. Applications need to choose the announcement's text, timing, and urgency without including action labels.

How?

Consumers call speak() when an update needs an announcement. Editor crashes announce the title and description assertively. A single dashboard widget failure uses a polite announcement. The visible notices have no live-region roles, avoiding duplicate announcements.

Storybook includes migration guidance and interactive examples. It recommends consumer-owned speak() calls and explains how to use an already mounted live region as an alternative.

This is a breaking API change. The legacy @wordpress/components Notice is unchanged. The compositional Notice.List proposal in #82701 remains separate work.

Testing Instructions

  1. Open Design System → Components → Notice → Announcements in Storybook.
  2. With a screen reader, open Static Notice. It should be available through normal navigation without an automatic announcement.
  3. In Urgent Error, select Save changes. The assertive announcement should include "Changes not saved" and the recovery message, without "Try again." Select Try again to clear the visible notice.
  4. In Polite Status, select Save draft. The polite announcement should include "Draft saved" and "Your draft has been saved," without "Dismiss."
  5. Inspect the examples. Only the shared @wordpress/a11y live regions should receive announcements; the visible notices should have no role="alert" or role="status".

Testing Instructions for Keyboard

Use Tab and Enter to run the examples. After either save action, focus should stay on its button. Tab should reach Try again or Dismiss.

Use of AI Tools

Codex was used to investigate, implement, verify, and draft this change.

@github-actions github-actions Bot added [Package] Editor /packages/editor [Package] Edit Widgets /packages/edit-widgets [Package] UI /packages/ui [Package] Widget Dashboard /packages/widget-dashboard labels Sep 10, 2026
@github-actions

github-actions Bot commented Sep 10, 2026 •

Copy link
Copy Markdown

🤖 PR meta 🤖

📦 Bundle size

Size Change: -564 B (-0.01%)

Total Size: 8.22 MB

📦 View Changed
Filename Size Change
build/scripts/block-editor/index.min.js 505 kB -234 B (-0.05%)
build/scripts/customize-widgets/index.min.js 59.3 kB -73 B (-0.12%)
build/scripts/edit-widgets/index.min.js 67 kB -71 B (-0.11%)
build/scripts/editor/index.min.js 613 kB -186 B (-0.03%)

d01ac37 Run

⚡ Performance

Show the results

Client side metrics exclude the server response time.

front-end-block-theme

Metric 6ca3852 trunk % Change
timeToFirstByte 58.65 ms +8.61% -6.22% 63.05 ms +6.98% -4.92% -6.98%
largestContentfulPaint 98 ms +12.24% -6.12% 104 ms +5.77% -7.69% -5.77%
lcpMinusTtfb 37.3 ms +25.6% -7.37% 36.55 ms +17.51% -2.87% 2.05%
wpBeforeTemplate 28.94 ms +13.1% -5.36% 31.24 ms +10.34% -4.8% -7.36%
wpTemplate 25.16 ms +4.85% -5.96% 26.71 ms +5.77% -3.52% -5.8%
wpTotal 54.89 ms +8.16% -6.36% 59.1 ms +5.16% -5.41% -7.12%
wpMemoryUsage 7.61 MB +0% -0% 7.57 MB +0% -0% 0.51%
wpDbQueries 17 +0% -0% 17 +0% -0% 0%

front-end-classic-theme

Metric 6ca3852 trunk % Change
timeToFirstByte 48.05 ms +6.87% -0.94% 46.5 ms +9.89% -2.37% 3.33%
largestContentfulPaint 104 ms +5.77% -0% 96 ms +6.25% -0% 8.33%
lcpMinusTtfb 56.4 ms +3.37% -0.89% 49.6 ms +3.12% -1.31% 13.71%
wpBeforeTemplate 26.36 ms +3.41% -1.1% 28.17 ms +7.74% -3.41% -6.43%
wpTemplate 18.42 ms +7.27% -1.57% 15.5 ms +1.81% -3.16% 18.84%
wpTotal 44.83 ms +7.25% -1.36% 43.39 ms +10.28% -2.26% 3.32%
wpMemoryUsage 6.22 MB +0% -0% 6.05 MB +0% -0% 2.86%
wpDbQueries 14 +0% -0% 10 +0% -0% 40%

media-processing

Metric 6ca3852 trunk % Change
mediaProcessingJpeg 400.92 ms +2.63% -1.1% 403.37 ms +1.72% -0.55% -0.61%
mediaProcessingAvif 6118.66 ms +0.29% -0.34% 6112.9 ms +0.38% -0.32% 0.09%
mediaProcessingJpegToAvif 4226.82 ms +0.17% -0.21% 4209.9 ms +0.44% -0.04% 0.4%

media-upload

Metric 6ca3852 trunk % Change
jpegUploadProcessing 1462.39 ms +35.41% -2.72% 1435.68 ms +0.38% -0.91% 1.86%
pngUploadProcessing 195.87 ms +33.31% -10.11% 197.32 ms +17.24% -7.28% -0.73%
largeJpegUploadProcessing 1416.7 ms +2.07% -0.28% 1414.88 ms +0.38% -0.55% 0.13%
multipleImageUploadProcessing 2055.76 ms +3.49% -22.85% 1610.55 ms +7.93% -1.66% 27.64%

post-editor

Metric 6ca3852 trunk % Change
serverResponse 494.7 ms +2.2% -5.2% 499.22 ms +3.87% -4.01% -0.91%
firstPaint 231.08 ms +15.58% -16.61% 248.01 ms +5.64% -23.59% -6.83%
domContentLoaded 1074.21 ms +0.99% -0.85% 1056.19 ms +2.3% -2.75% 1.71%
loaded 1075.5 ms +0.98% -0.84% 1057.36 ms +2.31% -2.73% 1.72%
firstContentfulPaint 440.14 ms +2.02% -1.56% 439.05 ms +1.44% -6.27% 0.25%
firstBlock 3207.93 ms +2.19% -0.96% 3186.74 ms +0.48% -0.6% 0.66%
type 18.07 ms +7.64% -6.86% 18.81 ms +8.67% -3.67% -3.93%
typeWithoutInspector 18.23 ms +1.37% -2.85% 18.26 ms +4.44% -3.5% -0.16%
typeWithTopToolbar 22.26 ms +6.87% -9.12% 24.22 ms +2.48% -2.81% -8.09%
typeContainer 9.17 ms +6.87% -9.81% 8.6 ms +0.47% -3.37% 6.63%
focus 68.96 ms +7.54% -4.42% 74.16 ms +5.02% -6.12% -7.01%
firstFocus 203.18 ms +0% -0% 238.24 ms +0% -0% -14.72%
selectAll 530.04 ms +1.44% -1.18% 517.92 ms +9.38% -0.24% 2.34%
listViewOpen 62.59 ms +6.34% -1.9% 65.46 ms +8.88% -6.68% -4.38%
inserterOpen 24.07 ms +16.08% -18.03% 22.87 ms +10.98% -2.71% 5.25%
inserterHover 2.2 ms +5.91% -11.82% 2.01 ms +8.96% -5.47% 9.45%
inserterSearch 7.94 ms +20.28% -7.56% 7.18 ms +24.79% -5.99% 10.58%
loadPatterns 668.79 ms +1.94% -9.75% 630.36 ms +5.65% -0.95% 6.1%
wpTotal 484.88 ms +2.19% -5.3% 489.26 ms +3.93% -4.1% -0.9%
wpMemoryUsage 13.14 MB +0% -0% 13.11 MB +0% -0% 0.28%
wpDbQueries 53.5 +0.93% -0.93% 54 +0% -1.85% -0.93%

site-editor

Metric 6ca3852 trunk % Change
serverResponse 488.81 ms +6.16% -7.24% 514.11 ms +1.53% -5.91% -4.92%
firstPaint 244.65 ms +26.27% -8.52% 270.24 ms +1.09% -15.18% -9.47%
domContentLoaded 1135.35 ms +2.14% -1.06% 1162.82 ms +0.8% -1.23% -2.36%
loaded 1136.49 ms +2.14% -1.05% 1164.13 ms +0.78% -1.25% -2.37%
firstContentfulPaint 459.75 ms +1.26% -1.01% 468.07 ms +2.27% -2.65% -1.78%
firstBlock 4153.89 ms +1.04% -0.48% 4187.46 ms +1.41% -0.7% -0.8%
type 17.5 ms +3.31% -2.69% 18.26 ms +3.72% -5.31% -4.16%
navigate 104.03 ms +9.17% -3.6% 117.7 ms +9.74% -8.43% -11.61%
loadPatterns 1256.67 ms +13.74% -1.66% 1328.38 ms +14.2% -3.72% -5.4%
loadPages 1070.06 ms +1.95% -3.01% 1069.4 ms +1.93% -1% 0.06%
wpTotal 479.33 ms +6.19% -7.45% 504.08 ms +1.56% -5.98% -4.91%
wpMemoryUsage 12.11 MB +0% -0% 12.09 MB +0% -0% 0.13%
wpDbQueries 43 +2.33% -0% 43.5 +1.15% -1.15% -1.15%

d01ac37 Run

@ciampo ciampo changed the title UI: Remove speak functionality from Notice primitive UI: Remove automatic Notice announcements Sep 10, 2026
@simison

simison commented Sep 10, 2026 •

Copy link
Copy Markdown
Member

To me it would make sense to add Storybook documentation or note with role example so that agents and folks know to think to add it where it actually is useful, or alternatively keep the API even if taking down the automated announcement.

dhasilva added a commit to Automattic/jetpack that referenced this pull request Sep 11, 2026
Rename the per-notice stamp from `id` to `noticeId`. It is a sequence number
rather than an identity, and `id` would have become a real DOM attribute the
day `Notice.Root` is handed rest props.

Say why only the toast announces, and drop the comparison with `Notice.Root`'s
own announcement: WordPress/gutenberg#82737 proposes removing that, so the
comparison would describe a mechanism a reader cannot reach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lo29yrft9KqShYK3p1wQe6
dhasilva added a commit to Automattic/jetpack that referenced this pull request Sep 11, 2026
* Protect notices: back the notice component with the @wordpress/ui Notice

The hand-rolled chassis — an icon switch, a message div and a dismiss button
over a dark banner — becomes `Notice.Root` with a `Notice.Description` and a
`Notice.CloseIcon`. The public props are unchanged, so all three call sites
keep working: `type` picks the intent, `message` fills the description,
`dismissable` draws the close control, and `duration` keeps its own timeout.

`type="warning"` now reaches an intent of its own. The old switch had no case
for it, so the fix-threat modal's inactive-extension warning fell through to
the info icon over the default chassis.

Only the floating toast passes a `spokenMessage`. It appears without a focus
change and no call site wraps it in a live region; the other two sit inside a
modal that is announced when it opens.

The stylesheet keeps the floating placement, the info notice's bottom margin
and the stacking context, and loses everything that painted the old chassis.
`container-type: normal` is new: `Notice.Root` sets `container-type:
inline-size`, and a size-contained child reports no width to the shrink-to-fit
fixed box, which collapsed the toast to 26px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: keep the notice silent and its dismiss label

The Protect e2e suite failed on both counts, and both are this PR's doing.

`spokenMessage` defaulted the toast into `speak()`, which copies the message
into `#a11y-speak-polite` — so `getByText( 'Changes saved' )` matched two nodes
and Playwright's strict mode rejected it. The legacy notice never announced and
no call site sits in a live region, so this restores that. Announcing a toast is
a real improvement, but it belongs in its own change, applied consistently.

`Notice.CloseIcon`'s default label is "Dismiss", which renamed the control the
suite clicks. The existing translated string is passed through instead, so the
accessible name users already have survives the chassis swap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: let the toast announce, and follow the three review notes

`speak()` keeps its own live region, so whether a call site sits in one was
never the right test — the reason the previous commit gave was wrong. The toast
reports an async result with no focus change, which is what `speak()` is for, so
it announces again; the two modal notices stay quiet, since their dialog is read
when it opens.

That is what broke the e2e suite: `speak()` copies the message into
`#a11y-speak-polite`, so `getByText( 'Changes saved' )` matches twice. Verified
in a browser that the notice is the earlier of the two, and scoped the
assertion to it.

Also: capitalise the changelog entry after its prefix and drop the repeated
word, and add `warning` to the component README's list of types, which this
change makes a real intent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: never hand a JSX message to the announcer

`Notice.Root` renders `spokenMessage` to a string during its own render. When
the message holds a component with hooks — every error notice interpolates a
`Link` — that component's hooks land in `Notice.Root`'s hook list, and the
`useEffect` on the next line reads a mismatched slot and throws. React then
unmounts the whole Protect dashboard, so any failed request blanked the page.
`safeRenderToString`'s try/catch does not help: the throw comes from the hook
after it.

Only a plain string is announced now, which covers the saved and saving toasts.
Announcing errors needs a spoken string carried alongside the JSX; that is worth
doing separately. The same trap is recorded in
`packages/newsletter/_inc/subscribers/components/modals/add-subscribers-modal.tsx`.

Two follow-ups from the same review: the e2e assertion is scoped to the app root,
because `speak()` leaves its text in the live region after the notice goes and
the helper runs seven times; and `NoticeState.type` gains `warning`, which the
component and README already offer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: bundle the two packages the close control pulls in

`Notice.CloseIcon` reaches `@wordpress/theme` and `@wordpress/private-apis`
through `IconButton` and its tooltip, so the build started externalising them to
the `wp-theme` and `wp-private-apis` script handles. Protect registers no shim
for those, and WordPress refuses to enqueue a bundle with an unmet dependency —
the admin page would render empty wherever core does not supply them.

They are bundled instead, which is what the Jetpack plugin's own admin bundles
do for the same reason. Verified by rebuilding: both handles are gone from
`build/index.asset.php`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: correct a comment the e2e scoping outdated

The Firewall block still said the "Changes saved" assertion stays page-scoped
on purpose. It does not any more.

The changelog entry becomes `minor`, matching the same migration for the
Jetpack plugin in #52015: every Protect notice is restyled, one turns amber,
and the toasts start announcing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: announce error notices to screen readers

The announcement was derived from the message, and only a plain string
was ever handed to `Notice.Root` — anything else corrupts its hook order.
`showErrorNotice` always builds JSX, because it interpolates a support
link, so failed saves stayed silent while "Changes saved." did not.

Carry the announcement in the notice state instead: `showErrorNotice`
sets `spokenMessage` from the plain string it already receives, and the
component prefers it over the message. It still resolves to null rather
than undefined, so `Notice.Root` never falls back to rendering children.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: speak the whole error, and speak it every time

The announcement dropped the "Please try again or contact support." sentence
because it only exists with interpolation tags. Stripping the tags off the
translated string gives the plain sentence at no translation cost, so the
announcement now carries what the notice says.

Each notice also gets an identity, and the toast is keyed on it. Without that a
repeat of the same error re-renders with identical props, the announcing effect
never re-runs, and the second failure is silent. `use-fixers-query` is the path
that reaches it: its polling error fires the same message with no other notice
in between.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: re-announce without stealing focus

Keying the toast on the notice id remounted it, which destroyed the node focus
was in: a keyboard user reading the error, with focus on its support link or
dismiss button, lost focus to the body when the next notice arrived. In an
accessibility change that is not a fair trade.

The announcement now varies instead of the element. A repeat alternates a
trailing non-breaking space — the same device `@wordpress/a11y` uses for this —
so the announcing effect re-runs while the element stays mounted. Two identical
errors in a row take consecutive ids, so the parity always flips between them;
anything that arrives in between announces on its own text.

Two more from the same review: the provider stamps the id, so no future writer
can forget it and silently break the repeat, and the tag strip no longer names
`supportLink`, so renaming the tag cannot silently stop it working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: announce per notice, not per distinct message

The parity nonce was wrong. `Notice.Root` announces when the value it is handed
changes, so the previous revision alternated a trailing space keyed on the
notice id — but the id advances once per stored notice while the announcement
happens once per committed render. Any event that stores an even number of
notices in one commit leaves the parity where it was, and the repeat is silent
again. `use-fixers-query` is exactly that shape: one instance per threat row
plus the modals, all sharing a query, all raising the same error in one commit.

So the announcement moves here, keyed on the notice, and `Notice.Root` is handed
`null` unconditionally — it never renders a message to a string now, which is
what the crash came from. `speak()` handles an identical repeat itself.

Two more from the same review: the id is stamped outside the state updater, and
the component README documents `spokenMessage` and `id`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: tie the dismiss timer to the notice as well

`duration` keyed its timer on the message, so a second identical success
inherited whatever was left of the first one's 7.5 seconds instead of starting
again. The notice's id is what says "this is a different notice", and the
announcement already uses it.

Also from review: the mid-render rationale is stated once, on the prop that
carries it, and the new dependency is filed in alphabetical order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: drop a comment's claim of a nonexistent dialog

The comment above `spokenMessage` said the modal notices are read when their
dialog opens. Protect has no dialog: `components/modal/index.jsx` renders a
plain `<div>` with no `role="dialog"`, no `aria-modal` and no focus move into
the window, so nothing reads those notices. Keep the half that is true and
load-bearing -- why the prop takes strings only -- and drop the rest.

Also cut the `requestMap` comment in the plugin's webpack config to the live
constraint. The mechanism is already spelled out in three other configs, and a
fourth copy drifts. The code is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: keep the joint-bundling constraint on the requestMap

The pair has to be bundled together: `@wordpress/theme` opts in and calls
`lock()` at module scope, and `@wordpress/private-apis` keeps its consent map
per module instance. Bundle one and externalize the other and the two maps
diverge, which surfaces at runtime as "Cannot unlock an object that was not
locked before" or a duplicate opt-in, with nothing pointing back at the build
config. The full account lives in #48173.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: point at the requestMap rationale instead of restating it

Three other webpack configs already carry this reasoning in full. A fourth copy
is the one outcome that guarantees they drift, so this leaves the constraint and
the reference and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: name the tag the spoken message strips

CodeQL reads a `<[^>]+>` strip as HTML sanitization and files it high severity
(js/incomplete-multi-character-sanitization). Nothing here reaches innerHTML —
the result goes to `speak()`, which writes textContent — but the generic form
buys nothing over naming the one placeholder the string actually carries, and
`createInterpolateElement` breaks just as loudly if that name ever changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: answer the review on the announcement

Rename the per-notice stamp from `id` to `noticeId`. It is a sequence number
rather than an identity, and `id` would have become a real DOM attribute the
day `Notice.Root` is handed rest props.

Say why only the toast announces, and drop the comparison with `Notice.Root`'s
own announcement: WordPress/gutenberg#82737 proposes removing that, so the
comparison would describe a mechanism a reader cannot reach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lo29yrft9KqShYK3p1wQe6

* Protect notices: announce a failing fixers poll once per streak

Every failed poll produces a new Error object, so keying the effect on it
re-announced the same message assertively every five seconds for as long as
the poll kept failing. Key on `isError` instead: one announcement per streak,
and another only once a success has come in between.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lo29yrft9KqShYK3p1wQe6

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Douglas <douglas.henri@automattic.com>
matticbot pushed a commit to Automattic/jetpack-storybook that referenced this pull request Sep 11, 2026
* Protect notices: back the notice component with the @wordpress/ui Notice

The hand-rolled chassis — an icon switch, a message div and a dismiss button
over a dark banner — becomes `Notice.Root` with a `Notice.Description` and a
`Notice.CloseIcon`. The public props are unchanged, so all three call sites
keep working: `type` picks the intent, `message` fills the description,
`dismissable` draws the close control, and `duration` keeps its own timeout.

`type="warning"` now reaches an intent of its own. The old switch had no case
for it, so the fix-threat modal's inactive-extension warning fell through to
the info icon over the default chassis.

Only the floating toast passes a `spokenMessage`. It appears without a focus
change and no call site wraps it in a live region; the other two sit inside a
modal that is announced when it opens.

The stylesheet keeps the floating placement, the info notice's bottom margin
and the stacking context, and loses everything that painted the old chassis.
`container-type: normal` is new: `Notice.Root` sets `container-type:
inline-size`, and a size-contained child reports no width to the shrink-to-fit
fixed box, which collapsed the toast to 26px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: keep the notice silent and its dismiss label

The Protect e2e suite failed on both counts, and both are this PR's doing.

`spokenMessage` defaulted the toast into `speak()`, which copies the message
into `#a11y-speak-polite` — so `getByText( 'Changes saved' )` matched two nodes
and Playwright's strict mode rejected it. The legacy notice never announced and
no call site sits in a live region, so this restores that. Announcing a toast is
a real improvement, but it belongs in its own change, applied consistently.

`Notice.CloseIcon`'s default label is "Dismiss", which renamed the control the
suite clicks. The existing translated string is passed through instead, so the
accessible name users already have survives the chassis swap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: let the toast announce, and follow the three review notes

`speak()` keeps its own live region, so whether a call site sits in one was
never the right test — the reason the previous commit gave was wrong. The toast
reports an async result with no focus change, which is what `speak()` is for, so
it announces again; the two modal notices stay quiet, since their dialog is read
when it opens.

That is what broke the e2e suite: `speak()` copies the message into
`#a11y-speak-polite`, so `getByText( 'Changes saved' )` matches twice. Verified
in a browser that the notice is the earlier of the two, and scoped the
assertion to it.

Also: capitalise the changelog entry after its prefix and drop the repeated
word, and add `warning` to the component README's list of types, which this
change makes a real intent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: never hand a JSX message to the announcer

`Notice.Root` renders `spokenMessage` to a string during its own render. When
the message holds a component with hooks — every error notice interpolates a
`Link` — that component's hooks land in `Notice.Root`'s hook list, and the
`useEffect` on the next line reads a mismatched slot and throws. React then
unmounts the whole Protect dashboard, so any failed request blanked the page.
`safeRenderToString`'s try/catch does not help: the throw comes from the hook
after it.

Only a plain string is announced now, which covers the saved and saving toasts.
Announcing errors needs a spoken string carried alongside the JSX; that is worth
doing separately. The same trap is recorded in
`packages/newsletter/_inc/subscribers/components/modals/add-subscribers-modal.tsx`.

Two follow-ups from the same review: the e2e assertion is scoped to the app root,
because `speak()` leaves its text in the live region after the notice goes and
the helper runs seven times; and `NoticeState.type` gains `warning`, which the
component and README already offer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: bundle the two packages the close control pulls in

`Notice.CloseIcon` reaches `@wordpress/theme` and `@wordpress/private-apis`
through `IconButton` and its tooltip, so the build started externalising them to
the `wp-theme` and `wp-private-apis` script handles. Protect registers no shim
for those, and WordPress refuses to enqueue a bundle with an unmet dependency —
the admin page would render empty wherever core does not supply them.

They are bundled instead, which is what the Jetpack plugin's own admin bundles
do for the same reason. Verified by rebuilding: both handles are gone from
`build/index.asset.php`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: correct a comment the e2e scoping outdated

The Firewall block still said the "Changes saved" assertion stays page-scoped
on purpose. It does not any more.

The changelog entry becomes `minor`, matching the same migration for the
Jetpack plugin in #52015: every Protect notice is restyled, one turns amber,
and the toasts start announcing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: announce error notices to screen readers

The announcement was derived from the message, and only a plain string
was ever handed to `Notice.Root` — anything else corrupts its hook order.
`showErrorNotice` always builds JSX, because it interpolates a support
link, so failed saves stayed silent while "Changes saved." did not.

Carry the announcement in the notice state instead: `showErrorNotice`
sets `spokenMessage` from the plain string it already receives, and the
component prefers it over the message. It still resolves to null rather
than undefined, so `Notice.Root` never falls back to rendering children.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: speak the whole error, and speak it every time

The announcement dropped the "Please try again or contact support." sentence
because it only exists with interpolation tags. Stripping the tags off the
translated string gives the plain sentence at no translation cost, so the
announcement now carries what the notice says.

Each notice also gets an identity, and the toast is keyed on it. Without that a
repeat of the same error re-renders with identical props, the announcing effect
never re-runs, and the second failure is silent. `use-fixers-query` is the path
that reaches it: its polling error fires the same message with no other notice
in between.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: re-announce without stealing focus

Keying the toast on the notice id remounted it, which destroyed the node focus
was in: a keyboard user reading the error, with focus on its support link or
dismiss button, lost focus to the body when the next notice arrived. In an
accessibility change that is not a fair trade.

The announcement now varies instead of the element. A repeat alternates a
trailing non-breaking space — the same device `@wordpress/a11y` uses for this —
so the announcing effect re-runs while the element stays mounted. Two identical
errors in a row take consecutive ids, so the parity always flips between them;
anything that arrives in between announces on its own text.

Two more from the same review: the provider stamps the id, so no future writer
can forget it and silently break the repeat, and the tag strip no longer names
`supportLink`, so renaming the tag cannot silently stop it working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: announce per notice, not per distinct message

The parity nonce was wrong. `Notice.Root` announces when the value it is handed
changes, so the previous revision alternated a trailing space keyed on the
notice id — but the id advances once per stored notice while the announcement
happens once per committed render. Any event that stores an even number of
notices in one commit leaves the parity where it was, and the repeat is silent
again. `use-fixers-query` is exactly that shape: one instance per threat row
plus the modals, all sharing a query, all raising the same error in one commit.

So the announcement moves here, keyed on the notice, and `Notice.Root` is handed
`null` unconditionally — it never renders a message to a string now, which is
what the crash came from. `speak()` handles an identical repeat itself.

Two more from the same review: the id is stamped outside the state updater, and
the component README documents `spokenMessage` and `id`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: tie the dismiss timer to the notice as well

`duration` keyed its timer on the message, so a second identical success
inherited whatever was left of the first one's 7.5 seconds instead of starting
again. The notice's id is what says "this is a different notice", and the
announcement already uses it.

Also from review: the mid-render rationale is stated once, on the prop that
carries it, and the new dependency is filed in alphabetical order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: drop a comment's claim of a nonexistent dialog

The comment above `spokenMessage` said the modal notices are read when their
dialog opens. Protect has no dialog: `components/modal/index.jsx` renders a
plain `<div>` with no `role="dialog"`, no `aria-modal` and no focus move into
the window, so nothing reads those notices. Keep the half that is true and
load-bearing -- why the prop takes strings only -- and drop the rest.

Also cut the `requestMap` comment in the plugin's webpack config to the live
constraint. The mechanism is already spelled out in three other configs, and a
fourth copy drifts. The code is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: keep the joint-bundling constraint on the requestMap

The pair has to be bundled together: `@wordpress/theme` opts in and calls
`lock()` at module scope, and `@wordpress/private-apis` keeps its consent map
per module instance. Bundle one and externalize the other and the two maps
diverge, which surfaces at runtime as "Cannot unlock an object that was not
locked before" or a duplicate opt-in, with nothing pointing back at the build
config. The full account lives in #48173.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: point at the requestMap rationale instead of restating it

Three other webpack configs already carry this reasoning in full. A fourth copy
is the one outcome that guarantees they drift, so this leaves the constraint and
the reference and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: name the tag the spoken message strips

CodeQL reads a `<[^>]+>` strip as HTML sanitization and files it high severity
(js/incomplete-multi-character-sanitization). Nothing here reaches innerHTML —
the result goes to `speak()`, which writes textContent — but the generic form
buys nothing over naming the one placeholder the string actually carries, and
`createInterpolateElement` breaks just as loudly if that name ever changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: answer the review on the announcement

Rename the per-notice stamp from `id` to `noticeId`. It is a sequence number
rather than an identity, and `id` would have become a real DOM attribute the
day `Notice.Root` is handed rest props.

Say why only the toast announces, and drop the comparison with `Notice.Root`'s
own announcement: WordPress/gutenberg#82737 proposes removing that, so the
comparison would describe a mechanism a reader cannot reach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lo29yrft9KqShYK3p1wQe6

* Protect notices: announce a failing fixers poll once per streak

Every failed poll produces a new Error object, so keying the effect on it
re-announced the same message assertively every five seconds for as long as
the poll kept failing. Key on `isError` instead: one announcement per streak,
and another only once a success has come in between.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lo29yrft9KqShYK3p1wQe6

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Douglas <douglas.henri@automattic.com>

Committed via a GitHub action: https://github.com/Automattic/jetpack/actions/runs/34654909076

Upstream-Ref: Automattic/jetpack@d121e06
matticbot pushed a commit to Automattic/jetpack-protect-plugin that referenced this pull request Sep 11, 2026
* Protect notices: back the notice component with the @wordpress/ui Notice

The hand-rolled chassis — an icon switch, a message div and a dismiss button
over a dark banner — becomes `Notice.Root` with a `Notice.Description` and a
`Notice.CloseIcon`. The public props are unchanged, so all three call sites
keep working: `type` picks the intent, `message` fills the description,
`dismissable` draws the close control, and `duration` keeps its own timeout.

`type="warning"` now reaches an intent of its own. The old switch had no case
for it, so the fix-threat modal's inactive-extension warning fell through to
the info icon over the default chassis.

Only the floating toast passes a `spokenMessage`. It appears without a focus
change and no call site wraps it in a live region; the other two sit inside a
modal that is announced when it opens.

The stylesheet keeps the floating placement, the info notice's bottom margin
and the stacking context, and loses everything that painted the old chassis.
`container-type: normal` is new: `Notice.Root` sets `container-type:
inline-size`, and a size-contained child reports no width to the shrink-to-fit
fixed box, which collapsed the toast to 26px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: keep the notice silent and its dismiss label

The Protect e2e suite failed on both counts, and both are this PR's doing.

`spokenMessage` defaulted the toast into `speak()`, which copies the message
into `#a11y-speak-polite` — so `getByText( 'Changes saved' )` matched two nodes
and Playwright's strict mode rejected it. The legacy notice never announced and
no call site sits in a live region, so this restores that. Announcing a toast is
a real improvement, but it belongs in its own change, applied consistently.

`Notice.CloseIcon`'s default label is "Dismiss", which renamed the control the
suite clicks. The existing translated string is passed through instead, so the
accessible name users already have survives the chassis swap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: let the toast announce, and follow the three review notes

`speak()` keeps its own live region, so whether a call site sits in one was
never the right test — the reason the previous commit gave was wrong. The toast
reports an async result with no focus change, which is what `speak()` is for, so
it announces again; the two modal notices stay quiet, since their dialog is read
when it opens.

That is what broke the e2e suite: `speak()` copies the message into
`#a11y-speak-polite`, so `getByText( 'Changes saved' )` matches twice. Verified
in a browser that the notice is the earlier of the two, and scoped the
assertion to it.

Also: capitalise the changelog entry after its prefix and drop the repeated
word, and add `warning` to the component README's list of types, which this
change makes a real intent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: never hand a JSX message to the announcer

`Notice.Root` renders `spokenMessage` to a string during its own render. When
the message holds a component with hooks — every error notice interpolates a
`Link` — that component's hooks land in `Notice.Root`'s hook list, and the
`useEffect` on the next line reads a mismatched slot and throws. React then
unmounts the whole Protect dashboard, so any failed request blanked the page.
`safeRenderToString`'s try/catch does not help: the throw comes from the hook
after it.

Only a plain string is announced now, which covers the saved and saving toasts.
Announcing errors needs a spoken string carried alongside the JSX; that is worth
doing separately. The same trap is recorded in
`packages/newsletter/_inc/subscribers/components/modals/add-subscribers-modal.tsx`.

Two follow-ups from the same review: the e2e assertion is scoped to the app root,
because `speak()` leaves its text in the live region after the notice goes and
the helper runs seven times; and `NoticeState.type` gains `warning`, which the
component and README already offer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: bundle the two packages the close control pulls in

`Notice.CloseIcon` reaches `@wordpress/theme` and `@wordpress/private-apis`
through `IconButton` and its tooltip, so the build started externalising them to
the `wp-theme` and `wp-private-apis` script handles. Protect registers no shim
for those, and WordPress refuses to enqueue a bundle with an unmet dependency —
the admin page would render empty wherever core does not supply them.

They are bundled instead, which is what the Jetpack plugin's own admin bundles
do for the same reason. Verified by rebuilding: both handles are gone from
`build/index.asset.php`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: correct a comment the e2e scoping outdated

The Firewall block still said the "Changes saved" assertion stays page-scoped
on purpose. It does not any more.

The changelog entry becomes `minor`, matching the same migration for the
Jetpack plugin in #52015: every Protect notice is restyled, one turns amber,
and the toasts start announcing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: announce error notices to screen readers

The announcement was derived from the message, and only a plain string
was ever handed to `Notice.Root` — anything else corrupts its hook order.
`showErrorNotice` always builds JSX, because it interpolates a support
link, so failed saves stayed silent while "Changes saved." did not.

Carry the announcement in the notice state instead: `showErrorNotice`
sets `spokenMessage` from the plain string it already receives, and the
component prefers it over the message. It still resolves to null rather
than undefined, so `Notice.Root` never falls back to rendering children.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: speak the whole error, and speak it every time

The announcement dropped the "Please try again or contact support." sentence
because it only exists with interpolation tags. Stripping the tags off the
translated string gives the plain sentence at no translation cost, so the
announcement now carries what the notice says.

Each notice also gets an identity, and the toast is keyed on it. Without that a
repeat of the same error re-renders with identical props, the announcing effect
never re-runs, and the second failure is silent. `use-fixers-query` is the path
that reaches it: its polling error fires the same message with no other notice
in between.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: re-announce without stealing focus

Keying the toast on the notice id remounted it, which destroyed the node focus
was in: a keyboard user reading the error, with focus on its support link or
dismiss button, lost focus to the body when the next notice arrived. In an
accessibility change that is not a fair trade.

The announcement now varies instead of the element. A repeat alternates a
trailing non-breaking space — the same device `@wordpress/a11y` uses for this —
so the announcing effect re-runs while the element stays mounted. Two identical
errors in a row take consecutive ids, so the parity always flips between them;
anything that arrives in between announces on its own text.

Two more from the same review: the provider stamps the id, so no future writer
can forget it and silently break the repeat, and the tag strip no longer names
`supportLink`, so renaming the tag cannot silently stop it working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: announce per notice, not per distinct message

The parity nonce was wrong. `Notice.Root` announces when the value it is handed
changes, so the previous revision alternated a trailing space keyed on the
notice id — but the id advances once per stored notice while the announcement
happens once per committed render. Any event that stores an even number of
notices in one commit leaves the parity where it was, and the repeat is silent
again. `use-fixers-query` is exactly that shape: one instance per threat row
plus the modals, all sharing a query, all raising the same error in one commit.

So the announcement moves here, keyed on the notice, and `Notice.Root` is handed
`null` unconditionally — it never renders a message to a string now, which is
what the crash came from. `speak()` handles an identical repeat itself.

Two more from the same review: the id is stamped outside the state updater, and
the component README documents `spokenMessage` and `id`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: tie the dismiss timer to the notice as well

`duration` keyed its timer on the message, so a second identical success
inherited whatever was left of the first one's 7.5 seconds instead of starting
again. The notice's id is what says "this is a different notice", and the
announcement already uses it.

Also from review: the mid-render rationale is stated once, on the prop that
carries it, and the new dependency is filed in alphabetical order.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: drop a comment's claim of a nonexistent dialog

The comment above `spokenMessage` said the modal notices are read when their
dialog opens. Protect has no dialog: `components/modal/index.jsx` renders a
plain `<div>` with no `role="dialog"`, no `aria-modal` and no focus move into
the window, so nothing reads those notices. Keep the half that is true and
load-bearing -- why the prop takes strings only -- and drop the rest.

Also cut the `requestMap` comment in the plugin's webpack config to the live
constraint. The mechanism is already spelled out in three other configs, and a
fourth copy drifts. The code is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: keep the joint-bundling constraint on the requestMap

The pair has to be bundled together: `@wordpress/theme` opts in and calls
`lock()` at module scope, and `@wordpress/private-apis` keeps its consent map
per module instance. Bundle one and externalize the other and the two maps
diverge, which surfaces at runtime as "Cannot unlock an object that was not
locked before" or a duplicate opt-in, with nothing pointing back at the build
config. The full account lives in #48173.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: point at the requestMap rationale instead of restating it

Three other webpack configs already carry this reasoning in full. A fourth copy
is the one outcome that guarantees they drift, so this leaves the constraint and
the reference and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: name the tag the spoken message strips

CodeQL reads a `<[^>]+>` strip as HTML sanitization and files it high severity
(js/incomplete-multi-character-sanitization). Nothing here reaches innerHTML —
the result goes to `speak()`, which writes textContent — but the generic form
buys nothing over naming the one placeholder the string actually carries, and
`createInterpolateElement` breaks just as loudly if that name ever changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx

* Protect notices: answer the review on the announcement

Rename the per-notice stamp from `id` to `noticeId`. It is a sequence number
rather than an identity, and `id` would have become a real DOM attribute the
day `Notice.Root` is handed rest props.

Say why only the toast announces, and drop the comparison with `Notice.Root`'s
own announcement: WordPress/gutenberg#82737 proposes removing that, so the
comparison would describe a mechanism a reader cannot reach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lo29yrft9KqShYK3p1wQe6

* Protect notices: announce a failing fixers poll once per streak

Every failed poll produces a new Error object, so keying the effect on it
re-announced the same message assertively every five seconds for as long as
the poll kept failing. Key on `isError` instead: one announcement per streak,
and another only once a success has come in between.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lo29yrft9KqShYK3p1wQe6

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Douglas <douglas.henri@automattic.com>

Committed via a GitHub action: https://github.com/Automattic/jetpack/actions/runs/34654909076

Upstream-Ref: Automattic/jetpack@d121e06
@ciampo
ciampo force-pushed the codex/remove-ui-notice-speak branch from a839c12 to 1de9984 Compare September 14, 2026 23:06
@ciampo ciampo added the [Type] Enhancement A suggestion for improvement. label Sep 14, 2026
@ciampo
ciampo marked this pull request as ready for review September 14, 2026 23:07
@ciampo
ciampo requested review from a team and retrofox as code owners September 14, 2026 23:07
@github-actions

github-actions Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: ciampo <mciampini@git.wordpress.org>
Co-authored-by: mirka <0mirka00@git.wordpress.org>
Co-authored-by: simison <simison@git.wordpress.org>
Co-authored-by: aduth <aduth@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@ciampo ciampo self-assigned this Sep 14, 2026
@ciampo

ciampo commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

To me it would make sense to add Storybook documentation or note with role example so that agents and folks know to think to add it where it actually is useful, or alternatively keep the API even if taking down the automated announcement.

@simison I added Storybook migration guidance with static, role="alert", and role="status" examples, keeping action labels outside the live regions.

@mirka mirka added the [Type] Breaking Change For PRs that introduce a change that will break existing functionality label Sep 15, 2026

@mirka mirka left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm getting conflicting information about the reliability of role="alert".

Do not try to dynamically add/generate an element with role="alert" that is already populated with the alert message you want announced - this generally does not lead to an announcement, as it is not a content change. (MDN)

Dynamically rendered alerts are automatically announced by most screen readers (APG)

Browser support seems acceptable, according to this page.

Which leads me to the question of what our "suggested pattern" is going to be. role="alert" with the content pre-populated might be fine in terms of browser/SR support, but only for when the notice is serious enough to be an alert. The widget-frame one, for example, is questionable in that regard to me. And of course many other usages are not even going to be suitable for role="alert", and then we can't just put aria-live="polite" on a pre-populated Notice element and be done with it. I'm starting to wonder if our suggested pattern should actually be based on speak(). Perhaps especially, since we'll likely want to announce the Notice.Title as well. Thoughts?

Comment thread packages/customize-widgets/src/components/error-boundary/index.jsx Outdated
@ciampo ciampo removed the [Type] Enhancement A suggestion for improvement. label Sep 15, 2026
@ciampo
ciampo force-pushed the codex/remove-ui-notice-speak branch from e7864bd to 684d482 Compare September 15, 2026 12:48
@ciampo

ciampo commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

I'm starting to wonder if our suggested pattern should actually be based on speak(). Perhaps especially, since we'll likely want to announce the Notice.Title as well. Thoughts?

@mirka this could be probably the best solution, at least for now, while we decide exactly what behavior should be implemented by Notice.List or its alternatives?

For now, I updated examples and error boundaries to use speak()

@ciampo
ciampo requested review from aduth and mirka September 15, 2026 12:53

@mirka mirka left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Just for posterity, I want to explicit note that routes/connectors-home/stage.tsx also has a wp-ui Notice instance, but was determined not suitable for an announcement.

}

componentDidCatch( error, errorInfo ) {
speak(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is a third translated string that restates Title + Description. Can we hoist those __() calls and speak( `${ title }. ${ description }` ) to prevent drift and unnecessary translations? Same for the other files.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point. I updated all three error boundaries to reuse the translated title and description for the announcement.

@aduth

aduth commented Sep 18, 2026

Copy link
Copy Markdown
Member

Hey 👋 I wanted to give you a heads-up since this pull request is affected by recent validation changes for changelog files.

#83043 adds additional validation for changelog files. You'll note that this pull request is currently failing a "Required changes from trunk" check.

What you'll need to do: You will need to either rebase or merge the latest code from trunk. In addition, a cursory review of open pull requests identified this pull request as potentially failing under the new validation checks. You will want to double-check that any changes to CHANGELOG.md files follow the Maintaining Changelogs guidance, which has been improved as part of these recent changes.

@ciampo
ciampo force-pushed the codex/remove-ui-notice-speak branch from b2e91f1 to d01ac37 Compare September 24, 2026 12:38
@ciampo
ciampo enabled auto-merge (squash) September 24, 2026 12:41
@ciampo
ciampo merged commit 5d46e87 into trunk Sep 24, 2026
100 of 101 checks passed
@ciampo
ciampo deleted the codex/remove-ui-notice-speak branch September 24, 2026 13:04
@github-actions github-actions Bot added this to the Gutenberg 24.2 milestone Sep 24, 2026
@joedolson

Copy link
Copy Markdown
Contributor

I'd strongly recommend sticking to just a single route for spoken notifications, so that any later changes to the mechanism can be modified globally.

role="alert" is still really a declaration that's in flux, and still under discussion by the W3C in the ARIA spec. See w3c/aria#2153; the discussions are a bit of a rabbit hole, however.

widoz pushed a commit to widoz/gutenberg that referenced this pull request Oct 2, 2026
* UI: Remove automatic Notice announcements

* UI: Add Notice announcement changelog

* Packages: Add Notice consumer changelogs

* UI: Strengthen Notice announcement regression

* Notice: Document announcements and test message-only alerts

* Notice: Remove redundant announcement tests

* Notice: Let consumers announce errors explicitly

* Widget Dashboard: Add a11y TypeScript project references

* Packages: Move Notice changelogs to Unreleased

* Notice: Reuse translated error text for announcements

---

Co-authored-by: ciampo <mciampini@git.wordpress.org>
Co-authored-by: mirka <0mirka00@git.wordpress.org>
Co-authored-by: simison <simison@git.wordpress.org>
Co-authored-by: aduth <aduth@git.wordpress.org>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Package] Edit Widgets /packages/edit-widgets [Package] Editor /packages/editor [Package] UI /packages/ui [Package] Widget Dashboard /packages/widget-dashboard [Type] Breaking Change For PRs that introduce a change that will break existing functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants