Commit e6f78eb3aeb for woocommerce

commit e6f78eb3aeb5e3f1b09824054b16c2c64f1b4b1c
Author: Cvetan Cvetanov <cvetan.cvetanov@automattic.com>
Date:   Thu Jun 18 19:06:46 2026 +0300

    Settings UI: add subtitle and badges to the shell header (#65825)

    * Settings UI: add subtitle and badges to the shell header

    The settings UI shell exposes a title, breadcrumbs, and navigation, but the
    admin-ui Page header it renders also supports a subtitle and status badges
    (e.g. an "Active" pill) that extensions targeting the modernised settings
    designs need. The schema had no way to set them.

    Add `subtitle` and `badges` to `SettingsUIShell` and forward them to the
    admin-ui Page as `subTitle` and `badges`. Badges render as intent-colored
    pills (default/info/success/warning/error) styled in the settings UI
    stylesheet. Both are plain text, escaped on render; the schema passes
    through to the client unchanged, so no server-side change is required.

    Refs WOOPMNT-6227

    * refactor(settings-ui): align badge styles with design tokens and harden intent

    Apply review feedback from PR #65825:

    - Replace hardcoded hex in the shell badge SCSS with the existing
      `--Alias-bg-bg-surface-*` / `--Alias-text-text-*` tokens (hex fallback
      preserved) so colors stay consistent with `settings-payments`
      `StatusBadge` and respond to theme tokens. Two side-by-side "success"
      badges no longer render different greens.
    - Add empty `&--default` modifier rule for symmetry with the four
      non-default intents (the `default` class is emitted on every neutral
      badge).
    - Guard the className interpolation with a runtime intent allowlist —
      TS unions erase at compile time, so an extension passing an
      unrecognized string from `get_schema()` could otherwise produce an
      unexpected class. Unknown intents now fall back to `default`.
    - Subtitle test asserts on forwarded text content instead of the mock's
      structural class.
    - Match the package's test teardown convention (`root.unmount()` +
      `container.remove()` at the end of each test) to avoid React root
      leaks if a test throws.
    - Add a coverage test for the unknown-intent fallback.
    - Document that `intent` is decorative — extension authors must keep
      `label` self-descriptive — and clarify the doc comment on
      `SettingsUIShellBadgeIntent`.

    Refs #65825

    * style(settings-ui): satisfy prettier on test querySelector line

    Refs #65825

    * refactor(settings-ui): tighten badge intent guard and test cleanup

    Round-2 review follow-ups:

    - Replace the module-level `VALID_BADGE_INTENTS` Set with an inline
      `getBadgeIntent()` helper using `.includes()`, matching the existing
      `getActionVariant()` pattern in the same file.
    - Restore `document.body.innerHTML = ''` in the test `afterEach` as a
      safety net so a failing assertion before the inline `container.remove()`
      doesn't leak DOM into subsequent tests.
    - Drop the empty `&--default {}` SCSS block — the modifier inherits the
      base `.wc-settings-ui-shell__badge` styling; an empty rule was
      surface-level symmetry, not real symmetry.

    Refs #65825

    * fix(settings-ui): drop forward reference to nonexistent error token

    No `--Alias-bg-bg-surface-error` / `--Alias-text-text-error` token
    exists in the admin design-token set bundled with WooCommerce (the
    admin CSS only resolves success, warning, secondary, and the base
    text token). The `var()` calls landed on a forward reference that was
    always hitting the hex fallback.

    Use direct hex for the error intent until a real token ships — same
    approach StatusBadge takes for its info intent (reusing
    `--Alias-bg-bg-surface-secondary` rather than referencing a
    nonexistent `--Alias-bg-bg-surface-info`).

    Refs #65825

