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 '
'; + echo '

' . esc_html__( 'WPDS Parity — classic buttons', 'gutenberg' ) . '

'; + 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' ) . '

'; + echo ''; + foreach ( array_keys( $variants ) as $variant_label ) { + echo ''; + } + echo ''; + + foreach ( gutenberg_wpds_admin_harness_states() as $state_label => $state ) { + echo ''; + foreach ( $variants as $classes ) { + list( $extra_class, $extra_attr ) = array_pad( explode( '|', $state, 2 ), 2, '' ); + printf( + '', + esc_attr( $classes ), + esc_attr( $extra_class ), + // Attribute strings are from the fixed map above, not user input. + $extra_attr, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + esc_html__( 'Button', 'gutenberg' ) + ); + } + echo ''; + } + echo '
' . esc_html__( 'State', 'gutenberg' ) . '' . esc_html( $variant_label ) . '
' . esc_html( $state_label ) . '
'; + + echo '

' . esc_html__( 'Sizes', 'gutenberg' ) . '

'; + echo '

'; + foreach ( $sizes as $size_label => $size_class ) { + printf( + '', + esc_attr( $size_class ), + esc_html( $size_label ) + ); + } + echo '

'; + + echo '

' . esc_html__( 'Fields — states', 'gutenberg' ) . '

'; + echo ''; + + $field_states = array( + 'default' => '', + '.disabled' => ' class="disabled"', + 'disabled attribute' => ' disabled', + 'aria-disabled="true"' => ' aria-disabled="true"', + 'readonly' => ' readonly', + ); + + foreach ( $field_states as $state_label => $attrs ) { + echo ''; + // Attribute strings come from the fixed map above, not from user input. + printf( + '', + esc_attr__( 'Value', 'gutenberg' ), + $attrs // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + ); + printf( + '', + $attrs, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + esc_html__( 'Option', 'gutenberg' ) + ); + printf( + '', + $attrs, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + esc_textarea( __( 'Value', 'gutenberg' ) ) + ); + echo ''; + } + echo '
' . esc_html__( 'State', 'gutenberg' ) . '' . esc_html__( 'Text input', 'gutenberg' ) . '' . esc_html__( 'Select', 'gutenberg' ) . '' . esc_html__( 'Textarea', 'gutenberg' ) . '
' . esc_html( $state_label ) . '
'; + + echo '

' . esc_html__( 'Fields — types and placeholder', 'gutenberg' ) . '

'; + echo '

'; + foreach ( array( 'text', 'search', 'email', 'url', 'number', 'date', 'password' ) as $type ) { + printf( + '', + esc_attr( $type ) + ); + } + echo '

'; + + echo '

' . esc_html__( 'Fields — invalid', 'gutenberg' ) . '

'; + 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 '

'; + + echo '

' . esc_html__( 'Checkboxes and radios', 'gutenberg' ) . '

'; + echo ''; + + $toggle_states = array( + 'default' => '', + '.disabled' => ' class="disabled"', + 'disabled attribute' => ' disabled', + 'aria-disabled="true"' => ' aria-disabled="true"', + ); + + $row = 0; + foreach ( $toggle_states as $state_label => $attrs ) { + ++$row; + echo ''; + foreach ( array( 'checkbox', 'radio' ) as $type ) { + foreach ( array( '', ' checked' ) as $checked ) { + printf( + '', + esc_attr( $type ), + esc_attr( $type ), + (int) $row, + // Both come from the fixed maps above, not from user input. + $checked, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + $attrs // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + ); + } + } + echo ''; + } + echo '
' . esc_html__( 'State', 'gutenberg' ) . '' . esc_html__( 'Checkbox', 'gutenberg' ) . '' . esc_html__( 'Checkbox, checked', 'gutenberg' ) . '' . esc_html__( 'Radio', 'gutenberg' ) . '' . esc_html__( 'Radio, checked', 'gutenberg' ) . '
' . esc_html( $state_label ) . '
'; + + echo '

' . esc_html__( 'Ecosystem override check', 'gutenberg' ) . '

'; + echo '

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

'; echo '

' . esc_html__( 'Fields — invalid', 'gutenberg' ) . '

'; - 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 '
'; - echo '

' . esc_html__( 'WPDS Parity — classic buttons', 'gutenberg' ) . '

'; - 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__( 'WPDS Parity — admin controls', 'gutenberg' ) . '

'; + 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' ) . '

'; echo '

' . esc_html__( 'Variants and states', 'gutenberg' ) . '

