PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.7
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.7
16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 All 506 releases
← All changes | jetpack_vendor/automattic/jetpack-admin-ui/src/class-admin-menu.php +429 -36 16.2 → 16.3-a.7 View file →
@@ -6,8 +6,9 @@
6 6 */
7 7
8 8 namespace Automattic\Jetpack\Admin_UI;
9 9
10 +use Automattic\Jetpack\Feature_Policy;
10 11 use Automattic\Jetpack\Tracking;
11 12 use Jetpack_Options;
12 13 use Jetpack_Tracks_Client;
13 14
@@ -16,9 +17,9 @@
16 17 * If the Jetpack top level was not previously registered by other plugin, it will be registered here.
17 18 */
18 19 class Admin_Menu {
19 20
20 - const PACKAGE_VERSION = '0.11.3';
21 + const PACKAGE_VERSION = '0.14.2';
21 22
22 23 /**
23 24 * Slug used for the upgrade menu item and redirect URL.
24 25 *
@@ -34,15 +35,74 @@
34 35 * @var string
35 36 */
36 37 const UPGRADE_MENU_FALLBACK_URL = 'https://jetpack.com/upgrade/';
37 38
39 + /*
40 + * The sidebar's position tiers. Items sharing a tier sort alphabetically by menu title, so a
41 + * product should pass no position and land in POSITION_DEFAULT. add_menu() treats any value
42 + * that is not a tier below as omitted, so an int of your own cannot opt an item out.
43 + */
44 +
38 45 /**
39 - * Handle for the shared, token-only WPDS design-tokens stylesheet.
46 + * Owns the top-level Jetpack link, since WordPress points it at whichever item sorts first.
40 47 *
41 - * Registered once and enqueued on every Jetpack admin page so that
42 - * `var(--wpds-*)` values resolve at runtime instead of falling back to
43 - * their hand-written hex defaults.
48 + * @var int
49 + */
50 + const POSITION_FIRST = -10;
51 +
52 + /**
53 + * Takes the first slot when nothing claims POSITION_FIRST, as in offline mode.
44 54 *
55 + * @var int
56 + */
57 + const POSITION_FIRST_FALLBACK = -5;
58 +
59 + /**
60 + * Products, in alphabetical order. Pass no position rather than this.
61 + *
62 + * @var int
63 + */
64 + const POSITION_DEFAULT = 0;
65 +
66 + /**
67 + * Links that leave wp-admin, grouped below the products.
68 + *
69 + * @var int
70 + */
71 + const POSITION_EXTERNAL = 100;
72 +
73 + /**
74 + * Site-level items that belong under everything else.
75 + *
76 + * @var int
77 + */
78 + const POSITION_LAST = 998;
79 +
80 + /**
81 + * The upgrade item this package adds, below every tier a caller can use.
82 + *
83 + * @var int
84 + */
85 + const POSITION_UPGRADE = 999;
86 +
87 + /**
88 + * The tiers add_menu() accepts. POSITION_UPGRADE is left out: only this class claims it.
89 + *
90 + * @var int[]
91 + */
92 + private const CALLER_POSITIONS = array(
93 + self::POSITION_FIRST,
94 + self::POSITION_FIRST_FALLBACK,
95 + self::POSITION_DEFAULT,
96 + self::POSITION_EXTERNAL,
97 + self::POSITION_LAST,
98 + );
99 +
100 + /**
101 + * Handle for the bundled WPDS design-tokens stylesheet.
102 + *
103 + * Fallback when Core/Gutenberg has not registered the `wp-theme` style.
104 + *
45 105 * @var string
46 106 */
47 107 const DESIGN_TOKENS_HANDLE = 'jetpack-admin-ui-design-tokens';
48 108
@@ -53,8 +113,29 @@
53 113 */
54 114 const HIDE_CORE_NOTICES_HANDLE = 'jetpack-admin-ui-hide-core-notices';
55 115
56 116 /**
117 + * Visibility state: show the item only when its declared gate is satisfied.
118 + *
119 + * @var string
120 + */
121 + const VISIBILITY_DEFAULT = 'default';
122 +
123 + /**
124 + * Visibility state: show the item whatever its gate says.
125 + *
126 + * @var string
127 + */
128 + const VISIBILITY_VISIBLE = 'visible';
129 +
130 + /**
131 + * Visibility state: keep the item out whatever its gate says.
132 + *
133 + * @var string
134 + */
135 + const VISIBILITY_HIDDEN = 'hidden';
136 +
137 + /**
57 138 * Whether this class has been initialized
58 139 *
59 140 * @var boolean
60 141 */
@@ -67,8 +148,15 @@
67 148 */
68 149 private static $menu_items = array();
69 150
70 151 /**
152 + * List of top level menu items enqueued to be added
153 + *
154 + * @var array
155 + */
156 + private static $top_level_items = array();
157 +
158 + /**
71 159 * Hook suffixes of the pages registered through this class.
72 160 *
73 161 * Used to scope the design-tokens stylesheet to Jetpack admin pages.
74 162 *
@@ -83,8 +171,41 @@
83 171 */
84 172 private static $connection_manager = null;
85 173
86 174 /**
175 + * Callback that answers whether a menu item's declared gate is satisfied.
176 + *
177 + * Set by My Jetpack, which owns the product classes the gates name. Stays null on a
178 + * site without it, where every gate then fails open.
179 + *
180 + * @var callable|null
181 + */
182 + private static $visibility_resolver = null;
183 +
184 + /**
185 + * Menu slugs registered this request but kept out of the rendered sidebar.
186 + *
187 + * @var string[]
188 + */
189 + private static $hidden_menu_slugs = array();
190 +
191 + /**
192 + * Top level menu slugs registered this request but kept out of the rendered sidebar.
193 + *
194 + * @var string[]
195 + */
196 + private static $hidden_top_level_slugs = array();
197 +
198 + /**
199 + * Whether the top level registration pass has been hooked.
200 + *
201 + * Separate from $initialized, which also builds the Jetpack menu.
202 + *
203 + * @var boolean
204 + */
205 + private static $top_level_initialized = false;
206 +
207 + /**
87 208 * Initialize the class and set up the main hook
88 209 *
89 210 * @return void
90 211 */
@@ -93,8 +214,9 @@
93 214 self::$initialized = true;
94 215 self::handle_akismet_menu();
95 216 add_action( 'admin_menu', array( __CLASS__, 'admin_menu_hook_callback' ), 1000 ); // Jetpack uses 998.
96 217 add_action( 'network_admin_menu', array( __CLASS__, 'admin_menu_hook_callback' ), 1000 ); // Jetpack uses 998.
218 + add_action( 'admin_head', array( __CLASS__, 'remove_hidden_menu_items' ) );
97 219 add_action( 'admin_enqueue_scripts', array( __CLASS__, 'add_upgrade_menu_item_styles' ) );
98 220 add_action( 'admin_enqueue_scripts', array( __CLASS__, 'maybe_enqueue_design_tokens' ) );
99 221 }
100 222 }
@@ -99,8 +221,32 @@
99 221 }
100 222 }
101 223
102 224 /**
225 + * Drops every queued item and unhooks the registration passes.
226 + *
227 + * Intended for tests.
228 + *
229 + * @return void
230 + */
231 + public static function reset() {
232 + self::$menu_items = array();
233 + self::$top_level_items = array();
234 + self::$page_hooks = array();
235 + self::$hidden_menu_slugs = array();
236 + self::$hidden_top_level_slugs = array();
237 + self::$initialized = false;
238 + self::$top_level_initialized = false;
239 +
240 + remove_action( 'admin_menu', array( __CLASS__, 'admin_menu_hook_callback' ), 1000 );
241 + remove_action( 'network_admin_menu', array( __CLASS__, 'admin_menu_hook_callback' ), 1000 );
242 + remove_action( 'admin_menu', array( __CLASS__, 'top_level_menu_hook_callback' ), 1000 );
243 + remove_action( 'admin_head', array( __CLASS__, 'remove_hidden_menu_items' ) );
244 + remove_action( 'admin_enqueue_scripts', array( __CLASS__, 'add_upgrade_menu_item_styles' ) );
245 + remove_action( 'admin_enqueue_scripts', array( __CLASS__, 'maybe_enqueue_design_tokens' ) );
246 + }
247 +
248 + /**
103 249 * Handles the Akismet menu item when used alongside other stand-alone plugins
104 250 *
105 251 * When Jetpack plugin is present, Akismet menu item is moved under the Jetpack top level menu, but if Akismet is active alongside other stand-alone plugins,
106 252 * we use this method to move the menu item.
@@ -129,11 +275,14 @@
129 275 */
130 276 public static function admin_menu_hook_callback() {
131 277 $can_see_toplevel_menu = true;
132 278 $jetpack_plugin_present = class_exists( 'Jetpack_React_Page' );
133 - $icon = method_exists( '\Automattic\Jetpack\Assets\Logo', 'get_base64_logo' )
134 - ? ( new \Automattic\Jetpack\Assets\Logo() )->get_base64_logo()
135 - : 'dashicons-admin-plugins';
279 + $icon = 'dashicons-admin-plugins';
280 + if ( method_exists( '\Automattic\Jetpack\Assets\Logo', 'get_base64_admin_menu_logo' ) ) {
281 + $icon = ( new \Automattic\Jetpack\Assets\Logo() )->get_base64_admin_menu_logo();
282 + } elseif ( method_exists( '\Automattic\Jetpack\Assets\Logo', 'get_base64_logo' ) ) {
283 + $icon = ( new \Automattic\Jetpack\Assets\Logo() )->get_base64_logo();
284 + }
136 285
137 286 if ( ! $jetpack_plugin_present ) {
138 287 add_menu_page(
139 288 'Jetpack',
@@ -148,14 +297,11 @@
148 297 // If Jetpack plugin is not present, user will only be able to see this menu if they have enough capability to at least one of the sub menus being added.
149 298 $can_see_toplevel_menu = false;
150 299 }
151 300
152 - /**
153 - * The add_sub_menu function has a bug and will not keep the right order of menu items.
154 - *
155 - * @see https://core.trac.wordpress.org/ticket/52035
156 - * Let's order the items before registering them.
157 - * Since this all happens after the Jetpack plugin menu items were added, all items will be added after Jetpack plugin items - unless position is very low number (smaller than the number of menu items present in Jetpack plugin).
301 + /*
302 + * Sort here and register in that order, without passing the position on: core splices an
303 + * int back in, and prepends 0 or less. See https://core.trac.wordpress.org/ticket/52035.
158 304 */
159 305 usort(
160 306 self::$menu_items,
161 307 function ( $a, $b ) {
@@ -172,14 +318,26 @@
172 318 return $result;
173 319 }
174 320 );
175 321
322 + $visibility = self::get_visibility_states();
323 +
324 + self::$hidden_menu_slugs = array();
325 +
176 326 foreach ( self::$menu_items as $menu_item ) {
177 327 if ( ! current_user_can( $menu_item['capability'] ) ) {
178 328 continue;
179 329 }
180 330
181 - $can_see_toplevel_menu = true;
331 + /*
332 + * A hidden item is still registered, so its page keeps resolving for links into it.
333 + * It leaves the submenu on admin_head instead: core's access check reads $submenu.
334 + */
335 + if ( self::is_menu_item_visible( $menu_item, $visibility ) ) {
336 + $can_see_toplevel_menu = true;
337 + } else {
338 + self::$hidden_menu_slugs[] = $menu_item['menu_slug'];
339 + }
182 340
183 341 add_submenu_page(
184 342 'jetpack',
185 343 $menu_item['page_title'],
@@ -185,10 +343,9 @@
185 343 $menu_item['page_title'],
186 344 $menu_item['menu_title'],
187 345 $menu_item['capability'],
188 346 $menu_item['menu_slug'],
189 - $menu_item['function'],
190 - $menu_item['position']
347 + $menu_item['function']
191 348 );
192 349 }
193 350
194 351 if ( ! $jetpack_plugin_present ) {
@@ -202,8 +359,82 @@
202 359 self::maybe_add_upgrade_menu_item();
203 360 }
204 361
205 362 /**
363 + * Hooks the top level registration pass, without building the Jetpack menu that init() does.
364 + *
365 + * @return void
366 + */
367 + private static function init_top_level() {
368 + if ( ! self::$top_level_initialized ) {
369 + self::$top_level_initialized = true;
370 + add_action( 'admin_menu', array( __CLASS__, 'top_level_menu_hook_callback' ), 1000 );
371 + add_action( 'admin_head', array( __CLASS__, 'remove_hidden_menu_items' ) );
372 + }
373 + }
374 +
375 + /**
376 + * Registers the queued top level items, skipping the ones that should not be seen.
377 + *
378 + * These sit beside the Jetpack menu, so they never count towards keeping it alive.
379 + *
380 + * @return void
381 + */
382 + public static function top_level_menu_hook_callback() {
383 + $visibility = self::get_visibility_states();
384 +
385 + self::$hidden_top_level_slugs = array();
386 +
387 + foreach ( self::$top_level_items as $menu_item ) {
388 + if ( ! current_user_can( $menu_item['capability'] ) ) {
389 + continue;
390 + }
391 +
392 + if ( ! self::is_menu_item_visible( $menu_item, $visibility ) ) {
393 + self::$hidden_top_level_slugs[] = $menu_item['menu_slug'];
394 + }
395 +
396 + add_menu_page(
397 + $menu_item['page_title'],
398 + $menu_item['menu_title'],
399 + $menu_item['capability'],
400 + $menu_item['menu_slug'],
401 + $menu_item['function'],
402 + $menu_item['icon_url'],
403 + $menu_item['position']
404 + );
405 + }
406 + }
407 +
408 + /**
409 + * Adds a top level menu item under the same visibility gate and filter as add_menu().
410 + *
411 + * Unlike add_menu(), the page gets neither the core-notice CSS nor the design tokens.
412 + * Parameters mirror add_menu_page(), with $args appended.
413 + *
414 + * @since 0.13.0
415 + *
416 + * @param string $page_title The text to be displayed in the title tags of the page when the menu
417 + * is selected.
418 + * @param string $menu_title The text to be used for the menu.
419 + * @param string $capability The capability required for this menu to be displayed to the user.
420 + * @param string $menu_slug The slug name to refer to this menu by. Should be unique for this menu.
421 + * @param callable|null $function The function to be called to output the content for this page.
422 + * @param string $icon_url The URL to the icon to be used for this menu, or a dashicons class.
423 + * @param int|null $position The position in the menu order this item should appear.
424 + * @param array $args Optional. Visibility declaration for this item; see add_menu().
425 + *
426 + * @return string The resulting page's hook_suffix
427 + */
428 + public static function add_top_level_menu( $page_title, $menu_title, $capability, $menu_slug, $function, $icon_url = '', $position = null, $args = array() ) {
429 + self::init_top_level();
430 + self::$top_level_items[] = compact( 'page_title', 'menu_title', 'capability', 'menu_slug', 'function', 'icon_url', 'position', 'args' );
431 +
432 + // Same derivation as get_plugin_page_hookname(), which strips ".php" anywhere in the slug.
433 + return 'toplevel_page_' . preg_replace( '!\.php!', '', plugin_basename( $menu_slug ) );
434 + }
435 +
436 + /**
206 437 * Adds a new submenu to the Jetpack Top level menu
207 438 *
208 439 * The parameters this method accepts are the same as @see add_submenu_page. This class will
209 440 * aggreagate all menu items registered by stand-alone plugins and make sure they all go under the same
@@ -216,16 +447,30 @@
216 447 * @param string $menu_slug The slug name to refer to this menu by. Should be unique for this menu
217 448 * and only include lowercase alphanumeric, dashes, and underscores characters
218 449 * to be compatible with sanitize_key().
219 450 * @param callable|null $function The function to be called to output the content for this page.
220 - * @param int $position The position in the menu order this item should appear. Leave empty typically.
451 + * @param int|null $position One of the POSITION_* tiers; any other value is ignored. Leave empty typically.
452 + * @param array $args Optional. Visibility declaration for this item:
453 + * - 'product' (string) My Jetpack product slug whose activation gates the item.
454 + * - 'module' (string) Jetpack module name, for items with no product class.
455 + * - 'key' (string) The name hosts use for this item in the visibility
456 + * filter. Declare one on every item; see get_item_key().
457 + * An item that declares no gate is always shown.
221 458 *
222 459 * @return string The resulting page's hook_suffix
223 460 */
224 - public static function add_menu( $page_title, $menu_title, $capability, $menu_slug, $function, $position = null ) {
461 + public static function add_menu( $page_title, $menu_title, $capability, $menu_slug, $function, $position = null, $args = array() ) {
225 462 self::init();
226 - self::$menu_items[] = compact( 'page_title', 'menu_title', 'capability', 'menu_slug', 'function', 'position' );
227 463
464 + /*
465 + * Anything but a tier would opt the item out of the alphabetical order, so treat it as omitted.
466 + * @todo Add _doing_it_wrong() here after December 2026. Until all our own plugins ship tier-only
467 + * positions, it would have the latest release of one Jetpack plugin warning about another.
468 + */
469 + $position = is_numeric( $position ) && in_array( (int) $position, self::CALLER_POSITIONS, true ) ? (int) $position : null;
470 +
471 + self::$menu_items[] = compact( 'page_title', 'menu_title', 'capability', 'menu_slug', 'function', 'position', 'args' );
472 +
228 473 /**
229 474 * Let's return the page hook so consumers can use.
230 475 * We know all pages will be under Jetpack top level menu page, so we can hardcode the first part of the string.
231 476 * Using get_plugin_page_hookname here won't work because the top level page is not registered yet.
@@ -300,8 +545,144 @@
300 545 ';
301 546 }
302 547
303 548 /**
549 + * Sets the callback that resolves a menu item's declared gate.
550 + *
551 + * The callback receives the item's $args array and returns true (gate satisfied),
552 + * false (not satisfied), or null when it cannot answer — an unknown product slug,
553 + * for instance. Null is treated as satisfied, so a gate this package cannot resolve
554 + * never removes a menu item.
555 + *
556 + * This is the seam My Jetpack fills. Hosts wanting to shape the sidebar should use the
557 + * `jetpack_admin_menu_visibility` filter instead, which takes precedence: an item the
558 + * filter names is never put to this callback at all.
559 + *
560 + * @param callable|null $resolver Resolver callback, or null to clear it.
561 + * @return void
562 + */
563 + public static function set_visibility_resolver( $resolver ) {
564 + self::$visibility_resolver = $resolver;
565 + }
566 +
567 + /**
568 + * Takes hidden items out of the sidebar before it renders.
569 + *
570 + * Runs on admin_head, after core's access check has already resolved the current page,
571 + * so a hidden item's page stays reachable while its entry disappears.
572 + *
573 + * @return void
574 + */
575 + public static function remove_hidden_menu_items() {
576 + foreach ( self::$hidden_menu_slugs as $menu_slug ) {
577 + remove_submenu_page( 'jetpack', plugin_basename( $menu_slug ) );
578 + }
579 +
580 + foreach ( self::$hidden_top_level_slugs as $menu_slug ) {
581 + remove_menu_page( plugin_basename( $menu_slug ) );
582 + }
583 + }
584 +
585 + /**
586 + * Returns the name a host uses for a menu item in the visibility filter.
587 + *
588 + * The menu slug is only a fallback. It is the wrong thing to hand a host as an identifier:
589 + * several items register a URL as their slug, Blaze's is filterable, and VideoPress swaps
590 + * between two slugs depending on whether the module is active — so a host naming one of
591 + * them is naming a moving target, or only half an item.
592 + *
593 + * @param array $menu_item A registered menu item.
594 + * @return string
595 + */
596 + private static function get_item_key( array $menu_item ) {
597 + if ( ! empty( $menu_item['args']['key'] ) ) {
598 + return (string) $menu_item['args']['key'];
599 + }
600 +
601 + return (string) $menu_item['menu_slug'];
602 + }
603 +
604 + /**
605 + * Builds the item => state map and hands it to hosts to amend.
606 + *
607 + * @return array Map of item key to one of the VISIBILITY_* states.
608 + */
609 + private static function get_visibility_states() {
610 + // This filter is one a policy feeds, and nothing else need have read the policy this request.
611 + if ( method_exists( Feature_Policy::class, 'ensure_hooks' ) ) {
612 + Feature_Policy::ensure_hooks();
613 + }
614 +
615 + $states = array();
616 + $items = array_merge( self::$menu_items, self::$top_level_items );
617 +
618 + foreach ( $items as $menu_item ) {
619 + $states[ self::get_item_key( $menu_item ) ] = self::VISIBILITY_DEFAULT;
620 + }
621 +
622 + /**
623 + * Filters which Jetpack items appear in the wp-admin sidebar.
624 + *
625 + * Governs the sidebar entry only — a hidden item's page stays reachable by URL, so this
626 + * is not an access control. States: 'default' follows the item's feature, 'visible' shows it, 'hidden' removes it.
627 + *
628 + * @since 0.12.0
629 + *
630 + * @param array $states Map of item key (menu slug unless the item declared one) to state.
631 + * @param array $menu_items The registered menu items, for context.
632 + */
633 + $states = apply_filters( 'jetpack_admin_menu_visibility', $states, $items );
634 +
635 + return is_array( $states ) ? $states : array();
636 + }
637 +
638 + /**
639 + * Decides whether a single menu item should appear in the sidebar.
640 + *
641 + * @param array $menu_item A registered menu item.
642 + * @param array $visibility The resolved state map from get_visibility_states().
643 + * @return bool
644 + */
645 + private static function is_menu_item_visible( array $menu_item, array $visibility ) {
646 + $key = self::get_item_key( $menu_item );
647 + $state = $visibility[ $key ] ?? self::VISIBILITY_DEFAULT;
648 +
649 + if ( self::VISIBILITY_HIDDEN === $state ) {
650 + return false;
651 + }
652 +
653 + if ( self::VISIBILITY_VISIBLE === $state ) {
654 + return true;
655 + }
656 +
657 + return self::is_gate_satisfied( $menu_item['args'] ?? array() );
658 + }
659 +
660 + /**
661 + * Asks the resolver whether an item's declared gate is satisfied.
662 + *
663 + * Everything here fails open. An item that declares no gate, a site with no resolver
664 + * registered, and a gate the resolver does not recognize all keep the item in the
665 + * sidebar, so adopting this mechanism cannot remove an item nobody asked it to.
666 + *
667 + * @param array $args The item's visibility declaration.
668 + * @return bool
669 + */
670 + private static function is_gate_satisfied( array $args ) {
671 + if ( ! isset( $args['product'] ) && ! isset( $args['module'] ) ) {
672 + return true;
673 + }
674 +
675 + if ( ! is_callable( self::$visibility_resolver ) ) {
676 + return true;
677 + }
678 +
679 + $resolved = call_user_func( self::$visibility_resolver, $args );
680 +
681 + return null === $resolved ? true : (bool) $resolved;
682 + }
683 +
684 + /**
304 685 * Removes an already added submenu
305 686 *
306 687 * @param string $menu_slug The slug of the submenu to remove.
307 688 *
@@ -322,18 +703,29 @@
322 703
323 704 /**
324 705 * Gets the slug for the first item under the Jetpack top level menu
325 706 *
707 + * Skips hidden items rather than reading $submenu alone, because callers on admin_enqueue_scripts
708 + * and outside wp-admin ask before — or without — the admin_head pass that drops them.
709 + *
326 710 * @return string|null
327 711 */
328 712 public static function get_top_level_menu_item_slug() {
329 713 global $submenu;
330 - if ( ! empty( $submenu['jetpack'] ) ) {
331 - $item = reset( $submenu['jetpack'] );
332 - if ( isset( $item[2] ) ) {
714 +
715 + if ( empty( $submenu['jetpack'] ) ) {
716 + return null;
717 + }
718 +
719 + $hidden = array_map( 'plugin_basename', self::$hidden_menu_slugs );
720 +
721 + foreach ( $submenu['jetpack'] as $item ) {
722 + if ( isset( $item[2] ) && ! in_array( $item[2], $hidden, true ) ) {
333 723 return $item[2];
334 724 }
335 725 }
726 +
727 + return null;
336 728 }
337 729
338 730 /**
339 731 * Gets the URL for the first item under the Jetpack top level menu
@@ -484,9 +876,9 @@
484 876 $menu_title,
485 877 'manage_options',
486 878 esc_url( $upgrade_url ),
487 879 null, // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal -- Core should ideally document null for no-callback arg. https://core.trac.wordpress.org/ticket/52539.
488 - 999
880 + self::POSITION_UPGRADE
489 881 );
490 882
491 883 // Add a CSS class to the <li> element so styles can target it precisely.
492 884 global $submenu;
@@ -527,31 +919,32 @@
527 919 self::enqueue_upgrade_menu_tracks_script( $asset );
528 920 }
529 921
530 922 /**
531 - * Enqueues the shared, token-only WPDS design-tokens stylesheet.
923 + * Enqueues WPDS design tokens so `var(--wpds-*)` values resolve at runtime.
532 924 *
533 - * Single entry point for any consumer that needs WPDS `var(--wpds-*)` values
534 - * to resolve at runtime on a Jetpack admin page. Registers the handle on
535 - * first use (idempotent) and enqueues it; the caller is responsible for
536 - * scoping the call to the right page(s). Since admin-ui is a dependency of
537 - * the Jetpack plugin and the modernized packages, both the plugin's
538 - * legacy/wrap_ui gate and this package's own dashboards call through here,
539 - * so the handle has a single owner and there is no duplicated enqueue logic.
925 + * Prefer Core/Gutenberg's `wp-theme` style when registered; otherwise ship
926 + * the bundled copy. The caller scopes the call to the right page(s).
540 927 *
541 928 * @return void
542 929 */
543 930 public static function enqueue_design_tokens() {
931 + // Registered since WP 7.1 (and by Gutenberg):
932 + // https://make.wordpress.org/core/2026/07/31/design-system-theming-in-wordpress-7-1/
933 + if ( wp_style_is( 'wp-theme', 'registered' ) ) {
934 + wp_enqueue_style( 'wp-theme' );
935 + return;
936 + }
937 +
938 + // @todo Remove this, the called function, and the webpack entrypoint it registers when WP 7.1 is the minimum version.
544 939 self::register_design_tokens_style();
545 940 wp_enqueue_style( self::DESIGN_TOKENS_HANDLE );
546 941 }
547 942
548 943 /**
549 - * Registers the shared, token-only WPDS design-tokens stylesheet.
944 + * Registers the bundled, token-only WPDS design-tokens stylesheet.
550 945 *
551 - * The stylesheet only defines `:root{--wpds-*}` custom properties (no
552 - * component or class styles), giving every Jetpack admin page a single
553 - * runtime source for design tokens. It is safe to call repeatedly:
946 + * Used only when `wp-theme` is not registered. Safe to call repeatedly:
554 947 * wp_register_style() is a no-op once the handle is registered.
555 948 *
556 949 * @return void
557 950 */