diff --git a/docs/extensions/settings-and-config/settings-ui.md b/docs/extensions/settings-and-config/settings-ui.md
index 2368629def3..56ee776eef0 100644
--- a/docs/extensions/settings-and-config/settings-ui.md
+++ b/docs/extensions/settings-and-config/settings-ui.md
@@ -185,6 +185,24 @@ array(

 Descriptions are sanitized with `wp_kses_post()`. Actions are structured data with `id`, `label`, `href`, optional `variant`, optional `target`, and optional `rel`.

+## Page header
+
+A settings UI page that supplies its own schema (via `SettingsUIPageInterface::get_schema()`) can set header content through the `shell` key. Alongside `title` and `breadcrumbs`, the header supports a `subtitle` and `badges`:
+
+```php
+$schema['shell']['subtitle'] = __( 'Manage your store payment settings.', 'my-plugin' );
+$schema['shell']['badges']   = array(
+	array(
+		'label'  => __( 'Active', 'my-plugin' ),
+		'intent' => 'success', // default | info | success | warning | error
+	),
+);
+```
+
+`subtitle` renders under the page title. Each badge renders as a pill next to the title; `intent` selects its color. Both are plain text and are escaped on render.
+
+`intent` is decorative styling only — it conveys meaning through color. The badge `label` must be self-descriptive so screen-reader and color-blind users get the same information (e.g. prefer `"Active"` or `"Beta"` over generic text). Unknown `intent` values fall back to `default`.
+
 ## Reference migration in WooCommerce core

 The Products settings page is the Core reference migration. With `settings-ui` enabled, the Products tab renders through the settings UI. With the flag disabled, it renders through the existing legacy settings UI.
diff --git a/packages/js/settings-ui/changelog/add-shell-header-fields b/packages/js/settings-ui/changelog/add-shell-header-fields
new file mode 100644
index 00000000000..4ba8e693fd3
--- /dev/null
+++ b/packages/js/settings-ui/changelog/add-shell-header-fields
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add `subtitle` and `badges` fields to the settings UI shell, forwarded to the admin-ui Page header.
diff --git a/packages/js/settings-ui/src/index.ts b/packages/js/settings-ui/src/index.ts
index 48e3815ee7f..3188af34744 100644
--- a/packages/js/settings-ui/src/index.ts
+++ b/packages/js/settings-ui/src/index.ts
@@ -19,6 +19,8 @@ export type {
 	SettingsUISaveStrategy,
 	SettingsUISchema,
 	SettingsUIShell,
+	SettingsUIShellBadge,
+	SettingsUIShellBadgeIntent,
 	SettingsUIShellBreadcrumb,
 	SettingsExtensionRegistration,
 	SettingsExtensionScope,
diff --git a/packages/js/settings-ui/src/settings-ui-page.tsx b/packages/js/settings-ui/src/settings-ui-page.tsx
index c44a4940dc7..b0993ea5d67 100644
--- a/packages/js/settings-ui/src/settings-ui-page.tsx
+++ b/packages/js/settings-ui/src/settings-ui-page.tsx
@@ -98,6 +98,15 @@ 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 getSaveStrategy = ( schema: SettingsUISchema ): SettingsUISaveStrategy =>
 	schema.save || { adapter: 'form_post' };

@@ -387,6 +396,19 @@ const ShellHeader = ( {
 			</nav>
 		) : undefined;

+	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
+					) }` }
+					key={ `${ badge.label }-${ index }` }
+				>
+					{ badge.label }
+				</span>
+		  ) )
+		: undefined;
+
 	const saveButtonLabel = __( 'Save', 'woocommerce' );

 	const actions = showSaveButton ? (
@@ -409,7 +431,9 @@ const ShellHeader = ( {
 			className="wc-settings-ui-shell"
 			headingLevel={ 1 }
 			title={ title }
+			subTitle={ shell.subtitle }
 			breadcrumbs={ breadcrumbs }
+			badges={ badges }
 			actions={ actions }
 			showSidebarToggle={ false }
 		>
diff --git a/packages/js/settings-ui/src/test/header-fields.test.tsx b/packages/js/settings-ui/src/test/header-fields.test.tsx
new file mode 100644
index 00000000000..8267e492a74
--- /dev/null
+++ b/packages/js/settings-ui/src/test/header-fields.test.tsx
@@ -0,0 +1,203 @@
+/**
+ * External dependencies
+ */
+import { createElement } from '@wordpress/element';
+import { act } from 'react';
+import { createRoot } from 'react-dom/client';
+import type { ReactNode } from 'react';
+
+// 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', () => ( {
+	Page: ( {
+		title,
+		subTitle,
+		breadcrumbs,
+		badges,
+		actions,
+		children,
+		className,
+	}: {
+		title?: ReactNode;
+		subTitle?: ReactNode;
+		breadcrumbs?: ReactNode;
+		badges?: ReactNode;
+		actions?: ReactNode;
+		children: ReactNode;
+		className?: string;
+	} ) => (
+		<div className={ className }>
+			<header>
+				{ title }
+				{ breadcrumbs }
+				{ badges }
+				{ subTitle && (
+					<p className="admin-ui-page__header-subtitle">
+						{ subTitle }
+					</p>
+				) }
+				{ actions }
+			</header>
+			{ children }
+		</div>
+	),
+} ) );
+
+/**
+ * Internal dependencies
+ */
+import { SettingsUIPage } from '../settings-ui-page';
+import { __resetRegistry } from '../registry';
+import type { SettingsUISchema } from '../types';
+
+globalThis.IS_REACT_ACT_ENVIRONMENT = true;
+
+const renderElement = ( element: JSX.Element ) => {
+	const container = document.createElement( 'div' );
+	document.body.appendChild( container );
+	const root = createRoot( container );
+
+	act( () => {
+		root.render( element );
+	} );
+
+	return { container, root };
+};
+
+const baseSchema = (
+	shell: SettingsUISchema[ 'shell' ]
+): SettingsUISchema => ( {
+	id: 'test_page',
+	title: 'Test page',
+	save: { adapter: 'none' },
+	shell,
+	groups: {
+		main: {
+			id: 'main',
+			title: 'Main',
+			fields: [
+				{
+					id: 'field_a',
+					label: 'Field A',
+					type: 'text',
+					value: '',
+					save: { adapter: 'none' },
+				},
+			],
+		},
+	},
+} );
+
+describe( 'settings UI shell header fields', () => {
+	afterEach( () => {
+		__resetRegistry();
+		// Safety net for failures between render and the inline `container.remove()`.
+		document.body.innerHTML = '';
+	} );
+
+	it( 'renders the shell subtitle', () => {
+		const { container, root } = renderElement(
+			<SettingsUIPage
+				schema={ baseSchema( {
+					title: 'Test page',
+					subtitle: 'Manage your test settings.',
+				} ) }
+				page="test_page"
+			/>
+		);
+
+		// Anchor on the forwarded text, not the mock's structural class —
+		// this would catch a `subtitle` → `subTitle` mapping bug regardless of how the mock renders.
+		expect( container.textContent ).toContain(
+			'Manage your test settings.'
+		);
+
+		act( () => root.unmount() );
+		container.remove();
+	} );
+
+	it( 'renders badges with their intent class', () => {
+		const { container, root } = renderElement(
+			<SettingsUIPage
+				schema={ baseSchema( {
+					title: 'Test page',
+					badges: [
+						{ label: 'Active', intent: 'success' },
+						{ label: 'Beta' },
+					],
+				} ) }
+				page="test_page"
+			/>
+		);
+
+		const badges = container.querySelectorAll(
+			'.wc-settings-ui-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 );
+		// Defaults to the neutral intent when none is provided.
+		expect(
+			badges[ 1 ].classList.contains(
+				'wc-settings-ui-shell__badge--default'
+			)
+		).toBe( true );
+
+		act( () => root.unmount() );
+		container.remove();
+	} );
+
+	it( 'falls back to the default intent for an unknown intent value', () => {
+		const { container, root } = renderElement(
+			<SettingsUIPage
+				schema={ baseSchema( {
+					title: 'Test page',
+					// Simulate an extension passing an unrecognized intent string at runtime
+					// (TS unions are erased; PHP-supplied schemas can carry arbitrary strings).
+					badges: [
+						{
+							label: 'Mystery',
+							intent: 'magic' as never,
+						},
+					],
+				} ) }
+				page="test_page"
+			/>
+		);
+
+		const badge = container.querySelector( '.wc-settings-ui-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 );
+
+		act( () => root.unmount() );
+		container.remove();
+	} );
+
+	it( 'omits subtitle and badges when not provided', () => {
+		const { container, root } = renderElement(
+			<SettingsUIPage
+				schema={ baseSchema( { title: 'Test page' } ) }
+				page="test_page"
+			/>
+		);
+
+		expect(
+			container.querySelector( '.admin-ui-page__header-subtitle' )
+		).toBeNull();
+		expect(
+			container.querySelector( '.wc-settings-ui-shell__badge' )
+		).toBeNull();
+
+		act( () => root.unmount() );
+		container.remove();
+	} );
+} );
diff --git a/packages/js/settings-ui/src/types.ts b/packages/js/settings-ui/src/types.ts
index 8b360fc615f..334bad64204 100644
--- a/packages/js/settings-ui/src/types.ts
+++ b/packages/js/settings-ui/src/types.ts
@@ -72,9 +72,29 @@ export type SettingsUIShellNavigationItem = {
 	active?: boolean;
 };

+/**
+ * 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.
+ */
+export type SettingsUIShellBadgeIntent =
+	| 'default'
+	| 'info'
+	| 'success'
+	| 'warning'
+	| 'error';
+
+export type SettingsUIShellBadge = {
+	label: string;
+	intent?: SettingsUIShellBadgeIntent;
+};
+
 export type SettingsUIShell = {
 	title?: string;
+	subtitle?: string;
 	breadcrumbs?: SettingsUIShellBreadcrumb[];
+	badges?: SettingsUIShellBadge[];
 	navigation?: SettingsUIShellNavigationItem[];
 	sectionNavigation?: SettingsUIShellNavigationItem[];
 	navigationComponent?: string;
diff --git a/plugins/woocommerce/changelog/add-settings-ui-shell-header-fields b/plugins/woocommerce/changelog/add-settings-ui-shell-header-fields
new file mode 100644
index 00000000000..6bf68477fe3
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-settings-ui-shell-header-fields
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add styling for settings UI shell header badges.
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 d7b57058491..88d460dc7ec 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
@@ -134,6 +134,43 @@ body.woocommerce_page_wc-settings.woocommerce-settings-ui-page {
 		}
 	}

+	.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;
 		margin: 0;