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