Repository navigation
Jetpack notices: back SimpleNotice with the @wordpress/ui Notice - #52015
Conversation
SimpleNotice and NoticeAction now wrap `Notice.Root` and its action subcomponents, so all 14 files that render a Jetpack admin notice move to the design system at once without touching their call sites. SimpleNotice keeps its two-slot contract: with `text` set, children are the actions and go into `Notice.Actions`; without it, children are the body. `NoticeAction` maps `href` to `Notice.ActionLink` with `external` becoming `openInNewTab`, which also draws the external-link arrow the Gridicon used to supply, and falls back to `Notice.ActionButton` when there is no href. The `dops-notice` classes are gone from the component, so the old rules cannot fight the design system's styles. Two consequences handled here: `AdminNotices` still rebuilds server-rendered core notices into that chassis with jQuery, so its stylesheet is now loaded from `scss/style.scss`; and the floating notice stack targets its children rather than a notice class, because `Notice.Root`'s CSS-module class name is hashed. Known gaps, none of which block the swap: `isCompact` has no design system equivalent, a Gridicon name passed as `icon` is dropped in favour of the intent's own icon, and `NoticeAction`'s `variant` and `icon` props are no longer honoured at their single call sites each. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017AnRQ7ZE68VGgGVQB4pFND
|
Are you an Automattician? Please test your changes on all WordPress.com environments to help mitigate accidental explosions.
Interested in more tips and information?
|
|
Thank you for your PR! When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:
This comment will be updated as you work on your PR and make changes. If you think that some of those checks are not needed for your PR, please explain why you think so. Thanks for cooperation 🤖 Follow this PR Review Process:
If you have questions about anything, reach out in #jetpack-developers for guidance! Jetpack plugin: The Jetpack plugin has different release cadences depending on the platform:
If you have any questions about the release process, please ask in the #jetpack-releases channel on Slack. |
Code Coverage SummaryCoverage changed in 5 files.
Full summary · PHP report · JS report If appropriate, add one of these labels to override the failing coverage check:
Covered by non-unit tests
|
… carried Wrapping SimpleNotice over `@wordpress/ui` dropped the `dops-notice` class, and three behaviours went with it, none of them visible in that diff. `SimpleNotice` now always contributes a `jp-notice` class alongside whatever the caller passes, because `Notice.Root`'s own class name is a CSS-module hash and page styles need something stable to target. The stacked notices lost their 24px gap, which `.dops-notice` supplied as `margin-bottom`. Both containers that stack notices use `Stack` instead: `JetpackNotices`, which was a bare `<div aria-live="polite">`, and `JetpackConnectionErrors`, which returned a bare array. `gap="xl"` is the same 24px, applied as an inline style, so nothing on the page can override it. Links inside notices lost their underline to `.jetpack-pagestyles a`, which is unlayered and so beats every `@layer wp-ui` rule regardless of specificity; `Link` sets no `text-decoration` of its own and relies on the UA underline. A scoped counter-rule restores it, to be removed once the `@wordpress/ui` global CSS defense grows a `text-decoration` bridge. Inside a settings card the notices were laid out for the old chassis, which was a dark full-bleed banner with square corners. A rounded, bordered design system notice cannot sit flush against the card edge, so both here get the padding the rest of the card uses. Account protection's notices also move to real JSX children rather than a `children` prop, which any actual children would beat. The Like buttons notice moves out of its `SettingsGroup`. That group paints an 80% white scrim over its children in offline mode, which left the notice below readable contrast and its link under an overlay that takes pointer events — on a notice whose whole purpose is to explain why the controls are unavailable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NfGbCrDbhYmgwY5hW68VLA
…exists `@wordpress/ui` `Notice` renders a title as `heading-md` and a description as `body-md`. SimpleNotice only ever filled the description, so callers that wanted a heading built one by hand. A `title` prop feeds `Notice.Title`. Four call sites already had a title in their markup and now pass it instead: the deprecation notice drops an inline `fontWeight: 600` div and hands over the `title` prop it already accepted, the static warning splits its two server-substituted placeholders, and both SEO banners drop a `<strong>`. This is extraction only — no copy changes and no new translated strings. The remaining notices either carry a single sentence, where a title would have to be written, or take their text from the server, where it would have to be derived from an error code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NfGbCrDbhYmgwY5hW68VLA
…othing `JetpackStateNotices` and `DismissableNotices` each wrapped their output in a `<div>`, so both left an empty element in the notice stack whenever they had nothing to show. `DismissableNotices` always does: its `renderNotices` has only a `default: return false`. That was free while each notice carried its own `margin-bottom`, because an empty div has no margin. The stack is now a flex `Stack`, and `gap` applies between every child, empty or not — so a lone notice sat between two 24px bands. Both return null instead. The other siblings already returned false, which puts no node in the DOM. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NfGbCrDbhYmgwY5hW68VLA
`Notice.Root` sets `container-type: inline-size` for its own container query, which means it is sized as if it had no contents. Above 660px the toast stack is a fixed container with `left: auto` and no width, so it shrinks to fit — and a size-contained child contributes nothing. The container collapsed and the icon and text spilled outside the tinted box. The legacy notice was a plain flex element, so it fed that calculation. Turning containment off for the toasts restores it, with no width invented. The query this disables only applies to a notice with a title, which a toast never has. The `text-align` pair goes with it: that was how the legacy notice aligned itself inside a full-width container, and the container now anchors itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NfGbCrDbhYmgwY5hW68VLA
The toast carried a Calypso-era shadow: two layers at alpha 0.2 and 0.15, the second a 56px blur at zero offset. Under a light tinted card that halo reads as a smudge rather than as elevation. `@wordpress/ui` has no shadow token. Its components define a private `--_wp-ui-elevation-*` locally instead, and a floating transient overlay belongs at the same level as a popover or a menu, so this copies that stack. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NfGbCrDbhYmgwY5hW68VLA
|
Reviewed on the PR head. Findings, most severe first. 1. 7 tests fail now, and CI won't catch them. Three causes:
2. 3. Two more notices sit flush against the card edge.
4. 5. Nit. Checked and correct: the client and GUI suites as CI runs them (46 + 19 suites) pass; Could you fix 1, 3 and 4 before this lands? 2 and 5 are your call. — Tangerine |
…remaining flush ones
Review found four things the migration missed.
`Notice.Root` defaults `spokenMessage` to its children, so every notice now
called `speak()` on mount — including permanent ones like the SEO banners and
the block-theme notice, which announced on page load, and the ones inside the
`aria-live` stack, which announced twice. The legacy notice never announced, and
the notices that should are already inside a live region, so this passes
`spokenMessage={ null }`.
Two more notices are direct `SettingsCard` children and so ran to the card's
edges: the SEO opt-in banner and the reader's WordPress.com notice. Both get the
same padded wrapper as account protection.
The Sharing buttons block-theme notice moves out of its `SettingsGroup` for the
same reason the Like buttons one did: the group paints an opaque scrim over its
children in offline mode, which takes pointer events.
Three test assertions were reading the old chassis: two on the `dops-notice`
class, one on a link's accessible name, which now carries the design system's
"(opens in a new tab)" suffix. None of these suites run in CI — `jest.config.gui.js`
matches `test/component.js`, not `.jsx`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfGbCrDbhYmgwY5hW68VLA
|
All four actioned in 8bf8616. Good catches — 1 and 2 were both real breakage. 1. Failing tests. Fixed. The cause is broader than the three files: Your run missed one, because it is named Component suites are now at parity with trunk. The 2. 3. Flush notices. Both wrapped — 4. Sharing buttons scrim. Fixed, moved out of the 5. — Terminator |
…rim too A sweep for the same shape found one more: the "Your SEO settings are managed by the following plugin" notice sits inside a `SettingsGroup disableInOfflineMode`, so an offline site with Yoast or Rank Math installed renders it under the scrim. Same treatment as the Like buttons and Sharing buttons notices — outside the group, in the card's padding. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NfGbCrDbhYmgwY5hW68VLA
|
Second pass on 1. <div className="jp-settings-card__notice">{ this.seoOptInBanner() }</div>The wrapper is unguarded, but const optInBanner = this.seoOptInBanner();
…
{ optInBanner && <div className="jp-settings-card__notice">{ optInBanner }</div> }2. Drop
With those two, — Tangerine |
…ere is no banner `seoOptInBanner()` returns null unless the SEO package reports the opt-in is available, which is most sites. The padded wrapper around it was unguarded, so those sites got 32px of empty space above the card's description. The call is hoisted so the guard does not run it twice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NfGbCrDbhYmgwY5hW68VLA
|
1. Empty padded div. Correct, and a regression I introduced. Fixed in 5ba5bc9, hoisted as you suggested. Worth adding that the null case is the common one: 2. Keeping b3efb09. I checked both halves of the objection and neither holds. The non-admin path is unreachable. The scope argument covers all three, not one. At
So That makes the three one case. Dropping only the SEO fix leaves the PR internally inconsistent — the same defect fixed in two files and left in a third. The options are all three or none, and none means also reverting the Likes fix in 51d0a72, which is already reviewed on screenshots in the PR body. Keeping all three. Design review will confirm the treatment. Separately: the — Terminator |
Four connection-error notices passed `icon="link-break"`. `Notice.Root` takes an icon element rather than a Gridicon name, so the design system's error icon has been rendering there since the migration and the prop did nothing. Standardising on the intent's icon is the decision, so the props go rather than being converted to elements. `SimpleNotice` still honours an icon passed as an element, which the SocialLogo in `jetpack-notices/index.jsx` relies on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NfGbCrDbhYmgwY5hW68VLA
dhasilva
left a comment
There was a problem hiding this comment.
Review depth: standard (363 lines, 1 project)
PR Review: #52015 — Jetpack notices: back SimpleNotice with the @wordpress/ui Notice
Summary
Swaps the implementation of SimpleNotice/NoticeAction for @wordpress/ui's Notice compound component while keeping the call-site contract, and restores the layout behaviour the dops-notice class was carrying. The migration mechanics are sound — I verified the WPDS API claims against @wordpress/ui@0.21.0 in node_modules and they hold up (see "Other checks verified" below). The findings below are almost all about the edges of the prop surface: props that survived in propTypes but stopped doing anything, and one prop whose semantics changed in a way that has an observable side effect.
Affected Projects
plugins/jetpack only. SimpleNotice lives at projects/plugins/jetpack/_inc/client/components/notice/ — it is not a shared js-package, and git grep finds no consumer outside _inc/client/. (@automattic/jetpack-components exports an unrelated Notice.) So there is no cross-project fan-out and no cross-package version skew here.
PR Description
Thorough, and the screenshots and repro snippets are genuinely useful. Two things:
- The data/tracking answer is no longer accurate. See blocker 1 — the
displaychange causesNoticeActionReconnect's mount-timeuseEffectto re-firejetpack_termination_error_notice_view. - The PR still carries
[Status] In Progressand its body says "Draft, pending design review on one thing", while the GitHub draft flag is off. Worth reconciling before requesting review.
Confidentiality: clean, no private URLs.
Changelog
Present and in the right (and only) project.
[suggestion]The entry readsNotices: render Jetpack admin notices…. AGENTS.md requires entries to start with a capital letter; with a component prefix the repo convention isPrefix: Capitalised sentence.(e.g. "Connection: Fix issue with site registration."). →Notices: Render Jetpack admin notices with the WordPress design system Notice component.
changelog/update-simple-notice-wpds-wrapper
No plugin fan-out is needed — the component is internal to plugins/jetpack.
Backward Compatibility / Public API
I enumerated the before/after prop surface rather than reading it off the diff. status, showDismiss, duration, text, className, onDismissClick, dismissText and the two-slot children contract all survive intact. Four do not:
-
[blocker]displaychanged from "render hidden" to "do not render". The old component always emitted the node and addedis-hidden(display: noneincomponents/notice/style.scss); the new one early-returnsnull, which unmounts the whole subtree.
components/notice/index.jsx#L87-L89That matters on one live path.
JetpackNoticespassesdisplay={ ! this.props.isReconnectingSite }toJetpackConnectionErrors, which forwards it to every notice, includingErrorNoticeCycleConnection— whose childNoticeActionReconnectfiresjetpack_termination_error_notice_viewfrom a mount-timeuseEffect. So: notice mounts (view #1) → user clicks Restore Connection →SITE_RECONNECTflipsisReconnectingSite→ the notice unmounts → the reconnect fails →SITE_RECONNECT_FAILflips it back → the notice remounts → view #2. Previously the node stayed mounted behinddisplay: noneand the event fired once. The redux dispatches indoReconnectsurvive the unmount, so the flow itself still completes, butuseRestoreConnection's own hook state is discarded mid-flight.Fix: keep the element mounted and hide it. Note that
hidden={ ! display }alone will not work —@wordpress/ui's.noticesetsdisplay: gridin@layer wp-ui, and an author-layer rule beats the UA's[hidden] { display: none }. Either passstyle={ display ? undefined : { display: 'none' } }, or add.jp-notice.is-hidden { display: none }alongside the class you already introduced. If unmounting is deliberate, it belongs in the "Behaviour thedops-noticeclass was carrying" table and the tracking question needs re-answering. -
[suggestion]The widenediconpropType has no user, and the stated justification doesn't hold.iconnow accepts a node, andisValidElement( icon ) ? icon : undefinedsilently drops strings.
components/notice/index.jsx#L31 · #L104The PR body says "An icon passed as an element still works, which the
SocialLogoinjetpack-notices/index.jsx:161relies on" — but thaticon={ <SocialLogo … /> }is onConnectionBanner, notSimpleNotice. After this PR removes the fouricon="link-break"props, noSimpleNoticecall site passesiconat all (I grepped all 15). So both branches are dead. Since the decision is "standardise on the intent icon", I'd drop the prop entirely — that way a staleicon="…"fails loudly in review instead of silently doing nothing. -
[suggestion]NoticeAction'svariantandiconare still declared but ignored.propTypesstill advertisesvariant: oneOf(['primary','secondary'])andicon, and neither reaches the renderedNotice.ActionLink/ActionButton.
components/notice/notice-action.jsx#L12-L13The PR flags the
variantquestion as pending design, which is fine — but the live state is a prop that type-checks and does nothing, plus the now-inertvariant="secondary"at jetpack-connection-errors.jsx#L71. Whichever way design lands,propTypesand the call site should agree with the render before merge. -
[suggestion]isCompactstops producing compact styling, and#52058is still open.isCompactis now read only for the (dead)showDismissdestructuring default. Its remaining call site iscomponents/dash-item/index.jsx:106, andcomponents/dash-item/style.scss:29's.dops-notice { margin-block: -1px }goes dead with it. #52058 (which removes that branch) has not merged and touches the samedash-item/test/component.jsxthis PR edits, so they will conflict. Either land #52058 first, or say in the description that this PR knowingly ships a restyled "Updates needed" badge until it does.
Bugs
-
[suggestion]JetpackConnectionErrorscan render an empty flex item — the phantom-gap failure this PR fixed inJetpackStateNoticesandDismissableNotices.render()now always emits aStack, even when every child renders nothing.
jetpack-connection-errors.jsx#L128-L133Two ways in: every error is filtered out by the
Object.hasOwn( error, 'action' )guard, ordisplayis false (i.e. exactly the reconnecting state above), where all children returnnull. Either way the outerJetpackNoticesStackgets a zero-height in-flow child and pays an extra 24px gap. Same fix as the other two: bail tonullwhenerrorsToDisplayis empty. (TheNoticesListtoast container isposition: fixed, so it is not a flex item and is correctly unaffected.)
HTML Structure / Accessibility
Net a11y improvement, worth saying explicitly: the dismiss control goes from <span role="button" tabIndex="0"> to a real <button>; Notice.CloseIcon supplies a default label so the call sites that never passed dismissText (state-notices, the licensing error, the global toasts) stop shipping an empty screen-reader-text; and an action with no href now renders a focusable <button> instead of a bare <a>. spokenMessage={ null } is justified — NoticesList has exactly one render site and it is inside the aria-live="polite" Stack, and Stack does forward aria-live (it spreads ...props onto the div), so the toasts still announce.
-
[suggestion]Notice.Descriptionrenders a<span>, and several migrated call sites hand it block content.Notice.Description→Text→defaultTagName: 'span'.deprecation-notice.jsxandstatic-warning.jsxpass a<div>;traffic/index.jsxandtraffic/seo.jsxpass<div><p>…</p></div>;OfflineModeNotice's interpolatedtextcontains a<ul>.
components/notice/index.jsx#L110Layout survives (the span is a grid item, so it's blockified), so this is a validity issue rather than a visual one — but it's a cheap fix:
<Notice.Description render={ <div /> }>, which the props type supports. -
[suggestion]<Notice.Description>renders unconditionally, so a notice with neithertextnorchildrenemits an empty styled span. Guarding it the wayTitleandActionsare guarded costs one ternary.
RTL
Clean, and slightly better than before: the removed text-align: right / text-align: left pair was the only physical property this diff touched. The new .global-notices > *, .jp-settings-card__notice (symmetric shorthand padding) and .jetpack-pagestyles .jp-notice a rules are all direction-neutral. The physical right/left offsets left in .global-notices are pre-existing and untouched.
CSS / Dead Rules
[suggestion]Three stylesheets still scope rules to.dops-noticefor notices that are nowNotice.Root, and are silently dead:components/jetpack-notices/style.scss:6(.dops-notice ul { font-size: 12px }under 480px — that one is a real, if small, loss for the offline-mode reason list),components/dash-item/style.scss:29, andat-a-glance/style.scss:435. Keepingcomponents/notice/style.scssloaded for the jQueryAdminNoticespath is correct and I verified it does rebuild that markup — but these three are not on that path.
Comment Budget
-
[suggestion]One block over budget: six prose lines carrying three unrelated rationales (why the selector targets> *, whypointer-eventsis re-enabled, whycontainer-typeis reset).
global-notices/style.scss#L41-L48Per AGENTS.md, three traps get three short comments, not one essay. Suggest one line above the selector — "
Notice.Root's class is a CSS-module hash, so target children" — and a one-liner on thecontainer-typedeclaration itself, which is the only genuinely surprising one.Two smaller notes in the same family, both in
scss/shared/_main.scss#L49-L55: "the unlayered rule above" reads as the rule immediately above it (.dops-notice__text a) when it means.jetpack-pagestyles aseven lines up — naming it removes the ambiguity; and "Drop this once--_gcd-a-text-decoration-linelands upstream" is a pointer with nothing to point at, which is the provenance-that-rots shape. A Gutenberg issue link would make it durable.
Test Coverage / Quality
[suggestion]components/notice/still has no test directory, and this PR rewrites it end to end. The behaviours worth pinning are exactly the ones this review is arguing about: thestatus→intentmap, the two-slottext-vs-children contract, the newtitleslot, and whateverdisplayends up meaning. Fifteen call sites depend on all four.[suggestion]The dash-item test lost its assertion. It is named "shows a warning badge when status isis-warning" but now only asserts the text is present — it would pass with a broken intent map.
dash-item/test/component.jsx#L188-L193
(Moot if #52058 lands first and deletes it — another reason to settle the ordering.)[suggestion]name: /Submit Beta feedback/loosens the assertion where it could tighten it.name: 'Submit Beta feedback (opens in a new tab)'asserts the new accessible name instead of tolerating it, and then the explanatory comment becomes unnecessary.
jetpack-notices/test/component.jsx#L151-L152
Other checks verified (no findings)
I checked the WPDS claims against @wordpress/ui@0.21.0 rather than taking them on trust, and they all hold:
container-type: inline-sizereally is in@layer wp-ui > components, so the unlayered.global-notices > *reset wins.clsx( 'jp-notice', className )survives — base-ui'smergePropsconcatenatesclassNamerather than overwriting, sojp-noticeand the module hash both land.spokenMessage={ null }does suppressspeak()(safeRenderToStringearly-returns on a falsy message).Notice.ActionLinkdoes acceptopenInNewTab(ActionLinkPropsomits onlyvariant/tonefromLinkProps), andLinkdraws the↗via aninline-block::after, so the new underline rule doesn't underline the arrow.Stack gap="xl"is 24px, and the builtstack.mjsinlines the24pxfallback into thevar(), so it holds even though this page never loads the@wordpress/themetoken sheet..jetpack-pagestyles .jp-notice aat (0,2,1) beats both.jetpack-pagestyles aand the unlayeredglobal-css-defense.arule, which indeed defines notext-decoration-*.Notice.CloseIcon's undocumentedonClickdoes reach the button through theIconButton→Buttonspread.
Security, performance, error handling, translations (no new strings), feature gating, dependency changes, PHP/WP version compat, Phan suppressions: not applicable or all clear.
Test Results
Not executed. This review ran in a parallel read-only context alongside two sibling reviews against the same checkout, so the worktree/test/phan steps were deliberately skipped to avoid racing them.
- JS: Skipped (runner would be
jp test js plugins/jetpack). The PR reports 203/203 admin-page, 393/393 components and 908/908 extensions passing on the branch. - PHP: N/A — no PHP touched.
- Phan: N/A — no PHP touched.
Verdict
Needs changes before merge. One blocker: display silently changed from "render hidden" to "unmount", which duplicates a jetpack_termination_error_notice_view track and tears down the reconnect subtree mid-flight — and the PR answers "No" to the tracking question. The rest are suggestions, but the cluster of props that still type-check and no longer do anything (icon on both components, variant, isCompact) is worth clearing in the same pass, since a wrapper whose declared surface has drifted from its real one is exactly what the next migration will trip over. The underlying migration is careful work and the CSS reasoning checks out.
Reviewed 22 files, 363 lines changed. Checked: PR description, changelog, backward compatibility, bugs, HTML/a11y/RTL, CSS logical properties, dead CSS, comment budget, cross-project impact, WPDS API correctness, test coverage and quality.
Generated by Claude.
…ing it
The original swap turned `display={ false }` from "render, then hide with
`is-hidden`" into an early `return null`. That unmounts the subtree, and
`JetpackNotices` passes `display={ ! isReconnectingSite }` down to the connection
errors — whose `NoticeActionReconnect` records
`jetpack_termination_error_notice_view` from a mount effect. A failed reconnect
therefore unmounted and remounted the notice and recorded the view twice, on top
of discarding `useRestoreConnection`'s own state mid-flight.
The class comes back, with the rule unlayered because `Notice.Root` sets
`display: grid` inside `@layer wp-ui`, which `[hidden]` cannot override.
Also clears the props that survived the migration without a job. Nothing passes
`icon` to `SimpleNotice` any more, and `isValidElement` silently dropped a string
anyway; `NoticeAction`'s `icon` and `variant` never reached the rendered action.
A stale value should fail in review rather than do nothing quietly, so the
declarations and their last two call sites go together. Whether a secondary
action needs its own treatment is still open, and is recorded on the PR.
`JetpackConnectionErrors` returns null when nothing renders, which is the empty
flex child that `JetpackStateNotices` and `DismissableNotices` were already
fixed for, and `Notice.Description` renders a div, since several call sites hand
it block content and it defaults to a span.
The wrapper now has tests. It had none, which is why the `display` regression
survived nine commits: both new display cases fail against the old behaviour.
They run once the component suites reach CI in #52056.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NfGbCrDbhYmgwY5hW68VLA
…-wpds-wrapper # Conflicts: # projects/plugins/jetpack/_inc/client/components/dash-item/test/component.jsx
* Jetpack: run the component tests in CI `jest.config.gui.js` matched `_inc/client/**/test/component.js`. Every component test under `_inc/client/components/` is named `component.jsx`, and `jest.config.client.js` excludes `components/` from its roots, so 20 files ran in neither suite. PR #52015 broke seven of them and CI stayed green for six commits. Matching `.jsx` as well takes the GUI suite from 19 files and 203 tests to 54 and 487. The list stays curated rather than becoming a bare glob, for the reason the comment above it already gives. Two tests in `jetpack-connection-errors.test.jsx` failed once visible, both since before #52015. It rendered through `@testing-library/react` directly, so there was no store for the `reconnect` action's connected `NoticeActionReconnect`; it now uses the shared helper and supplies the state that selector reads. And an assertion that no reconnect CTA is present used a substring match, which the notice's own message satisfies — it now names the action. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NfGbCrDbhYmgwY5hW68VLA * Jetpack: make the 'none' connection-error assertion guard something The replacement assertion could not fail either. `NoticeAction` renders an `<a>` with no `href` when given `onClick`, and an anchor without `href` has no implicit link role, so `queryByRole( 'link' )` returns null whether or not the reconnect CTA is present. The neighbouring `queryByRole( 'button' )` was vacuous for the same shape: every variant of this notice passes `showDismiss={ false }`, so none of them renders a button. Match the CTA by its label instead, and give the test the store state the connected `NoticeActionReconnect` needs, so a regression renders rather than crashes. Broaden the `.test.{js,jsx}` pattern from `components/` to all of `_inc/client`, so a file added under `traffic/` or `at-a-glance/` cannot land in neither suite the same way. `_inc/client` holds exactly two such files today, both already selected; the 20 others live under `jest.config.client.js`'s roots, outside this config's `_inc/client/**` anchor. Drop the seven entries the name globs already match. `jest --listTests` selects the same 54 files before and after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NfGbCrDbhYmgwY5hW68VLA * Jetpack: fold the component.js and component.jsx patterns together Matches the `*.test.{js,jsx}` glob on the line below. Four `component.js` files remain under `_inc/client`, so both extensions are still needed. `jest --listTests` selects the same 54 files before and after. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NfGbCrDbhYmgwY5hW68VLA * Jetpack: add a positive control for the connection-error CTA query The 'none' case asserts the reconnect CTA is absent. Nothing proved that query could see the CTA when it is present, so the assertion could rot back into a no-op without anyone noticing. The multiple-errors test already renders a `reconnect` action, so it can carry the control. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NfGbCrDbhYmgwY5hW68VLA --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment said to drop this rule once the design system grows a `text-decoration` bridge. That would regress three notices: the upstream defense only reaches elements carrying wp-ui's `Link` class, and the offline-mode notice and two state notices interpolate a raw `<a>` that nothing else underlines. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NfGbCrDbhYmgwY5hW68VLA
Four small items left from review, none behavioural: - The changelog entry capitalises after the component prefix, per AGENTS.md. - Two comment blocks come back inside the budget. The `.global-notices > *` block carried three unrelated rationales in one six-line essay; each trap now sits on the declaration it explains. The underline rule's seven-line note loses the upstream detail that duplicates the linked issue. - `dismissText` is read in `render()`, so it is declared in `propTypes`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx
`--_wp-ui-elevation-md` is not a token: `@wordpress/ui` redeclares it on each component's own element (popover's `.surface`, item-popup's `.popup`), inside `@layer wp-ui`, and never publishes it. Referencing it here would resolve to nothing and drop the shadow silently. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx
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
|
@CGastrell, how would it look if you went all in? :-) I'm particularly interested if this override style for SEO pages could be removed and all notices would just appear where they should: jetpack/projects/packages/seo/_inc/admin-page-layout.scss Lines 120 to 178 in dce1f9c |
* Remove CSS dead code left by the SimpleNotice/@wordpress/ui migration PR #52015 rewrote SimpleNotice to render the @wordpress/ui Notice and dropped the dops-notice class family from every React notice. After that PR, the only remaining producer of dops-notice* markup is components/admin-notices/index.jsx, which rebuilds server-rendered VaultPress, WooCommerce and core notices with jQuery and prepends them into #jp-admin-notices. That container renders as a sibling of the page content (main.jsx renderMainContent), so any rule scoped under a page-content selector can never match it. Removed: - dash-item/style.scss: the .dops-notice margin rule under .jp-dash-item .dops-section-header__actions, and the &.is-working/&.is-premium-inactive block under .jp-dash-item .dops-section-header. DashItem never passes a className to SectionHeader, so that selector never matched. - at-a-glance/style.scss: a.dops-notice__action rule under .dops-card.is-compact. - traffic/style.scss: both a.dops-notice__action rules under .jp-stats-odyssey-disabled-notice. - settings/style.scss: the two &.dops-notice__action rules under .jp-settings-container. - components/notice/style.scss: .dops-notice__text-no-underline and .dops-notice__button. No producer nests either class under a .dops-notice__text ancestor, which both selectors require. Left untouched: the .dops-notice chassis in components/notice/style.scss, _main.scss's .dops-notice__text a rule, jetpack-notices/style.scss, and all of admin-notices/style.scss. The jQuery path still styles its rebuilt markup with these. Verified with stylelint on the changed files and pnpm run test-gui in projects/plugins/jetpack (56 suites / 498 tests passing). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx * Jetpack notices: finish the two dead things review found `.jp-stats-odyssey-disabled-notice` is emitted nowhere in the repo — the only hit is this stylesheet — so the container rule goes with the two action rules inside it. That block was dead because nothing renders it, not because the selector could not reach the jQuery notices. `pro-status` still set `dops-notice__text-no-underline` on its action link. Nothing styles that class now, and the rule it was named for required a `dops-notice__text` ancestor the component never had. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx * Traffic stylesheet: drop three rules the Odyssey removal orphaned The "Drop Legacy Stats experience" change (#40384) removed the markup that carried `.jp-stats-odyssey-toggle`, its `.components-base-control` child and `.jp-stats-odyssey-badge`, the same removal that stranded the `.jp-stats-odyssey-disabled-notice` block this branch already deletes. No `jp-stats-odyssey` class is left anywhere under `projects/`. `.jp-stats-form-fieldset` sits between those rules and is still rendered by `_inc/client/traffic/site-stats.jsx`, so this removes three rules rather than a range. `.jp-stats-odyssey-badge` also carried a physical `margin-left`, so the deletion retires an RTL wart with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SWVs5oUDimen1FeGJhJAkx --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ice (#52160) * 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: 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 --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* 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>
Fixes JETPACK-2538
Proposed changes
Turns
SimpleNoticeandNoticeActioninto wrappers around the@wordpress/uiNotice, so every Jetpack admin notice moves to the design system in one change without touching a single call site.SimpleNoticerendersNotice.Rootand keeps its existing two-slot contract — withtextset, children are the actions and go intoNotice.Actions; without it, children are the body.statusmaps tointent, and thedurationauto-dismiss timer is unchanged.NoticeActionrendersNotice.ActionLinkwhen it has anhref, withexternalbecomingopenInNewTab— which draws the external-link arrow the Gridicon used to supply — andNotice.ActionButtonotherwise.titleprop feedsNotice.Title. Four call sites already built a title by hand and now pass it instead: the deprecation notice drops an inlinefontWeight: 600div, the static warning splits its two server-substituted placeholders, and both SEO banners drop a<strong>. Extraction only — no copy changes, no new translated strings.This reaches 14 files that import
SimpleNoticeand 12NoticeActioncall sites, including the notices on Settings, the At a Glance cards, Traffic, Reader, and Security.Behaviour the
dops-noticeclass was carryingDropping the class silently removed four things that no diff shows. Each is restored here:
pointer-events: autoper toast, inside a click-through container> *rather than a notice class, sinceNotice.Root's is a CSS-module hash..jetpack-pagestyles a { text-decoration: none }is unlayered, so it beats every@layer wp-uirule regardless of specificity, andLinksets notext-decorationof its own — it relies on the UA underline.margin-bottom: 24pxbetween stacked noticesStack direction="column" gap="xl"— the same 24px, applied as an inline style.JetpackStateNoticesandDismissableNoticesalso returnnullinstead of an empty<div>, because flexgapcounts empty children wheremargindid not.Two more fixes fall out of the migration:
Notice.Rootsetscontainer-type: inline-size, so it is sized as if it had no contents — and the toast stack is a fixed container that shrinks to fit. Containment is turned off for the toasts; the container query it disables only applies to a notice with a title, which a toast never has.SettingsGrouppaints one over its children in offline mode, which left the notice below readable contrast with its link under an overlay that takes pointer events — on a notice whose entire purpose is to explain why the controls are unavailable. Notices now render outside the scrim.Ready for review. The one open design question has been answered — see below.
Do secondary actions need to look secondary? — answered: no change
Reviewed by design; both actions stay as
Notice.ActionLink, rendering identically. The legacy difference wascolor: $gray-lighten-20plusopacity: 0.8against the primary's$gray-lighten-10— the same link, one step dimmer, and faint in the before shot — so very little is being given up.The inert
variant="secondary"prop is removed rather than left type-checking and doing nothing. If the hierarchy is ever wanted,Notice.ActionButtontakesvariant(solid/outline/minimal) and renders as an anchor viarender={ <a href=… /> }, so it is a small change to one call site plus theNoticeActionwrapper.This notice is unreachable without forcing it. To reproduce, in a mu-plugin:
action => 'custom'routes to the two-action branch;'none'gives the action-less variant,'reconnect'and'support'the others. It is also the only way to exerciseNotice.CloseIcon'sonClick.Resolved, not waiting on anyone
icon="link-break", whichNotice.Rootignored in favour of the intent's error icon. Standardising on the intent icon is the decision; the dead props went in 422ce4e. An earlier revision of this description claimedSimpleNoticestill had to accept an icon element for theSocialLogoinjetpack-notices/index.jsx:161— that is wrong, theSocialLogois onConnectionBanner. No call site passesiconat all now, so the prop itself is gone in 4e85039.isCompact— its only call site was themodule="manage"branch inDashItem, which is unreachable. Removed in Jetpack: remove the unreachable "manage" branch from DashItem #52058, which also retires that component's lastSimpleNoticeimport.NoticeAction'sicon— dead before this PR.notice-action-reconnect.jsx:76forwardsprops.icon, and its only renderer never passes one. Removed alongsidevariant.Also worth noting for review:
onClickonNotice.CloseIconis not in the documented props table, but it does work — it arrives through the spread chainCloseIconProps→IconButtonProps→ButtonProps→ the underlying button, and Gutenberg's own suite covers it inpackages/ui/src/notice/test/index.test.tsxunder "dismissing via CloseIcon".Screenshots
1440×900, same scroll position in each pair.
Stacked notices — development version + offline mode
Offline mode notice on its own
Account protection — the notice inside a settings card
The old banner ran edge to edge with square corners, which is what
border-radius: 0insettings-card/style.scsswas for. That placement does not survive a rounded card.Like buttons — the notice under the offline scrim
Toast
Appearance only. Both were captured with the auto-dismiss fix reverted, so both toasts persist — that behaviour belongs to #52054.
Related product discussion/links
Does this pull request change what data or activity we track or use?
No — but only after 4e85039. Before it,
display={ false }unmounted the notice instead of hiding it, so a failed reconnect tore downNoticeActionReconnectand remounted it, recordingjetpack_termination_error_notice_viewtwice. The prop hides again, and a test pins it.Testing instructions
Automated, run on this branch:
pnpm jetpack test js plugins/jetpack— 203/203 admin-page tests, 393/393 components, 908/908 extensions.Manual, on a connected site running this branch. The screenshots above were captured from these steps.
wp jetpack module deactivate account-protection) and open Jetpack → Settings → Security. The recommendation notice should sit inside the card's padding, not run to its edges, and Learn about the risks should be clickable.Notice.Rootis size-contained.AdminNotices. Confirm VaultPress or WooCommerce notices still render in the Jetpack chassis on any Jetpack screen — that is the jQuery path, and the reasoncomponents/notice/style.scssis still loaded.