From 2c364312bc1ff9ed5d127f6b0a2e404a592b70a1 Mon Sep 17 00:00:00 2001
From: "Joen A."
Date: Fri, 28 Aug 2026 12:40:03 +0200
Subject: [PATCH 01/26] Add an experiment that restyles wp-admin from design
system tokens
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds `gutenberg-wpds-admin-restyle`, off by default, which styles
wp-admin controls from the `--wpds-*` design tokens so PHP-rendered and React screens
share an appearance. Visual only: no markup is changed, and turning the
experiment off restores WordPress's rendering exactly.
Mechanism. Layered CSS loses to unlayered CSS, so WordPress's admin stylesheets are
demoted into a `wp-legacy` layer by rewriting their `` tags into
`@import ... layer()` through the `style_loader_tag` filter. Handles stay
registered and enqueued and the dependency graph is untouched. Admin style
concatenation is switched off while the experiment runs, because
`WP_Styles::do_item()` short-circuits before that filter when concatenating.
Three layers, ordered `wpds-overrides, wp-legacy, wpds`. Normal declarations
resolve to the last layer, so `wpds` wins. Important declarations reverse layer
order, so `wpds-overrides` wins — the only way to override a WordPress `!important`,
since specificity is not consulted across layers. `overrides.css` is limited to
declarations that cancel a specific WordPress `!important`, and is the only file
permitted to use it; a stylelint rule enforces that everywhere else, because a
layered `!important` would otherwise outrank a plugin's unlayered one.
The demoted set covers WordPress's admin stylesheets as a group rather than a chosen
few. WordPress's stylesheets override one another by source order — most visibly the
admin colour scheme over the base chrome — so demoting a subset inverts those
relationships. Gutenberg's own package styles are excluded, in particular
`wp-base-styles`, which defines `--wp-admin-theme-color` per scheme.
Stylesheets cover buttons, text inputs, selects, textareas, checkboxes and
radios. They set appearance only. Height, min-height and vertical-align are left
to WordPress, which refines them contextually in 67 rules across seven stylesheets
(`.wp-core-ui .tablenav .button` and similar); anything declared here would
outrank those regardless of their specificity and flatten WordPress's compact
toolbars. WordPress's defaults already match the design system's values.
The checkbox tick is drawn with `mask-image` rather than an SVG carrying a baked
fill, so its colour follows `currentColor` and remains legible when the control
is disabled. Checked controls take their accent from `--wp-admin-theme-color`
rather than a design system brand token, so admin colour schemes keep working.
Includes a parity harness at Tools > WPDS Parity that renders every control state
under the real cascade, with a control styled by an unlayered rule to check that
third-party CSS still takes precedence over the restyle.
Co-Authored-By: Claude Opus 5
---
.stylelintrc.mjs | 35 +++
lib/experimental/experiments/load.php | 5 +
lib/experimental/wpds-admin/css/button.css | 209 +++++++++++++
.../wpds-admin/css/checkbox-radio.css | 165 +++++++++++
.../wpds-admin/css/exceptions.css | 64 ++++
lib/experimental/wpds-admin/css/input.css | 272 +++++++++++++++++
lib/experimental/wpds-admin/css/overrides.css | 79 +++++
lib/experimental/wpds-admin/harness.php | 209 +++++++++++++
lib/experimental/wpds-admin/load.php | 280 ++++++++++++++++++
lib/load.php | 4 +
10 files changed, 1322 insertions(+)
create mode 100644 lib/experimental/wpds-admin/css/button.css
create mode 100644 lib/experimental/wpds-admin/css/checkbox-radio.css
create mode 100644 lib/experimental/wpds-admin/css/exceptions.css
create mode 100644 lib/experimental/wpds-admin/css/input.css
create mode 100644 lib/experimental/wpds-admin/css/overrides.css
create mode 100644 lib/experimental/wpds-admin/harness.php
create mode 100644 lib/experimental/wpds-admin/load.php
diff --git a/.stylelintrc.mjs b/.stylelintrc.mjs
index 603f6e8f6f9945..dc53ef04708868 100644
--- a/.stylelintrc.mjs
+++ b/.stylelintrc.mjs
@@ -4,4 +4,39 @@ const require = createRequire( import.meta.url );
export default {
extends: require.resolve( '@wordpress/stylelint-tools/config' ),
+ overrides: [
+ {
+ /*
+ * The classic admin restyle ships inside a cascade layer, and layer
+ * priority REVERSES for `!important`: a layered `!important` beats a
+ * third-party plugin's *unlayered* `!important`, even when the
+ * plugin's stylesheet loads later. Allowing one here would silently
+ * break overrides the ecosystem has relied on for years, which is
+ * exactly the back-compat guarantee this work depends on.
+ *
+ * Normal declarations are safe — unlayered plugin CSS still wins.
+ */
+ files: [ 'lib/experimental/wpds-admin/**/*.css' ],
+ rules: {
+ 'declaration-no-important': true,
+ },
+ },
+ {
+ /*
+ * The single exception. `overrides.css` exists only to cancel
+ * `!important` declarations in Core's admin CSS, which survive being
+ * demoted into a lower layer because importance is compared before
+ * layer order. It sits in a layer declared BEFORE the legacy one,
+ * since importance reverses layer order.
+ *
+ * Every rule in that file must be cancelling a specific Core
+ * `!important` and must record what would let us delete it. Do not
+ * widen this exception to any other file.
+ */
+ files: [ 'lib/experimental/wpds-admin/css/overrides.css' ],
+ rules: {
+ 'declaration-no-important': null,
+ },
+ },
+ ],
};
diff --git a/lib/experimental/experiments/load.php b/lib/experimental/experiments/load.php
index 11405b4b789583..59b035eb038799 100644
--- a/lib/experimental/experiments/load.php
+++ b/lib/experimental/experiments/load.php
@@ -104,6 +104,11 @@ function gutenberg_initialize_experiments_settings() {
'label' => __( 'Guidelines', 'gutenberg' ),
'description' => __( 'Enables the Guidelines page under Settings and the experimental knowledge storage (wp_knowledge).', 'gutenberg' ),
),
+ array(
+ 'id' => 'gutenberg-wpds-admin-restyle',
+ 'label' => __( 'Classic admin restyle', 'gutenberg' ),
+ 'description' => __( 'Restyles classic wp-admin screens from WordPress Design System tokens, so classic and React screens read as one product. Visual only; no markup changes.', 'gutenberg' ),
+ ),
array(
'id' => 'gutenberg-dashboard-widgets',
'label' => __( 'New Dashboard experience', 'gutenberg' ),
diff --git a/lib/experimental/wpds-admin/css/button.css b/lib/experimental/wpds-admin/css/button.css
new file mode 100644
index 00000000000000..d756d2484d96fe
--- /dev/null
+++ b/lib/experimental/wpds-admin/css/button.css
@@ -0,0 +1,209 @@
+/**
+ * Classic admin buttons, restyled from WordPress Design System tokens.
+ *
+ * Target: visual parity with the React `Button`
+ * (packages/components/src/button/style.scss). The React component is the
+ * source of truth for *values*. The selectors here are hand-authored against
+ * classic markup and are deliberately NOT derived from it — deriving selectors
+ * is what sank the earlier generation POC.
+ *
+ * Cascade: everything lives in the `wpds` layer, which sits above `wp-legacy`,
+ * where load.php demotes Core's own buttons.css. Layer order means the plain
+ * selectors below beat Core's more specific ones with no specificity
+ * escalation — and unlayered plugin CSS still beats us, which is the point.
+ *
+ * NO `!important` IN THIS FILE. Measured: a layered `!important` outranks a
+ * third-party plugin's *unlayered* `!important`, even when the plugin's
+ * stylesheet loads later. That would silently break overrides the ecosystem
+ * has relied on for years. Enforced by stylelint.
+ *
+ * Accent colour is always var(--wp-admin-theme-color) so admin colour schemes
+ * keep working. Never hardcode it.
+ */
+
+@layer wpds-overrides, wp-legacy, wpds;
+
+@layer wpds {
+
+ /* -----------------------------------------------------------------------
+ Base — shared by default, primary and secondary
+ ----------------------------------------------------------------------- */
+
+ .wp-core-ui .button,
+ .wp-core-ui .button-primary,
+ .wp-core-ui .button-secondary {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+ /*
+ * NO height here, deliberately. Core's default is already 40px, which is
+ * exactly --wpds-dimension-size-lg, so asserting it gains nothing — and it
+ * costs a great deal, because a height declared in this layer outranks
+ * Core's CONTEXTUAL overrides regardless of their specificity. Core makes
+ * 67 such rules (`.wp-core-ui .tablenav .button` and friends) to make
+ * toolbars compact; declaring a height here silently flattened all of
+ * them, and the list-table toolbar rendered as a mix of 32px and 40px
+ * controls that no longer shared a midline.
+ *
+ * Height is Core's. Appearance is ours.
+ */
+ margin: 0;
+ padding: 4px 12px;
+ font-family: inherit;
+ font-size: 13px;
+ font-weight: var(--wpds-typography-font-weight-emphasis);
+ line-height: 1;
+ text-decoration: none;
+ white-space: nowrap;
+ /*
+ * NO vertical-align either. Core varies it by context — `top` for the
+ * base, `baseline` for `p .button`, `middle` for `.button-group` — and a
+ * blanket value here flattens those the same way a height would. It is how
+ * the control sits in a line of other content, which is layout, not
+ * appearance. Measured: forcing `top` pushed the list table's "156 items"
+ * label 7px above the midline of its own toolbar.
+ *
+ * `line-height` stays ours: it pairs with our padding to centre the label
+ * inside the box, and reverting it makes buttons 34px tall.
+ */
+ appearance: none;
+ cursor: var(--wpds-cursor-control);
+ border: var(--wpds-border-width-xs) solid transparent;
+ border-radius: var(--wpds-border-radius-sm);
+ background: none;
+ color: var(--wpds-color-foreground-content-neutral);
+ box-shadow: none;
+ text-shadow: none;
+ }
+
+ /* -----------------------------------------------------------------------
+ Focus — outline/outline-offset, never box-shadow.
+ Core's legacy ring is a box-shadow; the base rule above clears it.
+ ----------------------------------------------------------------------- */
+
+ .wp-core-ui .button:focus,
+ .wp-core-ui .button-primary:focus,
+ .wp-core-ui .button-secondary:focus,
+ .wp-core-ui .button.focus,
+ .wp-core-ui .button-primary.focus,
+ .wp-core-ui .button-secondary.focus {
+ outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus);
+ outline-offset: var(--wpds-border-width-focus);
+ box-shadow: none;
+ }
+
+ /* React suppresses the ring while the button is actively pressed. */
+ .wp-core-ui .button:focus:active,
+ .wp-core-ui .button-primary:focus:active,
+ .wp-core-ui .button-secondary:focus:active {
+ outline-color: transparent;
+ }
+
+ /* -----------------------------------------------------------------------
+ Default and secondary — maps to the React `is-secondary` variant
+ ----------------------------------------------------------------------- */
+
+ .wp-core-ui .button,
+ .wp-core-ui .button-secondary {
+ border-color: var(--wp-admin-theme-color);
+ color: var(--wp-admin-theme-color);
+ background: transparent;
+ }
+
+ @media not (prefers-reduced-motion) {
+ .wp-core-ui .button,
+ .wp-core-ui .button-secondary {
+ transition: border-color 0.1s linear;
+ }
+ }
+
+ .wp-core-ui .button:hover,
+ .wp-core-ui .button-secondary:hover,
+ .wp-core-ui .button.hover,
+ .wp-core-ui .button-secondary.hover {
+ border-color: var(--wp-admin-theme-color-darker-20);
+ color: var(--wp-admin-theme-color-darker-20);
+ background: color-mix(in srgb, var(--wp-admin-theme-color) 4%, transparent);
+ }
+
+ /* Hide the border while pressed, keeping its width so nothing shifts. */
+ .wp-core-ui .button:active,
+ .wp-core-ui .button-secondary:active {
+ border-color: transparent;
+ background: color-mix(in srgb, var(--wp-admin-theme-color) 8%, transparent);
+ }
+
+ /*
+ * `.active` is a PERSISTENT selected state in classic admin, toggled by
+ * legacy admin JS. It is not `:active`. Never conflate the two.
+ */
+ .wp-core-ui .button.active,
+ .wp-core-ui .button-secondary.active {
+ border-color: var(--wp-admin-theme-color-darker-20);
+ color: var(--wp-admin-theme-color-darker-20);
+ background: color-mix(in srgb, var(--wp-admin-theme-color) 8%, transparent);
+ }
+
+ /* -----------------------------------------------------------------------
+ Primary — maps to the React `is-primary` variant
+ ----------------------------------------------------------------------- */
+
+ .wp-core-ui .button-primary {
+ border-color: transparent;
+ background: var(--wp-admin-theme-color);
+ color: var(--wpds-color-foreground-interactive-brand-strong);
+ }
+
+ .wp-core-ui .button-primary:hover,
+ .wp-core-ui .button-primary.hover {
+ border-color: transparent;
+ background: var(--wp-admin-theme-color-darker-10);
+ color: var(--wpds-color-foreground-interactive-brand-strong);
+ }
+
+ .wp-core-ui .button-primary:active,
+ .wp-core-ui .button-primary.active {
+ border-color: transparent;
+ background: var(--wp-admin-theme-color-darker-20);
+ color: var(--wpds-color-foreground-interactive-brand-strong);
+ }
+
+ /* -----------------------------------------------------------------------
+ Disabled — classic drives this from classes as well as attributes
+ ----------------------------------------------------------------------- */
+
+ .wp-core-ui .button:disabled,
+ .wp-core-ui .button[disabled],
+ .wp-core-ui .button.disabled,
+ .wp-core-ui .button[aria-disabled="true"],
+ .wp-core-ui .button-secondary:disabled,
+ .wp-core-ui .button-secondary[disabled],
+ .wp-core-ui .button-secondary.disabled,
+ .wp-core-ui .button-secondary[aria-disabled="true"],
+ .wp-core-ui .button-disabled {
+ border-color: var(--wpds-color-stroke-interactive-neutral-disabled);
+ color: var(--wpds-color-foreground-content-neutral-weak);
+ background: transparent;
+ transform: none;
+ }
+
+ .wp-core-ui .button-primary:disabled,
+ .wp-core-ui .button-primary[disabled],
+ .wp-core-ui .button-primary.disabled,
+ .wp-core-ui .button-primary[aria-disabled="true"],
+ .wp-core-ui .button-primary-disabled {
+ border-color: transparent;
+ background: var(--wp-admin-theme-color);
+ color: rgba(255, 255, 255, 0.4);
+ }
+
+ /* Disabled buttons keep their focus ring when focused. */
+ .wp-core-ui .button:disabled:focus,
+ .wp-core-ui .button[aria-disabled="true"]:focus,
+ .wp-core-ui .button-primary:disabled:focus,
+ .wp-core-ui .button-primary[aria-disabled="true"]:focus {
+ outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus);
+ outline-offset: var(--wpds-border-width-focus);
+ }
+}
diff --git a/lib/experimental/wpds-admin/css/checkbox-radio.css b/lib/experimental/wpds-admin/css/checkbox-radio.css
new file mode 100644
index 00000000000000..45e56d8b09efb2
--- /dev/null
+++ b/lib/experimental/wpds-admin/css/checkbox-radio.css
@@ -0,0 +1,165 @@
+/**
+ * Classic admin checkboxes and radios, restyled from WordPress Design System
+ * tokens.
+ *
+ * Parity target is the `@wordpress/ui` checkbox primitive
+ * (packages/ui/src/form/primitives/checkbox/style.module.css) for VALUES. Its
+ * STRUCTURE cannot be copied: it renders the tick as a separate icon element,
+ * and classic ships a bare `` with no markup to hook. So
+ * the indicator stays a `::before`, as Core does it, and only the values move.
+ * That is the "specs are shared, selectors never are" split in practice.
+ *
+ * The tick is drawn with `mask-image` rather than an SVG `url()` with a baked
+ * fill, so its colour comes from `currentColor` and can respond to state. Core's
+ * version hardcodes `#ffffff` inside the data URI, which makes a disabled tick
+ * invisible against a muted box. Same technique the dashicons swap will need.
+ *
+ * Geometry (size, margins) is deliberately left to Core: it is layout, not
+ * style, and touching it would shift every checkbox on every screen.
+ *
+ * NO `!important` IN THIS FILE. See overrides.css for the one exception.
+ */
+
+@layer wpds-overrides, wp-legacy, wpds;
+
+@layer wpds {
+
+ /* -----------------------------------------------------------------------
+ Base
+ ----------------------------------------------------------------------- */
+
+ input[type="checkbox"],
+ input[type="radio"] {
+ box-sizing: border-box;
+ border: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-interactive-neutral);
+ border-radius: var(--wpds-border-radius-sm);
+ /*
+ * White rather than the design system's transparent field background,
+ * for the same reason as input.css: classic controls sit on the grey page
+ * background as often as on white, and a transparent control would read as
+ * grey.
+ */
+ background: var(--wpds-color-background-surface-neutral-strong);
+ color: var(--wpds-color-foreground-interactive-brand-strong);
+ box-shadow: none;
+ cursor: var(--wpds-cursor-control);
+ }
+
+ input[type="radio"] {
+ border-radius: 50%;
+ }
+
+ /* -----------------------------------------------------------------------
+ Hover — excluded from disabled, as in input.css
+ ----------------------------------------------------------------------- */
+
+ input[type="checkbox"]:hover,
+ input[type="radio"]:hover {
+ border-color: var(--wpds-color-stroke-interactive-neutral-active);
+ }
+
+ /* -----------------------------------------------------------------------
+ Checked
+
+ Accent comes from `--wp-admin-theme-color`, not from the design system's
+ `--wpds-color-background-interactive-brand-strong`.
+
+ React admin UI does follow the user's colour scheme: `getAdminThemeColors()`
+ reads the `admin-color-*` body class and `ThemeProvider` derives `--wpds-*`
+ values from it. But the prebuilt token stylesheet emits a flat `#3858e9`,
+ and classic admin has no provider — so a CSS-only consumer is pinned to
+ that literal with no way to accept a spot colour.
+
+ `@wordpress/components` solves this with a fallback chain
+ (`var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9))`).
+ Until the prebuilt brand tokens carry the same chain, classic uses the
+ scheme variable directly.
+ ----------------------------------------------------------------------- */
+
+ input[type="checkbox"]:checked,
+ input[type="radio"]:checked {
+ border-color: var(--wp-admin-theme-color);
+ background: var(--wp-admin-theme-color);
+ }
+
+ /* The tick, masked so its colour follows `currentColor`. */
+ input[type="checkbox"]:checked::before {
+ content: "";
+ display: block;
+ float: none;
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ background-color: currentColor;
+ mask-image: url(data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20viewBox%3D%270%200%2020%2020%27%3E%3Cpath%20d%3D%27M14.83%204.89l1.34.94-5.81%208.38H9.02L5.78%209.67l1.34-1.25%202.57%202.4z%27%2F%3E%3C%2Fsvg%3E);
+ mask-size: contain;
+ mask-position: center;
+ mask-repeat: no-repeat;
+ }
+
+ /* The dot. Geometry is Core's; only the colour changes. */
+ input[type="radio"]:checked::before {
+ background-color: currentColor;
+ }
+
+ /* -----------------------------------------------------------------------
+ Focus — outline, never box-shadow. Core uses a two-ring box-shadow here.
+ ----------------------------------------------------------------------- */
+
+ input[type="checkbox"]:focus,
+ input[type="radio"]:focus {
+ outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus);
+ outline-offset: var(--wpds-border-width-focus);
+ box-shadow: none;
+ }
+
+ /* -----------------------------------------------------------------------
+ Disabled
+ ----------------------------------------------------------------------- */
+
+ input[type="checkbox"]:disabled,
+ input[type="checkbox"].disabled,
+ input[type="checkbox"][aria-disabled="true"],
+ input[type="radio"]:disabled,
+ input[type="radio"].disabled,
+ input[type="radio"][aria-disabled="true"] {
+ border-color: var(--wpds-color-stroke-interactive-neutral-disabled);
+ background: var(--wpds-color-background-surface-neutral-weak);
+ color: var(--wpds-color-foreground-interactive-neutral-disabled);
+ cursor: default;
+ opacity: 1;
+ }
+
+ /* Hover must not re-enable the border on a disabled control. */
+ input[type="checkbox"]:disabled:hover,
+ input[type="checkbox"].disabled:hover,
+ input[type="checkbox"][aria-disabled="true"]:hover,
+ input[type="radio"]:disabled:hover,
+ input[type="radio"].disabled:hover,
+ input[type="radio"][aria-disabled="true"]:hover {
+ border-color: var(--wpds-color-stroke-interactive-neutral-disabled);
+ }
+
+ /*
+ * Disabled AND checked: the box stays muted and the mark greys out with it,
+ * which is only legible because the mark is masked rather than a baked-white
+ * SVG. Core instead fades the whole control with `opacity: 0.7`.
+ */
+ input[type="checkbox"]:disabled:checked,
+ input[type="checkbox"].disabled:checked,
+ input[type="checkbox"][aria-disabled="true"]:checked,
+ input[type="radio"]:disabled:checked,
+ input[type="radio"].disabled:checked,
+ input[type="radio"][aria-disabled="true"]:checked {
+ border-color: var(--wpds-color-stroke-interactive-neutral-disabled);
+ background: var(--wpds-color-background-interactive-neutral-strong-disabled);
+ color: var(--wpds-color-foreground-interactive-neutral-disabled);
+ }
+
+ input[type="checkbox"]:disabled:checked::before,
+ input[type="checkbox"].disabled:checked::before,
+ input[type="radio"]:disabled:checked::before,
+ input[type="radio"].disabled:checked::before {
+ opacity: 1;
+ }
+}
diff --git a/lib/experimental/wpds-admin/css/exceptions.css b/lib/experimental/wpds-admin/css/exceptions.css
new file mode 100644
index 00000000000000..e7c6368263cd6d
--- /dev/null
+++ b/lib/experimental/wpds-admin/css/exceptions.css
@@ -0,0 +1,64 @@
+/**
+ * Classic-only button styles with no React counterpart.
+ *
+ * RULE: every rule in this file carries the condition that would let us delete
+ * it. Without that this file becomes permanent sediment instead of a to-do list.
+ *
+ * Heights are deliberately absent here too: Core already defines every one of
+ * these sizes at the same values, and a height declared in this layer would
+ * outrank Core's contextual overrides. Only the padding and font-size remain.
+ *
+ * Sizing note — the atomic-styles brief recorded `.button-compact`,
+ * `.button-small` and `.button-large` as classic-only sizes missing from the
+ * design system. That is not accurate as of this branch: the React Button has
+ * `is-compact` (32px) and `is-small` (24px), matching classic's heights exactly,
+ * so those need no exception and are handled by button.css. `.button-large` is
+ * defined by Core as an explicit 40px, identical to the default, so it also
+ * needs nothing. Only `.button-hero` is genuinely unmatched.
+ */
+
+@layer wpds-overrides, wp-legacy, wpds;
+
+@layer wpds {
+
+ /*
+ * Compact — 32px. Matches React `is-compact`.
+ * DELETE WHEN: never; this is parity, not an exception. Kept here only
+ * because Core's size classes are all defined together and splitting them
+ * across two files would be harder to read.
+ */
+ .wp-core-ui .button.button-compact,
+ .wp-core-ui .button-group.button-compact .button {
+ padding: 0 12px;
+ }
+
+ /*
+ * Small — 24px. Matches React `is-small`.
+ * DELETE WHEN: never; see above.
+ */
+ .wp-core-ui .button.button-small,
+ .wp-core-ui .button-group.button-small .button {
+ padding: 0 8px;
+ font-size: 11px;
+ }
+
+ /*
+ * Large — Core defines this as an explicit 40px, i.e. the default size,
+ * kept for semantic clarity in markup.
+ * DELETE WHEN: nothing to delete; listed for completeness so a reader does
+ * not go looking for a missing rule.
+ */
+
+ /*
+ * Hero — 48px. GENUINE EXCEPTION: no React equivalent exists.
+ * DELETE WHEN: either a 48px size lands on the React Button (at which point
+ * this becomes parity and moves to button.css), or Core deprecates
+ * `.button-hero`. Until one of those happens this rule is load-bearing.
+ * Used by the installer and a handful of onboarding screens.
+ */
+ .wp-core-ui .button.button-hero,
+ .wp-core-ui .button-group.button-hero .button {
+ padding: 0 36px;
+ font-size: 14px;
+ }
+}
diff --git a/lib/experimental/wpds-admin/css/input.css b/lib/experimental/wpds-admin/css/input.css
new file mode 100644
index 00000000000000..9b79d6a4100e39
--- /dev/null
+++ b/lib/experimental/wpds-admin/css/input.css
@@ -0,0 +1,272 @@
+/**
+ * Classic admin text inputs, selects and textareas, restyled from WordPress
+ * Design System tokens.
+ *
+ * Target: visual parity with the `@wordpress/ui` input primitive
+ * (packages/ui/src/form/primitives/input-layout/style.module.css), which is the
+ * purest token expression of a field in the design system. Values come from
+ * there; the selectors below are hand-authored against classic markup.
+ *
+ * Selector list mirrors Core's own in wp-admin/css/forms.css so the same set of
+ * controls is covered — no more, no less.
+ *
+ * NO `!important` IN THIS FILE. See overrides.css for the one exception and why.
+ */
+
+@layer wpds-overrides, wp-legacy, wpds;
+
+@layer wpds {
+
+ /* -----------------------------------------------------------------------
+ Base — shared by text inputs, select and textarea
+ ----------------------------------------------------------------------- */
+
+ input[type="text"],
+ input[type="password"],
+ input[type="color"],
+ input[type="date"],
+ input[type="datetime"],
+ input[type="datetime-local"],
+ input[type="email"],
+ input[type="month"],
+ input[type="number"],
+ input[type="search"],
+ input[type="tel"],
+ input[type="time"],
+ input[type="url"],
+ input[type="week"],
+ select,
+ textarea {
+ box-sizing: border-box;
+ border: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-interactive-neutral);
+ border-radius: var(--wpds-border-radius-sm);
+ /*
+ * DEVIATION from the design system, deliberate.
+ * The DS field is transparent (`--wpds-color-background-interactive-neutral-weak`
+ * is `#0000`) because it always sits on a defined surface. Classic admin
+ * has no such guarantee — fields sit directly on the grey `#f0f0f0` page
+ * background as often as on white cards — so a transparent field would
+ * read as grey on many screens. White keeps the field legible as a field.
+ */
+ background-color: var(--wpds-color-background-surface-neutral-strong);
+ color: var(--wpds-color-foreground-interactive-neutral);
+ box-shadow: none;
+ font-family: var(--wpds-typography-font-family-body);
+ /* 16px below 600px avoids iOS zooming on focus; matches the DS. */
+ font-size: max(var(--wpds-typography-font-size-md), 16px);
+ line-height: 1;
+ }
+
+ @media (min-width: 600px) {
+
+ input[type="text"],
+ input[type="password"],
+ input[type="color"],
+ input[type="date"],
+ input[type="datetime"],
+ input[type="datetime-local"],
+ input[type="email"],
+ input[type="month"],
+ input[type="number"],
+ input[type="search"],
+ input[type="tel"],
+ input[type="time"],
+ input[type="url"],
+ input[type="week"],
+ select,
+ textarea {
+ font-size: var(--wpds-typography-font-size-md);
+ }
+ }
+
+ /* -----------------------------------------------------------------------
+ Sizing — text inputs and select get a fixed height; textarea does not
+ ----------------------------------------------------------------------- */
+
+ input[type="text"],
+ input[type="password"],
+ input[type="date"],
+ input[type="datetime"],
+ input[type="datetime-local"],
+ input[type="email"],
+ input[type="month"],
+ input[type="number"],
+ input[type="search"],
+ input[type="tel"],
+ input[type="time"],
+ input[type="url"],
+ input[type="week"] {
+ /* No height — see the note in button.css. Core's default is already 40px
+ and its contextual rules must keep winning. */
+ padding: 0 var(--wpds-dimension-padding-md);
+ }
+
+ /*
+ * Select needs asymmetric padding, because Core draws the dropdown arrow as a
+ * background image at the trailing edge and the text has to clear it.
+ *
+ * Core reserves exactly the arrow's own width (24px for a 16px arrow inset
+ * 8px), so the text ends precisely where the arrow begins and there is no
+ * gutter at all. React's SelectControl reserves the icon PLUS a trailing
+ * gutter mirroring the leading one:
+ *
+ * paddingLeft: controlPaddingX // 12
+ * paddingRight: controlPaddingX + 18 // chevronIconSize
+ *
+ * This adopts that formula rather than inventing a number, so the two are the
+ * same construction rather than coincidentally similar.
+ *
+ * The 18px is deliberately not a token. The icon set is drawn and hinted at
+ * 24x24 and, being fill-based, scales badly to arbitrary sizes; 18 is 75% of
+ * 24, picked to interpolate cleanly. Mirroring it keeps classic and the
+ * component in step.
+ *
+ * Logical properties, so this follows the arrow when Core's RTL stylesheet
+ * flips it to the leading edge.
+ */
+ select {
+ /*
+ * Only the TRAILING padding, not the shorthand. Core narrows the leading
+ * padding contextually (`.wp-core-ui .tablenav select` uses 8px), and a
+ * shorthand here would flatten that along with everything else. Setting
+ * one side leaves Core's contextual value on the other intact, and the
+ * arrow still gets its gutter in every context.
+ *
+ * No height, for the reason given in button.css.
+ */
+ padding-inline-end: calc(var(--wpds-dimension-padding-md) + 18px);
+ }
+
+ textarea {
+ padding: var(--wpds-dimension-padding-sm) var(--wpds-dimension-padding-md);
+ line-height: var(--wpds-typography-line-height-md);
+ }
+
+ /* -----------------------------------------------------------------------
+ Placeholder
+ ----------------------------------------------------------------------- */
+
+ /*
+ * Uses the design system token even though it fails contrast on white
+ * (#8d8d8d is about 3.3:1; WCAG AA wants 4.5:1 for text). Matching the token
+ * is deliberate: diverging here would hide the problem and report a false
+ * difference against the component. The fix belongs in the design system.
+ */
+ input::placeholder,
+ textarea::placeholder {
+ color: var(--wpds-color-foreground-interactive-neutral-disabled);
+ opacity: 1;
+ }
+
+ /* -----------------------------------------------------------------------
+ Hover — classic has no hover state; the design system darkens the border.
+
+ Excluded from disabled and readonly fields. Without the :not() the hover
+ rule outranks the disabled rule: an attribute selector plus a pseudo-class
+ is (0,2,1), while `input:disabled` is only (0,1,1), so hovering a disabled
+ field would light up its border as if it were editable.
+ ----------------------------------------------------------------------- */
+
+ input[type="text"]:hover,
+ input[type="password"]:hover,
+ input[type="date"]:hover,
+ input[type="datetime"]:hover,
+ input[type="datetime-local"]:hover,
+ input[type="email"]:hover,
+ input[type="month"]:hover,
+ input[type="number"]:hover,
+ input[type="search"]:hover,
+ input[type="tel"]:hover,
+ input[type="time"]:hover,
+ input[type="url"]:hover,
+ input[type="week"]:hover,
+ select:hover,
+ textarea:hover {
+ border-color: var(--wpds-color-stroke-interactive-neutral-active);
+ }
+
+ /* Disabled fields keep the disabled border on hover. */
+ input:disabled:hover,
+ input.disabled:hover,
+ input[aria-disabled="true"]:hover,
+ select:disabled:hover,
+ select.disabled:hover,
+ select[aria-disabled="true"]:hover,
+ textarea:disabled:hover,
+ textarea.disabled:hover,
+ textarea[aria-disabled="true"]:hover {
+ border-color: var(--wpds-color-stroke-interactive-neutral-disabled);
+ }
+
+ /*
+ * Readonly fields keep the NORMAL border on hover — they are not disabled,
+ * their border is the normal one, and lightening it would be wrong in the
+ * other direction. They simply do not gain the hover emphasis, because the
+ * tinted background already says the value cannot be edited.
+ */
+ input[readonly]:hover,
+ input.readonly:hover,
+ textarea[readonly]:hover,
+ textarea.readonly:hover {
+ border-color: var(--wpds-color-stroke-interactive-neutral);
+ }
+
+ /* -----------------------------------------------------------------------
+ Focus — outline/outline-offset, never box-shadow.
+ Core's ring is a box-shadow plus an accent border; both are replaced.
+ ----------------------------------------------------------------------- */
+
+ input[type="text"]:focus,
+ input[type="password"]:focus,
+ input[type="color"]:focus,
+ input[type="date"]:focus,
+ input[type="datetime"]:focus,
+ input[type="datetime-local"]:focus,
+ input[type="email"]:focus,
+ input[type="month"]:focus,
+ input[type="number"]:focus,
+ input[type="search"]:focus,
+ input[type="tel"]:focus,
+ input[type="time"]:focus,
+ input[type="url"]:focus,
+ input[type="week"]:focus,
+ select:focus,
+ textarea:focus {
+ border-color: var(--wpds-color-stroke-interactive-neutral);
+ outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus);
+ outline-offset: var(--wpds-border-width-focus);
+ box-shadow: none;
+ }
+
+ /* -----------------------------------------------------------------------
+ Disabled and readonly
+ ----------------------------------------------------------------------- */
+
+ input:disabled,
+ input.disabled,
+ input[aria-disabled="true"],
+ select:disabled,
+ select.disabled,
+ select[aria-disabled="true"],
+ textarea:disabled,
+ textarea.disabled,
+ textarea[aria-disabled="true"] {
+ /*
+ * DS border and text, but a tinted background rather than the DS's
+ * transparent one. Classic forms are dense, and border-plus-text alone is
+ * a weak disabled signal at a glance; the tint is what classic readers
+ * already recognise. Same reasoning as the base background above.
+ */
+ background-color: var(--wpds-color-background-surface-neutral-weak);
+ border-color: var(--wpds-color-stroke-interactive-neutral-disabled);
+ color: var(--wpds-color-foreground-interactive-neutral-disabled);
+ box-shadow: none;
+ }
+
+ input.readonly,
+ input[readonly],
+ textarea.readonly,
+ textarea[readonly] {
+ background-color: var(--wpds-color-background-surface-neutral-weak);
+ }
+}
diff --git a/lib/experimental/wpds-admin/css/overrides.css b/lib/experimental/wpds-admin/css/overrides.css
new file mode 100644
index 00000000000000..6133e4c5a3a2a5
--- /dev/null
+++ b/lib/experimental/wpds-admin/css/overrides.css
@@ -0,0 +1,79 @@
+/**
+ * Overrides that exist ONLY to cancel an `!important` in Core's admin CSS.
+ *
+ * THIS IS THE ONE FILE ALLOWED TO USE `!important`, and it is deliberately tiny.
+ *
+ * Why it sits in `wpds-overrides`, declared BEFORE `wp-legacy`:
+ * importance reverses layer order. For normal declarations the last layer wins
+ * (`wpds` beats `wp-legacy`); for `!important` the FIRST layer wins. So an
+ * `!important` in a layer above Core's loses, no matter its specificity —
+ * specificity is not even consulted, because layer order is compared first.
+ * The only way to beat a demoted `!important` is from a layer below it.
+ *
+ * THE COST, stated plainly: a rule here also beats a third-party plugin's
+ * unlayered `!important`. Today those plugins usually win by source order, so
+ * every entry below takes precedence away from someone. That is why this file
+ * is restricted to declarations that are cancelling a Core `!important` and
+ * nothing else, and why each block records what would let us delete it.
+ *
+ * Every entry here should eventually disappear: when Core drops the matching
+ * `!important`, the plain rules in button.css take over on their own and the
+ * block below can be removed with no other change.
+ */
+
+@layer wpds-overrides, wp-legacy, wpds;
+
+@layer wpds-overrides {
+
+ /*
+ * Cancels: wp-includes/css/buttons.css — the default/secondary disabled rule
+ * sets color / border-color / background / box-shadow / transform
+ * with `!important`.
+ * DELETE WHEN: those `!important` flags are removed from Core.
+ */
+ .wp-core-ui .button:disabled,
+ .wp-core-ui .button[disabled],
+ .wp-core-ui .button.disabled,
+ .wp-core-ui .button[aria-disabled="true"],
+ .wp-core-ui .button-secondary:disabled,
+ .wp-core-ui .button-secondary[disabled],
+ .wp-core-ui .button-secondary.disabled,
+ .wp-core-ui .button-secondary[aria-disabled="true"],
+ .wp-core-ui .button-disabled {
+ border-color: var(--wpds-color-stroke-interactive-neutral-disabled) !important;
+ background: transparent !important;
+ color: var(--wpds-color-foreground-content-neutral-weak) !important;
+ }
+
+ /*
+ * Cancels: wp-includes/css/buttons.css — `.button-primary-disabled` and
+ * `.button-primary.disabled` set color / background / border-color /
+ * box-shadow / text-shadow with `!important`.
+ * DELETE WHEN: those `!important` flags are removed from Core.
+ */
+ /*
+ * Cancels: wp-admin/css/forms.css — the `.form-invalid` rules set
+ * `border-color: #d63638 !important` on required fields and their
+ * focus state. Only `border-color` carries the flag; the companion
+ * `box-shadow` does not, so input.css handles that normally.
+ * DELETE WHEN: that `!important` is removed from Core.
+ */
+ .form-invalid .form-required,
+ .form-invalid .form-required:focus,
+ .form-invalid.form-required input,
+ .form-invalid.form-required input:focus,
+ .form-invalid.form-required select,
+ .form-invalid.form-required select:focus {
+ border-color: var(--wpds-color-stroke-interactive-error) !important;
+ }
+
+ .wp-core-ui .button-primary:disabled,
+ .wp-core-ui .button-primary[disabled],
+ .wp-core-ui .button-primary.disabled,
+ .wp-core-ui .button-primary[aria-disabled="true"],
+ .wp-core-ui .button-primary-disabled {
+ border-color: transparent !important;
+ background: var(--wp-admin-theme-color) !important;
+ color: rgba(255, 255, 255, 0.4) !important;
+ }
+}
diff --git a/lib/experimental/wpds-admin/harness.php b/lib/experimental/wpds-admin/harness.php
new file mode 100644
index 00000000000000..b804cb5c96dce9
--- /dev/null
+++ b/lib/experimental/wpds-admin/harness.php
@@ -0,0 +1,209 @@
+ WPDS Parity, visible only while the experiment is enabled.
+ *
+ * @package gutenberg
+ */
+
+/**
+ * Registers the parity harness page.
+ *
+ * @since 23.9.0
+ */
+function gutenberg_wpds_admin_register_harness() {
+ add_submenu_page(
+ 'tools.php',
+ __( 'WPDS Parity', 'gutenberg' ),
+ __( 'WPDS Parity', 'gutenberg' ),
+ 'manage_options',
+ 'wpds-parity',
+ 'gutenberg_wpds_admin_render_harness'
+ );
+}
+add_action( 'admin_menu', 'gutenberg_wpds_admin_register_harness' );
+
+/**
+ * Returns the button state matrix.
+ *
+ * Classic drives state from CLASSES as well as pseudo-classes — legacy admin JS
+ * toggles `.hover` / `.focus` / `.disabled`. Only the class forms can be
+ * rendered statically, so those are what the harness shows; the pseudo-class
+ * forms have to be checked interactively.
+ *
+ * Note `.active` is a PERSISTENT selected state, not `:active`.
+ *
+ * @since 23.9.0
+ *
+ * @return array Map of label to extra markup for the button tag.
+ */
+function gutenberg_wpds_admin_harness_states() {
+ return array(
+ 'default' => '',
+ '.hover' => ' hover',
+ '.focus' => ' focus',
+ '.active (persistent)' => ' active',
+ '.disabled' => ' disabled',
+ 'disabled attribute' => '|disabled',
+ 'aria-disabled="true"' => '|aria-disabled="true"',
+ );
+}
+
+/**
+ * Renders the harness page.
+ *
+ * @since 23.9.0
+ */
+function gutenberg_wpds_admin_render_harness() {
+ $variants = array(
+ '.button (default)' => 'button',
+ '.button-primary' => 'button button-primary',
+ '.button-secondary' => 'button button-secondary',
+ );
+
+ $sizes = array(
+ 'default (40px)' => '',
+ '.button-large (40px)' => ' button-large',
+ '.button-compact (32px)' => ' button-compact',
+ '.button-small (24px)' => ' button-small',
+ '.button-hero (48px)' => ' button-hero',
+ );
+
+ echo '
' . esc_html__( 'Classic button markup rendered under the real admin cascade. Compare against the React Button. Pseudo-class states (:hover, :focus, :active) must be checked interactively; the class-driven equivalents legacy admin JS toggles are shown below.', 'gutenberg' ) . '
';
+
+ echo '
' . esc_html__( 'Variants and states', 'gutenberg' ) . '
' . esc_html__( 'Core marks the invalid border with !important, so this is driven from the wpds-overrides layer rather than input.css.', 'gutenberg' ) . '
';
+ echo '';
+
+ echo '
' . esc_html__( 'Checkboxes and radios', 'gutenberg' ) . '
' . esc_html__( 'This button is styled by an unlayered rule, exactly as a third-party plugin would style it. It MUST stay orange — if it picks up the restyle instead, the back-compat guarantee this work depends on is broken.', 'gutenberg' ) . '
';
+ // Unlayered on purpose: this stands in for third-party plugin CSS.
+ echo '';
+ echo '';
+
+ echo '
';
+}
diff --git a/lib/experimental/wpds-admin/load.php b/lib/experimental/wpds-admin/load.php
new file mode 100644
index 00000000000000..662a61653648c4
--- /dev/null
+++ b/lib/experimental/wpds-admin/load.php
@@ -0,0 +1,280 @@
+` tag for an explicit allowlist of Core handles into an
+ * `@import ... layer(wp-legacy)`. The handle stays registered and enqueued, the
+ * dependency graph is untouched, and Core's rules land in a layer below ours.
+ *
+ * Consequences, all verified by rendering rather than by reading CSS:
+ * - our styles beat Core's, regardless of selector specificity;
+ * - unlayered third-party plugin CSS still beats ours, which preserves the
+ * override the ecosystem has always relied on;
+ * - EXCEPT for `!important`, where layer priority reverses and a layered
+ * `!important` would beat a plugin's unlayered one. Hence the hard no-
+ * `!important` rule in the stylesheets, enforced by stylelint.
+ *
+ * This is prototype-grade. `@import` is render-blocking and costs a round trip
+ * per demoted sheet. The permanent form is for Core to wrap its own admin CSS
+ * in `@layer`, at which point this whole demotion step is deleted and the
+ * stylesheets are unchanged. Tracked as a Core to-do.
+ *
+ * @package gutenberg
+ */
+
+/**
+ * Disables admin style concatenation while the experiment is on.
+ *
+ * By default wp-admin bundles its stylesheets into a single `load-styles.php`
+ * request, and `WP_Styles::do_item()` short-circuits before the
+ * `style_loader_tag` filter when it is concatenating. `buttons` therefore never
+ * gets its own `` and cannot be demoted into a layer.
+ *
+ * Setting this global is the documented plugin-facing way to opt out (see the
+ * file header of Core's script-loader.php). The cost is more requests on admin
+ * screens, which is acceptable for an off-by-default experiment.
+ *
+ * NOTE: this is scaffolding, and it is a second reason the permanent fix
+ * belongs in Core. Once Core wraps its own admin CSS in `@layer`, both this and
+ * the demotion filter below are deleted, concatenation stays on, and the
+ * stylesheets are unchanged.
+ *
+ * An alternative was considered and rejected: demoting the entire
+ * `load-styles.php` bundle in one go. That would put *all* of Core's admin CSS
+ * below unlayered plugin CSS, so plugin rules that currently lose on
+ * specificity or source order would suddenly win. That is a large and silent
+ * behaviour change for the ecosystem, and the opposite of the small, explicit
+ * blast radius this experiment is built around.
+ *
+ * @since 23.9.0
+ *
+ * @global bool $concatenate_scripts
+ */
+function gutenberg_wpds_admin_disable_concatenation() {
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ $GLOBALS['concatenate_scripts'] = false;
+}
+add_action( 'admin_init', 'gutenberg_wpds_admin_disable_concatenation' );
+
+/**
+ * Declares the layer order.
+ *
+ * Layer order is fixed by FIRST APPEARANCE in the document, not by the load
+ * order of the stylesheets themselves. Rather than reason about hook priority,
+ * every style element this experiment emits — the demoted imports and our own
+ * stylesheets alike — repeats this same declaration. They all agree, so
+ * whichever lands first sets the order correctly.
+ *
+ * @since 23.9.0
+ */
+const GUTENBERG_WPDS_ADMIN_LAYER_ORDER = '@layer wpds-overrides, wp-legacy, wpds;';
+
+/**
+ * Core style handles to demote into the `wp-legacy` layer.
+ *
+ * Deliberately an explicit allowlist rather than a blanket rewrite: the blast
+ * radius is exactly what is named here, and it grows one control at a time.
+ *
+ * The set must be CONSISTENT, not minimal. Core's stylesheets override each
+ * other by source order — most visibly, the active admin colour scheme
+ * (`colors`) restyles the chrome defined in `admin-menu`, `common`, `forms` and
+ * the rest. Demoting only some of them inverts those relationships: demoting
+ * `colors` alone put the modern scheme's `#adminmenuback { background: #1e1e1e }`
+ * below the unlayered `admin-menu.css` default of `#1d2327`, silently reverting
+ * the admin colour scheme to legacy colours. So the whole Core admin set moves
+ * together, preserving their existing order relative to one another.
+ *
+ * DELIBERATELY EXCLUDED: Gutenberg's own package styles, above all
+ * `wp-base-styles`. Gutenberg points that handle at its own build, and it is
+ * what defines `--wp-admin-theme-color` per scheme via `body.admin-color-*`
+ * (specificity 0,2,0). Other Gutenberg package styles define the same custom
+ * property unlayered on `:root` (0,1,0). Demote `wp-base-styles` and those
+ * unlayered `:root` definitions win, taking the accent colour with them. It
+ * stays unlayered, and it does not need demoting — those styles are already
+ * design-system aligned.
+ *
+ * Trade-off worth naming: everything listed here now sits below unlayered
+ * plugin CSS. Plugin rules that currently lose to Core on specificity or source
+ * order will start winning. That is a real behaviour change for the ecosystem
+ * and the main thing this experiment needs to prove is safe.
+ *
+ * @since 23.9.0
+ *
+ * @return string[] Array of registered style handles.
+ */
+function gutenberg_wpds_admin_demoted_handles() {
+ return array(
+ // Controls and chrome.
+ 'buttons',
+ 'common',
+ 'forms',
+ 'admin-menu',
+ 'admin-bar',
+ 'list-tables',
+ 'edit',
+ 'dashboard',
+ 'revisions',
+ 'media',
+ 'themes',
+ 'about',
+ 'nav-menus',
+ 'widgets',
+ 'site-icon',
+ 'l10n',
+ 'wp-tooltip',
+ 'dashicons',
+ // The active admin colour scheme. Must move with everything above.
+ 'colors',
+ );
+}
+
+/**
+ * Escapes a URL for use inside a CSS string in an inline `\n",
+ esc_attr( $handle ),
+ GUTENBERG_WPDS_ADMIN_LAYER_ORDER,
+ gutenberg_wpds_admin_escape_css_url( $href ),
+ $media_suffix
+ );
+}
+add_filter( 'style_loader_tag', 'gutenberg_wpds_admin_demote_style', 10, 4 );
+
+/**
+ * Enqueues design tokens and the restyle stylesheets on classic admin screens.
+ *
+ * `admin_enqueue_scripts` only fires in wp-admin, so this never touches the
+ * login screen or the front end — both of which also use the `buttons` handle.
+ *
+ * @since 23.9.0
+ */
+function gutenberg_wpds_admin_enqueue_styles() {
+ $version = defined( 'GUTENBERG_VERSION' ) && ! SCRIPT_DEBUG ? GUTENBERG_VERSION : time();
+ $base_url = gutenberg_url( 'lib/experimental/wpds-admin/css/' );
+
+ /*
+ * Make `--wpds-*` resolvable in classic admin. Gutenberg registers the
+ * `wp-theme` handle itself (see lib/client-assets.php), so no Core change
+ * is needed here. Fall back to the package's prebuilt copy if that ever
+ * stops being true.
+ */
+ if ( wp_style_is( 'wp-theme', 'registered' ) ) {
+ wp_enqueue_style( 'wp-theme' );
+ $token_deps = array( 'wp-theme' );
+ } else {
+ wp_enqueue_style(
+ 'gutenberg-wpds-admin-tokens',
+ gutenberg_url( 'packages/theme/prebuilt/css/design-tokens.css' ),
+ array(),
+ $version
+ );
+ $token_deps = array( 'gutenberg-wpds-admin-tokens' );
+ }
+
+ /*
+ * Depending on `buttons` keeps document order sane (our sheet prints after
+ * the demoted one). The cascade no longer relies on that ordering — layer
+ * order decides — but it keeps the generated HTML readable.
+ */
+ wp_enqueue_style(
+ 'gutenberg-wpds-admin-button',
+ $base_url . 'button.css',
+ array_merge( $token_deps, array( 'buttons' ) ),
+ $version
+ );
+
+ wp_enqueue_style(
+ 'gutenberg-wpds-admin-input',
+ $base_url . 'input.css',
+ array( 'gutenberg-wpds-admin-button' ),
+ $version
+ );
+
+ wp_enqueue_style(
+ 'gutenberg-wpds-admin-checkbox-radio',
+ $base_url . 'checkbox-radio.css',
+ array( 'gutenberg-wpds-admin-input' ),
+ $version
+ );
+
+ wp_enqueue_style(
+ 'gutenberg-wpds-admin-exceptions',
+ $base_url . 'exceptions.css',
+ array( 'gutenberg-wpds-admin-button' ),
+ $version
+ );
+
+ /*
+ * Cancels Core `!important` declarations that survive demotion. Lives in the
+ * `wpds-overrides` layer; see the file header for why that layer is declared
+ * first rather than last.
+ */
+ wp_enqueue_style(
+ 'gutenberg-wpds-admin-overrides',
+ $base_url . 'overrides.css',
+ array( 'gutenberg-wpds-admin-button' ),
+ $version
+ );
+}
+add_action( 'admin_enqueue_scripts', 'gutenberg_wpds_admin_enqueue_styles' );
+
+require_once __DIR__ . '/harness.php';
diff --git a/lib/load.php b/lib/load.php
index ef00a8477f2391..37f2ee1c2be7a1 100644
--- a/lib/load.php
+++ b/lib/load.php
@@ -135,6 +135,10 @@ function gutenberg_is_experiment_enabled( $name ) {
require __DIR__ . '/experimental/workflow-palette.php';
}
+if ( gutenberg_is_experiment_enabled( 'gutenberg-wpds-admin-restyle' ) ) {
+ require __DIR__ . '/experimental/wpds-admin/load.php';
+}
+
// Plugin specific code.
require __DIR__ . '/script-loader.php';
require __DIR__ . '/global-styles-and-settings.php';
From 15d7d341457484dedc23e11cca8968c14f9e9dc1 Mon Sep 17 00:00:00 2001
From: "Joen A."
Date: Fri, 28 Aug 2026 12:50:09 +0200
Subject: [PATCH 02/26] Grey the dropdown arrow on aria-disabled selects
A select marked `aria-disabled="true"` rendered with a greyed box and a black
dropdown arrow, because the arrow is a background image whose colour is baked
into a data URI. WordPress swaps in a grey arrow for `select.disabled` and
`select:disabled`, but gives `select[aria-disabled="true"]` only
`cursor: default`, so the restyled disabled box was left with the enabled arrow.
Repeats WordPress's own grey arrow for the aria form so all three disabled states
render alike. The colour is WordPress's #a0a5aa rather than the disabled token,
because matching the other two matters more than matching the palette while the
value is trapped inside a data URI. Replacing dashicons with the stroke-based set
removes the duplicate entirely, since a masked icon takes `currentColor`.
Note that a genuinely disabled control also picks up the browser's own dimming,
so the three states are consistent in colour but not identical in rendering.
Co-Authored-By: Claude Opus 5
---
lib/experimental/wpds-admin/css/input.css | 21 +++++++++++++++++++++
1 file changed, 21 insertions(+)
diff --git a/lib/experimental/wpds-admin/css/input.css b/lib/experimental/wpds-admin/css/input.css
index 9b79d6a4100e39..954b8101a558ad 100644
--- a/lib/experimental/wpds-admin/css/input.css
+++ b/lib/experimental/wpds-admin/css/input.css
@@ -263,6 +263,27 @@
box-shadow: none;
}
+ /*
+ * The dropdown arrow is a background image with its colour baked into the
+ * data URI, so it cannot follow a token or `currentColor`. Core swaps in a
+ * grey arrow for `select.disabled` and `select:disabled`, but leaves
+ * `select[aria-disabled="true"]` with only `cursor: default` — no visual
+ * disabled treatment at all.
+ *
+ * Since the rules above do give an aria-disabled field the disabled box, the
+ * arrow has to follow, or it stays black against a greyed control. This
+ * repeats Core's own grey arrow verbatim so all three disabled forms render
+ * identically; the colour is Core's #a0a5aa rather than the disabled token,
+ * because matching the other two matters more than matching the palette
+ * while the value is trapped inside a data URI.
+ *
+ * Replacing dashicons with the stroke-based set removes this entirely: a
+ * masked icon takes `currentColor` and needs no second copy.
+ */
+ select[aria-disabled="true"] {
+ background-image: url(data:image/svg+xml;charset=US-ASCII,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M5%206l5%205%205-5%202%201-7%207-7-7%202-1z%22%20fill%3D%22%23a0a5aa%22%2F%3E%3C%2Fsvg%3E);
+ }
+
input.readonly,
input[readonly],
textarea.readonly,
From 311e41bfadc3d49c5cf51e717d3942507e5531d8 Mon Sep 17 00:00:00 2001
From: "Joen A."
Date: Fri, 28 Aug 2026 14:47:34 +0200
Subject: [PATCH 03/26] Let form controls inherit their font size
WordPress sets `input, select, textarea, button { font-size: inherit }`, so a control
takes the size of whatever contains it. That is what makes a settings form read
coherently: the label, the field and the help text are all one size because they
all come from the same container.
The restyle pinned an explicit size on fields, which broke that. A label stayed
at its container's size while the field it belonged to shrank to 13px, so the
value inside a field was smaller than the label beside it.
Removing the declaration restores WordPress's behaviour. Fields are 14px in a settings
form and 14px in a list table toolbar, matching their surroundings without this
stylesheet needing to know what those surroundings are.
The mobile guard goes with it. WordPress already raises inputs to 16px below 782px so
iOS does not zoom on focus, which is the reason ours existed.
Same shape as leaving geometry to WordPress: the value was already handled
contextually, and specifying it here could only overwrite something better
informed.
Co-Authored-By: Claude Opus 5
---
lib/experimental/wpds-admin/css/input.css | 35 +++++++----------------
1 file changed, 11 insertions(+), 24 deletions(-)
diff --git a/lib/experimental/wpds-admin/css/input.css b/lib/experimental/wpds-admin/css/input.css
index 954b8101a558ad..229df1f082a15d 100644
--- a/lib/experimental/wpds-admin/css/input.css
+++ b/lib/experimental/wpds-admin/css/input.css
@@ -52,33 +52,20 @@
color: var(--wpds-color-foreground-interactive-neutral);
box-shadow: none;
font-family: var(--wpds-typography-font-family-body);
- /* 16px below 600px avoids iOS zooming on focus; matches the DS. */
- font-size: max(var(--wpds-typography-font-size-md), 16px);
+ /*
+ * NO font-size. Core sets `input, select, textarea, button { font-size:
+ * inherit }`, so a control takes the size of whatever contains it — which
+ * is why a settings form reads coherently, with label, field and help text
+ * all at one size. Pinning a size here broke that: the label stayed at the
+ * container's size while the field it belongs to shrank to 13px.
+ *
+ * Inheriting keeps a control correct in every context without us having to
+ * know what those contexts are. Core also handles the mobile case, raising
+ * inputs to 16px below 782px so iOS does not zoom on focus.
+ */
line-height: 1;
}
- @media (min-width: 600px) {
-
- input[type="text"],
- input[type="password"],
- input[type="color"],
- input[type="date"],
- input[type="datetime"],
- input[type="datetime-local"],
- input[type="email"],
- input[type="month"],
- input[type="number"],
- input[type="search"],
- input[type="tel"],
- input[type="time"],
- input[type="url"],
- input[type="week"],
- select,
- textarea {
- font-size: var(--wpds-typography-font-size-md);
- }
- }
-
/* -----------------------------------------------------------------------
Sizing — text inputs and select get a fixed height; textarea does not
----------------------------------------------------------------------- */
From 96e9e33d88f0ad842c68d6e0b79ea8b5e3388cb2 Mon Sep 17 00:00:00 2001
From: "Joen A."
Date: Thu, 3 Sep 2026 12:29:23 +0200
Subject: [PATCH 04/26] Load the restyle stylesheets in filename order
Discover the stylesheets from the css directory and enqueue them in
filename order rather than naming each one in the loader. The numeric
prefix is now the only place their relative order is expressed, so
adding an area of the restyle is one new file and no edit here.
Each sheet still depends on the one before it, which keeps the
generated HTML in the same order as the cascade. The first depends on
`buttons` so ours prints after the demoted WordPress sheet.
Co-Authored-By: Claude Opus 5
---
.stylelintrc.mjs | 2 +-
.../css/{button.css => 10-button.css} | 0
.../css/{input.css => 20-input.css} | 6 +-
...eckbox-radio.css => 30-checkbox-radio.css} | 6 +-
.../css/{exceptions.css => 90-exceptions.css} | 4 +-
.../css/{overrides.css => 99-overrides.css} | 4 +-
lib/experimental/wpds-admin/harness.php | 2 +-
lib/experimental/wpds-admin/load.php | 65 ++++++++-----------
8 files changed, 39 insertions(+), 50 deletions(-)
rename lib/experimental/wpds-admin/css/{button.css => 10-button.css} (100%)
rename lib/experimental/wpds-admin/css/{input.css => 20-input.css} (97%)
rename lib/experimental/wpds-admin/css/{checkbox-radio.css => 30-checkbox-radio.css} (96%)
rename lib/experimental/wpds-admin/css/{exceptions.css => 90-exceptions.css} (93%)
rename lib/experimental/wpds-admin/css/{overrides.css => 99-overrides.css} (95%)
diff --git a/.stylelintrc.mjs b/.stylelintrc.mjs
index dc53ef04708868..1b6456fc850401 100644
--- a/.stylelintrc.mjs
+++ b/.stylelintrc.mjs
@@ -33,7 +33,7 @@ export default {
* `!important` and must record what would let us delete it. Do not
* widen this exception to any other file.
*/
- files: [ 'lib/experimental/wpds-admin/css/overrides.css' ],
+ files: [ 'lib/experimental/wpds-admin/css/99-overrides.css' ],
rules: {
'declaration-no-important': null,
},
diff --git a/lib/experimental/wpds-admin/css/button.css b/lib/experimental/wpds-admin/css/10-button.css
similarity index 100%
rename from lib/experimental/wpds-admin/css/button.css
rename to lib/experimental/wpds-admin/css/10-button.css
diff --git a/lib/experimental/wpds-admin/css/input.css b/lib/experimental/wpds-admin/css/20-input.css
similarity index 97%
rename from lib/experimental/wpds-admin/css/input.css
rename to lib/experimental/wpds-admin/css/20-input.css
index 229df1f082a15d..4e70164a477d47 100644
--- a/lib/experimental/wpds-admin/css/input.css
+++ b/lib/experimental/wpds-admin/css/20-input.css
@@ -10,7 +10,7 @@
* Selector list mirrors Core's own in wp-admin/css/forms.css so the same set of
* controls is covered — no more, no less.
*
- * NO `!important` IN THIS FILE. See overrides.css for the one exception and why.
+ * NO `!important` IN THIS FILE. See 99-overrides.css for the one exception and why.
*/
@layer wpds-overrides, wp-legacy, wpds;
@@ -83,7 +83,7 @@
input[type="time"],
input[type="url"],
input[type="week"] {
- /* No height — see the note in button.css. Core's default is already 40px
+ /* No height — see the note in 10-button.css. Core's default is already 40px
and its contextual rules must keep winning. */
padding: 0 var(--wpds-dimension-padding-md);
}
@@ -119,7 +119,7 @@
* one side leaves Core's contextual value on the other intact, and the
* arrow still gets its gutter in every context.
*
- * No height, for the reason given in button.css.
+ * No height, for the reason given in 10-button.css.
*/
padding-inline-end: calc(var(--wpds-dimension-padding-md) + 18px);
}
diff --git a/lib/experimental/wpds-admin/css/checkbox-radio.css b/lib/experimental/wpds-admin/css/30-checkbox-radio.css
similarity index 96%
rename from lib/experimental/wpds-admin/css/checkbox-radio.css
rename to lib/experimental/wpds-admin/css/30-checkbox-radio.css
index 45e56d8b09efb2..97c32867e6b319 100644
--- a/lib/experimental/wpds-admin/css/checkbox-radio.css
+++ b/lib/experimental/wpds-admin/css/30-checkbox-radio.css
@@ -17,7 +17,7 @@
* Geometry (size, margins) is deliberately left to Core: it is layout, not
* style, and touching it would shift every checkbox on every screen.
*
- * NO `!important` IN THIS FILE. See overrides.css for the one exception.
+ * NO `!important` IN THIS FILE. See 99-overrides.css for the one exception.
*/
@layer wpds-overrides, wp-legacy, wpds;
@@ -35,7 +35,7 @@
border-radius: var(--wpds-border-radius-sm);
/*
* White rather than the design system's transparent field background,
- * for the same reason as input.css: classic controls sit on the grey page
+ * for the same reason as 20-input.css: classic controls sit on the grey page
* background as often as on white, and a transparent control would read as
* grey.
*/
@@ -50,7 +50,7 @@
}
/* -----------------------------------------------------------------------
- Hover — excluded from disabled, as in input.css
+ Hover — excluded from disabled, as in 20-input.css
----------------------------------------------------------------------- */
input[type="checkbox"]:hover,
diff --git a/lib/experimental/wpds-admin/css/exceptions.css b/lib/experimental/wpds-admin/css/90-exceptions.css
similarity index 93%
rename from lib/experimental/wpds-admin/css/exceptions.css
rename to lib/experimental/wpds-admin/css/90-exceptions.css
index e7c6368263cd6d..9ce40aaf2331eb 100644
--- a/lib/experimental/wpds-admin/css/exceptions.css
+++ b/lib/experimental/wpds-admin/css/90-exceptions.css
@@ -12,7 +12,7 @@
* `.button-small` and `.button-large` as classic-only sizes missing from the
* design system. That is not accurate as of this branch: the React Button has
* `is-compact` (32px) and `is-small` (24px), matching classic's heights exactly,
- * so those need no exception and are handled by button.css. `.button-large` is
+ * so those need no exception and are handled by 10-button.css. `.button-large` is
* defined by Core as an explicit 40px, identical to the default, so it also
* needs nothing. Only `.button-hero` is genuinely unmatched.
*/
@@ -52,7 +52,7 @@
/*
* Hero — 48px. GENUINE EXCEPTION: no React equivalent exists.
* DELETE WHEN: either a 48px size lands on the React Button (at which point
- * this becomes parity and moves to button.css), or Core deprecates
+ * this becomes parity and moves to 10-button.css), or Core deprecates
* `.button-hero`. Until one of those happens this rule is load-bearing.
* Used by the installer and a handful of onboarding screens.
*/
diff --git a/lib/experimental/wpds-admin/css/overrides.css b/lib/experimental/wpds-admin/css/99-overrides.css
similarity index 95%
rename from lib/experimental/wpds-admin/css/overrides.css
rename to lib/experimental/wpds-admin/css/99-overrides.css
index 6133e4c5a3a2a5..39677933049a5c 100644
--- a/lib/experimental/wpds-admin/css/overrides.css
+++ b/lib/experimental/wpds-admin/css/99-overrides.css
@@ -17,7 +17,7 @@
* nothing else, and why each block records what would let us delete it.
*
* Every entry here should eventually disappear: when Core drops the matching
- * `!important`, the plain rules in button.css take over on their own and the
+ * `!important`, the plain rules in 10-button.css take over on their own and the
* block below can be removed with no other change.
*/
@@ -55,7 +55,7 @@
* Cancels: wp-admin/css/forms.css — the `.form-invalid` rules set
* `border-color: #d63638 !important` on required fields and their
* focus state. Only `border-color` carries the flag; the companion
- * `box-shadow` does not, so input.css handles that normally.
+ * `box-shadow` does not, so 20-input.css handles that normally.
* DELETE WHEN: that `!important` is removed from Core.
*/
.form-invalid .form-required,
diff --git a/lib/experimental/wpds-admin/harness.php b/lib/experimental/wpds-admin/harness.php
index b804cb5c96dce9..ec4261824ad8c1 100644
--- a/lib/experimental/wpds-admin/harness.php
+++ b/lib/experimental/wpds-admin/harness.php
@@ -165,7 +165,7 @@ function gutenberg_wpds_admin_render_harness() {
echo '
' . esc_html__( 'Core marks the invalid border with !important, so this is driven from the wpds-overrides layer rather than input.css.', 'gutenberg' ) . '
';
+ echo '
' . esc_html__( 'Core marks the invalid border with !important, so this is driven from the wpds-overrides layer rather than 20-input.css.', 'gutenberg' ) . '
';
echo '';
echo '
' . esc_html__( 'Checkboxes and radios', 'gutenberg' ) . '
';
diff --git a/lib/experimental/wpds-admin/load.php b/lib/experimental/wpds-admin/load.php
index 662a61653648c4..10f895bbd4a530 100644
--- a/lib/experimental/wpds-admin/load.php
+++ b/lib/experimental/wpds-admin/load.php
@@ -231,49 +231,38 @@ function gutenberg_wpds_admin_enqueue_styles() {
}
/*
- * Depending on `buttons` keeps document order sane (our sheet prints after
- * the demoted one). The cascade no longer relies on that ordering — layer
- * order decides — but it keeps the generated HTML readable.
+ * Stylesheets load in filename order, which is what the numeric prefixes are
+ * for. Each area of the restyle owns one file and registers itself by being
+ * present, so adding one is a single new file and no edit here.
+ *
+ * The first sheet depends on `buttons` so ours prints after the demoted Core
+ * sheet and the generated HTML reads in order; the cascade itself no longer
+ * relies on that, since layer order decides. Each subsequent sheet depends on
+ * the one before it to keep that document order.
*/
- wp_enqueue_style(
- 'gutenberg-wpds-admin-button',
- $base_url . 'button.css',
- array_merge( $token_deps, array( 'buttons' ) ),
- $version
- );
+ $files = glob( __DIR__ . '/css/*.css' );
- wp_enqueue_style(
- 'gutenberg-wpds-admin-input',
- $base_url . 'input.css',
- array( 'gutenberg-wpds-admin-button' ),
- $version
- );
+ if ( empty( $files ) ) {
+ return;
+ }
- wp_enqueue_style(
- 'gutenberg-wpds-admin-checkbox-radio',
- $base_url . 'checkbox-radio.css',
- array( 'gutenberg-wpds-admin-input' ),
- $version
- );
+ sort( $files );
- wp_enqueue_style(
- 'gutenberg-wpds-admin-exceptions',
- $base_url . 'exceptions.css',
- array( 'gutenberg-wpds-admin-button' ),
- $version
- );
+ $previous = null;
- /*
- * Cancels Core `!important` declarations that survive demotion. Lives in the
- * `wpds-overrides` layer; see the file header for why that layer is declared
- * first rather than last.
- */
- wp_enqueue_style(
- 'gutenberg-wpds-admin-overrides',
- $base_url . 'overrides.css',
- array( 'gutenberg-wpds-admin-button' ),
- $version
- );
+ foreach ( $files as $file ) {
+ $name = basename( $file, '.css' );
+ $handle = 'gutenberg-wpds-admin-' . $name;
+
+ wp_enqueue_style(
+ $handle,
+ $base_url . basename( $file ),
+ null === $previous ? array_merge( $token_deps, array( 'buttons' ) ) : array( $previous ),
+ $version
+ );
+
+ $previous = $handle;
+ }
}
add_action( 'admin_enqueue_scripts', 'gutenberg_wpds_admin_enqueue_styles' );
From e35fa8a50bc4167036252574f9be91be5cb88879 Mon Sep 17 00:00:00 2001
From: "Joen A."
Date: Fri, 4 Sep 2026 09:09:03 +0200
Subject: [PATCH 05/26] Leave the media handle undemoted
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Demotion turns a `` into an `@import`, which the browser fetches
only after parsing the element that imports it, so the stylesheet lands
later than a link would. The media grid measures its frame in JavaScript
as the page initialises; with `media-views.css` still in flight it
measures an unstyled frame, computes a collapsed height and never
recalculates, so upload.php?mode=grid renders as an empty strip.
Verified by removing this one handle. The cost is that
`media-views.css` now outranks the demoted colour scheme's media rules,
which can leave a stale colour in the modal — a better failure than an
unusable Media Library.
Any screen that sizes itself from measured CSS is exposed the same way.
That is a property of reaching into WordPress's stylesheets from outside rather than
something the stylesheets can fix.
Co-Authored-By: Claude Opus 5
---
lib/experimental/wpds-admin/load.php | 13 ++++++++++++-
1 file changed, 12 insertions(+), 1 deletion(-)
diff --git a/lib/experimental/wpds-admin/load.php b/lib/experimental/wpds-admin/load.php
index 10f895bbd4a530..db1afa7b5b2d92 100644
--- a/lib/experimental/wpds-admin/load.php
+++ b/lib/experimental/wpds-admin/load.php
@@ -104,6 +104,18 @@ function gutenberg_wpds_admin_disable_concatenation() {
* stays unlayered, and it does not need demoting — those styles are already
* design-system aligned.
*
+ * ALSO EXCLUDED: `media`. Demotion turns a `` into an `@import`, which the
+ * browser fetches only after it has parsed the importing element, so the
+ * stylesheet lands later than a link would. The media grid measures its frame in
+ * JavaScript as the page initialises, and with `media-views.css` still in flight
+ * it measures an unstyled frame, computes a collapsed height and never
+ * recalculates: upload.php?mode=grid renders as an empty strip. Verified by
+ * removing this one handle. Any screen that sizes itself from measured CSS is
+ * exposed the same way, which is one more thing that only Core owning its own
+ * layers can fix. The cost of leaving it out is that `media-views.css` outranks
+ * the demoted colour scheme's media rules; a wrongly-coloured modal is a better
+ * failure than an unusable Media Library.
+ *
* Trade-off worth naming: everything listed here now sits below unlayered
* plugin CSS. Plugin rules that currently lose to Core on specificity or source
* order will start winning. That is a real behaviour change for the ecosystem
@@ -125,7 +137,6 @@ function gutenberg_wpds_admin_demoted_handles() {
'edit',
'dashboard',
'revisions',
- 'media',
'themes',
'about',
'nav-menus',
From 40c56ef715e6f58e20e6d1cf058e85874aede0aa Mon Sep 17 00:00:00 2001
From: "Joen A."
Date: Fri, 4 Sep 2026 11:17:10 +0200
Subject: [PATCH 06/26] Name the experiment for what it does
"Classic admin restyle" names one half of the admin as the odd one out,
which works against the thing the experiment is for. The screens differ
in how they are rendered, not in how current they are, and a label that
says otherwise makes the split sound permanent.
"Admin design tokens" describes the mechanism instead, and the
description says the goal without dividing the admin into two camps.
The experiment ID is unchanged, so anyone who already enabled it keeps
it enabled.
Co-Authored-By: Claude Opus 5
---
lib/experimental/experiments/load.php | 4 ++--
lib/experimental/wpds-admin/css/10-button.css | 2 +-
lib/experimental/wpds-admin/css/20-input.css | 2 +-
lib/experimental/wpds-admin/css/30-checkbox-radio.css | 2 +-
lib/experimental/wpds-admin/css/90-exceptions.css | 2 +-
lib/experimental/wpds-admin/harness.php | 2 +-
lib/experimental/wpds-admin/load.php | 6 +++---
7 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/lib/experimental/experiments/load.php b/lib/experimental/experiments/load.php
index 59b035eb038799..379f5dff5bd4b0 100644
--- a/lib/experimental/experiments/load.php
+++ b/lib/experimental/experiments/load.php
@@ -106,8 +106,8 @@ function gutenberg_initialize_experiments_settings() {
),
array(
'id' => 'gutenberg-wpds-admin-restyle',
- 'label' => __( 'Classic admin restyle', 'gutenberg' ),
- 'description' => __( 'Restyles classic wp-admin screens from WordPress Design System tokens, so classic and React screens read as one product. Visual only; no markup changes.', 'gutenberg' ),
+ 'label' => __( 'Admin design tokens', 'gutenberg' ),
+ 'description' => __( 'Styles wp-admin from WordPress Design System tokens, so every admin screen reads as one product regardless of how it is rendered. Visual only; no markup changes.', 'gutenberg' ),
),
array(
'id' => 'gutenberg-dashboard-widgets',
diff --git a/lib/experimental/wpds-admin/css/10-button.css b/lib/experimental/wpds-admin/css/10-button.css
index d756d2484d96fe..9f705f93566b5d 100644
--- a/lib/experimental/wpds-admin/css/10-button.css
+++ b/lib/experimental/wpds-admin/css/10-button.css
@@ -1,5 +1,5 @@
/**
- * Classic admin buttons, restyled from WordPress Design System tokens.
+ * wp-admin buttons, styled from WordPress Design System tokens.
*
* Target: visual parity with the React `Button`
* (packages/components/src/button/style.scss). The React component is the
diff --git a/lib/experimental/wpds-admin/css/20-input.css b/lib/experimental/wpds-admin/css/20-input.css
index 4e70164a477d47..f7b9d7d99e8a8d 100644
--- a/lib/experimental/wpds-admin/css/20-input.css
+++ b/lib/experimental/wpds-admin/css/20-input.css
@@ -1,5 +1,5 @@
/**
- * Classic admin text inputs, selects and textareas, restyled from WordPress
+ * wp-admin text inputs, selects and textareas, styled from WordPress
* Design System tokens.
*
* Target: visual parity with the `@wordpress/ui` input primitive
diff --git a/lib/experimental/wpds-admin/css/30-checkbox-radio.css b/lib/experimental/wpds-admin/css/30-checkbox-radio.css
index 97c32867e6b319..3c0caf9764a985 100644
--- a/lib/experimental/wpds-admin/css/30-checkbox-radio.css
+++ b/lib/experimental/wpds-admin/css/30-checkbox-radio.css
@@ -1,5 +1,5 @@
/**
- * Classic admin checkboxes and radios, restyled from WordPress Design System
+ * wp-admin checkboxes and radios, styled from WordPress Design System
* tokens.
*
* Parity target is the `@wordpress/ui` checkbox primitive
diff --git a/lib/experimental/wpds-admin/css/90-exceptions.css b/lib/experimental/wpds-admin/css/90-exceptions.css
index 9ce40aaf2331eb..f46fc993755611 100644
--- a/lib/experimental/wpds-admin/css/90-exceptions.css
+++ b/lib/experimental/wpds-admin/css/90-exceptions.css
@@ -1,5 +1,5 @@
/**
- * Classic-only button styles with no React counterpart.
+ * Button styles with no design system counterpart.
*
* RULE: every rule in this file carries the condition that would let us delete
* it. Without that this file becomes permanent sediment instead of a to-do list.
diff --git a/lib/experimental/wpds-admin/harness.php b/lib/experimental/wpds-admin/harness.php
index ec4261824ad8c1..4e7b1e75fe1ab8 100644
--- a/lib/experimental/wpds-admin/harness.php
+++ b/lib/experimental/wpds-admin/harness.php
@@ -1,6 +1,6 @@
Date: Fri, 4 Sep 2026 11:22:50 +0200
Subject: [PATCH 07/26] Name the harness for what it shows
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The heading said "classic buttons" — one term the experiment is trying
not to entrench, and inaccurate besides, since the harness has covered
fields, checkboxes and radios for a while.
Co-Authored-By: Claude Opus 5
---
lib/experimental/wpds-admin/harness.php | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/lib/experimental/wpds-admin/harness.php b/lib/experimental/wpds-admin/harness.php
index 4e7b1e75fe1ab8..b73625104addf7 100644
--- a/lib/experimental/wpds-admin/harness.php
+++ b/lib/experimental/wpds-admin/harness.php
@@ -83,8 +83,8 @@ function gutenberg_wpds_admin_render_harness() {
);
echo '
' . esc_html__( 'Classic button markup rendered under the real admin cascade. Compare against the React Button. Pseudo-class states (:hover, :focus, :active) must be checked interactively; the class-driven equivalents legacy admin JS toggles are shown below.', 'gutenberg' ) . '
' . esc_html__( 'Admin control markup rendered under the real admin cascade, so each state can be compared against its design system counterpart. Pseudo-class states (:hover, :focus, :active) must be checked interactively; the class-driven equivalents admin JS toggles are shown below.', 'gutenberg' ) . '
';
echo '
' . esc_html__( 'Variants and states', 'gutenberg' ) . '
';
echo '
' . esc_html__( 'State', 'gutenberg' ) . '
';
From 6b1606b6f5f9db0b34285bcf95b25bdc5b2b7550 Mon Sep 17 00:00:00 2001
From: "Joen A."
Date: Wed, 16 Sep 2026 11:14:01 +0200
Subject: [PATCH 08/26] Use the design system's field background and
placeholder tokens
Fields, checkboxes and radios now take their surface from
`--wpds-color-background-interactive-neutral` and its `-disabled`
variant, and placeholders from
`--wpds-color-foreground-interactive-neutral-weak`. The design system
added the background tokens in #82391 and moved the placeholder in
#82304, which removes the reason for the local values these rules used
instead.
Disabled controls follow the design system's choice: a solid surface
with muted border and text, rather than a tinted background. Read-only
fields keep their tint, since the design system has no read-only
treatment to follow.
Co-Authored-By: Claude Opus 5
---
lib/experimental/wpds-admin/css/20-input.css | 26 +++----------------
.../wpds-admin/css/30-checkbox-radio.css | 19 +++++---------
2 files changed, 10 insertions(+), 35 deletions(-)
diff --git a/lib/experimental/wpds-admin/css/20-input.css b/lib/experimental/wpds-admin/css/20-input.css
index f7b9d7d99e8a8d..c7b0a84b6bc3bd 100644
--- a/lib/experimental/wpds-admin/css/20-input.css
+++ b/lib/experimental/wpds-admin/css/20-input.css
@@ -40,15 +40,7 @@
box-sizing: border-box;
border: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-interactive-neutral);
border-radius: var(--wpds-border-radius-sm);
- /*
- * DEVIATION from the design system, deliberate.
- * The DS field is transparent (`--wpds-color-background-interactive-neutral-weak`
- * is `#0000`) because it always sits on a defined surface. Classic admin
- * has no such guarantee — fields sit directly on the grey `#f0f0f0` page
- * background as often as on white cards — so a transparent field would
- * read as grey on many screens. White keeps the field legible as a field.
- */
- background-color: var(--wpds-color-background-surface-neutral-strong);
+ background-color: var(--wpds-color-background-interactive-neutral);
color: var(--wpds-color-foreground-interactive-neutral);
box-shadow: none;
font-family: var(--wpds-typography-font-family-body);
@@ -133,15 +125,9 @@
Placeholder
----------------------------------------------------------------------- */
- /*
- * Uses the design system token even though it fails contrast on white
- * (#8d8d8d is about 3.3:1; WCAG AA wants 4.5:1 for text). Matching the token
- * is deliberate: diverging here would hide the problem and report a false
- * difference against the component. The fix belongs in the design system.
- */
input::placeholder,
textarea::placeholder {
- color: var(--wpds-color-foreground-interactive-neutral-disabled);
+ color: var(--wpds-color-foreground-interactive-neutral-weak);
opacity: 1;
}
@@ -238,13 +224,7 @@
textarea:disabled,
textarea.disabled,
textarea[aria-disabled="true"] {
- /*
- * DS border and text, but a tinted background rather than the DS's
- * transparent one. Classic forms are dense, and border-plus-text alone is
- * a weak disabled signal at a glance; the tint is what classic readers
- * already recognise. Same reasoning as the base background above.
- */
- background-color: var(--wpds-color-background-surface-neutral-weak);
+ background-color: var(--wpds-color-background-interactive-neutral-disabled);
border-color: var(--wpds-color-stroke-interactive-neutral-disabled);
color: var(--wpds-color-foreground-interactive-neutral-disabled);
box-shadow: none;
diff --git a/lib/experimental/wpds-admin/css/30-checkbox-radio.css b/lib/experimental/wpds-admin/css/30-checkbox-radio.css
index 3c0caf9764a985..a630c8626c64da 100644
--- a/lib/experimental/wpds-admin/css/30-checkbox-radio.css
+++ b/lib/experimental/wpds-admin/css/30-checkbox-radio.css
@@ -33,13 +33,7 @@
box-sizing: border-box;
border: var(--wpds-border-width-xs) solid var(--wpds-color-stroke-interactive-neutral);
border-radius: var(--wpds-border-radius-sm);
- /*
- * White rather than the design system's transparent field background,
- * for the same reason as 20-input.css: classic controls sit on the grey page
- * background as often as on white, and a transparent control would read as
- * grey.
- */
- background: var(--wpds-color-background-surface-neutral-strong);
+ background: var(--wpds-color-background-interactive-neutral);
color: var(--wpds-color-foreground-interactive-brand-strong);
box-shadow: none;
cursor: var(--wpds-cursor-control);
@@ -124,7 +118,7 @@
input[type="radio"].disabled,
input[type="radio"][aria-disabled="true"] {
border-color: var(--wpds-color-stroke-interactive-neutral-disabled);
- background: var(--wpds-color-background-surface-neutral-weak);
+ background: var(--wpds-color-background-interactive-neutral-disabled);
color: var(--wpds-color-foreground-interactive-neutral-disabled);
cursor: default;
opacity: 1;
@@ -141,9 +135,10 @@
}
/*
- * Disabled AND checked: the box stays muted and the mark greys out with it,
- * which is only legible because the mark is masked rather than a baked-white
- * SVG. Core instead fades the whole control with `opacity: 0.7`.
+ * Disabled AND checked: the box drops its accent fill for the disabled
+ * surface and the mark greys out, which is only legible because the mark is
+ * masked rather than a baked-white SVG. Core instead fades the whole control
+ * with `opacity: 0.7`.
*/
input[type="checkbox"]:disabled:checked,
input[type="checkbox"].disabled:checked,
@@ -152,7 +147,7 @@
input[type="radio"].disabled:checked,
input[type="radio"][aria-disabled="true"]:checked {
border-color: var(--wpds-color-stroke-interactive-neutral-disabled);
- background: var(--wpds-color-background-interactive-neutral-strong-disabled);
+ background: var(--wpds-color-background-interactive-neutral-disabled);
color: var(--wpds-color-foreground-interactive-neutral-disabled);
}
From ff4a2816148ebc333654b0c74f0837a8e84d866f Mon Sep 17 00:00:00 2001
From: "Joen A."
Date: Wed, 16 Sep 2026 11:18:00 +0200
Subject: [PATCH 09/26] Scope disabled field styles to text-like inputs
The disabled rules matched every `input`, so from the higher layer they
also overrode forms.css's transparent background for disabled file and
range controls. They now name the same text-like types as the rest of
the file.
The type list also fixes a focused `aria-disabled` field: it stays
focusable, and `input[type="text"]:focus` outranked the plain
`input[aria-disabled="true"]` rule, handing the field back its enabled
border. The scoped selectors carry enough specificity to keep the
disabled border.
Co-Authored-By: Claude Opus 5
---
lib/experimental/wpds-admin/css/20-input.css | 22 ++++++++++++++------
1 file changed, 16 insertions(+), 6 deletions(-)
diff --git a/lib/experimental/wpds-admin/css/20-input.css b/lib/experimental/wpds-admin/css/20-input.css
index c7b0a84b6bc3bd..11d3c2763da47b 100644
--- a/lib/experimental/wpds-admin/css/20-input.css
+++ b/lib/experimental/wpds-admin/css/20-input.css
@@ -159,9 +159,9 @@
}
/* Disabled fields keep the disabled border on hover. */
- input:disabled:hover,
- input.disabled:hover,
- input[aria-disabled="true"]:hover,
+ input:is([type="text"], [type="password"], [type="color"], [type="date"], [type="datetime"], [type="datetime-local"], [type="email"], [type="month"], [type="number"], [type="search"], [type="tel"], [type="time"], [type="url"], [type="week"]):disabled:hover,
+ input:is([type="text"], [type="password"], [type="color"], [type="date"], [type="datetime"], [type="datetime-local"], [type="email"], [type="month"], [type="number"], [type="search"], [type="tel"], [type="time"], [type="url"], [type="week"]).disabled:hover,
+ input:is([type="text"], [type="password"], [type="color"], [type="date"], [type="datetime"], [type="datetime-local"], [type="email"], [type="month"], [type="number"], [type="search"], [type="tel"], [type="time"], [type="url"], [type="week"])[aria-disabled="true"]:hover,
select:disabled:hover,
select.disabled:hover,
select[aria-disabled="true"]:hover,
@@ -213,11 +213,21 @@
/* -----------------------------------------------------------------------
Disabled and readonly
+
+ Inputs are scoped to the text-like types this file styles. A bare
+ `input:disabled` also matches file, range and button inputs, and from
+ this layer it would override the transparent background forms.css gives
+ file and range controls on purpose.
+
+ The type list also carries the specificity these rules need. A focused
+ `aria-disabled` field is still focusable, and without the attribute
+ selectors here `input[type="text"]:focus` would outrank the disabled
+ rule and hand the field back its enabled border.
----------------------------------------------------------------------- */
- input:disabled,
- input.disabled,
- input[aria-disabled="true"],
+ input:is([type="text"], [type="password"], [type="color"], [type="date"], [type="datetime"], [type="datetime-local"], [type="email"], [type="month"], [type="number"], [type="search"], [type="tel"], [type="time"], [type="url"], [type="week"]):disabled,
+ input:is([type="text"], [type="password"], [type="color"], [type="date"], [type="datetime"], [type="datetime-local"], [type="email"], [type="month"], [type="number"], [type="search"], [type="tel"], [type="time"], [type="url"], [type="week"]).disabled,
+ input:is([type="text"], [type="password"], [type="color"], [type="date"], [type="datetime"], [type="datetime-local"], [type="email"], [type="month"], [type="number"], [type="search"], [type="tel"], [type="time"], [type="url"], [type="week"])[aria-disabled="true"],
select:disabled,
select.disabled,
select[aria-disabled="true"],
From a056b6f07003e7ff83bca0426589bcc171dc64e1 Mon Sep 17 00:00:00 2001
From: "Joen A."
Date: Wed, 16 Sep 2026 11:18:13 +0200
Subject: [PATCH 10/26] Give invalid fields an error-coloured focus ring
Invalid required fields already take the error border, but focusing
one drew the default accent ring around it. The ring now uses
`--wpds-color-stroke-interactive-error`, matching the border, as the
design system's own fields do for invalid input.
Co-Authored-By: Claude Opus 5
---
lib/experimental/wpds-admin/css/20-input.css | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/lib/experimental/wpds-admin/css/20-input.css b/lib/experimental/wpds-admin/css/20-input.css
index 11d3c2763da47b..6e513c43b48150 100644
--- a/lib/experimental/wpds-admin/css/20-input.css
+++ b/lib/experimental/wpds-admin/css/20-input.css
@@ -211,6 +211,18 @@
box-shadow: none;
}
+ /*
+ * Invalid required fields take an error-coloured ring, so the ring and the
+ * border agree. The border itself is set in 99-overrides.css, because
+ * forms.css marks it `!important`. Selectors follow forms.css's own
+ * `.form-invalid` rules.
+ */
+ .form-invalid .form-required:focus,
+ .form-invalid.form-required input:focus,
+ .form-invalid.form-required select:focus {
+ outline-color: var(--wpds-color-stroke-interactive-error);
+ }
+
/* -----------------------------------------------------------------------
Disabled and readonly
From e1ce4e9f78b346aa44ea07ebabb7defa243dacf8 Mon Sep 17 00:00:00 2001
From: "Joen A."
Date: Wed, 16 Sep 2026 11:18:26 +0200
Subject: [PATCH 11/26] Keep grouped buttons joined
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The base button rule's `margin` and `border-radius` also applied to
buttons inside a `.button-group`, and from the higher layer they
outranked buttons.css's joining rules. Grouped buttons came apart into
separate rounded controls with doubled borders.
The joining geometry is repeated from buttons.css — square inner
corners, a -1px overlap, no leading border after a primary button —
with the corner radius taken from the scale. The parity harness gains
a button group section to show it.
Co-Authored-By: Claude Opus 5
---
lib/experimental/wpds-admin/css/10-button.css | 30 +++++++++++++++++++
lib/experimental/wpds-admin/harness.php | 22 ++++++++++++++
2 files changed, 52 insertions(+)
diff --git a/lib/experimental/wpds-admin/css/10-button.css b/lib/experimental/wpds-admin/css/10-button.css
index 9f705f93566b5d..7967a443147206 100644
--- a/lib/experimental/wpds-admin/css/10-button.css
+++ b/lib/experimental/wpds-admin/css/10-button.css
@@ -77,6 +77,36 @@
text-shadow: none;
}
+ /* -----------------------------------------------------------------------
+ Button groups
+
+ The base rule's `margin` and `border-radius` apply to every button, and
+ from this layer they outrank buttons.css's `.button-group` rules, which
+ join grouped buttons into one control: square inner corners, and a -1px
+ margin so neighbouring borders overlap instead of doubling. That joining
+ is geometry, so it is repeated here from buttons.css with only the radius
+ value taken from the scale.
+ ----------------------------------------------------------------------- */
+
+ .wp-core-ui .button-group > .button {
+ border-radius: 0;
+ margin-inline-end: -1px;
+ }
+
+ .wp-core-ui .button-group > .button:first-child {
+ border-start-start-radius: var(--wpds-border-radius-sm);
+ border-end-start-radius: var(--wpds-border-radius-sm);
+ }
+
+ .wp-core-ui .button-group > .button:last-child {
+ border-start-end-radius: var(--wpds-border-radius-sm);
+ border-end-end-radius: var(--wpds-border-radius-sm);
+ }
+
+ .wp-core-ui .button-group > .button-primary + .button {
+ border-inline-start: 0;
+ }
+
/* -----------------------------------------------------------------------
Focus — outline/outline-offset, never box-shadow.
Core's legacy ring is a box-shadow; the base rule above clears it.
diff --git a/lib/experimental/wpds-admin/harness.php b/lib/experimental/wpds-admin/harness.php
index b73625104addf7..5218b2c92e808b 100644
--- a/lib/experimental/wpds-admin/harness.php
+++ b/lib/experimental/wpds-admin/harness.php
@@ -121,6 +121,28 @@ function gutenberg_wpds_admin_render_harness() {
}
echo '';
+ echo '
';
From 6bccecade2fb4c7f2a30fb80e0c561170b1a6ff3 Mon Sep 17 00:00:00 2001
From: "Joen A."
Date: Wed, 16 Sep 2026 11:18:40 +0200
Subject: [PATCH 12/26] Stop aria-disabled harness specimens from responding to
input
`aria-disabled` controls stay focusable, but nothing else about them is
inert: the harness's text field, select and checkbox specimens could
still be typed in, opened and toggled, drifting out of the state their
row names. Pointer and keyboard activation are now blocked for them.
Tab still moves focus, a click still focuses a field so its focus state
can be checked, and the selectors are unchanged.
Co-Authored-By: Claude Opus 5
---
lib/experimental/wpds-admin/harness.php | 41 ++++++++++++++++++++++++-
1 file changed, 40 insertions(+), 1 deletion(-)
diff --git a/lib/experimental/wpds-admin/harness.php b/lib/experimental/wpds-admin/harness.php
index 5218b2c92e808b..c2d25f95f1fd70 100644
--- a/lib/experimental/wpds-admin/harness.php
+++ b/lib/experimental/wpds-admin/harness.php
@@ -82,7 +82,7 @@ function gutenberg_wpds_admin_render_harness() {
'.button-hero (48px)' => ' button-hero',
);
- echo '
' . esc_html__( 'Admin control markup rendered under the real admin cascade, so each state can be compared against its design system counterpart. Pseudo-class states (:hover, :focus, :active) must be checked interactively; the class-driven equivalents admin JS toggles are shown below.', 'gutenberg' ) . '
' . esc_html__( 'Grouped buttons join into one control: square inner corners, and borders that overlap rather than double.', 'gutenberg' ) . '
';
+ echo '
' . esc_html__( 'Grouped buttons join into one control: square inner corners, and borders that overlap rather than double. The last group has a selected button, marked with the `.active` class, as groups used as toggles do — for example the alignment and link settings in the media modal.', 'gutenberg' ) . '
';
foreach ( $groups as $group ) {
From 057fcfbd971e6b76682de14a2f1360a4786b347c Mon Sep 17 00:00:00 2001
From: "Joen A."
Date: Thu, 17 Sep 2026 12:12:18 +0200
Subject: [PATCH 17/26] Move the scheme token generator into the monorepo tools
workspace
The generator now lives in `tools/monorepo/wpds/` and runs through the
workspace's own npm script, rather than from `bin/`. Its output is
unchanged.
The generated file's header now names what replaces it: generating these
tokens from `@wordpress/theme` outside React, for the active colour
scheme, including custom colour seeds.
Co-Authored-By: Claude Opus 5
---
.../wpds-admin/css/05-scheme-tokens.css | 9 +++++---
tools/monorepo/README.md | 4 ++++
tools/monorepo/package.json | 3 ++-
.../wpds/generate-admin-scheme-tokens.mjs | 23 ++++++++++++-------
4 files changed, 27 insertions(+), 12 deletions(-)
rename bin/generate-wpds-admin-scheme-tokens.mjs => tools/monorepo/wpds/generate-admin-scheme-tokens.mjs (84%)
diff --git a/lib/experimental/wpds-admin/css/05-scheme-tokens.css b/lib/experimental/wpds-admin/css/05-scheme-tokens.css
index 793c695f33a60d..3ebd6a6a71e63a 100644
--- a/lib/experimental/wpds-admin/css/05-scheme-tokens.css
+++ b/lib/experimental/wpds-admin/css/05-scheme-tokens.css
@@ -3,7 +3,7 @@
*
* GENERATED FILE. Do not edit. Regenerate with:
*
- * node bin/generate-wpds-admin-scheme-tokens.mjs
+ * npm run wpds:admin-scheme-tokens --workspace @wordpress/monorepo-tools
*
* React admin screens derive these values at runtime through ThemeProvider.
* PHP-rendered screens have no provider, so without this file every token
@@ -11,8 +11,11 @@
* pair the scheme's background with text chosen for the default blue. Only
* tokens that differ from the default scheme are listed.
*
- * Setting `--wpds-*` properties is reserved for the theme package, which is
- * where this output belongs once the experiment graduates.
+ * Setting `--wpds-*` properties is reserved for the theme package.
+ *
+ * DELETE WHEN: `@wordpress/theme` can generate these tokens outside React and
+ * wp-admin loads them for the active colour scheme, which will also need to
+ * cover custom colour seeds (Core-66026, Core-65776).
*/
/* stylelint-disable plugin-wpds/no-setting-wpds-custom-properties -- Generated equivalent of ThemeProvider's runtime output. */
diff --git a/tools/monorepo/README.md b/tools/monorepo/README.md
index 838b1db77d6405..c1229407dab116 100644
--- a/tools/monorepo/README.md
+++ b/tools/monorepo/README.md
@@ -28,3 +28,7 @@ See the TypeScript section of [`packages/README.md`](../../packages/README.md) f
## Experimental API audit
`npm run --workspace @wordpress/monorepo-tools list-experimental-apis` prints a Markdown list of every `__experimental` API in `packages/` and `lib/`, each linked to a GitHub search for it. Release leads run it to audit experimental APIs ahead of a major WordPress release, as in [the WordPress 6.2 audit](https://github.com/WordPress/gutenberg/issues/47196).
+
+## Admin design tokens experiment
+
+`wpds/generate-admin-scheme-tokens.mjs` writes the per-colour-scheme design token stylesheet for the admin design tokens experiment, `lib/experimental/wpds-admin/css/05-scheme-tokens.css`. Run it with `npm run wpds:admin-scheme-tokens --workspace @wordpress/monorepo-tools` after changing the theme's colour ramps or the admin colour schemes. It is temporary; the generated file's header says what replaces it.
diff --git a/tools/monorepo/package.json b/tools/monorepo/package.json
index cfc62251102fc7..6cad3aff55eae0 100644
--- a/tools/monorepo/package.json
+++ b/tools/monorepo/package.json
@@ -36,6 +36,7 @@
"access": "public"
},
"scripts": {
- "list-experimental-apis": "node scripts/list-experimental-apis.mts"
+ "list-experimental-apis": "node scripts/list-experimental-apis.mts",
+ "wpds:admin-scheme-tokens": "node wpds/generate-admin-scheme-tokens.mjs"
}
}
diff --git a/bin/generate-wpds-admin-scheme-tokens.mjs b/tools/monorepo/wpds/generate-admin-scheme-tokens.mjs
similarity index 84%
rename from bin/generate-wpds-admin-scheme-tokens.mjs
rename to tools/monorepo/wpds/generate-admin-scheme-tokens.mjs
index e5d133c51497a6..a2bde5b758020d 100644
--- a/bin/generate-wpds-admin-scheme-tokens.mjs
+++ b/tools/monorepo/wpds/generate-admin-scheme-tokens.mjs
@@ -14,7 +14,11 @@
* the ramps from `@wordpress/theme`. Only tokens that differ from the default
* scheme are written, and schemes that derive identical values share a rule.
*
- * Usage: node bin/generate-wpds-admin-scheme-tokens.mjs
+ * Both are imported from package source because neither package exports these
+ * as a plain function yet. Once `@wordpress/theme` does, this script and its
+ * output are meant to be replaced by that.
+ *
+ * Usage: npm run wpds:admin-scheme-tokens --workspace @wordpress/monorepo-tools
*/
import { writeFileSync } from 'node:fs';
@@ -24,11 +28,11 @@ import {
buildBgRamp,
buildAccentRamp,
DEFAULT_SEED_COLORS,
-} from '../packages/theme/src/color-ramps/index.ts';
-import colorTokens from '../packages/theme/src/prebuilt/ts/color-tokens.ts';
-import { getAdminThemeColors } from '../packages/admin-ui/src/admin-theme-colors/index.ts';
+} from '../../../packages/theme/src/color-ramps/index.ts';
+import colorTokens from '../../../packages/theme/src/prebuilt/ts/color-tokens.ts';
+import { getAdminThemeColors } from '../../../packages/admin-ui/src/admin-theme-colors/index.ts';
-const ROOT = join( dirname( fileURLToPath( import.meta.url ) ), '..' );
+const ROOT = join( dirname( fileURLToPath( import.meta.url ) ), '../../..' );
const OUTPUT = join(
ROOT,
'lib/experimental/wpds-admin/css/05-scheme-tokens.css'
@@ -120,7 +124,7 @@ const css = `/**
*
* GENERATED FILE. Do not edit. Regenerate with:
*
- * node bin/generate-wpds-admin-scheme-tokens.mjs
+ * npm run wpds:admin-scheme-tokens --workspace @wordpress/monorepo-tools
*
* React admin screens derive these values at runtime through ThemeProvider.
* PHP-rendered screens have no provider, so without this file every token
@@ -128,8 +132,11 @@ const css = `/**
* pair the scheme's background with text chosen for the default blue. Only
* tokens that differ from the default scheme are listed.
*
- * Setting \`--wpds-*\` properties is reserved for the theme package, which is
- * where this output belongs once the experiment graduates.
+ * Setting \`--wpds-*\` properties is reserved for the theme package.
+ *
+ * DELETE WHEN: \`@wordpress/theme\` can generate these tokens outside React and
+ * wp-admin loads them for the active colour scheme, which will also need to
+ * cover custom colour seeds (Core-66026, Core-65776).
*/
/* stylelint-disable plugin-wpds/no-setting-wpds-custom-properties -- Generated equivalent of ThemeProvider's runtime output. */
From fd360808b2bc8e257cccf1b76ee0f3abe96d9238 Mon Sep 17 00:00:00 2001
From: "Joen A."
Date: Thu, 17 Sep 2026 12:14:15 +0200
Subject: [PATCH 18/26] Describe the experiment without calling the admin
classic or Core
Comments and the harness now say "admin" for the admin screens, and name
WordPress or the specific stylesheet, such as buttons.css or forms.css,
where they meant WordPress's own admin CSS.
The loader also no longer describes wrapping WordPress's admin CSS in
`@layer` as the permanent form. That would let every unlayered plugin
rule outrank the admin unconditionally. The permanent form is moving
these styles into WordPress's own stylesheets, after which the demotion
step and the concatenation switch are removed.
Co-Authored-By: Claude Opus 5
---
.stylelintrc.mjs | 6 +-
lib/experimental/wpds-admin/css/10-button.css | 20 +++----
lib/experimental/wpds-admin/css/20-input.css | 32 +++++------
.../wpds-admin/css/30-checkbox-radio.css | 14 ++---
.../wpds-admin/css/90-exceptions.css | 16 +++---
.../wpds-admin/css/99-overrides.css | 14 ++---
lib/experimental/wpds-admin/harness.php | 8 +--
lib/experimental/wpds-admin/load.php | 55 ++++++++++---------
8 files changed, 84 insertions(+), 81 deletions(-)
diff --git a/.stylelintrc.mjs b/.stylelintrc.mjs
index 1b6456fc850401..e5024e293f3b8e 100644
--- a/.stylelintrc.mjs
+++ b/.stylelintrc.mjs
@@ -7,7 +7,7 @@ export default {
overrides: [
{
/*
- * The classic admin restyle ships inside a cascade layer, and layer
+ * The admin design tokens experiment ships inside a cascade layer, and layer
* priority REVERSES for `!important`: a layered `!important` beats a
* third-party plugin's *unlayered* `!important`, even when the
* plugin's stylesheet loads later. Allowing one here would silently
@@ -24,12 +24,12 @@ export default {
{
/*
* The single exception. `overrides.css` exists only to cancel
- * `!important` declarations in Core's admin CSS, which survive being
+ * `!important` declarations in WordPress's admin CSS, which survive being
* demoted into a lower layer because importance is compared before
* layer order. It sits in a layer declared BEFORE the legacy one,
* since importance reverses layer order.
*
- * Every rule in that file must be cancelling a specific Core
+ * Every rule in that file must be cancelling a specific WordPress
* `!important` and must record what would let us delete it. Do not
* widen this exception to any other file.
*/
diff --git a/lib/experimental/wpds-admin/css/10-button.css b/lib/experimental/wpds-admin/css/10-button.css
index 20d2e1e1c1349f..72f02a8c7e6966 100644
--- a/lib/experimental/wpds-admin/css/10-button.css
+++ b/lib/experimental/wpds-admin/css/10-button.css
@@ -4,12 +4,12 @@
* Target: visual parity with the React `Button`
* (packages/components/src/button/style.scss). The React component is the
* source of truth for *values*. The selectors here are hand-authored against
- * classic markup and are deliberately NOT derived from it — deriving selectors
+ * the admin's markup and are deliberately NOT derived from it — deriving selectors
* is what sank the earlier generation POC.
*
* Cascade: everything lives in the `wpds` layer, which sits above `wp-legacy`,
- * where load.php demotes Core's own buttons.css. Layer order means the plain
- * selectors below beat Core's more specific ones with no specificity
+ * where load.php demotes WordPress's own buttons.css. Layer order means the plain
+ * selectors below beat WordPress's more specific ones with no specificity
* escalation — and unlayered plugin CSS still beats us, which is the point.
*
* NO `!important` IN THIS FILE. Measured: a layered `!important` outranks a
@@ -40,16 +40,16 @@
justify-content: center;
box-sizing: border-box;
/*
- * NO height here, deliberately. Core's default is already 40px, which is
+ * NO height here, deliberately. WordPress's default is already 40px, which is
* exactly --wpds-dimension-size-lg, so asserting it gains nothing — and it
* costs a great deal, because a height declared in this layer outranks
- * Core's CONTEXTUAL overrides regardless of their specificity. Core makes
+ * WordPress's CONTEXTUAL overrides regardless of their specificity. WordPress makes
* 67 such rules (`.wp-core-ui .tablenav .button` and friends) to make
* toolbars compact; declaring a height here silently flattened all of
* them, and the list-table toolbar rendered as a mix of 32px and 40px
* controls that no longer shared a midline.
*
- * Height is Core's. Appearance is ours.
+ * Height belongs to buttons.css. Appearance is ours.
*/
margin: 0;
padding: 4px 12px;
@@ -60,7 +60,7 @@
text-decoration: none;
white-space: nowrap;
/*
- * NO vertical-align either. Core varies it by context — `top` for the
+ * NO vertical-align either. WordPress varies it by context — `top` for the
* base, `baseline` for `p .button`, `middle` for `.button-group` — and a
* blanket value here flattens those the same way a height would. It is how
* the control sits in a line of other content, which is layout, not
@@ -112,7 +112,7 @@
/* -----------------------------------------------------------------------
Focus — outline/outline-offset, never box-shadow.
- Core's legacy ring is a box-shadow; the base rule above clears it.
+ WordPress's legacy ring is a box-shadow; the base rule above clears it.
----------------------------------------------------------------------- */
.wp-core-ui .button:focus,
@@ -168,7 +168,7 @@
}
/*
- * `.active` is a PERSISTENT selected state in classic admin, toggled by
+ * `.active` is a PERSISTENT selected state in the admin, toggled by
* legacy admin JS. It is not `:active`. Never conflate the two.
*/
.wp-core-ui .button.active,
@@ -199,7 +199,7 @@
}
/* -----------------------------------------------------------------------
- Disabled — classic drives this from classes as well as attributes.
+ Disabled — the admin drives this from classes as well as attributes.
buttons.css marks most of these `!important`; 99-overrides.css repeats
the same values to cancel that.
----------------------------------------------------------------------- */
diff --git a/lib/experimental/wpds-admin/css/20-input.css b/lib/experimental/wpds-admin/css/20-input.css
index 6e513c43b48150..bbe76753a49e86 100644
--- a/lib/experimental/wpds-admin/css/20-input.css
+++ b/lib/experimental/wpds-admin/css/20-input.css
@@ -5,9 +5,9 @@
* Target: visual parity with the `@wordpress/ui` input primitive
* (packages/ui/src/form/primitives/input-layout/style.module.css), which is the
* purest token expression of a field in the design system. Values come from
- * there; the selectors below are hand-authored against classic markup.
+ * there; the selectors below are hand-authored against the admin's markup.
*
- * Selector list mirrors Core's own in wp-admin/css/forms.css so the same set of
+ * Selector list mirrors the one in wp-admin/css/forms.css so the same set of
* controls is covered — no more, no less.
*
* NO `!important` IN THIS FILE. See 99-overrides.css for the one exception and why.
@@ -45,14 +45,14 @@
box-shadow: none;
font-family: var(--wpds-typography-font-family-body);
/*
- * NO font-size. Core sets `input, select, textarea, button { font-size:
+ * NO font-size. WordPress sets `input, select, textarea, button { font-size:
* inherit }`, so a control takes the size of whatever contains it — which
* is why a settings form reads coherently, with label, field and help text
* all at one size. Pinning a size here broke that: the label stayed at the
* container's size while the field it belongs to shrank to 13px.
*
* Inheriting keeps a control correct in every context without us having to
- * know what those contexts are. Core also handles the mobile case, raising
+ * know what those contexts are. WordPress also handles the mobile case, raising
* inputs to 16px below 782px so iOS does not zoom on focus.
*/
line-height: 1;
@@ -75,16 +75,16 @@
input[type="time"],
input[type="url"],
input[type="week"] {
- /* No height — see the note in 10-button.css. Core's default is already 40px
+ /* No height — see the note in 10-button.css. WordPress's default is already 40px
and its contextual rules must keep winning. */
padding: 0 var(--wpds-dimension-padding-md);
}
/*
- * Select needs asymmetric padding, because Core draws the dropdown arrow as a
+ * Select needs asymmetric padding, because WordPress draws the dropdown arrow as a
* background image at the trailing edge and the text has to clear it.
*
- * Core reserves exactly the arrow's own width (24px for a 16px arrow inset
+ * WordPress reserves exactly the arrow's own width (24px for a 16px arrow inset
* 8px), so the text ends precisely where the arrow begins and there is no
* gutter at all. React's SelectControl reserves the icon PLUS a trailing
* gutter mirroring the leading one:
@@ -97,18 +97,18 @@
*
* The 18px is deliberately not a token. The icon set is drawn and hinted at
* 24x24 and, being fill-based, scales badly to arbitrary sizes; 18 is 75% of
- * 24, picked to interpolate cleanly. Mirroring it keeps classic and the
+ * 24, picked to interpolate cleanly. Mirroring it keeps the admin and the
* component in step.
*
- * Logical properties, so this follows the arrow when Core's RTL stylesheet
+ * Logical properties, so this follows the arrow when WordPress's RTL stylesheet
* flips it to the leading edge.
*/
select {
/*
- * Only the TRAILING padding, not the shorthand. Core narrows the leading
+ * Only the TRAILING padding, not the shorthand. WordPress narrows the leading
* padding contextually (`.wp-core-ui .tablenav select` uses 8px), and a
* shorthand here would flatten that along with everything else. Setting
- * one side leaves Core's contextual value on the other intact, and the
+ * one side leaves WordPress's contextual value on the other intact, and the
* arrow still gets its gutter in every context.
*
* No height, for the reason given in 10-button.css.
@@ -132,7 +132,7 @@
}
/* -----------------------------------------------------------------------
- Hover — classic has no hover state; the design system darkens the border.
+ Hover — the admin has no hover state; the design system darkens the border.
Excluded from disabled and readonly fields. Without the :not() the hover
rule outranks the disabled rule: an attribute selector plus a pseudo-class
@@ -186,7 +186,7 @@
/* -----------------------------------------------------------------------
Focus — outline/outline-offset, never box-shadow.
- Core's ring is a box-shadow plus an accent border; both are replaced.
+ WordPress's ring is a box-shadow plus an accent border; both are replaced.
----------------------------------------------------------------------- */
input[type="text"]:focus,
@@ -254,15 +254,15 @@
/*
* The dropdown arrow is a background image with its colour baked into the
- * data URI, so it cannot follow a token or `currentColor`. Core swaps in a
+ * data URI, so it cannot follow a token or `currentColor`. WordPress swaps in a
* grey arrow for `select.disabled` and `select:disabled`, but leaves
* `select[aria-disabled="true"]` with only `cursor: default` — no visual
* disabled treatment at all.
*
* Since the rules above do give an aria-disabled field the disabled box, the
* arrow has to follow, or it stays black against a greyed control. This
- * repeats Core's own grey arrow verbatim so all three disabled forms render
- * identically; the colour is Core's #a0a5aa rather than the disabled token,
+ * repeats WordPress's own grey arrow verbatim so all three disabled forms render
+ * identically; the colour is WordPress's #a0a5aa rather than the disabled token,
* because matching the other two matters more than matching the palette
* while the value is trapped inside a data URI.
*
diff --git a/lib/experimental/wpds-admin/css/30-checkbox-radio.css b/lib/experimental/wpds-admin/css/30-checkbox-radio.css
index b1f27ca576efb9..98cbffdb47e101 100644
--- a/lib/experimental/wpds-admin/css/30-checkbox-radio.css
+++ b/lib/experimental/wpds-admin/css/30-checkbox-radio.css
@@ -5,16 +5,16 @@
* Parity target is the `@wordpress/ui` checkbox primitive
* (packages/ui/src/form/primitives/checkbox/style.module.css) for VALUES. Its
* STRUCTURE cannot be copied: it renders the tick as a separate icon element,
- * and classic ships a bare `` with no markup to hook. So
- * the indicator stays a `::before`, as Core does it, and only the values move.
+ * and the admin ships a bare `` with no markup to hook. So
+ * the indicator stays a `::before`, as WordPress does it, and only the values move.
* That is the "specs are shared, selectors never are" split in practice.
*
* The tick is drawn with `mask-image` rather than an SVG `url()` with a baked
- * fill, so its colour comes from `currentColor` and can respond to state. Core's
+ * fill, so its colour comes from `currentColor` and can respond to state. WordPress's
* version hardcodes `#ffffff` inside the data URI, which makes a disabled tick
* invisible against a muted box. Same technique the dashicons swap will need.
*
- * Geometry (size, margins) is deliberately left to Core: it is layout, not
+ * Geometry (size, margins) is deliberately left to forms.css: it is layout, not
* style, and touching it would shift every checkbox on every screen.
*
* NO `!important` IN THIS FILE. See 99-overrides.css for the one exception.
@@ -78,13 +78,13 @@
mask-repeat: no-repeat;
}
- /* The dot. Geometry is Core's; only the colour changes. */
+ /* The dot. Geometry stays with forms.css; only the colour changes. */
input[type="radio"]:checked::before {
background-color: currentColor;
}
/* -----------------------------------------------------------------------
- Focus — outline, never box-shadow. Core uses a two-ring box-shadow here.
+ Focus — outline, never box-shadow. WordPress uses a two-ring box-shadow here.
----------------------------------------------------------------------- */
input[type="checkbox"]:focus,
@@ -124,7 +124,7 @@
/*
* Disabled AND checked: the box drops its accent fill for the disabled
* surface and the mark greys out, which is only legible because the mark is
- * masked rather than a baked-white SVG. Core instead fades the whole control
+ * masked rather than a baked-white SVG. forms.css instead fades the whole control
* with `opacity: 0.7`.
*/
input[type="checkbox"]:disabled:checked,
diff --git a/lib/experimental/wpds-admin/css/90-exceptions.css b/lib/experimental/wpds-admin/css/90-exceptions.css
index f46fc993755611..c1875c70206947 100644
--- a/lib/experimental/wpds-admin/css/90-exceptions.css
+++ b/lib/experimental/wpds-admin/css/90-exceptions.css
@@ -4,16 +4,16 @@
* RULE: every rule in this file carries the condition that would let us delete
* it. Without that this file becomes permanent sediment instead of a to-do list.
*
- * Heights are deliberately absent here too: Core already defines every one of
+ * Heights are deliberately absent here too: WordPress already defines every one of
* these sizes at the same values, and a height declared in this layer would
- * outrank Core's contextual overrides. Only the padding and font-size remain.
+ * outrank WordPress's contextual overrides. Only the padding and font-size remain.
*
* Sizing note — the atomic-styles brief recorded `.button-compact`,
- * `.button-small` and `.button-large` as classic-only sizes missing from the
+ * `.button-small` and `.button-large` as admin-only sizes missing from the
* design system. That is not accurate as of this branch: the React Button has
- * `is-compact` (32px) and `is-small` (24px), matching classic's heights exactly,
+ * `is-compact` (32px) and `is-small` (24px), matching the admin's heights exactly,
* so those need no exception and are handled by 10-button.css. `.button-large` is
- * defined by Core as an explicit 40px, identical to the default, so it also
+ * defined by buttons.css as an explicit 40px, identical to the default, so it also
* needs nothing. Only `.button-hero` is genuinely unmatched.
*/
@@ -24,7 +24,7 @@
/*
* Compact — 32px. Matches React `is-compact`.
* DELETE WHEN: never; this is parity, not an exception. Kept here only
- * because Core's size classes are all defined together and splitting them
+ * because WordPress's size classes are all defined together and splitting them
* across two files would be harder to read.
*/
.wp-core-ui .button.button-compact,
@@ -43,7 +43,7 @@
}
/*
- * Large — Core defines this as an explicit 40px, i.e. the default size,
+ * Large — WordPress defines this as an explicit 40px, i.e. the default size,
* kept for semantic clarity in markup.
* DELETE WHEN: nothing to delete; listed for completeness so a reader does
* not go looking for a missing rule.
@@ -52,7 +52,7 @@
/*
* Hero — 48px. GENUINE EXCEPTION: no React equivalent exists.
* DELETE WHEN: either a 48px size lands on the React Button (at which point
- * this becomes parity and moves to 10-button.css), or Core deprecates
+ * this becomes parity and moves to 10-button.css), or WordPress deprecates
* `.button-hero`. Until one of those happens this rule is load-bearing.
* Used by the installer and a handful of onboarding screens.
*/
diff --git a/lib/experimental/wpds-admin/css/99-overrides.css b/lib/experimental/wpds-admin/css/99-overrides.css
index 9ba56f2091a321..d68fc4fb639633 100644
--- a/lib/experimental/wpds-admin/css/99-overrides.css
+++ b/lib/experimental/wpds-admin/css/99-overrides.css
@@ -1,22 +1,22 @@
/**
- * Overrides that exist ONLY to cancel an `!important` in Core's admin CSS.
+ * Overrides that exist ONLY to cancel an `!important` in WordPress's admin CSS.
*
* THIS IS THE ONE FILE ALLOWED TO USE `!important`, and it is deliberately tiny.
*
* Why it sits in `wpds-overrides`, declared BEFORE `wp-legacy`:
* importance reverses layer order. For normal declarations the last layer wins
* (`wpds` beats `wp-legacy`); for `!important` the FIRST layer wins. So an
- * `!important` in a layer above Core's loses, no matter its specificity —
+ * `!important` in a layer above WordPress's loses, no matter its specificity —
* specificity is not even consulted, because layer order is compared first.
* The only way to beat a demoted `!important` is from a layer below it.
*
* THE COST, stated plainly: a rule here also beats a third-party plugin's
* unlayered `!important`. Today those plugins usually win by source order, so
* every entry below takes precedence away from someone. That is why this file
- * is restricted to declarations that are cancelling a Core `!important` and
+ * is restricted to declarations that are cancelling a WordPress `!important` and
* nothing else, and why each block records what would let us delete it.
*
- * Every entry here should eventually disappear: when Core drops the matching
+ * Every entry here should eventually disappear: when WordPress drops the matching
* `!important`, the plain rules in 10-button.css take over on their own and the
* block below can be removed with no other change.
*/
@@ -29,7 +29,7 @@
* Cancels: wp-includes/css/buttons.css — the default/secondary disabled rule
* sets color / border-color / background / box-shadow / transform
* with `!important`.
- * DELETE WHEN: those `!important` flags are removed from Core.
+ * DELETE WHEN: those `!important` flags are removed from WordPress.
*/
.wp-core-ui .button:disabled,
.wp-core-ui .button[disabled],
@@ -49,7 +49,7 @@
* Cancels: wp-includes/css/buttons.css — `.button-primary-disabled` and
* `.button-primary.disabled` set color / background / border-color /
* box-shadow / text-shadow with `!important`.
- * DELETE WHEN: those `!important` flags are removed from Core.
+ * DELETE WHEN: those `!important` flags are removed from WordPress.
*/
.wp-core-ui .button-primary:disabled,
.wp-core-ui .button-primary[disabled],
@@ -66,7 +66,7 @@
* `border-color: #d63638 !important` on required fields and their
* focus state. Only `border-color` carries the flag; the companion
* `box-shadow` does not, so 20-input.css handles that normally.
- * DELETE WHEN: that `!important` is removed from Core.
+ * DELETE WHEN: that `!important` is removed from WordPress.
*/
.form-invalid .form-required,
.form-invalid .form-required:focus,
diff --git a/lib/experimental/wpds-admin/harness.php b/lib/experimental/wpds-admin/harness.php
index fd449dc0920ab1..d1c1a3e15f43b7 100644
--- a/lib/experimental/wpds-admin/harness.php
+++ b/lib/experimental/wpds-admin/harness.php
@@ -2,12 +2,12 @@
/**
* Parity harness for the admin design tokens experiment.
*
- * Renders classic control markup — buttons, fields, checkboxes and radios —
+ * Renders admin control markup — buttons, fields, checkboxes and radios —
* across every variant, size and state on a real wp-admin screen, so the
* restyled result can be compared against the equivalent React components.
*
* It is a real admin page rather than a static fixture so that the full cascade
- * applies: the demoted Core stylesheets, the active admin colour scheme, and any
+ * applies: the demoted WordPress stylesheets, the active admin colour scheme, and any
* CSS added by other active plugins. Differences that only exist in computed
* styles are visible here, where reading the stylesheets would miss them.
*
@@ -39,7 +39,7 @@ function gutenberg_wpds_admin_register_harness() {
/**
* Returns the button state matrix.
*
- * Classic drives state from CLASSES as well as pseudo-classes — legacy admin JS
+ * The admin drives state from CLASSES as well as pseudo-classes — legacy admin JS
* toggles `.hover` / `.focus` / `.disabled`. Only the class forms can be
* rendered statically, so those are what the harness shows; the pseudo-class
* forms have to be checked interactively.
@@ -188,7 +188,7 @@ function gutenberg_wpds_admin_render_harness() {
echo '
' . esc_html__( 'Core marks the invalid border with !important, so this is driven from the wpds-overrides layer rather than 20-input.css.', 'gutenberg' ) . '
';
+ echo '
' . esc_html__( 'The invalid border is marked !important in forms.css, so it is driven from the wpds-overrides layer rather than 20-input.css.', 'gutenberg' ) . '
';
echo '';
echo '
' . esc_html__( 'Checkboxes and radios', 'gutenberg' ) . '
';
diff --git a/lib/experimental/wpds-admin/load.php b/lib/experimental/wpds-admin/load.php
index 3c23983d07bc8c..4cb4963324c342 100644
--- a/lib/experimental/wpds-admin/load.php
+++ b/lib/experimental/wpds-admin/load.php
@@ -10,15 +10,15 @@
* MECHANISM — cascade layers, no dependency surgery.
*
* Layered CSS *loses* to unlayered CSS, so simply shipping our styles inside
- * `@layer wpds` would be outranked by Core's unlayered admin CSS. Rather than
- * dequeue Core's stylesheets and rewire the dependency graph (which is what the
+ * `@layer wpds` would be outranked by WordPress's unlayered admin CSS. Rather than
+ * dequeue WordPress's stylesheets and rewire the dependency graph (which is what the
* earlier POC did, and where its cost and its escaped bugs lived), we rewrite
- * the `` tag for an explicit allowlist of Core handles into an
+ * the `` tag for an explicit allowlist of WordPress style handles into an
* `@import ... layer(wp-legacy)`. The handle stays registered and enqueued, the
- * dependency graph is untouched, and Core's rules land in a layer below ours.
+ * dependency graph is untouched, and WordPress's rules land in a layer below ours.
*
* Consequences, all verified by rendering rather than by reading CSS:
- * - our styles beat Core's, regardless of selector specificity;
+ * - our styles beat WordPress's, regardless of selector specificity;
* - unlayered third-party plugin CSS still beats ours, which preserves the
* override the ecosystem has always relied on;
* - EXCEPT for `!important`, where layer priority reverses and a layered
@@ -26,9 +26,12 @@
* `!important` rule in the stylesheets, enforced by stylelint.
*
* This is prototype-grade. `@import` is render-blocking and costs a round trip
- * per demoted sheet. The permanent form is for Core to wrap its own admin CSS
- * in `@layer`, at which point this whole demotion step is deleted and the
- * stylesheets are unchanged. Tracked as a Core to-do.
+ * per demoted sheet. The permanent form is for these styles to move into
+ * WordPress's own admin stylesheets, one change at a time. Layers are
+ * scaffolding for reaching those stylesheets from a plugin: once the styles
+ * live there, this whole demotion step is deleted. Wrapping WordPress's admin
+ * CSS in `@layer` instead would not do it, because every unlayered plugin rule
+ * would then outrank the admin unconditionally.
*
* @package gutenberg
*/
@@ -42,16 +45,16 @@
* gets its own `` and cannot be demoted into a layer.
*
* Setting this global is the documented plugin-facing way to opt out (see the
- * file header of Core's script-loader.php). The cost is more requests on admin
+ * file header of WordPress's script-loader.php). The cost is more requests on admin
* screens, which is acceptable for an off-by-default experiment.
*
* NOTE: this is scaffolding, and it is a second reason the permanent fix
- * belongs in Core. Once Core wraps its own admin CSS in `@layer`, both this and
- * the demotion filter below are deleted, concatenation stays on, and the
- * stylesheets are unchanged.
+ * belongs in WordPress itself. Once these styles live in WordPress's own admin
+ * stylesheets, both this and the demotion filter below are deleted and
+ * concatenation stays on.
*
* An alternative was considered and rejected: demoting the entire
- * `load-styles.php` bundle in one go. That would put *all* of Core's admin CSS
+ * `load-styles.php` bundle in one go. That would put *all* of WordPress's admin CSS
* below unlayered plugin CSS, so plugin rules that currently lose on
* specificity or source order would suddenly win. That is a large and silent
* behaviour change for the ecosystem, and the opposite of the small, explicit
@@ -81,18 +84,18 @@ function gutenberg_wpds_admin_disable_concatenation() {
const GUTENBERG_WPDS_ADMIN_LAYER_ORDER = '@layer wpds-overrides, wp-legacy, wpds;';
/**
- * Core style handles to demote into the `wp-legacy` layer.
+ * WordPress style handles to demote into the `wp-legacy` layer.
*
* Deliberately an explicit allowlist rather than a blanket rewrite: the blast
* radius is exactly what is named here, and it grows one control at a time.
*
- * The set must be CONSISTENT, not minimal. Core's stylesheets override each
+ * The set must be CONSISTENT, not minimal. WordPress's stylesheets override each
* other by source order — most visibly, the active admin colour scheme
* (`colors`) restyles the chrome defined in `admin-menu`, `common`, `forms` and
* the rest. Demoting only some of them inverts those relationships: demoting
* `colors` alone put the modern scheme's `#adminmenuback { background: #1e1e1e }`
* below the unlayered `admin-menu.css` default of `#1d2327`, silently reverting
- * the admin colour scheme to legacy colours. So the whole Core admin set moves
+ * the admin colour scheme to legacy colours. So the whole admin set moves
* together, preserving their existing order relative to one another.
*
* DELIBERATELY EXCLUDED: Gutenberg's own package styles, above all
@@ -111,14 +114,14 @@ function gutenberg_wpds_admin_disable_concatenation() {
* it measures an unstyled frame, computes a collapsed height and never
* recalculates: upload.php?mode=grid renders as an empty strip. Verified by
* removing this one handle. Any screen that sizes itself from measured CSS is
- * exposed the same way, which is one more thing that only Core owning its own
- * layers can fix. The cost of leaving it out is that `media-views.css` outranks
- * the demoted colour scheme's media rules; a wrongly-coloured modal is a better
+ * exposed the same way, which is one more thing that only moving these styles
+ * into WordPress's own stylesheets can fix. The cost of leaving it out is that
+ * `media-views.css` outranks the demoted colour scheme's media rules; a wrongly-coloured modal is a better
* failure than an unusable Media Library.
*
* Trade-off worth naming: everything listed here now sits below unlayered
- * plugin CSS. Plugin rules that currently lose to Core on specificity or source
- * order will start winning. That is a real behaviour change for the ecosystem
+ * plugin CSS. Plugin rules that currently lose to WordPress's stylesheets on
+ * specificity or source order will start winning. That is a real behaviour change for the ecosystem
* and the main thing this experiment needs to prove is safe.
*
* @since 24.1.0
@@ -171,7 +174,7 @@ function gutenberg_wpds_admin_escape_css_url( $href ) {
}
/**
- * Rewrites an allowlisted Core stylesheet link into a layered `@import`.
+ * Rewrites an allowlisted WordPress stylesheet link into a layered `@import`.
*
* Fires for the RTL variant as a second call with the same handle, so both
* directions are demoted without any extra handling.
@@ -211,7 +214,7 @@ function gutenberg_wpds_admin_demote_style( $tag, $handle, $href, $media ) {
add_filter( 'style_loader_tag', 'gutenberg_wpds_admin_demote_style', 10, 4 );
/**
- * Enqueues design tokens and the restyle stylesheets on classic admin screens.
+ * Enqueues design tokens and the restyle stylesheets on admin screens.
*
* `admin_enqueue_scripts` only fires in wp-admin, so this never touches the
* login screen or the front end — both of which also use the `buttons` handle.
@@ -223,8 +226,8 @@ function gutenberg_wpds_admin_enqueue_styles() {
$base_url = gutenberg_url( 'lib/experimental/wpds-admin/css/' );
/*
- * Make `--wpds-*` resolvable in classic admin. Gutenberg registers the
- * `wp-theme` handle itself (see lib/client-assets.php), so no Core change
+ * Make `--wpds-*` resolvable in wp-admin. Gutenberg registers the
+ * `wp-theme` handle itself (see lib/client-assets.php), so no wordpress-develop change
* is needed here. Fall back to the package's prebuilt copy if that ever
* stops being true.
*/
@@ -246,7 +249,7 @@ function gutenberg_wpds_admin_enqueue_styles() {
* for. Each area of the restyle owns one file and registers itself by being
* present, so adding one is a single new file and no edit here.
*
- * The first sheet depends on `buttons` so ours prints after the demoted Core
+ * The first sheet depends on `buttons` so ours prints after the demoted WordPress
* sheet and the generated HTML reads in order; the cascade itself no longer
* relies on that, since layer order decides. Each subsequent sheet depends on
* the one before it to keep that document order.
From 40349df31f5e0e0682a1f73f9653b7cc890e8eea Mon Sep 17 00:00:00 2001
From: "Joen A."
Date: Thu, 17 Sep 2026 13:37:04 +0200
Subject: [PATCH 19/26] Read the admin colour schemes from admin-ui instead of
a copied list
The token generator kept its own list of the nine bundled schemes,
duplicating `ADMIN_THEME_COLORS` in `@wordpress/admin-ui`. A guard caught
a scheme missing from admin-ui, but nothing caught a scheme added there,
which would have silently fallen back to the default scheme's tokens.
The map is now exported from its own module, not from the package, and
the generator iterates it. The copied list and the guard are gone. The
generated tokens are unchanged; only their order follows the map.
Co-Authored-By: Claude Opus 5
---
.../wpds-admin/css/05-scheme-tokens.css | 104 +++++++++---------
.../admin-ui/src/admin-theme-colors/index.ts | 6 +-
.../wpds/generate-admin-scheme-tokens.mjs | 50 +++------
3 files changed, 75 insertions(+), 85 deletions(-)
diff --git a/lib/experimental/wpds-admin/css/05-scheme-tokens.css b/lib/experimental/wpds-admin/css/05-scheme-tokens.css
index 3ebd6a6a71e63a..5db5582e6b6d64 100644
--- a/lib/experimental/wpds-admin/css/05-scheme-tokens.css
+++ b/lib/experimental/wpds-admin/css/05-scheme-tokens.css
@@ -42,22 +42,22 @@
--wpds-color-stroke-surface-brand-strong: #0073b0;
}
- body.admin-color-blue {
- --wpds-color-background-interactive-brand-strong: #437aa8;
- --wpds-color-background-interactive-brand-strong-active: #356c99;
- --wpds-color-background-interactive-brand-weak-active: #dfebf7;
- --wpds-color-background-surface-brand: #e8f1fa;
- --wpds-color-background-thumb-brand: #3b729f;
- --wpds-color-background-thumb-brand-active: #3b729f;
- --wpds-color-foreground-interactive-brand: #3b729f;
- --wpds-color-foreground-interactive-brand-active: #001f46;
- --wpds-color-foreground-interactive-brand-strong: #fff;
- --wpds-color-foreground-interactive-brand-strong-active: #fff;
- --wpds-color-stroke-focus: #3b729f;
- --wpds-color-stroke-interactive-brand: #3b729f;
- --wpds-color-stroke-interactive-brand-active: #1d5581;
- --wpds-color-stroke-surface-brand: #afc6db;
- --wpds-color-stroke-surface-brand-strong: #3b729f;
+ body.admin-color-midnight {
+ --wpds-color-background-interactive-brand-strong: #cf4339;
+ --wpds-color-background-interactive-brand-strong-active: #bf332b;
+ --wpds-color-background-interactive-brand-weak-active: #f8e5e2;
+ --wpds-color-background-surface-brand: #fbecea;
+ --wpds-color-background-thumb-brand: #c63a31;
+ --wpds-color-background-thumb-brand-active: #c63a31;
+ --wpds-color-foreground-interactive-brand: #c63a31;
+ --wpds-color-foreground-interactive-brand-active: #470000;
+ --wpds-color-foreground-interactive-brand-strong: #fefdfd;
+ --wpds-color-foreground-interactive-brand-strong-active: #fefdfd;
+ --wpds-color-stroke-focus: #c63a31;
+ --wpds-color-stroke-interactive-brand: #c63a31;
+ --wpds-color-stroke-interactive-brand-active: #a41111;
+ --wpds-color-stroke-surface-brand: #e0bab4;
+ --wpds-color-stroke-surface-brand-strong: #c63a31;
}
body.admin-color-coffee {
@@ -78,42 +78,6 @@
--wpds-color-stroke-surface-brand-strong: #8e6442;
}
- body.admin-color-ectoplasm {
- --wpds-color-background-interactive-brand-strong: #646c3e;
- --wpds-color-background-interactive-brand-strong-active: #575f31;
- --wpds-color-background-interactive-brand-weak-active: #e4f2a2;
- --wpds-color-background-surface-brand: #e9f8a7;
- --wpds-color-background-thumb-brand: #646c3e;
- --wpds-color-background-thumb-brand-active: #646c3e;
- --wpds-color-foreground-interactive-brand: #646c3e;
- --wpds-color-foreground-interactive-brand-active: #1c2100;
- --wpds-color-foreground-interactive-brand-strong: #eff2e3;
- --wpds-color-foreground-interactive-brand-strong-active: #eff2e3;
- --wpds-color-stroke-focus: #646c3e;
- --wpds-color-stroke-interactive-brand: #646c3e;
- --wpds-color-stroke-interactive-brand-active: #495023;
- --wpds-color-stroke-surface-brand: #b7c090;
- --wpds-color-stroke-surface-brand-strong: #646c3e;
- }
-
- body.admin-color-midnight {
- --wpds-color-background-interactive-brand-strong: #cf4339;
- --wpds-color-background-interactive-brand-strong-active: #bf332b;
- --wpds-color-background-interactive-brand-weak-active: #f8e5e2;
- --wpds-color-background-surface-brand: #fbecea;
- --wpds-color-background-thumb-brand: #c63a31;
- --wpds-color-background-thumb-brand-active: #c63a31;
- --wpds-color-foreground-interactive-brand: #c63a31;
- --wpds-color-foreground-interactive-brand-active: #470000;
- --wpds-color-foreground-interactive-brand-strong: #fefdfd;
- --wpds-color-foreground-interactive-brand-strong-active: #fefdfd;
- --wpds-color-stroke-focus: #c63a31;
- --wpds-color-stroke-interactive-brand: #c63a31;
- --wpds-color-stroke-interactive-brand-active: #a41111;
- --wpds-color-stroke-surface-brand: #e0bab4;
- --wpds-color-stroke-surface-brand-strong: #c63a31;
- }
-
body.admin-color-ocean {
--wpds-color-background-interactive-brand-strong: #567958;
--wpds-color-background-interactive-brand-strong-active: #496b4b;
@@ -132,6 +96,42 @@
--wpds-color-stroke-surface-brand-strong: #537655;
}
+ body.admin-color-blue {
+ --wpds-color-background-interactive-brand-strong: #437aa8;
+ --wpds-color-background-interactive-brand-strong-active: #356c99;
+ --wpds-color-background-interactive-brand-weak-active: #dfebf7;
+ --wpds-color-background-surface-brand: #e8f1fa;
+ --wpds-color-background-thumb-brand: #3b729f;
+ --wpds-color-background-thumb-brand-active: #3b729f;
+ --wpds-color-foreground-interactive-brand: #3b729f;
+ --wpds-color-foreground-interactive-brand-active: #001f46;
+ --wpds-color-foreground-interactive-brand-strong: #fff;
+ --wpds-color-foreground-interactive-brand-strong-active: #fff;
+ --wpds-color-stroke-focus: #3b729f;
+ --wpds-color-stroke-interactive-brand: #3b729f;
+ --wpds-color-stroke-interactive-brand-active: #1d5581;
+ --wpds-color-stroke-surface-brand: #afc6db;
+ --wpds-color-stroke-surface-brand-strong: #3b729f;
+ }
+
+ body.admin-color-ectoplasm {
+ --wpds-color-background-interactive-brand-strong: #646c3e;
+ --wpds-color-background-interactive-brand-strong-active: #575f31;
+ --wpds-color-background-interactive-brand-weak-active: #e4f2a2;
+ --wpds-color-background-surface-brand: #e9f8a7;
+ --wpds-color-background-thumb-brand: #646c3e;
+ --wpds-color-background-thumb-brand-active: #646c3e;
+ --wpds-color-foreground-interactive-brand: #646c3e;
+ --wpds-color-foreground-interactive-brand-active: #1c2100;
+ --wpds-color-foreground-interactive-brand-strong: #eff2e3;
+ --wpds-color-foreground-interactive-brand-strong-active: #eff2e3;
+ --wpds-color-stroke-focus: #646c3e;
+ --wpds-color-stroke-interactive-brand: #646c3e;
+ --wpds-color-stroke-interactive-brand-active: #495023;
+ --wpds-color-stroke-surface-brand: #b7c090;
+ --wpds-color-stroke-surface-brand-strong: #646c3e;
+ }
+
body.admin-color-sunrise {
--wpds-color-background-interactive-brand-strong: #ad631e;
--wpds-color-background-interactive-brand-strong-active: #9e5505;
diff --git a/packages/admin-ui/src/admin-theme-colors/index.ts b/packages/admin-ui/src/admin-theme-colors/index.ts
index d059e18a2a8090..178d27260a81ad 100644
--- a/packages/admin-ui/src/admin-theme-colors/index.ts
+++ b/packages/admin-ui/src/admin-theme-colors/index.ts
@@ -8,7 +8,11 @@ const DEFAULT_THEME_COLORS: AdminThemeColors = {
background: '#222524',
};
-const ADMIN_THEME_COLORS = new Map< string, AdminThemeColors >( [
+/*
+ * Exported from this module only, not from the package, so tooling in this
+ * repository can read the list of bundled schemes without duplicating it.
+ */
+export const ADMIN_THEME_COLORS = new Map< string, AdminThemeColors >( [
[ 'modern', DEFAULT_THEME_COLORS ],
[ 'fresh', { primary: '#007cba', background: '#25292b' } ],
[ 'midnight', { primary: '#cf4339', background: '#3d4042' } ],
diff --git a/tools/monorepo/wpds/generate-admin-scheme-tokens.mjs b/tools/monorepo/wpds/generate-admin-scheme-tokens.mjs
index a2bde5b758020d..9dc72912f6f381 100644
--- a/tools/monorepo/wpds/generate-admin-scheme-tokens.mjs
+++ b/tools/monorepo/wpds/generate-admin-scheme-tokens.mjs
@@ -30,7 +30,10 @@ import {
DEFAULT_SEED_COLORS,
} from '../../../packages/theme/src/color-ramps/index.ts';
import colorTokens from '../../../packages/theme/src/prebuilt/ts/color-tokens.ts';
-import { getAdminThemeColors } from '../../../packages/admin-ui/src/admin-theme-colors/index.ts';
+import {
+ ADMIN_THEME_COLORS,
+ getAdminThemeColors,
+} from '../../../packages/admin-ui/src/admin-theme-colors/index.ts';
const ROOT = join( dirname( fileURLToPath( import.meta.url ) ), '../../..' );
const OUTPUT = join(
@@ -38,28 +41,14 @@ const OUTPUT = join(
'lib/experimental/wpds-admin/css/05-scheme-tokens.css'
);
-// The colour schemes bundled with WordPress. `modern` is the default.
-const DEFAULT_SCHEME = 'modern';
-const SCHEMES = [
- 'modern',
- 'fresh',
- 'light',
- 'blue',
- 'coffee',
- 'ectoplasm',
- 'midnight',
- 'ocean',
- 'sunrise',
-];
-
/**
- * Returns a scheme's primary colour, as `getAdminThemeColors()` reports it.
+ * Returns the default scheme's primary colour, as `getAdminThemeColors()`
+ * resolves it when no scheme class is present.
*
- * @param {string} scheme Colour scheme name.
* @return {string} Primary colour.
*/
-function getSchemePrimary( scheme ) {
- globalThis.document = { body: { className: `admin-color-${ scheme }` } };
+function getDefaultPrimary() {
+ globalThis.document = { body: { className: '' } };
return getAdminThemeColors().primary;
}
@@ -94,26 +83,23 @@ function deriveTokens( primary ) {
return tokens;
}
-const defaultPrimary = getSchemePrimary( DEFAULT_SCHEME );
-const defaults = deriveTokens( defaultPrimary );
+const defaults = deriveTokens( getDefaultPrimary() );
const rules = new Map();
-for ( const scheme of SCHEMES.filter( ( name ) => name !== DEFAULT_SCHEME ) ) {
- const primary = getSchemePrimary( scheme );
-
- // An unknown scheme silently falls back to the default; fail instead.
- if ( primary === defaultPrimary ) {
- throw new Error(
- `No colours found for the "${ scheme }" admin colour scheme.`
- );
- }
-
+// Every scheme `@wordpress/admin-ui` knows about, so an added scheme is
+// picked up without editing this script.
+for ( const [ scheme, { primary } ] of ADMIN_THEME_COLORS ) {
const declarations = [ ...deriveTokens( primary ) ]
.filter( ( [ name, value ] ) => defaults.get( name ) !== value )
.sort( ( [ a ], [ b ] ) => a.localeCompare( b ) )
.map( ( [ name, value ] ) => `\t\t${ name }: ${ value };` )
.join( '\n' );
+ // The default scheme, or any scheme deriving identical tokens.
+ if ( ! declarations ) {
+ continue;
+ }
+
const selectors = rules.get( declarations ) ?? [];
selectors.push( `\tbody.admin-color-${ scheme }` );
rules.set( declarations, selectors );
@@ -155,5 +141,5 @@ ${ [ ...rules ]
writeFileSync( OUTPUT, css );
console.log(
- `Wrote ${ rules.size } rules for ${ SCHEMES.length - 1 } schemes.`
+ `Wrote ${ rules.size } rules for ${ ADMIN_THEME_COLORS.size } schemes.`
);
From d26d84ec843bcea126b910002507604ea439f852 Mon Sep 17 00:00:00 2001
From: "Joen A."
Date: Thu, 17 Sep 2026 13:38:09 +0200
Subject: [PATCH 20/26] Declare the scheme token generator's package
dependencies
The generator imports source from `@wordpress/theme` and
`@wordpress/admin-ui`, but the monorepo tools workspace declared neither,
so dependency checks could not see the coupling. Both are now
devDependencies using the `file:` pattern the workspace already uses for
`@wordpress/data`. The imports stay relative, since they reach files the
packages do not export.
Co-Authored-By: Claude Opus 5
---
package-lock.json | 2 ++
tools/monorepo/package.json | 2 ++
2 files changed, 4 insertions(+)
diff --git a/package-lock.json b/package-lock.json
index c5d0988866b394..c2284518be5bac 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -47877,7 +47877,9 @@
"@testing-library/jest-dom": "^7.0.1",
"@types/node": "^24.13.3",
"@types/react": "^18.3.27",
+ "@wordpress/admin-ui": "file:../../packages/admin-ui",
"@wordpress/data": "file:../../packages/data",
+ "@wordpress/theme": "file:../../packages/theme",
"vitest": "^5.0.0"
}
},
diff --git a/tools/monorepo/package.json b/tools/monorepo/package.json
index 6cad3aff55eae0..88ea7d983df5c9 100644
--- a/tools/monorepo/package.json
+++ b/tools/monorepo/package.json
@@ -29,7 +29,9 @@
"@testing-library/jest-dom": "^7.0.1",
"@types/node": "^24.13.3",
"@types/react": "^18.3.27",
+ "@wordpress/admin-ui": "file:../../packages/admin-ui",
"@wordpress/data": "file:../../packages/data",
+ "@wordpress/theme": "file:../../packages/theme",
"vitest": "^5.0.0"
},
"publishConfig": {
From 02ae43acfe68e25d9b1758044cdb2ccde6c4ebb5 Mon Sep 17 00:00:00 2001
From: "Joen A."
Date: Thu, 17 Sep 2026 13:39:21 +0200
Subject: [PATCH 21/26] Move the experiment's stylelint rules into the shared
config
The rule banning `!important` in the experiment's stylesheets, and its
one exception for `99-overrides.css`, lived in the repository-root
`.stylelintrc.mjs`, apart from every other override. When the experiment
is removed their globs would match nothing and nothing would flag them as
left behind.
Both now sit in `tools/stylelint/config.js` with the other overrides,
each marked with when it can be deleted, and the root config is back to
extending the shared one only.
Co-Authored-By: Claude Opus 5
---
.stylelintrc.mjs | 35 -----------------------------------
tools/stylelint/config.js | 32 ++++++++++++++++++++++++++++++++
2 files changed, 32 insertions(+), 35 deletions(-)
diff --git a/.stylelintrc.mjs b/.stylelintrc.mjs
index e5024e293f3b8e..603f6e8f6f9945 100644
--- a/.stylelintrc.mjs
+++ b/.stylelintrc.mjs
@@ -4,39 +4,4 @@ const require = createRequire( import.meta.url );
export default {
extends: require.resolve( '@wordpress/stylelint-tools/config' ),
- overrides: [
- {
- /*
- * The admin design tokens experiment ships inside a cascade layer, and layer
- * priority REVERSES for `!important`: a layered `!important` beats a
- * third-party plugin's *unlayered* `!important`, even when the
- * plugin's stylesheet loads later. Allowing one here would silently
- * break overrides the ecosystem has relied on for years, which is
- * exactly the back-compat guarantee this work depends on.
- *
- * Normal declarations are safe — unlayered plugin CSS still wins.
- */
- files: [ 'lib/experimental/wpds-admin/**/*.css' ],
- rules: {
- 'declaration-no-important': true,
- },
- },
- {
- /*
- * The single exception. `overrides.css` exists only to cancel
- * `!important` declarations in WordPress's admin CSS, which survive being
- * demoted into a lower layer because importance is compared before
- * layer order. It sits in a layer declared BEFORE the legacy one,
- * since importance reverses layer order.
- *
- * Every rule in that file must be cancelling a specific WordPress
- * `!important` and must record what would let us delete it. Do not
- * widen this exception to any other file.
- */
- files: [ 'lib/experimental/wpds-admin/css/99-overrides.css' ],
- rules: {
- 'declaration-no-important': null,
- },
- },
- ],
};
diff --git a/tools/stylelint/config.js b/tools/stylelint/config.js
index b31ff0e158b259..10f8bcacf586ae 100644
--- a/tools/stylelint/config.js
+++ b/tools/stylelint/config.js
@@ -176,6 +176,38 @@ module.exports = {
'no-duplicate-selectors': true,
},
},
+ {
+ /*
+ * The admin design tokens experiment ships inside a cascade layer, and
+ * layer priority REVERSES for `!important`: a layered `!important`
+ * beats a third-party plugin's *unlayered* `!important`, even when the
+ * plugin's stylesheet loads later. Allowing one would silently break
+ * overrides the ecosystem has relied on for years. Normal declarations
+ * are safe, since unlayered plugin CSS still wins.
+ *
+ * DELETE WHEN: `lib/experimental/wpds-admin/` is removed.
+ */
+ files: [ 'lib/experimental/wpds-admin/**/*.css' ],
+ rules: {
+ 'declaration-no-important': true,
+ },
+ },
+ {
+ /*
+ * The single exception. `99-overrides.css` exists only to cancel
+ * `!important` declarations in WordPress's admin CSS, which survive
+ * being demoted into a lower layer because importance is compared
+ * before layer order. Every rule in that file must cancel a specific
+ * WordPress `!important` and record what would let it be deleted. Do
+ * not widen this exception to any other file.
+ *
+ * DELETE WHEN: `lib/experimental/wpds-admin/` is removed.
+ */
+ files: [ 'lib/experimental/wpds-admin/css/99-overrides.css' ],
+ rules: {
+ 'declaration-no-important': null,
+ },
+ },
],
reportDescriptionlessDisables: true,
};
From 31bbb3a69dab96a2837d213c8659446f9dc20bb2 Mon Sep 17 00:00:00 2001
From: "Joen A."
Date: Thu, 17 Sep 2026 13:48:54 +0200
Subject: [PATCH 22/26] Add a changelog entry for the admin-ui scheme map
export
Co-Authored-By: Claude Opus 5
---
packages/admin-ui/CHANGELOG.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/packages/admin-ui/CHANGELOG.md b/packages/admin-ui/CHANGELOG.md
index 269e35e9c65e18..96f8f2f67d0bd6 100644
--- a/packages/admin-ui/CHANGELOG.md
+++ b/packages/admin-ui/CHANGELOG.md
@@ -6,6 +6,10 @@
- Declare `react-dom` and `@types/react-dom` as peer dependencies, forwarding the peers of `@wordpress/element`, so strict package managers such as Yarn PnP can resolve them ([#83765](https://github.com/WordPress/gutenberg/pull/83765)).
+### Internal
+
+- Export `ADMIN_THEME_COLORS` from its own module, without adding it to the package's exports, so tooling in the repository can read the bundled admin colour schemes instead of copying them ([#82425](https://github.com/WordPress/gutenberg/pull/82425)).
+
## 2.11.0 (2026-09-23)
## 2.10.0 (2026-09-10)
From 79dc2f898add706432f728bb8809709802de1d00 Mon Sep 17 00:00:00 2001
From: "Joen A."
Date: Tue, 29 Sep 2026 09:27:25 +0200
Subject: [PATCH 23/26] Address feedback
---
lib/experimental/wpds-admin/css/10-button.css | 32 +++++++++++++++++--
lib/experimental/wpds-admin/css/20-input.css | 17 +++++++++-
2 files changed, 46 insertions(+), 3 deletions(-)
diff --git a/lib/experimental/wpds-admin/css/10-button.css b/lib/experimental/wpds-admin/css/10-button.css
index 72f02a8c7e6966..eca614515959ee 100644
--- a/lib/experimental/wpds-admin/css/10-button.css
+++ b/lib/experimental/wpds-admin/css/10-button.css
@@ -160,10 +160,17 @@
background: var(--wpds-color-background-interactive-brand-weak-active);
}
- /* Hide the border while pressed, keeping its width so nothing shifts. */
+ /*
+ * Pressed takes the same border as hover. `@wordpress/ui` Button drives
+ * hover, active and focus from one `border-color-active` custom property,
+ * and the persistent `.active` rule below already uses that value, so an
+ * earlier `border-color: transparent` here was the only state in this file
+ * that dropped the edge — the button appeared to lose its outline for as
+ * long as the pointer was down.
+ */
.wp-core-ui .button:active,
.wp-core-ui .button-secondary:active {
- border-color: transparent;
+ border-color: var(--wpds-color-stroke-interactive-brand-active);
background: var(--wpds-color-background-interactive-brand-weak-active);
}
@@ -178,6 +185,27 @@
background: var(--wpds-color-background-interactive-brand-weak-active);
}
+ /* -----------------------------------------------------------------------
+ The page title action — appearance only
+
+ "Add Post" and its siblings are `` with no
+ `button` class, so none of the rules above reach them. They still look
+ like our buttons, because WordPress draws them with
+ `var(--wp-admin-theme-color)` and the scheme tokens now feed that — but
+ the weight stays WordPress's 500 while every real button is at the emphasis
+ token, which is the difference you see side by side in a list table header.
+
+ Only the weight is taken. WordPress gives this control `min-height: 40px`,
+ `line-height: 2.71428571` and `top: -3px` to sit it against the page
+ title, and adopting it properly means taking on that geometry — which is
+ the contextual sizing this experiment leaves alone on purpose. Covering
+ the control fully is a separate question; matching the weight is not.
+ ----------------------------------------------------------------------- */
+
+ .wrap .page-title-action {
+ font-weight: var(--wpds-typography-font-weight-emphasis);
+ }
+
/* -----------------------------------------------------------------------
Primary — `@wordpress/ui` Button, brand tone, solid. The border takes
the background colour, as it does there.
diff --git a/lib/experimental/wpds-admin/css/20-input.css b/lib/experimental/wpds-admin/css/20-input.css
index bbe76753a49e86..2294e375847f7e 100644
--- a/lib/experimental/wpds-admin/css/20-input.css
+++ b/lib/experimental/wpds-admin/css/20-input.css
@@ -55,7 +55,22 @@
* know what those contexts are. WordPress also handles the mobile case, raising
* inputs to 16px below 782px so iOS does not zoom on focus.
*/
- line-height: 1;
+
+ /*
+ * `normal`, not `1`. The `@wordpress/ui` input primitive uses `line-height: 1`,
+ * but it sets it on a wrapper that lays out a separate inner ``, where
+ * the text has room regardless. wp-admin has no wrapper: the declaration lands
+ * on the bare control, and a line box exactly as tall as the font is shorter
+ * than the font's own ascender-plus-descender, so descenders are clipped by the
+ * control's chrome — visibly on `