Skip to content

Jetpack notices: back SimpleNotice with the @wordpress/ui Notice - #52015

Merged
CGastrell merged 17 commits into
trunkfrom
update/simple-notice-wpds-wrapper
Sep 9, 2026
Merged

CGastrell merged 17 commits into
trunkfrom
update/simple-notice-wpds-wrapper

Conversation

@CGastrell

@CGastrell CGastrell commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Fixes JETPACK-2538

Proposed changes

Turns SimpleNotice and NoticeAction into wrappers around the @wordpress/ui Notice, so every Jetpack admin notice moves to the design system in one change without touching a single call site.

  • SimpleNotice renders Notice.Root and keeps its existing two-slot contract — with text set, children are the actions and go into Notice.Actions; without it, children are the body. status maps to intent, and the duration auto-dismiss timer is unchanged.
  • NoticeAction renders Notice.ActionLink when it has an href, with external becoming openInNewTab — which draws the external-link arrow the Gridicon used to supply — and Notice.ActionButton otherwise.
  • A new title prop feeds Notice.Title. Four call sites already built a title by hand and now pass it instead: the deprecation notice drops an inline fontWeight: 600 div, 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 SimpleNotice and 12 NoticeAction call sites, including the notices on Settings, the At a Glance cards, Traffic, Reader, and Security.

Behaviour the dops-notice class was carrying

Dropping the class silently removed four things that no diff shows. Each is restored here:

What was lost Restored by
pointer-events: auto per toast, inside a click-through container The stack rule targets > * rather than a notice class, since Notice.Root's is a CSS-module hash.
The underline on links inside notices A scoped counter-rule. .jetpack-pagestyles a { text-decoration: none } is unlayered, so it beats every @layer wp-ui rule regardless of specificity, and Link sets no text-decoration of its own — it relies on the UA underline.
margin-bottom: 24px between stacked notices Both containers that stack notices now use Stack direction="column" gap="xl" — the same 24px, applied as an inline style. JetpackStateNotices and DismissableNotices also return null instead of an empty <div>, because flex gap counts empty children where margin did not.
A full-bleed banner shape inside settings cards The notice now sits in the card's own padding. A rounded, bordered design system card cannot sit flush against the card edge the way a dark square-cornered banner could.

Two more fixes fall out of the migration:

  • Toasts collapsed on desktop. Notice.Root sets container-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.
  • The Like buttons notice was under an 80% white scrim. SettingsGroup paints 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 was color: $gray-lighten-20 plus opacity: 0.8 against 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.ActionButton takes variant (solid / outline / minimal) and renders as an anchor via render={ <a href=… /> }, so it is a small change to one call site plus the NoticeAction wrapper.

Before After
before after

This notice is unreachable without forcing it. To reproduce, in a mu-plugin:

add_filter( 'react_connection_errors_initial_state', function () {
	return array( array(
		'code'    => 'demo',
		'message' => 'Your connection to WordPress.com needs attention.',
		'action'  => 'custom',
		'data'    => array(
			'action_url'             => 'https://example.com/primary',
			'action_label'           => 'Reconnect Jetpack',
			'secondary_action_url'   => 'https://example.com/secondary',
			'secondary_action_label' => 'Learn more',
		),
	) );
} );

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 exercise Notice.CloseIcon's onClick.

Resolved, not waiting on anyone

  • The broken-link icon — four connection errors passed icon="link-break", which Notice.Root ignored 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 claimed SimpleNotice still had to accept an icon element for the SocialLogo in jetpack-notices/index.jsx:161 — that is wrong, the SocialLogo is on ConnectionBanner. No call site passes icon at all now, so the prop itself is gone in 4e85039.
  • isCompact — its only call site was the module="manage" branch in DashItem, which is unreachable. Removed in Jetpack: remove the unreachable "manage" branch from DashItem #52058, which also retires that component's last SimpleNotice import.
  • NoticeAction's icon — dead before this PR. notice-action-reconnect.jsx:76 forwards props.icon, and its only renderer never passes one. Removed alongside variant.

Also worth noting for review: onClick on Notice.CloseIcon is not in the documented props table, but it does work — it arrives through the spread chain CloseIconProps → IconButtonProps → ButtonProps → the underlying button, and Gutenberg's own suite covers it in packages/ui/src/notice/test/index.test.tsx under "dismissing via CloseIcon".

Screenshots

1440×900, same scroll position in each pair.

Stacked notices — development version + offline mode