'; echo ''; 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 '

' . esc_html__( 'Button groups', 'gutenberg' ) . '

'; + echo '

' . esc_html__( 'Grouped buttons join into one control: square inner corners, and borders that overlap rather than double.', 'gutenberg' ) . '

'; + $groups = array( + array( '', array( 'button', 'button', 'button' ) ), + array( '', array( 'button button-primary', 'button', 'button' ) ), + array( ' button-compact', array( 'button', 'button', 'button' ) ), + ); + echo '

'; + foreach ( $groups as $group ) { + list( $group_class, $buttons ) = $group; + printf( '', esc_attr( $group_class ) ); + foreach ( $buttons as $button_class ) { + printf( + '', + esc_attr( $button_class ), + esc_html__( 'Button', 'gutenberg' ) + ); + } + echo ''; + } + echo '

'; + echo '

' . esc_html__( 'Fields — states', 'gutenberg' ) . '

'; echo '
' . esc_html__( 'State', 'gutenberg' ) . '
'; 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 '
'; + echo '
'; echo '

' . esc_html__( 'WPDS Parity — admin controls', 'gutenberg' ) . '

'; 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' ) . '

'; @@ -228,4 +228,43 @@ function gutenberg_wpds_admin_render_harness() { echo '

'; echo '
'; + + /* + * `aria-disabled` controls stay focusable, which is the point of the + * attribute, but nothing else about them is inert: browsers still let the + * specimens be typed in, toggled and opened. On a real screen the markup's + * owner prevents that. Here it would let a specimen drift out of the state + * it is labelled with, so pointer and keyboard activation are blocked while + * Tab still moves focus and the selectors stay intact for comparison. + */ + wp_print_inline_script_tag( + "( function () { + var wrap = document.getElementById( 'wpds-parity' ); + if ( ! wrap ) { + return; + } + var isInert = function ( target ) { + return target.closest && target.closest( '[aria-disabled=\"true\"]' ); + }; + var block = function ( event ) { + if ( isInert( event.target ) ) { + event.preventDefault(); + } + }; + [ 'click', 'beforeinput', 'paste', 'drop' ].forEach( function ( type ) { + wrap.addEventListener( type, block, true ); + } ); + // Only selects: blocking mousedown elsewhere would also stop a click from focusing the field. + wrap.addEventListener( 'mousedown', function ( event ) { + if ( 'SELECT' === event.target.tagName ) { + block( event ); + } + }, true ); + wrap.addEventListener( 'keydown', function ( event ) { + if ( 'Tab' !== event.key && isInert( event.target ) ) { + event.preventDefault(); + } + }, true ); + } )();" + ); } From 071f5cab6d16c5150e2f3da67030b68e3ae1c5e1 Mon Sep 17 00:00:00 2001 From: "Joen A." Date: Wed, 16 Sep 2026 11:18:41 +0200 Subject: [PATCH 13/26] Update @since tags to 24.1.0 Co-Authored-By: Claude Opus 5 --- lib/experimental/wpds-admin/harness.php | 6 +++--- lib/experimental/wpds-admin/load.php | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/experimental/wpds-admin/harness.php b/lib/experimental/wpds-admin/harness.php index c2d25f95f1fd70..14ec0012811793 100644 --- a/lib/experimental/wpds-admin/harness.php +++ b/lib/experimental/wpds-admin/harness.php @@ -22,7 +22,7 @@ /** * Registers the parity harness page. * - * @since 23.9.0 + * @since 24.1.0 */ function gutenberg_wpds_admin_register_harness() { add_submenu_page( @@ -46,7 +46,7 @@ function gutenberg_wpds_admin_register_harness() { * * Note `.active` is a PERSISTENT selected state, not `:active`. * - * @since 23.9.0 + * @since 24.1.0 * * @return array Map of label to extra markup for the button tag. */ @@ -65,7 +65,7 @@ function gutenberg_wpds_admin_harness_states() { /** * Renders the harness page. * - * @since 23.9.0 + * @since 24.1.0 */ function gutenberg_wpds_admin_render_harness() { $variants = array( diff --git a/lib/experimental/wpds-admin/load.php b/lib/experimental/wpds-admin/load.php index 27b76996c6c070..3c23983d07bc8c 100644 --- a/lib/experimental/wpds-admin/load.php +++ b/lib/experimental/wpds-admin/load.php @@ -57,7 +57,7 @@ * behaviour change for the ecosystem, and the opposite of the small, explicit * blast radius this experiment is built around. * - * @since 23.9.0 + * @since 24.1.0 * * @global bool $concatenate_scripts */ @@ -76,7 +76,7 @@ function gutenberg_wpds_admin_disable_concatenation() { * stylesheets alike — repeats this same declaration. They all agree, so * whichever lands first sets the order correctly. * - * @since 23.9.0 + * @since 24.1.0 */ const GUTENBERG_WPDS_ADMIN_LAYER_ORDER = '@layer wpds-overrides, wp-legacy, wpds;'; @@ -121,7 +121,7 @@ function gutenberg_wpds_admin_disable_concatenation() { * 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 + * @since 24.1.0 * * @return string[] Array of registered style handles. */ @@ -157,7 +157,7 @@ function gutenberg_wpds_admin_demoted_handles() { * decode HTML entities, so a URL carrying `&ver=` would break. Use the raw * escaper and then guard the CSS string context and the containing element. * - * @since 23.9.0 + * @since 24.1.0 * * @param string $href URL to escape. * @return string Escaped URL, safe inside a double-quoted CSS string. @@ -176,7 +176,7 @@ function gutenberg_wpds_admin_escape_css_url( $href ) { * Fires for the RTL variant as a second call with the same handle, so both * directions are demoted without any extra handling. * - * @since 23.9.0 + * @since 24.1.0 * * @param string $tag The `` tag for the enqueued style. * @param string $handle The style's registered handle. @@ -216,7 +216,7 @@ function gutenberg_wpds_admin_demote_style( $tag, $handle, $href, $media ) { * `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 + * @since 24.1.0 */ function gutenberg_wpds_admin_enqueue_styles() { $version = defined( 'GUTENBERG_VERSION' ) && ! SCRIPT_DEBUG ? GUTENBERG_VERSION : time(); From 3bfc605c6a837fe9e8cd74b1b7c6306866e862ac Mon Sep 17 00:00:00 2001 From: "Joen A." Date: Wed, 16 Sep 2026 13:43:17 +0200 Subject: [PATCH 14/26] Derive design tokens for each admin colour scheme React admin screens read the colour scheme from the `admin-color-*` body class and pass its primary colour to `ThemeProvider`, which derives the `--wpds-color-*` tokens at runtime. PHP-rendered screens have no provider, so every token kept the default scheme's value: focus rings stayed blue in every scheme, and any control pairing a scheme colour with a token chose its text for the wrong background. A script now runs the same derivation at build time and writes the result as CSS scoped to each scheme's body class. Scheme colours come from `@wordpress/admin-ui` and the ramps from `@wordpress/theme`, so nothing is copied. Only the 15 brand tokens that differ from the default are written; the default scheme's derived values match the prebuilt token stylesheet exactly. Setting `--wpds-*` properties is otherwise reserved for the theme package, so the generated file disables that lint rule and says why. Co-Authored-By: Claude Opus 5 --- bin/generate-wpds-admin-scheme-tokens.mjs | 152 ++++++++++++++++++ .../wpds-admin/css/05-scheme-tokens.css | 149 +++++++++++++++++ 2 files changed, 301 insertions(+) create mode 100644 bin/generate-wpds-admin-scheme-tokens.mjs create mode 100644 lib/experimental/wpds-admin/css/05-scheme-tokens.css diff --git a/bin/generate-wpds-admin-scheme-tokens.mjs b/bin/generate-wpds-admin-scheme-tokens.mjs new file mode 100644 index 00000000000000..e5d133c51497a6 --- /dev/null +++ b/bin/generate-wpds-admin-scheme-tokens.mjs @@ -0,0 +1,152 @@ +#!/usr/bin/env node +/** + * Generates the per-colour-scheme design token stylesheet for the "Admin + * design tokens" experiment. + * + * React admin screens read the active colour scheme from the `admin-color-*` + * body class and hand its primary colour to `ThemeProvider`, which derives + * the `--wpds-color-*` tokens at runtime. PHP-rendered screens have no + * provider, so every token keeps the default scheme's value. This script runs + * the same derivation at build time instead, and writes the result as CSS + * scoped to each scheme's body class. + * + * Nothing is duplicated: scheme colours come from `@wordpress/admin-ui` and + * 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 + */ + +import { writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +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'; + +const ROOT = join( dirname( fileURLToPath( import.meta.url ) ), '..' ); +const OUTPUT = join( + ROOT, + '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. + * + * @param {string} scheme Colour scheme name. + * @return {string} Primary colour. + */ +function getSchemePrimary( scheme ) { + globalThis.document = { body: { className: `admin-color-${ scheme }` } }; + return getAdminThemeColors().primary; +} + +/** + * Derives every colour token for a primary colour, the way `ThemeProvider` + * does, with the default background. + * + * @param {string} primary Primary colour. + * @return {Map} Token name to value. + */ +function deriveTokens( primary ) { + const seeds = { ...DEFAULT_SEED_COLORS, primary }; + const bgRamp = buildBgRamp( seeds.background ); + const tokens = new Map(); + + for ( const [ rampName, seed ] of Object.entries( seeds ) ) { + const { ramp } = + rampName === 'background' + ? bgRamp + : buildAccentRamp( seed, bgRamp ); + const primitive = rampName === 'background' ? 'bg' : rampName; + + for ( const [ step, value ] of Object.entries( ramp ) ) { + for ( const id of colorTokens[ `${ primitive }-${ step }` ] ?? + [] ) { + // eslint-disable-next-line @wordpress/no-unknown-ds-tokens -- Names come from the theme's own generated alias map. + tokens.set( `--wpds-color-${ id }`, String( value ) ); + } + } + } + + return tokens; +} + +const defaultPrimary = getSchemePrimary( DEFAULT_SCHEME ); +const defaults = deriveTokens( defaultPrimary ); +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.` + ); + } + + 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' ); + + const selectors = rules.get( declarations ) ?? []; + selectors.push( `\tbody.admin-color-${ scheme }` ); + rules.set( declarations, selectors ); +} + +const css = `/** + * Design tokens for each bundled admin colour scheme. + * + * GENERATED FILE. Do not edit. Regenerate with: + * + * node bin/generate-wpds-admin-scheme-tokens.mjs + * + * React admin screens derive these values at runtime through ThemeProvider. + * PHP-rendered screens have no provider, so without this file every token + * would keep the default scheme's value, and scheme-coloured controls would + * 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. + */ + +/* stylelint-disable plugin-wpds/no-setting-wpds-custom-properties -- Generated equivalent of ThemeProvider's runtime output. */ + +@layer wpds-overrides, wp-legacy, wpds; + +@layer wpds { +${ [ ...rules ] + .map( + ( [ declarations, selectors ] ) => + `${ selectors.join( ',\n' ) } {\n${ declarations }\n\t}` + ) + .join( '\n\n' ) } +} +`; + +writeFileSync( OUTPUT, css ); +console.log( + `Wrote ${ rules.size } rules for ${ SCHEMES.length - 1 } schemes.` +); diff --git a/lib/experimental/wpds-admin/css/05-scheme-tokens.css b/lib/experimental/wpds-admin/css/05-scheme-tokens.css new file mode 100644 index 00000000000000..793c695f33a60d --- /dev/null +++ b/lib/experimental/wpds-admin/css/05-scheme-tokens.css @@ -0,0 +1,149 @@ +/** + * Design tokens for each bundled admin colour scheme. + * + * GENERATED FILE. Do not edit. Regenerate with: + * + * node bin/generate-wpds-admin-scheme-tokens.mjs + * + * React admin screens derive these values at runtime through ThemeProvider. + * PHP-rendered screens have no provider, so without this file every token + * would keep the default scheme's value, and scheme-coloured controls would + * 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. + */ + +/* stylelint-disable plugin-wpds/no-setting-wpds-custom-properties -- Generated equivalent of ThemeProvider's runtime output. */ + +@layer wpds-overrides, wp-legacy, wpds; + +@layer wpds { + body.admin-color-fresh, + body.admin-color-light { + --wpds-color-background-interactive-brand-strong: #007cba; + --wpds-color-background-interactive-brand-strong-active: #006daa; + --wpds-color-background-interactive-brand-weak-active: #daecfb; + --wpds-color-background-surface-brand: #e4f2fd; + --wpds-color-background-thumb-brand: #0073b0; + --wpds-color-background-thumb-brand-active: #0073b0; + --wpds-color-foreground-interactive-brand: #0073b0; + --wpds-color-foreground-interactive-brand-active: #002041; + --wpds-color-foreground-interactive-brand-strong: #fff; + --wpds-color-foreground-interactive-brand-strong-active: #fff; + --wpds-color-stroke-focus: #0073b0; + --wpds-color-stroke-interactive-brand: #0073b0; + --wpds-color-stroke-interactive-brand-active: #00558f; + --wpds-color-stroke-surface-brand: #a6c7e1; + --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-coffee { + --wpds-color-background-interactive-brand-strong: #916745; + --wpds-color-background-interactive-brand-strong-active: #835a38; + --wpds-color-background-interactive-brand-weak-active: #f8e6d9; + --wpds-color-background-surface-brand: #fbede2; + --wpds-color-background-thumb-brand: #8e6442; + --wpds-color-background-thumb-brand-active: #8e6442; + --wpds-color-foreground-interactive-brand: #8e6442; + --wpds-color-foreground-interactive-brand-active: #361300; + --wpds-color-foreground-interactive-brand-strong: #f7f5f4; + --wpds-color-foreground-interactive-brand-strong-active: #f7f5f4; + --wpds-color-stroke-focus: #8e6442; + --wpds-color-stroke-interactive-brand: #8e6442; + --wpds-color-stroke-interactive-brand-active: #704827; + --wpds-color-stroke-surface-brand: #dcbda4; + --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; + --wpds-color-background-interactive-brand-weak-active: #d0f4d2; + --wpds-color-background-surface-brand: #ddf7de; + --wpds-color-background-thumb-brand: #537655; + --wpds-color-background-thumb-brand-active: #537655; + --wpds-color-foreground-interactive-brand: #537655; + --wpds-color-foreground-interactive-brand-active: #042609; + --wpds-color-foreground-interactive-brand-strong: #f5f8f5; + --wpds-color-foreground-interactive-brand-strong-active: #f5f8f5; + --wpds-color-stroke-focus: #537655; + --wpds-color-stroke-interactive-brand: #537655; + --wpds-color-stroke-interactive-brand-active: #385a3b; + --wpds-color-stroke-surface-brand: #a2cfa4; + --wpds-color-stroke-surface-brand-strong: #537655; + } + + body.admin-color-sunrise { + --wpds-color-background-interactive-brand-strong: #ad631e; + --wpds-color-background-interactive-brand-strong-active: #9e5505; + --wpds-color-background-interactive-brand-weak-active: #fbe5d5; + --wpds-color-background-surface-brand: #feecdf; + --wpds-color-background-thumb-brand: #a35a10; + --wpds-color-background-thumb-brand-active: #a35a10; + --wpds-color-foreground-interactive-brand: #a35a10; + --wpds-color-foreground-interactive-brand-active: #371100; + --wpds-color-foreground-interactive-brand-strong: #fff; + --wpds-color-foreground-interactive-brand-strong-active: #fff; + --wpds-color-stroke-focus: #a35a10; + --wpds-color-stroke-interactive-brand: #a35a10; + --wpds-color-stroke-interactive-brand-active: #843d00; + --wpds-color-stroke-surface-brand: #e3ba9c; + --wpds-color-stroke-surface-brand-strong: #a35a10; + } +} From c417b958048564a8b9ea7411deac16b68eb0917f Mon Sep 17 00:00:00 2001 From: "Joen A." Date: Wed, 16 Sep 2026 13:44:41 +0200 Subject: [PATCH 15/26] Pair button and checkbox colours as the design system does Buttons and checked controls took their background from the scheme's `--wp-admin-theme-color` but their text from a design system token derived for the default blue. That mixed pair fell below 4.5:1 in six of the eight non-default colour schemes, down to 4.01:1. They now use the matched background and foreground pairs `@wordpress/ui` uses for each state: the brand outline variant for `.button` and `.button-secondary`, the brand solid variant for `.button-primary`, and the brand strong pair for checked checkboxes and radios. With the scheme tokens in place the pairs follow the colour scheme, and every scheme measures at least 4.57:1 for primary button text. Disabled buttons follow the design system too: a grey surface with muted text, rather than the scheme colour at reduced opacity. Co-Authored-By: Claude Opus 5 --- lib/experimental/wpds-admin/css/10-button.css | 65 ++++++++++--------- .../wpds-admin/css/30-checkbox-radio.css | 21 ++---- .../wpds-admin/css/99-overrides.css | 26 ++++---- 3 files changed, 50 insertions(+), 62 deletions(-) diff --git a/lib/experimental/wpds-admin/css/10-button.css b/lib/experimental/wpds-admin/css/10-button.css index 7967a443147206..20d2e1e1c1349f 100644 --- a/lib/experimental/wpds-admin/css/10-button.css +++ b/lib/experimental/wpds-admin/css/10-button.css @@ -17,8 +17,11 @@ * 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. + * Colours come in the design system's matched background and foreground + * pairs, state by state, exactly as `@wordpress/ui` Button uses them. Never mix + * a pair's background with another pair's foreground: the pairs are what + * guarantee contrast. They follow the user's colour scheme through + * 05-scheme-tokens.css. */ @layer wpds-overrides, wp-legacy, wpds; @@ -131,14 +134,14 @@ } /* ----------------------------------------------------------------------- - Default and secondary — maps to the React `is-secondary` variant + Default and secondary — `@wordpress/ui` Button, brand tone, outline ----------------------------------------------------------------------- */ .wp-core-ui .button, .wp-core-ui .button-secondary { - border-color: var(--wp-admin-theme-color); - color: var(--wp-admin-theme-color); - background: transparent; + border-color: var(--wpds-color-stroke-interactive-brand); + color: var(--wpds-color-foreground-interactive-brand); + background: var(--wpds-color-background-interactive-brand-weak); } @media not (prefers-reduced-motion) { @@ -152,16 +155,16 @@ .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); + border-color: var(--wpds-color-stroke-interactive-brand-active); + color: var(--wpds-color-foreground-interactive-brand-active); + background: var(--wpds-color-background-interactive-brand-weak-active); } /* 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); + background: var(--wpds-color-background-interactive-brand-weak-active); } /* @@ -170,37 +173,35 @@ */ .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); + border-color: var(--wpds-color-stroke-interactive-brand-active); + color: var(--wpds-color-foreground-interactive-brand-active); + background: var(--wpds-color-background-interactive-brand-weak-active); } /* ----------------------------------------------------------------------- - Primary — maps to the React `is-primary` variant + Primary — `@wordpress/ui` Button, brand tone, solid. The border takes + the background colour, as it does there. ----------------------------------------------------------------------- */ .wp-core-ui .button-primary { - border-color: transparent; - background: var(--wp-admin-theme-color); + border-color: var(--wpds-color-background-interactive-brand-strong); + background: var(--wpds-color-background-interactive-brand-strong); 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.hover, .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); + border-color: var(--wpds-color-background-interactive-brand-strong-active); + background: var(--wpds-color-background-interactive-brand-strong-active); + color: var(--wpds-color-foreground-interactive-brand-strong-active); } /* ----------------------------------------------------------------------- - Disabled — classic drives this from classes as well as attributes + Disabled — classic 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. ----------------------------------------------------------------------- */ .wp-core-ui .button:disabled, @@ -212,9 +213,9 @@ .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; + border-color: var(--wpds-color-stroke-interactive-brand-disabled); + color: var(--wpds-color-foreground-interactive-brand-disabled); + background: var(--wpds-color-background-interactive-brand-weak-disabled); transform: none; } @@ -223,9 +224,9 @@ .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); + border-color: var(--wpds-color-background-interactive-brand-strong-disabled); + background: var(--wpds-color-background-interactive-brand-strong-disabled); + color: var(--wpds-color-foreground-interactive-brand-strong-disabled); } /* Disabled buttons keep their focus ring when focused. */ diff --git a/lib/experimental/wpds-admin/css/30-checkbox-radio.css b/lib/experimental/wpds-admin/css/30-checkbox-radio.css index a630c8626c64da..b1f27ca576efb9 100644 --- a/lib/experimental/wpds-admin/css/30-checkbox-radio.css +++ b/lib/experimental/wpds-admin/css/30-checkbox-radio.css @@ -53,27 +53,14 @@ } /* ----------------------------------------------------------------------- - 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. + Checked — the brand strong pair, as the `@wordpress/ui` checkbox uses it. + The tick and dot take the pair's foreground through `currentColor`. ----------------------------------------------------------------------- */ input[type="checkbox"]:checked, input[type="radio"]:checked { - border-color: var(--wp-admin-theme-color); - background: var(--wp-admin-theme-color); + border-color: transparent; + background: var(--wpds-color-background-interactive-brand-strong); } /* The tick, masked so its colour follows `currentColor`. */ diff --git a/lib/experimental/wpds-admin/css/99-overrides.css b/lib/experimental/wpds-admin/css/99-overrides.css index 39677933049a5c..9ba56f2091a321 100644 --- a/lib/experimental/wpds-admin/css/99-overrides.css +++ b/lib/experimental/wpds-admin/css/99-overrides.css @@ -40,9 +40,9 @@ .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; + border-color: var(--wpds-color-stroke-interactive-brand-disabled) !important; + background: var(--wpds-color-background-interactive-brand-weak-disabled) !important; + color: var(--wpds-color-foreground-interactive-brand-disabled) !important; } /* @@ -51,6 +51,16 @@ * box-shadow / text-shadow with `!important`. * DELETE WHEN: those `!important` flags are removed from Core. */ + .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: var(--wpds-color-background-interactive-brand-strong-disabled) !important; + background: var(--wpds-color-background-interactive-brand-strong-disabled) !important; + color: var(--wpds-color-foreground-interactive-brand-strong-disabled) !important; + } + /* * Cancels: wp-admin/css/forms.css — the `.form-invalid` rules set * `border-color: #d63638 !important` on required fields and their @@ -66,14 +76,4 @@ .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; - } } From cd72bbfbbf551b1410c216efb50ea1ccdd2b3e41 Mon Sep 17 00:00:00 2001 From: "Joen A." Date: Wed, 16 Sep 2026 14:11:17 +0200 Subject: [PATCH 16/26] Show a selected button in the harness's button groups Groups used as toggles mark the selected button with `.active`, as the media modal's alignment and link settings do. None of the harness specimens had one, so the pressed appearance inside a group could only be checked in the media modal. Co-Authored-By: Claude Opus 5 --- lib/experimental/wpds-admin/harness.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/experimental/wpds-admin/harness.php b/lib/experimental/wpds-admin/harness.php index 14ec0012811793..fd449dc0920ab1 100644 --- a/lib/experimental/wpds-admin/harness.php +++ b/lib/experimental/wpds-admin/harness.php @@ -122,11 +122,12 @@ function gutenberg_wpds_admin_render_harness() { echo '

'; echo '

' . esc_html__( 'Button groups', 'gutenberg' ) . '

'; - echo '

' . 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' ) . '

'; $groups = array( array( '', array( 'button', 'button', 'button' ) ), array( '', array( 'button button-primary', 'button', 'button' ) ), array( ' button-compact', array( 'button', 'button', 'button' ) ), + array( '', array( 'button', 'button active', 'button' ) ), ); echo '

'; 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 '

'; echo '

' . esc_html__( 'Fields — invalid', '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 '

' . 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 `` and those clip their content, so "Save Changes" lost the tail of its "g". This is the same defect the text inputs had. No token fits: the line-height tokens are pixel values on a 4px rhythm meant to pair with a size, and the smallest would pin the label's box rather than let the font size it. The page title action also takes the focus ring. WordPress rings that control with `box-shadow: 0 0 0 1px` plus an accent border while every button here takes an `outline`, so the two read as different controls side by side. As with the font weight, only appearance is taken: WordPress keeps the geometry. Co-Authored-By: Claude Opus 5 --- lib/experimental/wpds-admin/css/10-button.css | 32 +++++++++++++++++-- .../wpds-admin/css/90-exceptions.css | 6 ++-- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/lib/experimental/wpds-admin/css/10-button.css b/lib/experimental/wpds-admin/css/10-button.css index eca614515959ee..be9b2ebd07045d 100644 --- a/lib/experimental/wpds-admin/css/10-button.css +++ b/lib/experimental/wpds-admin/css/10-button.css @@ -52,11 +52,24 @@ * Height belongs to buttons.css. Appearance is ours. */ margin: 0; - padding: 4px 12px; + padding: var(--wpds-dimension-padding-xs) var(--wpds-dimension-padding-md); font-family: inherit; - font-size: 13px; + font-size: var(--wpds-typography-font-size-md); font-weight: var(--wpds-typography-font-weight-emphasis); - line-height: 1; + /* + * `normal`, not `1`. A line box exactly as tall as the font is shorter than + * the font's own ascender-plus-descender — measured, 13px against 15px — so a + * descender has nowhere to go. On the `.button` anchors it simply overflows, + * which is invisible, but `#submit` is an `` and those + * clip their content by default, so "Save Changes" lost the tail of its "g". + * Same defect the text inputs had in 20-input.css. + * + * There is no token for this: the line-height tokens are pixel values on a + * 4px rhythm (16, 20, 24…) meant to pair with a size, and the smallest of + * them would pin the label's box rather than let the font size it. `normal` + * cannot clip and does not compete with the contextual heights WordPress sets. + */ + line-height: normal; text-decoration: none; white-space: nowrap; /* @@ -206,6 +219,19 @@ font-weight: var(--wpds-typography-font-weight-emphasis); } + /* + * And the focus ring, for the same reason. WordPress rings this control with + * `box-shadow: 0 0 0 1px` plus an accent border and a transparent outline for + * forced-colors mode, while every button above takes an `outline`. Side by + * side the two rings read as different controls. Appearance again, so it is + * safe to take; the geometry stays WordPress's. + */ + .wrap .page-title-action:focus { + outline: var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus); + outline-offset: var(--wpds-border-width-focus); + box-shadow: none; + } + /* ----------------------------------------------------------------------- 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/90-exceptions.css b/lib/experimental/wpds-admin/css/90-exceptions.css index c1875c70206947..fef7c28086076a 100644 --- a/lib/experimental/wpds-admin/css/90-exceptions.css +++ b/lib/experimental/wpds-admin/css/90-exceptions.css @@ -29,7 +29,7 @@ */ .wp-core-ui .button.button-compact, .wp-core-ui .button-group.button-compact .button { - padding: 0 12px; + padding: 0 var(--wpds-dimension-padding-md); } /* @@ -38,8 +38,8 @@ */ .wp-core-ui .button.button-small, .wp-core-ui .button-group.button-small .button { - padding: 0 8px; - font-size: 11px; + padding: 0 var(--wpds-dimension-padding-sm); + font-size: var(--wpds-typography-font-size-xs); } /* From 160d0361782f7d69251ed29bb5555f219e4d21f2 Mon Sep 17 00:00:00 2001 From: "Joen A." Date: Tue, 29 Sep 2026 11:12:16 +0200 Subject: [PATCH 25/26] Say in the experiment description that it is not for live sites The experiment is off by default, but nothing on the Experiments screen said what switching it on costs. Two things are worth knowing before anyone tries it on a site they care about: plugin styling on admin screens can render differently, because demoting WordPress's stylesheets into a lower cascade layer changes which rules win; and admin stylesheets arrive one by one, because style concatenation has to be off for the demotion to happen at all. Both are named rather than summarised as "not for production", which tells a reader nothing they can act on. The warning sits in the description rather than the label, as the experimental blocks entry does, so the feature keeps a plain name. "Visual only" is dropped. It read oddly beside a warning that plugin styles can change, and the change does have effects beyond appearance. "No markup changes" is the part that stays true. Co-Authored-By: Claude Opus 5 --- lib/experimental/experiments/load.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/experimental/experiments/load.php b/lib/experimental/experiments/load.php index 379f5dff5bd4b0..64bcd6cb32d786 100644 --- a/lib/experimental/experiments/load.php +++ b/lib/experimental/experiments/load.php @@ -107,7 +107,7 @@ function gutenberg_initialize_experiments_settings() { array( 'id' => 'gutenberg-wpds-admin-restyle', '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' ), + 'description' => __( 'Styles wp-admin from WordPress Design System tokens, so every admin screen reads as one product regardless of how it is rendered. No markup changes. (Warning: this can change how plugin styles apply to admin screens, and it loads admin stylesheets one by one instead of combined. Use it for testing, not on a live site.)', 'gutenberg' ), ), array( 'id' => 'gutenberg-dashboard-widgets', From 49cccdfb88f665101ba858be70d9e5f9a2850f67 Mon Sep 17 00:00:00 2001 From: "Joen A." Date: Thu, 1 Oct 2026 13:03:52 +0200 Subject: [PATCH 26/26] Fix the demoted URL escaping, the colour scheme preview and a harness row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues raised in review, all confirmed before changing anything. The URL arrives HTML-escaped. `WP_Styles::_css_href()` returns `esc_url()`, which encodes `&` as `&`, and `esc_url_raw()` sanitises without decoding, so the entity survived into the `@import` prelude and any stylesheet carrying more than one query argument was requested with a literal `&` in its URL. `wp_specialchars_decode()` with `ENT_QUOTES` is the matching decoder — it handles exactly the `&` and `'` that `esc_url()` produces. Verified: core hands us `?ver=1&foo=bar`, the old path preserved it, the new one yields `?ver=1&foo=bar`. The old comment described this hazard correctly and then applied a remedy that did not address it. The colour scheme picker previews a scheme by setting `href` on `#colors-css` (wp-admin/js/user-profile.js). Demoting that handle turns the `` into a `
' . esc_html__( 'State', 'gutenberg' ) . '' . esc_html__( 'Text input', 'gutenberg' ) . '' . esc_html__( 'Select', 'gutenberg' ) . '' . esc_html__( 'Textarea', 'gutenberg' ) . '