Commit 4d436fd9d89 for woocommerce
commit 4d436fd9d8971c95aa5434eed20720e24aa2fbf8
Author: Daniel Mallory <daniel.mallory@automattic.com>
Date: Mon Jul 13 11:42:00 2026 +0100
Settings UI: adopt WPDS design tokens and components on settings pages (#66461)
* update(settings-ui): load WPDS design tokens on the settings page
The Settings UI shell rendered entirely from the fallback values baked
into the @wordpress/ui component CSS, and our own stylesheet carried a
hand-written fallback for the surface width token. Review feedback on
the card migration flagged manual fallbacks as unsustainable, but token
delivery was blocked behind private APIs at the time.
@wordpress/theme 0.16.0 stabilised that surface: the prebuilt token
stylesheet is an official package export. Import it from the
settings-embed entry so the tokens ship in the same stylesheet as the
rules that reference them, which is also why no fallbacks are needed
(hand-written or generated): the tokens and their consumers cannot load
separately. Drop the manual 720px fallback and swap the info-box
background to the closest token (#f6f7f7 to #f4f4f4).
The package declares sideEffects: false, which tree-shakes a bare CSS
import, so a webpack rule marks the token stylesheet side-effectful.
Visual delta is nil: the 0.17.0 token values match the fallbacks the
cards already rendered with. Cost is ~10KB raw (~2KB gzipped) on the
settings-embed stylesheet, which only loads on WooCommerce settings
pages.
The stylelint no-token-fallback-values guard is deferred: it requires
stylelint 16 and the admin package is on 14.
Refs WOOPRD-3564
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* update(settings-ui): replace hardcoded shell styles with WPDS tokens
The shell chrome in settings-ui.scss carried literal colours, font
sizes, and radii from before the design tokens were available on the
page, and the badge referenced --Alias-* custom properties that are
defined nowhere in wp-admin, so their literal fallbacks always
rendered.
Now that the token stylesheet loads on settings pages, swap the shell
chrome (tab strip, navigation, header title, breadcrumb typography,
page background) to the --wpds-* tokens with matching values. The only
computed-style change there is font-weight 500 to 499, the WPDS medium
weight the @wordpress/ui cards on the page already use.
The badge block moves to the same intent-to-token mapping as the
@wordpress/ui Badge component, which replaces it in the planned
component migration. This shifts the badge colours slightly and gives
the error variant the real token the old comment was waiting for.
Field-layer rules (section layout, control overrides, number control,
modal) are left alone; the component migration replaces them wholesale.
Refs WOOPRD-3564
* update(settings-ui): use WPDS brand tokens for interactive accents
The tab underline, tab hover, and breadcrumb link hover followed
--wp-admin-theme-color, so the shell accent changed with the merchant's
admin colour scheme while the @wordpress/ui components inside the cards
always rendered the WPDS brand colour, splitting the page across two
accents on non-default schemes.
Move these accents to the WPDS brand tokens per the design direction
that the Settings UI shell follows WPDS brand colours rather than the
admin scheme. The underline uses the stroke token, matching how the
@wordpress/ui Tabs indicator is expressed as a stroke.
Verified on the fresh admin scheme (#007cba): the underline stays
#3858e9.
Refs WOOPRD-3564
* update(settings-ui): use WPDS neutral tokens for secondary text
Breadcrumbs, section descriptions, and field help used the Gutenberg
help-text grey #757575, and breadcrumb links used #505050. Neither has
an exact WPDS token.
Move them to the nearest semantic tokens: content-neutral-weak for the
text and interactive-neutral-weak for the links, both #707070. The
shift from #757575 is imperceptible; breadcrumb links lighten from
#505050 to match the surrounding breadcrumb text, keeping hover as the
distinguishing state.
Refs WOOPRD-3564
* update(settings-ui): use the WPDS radius token for the info box
The info box radius was a literal 4px. --wpds-border-radius-md is the
same value, so this is a pure token swap with no rendering change.
Refs WOOPRD-3564
* update(settings-ui): render badges and section layout with WPDS components
The shell badge was a hand-styled span and the section header, actions,
and fields layout was hand-rolled flexbox in settings-ui.scss,
duplicating what @wordpress/ui now provides.
Swap the badge to the @wordpress/ui Badge component with the schema
intents mapped to Badge intents (success=stable, warning=medium,
error=high, info=informational, default=draft), keeping the documented
wc-settings-ui-shell__badge--<intent> classes. Replace the section
layout flexbox with Stack composed into Card.Header and Card.Content
via the render prop, and wrap the section description in Text so its
typography comes from the body-md variant. The corresponding SCSS rules
are removed; the mobile display:block fallback still applies because
Stack's flex properties are inert in block layout.
Description paragraph spacing stays in SCSS: the description is
schema-supplied raw HTML, so its paragraphs are not Stack children.
Verified by pixel-diffing the Products settings page before and after:
the section markup renders identically.
Refs WOOPRD-3563
* update(settings-ui): map schema badge intents straight to Badge intents
The shell badge kept the wc-settings-ui-shell__badge intent classnames
after their CSS moved to the @wordpress/ui Badge component, and
getBadgeIntent returned a schema-side intent key that only existed to
build those classnames. The indirection also read as if the Badge
component received the schema's 'default' value.
Drop the classnames and fold the mapping into getBadgeIntent, so it
takes the schema intent and returns the Badge intent directly. The
badge intent type docblock no longer references the removed CSS
modifiers, and the tests assert the Badge intent prop instead of the
classnames.
Addresses review feedback from simison on PR #66461.
Refs WOOPRD-3563
* chore(settings-ui): mark the theme css side-effects rule for removal
The upstream fix ships in the next @wordpress/theme release, so the
webpack rule goes away with that bump.
Refs WOOPRD-3564
* update(settings-ui): lay out the unsaved-changes modal actions with Stack
The modal footer buttons were the last shell-owned flexbox in
settings-ui.scss; the Stack component carries the layout now and the
class keeps only the top margin.
Addresses review feedback from simison on PR #66461.
Refs WOOPRD-3563
* update(settings-ui): render native fields with WPDS form controls
The native field renderers used @wordpress/components controls
throughout. @wordpress/ui now ships InputControl, SelectControl, and
the Field and Textarea primitives, so the text-family, textarea,
select, and radio field types move to the design system controls.
Field descriptions flow through the Field details slot, which accepts
the sanitized HTML the settings schema allows (the description slot is
typed plain-text only). Checkbox fields stay on
@wordpress/components CheckboxControl and the multi-select stays on the
native element until the design system ships equivalents; the number
field keeps the package's own spin control.
Save flow verified end to end: the select popup updates the form-post
hidden inputs and values persist through the classic settings POST.
Addresses review feedback from simison on PR #66461.
Refs WOOPRD-3563
* update(settings-ui): paint the settings page with the neutral surface token
The settings page painted every surface neutral-strong (#fff), so cards
sat white on white. The design places white cards on the neutral page
surface, which is how the @wordpress/ui Card composes too: it paints
itself neutral-strong.
Move the page surfaces to the surface-neutral token (#fcfcfc) and keep
the header band and tab strips explicitly neutral-strong. The shell
root goes transparent so the admin-ui Page background does not cover
the page surface behind the content.
Refs WOOPRD-3564
* update(settings-ui): render the save notice and unsaved dialog with the design system
The shell still used @wordpress/components for the save and error
notices and for the unsaved-changes modal, with SCSS overriding the
components-modal internals to approximate the design system look.
@wordpress/ui ships composable Notice and Dialog namespaces, so both
move over. The notices map their status onto the Notice intent and
dismiss through Notice.CloseIcon. The unsaved-changes prompt uses the
freeform Dialog rather than AlertDialog: the alert variant hardcodes a
confirm/cancel pair, and Dialog.Action closes the dialog on click,
which would clear the pending navigation state mid-save. Plain ui
Buttons in Dialog.Footer keep the Close/Discard/Save flow and the
saving busy state intact.
The shell (.admin-ui-page) creates a stacking context at z-index 1,
which buried the portaled dialog and its backdrop beneath the page
content. The dialog portal now carries a classname so the stylesheet
can lift it above the wp-admin chrome, matching the z-index used by
@wordpress/components modals.
Verified in the browser: dialog opens on in-shell navigation with
unsaved changes, Discard navigates, Save posts the form and redirects
to the pending destination, close keeps the page, and the error notice
renders and dismisses on a custom-save section.
Refs WOOPRD-3563
* update(settings-ui): space sections with Stack instead of a margin rule
Each section carried a bottom margin in SCSS purely to space the cards
apart. The wrapper renders as a Stack with the xl gap token (24px)
instead, and the rule goes away. The only visual difference is below
the last card, where the trailing margin no longer stacks on top of
the wrapper's own bottom margin.
Addresses review feedback from simison on PR #66461.
Refs WOOPRD-3563
* update(settings-ui): drop the redundant section card styles
The section card rule set width and box-sizing that Card provides on
its own: Card.Root is a block-level flex container, so it fills the
section's width without the explicit 100%, and box-sizing comes from
the design system styles. Verified the card still spans the 720px
column with the rule removed.
Addresses review feedback from simison on PR #66461.
Refs WOOPRD-3563
* chore(settings-ui): remove the unstyled primary tabs modifier
A selector sweep after the design system migration found the
tabs--primary modifier had no styles behind it; only the secondary
modifier does. The base tabs classname still styles both rows.
Addresses review feedback from simison on PR #66461.
Refs WOOPRD-3563
* chore(settings-ui): trim the Badge mock comment to the constraint
Refs WOOPRD-3563
* chore(settings-ui): trim the dialog portal z-index comment
Refs WOOPRD-3563
* fix(settings-ui): repair shell styles for admin-ui 2.4.1
The admin-ui 2.4.1 bump on trunk moved Page to hashed CSS-module
classnames, so the shell's .admin-ui-page selectors stopped matching:
the shell lost its full-bleed margins, the header padding fell out of
line with the 48px gutters, and the header became sticky under the
admin bar.
Root styling moves onto .wc-settings-ui-shell, which sits on the same
element through the className prop. The header is targeted structurally
as the Page's first child until the shell owns its header markup. The
title typography override goes away because 2.4.1 renders the title
with the design system heading style.
Refs WOOPRD-3564
* chore(settings-ui): condense the package changelog entries
Refs WOOPRD-3563
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
diff --git a/.syncpackrc b/.syncpackrc
index ff75e16da9e..32f2c9f9e46 100644
--- a/.syncpackrc
+++ b/.syncpackrc
@@ -479,6 +479,7 @@
"@wordpress/icons",
"@wordpress/interface",
"@wordpress/style-runtime",
+ "@wordpress/theme",
"@wordpress/ui",
"@wordpress/undo-manager",
"@wordpress/views"
diff --git a/packages/js/settings-ui/changelog/update-adopt-wpds-components b/packages/js/settings-ui/changelog/update-adopt-wpds-components
new file mode 100644
index 00000000000..d25dda9b450
--- /dev/null
+++ b/packages/js/settings-ui/changelog/update-adopt-wpds-components
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Render the Settings UI shell and settings fields with WordPress Design System components.
diff --git a/packages/js/settings-ui/src/native-fields.tsx b/packages/js/settings-ui/src/native-fields.tsx
index 86564dced17..8b4803c1da3 100644
--- a/packages/js/settings-ui/src/native-fields.tsx
+++ b/packages/js/settings-ui/src/native-fields.tsx
@@ -1,14 +1,9 @@
/**
* External dependencies
*/
-import {
- BaseControl,
- CheckboxControl,
- SelectControl,
- TextControl,
- TextareaControl,
-} from '@wordpress/components';
+import { BaseControl, CheckboxControl } from '@wordpress/components';
import { createElement, RawHTML } from '@wordpress/element';
+import { Field, InputControl, SelectControl, Textarea } from '@wordpress/ui';
/**
* Internal dependencies
@@ -124,31 +119,41 @@ export const NativeSettingsField = ( {
if ( field.type === 'textarea' ) {
return (
- <TextareaControl
- className="wc-settings-ui__control"
- label={ field.label }
- help={ getHelp( field.description ) }
- value={ toStringValue( value ) }
- placeholder={ field.placeholder }
- disabled={ field.disabled }
- onChange={ onChange }
- __nextHasNoMarginBottom
- />
+ <Field.Root className="wc-settings-ui__control">
+ <Field.Label>{ field.label }</Field.Label>
+ <Textarea
+ value={ toStringValue( value ) }
+ placeholder={ field.placeholder }
+ disabled={ field.disabled }
+ onChange={ ( event ) => onChange( event.target.value ) }
+ />
+ { field.description ? (
+ <Field.Details>
+ { getHelp( field.description ) }
+ </Field.Details>
+ ) : null }
+ </Field.Root>
);
}
if ( field.type === 'select' || field.type === 'radio' ) {
+ const items = ( field.options || [] ).map( ( option ) => ( {
+ value: option.value,
+ label: option.label,
+ } ) );
+ const selectedItem =
+ items.find( ( item ) => item.value === toStringValue( value ) ) ??
+ null;
+
return (
<SelectControl
className="wc-settings-ui__control"
label={ field.label }
- help={ getHelp( field.description ) }
- value={ toStringValue( value ) }
- options={ field.options || [] }
+ details={ getHelp( field.description ) }
+ items={ items }
+ value={ selectedItem }
disabled={ field.disabled }
- onChange={ onChange }
- __next40pxDefaultSize
- __nextHasNoMarginBottom
+ onValueChange={ ( item ) => onChange( item?.value ?? '' ) }
/>
);
}
@@ -206,17 +211,15 @@ export const NativeSettingsField = ( {
if ( isTextInputType( field.type ) ) {
return (
- <TextControl
+ <InputControl
className="wc-settings-ui__control"
type={ field.type }
label={ field.label }
- help={ getHelp( field.description ) }
+ details={ getHelp( field.description ) }
value={ toStringValue( value ) }
placeholder={ field.placeholder }
disabled={ field.disabled }
- onChange={ onChange }
- __next40pxDefaultSize
- __nextHasNoMarginBottom
+ onChange={ ( event ) => onChange( event.target.value ) }
{ ...field.customAttributes }
/>
);
@@ -225,15 +228,13 @@ export const NativeSettingsField = ( {
warn( `Field type "${ field.type }" is not supported.`, { field } );
return (
- <TextControl
+ <InputControl
className="wc-settings-ui__control"
label={ field.label }
- help={ getHelp( field.description ) }
+ details={ getHelp( field.description ) }
value={ toStringValue( value ) }
disabled={ field.disabled }
- onChange={ onChange }
- __next40pxDefaultSize
- __nextHasNoMarginBottom
+ onChange={ ( event ) => onChange( event.target.value ) }
/>
);
};
diff --git a/packages/js/settings-ui/src/settings-ui-page.tsx b/packages/js/settings-ui/src/settings-ui-page.tsx
index 00f1b9105fc..6a19ccc21ae 100644
--- a/packages/js/settings-ui/src/settings-ui-page.tsx
+++ b/packages/js/settings-ui/src/settings-ui-page.tsx
@@ -2,7 +2,7 @@
* External dependencies
*/
import { Page } from '@wordpress/admin-ui';
-import { Button, Modal, Notice } from '@wordpress/components';
+import { Button } from '@wordpress/components';
import {
Component,
createElement,
@@ -14,8 +14,16 @@ import {
useState,
} from '@wordpress/element';
import { __ } from '@wordpress/i18n';
-import { Card } from '@wordpress/ui';
-import type { ErrorInfo, ReactNode } from 'react';
+import {
+ Badge,
+ Button as UIButton,
+ Card,
+ Dialog,
+ Notice,
+ Stack,
+ Text,
+} from '@wordpress/ui';
+import type { ComponentProps, ErrorInfo, ReactNode } from 'react';
/**
* Internal dependencies
@@ -36,6 +44,7 @@ import type {
SettingsUIGroup,
SettingsUISaveStrategy,
SettingsUISchema,
+ SettingsUIShellBadgeIntent,
SettingsFieldContext,
SettingsValue,
SettingsValues,
@@ -104,14 +113,25 @@ const getActionVariant = ( variant?: string ) =>
? variant
: 'secondary' ) as 'primary' | 'secondary' | 'tertiary' | 'link';
-// TS unions erase at runtime, so guard the className interpolation against unexpected
-// strings from PHP-supplied schemas.
-const getBadgeIntent = ( intent?: string ) =>
- [ 'default', 'info', 'success', 'warning', 'error' ].includes(
- intent || ''
- )
- ? intent
- : 'default';
+const BADGE_INTENTS: Record<
+ SettingsUIShellBadgeIntent,
+ ComponentProps< typeof Badge >[ 'intent' ]
+> = {
+ default: 'draft',
+ info: 'informational',
+ success: 'stable',
+ warning: 'medium',
+ error: 'high',
+};
+
+// TS unions erase at runtime, so guard against unexpected strings from
+// PHP-supplied schemas.
+const getBadgeIntent = (
+ intent?: string
+): ComponentProps< typeof Badge >[ 'intent' ] =>
+ intent && intent in BADGE_INTENTS
+ ? BADGE_INTENTS[ intent as SettingsUIShellBadgeIntent ]
+ : BADGE_INTENTS.default;
const getSaveStrategy = ( schema: SettingsUISchema ): SettingsUISaveStrategy =>
schema.save || { adapter: 'form_post' };
@@ -190,34 +210,49 @@ const UnsavedChangesModal = ( {
onSave: () => void;
} ) => {
return (
- <Modal
- className="wc-settings-ui__unsaved-changes-modal"
- title={ __( 'You have unsaved changes', 'woocommerce' ) }
- onRequestClose={ onClose }
+ <Dialog.Root
+ open
+ onOpenChange={ ( open ) => {
+ if ( ! open ) {
+ onClose();
+ }
+ } }
>
- <p>
- { __(
- "If you leave now, your changes won't be saved.",
- 'woocommerce'
- ) }
- </p>
- <div className="wc-settings-ui__unsaved-changes-actions">
- <Button variant="tertiary" onClick={ onDiscard }>
- { __( 'Discard', 'woocommerce' ) }
- </Button>
- <Button
- variant="primary"
- type="button"
- name="save"
- value={ __( 'Save', 'woocommerce' ) }
- isBusy={ isSaving }
- disabled={ isSaving }
- onClick={ onSave }
- >
- { __( 'Save', 'woocommerce' ) }
- </Button>
- </div>
- </Modal>
+ <Dialog.Popup
+ className="wc-settings-ui__unsaved-changes-modal"
+ portal={
+ <Dialog.Portal className="wc-settings-ui__unsaved-changes-portal" />
+ }
+ size="small"
+ >
+ <Dialog.Header>
+ <Dialog.Title>
+ { __( 'You have unsaved changes', 'woocommerce' ) }
+ </Dialog.Title>
+ <Dialog.CloseIcon />
+ </Dialog.Header>
+ <Dialog.Content>
+ <Dialog.Description>
+ { __(
+ "If you leave now, your changes won't be saved.",
+ 'woocommerce'
+ ) }
+ </Dialog.Description>
+ </Dialog.Content>
+ <Dialog.Footer className="wc-settings-ui__unsaved-changes-actions">
+ <UIButton variant="minimal" onClick={ onDiscard }>
+ { __( 'Discard', 'woocommerce' ) }
+ </UIButton>
+ <UIButton
+ loading={ isSaving }
+ disabled={ isSaving }
+ onClick={ onSave }
+ >
+ { __( 'Save', 'woocommerce' ) }
+ </UIButton>
+ </Dialog.Footer>
+ </Dialog.Popup>
+ </Dialog.Root>
);
};
@@ -232,7 +267,15 @@ const GroupHeader = ( { group }: { group: SettingsUIGroup } ) => {
return (
<Card.Header
className="wc-settings-ui__section-header"
- render={ <header /> }
+ render={
+ <Stack
+ render={ <header /> }
+ direction="row"
+ gap="xl"
+ align="flex-start"
+ justify="space-between"
+ />
+ }
>
<div className="wc-settings-ui__section-heading">
{ group.title ? (
@@ -244,15 +287,24 @@ const GroupHeader = ( { group }: { group: SettingsUIGroup } ) => {
</Card.Title>
) : null }
{ group.description ? (
- <div className="wc-settings-ui__section-description">
+ <Text
+ className="wc-settings-ui__section-description"
+ variant="body-md"
+ render={ <div /> }
+ >
<RawHTML>
{ sanitizeSettingsHtml( group.description ) }
</RawHTML>
- </div>
+ </Text>
) : null }
</div>
{ group.actions && group.actions.length > 0 ? (
- <div className="wc-settings-ui__section-actions">
+ <Stack
+ className="wc-settings-ui__section-actions"
+ direction="row"
+ gap="sm"
+ wrap="wrap"
+ >
{ group.actions.map( ( action ) => (
<Button
key={ action.id }
@@ -264,7 +316,7 @@ const GroupHeader = ( { group }: { group: SettingsUIGroup } ) => {
{ action.label }
</Button>
) ) }
- </div>
+ </Stack>
) : null }
</Card.Header>
);
@@ -358,12 +410,14 @@ export class SettingsUIErrorBoundary extends Component<
render() {
if ( this.state.hasError ) {
return (
- <Notice status="error" isDismissible={ false }>
- { __(
- 'Something went wrong while rendering this settings page. Reload the page with the settings UI feature disabled to use the classic settings screen.',
- 'woocommerce'
- ) }
- </Notice>
+ <Notice.Root intent="error">
+ <Notice.Description>
+ { __(
+ 'Something went wrong while rendering this settings page. Reload the page with the settings UI feature disabled to use the classic settings screen.',
+ 'woocommerce'
+ ) }
+ </Notice.Description>
+ </Notice.Root>
);
}
@@ -429,14 +483,12 @@ const ShellHeader = ( {
const badges = shell.badges?.length
? shell.badges.map( ( badge, index ) => (
- <span
- className={ `wc-settings-ui-shell__badge wc-settings-ui-shell__badge--${ getBadgeIntent(
- badge.intent
- ) }` }
+ <Badge
+ intent={ getBadgeIntent( badge.intent ) }
key={ `${ badge.label }-${ index }` }
>
{ badge.label }
- </span>
+ </Badge>
) )
: undefined;
@@ -470,7 +522,7 @@ const ShellHeader = ( {
<div className="wc-settings-ui-shell__navigation">
{ shell.navigation && shell.navigation.length > 0 ? (
<nav
- className="wc-settings-ui-shell__tabs wc-settings-ui-shell__tabs--primary"
+ className="wc-settings-ui-shell__tabs"
aria-label={ __( 'Settings pages', 'woocommerce' ) }
>
{ shell.navigation.map( ( item ) => (
@@ -846,16 +898,17 @@ export const SettingsUIPage = ( {
/>
) : null }
{ saveNotice ? (
- <Notice
+ <Notice.Root
className="wc-settings-ui-shell__notice"
- status={ saveNotice.status }
- isDismissible
- onRemove={ () => setSaveNotice( null ) }
+ intent={ saveNotice.status }
>
- { saveNotice.message }
- </Notice>
+ <Notice.Description>
+ { saveNotice.message }
+ </Notice.Description>
+ <Notice.CloseIcon onClick={ () => setSaveNotice( null ) } />
+ </Notice.Root>
) : null }
- <div className="wc-settings-ui">
+ <Stack className="wc-settings-ui" direction="column" gap="xl">
{ visibleGroups.map( ( group ) => (
<section
className="wc-settings-ui__section"
@@ -863,7 +916,10 @@ export const SettingsUIPage = ( {
>
<Card.Root className="wc-settings-ui__section-card">
<GroupHeader group={ group } />
- <Card.Content className="wc-settings-ui__section-fields">
+ <Card.Content
+ className="wc-settings-ui__section-fields"
+ render={ <Stack direction="column" gap="lg" /> }
+ >
{ group.fields.map( ( field ) => {
const FieldComponent =
resolveFieldComponent(
@@ -904,7 +960,7 @@ export const SettingsUIPage = ( {
</Card.Root>
</section>
) ) }
- </div>
+ </Stack>
{ formPostFields.length > 0 ? (
<div className="wc-settings-ui__hidden-inputs">
{ formPostFields.map( ( field ) => (
diff --git a/packages/js/settings-ui/src/test/header-fields.test.tsx b/packages/js/settings-ui/src/test/header-fields.test.tsx
index 8267e492a74..1caf474959b 100644
--- a/packages/js/settings-ui/src/test/header-fields.test.tsx
+++ b/packages/js/settings-ui/src/test/header-fields.test.tsx
@@ -6,6 +6,22 @@ import { act } from 'react';
import { createRoot } from 'react-dom/client';
import type { ReactNode } from 'react';
+// Jest stubs CSS modules, so the real Badge renders nothing that reveals its intent.
+jest.mock( '@wordpress/ui', () => ( {
+ ...jest.requireActual( '@wordpress/ui' ),
+ Badge: ( {
+ intent,
+ children,
+ }: {
+ intent?: string;
+ children: ReactNode;
+ } ) => (
+ <span data-testid="shell-badge" data-intent={ intent }>
+ { children }
+ </span>
+ ),
+} ) );
+
// Surface the header props the real admin-ui Page would render so the shell
// header wiring (subtitle, badges) can be asserted.
jest.mock( '@wordpress/admin-ui', () => ( {
@@ -116,7 +132,7 @@ describe( 'settings UI shell header fields', () => {
container.remove();
} );
- it( 'renders badges with their intent class', () => {
+ it( 'maps schema intents to Badge intents', () => {
const { container, root } = renderElement(
<SettingsUIPage
schema={ baseSchema( {
@@ -131,21 +147,13 @@ describe( 'settings UI shell header fields', () => {
);
const badges = container.querySelectorAll(
- '.wc-settings-ui-shell__badge'
+ '[data-testid="shell-badge"]'
);
expect( badges ).toHaveLength( 2 );
expect( badges[ 0 ].textContent ).toBe( 'Active' );
- expect(
- badges[ 0 ].classList.contains(
- 'wc-settings-ui-shell__badge--success'
- )
- ).toBe( true );
+ expect( badges[ 0 ].getAttribute( 'data-intent' ) ).toBe( 'stable' );
// Defaults to the neutral intent when none is provided.
- expect(
- badges[ 1 ].classList.contains(
- 'wc-settings-ui-shell__badge--default'
- )
- ).toBe( true );
+ expect( badges[ 1 ].getAttribute( 'data-intent' ) ).toBe( 'draft' );
act( () => root.unmount() );
container.remove();
@@ -169,14 +177,9 @@ describe( 'settings UI shell header fields', () => {
/>
);
- const badge = container.querySelector( '.wc-settings-ui-shell__badge' );
+ const badge = container.querySelector( '[data-testid="shell-badge"]' );
expect( badge ).not.toBeNull();
- expect(
- badge?.classList.contains( 'wc-settings-ui-shell__badge--default' )
- ).toBe( true );
- expect(
- badge?.classList.contains( 'wc-settings-ui-shell__badge--magic' )
- ).toBe( false );
+ expect( badge?.getAttribute( 'data-intent' ) ).toBe( 'draft' );
act( () => root.unmount() );
container.remove();
@@ -194,7 +197,7 @@ describe( 'settings UI shell header fields', () => {
container.querySelector( '.admin-ui-page__header-subtitle' )
).toBeNull();
expect(
- container.querySelector( '.wc-settings-ui-shell__badge' )
+ container.querySelector( '[data-testid="shell-badge"]' )
).toBeNull();
act( () => root.unmount() );
diff --git a/packages/js/settings-ui/src/types.ts b/packages/js/settings-ui/src/types.ts
index 334bad64204..a7b631342db 100644
--- a/packages/js/settings-ui/src/types.ts
+++ b/packages/js/settings-ui/src/types.ts
@@ -74,9 +74,9 @@ export type SettingsUIShellNavigationItem = {
/**
* Visual intent for a shell header badge. Mirrors common web semantic
- * conventions and maps to the `wc-settings-ui-shell__badge--<intent>`
- * CSS modifiers. Intent conveys color only — the `label` text must
- * carry the actual meaning for screen-reader and color-blind users.
+ * conventions and maps to the design system Badge intents. Intent conveys
+ * color only; the `label` text must carry the actual meaning for
+ * screen-reader and color-blind users.
*/
export type SettingsUIShellBadgeIntent =
| 'default'
diff --git a/plugins/woocommerce/changelog/update-settings-ui-wpds-token-delivery b/plugins/woocommerce/changelog/update-settings-ui-wpds-token-delivery
new file mode 100644
index 00000000000..636d01c945b
--- /dev/null
+++ b/plugins/woocommerce/changelog/update-settings-ui-wpds-token-delivery
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Load the WordPress Design System tokens stylesheet on WooCommerce settings pages so Settings UI styles consume real design tokens.
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/settings-embed/index.tsx b/plugins/woocommerce/client/admin/client/wp-admin-scripts/settings-embed/index.tsx
index 4610ee8325c..0b829820a76 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/settings-embed/index.tsx
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/settings-embed/index.tsx
@@ -6,6 +6,7 @@
* External dependencies
*/
import { createRoot } from '@wordpress/element';
+import '@wordpress/theme/design-tokens.css';
/**
* Internal dependencies
diff --git a/plugins/woocommerce/client/admin/client/wp-admin-scripts/settings-embed/settings-ui.scss b/plugins/woocommerce/client/admin/client/wp-admin-scripts/settings-embed/settings-ui.scss
index 45c1376a563..7f35fb26a7a 100644
--- a/plugins/woocommerce/client/admin/client/wp-admin-scripts/settings-embed/settings-ui.scss
+++ b/plugins/woocommerce/client/admin/client/wp-admin-scripts/settings-embed/settings-ui.scss
@@ -1,13 +1,13 @@
@import "@wordpress/dataviews/build-style/style.css";
body.woocommerce_page_wc-settings.woocommerce-settings-ui-page {
- background-color: #fff;
+ background-color: var(--wpds-color-background-surface-neutral);
#mainform,
#wpcontent,
#wpbody-content,
.wrap.woocommerce {
- background-color: #fff;
+ background-color: var(--wpds-color-background-surface-neutral);
}
#mainform {
@@ -19,7 +19,8 @@ body.woocommerce_page_wc-settings.woocommerce-settings-ui-page {
}
#mainform .subsubsub:not(.list-table-filters) {
- border-bottom: 1px solid #f0f0f0;
+ background: var(--wpds-color-background-surface-neutral-strong);
+ border-bottom: 1px solid var(--wpds-color-stroke-surface-neutral-weak);
box-sizing: border-box;
color: transparent;
display: flex;
@@ -40,14 +41,14 @@ body.woocommerce_page_wc-settings.woocommerce-settings-ui-page {
li:first-child a,
li:last-child a {
align-items: center;
- color: #1e1e1e;
+ color: var(--wpds-color-foreground-interactive-neutral);
display: inline-flex;
- font-size: 13px;
- font-weight: 500;
+ font-size: var(--wpds-typography-font-size-md);
+ font-weight: var(--wpds-typography-font-weight-medium);
height: 48px;
- line-height: 20px;
+ line-height: var(--wpds-typography-line-height-sm);
margin: 0;
- padding: 0 16px;
+ padding: 0 var(--wpds-dimension-padding-lg);
position: relative;
text-decoration: none;
white-space: nowrap;
@@ -56,16 +57,16 @@ body.woocommerce_page_wc-settings.woocommerce-settings-ui-page {
&:hover,
&:active {
box-shadow: none;
- color: var(--wp-admin-theme-color, #3858e9);
+ color: var(--wpds-color-foreground-interactive-brand);
outline: none;
}
&.current {
- color: #1e1e1e;
- font-weight: 500;
+ color: var(--wpds-color-foreground-interactive-neutral);
+ font-weight: var(--wpds-typography-font-weight-medium);
&::after {
- background: var(--wp-admin-theme-color, #3858e9);
+ background: var(--wpds-color-stroke-interactive-brand);
border-radius: 999px;
bottom: 0;
content: "";
@@ -82,37 +83,32 @@ body.woocommerce_page_wc-settings.woocommerce-settings-ui-page {
}
}
- .wc-settings-ui-shell.admin-ui-page {
+ .wc-settings-ui-shell {
+ background: transparent;
box-sizing: border-box;
- color: #1e1e1e;
- font-size: 13px;
+ color: var(--wpds-color-foreground-content-neutral);
+ font-size: var(--wpds-typography-font-size-md);
height: auto;
- line-height: 20px;
+ line-height: var(--wpds-typography-line-height-sm);
margin: 0 -48px;
}
- .wc-settings-ui-shell .admin-ui-page__header {
+ .wc-settings-ui-shell > :first-child {
padding-left: 48px;
padding-right: 48px;
position: static;
}
.wc-settings-ui-shell {
- &:has(.wc-settings-ui-shell__navigation) .admin-ui-page__header {
+ &:has(.wc-settings-ui-shell__navigation) > :first-child {
border-bottom: 0;
}
}
- .wc-settings-ui-shell .admin-ui-page__header-title {
- font-size: 20px;
- font-weight: 500;
- line-height: 24px;
- }
-
.wc-settings-ui-shell__breadcrumbs {
- color: #757575;
- font-size: 12px;
- line-height: 16px;
+ color: var(--wpds-color-foreground-content-neutral-weak);
+ font-size: var(--wpds-typography-font-size-sm);
+ line-height: var(--wpds-typography-line-height-xs);
margin: 0 0 8px;
}
@@ -123,61 +119,24 @@ body.woocommerce_page_wc-settings.woocommerce-settings-ui-page {
}
a {
- color: #505050;
+ color: var(--wpds-color-foreground-interactive-neutral-weak);
text-decoration: none;
&:focus,
&:hover {
- color: var(--wp-admin-theme-color, #3858e9);
+ color: var(--wpds-color-foreground-interactive-brand);
}
}
}
- .wc-settings-ui-shell__badge {
- align-items: center;
- border-radius: 2px;
- display: inline-flex;
- font-size: 12px;
- font-weight: 400;
- line-height: 16px;
- min-height: 24px;
- padding: 0 8px;
-
- // Default / neutral — also applies to `&--default`, which inherits the base styling.
- background: var(--Alias-bg-bg-surface-secondary, #f5f5f5);
- color: var(--Alias-text-text, #070707);
-
- &--success {
- background: var(--Alias-bg-bg-surface-success, #daffe0);
- color: var(--Alias-text-text-success, #144722);
- }
-
- &--info {
- background: var(--Alias-bg-bg-surface-secondary, #f5f5f5);
- color: var(--Alias-text-text, #070707);
- }
-
- &--warning {
- background: var(--Alias-bg-bg-surface-warning, #fff2d7);
- color: var(--Alias-text-text-warning, #4d3716);
- }
-
- &--error {
- // No `--Alias-*-error` token is defined in the admin design-token set,
- // so use a direct color until one ships.
- background: #fce4e4;
- color: #5a0000;
- }
- }
-
.wc-settings-ui-shell__navigation {
- border-bottom: 1px solid #f0f0f0;
+ border-bottom: 1px solid var(--wpds-color-stroke-surface-neutral-weak);
margin: 0;
width: 100%;
}
.wc-settings-ui-shell__tabs {
- background: #fff;
+ background: var(--wpds-color-background-surface-neutral-strong);
box-sizing: border-box;
display: flex;
margin: 0 !important;
@@ -192,13 +151,13 @@ body.woocommerce_page_wc-settings.woocommerce-settings-ui-page {
.wc-settings-ui-shell__tab {
align-items: center;
- color: #1e1e1e;
+ color: var(--wpds-color-foreground-interactive-neutral);
display: inline-flex;
- font-size: 13px;
- font-weight: 500;
+ font-size: var(--wpds-typography-font-size-md);
+ font-weight: var(--wpds-typography-font-weight-medium);
height: 48px;
- line-height: 20px;
- padding: 0 16px;
+ line-height: var(--wpds-typography-line-height-sm);
+ padding: 0 var(--wpds-dimension-padding-lg);
position: relative;
text-decoration: none;
white-space: nowrap;
@@ -207,15 +166,15 @@ body.woocommerce_page_wc-settings.woocommerce-settings-ui-page {
&:hover,
&:active {
box-shadow: none;
- color: var(--wp-admin-theme-color, #3858e9);
+ color: var(--wpds-color-foreground-interactive-brand);
outline: none;
}
&.is-active {
- color: #1e1e1e;
+ color: var(--wpds-color-foreground-interactive-neutral);
&::after {
- background: var(--wp-admin-theme-color, #3858e9);
+ background: var(--wpds-color-stroke-interactive-brand);
border-radius: 999px;
bottom: 0;
content: "";
@@ -227,32 +186,14 @@ body.woocommerce_page_wc-settings.woocommerce-settings-ui-page {
}
}
- .wc-settings-ui-shell__notice.components-notice {
+ .wc-settings-ui-shell__notice {
margin: 24px 48px 0;
}
.wc-settings-ui {
box-sizing: border-box;
margin: 32px auto 48px;
- max-width: var(--wpds-dimension-surface-width-xl, 720px);
- }
-
- .wc-settings-ui__section {
- margin: 0 0 24px;
- }
-
- // Surface styling (background, border, radius, padding) comes from the
- // @wordpress/ui Card components.
- .wc-settings-ui__section-card {
- box-sizing: border-box;
- width: 100%;
- }
-
- .wc-settings-ui__section-header {
- align-items: flex-start;
- display: flex;
- gap: 24px;
- justify-content: space-between;
+ max-width: var(--wpds-dimension-surface-width-xl);
}
.wc-settings-ui__section-heading {
@@ -260,9 +201,7 @@ body.woocommerce_page_wc-settings.woocommerce-settings-ui-page {
}
.wc-settings-ui__section-description {
- color: #757575;
- font-size: 13px;
- line-height: 20px;
+ color: var(--wpds-color-foreground-content-neutral-weak);
margin: 8px 0 0;
p {
@@ -275,16 +214,7 @@ body.woocommerce_page_wc-settings.woocommerce-settings-ui-page {
}
.wc-settings-ui__section-actions {
- display: flex;
flex: 0 0 auto;
- flex-wrap: wrap;
- gap: 8px;
- }
-
- .wc-settings-ui__section-fields {
- display: flex;
- flex-direction: column;
- gap: 16px;
}
.wc-settings-ui__field {
@@ -293,8 +223,8 @@ body.woocommerce_page_wc-settings.woocommerce-settings-ui-page {
}
.wc-settings-ui__info {
- background: #f6f7f7;
- border-radius: 4px;
+ background: var(--wpds-color-background-surface-neutral-weak);
+ border-radius: var(--wpds-border-radius-md);
padding: 16px;
strong {
@@ -318,7 +248,7 @@ body.woocommerce_page_wc-settings.woocommerce-settings-ui-page {
}
.components-base-control__help {
- color: #757575;
+ color: var(--wpds-color-foreground-content-neutral-weak);
font-size: 13px;
line-height: 20px;
margin-top: 8px;
@@ -375,34 +305,11 @@ body.woocommerce_page_wc-settings.woocommerce-settings-ui-page {
transform: translateY(-50%);
}
- .wc-settings-ui__unsaved-changes-modal {
- .components-modal__header {
- min-height: 72px;
- padding: 24px 32px 8px;
- }
-
- .components-modal__header-heading {
- font-size: 20px;
- font-weight: 500;
- line-height: 24px;
- }
-
- .components-modal__content {
- padding: 4px 32px 32px;
- }
-
- p {
- font-size: 13px;
- line-height: 20px;
- margin: 0;
- }
- }
-
- .wc-settings-ui__unsaved-changes-actions {
- display: flex;
- gap: 8px;
- justify-content: flex-end;
- margin-top: 24px;
+ // Lifts the dialog above the shell's own stacking context; 100000 matches
+ // @wordpress/components modals.
+ .wc-settings-ui__unsaved-changes-portal {
+ position: relative;
+ z-index: 100000;
}
@media (max-width: 960px) {
@@ -417,15 +324,12 @@ body.woocommerce_page_wc-settings.woocommerce-settings-ui-page {
padding-right: 24px;
}
- // Match the base `.wc-settings-ui-shell.admin-ui-page` specificity so
- // this breakpoint's full-bleed margin actually overrides the -48px
- // base value instead of losing the cascade.
- .wc-settings-ui-shell.admin-ui-page {
+ .wc-settings-ui-shell {
margin-left: -24px;
margin-right: -24px;
}
- .wc-settings-ui-shell .admin-ui-page__header {
+ .wc-settings-ui-shell > :first-child {
padding-left: 24px;
padding-right: 24px;
}
@@ -435,7 +339,7 @@ body.woocommerce_page_wc-settings.woocommerce-settings-ui-page {
padding-right: 24px;
}
- .wc-settings-ui-shell__notice.components-notice {
+ .wc-settings-ui-shell__notice {
margin-left: 24px;
margin-right: 24px;
}
diff --git a/plugins/woocommerce/client/admin/package.json b/plugins/woocommerce/client/admin/package.json
index 4fb15dab12f..beff528df3a 100644
--- a/plugins/woocommerce/client/admin/package.json
+++ b/plugins/woocommerce/client/admin/package.json
@@ -101,6 +101,7 @@
"@wordpress/plugins": "catalog:wp-min",
"@wordpress/primitives": "catalog:wp-min",
"@wordpress/router": "0.7.0",
+ "@wordpress/theme": "catalog:wp-bundled",
"@wordpress/ui": "catalog:wp-bundled",
"@wordpress/url": "catalog:wp-min",
"@wordpress/viewport": "catalog:wp-min",
diff --git a/plugins/woocommerce/client/admin/webpack.config.js b/plugins/woocommerce/client/admin/webpack.config.js
index cae895344a9..3112cdf411b 100644
--- a/plugins/woocommerce/client/admin/webpack.config.js
+++ b/plugins/woocommerce/client/admin/webpack.config.js
@@ -212,6 +212,14 @@ const jsConfig = {
},
},
{ test: /\.md$/, use: 'raw-loader' },
+ {
+ // @wordpress/theme declares sideEffects: false, which would
+ // tree-shake bare imports of its design tokens stylesheet.
+ // TODO: remove this rule when bumping @wordpress/theme past
+ // 0.17.0; newer releases fix the tree-shaking upstream.
+ test: /@wordpress[\/\\]theme[\/\\].*\.css$/,
+ sideEffects: true,
+ },
{
test: /\.(png|jpe?g|gif|svg|eot|ttf|woff|woff2)$/,
type: 'asset',
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 605bc555c82..3fbe3df7b94 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -21,6 +21,9 @@ catalogs:
'@wordpress/interface':
specifier: 9.18.5
version: 9.18.5
+ '@wordpress/theme':
+ specifier: 0.17.0
+ version: 0.17.0
'@wordpress/ui':
specifier: 0.17.0
version: 0.17.0
@@ -2975,6 +2978,9 @@ importers:
'@wordpress/router':
specifier: 0.7.0
version: 0.7.0(react@18.3.1)
+ '@wordpress/theme':
+ specifier: catalog:wp-bundled
+ version: 0.17.0(@types/react@18.3.28)(postcss@8.4.49)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@14.16.1)
'@wordpress/ui':
specifier: catalog:wp-bundled
version: 0.17.0(@date-fns/tz@1.4.1)(@types/react@18.3.28)(date-fns@4.1.0)(postcss@8.4.49)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(stylelint@14.16.1)
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index d0fdaa869a9..8d789b3dff1 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -63,6 +63,7 @@ catalogs:
'@wordpress/fields': 0.25.11
'@wordpress/icons': 11.0.1
'@wordpress/interface': 9.18.5
+ '@wordpress/theme': 0.17.0
'@wordpress/ui': 0.17.0
'@wordpress/undo-manager': 1.33.1
'@wordpress/views': 1.0.8