Before After
before after

Offline mode notice on its own

Before After
before after-3-offline-only

Account protection — the notice inside a settings card

The old banner ran edge to edge with square corners, which is what border-radius: 0 in settings-card/style.scss was for. That placement does not survive a rounded card.

Before After
before after

Like buttons — the notice under the offline scrim

Before After
before after

Toast

Appearance only. Both were captured with the auto-dismiss fix reverted, so both toasts persist — that behaviour belongs to #52054.

Before After
before after

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 down NoticeActionReconnect and remounted it, recording jetpack_termination_error_notice_view twice. 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.
  • ESLint on the changed JS and Stylelint on the changed stylesheets.

Manual, on a connected site running this branch. The screenshots above were captured from these steps.

  • Development-version and offline-mode notices. Go to Jetpack → Settings. With both active you should see two notices with a clear gap between them, not touching. Check there is no extra space above the first or below the last — empty sibling elements used to add phantom gaps once the stack became a flex container.
  • Action links. On the same notices, Submit Beta feedback and Learn More should be underlined and carry the external-link arrow where the link opens in a new tab.
  • Account protection. Deactivate the module (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.
  • Like buttons under the scrim. With a block theme active and the site in offline mode, open Jetpack → Settings → Sharing. The block-theme notice should be at full contrast while the card above it is dimmed, and Discover how should be clickable.
  • Sharing buttons on the same screen is the control — it deliberately shows a block action instead of a notice, and should be unchanged.
  • Toasts. With offline mode off, toggle any module. The toast should appear top right, sized to its message rather than collapsed to a sliver, and be clickable and dismissable. The collapse is the regression risk: the container shrinks to fit and Notice.Root is 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 reason components/notice/style.scss is still loaded.

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
@CGastrell CGastrell added [Status] In Progress [Plugin] Jetpack Issues about the Jetpack plugin. https://wordpress.org/plugins/jetpack/ labels Sep 4, 2026
@CGastrell CGastrell self-assigned this Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Are you an Automattician? Please test your changes on all WordPress.com environments to help mitigate accidental explosions.

  • To test on WoA, go to the Plugins menu on a WoA dev site. Click on the "Upload" button and follow the upgrade flow to be able to upload, install, and activate the Jetpack Beta plugin. Once the plugin is active, go to Jetpack > Jetpack Beta, select your plugin (Jetpack), and enable the update/simple-notice-wpds-wrapper branch.
  • To test on Simple, run the following command on your sandbox:
bin/jetpack-downloader test jetpack update/simple-notice-wpds-wrapper

Interested in more tips and information?

  • In your local development environment, use the jetpack rsync command to sync your changes to a WoA dev blog.
  • Read more about our development workflow here: PCYsg-eg0-p2
  • Figure out when your changes will be shipped to customers here: PCYsg-eg5-p2

@github-actions github-actions Bot added the Admin Page React-powered dashboard under the Jetpack menu label Sep 4, 2026
@github-actions

github-actions Bot commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Thank you for your PR!

When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:

  • ✅ Include a description of your PR changes.
  • ✅ Add a "[Status]" label (In Progress, Needs Review, ...).
  • ✅ Add testing instructions.
  • ✅ Specify whether this PR includes any changes to data or privacy.
  • ✅ Add changelog entries to affected projects

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:

  1. Ensure all required checks appearing at the bottom of this PR are passing.
  2. Make sure to test your changes on all platforms that it applies to. You're responsible for the quality of the code you ship.
  3. You can use GitHub's Reviewers functionality to request a review.
  4. When it's reviewed and merged, you will be pinged in Slack to deploy the changes to WordPress.com simple once the build is done.

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:

  • WordPress.com Simple releases happen as soon as you deploy your changes after merging this PR (PCYsg-Jjm-p2).
  • WoA releases happen weekly.
  • Releases to self-hosted sites happen monthly:
    • Scheduled release: October 6, 2026

If you have any questions about the release process, please ask in the #jetpack-releases channel on Slack.

@jp-launch-control

jp-launch-control Bot commented Sep 4, 2026 •

Copy link
Copy Markdown

Code Coverage Summary

Coverage changed in 5 files.

File Coverage Δ% Δ Uncovered
projects/plugins/jetpack/_inc/client/components/gridicon/index.jsx 41/322 (12.73%) -1.86% 6 💔
projects/plugins/jetpack/_inc/client/components/jetpack-notices/jetpack-connection-errors.jsx 20/22 (90.91%) 0.43% 0 💚
projects/plugins/jetpack/_inc/client/components/notice/index.jsx 24/24 (100.00%) 0.00% 0 💚
projects/plugins/jetpack/_inc/client/components/notice/notice-action.jsx 7/7 (100.00%) 0.00% 0 💚
projects/plugins/jetpack/_inc/client/traffic/seo.jsx 34/49 (69.39%) 0.64% 0 💚

Full summary · PHP report · JS report

If appropriate, add one of these labels to override the failing coverage check: Covered by non-unit tests Use to ignore the Code coverage requirement check when E2Es or other non-unit tests cover the code Coverage tests to be added later Use to ignore the Code coverage requirement check when tests will be added in a follow-up PR I don't care about code coverage for this PR Use this label to ignore the check for insufficient code coveage.

CGastrell and others added 5 commits September 7, 2026 14:31
… 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
@CGastrell
CGastrell marked this pull request as ready for review September 7, 2026 20:23
@CGastrell

Copy link
Copy Markdown
Contributor Author

Reviewed on the PR head. Findings, most severe first.

1. 7 tests fail now, and CI won't catch them. tests/jest.config.gui.js uses a curated testMatch that omits _inc/client/components/**/test/component.jsx, and jest.config.client.js roots exclude components/. Run directly:

FAIL _inc/client/components/block-theme-notice/test/component.jsx  (2)
FAIL _inc/client/components/dash-item/test/component.jsx           (2)
FAIL _inc/client/components/jetpack-notices/test/component.jsx     (3)

Three causes:

  • dash-item/test/component.jsx:193 asserts .dops-notice.is-warning. Class is gone.
  • getByText finds two matches: Notice.Root calls speak(), which copies the body into #a11y-speak-polite in the DOM.
  • getByRole( 'link', { name: 'Submit Beta feedback' } ) misses. Link openInNewTab appends aria-label="(opens in a new tab)", so the accessible name changed.

2. speak() fires for every notice, including permanent ones. Notice.Root defaults spokenMessage to children. The SEO banners, BlockThemeNotice, the reader WoA notice and dash-item's "Updates needed" now announce on page load. Notices inside the <Stack aria-live="polite"> in jetpack-notices/index.jsx announce twice. Passing spokenMessage={ null } from SimpleNotice fixes both.

3. Two more notices sit flush against the card edge. SettingsCard renders <CardBody size="none">, so children get no padding. .jp-settings-card__notice was added to account-protection.jsx and likes.jsx, but not to:

  • traffic/seo.jsx:293 — seoOptInBanner(), a direct SettingsCard child with no CSS of its own. This file is already in the diff.
  • reader/index.jsx:158 — woaNotice, same shape.

4. BlockThemeNotice is still under the offline scrim on Sharing buttons. sharing/share-buttons.jsx:122 renders it inside <SettingsGroup disableInOfflineMode>, which paints .jp-form-block-fade (rgba(255, 255, 255, 0.8), z-index: 1, takes pointer events). Same case as the likes.jsx one this PR fixes.

5. Nit. notice-action.jsx still declares icon and variant in propTypes but ignores both.

Checked and correct: the client and GUI suites as CI runs them (46 + 19 suites) pass; openInNewTab does reach Link through the Text → useRender clone; container-type: normal is safe because the container query is .notice:has(.title) and toasts never set a title; .jetpack-pagestyles is a body class, so the underline rule reaches the fixed-position toasts; .global-notices is position: fixed, so it is not a flex item of the new Stack.

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
@CGastrell

Copy link
Copy Markdown
Contributor Author

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: jest.config.gui.js matches _inc/client/**/test/component.js, not .jsx, so all 20 component test files are dead, not only the notice ones.

Your run missed one, because it is named *.test.jsx rather than component.jsx: components/jetpack-notices/test/jetpack-connection-errors.test.jsx, 2 failures. Both are pre-existing on trunk — a missing <Provider> around Connect(NoticeActionReconnect), and a queryByText( 'Reconnect', { exact: false } ) that substring-matches its own message. Verified by running the same files at a67f5be8ec6~1: identical 19/20 suites, 2 failures. Left alone here.

Component suites are now at parity with trunk. The testMatch gap and those two pre-existing failures are going to their own PR — closing a repo-wide hole is not a notices change.

2. speak(). Fixed with spokenMessage={ null } on Notice.Root. Not a judgement call in the end: it accounted for 4 of the 7 failures on its own, via getByText matching both the notice and the #a11y-speak-polite copy. The legacy notice never announced, and the ones that should are already inside a live region.

3. Flush notices. Both wrapped — traffic/seo.jsx and reader/index.jsx.

4. Sharing buttons scrim. Fixed, moved out of the SettingsGroup the same way likes.jsx was. Worth recording why neither of us saw it on a dev site: it renders only when sharingTemplateUrl is empty, so a normal block-theme install takes the block-action path instead.

5. propTypes nit. Left deliberately. icon and variant are two of the three open decisions this PR is drafted on; dropping the declarations now would erase the record of which call sites still pass them. They go when those calls are made.

— 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
@CGastrell

Copy link
Copy Markdown
Contributor Author

Second pass on 8bf86162a40 + b3efb097b44. Findings 1, 3, 4 and the speak() one are fixed — 39 suites / 345 tests pass, ESLint clean. Two things left.

1. traffic/seo.jsx:293 — empty padded div on most sites.

<div className="jp-settings-card__notice">{ this.seoOptInBanner() }</div>

The wrapper is unguarded, but seoOptInBanner() returns null unless getScriptData()?.seo?.optin_available. .jp-settings-card__notice sets padding: 16px 24px, so those sites get a 32px empty block above the card's description. reader/index.jsx:158 guarded this correctly. Hoist so the call isn't repeated:

const optInBanner = this.seoOptInBanner();
…
{ optInBanner && <div className="jp-settings-card__notice">{ optInBanner }</div> }

2. Drop b3efb097b44 — out of scope. On trunk the conflicting-SEO-plugin notice already sits inside <SettingsGroup hasChild disableInOfflineMode module={ seo }> (traffic/seo.jsx:303), so the scrim covered it before this PR. Nothing in the SimpleNotice migration changed that. It also moves the notice past that component's ! userCanManageModules early return, which is a behaviour change this PR shouldn't be making. Real bug, separate PR.

sharing/share-buttons.jsx is different and the fix there is correct — that notice only ends up under the scrim because of this migration.

With those two, traffic/seo.jsx is back to a one-line change.

— 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
@CGastrell

Copy link
Copy Markdown
Contributor Author

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: optin_available is absent from JetpackScriptData on a normal dev site, so most installs got the 32px gap, not a minority.

2. Keeping b3efb09. I checked both halves of the objection and neither holds.

The non-admin path is unreachable. SettingsCard returns <span /> when ! userCanManageModules and the module is not post-by-email or publicize (components/settings-card/index.jsx:94). The SEO card passes module={ seo.module }, which is seo-tools, so a non-admin never renders the card and never reaches the inner SettingsGroup guard. Moving the notice past it changes nothing.

The scope argument covers all three, not one. At a67f5be8ec6~1, all three notices sat inside a SettingsGroup disableInOfflineMode:

  • sharing/likes.jsx:86 — inside the group, group closes at 92
  • sharing/share-buttons.jsx:122 — inside moduleAction(), which renders inside the group
  • traffic/seo.jsx — the conflicting-plugin notice, as you noted

So share-buttons.jsx was under the scrim before this PR too, on the same footing as the SEO one. What the migration changed is legibility, not placement: an 80% white scrim over a dark banner still reads, over a light tinted card it does not.

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 testMatch gap is #52056. GUI suite goes from 19 files / 203 tests to 54 / 487. Both pre-existing failures in jetpack-connection-errors.test.jsx are fixed there — including one your run did not reach, since it is named *.test.jsx rather than component.jsx.

— 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 dhasilva left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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:

  1. The data/tracking answer is no longer accurate. See blocker 1 — the display change causes NoticeActionReconnect's mount-time useEffect to re-fire jetpack_termination_error_notice_view.
  2. The PR still carries [Status] In Progress and 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.

  1. [suggestion] The entry reads Notices: render Jetpack admin notices…. AGENTS.md requires entries to start with a capital letter; with a component prefix the repo convention is Prefix: 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:

  1. [blocker] display changed from "render hidden" to "do not render". The old component always emitted the node and added is-hidden (display: none in components/notice/style.scss); the new one early-returns null, which unmounts the whole subtree.
    components/notice/index.jsx#L87-L89

    That matters on one live path. JetpackNotices passes display={ ! this.props.isReconnectingSite } to JetpackConnectionErrors, which forwards it to every notice, including ErrorNoticeCycleConnection — whose child NoticeActionReconnect fires jetpack_termination_error_notice_view from a mount-time useEffect. So: notice mounts (view #1) → user clicks Restore Connection → SITE_RECONNECT flips isReconnectingSite → the notice unmounts → the reconnect fails → SITE_RECONNECT_FAIL flips it back → the notice remounts → view #2. Previously the node stayed mounted behind display: none and the event fired once. The redux dispatches in doReconnect survive the unmount, so the flow itself still completes, but useRestoreConnection'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 .notice sets display: grid in @layer wp-ui, and an author-layer rule beats the UA's [hidden] { display: none }. Either pass style={ 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 the dops-notice class was carrying" table and the tracking question needs re-answering.

  2. [suggestion] The widened icon propType has no user, and the stated justification doesn't hold. icon now accepts a node, and isValidElement( icon ) ? icon : undefined silently drops strings.
    components/notice/index.jsx#L31 · #L104

    The PR body says "An icon passed as an element still works, which the SocialLogo in jetpack-notices/index.jsx:161 relies on" — but that icon={ <SocialLogo … /> } is on ConnectionBanner, not SimpleNotice. After this PR removes the four icon="link-break" props, no SimpleNotice call site passes icon at 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 stale icon="…" fails loudly in review instead of silently doing nothing.

  3. [suggestion] NoticeAction's variant and icon are still declared but ignored. propTypes still advertises variant: oneOf(['primary','secondary']) and icon, and neither reaches the rendered Notice.ActionLink/ActionButton.
    components/notice/notice-action.jsx#L12-L13

    The PR flags the variant question as pending design, which is fine — but the live state is a prop that type-checks and does nothing, plus the now-inert variant="secondary" at jetpack-connection-errors.jsx#L71. Whichever way design lands, propTypes and the call site should agree with the render before merge.

  4. [suggestion] isCompact stops producing compact styling, and #52058 is still open. isCompact is now read only for the (dead) showDismiss destructuring default. Its remaining call site is components/dash-item/index.jsx:106, and components/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 same dash-item/test/component.jsx this 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

  1. [suggestion] JetpackConnectionErrors can render an empty flex item — the phantom-gap failure this PR fixed in JetpackStateNotices and DismissableNotices. render() now always emits a Stack, even when every child renders nothing.
    jetpack-connection-errors.jsx#L128-L133

    Two ways in: every error is filtered out by the Object.hasOwn( error, 'action' ) guard, or display is false (i.e. exactly the reconnecting state above), where all children return null. Either way the outer JetpackNotices Stack gets a zero-height in-flow child and pays an extra 24px gap. Same fix as the other two: bail to null when errorsToDisplay is empty. (The NoticesList toast container is position: 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.

  1. [suggestion] Notice.Description renders a <span>, and several migrated call sites hand it block content. Notice.Description → Text → defaultTagName: 'span'. deprecation-notice.jsx and static-warning.jsx pass a <div>; traffic/index.jsx and traffic/seo.jsx pass <div><p>…</p></div>; OfflineModeNotice's interpolated text contains a <ul>.
    components/notice/index.jsx#L110

    Layout 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.

  2. [suggestion] <Notice.Description> renders unconditionally, so a notice with neither text nor children emits an empty styled span. Guarding it the way Title and Actions are 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

  1. [suggestion] Three stylesheets still scope rules to .dops-notice for notices that are now Notice.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, and at-a-glance/style.scss:435. Keeping components/notice/style.scss loaded for the jQuery AdminNotices path is correct and I verified it does rebuild that markup — but these three are not on that path.

Comment Budget

  1. [suggestion] One block over budget: six prose lines carrying three unrelated rationales (why the selector targets > *, why pointer-events is re-enabled, why container-type is reset).
    global-notices/style.scss#L41-L48

    Per 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 the container-type declaration 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 a seven lines up — naming it removes the ambiguity; and "Drop this once --_gcd-a-text-decoration-line lands 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

  1. [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: the status → intent map, the two-slot text-vs-children contract, the new title slot, and whatever display ends up meaning. Fifteen call sites depend on all four.
  2. [suggestion] The dash-item test lost its assertion. It is named "shows a warning badge when status is is-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.)
  3. [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-size really is in @layer wp-ui > components, so the unlayered .global-notices > * reset wins.
  • clsx( 'jp-notice', className ) survives — base-ui's mergeProps concatenates className rather than overwriting, so jp-notice and the module hash both land.
  • spokenMessage={ null } does suppress speak() (safeRenderToString early-returns on a falsy message).
  • Notice.ActionLink does accept openInNewTab (ActionLinkProps omits only variant/tone from LinkProps), and Link draws the ↗ via an inline-block ::after, so the new underline rule doesn't underline the arrow.
  • Stack gap="xl" is 24px, and the built stack.mjs inlines the 24px fallback into the var(), so it holds even though this page never loads the @wordpress/theme token sheet.
  • .jetpack-pagestyles .jp-notice a at (0,2,1) beats both .jetpack-pagestyles a and the unlayered global-css-defense .a rule, which indeed defines no text-decoration-*.
  • Notice.CloseIcon's undocumented onClick does reach the button through the IconButton → Button spread.

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.

CGastrell and others added 2 commits September 8, 2026 09:22
…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
CGastrell added a commit that referenced this pull request Sep 8, 2026
* 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>
@CGastrell CGastrell added [Status] Needs Review This PR is ready for review. and removed [Status] In Progress labels Sep 8, 2026
CGastrell and others added 3 commits September 8, 2026 17:08
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
@CGastrell
CGastrell merged commit 986f751 into trunk Sep 9, 2026
77 of 79 checks passed
@CGastrell
CGastrell deleted the update/simple-notice-wpds-wrapper branch September 9, 2026 15:16
@github-actions github-actions Bot removed the [Status] Needs Review This PR is ready for review. label Sep 9, 2026
@github-actions github-actions Bot added this to the jetpack/16.3 milestone Sep 9, 2026
CGastrell added a commit that referenced this pull request Sep 9, 2026
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
@simison

simison commented Sep 10, 2026 •

Copy link
Copy Markdown
Member

@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:

// Toasts: move the boot layer's snackbars to the top-right, matching the
// rest of Jetpack.
// Two systems place Jetpack toasts and they disagree. `@wordpress/boot` renders
// one `<SnackbarNotices>` per wp-build page
// and pins it bottom-center; `<GlobalNotices>` in
// @automattic/jetpack-components pins top-right, which is what Newsletter,
// My Jetpack and VideoPress show.
// This page is wp-build, so it inherited the odd one out.
// We restyle boot's list rather than rendering our own. Rendering a second
// `SnackbarList` is what #49470 removed: the store is shared, so both lists
// subscribe and every toast appears twice.
// The values below intentionally mirror `global-notices/styles.module.scss` so
// the two stay identical — small screens keep the full-width bottom strip
// (nothing is pinned over the content on a narrow viewport), and the top offset
// steps down at 782px where the wp-admin bar shrinks.
// Scoped to the SEO page's body class, like the layout rules at the top of
// this file. Every other rule here is namespaced by a `jetpack-seo-*` class,
// but this one targets a class we don't own — so without the scope it would
// move the snackbars on any other wp-build screen this stylesheet loaded on.
// It also settles specificity. Boot injects its rule at runtime as a
// layout-scoped descendant (0,2,0); the body class puts this at (0,3,1), so it
// wins outright rather than depending on load order. Boot's classes come in two
// shapes; see the guard in `@automattic/jetpack-base-styles/admin-page-layout`
// for why. Both selectors below are (0,2,0), so the specificity holds either
// way.
body.jetpack_page_jetpack-seo,
body.toplevel_page_jetpack-seo {
.boot-layout .boot-notices__snackbar,
[class*="__layout"] [class*="__notices-snackbar"] {
inset-block-start: auto;
inset-block-end: 0;
inset-inline: 0;
@media (min-width: 600px) {
inset-block-start: 4rem;
inset-block-end: auto;
inset-inline-start: auto;
inset-inline-end: 1rem;
inline-size: auto;
// Boot centers each snackbar in a full-width container
// (`margin-inline: auto`). The container is no longer full width, so
// that would now center them against the right edge, not align to it.
.components-snackbar {
margin-inline: 0;
}
}
@media (min-width: 782px) {
inset-block-start: 3rem;
}
}
}

@chrisbliss18 chrisbliss18 mentioned this pull request Sep 10, 2026
3 tasks done
CGastrell added a commit that referenced this pull request Sep 10, 2026
* 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>
CGastrell added a commit that referenced this pull request Sep 10, 2026
…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>
dhasilva added a commit 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Admin Page React-powered dashboard under the Jetpack menu [Plugin] Jetpack Issues about the Jetpack plugin. https://wordpress.org/plugins/jetpack/ [Tests] Includes Tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants