Commit f1a778a88b9 for woocommerce

commit f1a778a88b91421e768d006c7bffff729b3e2d17
Author: Daniel Mallory <daniel.mallory@automattic.com>
Date:   Wed Jul 15 15:20:23 2026 +0100

    Settings UI: shell header follows the page registration (#66437)

    * update(settings-ui): hide the shell header on top-level settings pages

    The shell rendered its header (page title and top save button) on every
    settings UI page. Pages registered at the top level of settings already
    sit under the wc-settings tab bar, so the header read as a second
    heading, and design reserves it for drill-down pages like Payments.

    The request context now stamps shell.header = hidden on every schema it
    resolves, since everything it resolves is registered at the top level
    (a settings tab or a registered section). The client renders the header
    only when the schema carries shell.header = visible, and otherwise
    renders the save button at the bottom of the content column, matching
    the classic settings pages. A future drill-down resolution path can
    decide differently when one exists.

    Refs WOOPRD-3565

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * update(settings-ui): show the shell header on payments drill-down pages

    The shell header visibility treated every Settings UI page as top-level,
    so a payments extension page (a registered section under the Payments
    tab) rendered with the top-level settings tabs, a hidden header, and a
    bottom save button.

    Design guidance distinguishes two registration cases: pages registered
    at the top level of settings hide the header and keep the tabs, while
    drill-down pages under Payments show the header with breadcrumbs in
    place of the tabs, following the pattern the Payments React pages
    already use.

    Derive drill-down pages from the registration in the request context: a
    section of the Payments tab gets a visible header, breadcrumbs
    defaulting to a link back to the parent tab, and no default sibling
    section navigation. The settings template hides the top-level tabs for
    drill-downs through the same mechanism as the Payments React sections.
    The registered-section adapter now titles its page with the section
    label rather than the parent tab label, and the default sibling
    navigation moved from the legacy adapter into the context so the
    drill-down default can apply; every rendering path goes through the
    context, which keeps supplying it for top-level pages.

    Refs WOOPRD-3565

    * update(settings-ui): render the shell header with shell-owned markup

    The shell passed title, breadcrumbs, badges, and actions into the
    @wordpress/admin-ui Page header slots. That header renders breadcrumbs
    and badges inline in the same space-between row as the title, which
    pushed the breadcrumbs against the save button and clipped them. The
    ariaLabel passed to Page was also silently dropped, since Page does not
    accept that prop, leaving the region unlabeled when the header is
    hidden.

    Own the header markup in the shell, following the confirmed payments
    settings design: a single row with the breadcrumbs leading into the
    page title (parent link, slash separator, current page), badges beside
    the title, and actions on the right, with the subtitle below. The shell
    wraps in admin-ui's NavigableRegion directly, which keeps the landmark
    region and gives it a working aria-label from the page title. The
    breadcrumb margins carry #mainform specificity because extensions style
    bare nav elements inside the settings form (WooPayments ships one at id
    specificity).

    Refs WOOPRD-3565

    * update(settings-ui): match the wc-admin header background to the page

    The wc-admin header bar (the "Settings" title above the tabs) kept its
    default grey background on Settings UI pages while everything below it
    renders white, leaving a grey band the settings page designs do not
    have. Paint it white with the rest of the page surfaces.

    Refs WOOPRD-3565

    * update(settings-ui): keep the classic section links on top-level pages

    The shell rendered sibling-section navigation as an underlined tab
    strip on top-level settings pages, doubling up with the settings tabs
    above it. The confirmed header designs keep the classic section text
    links there instead.

    Stamp sectionNavigation empty for top-level schemas, keep the classic
    output_sections rendering on top-level pages (drill-down pages still
    remove it in favour of breadcrumbs), and drop the subsubsub restyle so
    the links render natively. The SettingsSectionNavigation builder has
    no default navigation left to build and has not shipped in a release,
    so it is removed. The unsaved-changes prompt now also covers the
    classic links, which render outside the shell.

    Refs WOOPRD-3565

    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

    * Fix shell region label falling back when the schema has no title

    * Update get_schema docblock for drill-down-only section navigation

    * Refine Settings UI shell header layout

    ---------

    Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

diff --git a/docs/extensions/settings-and-config/settings-ui.md b/docs/extensions/settings-and-config/settings-ui.md
index e92e859b8e3..f6e6c017cd2 100644
--- a/docs/extensions/settings-and-config/settings-ui.md
+++ b/docs/extensions/settings-and-config/settings-ui.md
@@ -159,11 +159,10 @@ When `get_settings_ui_page()` returns a `SettingsUIPageInterface`, WooCommerce u

 ### Section navigation on native pages

-The Settings UI shell renders sibling-section navigation from the `shell.sectionNavigation` schema key. Native page schemas control it through three states:
+The Settings UI shell renders sibling-section navigation from the `shell.sectionNavigation` schema key. How it applies depends on where the page is registered:

--   **Omit the key** - WooCommerce injects the default navigation listing every section of the settings page, matching the legacy settings adapter. This is the right choice for most pages.
--   **Set a custom array** - the page owns navigation entirely. Each entry needs `id`, `label`, `href`, and `active` keys.
--   **Set an empty array** - no shell navigation renders, for pages that provide their own in-page navigation.
+-   **Top-level pages** never render shell section navigation. The classic section links render with the settings header instead, and any schema-provided value is cleared.
+-   **Drill-down pages** default to no section navigation, since the header breadcrumbs replace it. Setting a custom array renders it as tabs under the header; each entry needs `id`, `label`, `href`, and `active` keys.

 ```php
 // Custom navigation entry shape.
@@ -306,7 +305,12 @@ Descriptions are sanitized with `wp_kses_post()`. Actions are structured data wi

 ## 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`:
+The shell header (the page title, badges, breadcrumbs, and the top save button) is reserved for drill-down pages. WooCommerce decides this from the page registration, so a page cannot change it through its schema:
+
+- Pages registered at the top level of settings, whether a `WC_Settings_Page` tab or a registered section, render without the header. The top-level settings tabs stay visible, pages with sections keep the classic section links, and the save button appears at the bottom of the page.
+- Sections of the Payments tab render as drill-down pages. The header replaces the top-level settings tabs, and its breadcrumbs default to a link back to the Payments tab when the schema does not provide any. The save button renders in the header.
+
+A settings UI page that supplies its own schema (via `SettingsUIPageInterface::get_schema()`) can set header content through the `shell` key for drill-down pages. Alongside `title` and `breadcrumbs`, the header supports a `subtitle` and `badges`:

 ```php
 $schema['shell']['subtitle'] = __( 'Manage your store payment settings.', 'my-plugin' );
diff --git a/packages/js/settings-ui/changelog/update-shell-header-top-level-pages b/packages/js/settings-ui/changelog/update-shell-header-top-level-pages
new file mode 100644
index 00000000000..4cbbfc5c3a2
--- /dev/null
+++ b/packages/js/settings-ui/changelog/update-shell-header-top-level-pages
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Hide the shell header on top-level settings pages and render the save button at the bottom of the page. The header is opt-in via shell.header for drill-down pages.
diff --git a/packages/js/settings-ui/src/settings-ui-page.tsx b/packages/js/settings-ui/src/settings-ui-page.tsx
index 6a19ccc21ae..7875df2880e 100644
--- a/packages/js/settings-ui/src/settings-ui-page.tsx
+++ b/packages/js/settings-ui/src/settings-ui-page.tsx
@@ -1,7 +1,7 @@
 /**
  * External dependencies
  */
-import { Page } from '@wordpress/admin-ui';
+import { NavigableRegion } from '@wordpress/admin-ui';
 import { Button } from '@wordpress/components';
 import {
 	Component,
@@ -430,23 +430,18 @@ const ShellHeader = ( {
 	context,
 	values,
 	initialValues,
-	isDirty,
-	isSaving,
-	saveStrategy,
-	onSave,
+	actions,
 	children,
 }: {
 	schema: SettingsUISchema;
 	context: SettingsFieldContext;
 	values: SettingsValues;
 	initialValues: SettingsValues;
-	isDirty: boolean;
-	isSaving: boolean;
-	saveStrategy: SettingsUISaveStrategy;
-	onSave: () => void | Promise< boolean >;
+	actions?: ReactNode;
 	children: ReactNode;
 } ) => {
 	const shell = schema.shell || {};
+	const showHeader = shell.header === 'visible';
 	const title = shell.title || schema.title;
 	const NavigationComponent = shell.navigationComponent
 		? resolveRegionComponent( shell.navigationComponent, context )
@@ -456,9 +451,6 @@ const ShellHeader = ( {
 			( shell.sectionNavigation && shell.sectionNavigation.length > 0 ) ||
 			NavigationComponent
 	);
-	const showSaveButton = saveStrategy.adapter !== 'none';
-	const saveButtonType =
-		saveStrategy.adapter === 'form_post' ? 'submit' : 'button';

 	const breadcrumbs =
 		shell.breadcrumbs && shell.breadcrumbs.length > 0 ? (
@@ -484,6 +476,7 @@ const ShellHeader = ( {
 	const badges = shell.badges?.length
 		? shell.badges.map( ( badge, index ) => (
 				<Badge
+					className="wc-settings-ui-shell__badge"
 					intent={ getBadgeIntent( badge.intent ) }
 					key={ `${ badge.label }-${ index }` }
 				>
@@ -492,32 +485,49 @@ const ShellHeader = ( {
 		  ) )
 		: undefined;

-	const saveButtonLabel = __( 'Save', 'woocommerce' );
-
-	const actions = showSaveButton ? (
-		<Button
-			className="woocommerce-save-button"
-			variant="primary"
-			type={ saveButtonType }
-			name="save"
-			value={ saveButtonLabel }
-			disabled={ ! isDirty || isSaving }
-			isBusy={ isSaving }
-			onClick={ onSave }
-		>
-			{ saveButtonLabel }
-		</Button>
-	) : undefined;
-
 	return (
-		<Page
+		<NavigableRegion
 			className="wc-settings-ui-shell"
-			title={ title }
-			subTitle={ shell.subtitle }
-			breadcrumbs={ breadcrumbs }
-			badges={ badges }
-			actions={ actions }
+			ariaLabel={ title || __( 'Settings', 'woocommerce' ) }
 		>
+			{ showHeader ? (
+				<Stack
+					className="wc-settings-ui-shell__header"
+					direction="column"
+					gap="sm"
+					render={ <header /> }
+				>
+					<Stack
+						className="wc-settings-ui-shell__header-row"
+						direction="row"
+						align="center"
+					>
+						{ breadcrumbs }
+						<h2 className="wc-settings-ui-shell__title">
+							{ title }
+						</h2>
+						{ badges }
+						{ actions ? (
+							<Stack
+								className="wc-settings-ui-shell__header-actions"
+								direction="row"
+								gap="sm"
+							>
+								{ actions }
+							</Stack>
+						) : null }
+					</Stack>
+					{ shell.subtitle ? (
+						<Text
+							className="wc-settings-ui-shell__subtitle"
+							variant="body-md"
+							render={ <p /> }
+						>
+							{ shell.subtitle }
+						</Text>
+					) : null }
+				</Stack>
+			) : null }
 			{ hasNavigation ? (
 				<div className="wc-settings-ui-shell__navigation">
 					{ shell.navigation && shell.navigation.length > 0 ? (
@@ -543,7 +553,7 @@ const ShellHeader = ( {
 					{ shell.sectionNavigation &&
 					shell.sectionNavigation.length > 0 ? (
 						<nav
-							className="wc-settings-ui-shell__tabs wc-settings-ui-shell__tabs--secondary"
+							className="wc-settings-ui-shell__tabs"
 							aria-label={ __(
 								'Settings sections',
 								'woocommerce'
@@ -575,7 +585,7 @@ const ShellHeader = ( {
 				</div>
 			) : null }
 			{ children }
-		</Page>
+		</NavigableRegion>
 	);
 };

@@ -778,7 +788,10 @@ export const SettingsUIPage = ( {

 			if (
 				! ( target instanceof Element ) ||
-				! target.closest( '.wc-settings-ui-shell' ) ||
+				// The classic section links render outside the shell on top-level pages.
+				! target.closest(
+					'.wc-settings-ui-shell, #mainform .subsubsub'
+				) ||
 				! shouldPromptForNavigation( event )
 			) {
 				return;
@@ -874,20 +887,37 @@ export const SettingsUIPage = ( {
 	const formPostFields =
 		saveStrategy.adapter === 'form_post' ? getAllFields( schema ) : [];

+	const showHeader = schema.shell?.header === 'visible';
+	const saveButtonLabel = __( 'Save', 'woocommerce' );
+	const saveButton =
+		saveStrategy.adapter !== 'none' ? (
+			<Button
+				className="woocommerce-save-button"
+				variant="primary"
+				type={
+					saveStrategy.adapter === 'form_post' ? 'submit' : 'button'
+				}
+				name="save"
+				value={ saveButtonLabel }
+				disabled={ ! isDirty || isSaving }
+				isBusy={ isSaving }
+				onClick={ () =>
+					saveStrategy.adapter === 'form_post'
+						? submitSettingsForm()
+						: handleCustomSave()
+				}
+			>
+				{ saveButtonLabel }
+			</Button>
+		) : undefined;
+
 	return (
 		<ShellHeader
 			schema={ schema }
 			context={ context }
 			values={ values }
 			initialValues={ initialValues }
-			isDirty={ isDirty }
-			isSaving={ isSaving }
-			saveStrategy={ saveStrategy }
-			onSave={
-				saveStrategy.adapter === 'form_post'
-					? submitSettingsForm
-					: handleCustomSave
-			}
+			actions={ saveButton }
 		>
 			{ pendingNavigation ? (
 				<UnsavedChangesModal
@@ -960,6 +990,11 @@ export const SettingsUIPage = ( {
 						</Card.Root>
 					</section>
 				) ) }
+				{ ! showHeader && saveButton ? (
+					<div className="wc-settings-ui__footer-actions">
+						{ saveButton }
+					</div>
+				) : null }
 			</Stack>
 			{ formPostFields.length > 0 ? (
 				<div className="wc-settings-ui__hidden-inputs">
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 1caf474959b..1e15300e23c 100644
--- a/packages/js/settings-ui/src/test/header-fields.test.tsx
+++ b/packages/js/settings-ui/src/test/header-fields.test.tsx
@@ -22,38 +22,18 @@ jest.mock( '@wordpress/ui', () => ( {
 	),
 } ) );

-// Surface the header props the real admin-ui Page would render so the shell
-// header wiring (subtitle, badges) can be asserted.
+// Mirror the real admin-ui NavigableRegion, which wraps the shell in a labeled region.
 jest.mock( '@wordpress/admin-ui', () => ( {
-	Page: ( {
-		title,
-		subTitle,
-		breadcrumbs,
-		badges,
-		actions,
+	NavigableRegion: ( {
 		children,
 		className,
+		ariaLabel,
 	}: {
-		title?: ReactNode;
-		subTitle?: ReactNode;
-		breadcrumbs?: ReactNode;
-		badges?: ReactNode;
-		actions?: ReactNode;
 		children: ReactNode;
 		className?: string;
+		ariaLabel?: string;
 	} ) => (
-		<div className={ className }>
-			<header>
-				{ title }
-				{ breadcrumbs }
-				{ badges }
-				{ subTitle && (
-					<p className="admin-ui-page__header-subtitle">
-						{ subTitle }
-					</p>
-				) }
-				{ actions }
-			</header>
+		<div className={ className } role="region" aria-label={ ariaLabel }>
 			{ children }
 		</div>
 	),
@@ -115,6 +95,7 @@ describe( 'settings UI shell header fields', () => {
 		const { container, root } = renderElement(
 			<SettingsUIPage
 				schema={ baseSchema( {
+					header: 'visible',
 					title: 'Test page',
 					subtitle: 'Manage your test settings.',
 				} ) }
@@ -122,11 +103,10 @@ describe( 'settings UI shell header fields', () => {
 			/>
 		);

-		// 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.'
+		const subtitle = container.querySelector(
+			'.wc-settings-ui-shell__subtitle'
 		);
+		expect( subtitle?.textContent ).toBe( 'Manage your test settings.' );

 		act( () => root.unmount() );
 		container.remove();
@@ -136,6 +116,7 @@ describe( 'settings UI shell header fields', () => {
 		const { container, root } = renderElement(
 			<SettingsUIPage
 				schema={ baseSchema( {
+					header: 'visible',
 					title: 'Test page',
 					badges: [
 						{ label: 'Active', intent: 'success' },
@@ -163,6 +144,7 @@ describe( 'settings UI shell header fields', () => {
 		const { container, root } = renderElement(
 			<SettingsUIPage
 				schema={ baseSchema( {
+					header: 'visible',
 					title: 'Test page',
 					// Simulate an extension passing an unrecognized intent string at runtime
 					// (TS unions are erased; PHP-supplied schemas can carry arbitrary strings).
@@ -188,13 +170,16 @@ describe( 'settings UI shell header fields', () => {
 	it( 'omits subtitle and badges when not provided', () => {
 		const { container, root } = renderElement(
 			<SettingsUIPage
-				schema={ baseSchema( { title: 'Test page' } ) }
+				schema={ baseSchema( {
+					header: 'visible',
+					title: 'Test page',
+				} ) }
 				page="test_page"
 			/>
 		);

 		expect(
-			container.querySelector( '.admin-ui-page__header-subtitle' )
+			container.querySelector( '.wc-settings-ui-shell__subtitle' )
 		).toBeNull();
 		expect(
 			container.querySelector( '[data-testid="shell-badge"]' )
diff --git a/packages/js/settings-ui/src/test/html-rendering.test.tsx b/packages/js/settings-ui/src/test/html-rendering.test.tsx
index 433c8def161..a6bed3d6149 100644
--- a/packages/js/settings-ui/src/test/html-rendering.test.tsx
+++ b/packages/js/settings-ui/src/test/html-rendering.test.tsx
@@ -7,20 +7,13 @@ import { createRoot } from 'react-dom/client';
 import type { ReactNode } from 'react';

 jest.mock( '@wordpress/admin-ui', () => ( {
-	Page: ( {
-		actions,
+	NavigableRegion: ( {
 		children,
 		className,
 	}: {
-		actions?: ReactNode;
 		children: ReactNode;
 		className?: string;
-	} ) => (
-		<div className={ className }>
-			{ actions }
-			{ children }
-		</div>
-	),
+	} ) => <div className={ className }>{ children }</div>,
 } ) );

 /**
@@ -357,6 +350,68 @@ describe( 'settings HTML rendering', () => {
 		container.remove();
 	} );

+	it( 'prompts before navigating away through the classic section links', () => {
+		const schema: SettingsUISchema = {
+			id: 'test-page',
+			title: 'Test page',
+			section: 'default',
+			save: { adapter: 'form_post' },
+			groups: {
+				general: {
+					id: 'general',
+					fields: [
+						{
+							id: 'test_field',
+							label: 'Test field',
+							type: 'text',
+							value: 'Initial value',
+						},
+					],
+				},
+			},
+		};
+
+		const { container, form, root } = renderElementInMainForm(
+			<SettingsUIPage schema={ schema } />
+		);
+
+		// Classic section links render inside #mainform but outside the shell.
+		const sectionLinks = document.createElement( 'ul' );
+		sectionLinks.className = 'subsubsub';
+		sectionLinks.innerHTML =
+			'<li><a href="https://example.com/inventory">Inventory</a></li>';
+		form.insertBefore( sectionLinks, container );
+
+		try {
+			const input = container.querySelector( 'input[type="text"]' );
+			const link = sectionLinks.querySelector( 'a' );
+
+			expect( input ).toBeInstanceOf( HTMLInputElement );
+			expect( link ).not.toBeNull();
+
+			act( () => {
+				changeTextInput( input as HTMLInputElement, 'Changed value' );
+			} );
+
+			act( () => {
+				link?.dispatchEvent(
+					new MouseEvent( 'click', {
+						bubbles: true,
+						cancelable: true,
+						button: 0,
+					} )
+				);
+			} );
+
+			expect( document.body.textContent ).toContain(
+				'You have unsaved changes'
+			);
+		} finally {
+			act( () => root.unmount() );
+			form.remove();
+		}
+	} );
+
 	it( 'submits form-post saves with the pending destination', () => {
 		const requestSubmit = jest
 			.spyOn( HTMLFormElement.prototype, 'requestSubmit' )
diff --git a/packages/js/settings-ui/src/test/shell-header-visibility.test.tsx b/packages/js/settings-ui/src/test/shell-header-visibility.test.tsx
new file mode 100644
index 00000000000..ae195ebfccc
--- /dev/null
+++ b/packages/js/settings-ui/src/test/shell-header-visibility.test.tsx
@@ -0,0 +1,169 @@
+/**
+ * External dependencies
+ */
+import { createElement } from '@wordpress/element';
+import { act } from 'react';
+import { createRoot } from 'react-dom/client';
+import type { ReactNode } from 'react';
+
+// Mirror the real admin-ui NavigableRegion, which wraps the shell in a labeled region.
+jest.mock( '@wordpress/admin-ui', () => ( {
+	NavigableRegion: ( {
+		children,
+		className,
+		ariaLabel,
+	}: {
+		children: ReactNode;
+		className?: string;
+		ariaLabel?: string;
+	} ) => (
+		<div className={ className } role="region" aria-label={ ariaLabel }>
+			{ 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' ],
+	saveAdapter: 'form_post' | 'none' = 'form_post'
+): SettingsUISchema => ( {
+	id: 'test_page',
+	title: 'Test page',
+	save: { adapter: saveAdapter },
+	shell,
+	groups: {
+		main: {
+			id: 'main',
+			title: 'Main',
+			fields: [
+				{
+					id: 'field_a',
+					label: 'Field A',
+					type: 'text',
+					value: '',
+					save: { adapter: saveAdapter },
+				},
+			],
+		},
+	},
+} );
+
+describe( 'settings UI shell header visibility', () => {
+	afterEach( () => {
+		__resetRegistry();
+		document.body.innerHTML = '';
+	} );
+
+	it( 'labels the shell region with a fallback when the schema has no title', () => {
+		const schema = baseSchema( {} );
+		delete schema.title;
+
+		const { container, root } = renderElement(
+			<SettingsUIPage schema={ schema } page="test_page" />
+		);
+
+		expect(
+			container
+				.querySelector( '.wc-settings-ui-shell' )
+				?.getAttribute( 'aria-label' )
+		).toBe( 'Settings' );
+
+		act( () => {
+			root.unmount();
+		} );
+	} );
+
+	it( 'hides the header and saves from the page footer by default', () => {
+		const { container, root } = renderElement(
+			<SettingsUIPage
+				schema={ baseSchema( { title: 'Test page' } ) }
+				page="test_page"
+			/>
+		);
+
+		expect(
+			container.querySelector( '.wc-settings-ui-shell__header' )
+		).toBeNull();
+		expect( container.textContent ).not.toContain( 'Test page' );
+		expect(
+			container
+				.querySelector( '.wc-settings-ui-shell' )
+				?.getAttribute( 'aria-label' )
+		).toBe( 'Test page' );
+
+		const footerSaveButton = container.querySelector(
+			'.wc-settings-ui .wc-settings-ui__footer-actions .woocommerce-save-button'
+		);
+		expect( footerSaveButton ).not.toBeNull();
+
+		act( () => root.unmount() );
+		container.remove();
+	} );
+
+	it( 'shows the header with the top save button when the shell opts in', () => {
+		const { container, root } = renderElement(
+			<SettingsUIPage
+				schema={ baseSchema( {
+					header: 'visible',
+					title: 'Test page',
+				} ) }
+				page="test_page"
+			/>
+		);
+
+		const header = container.querySelector(
+			'.wc-settings-ui-shell__header'
+		);
+		expect( header ).not.toBeNull();
+		expect( header?.textContent ).toContain( 'Test page' );
+		expect(
+			header?.querySelector( '.woocommerce-save-button' )
+		).not.toBeNull();
+		expect(
+			container.querySelector( '.wc-settings-ui__footer-actions' )
+		).toBeNull();
+
+		act( () => root.unmount() );
+		container.remove();
+	} );
+
+	it( 'renders no save button anywhere when the save adapter is none', () => {
+		const { container, root } = renderElement(
+			<SettingsUIPage
+				schema={ baseSchema( { title: 'Test page' }, 'none' ) }
+				page="test_page"
+			/>
+		);
+
+		expect(
+			container.querySelector( '.woocommerce-save-button' )
+		).toBeNull();
+		expect(
+			container.querySelector( '.wc-settings-ui__footer-actions' )
+		).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 a7b631342db..b1db7f2ac2d 100644
--- a/packages/js/settings-ui/src/types.ts
+++ b/packages/js/settings-ui/src/types.ts
@@ -91,6 +91,11 @@ export type SettingsUIShellBadge = {
 };

 export type SettingsUIShell = {
+	/**
+	 * Header visibility: drill-down pages show it, top-level pages hide it
+	 * and save from the bottom of the page. Defaults to 'hidden'.
+	 */
+	header?: 'visible' | 'hidden';
 	title?: string;
 	subtitle?: string;
 	breadcrumbs?: SettingsUIShellBreadcrumb[];
diff --git a/plugins/woocommerce/changelog/update-settings-ui-shell-header-top-level b/plugins/woocommerce/changelog/update-settings-ui-shell-header-top-level
new file mode 100644
index 00000000000..bc8b1b95f85
--- /dev/null
+++ b/plugins/woocommerce/changelog/update-settings-ui-shell-header-top-level
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Settings UI: hide the shell header for pages registered at the top level of settings, which keep the classic section links and a save button at the bottom of the page. Payments drill-down pages show the header with breadcrumbs in place of the top-level settings tabs.
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 7f35fb26a7a..6eadc8673a7 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
@@ -10,6 +10,10 @@ body.woocommerce_page_wc-settings.woocommerce-settings-ui-page {
 		background-color: var(--wpds-color-background-surface-neutral);
 	}

+	.woocommerce-layout__header {
+		background-color: var(--wpds-color-background-surface-neutral-strong);
+	}
+
 	#mainform {
 		padding: 0 48px 48px;
 	}
@@ -18,71 +22,6 @@ body.woocommerce_page_wc-settings.woocommerce-settings-ui-page {
 		display: none;
 	}

-	#mainform .subsubsub:not(.list-table-filters) {
-		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;
-		float: none;
-		gap: 0;
-		margin: 0 -48px 48px;
-		overflow-x: auto;
-		padding: 0 48px;
-		width: auto;
-
-		li {
-			color: transparent;
-			margin: 0;
-			padding: 0;
-		}
-
-		li a,
-		li:first-child a,
-		li:last-child a {
-			align-items: center;
-			color: var(--wpds-color-foreground-interactive-neutral);
-			display: inline-flex;
-			font-size: var(--wpds-typography-font-size-md);
-			font-weight: var(--wpds-typography-font-weight-medium);
-			height: 48px;
-			line-height: var(--wpds-typography-line-height-sm);
-			margin: 0;
-			padding: 0 var(--wpds-dimension-padding-lg);
-			position: relative;
-			text-decoration: none;
-			white-space: nowrap;
-
-			&:focus,
-			&:hover,
-			&:active {
-				box-shadow: none;
-				color: var(--wpds-color-foreground-interactive-brand);
-				outline: none;
-			}
-
-			&.current {
-				color: var(--wpds-color-foreground-interactive-neutral);
-				font-weight: var(--wpds-typography-font-weight-medium);
-
-				&::after {
-					background: var(--wpds-color-stroke-interactive-brand);
-					border-radius: 999px;
-					bottom: 0;
-					content: "";
-					height: 2px;
-					left: 0;
-					position: absolute;
-					right: 0;
-				}
-			}
-		}
-
-		+ br.clear {
-			display: none;
-		}
-	}
-
 	.wc-settings-ui-shell {
 		background: transparent;
 		box-sizing: border-box;
@@ -91,36 +30,58 @@ body.woocommerce_page_wc-settings.woocommerce-settings-ui-page {
 		height: auto;
 		line-height: var(--wpds-typography-line-height-sm);
 		margin: 0 -48px;
+
+		&:has(.wc-settings-ui-shell__navigation) .wc-settings-ui-shell__header {
+			border-bottom: 0;
+		}
 	}

-	.wc-settings-ui-shell > :first-child {
-		padding-left: 48px;
-		padding-right: 48px;
-		position: static;
+	.wc-settings-ui-shell__header {
+		background: var(--wpds-color-background-surface-neutral-strong);
+		border-bottom: 1px solid var(--wpds-color-stroke-surface-neutral-weak);
+		padding: 24px 48px;
 	}

-	.wc-settings-ui-shell {
-		&:has(.wc-settings-ui-shell__navigation) > :first-child {
-			border-bottom: 0;
-		}
+	.wc-settings-ui-shell__title {
+		font-size: 16px;
+		font-weight: var(--wpds-typography-font-weight-medium);
+		line-height: var(--wpds-typography-line-height-md);
+		margin: 0;
+	}
+
+	.wc-settings-ui-shell__header-row > .wc-settings-ui-shell__badge {
+		margin-inline-start: var(--wpds-dimension-gap-sm, 8px);
+	}
+
+	.wc-settings-ui-shell__header-actions {
+		flex: 0 0 auto;
+		margin-inline-start: auto;
 	}

-	.wc-settings-ui-shell__breadcrumbs {
+	.wc-settings-ui-shell__subtitle {
 		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;
 	}

+	// Carries #mainform so the margins beat extension styles targeting bare
+	// `nav` elements inside the settings form (WooPayments ships one).
+	#mainform .wc-settings-ui-shell__breadcrumbs {
+		font-size: 16px;
+		line-height: var(--wpds-typography-line-height-md);
+		margin: 0;
+	}
+
+	// The page title always follows the breadcrumbs, so every crumb gets a
+	// trailing separator.
 	.wc-settings-ui-shell__breadcrumb {
-		&:not(:last-child)::after {
+		&::after {
+			color: var(--wpds-color-foreground-content-neutral-weak);
 			content: "/";
-			margin: 0 8px;
+			margin: 0 var(--wpds-dimension-gap-sm, 8px);
 		}

 		a {
-			color: var(--wpds-color-foreground-interactive-neutral-weak);
-			text-decoration: none;
+			color: var(--wpds-color-foreground-content-neutral);
+			text-decoration: underline;

 			&:focus,
 			&:hover {
@@ -145,10 +106,6 @@ body.woocommerce_page_wc-settings.woocommerce-settings-ui-page {
 		width: 100%;
 	}

-	.wc-settings-ui-shell__tabs--secondary {
-		border-bottom: 0;
-	}
-
 	.wc-settings-ui-shell__tab {
 		align-items: center;
 		color: var(--wpds-color-foreground-interactive-neutral);
@@ -317,19 +274,12 @@ body.woocommerce_page_wc-settings.woocommerce-settings-ui-page {
 			padding: 0 24px 32px;
 		}

-		#mainform .subsubsub:not(.list-table-filters) {
-			margin-left: -24px;
-			margin-right: -24px;
-			padding-left: 24px;
-			padding-right: 24px;
-		}
-
 		.wc-settings-ui-shell {
 			margin-left: -24px;
 			margin-right: -24px;
 		}

-		.wc-settings-ui-shell > :first-child {
+		.wc-settings-ui-shell__header {
 			padding-left: 24px;
 			padding-right: 24px;
 		}
diff --git a/plugins/woocommerce/includes/admin/views/html-admin-settings.php b/plugins/woocommerce/includes/admin/views/html-admin-settings.php
index 319a6d9b481..378fa9c4484 100644
--- a/plugins/woocommerce/includes/admin/views/html-admin-settings.php
+++ b/plugins/woocommerce/includes/admin/views/html-admin-settings.php
@@ -36,8 +36,6 @@ if ( ! $tab_exists ) {
 	exit;
 }

-$hide_nav = 'checkout' === $current_tab && in_array( $current_section, array( 'offline', 'bacs', 'cheque', 'cod' ), true );
-
 // Resolve the Settings UI context for this request, falling back to legacy
 // rendering when the settings SDK classes are unavailable. The class can be
 // missing mid-update, when this file has been replaced on disk but the cached
@@ -51,10 +49,16 @@ try {
 	$settings_ui_context = null;
 }

+// Drill-down pages replace the top-level settings tabs with their own header.
+$hide_nav = ( 'checkout' === $current_tab && in_array( $current_section, array( 'offline', 'bacs', 'cheque', 'cod' ), true ) )
+	|| ( $settings_ui_context && $settings_ui_context->is_drill_down() );
+
 $settings_ui_settings_page = $settings_ui_context ? $settings_ui_context->get_settings_page() : null;
 $is_settings_ui_page       = null !== $settings_ui_settings_page;

-if ( $settings_ui_settings_page instanceof WC_Settings_Page ) {
+// Drill-down pages replace the section links with header breadcrumbs. Top-level
+// pages keep the classic section links.
+if ( $settings_ui_settings_page instanceof WC_Settings_Page && $settings_ui_context->is_drill_down() ) {
 	remove_action( 'woocommerce_sections_' . $current_tab, array( $settings_ui_settings_page, 'output_sections' ) );
 }

diff --git a/plugins/woocommerce/src/Admin/Settings/SettingsUIPageInterface.php b/plugins/woocommerce/src/Admin/Settings/SettingsUIPageInterface.php
index c9dbcf7686c..0e3258d21a4 100644
--- a/plugins/woocommerce/src/Admin/Settings/SettingsUIPageInterface.php
+++ b/plugins/woocommerce/src/Admin/Settings/SettingsUIPageInterface.php
@@ -29,10 +29,11 @@ interface SettingsUIPageInterface {
 	 * Build the canonical settings schema for a section.
 	 *
 	 * The `shell.sectionNavigation` key controls the sibling-section navigation
-	 * rendered by the Settings UI shell. Omit the key to let WooCommerce inject
-	 * the default navigation listing every section of the settings page, set a
-	 * custom array of `id`/`label`/`href`/`active` entries to own the navigation,
-	 * or set an empty array to render no shell navigation at all.
+	 * rendered by the Settings UI shell, and only applies to drill-down pages
+	 * (sections of the Payments tab). Set a custom array of `id`/`label`/`href`/
+	 * `active` entries to own the navigation, or omit the key to render none;
+	 * drill-down pages default to header breadcrumbs instead. Top-level settings
+	 * pages render the classic section links and ignore this key.
 	 *
 	 * @since 10.9.0
 	 *
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings/LegacySettingsPageAdapter.php b/plugins/woocommerce/src/Internal/Admin/Settings/LegacySettingsPageAdapter.php
index 040b9fab52d..7099644ed5c 100644
--- a/plugins/woocommerce/src/Internal/Admin/Settings/LegacySettingsPageAdapter.php
+++ b/plugins/woocommerce/src/Internal/Admin/Settings/LegacySettingsPageAdapter.php
@@ -53,17 +53,13 @@ class LegacySettingsPageAdapter implements PublicSettingsUIPageInterface {
 	 * @return array
 	 */
 	public function get_schema( string $section ): array {
-		$schema = SettingsUISchema::from_legacy_settings(
+		return SettingsUISchema::from_legacy_settings(
 			$this->settings_page->get_id(),
 			$section,
 			$this->settings_page->get_label(),
 			$this->settings_page->get_settings( $section ),
 			$this->get_save_adapter( $section )
 		);
-
-		$schema['shell']['sectionNavigation'] = SettingsSectionNavigation::build_default( $this->settings_page, $section );
-
-		return $schema;
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings/RegisteredSettingsSectionAdapter.php b/plugins/woocommerce/src/Internal/Admin/Settings/RegisteredSettingsSectionAdapter.php
index b94d9b32e7a..55d6067406d 100644
--- a/plugins/woocommerce/src/Internal/Admin/Settings/RegisteredSettingsSectionAdapter.php
+++ b/plugins/woocommerce/src/Internal/Admin/Settings/RegisteredSettingsSectionAdapter.php
@@ -38,6 +38,27 @@ class RegisteredSettingsSectionAdapter extends LegacySettingsPageAdapter {
 		$this->section = $section;
 	}

+	/**
+	 * Build the canonical settings schema for the registered section.
+	 *
+	 * The page is scoped to the registered section, so it is titled with the
+	 * section label rather than the parent settings page label.
+	 *
+	 * @param string $section Unused. This adapter wraps a single registered section.
+	 * @return array
+	 *
+	 * @since 11.1.0
+	 */
+	public function get_schema( string $section ): array {
+		$schema = parent::get_schema( $section );
+
+		$label                    = html_entity_decode( $this->section->get_label(), ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 );
+		$schema['title']          = $label;
+		$schema['shell']['title'] = $label;
+
+		return $schema;
+	}
+
 	/**
 	 * Get script handles that must be loaded before the settings UI app mounts.
 	 *
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings/SettingsSectionNavigation.php b/plugins/woocommerce/src/Internal/Admin/Settings/SettingsSectionNavigation.php
deleted file mode 100644
index ce09cae185e..00000000000
--- a/plugins/woocommerce/src/Internal/Admin/Settings/SettingsSectionNavigation.php
+++ /dev/null
@@ -1,57 +0,0 @@
-<?php
-/**
- * Settings UI section navigation builder.
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\Internal\Admin\Settings;
-
-defined( 'ABSPATH' ) || exit;
-
-/**
- * Builds section navigation entries for the Settings UI shell.
- *
- * @since 11.0.0
- */
-final class SettingsSectionNavigation {
-
-	/**
-	 * Build the default settings section navigation for the settings UI shell.
-	 *
-	 * Lists every section of the settings page, linking back through the classic
-	 * settings URLs. Returns an empty array for pages with fewer than two sections.
-	 *
-	 * @since 11.0.0
-	 *
-	 * @param \WC_Settings_Page $settings_page Settings page to build the navigation for.
-	 * @param string            $current_section Current section id. Empty string means the default section.
-	 * @return array<int, array{id: string, label: string, href: string, active: bool}>
-	 */
-	public static function build_default( \WC_Settings_Page $settings_page, string $current_section ): array {
-		$sections = $settings_page->get_sections();
-		if ( empty( $sections ) || 1 === count( $sections ) ) {
-			return array();
-		}
-
-		$navigation = array();
-		foreach ( $sections as $id => $label ) {
-			$section_id   = (string) $id;
-			$navigation[] = array(
-				'id'     => '' === $section_id ? 'default' : $section_id,
-				'label'  => wp_strip_all_tags( html_entity_decode( (string) $label, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 ) ),
-				'href'   => add_query_arg(
-					array(
-						'page'    => 'wc-settings',
-						'tab'     => sanitize_title( $settings_page->get_id() ),
-						'section' => sanitize_title( $section_id ),
-					),
-					admin_url( 'admin.php' )
-				),
-				'active' => $current_section === $section_id,
-			);
-		}
-
-		return $navigation;
-	}
-}
diff --git a/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUIRequestContext.php b/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUIRequestContext.php
index d1f7b55eb11..7e3bb1cff8a 100644
--- a/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUIRequestContext.php
+++ b/plugins/woocommerce/src/Internal/Admin/Settings/SettingsUIRequestContext.php
@@ -27,6 +27,13 @@ class SettingsUIRequestContext {
 	 */
 	private const DEFAULT_SECTION_KEY = 'default';

+	/**
+	 * Settings tabs whose sections render as drill-down pages.
+	 *
+	 * @var string[]
+	 */
+	private const DRILL_DOWN_TABS = array( 'checkout' );
+
 	/**
 	 * Context instances keyed by settings page object and section.
 	 *
@@ -257,6 +264,23 @@ class SettingsUIRequestContext {
 		return $this->settings_ui_page ? $this->settings_ui_page->get_page_id() : $this->settings_page->get_id();
 	}

+	/**
+	 * Whether this context renders a drill-down page.
+	 *
+	 * A drill-down page is a section of a settings tab whose sections are
+	 * presented as standalone pages, following the Payments pattern: the shell
+	 * header (title, breadcrumbs, top save button) replaces the top-level
+	 * settings tabs. Pages registered at the top level of settings are not
+	 * drill-downs: they hide the header and keep the tabs.
+	 *
+	 * @since 11.1.0
+	 *
+	 * @return bool
+	 */
+	public function is_drill_down(): bool {
+		return '' !== $this->section && in_array( $this->settings_page->get_id(), self::DRILL_DOWN_TABS, true );
+	}
+
 	/**
 	 * Whether this context can render through the Settings UI.
 	 *
@@ -447,7 +471,10 @@ class SettingsUIRequestContext {
 		}

 		try {
-			$this->schema = $this->ensure_section_navigation( $this->settings_ui_page->get_schema( $this->section ) );
+			$schema       = $this->settings_ui_page->get_schema( $this->section );
+			$schema       = $this->apply_section_navigation( $schema );
+			$schema       = $this->apply_shell_header_visibility( $schema );
+			$this->schema = $this->ensure_drill_down_breadcrumbs( $schema );
 		} catch ( \Throwable $e ) {
 			$this->schema_failed = true;

@@ -483,28 +510,79 @@ class SettingsUIRequestContext {
 	}

 	/**
-	 * Ensure a resolved settings UI schema carries section navigation for the shell.
+	 * Set the shell section navigation from the page registration.
 	 *
-	 * Schemas that omit `shell.sectionNavigation` get the default sibling-section
-	 * navigation for the settings page, matching the legacy settings adapter.
-	 * Setting the key — including to an empty array — keeps the provided value,
-	 * so pages with custom or no navigation stay untouched.
+	 * Top-level pages never carry section navigation: the classic section
+	 * links render with the settings header instead. Drill-down pages keep
+	 * schema-provided navigation and default to none, since the header
+	 * breadcrumbs replace it.
 	 *
 	 * @param array $schema Resolved settings UI schema.
 	 * @return array
 	 */
-	private function ensure_section_navigation( array $schema ): array {
-		if ( ! isset( $schema['shell'] ) ) {
+	private function apply_section_navigation( array $schema ): array {
+		if ( ! isset( $schema['shell'] ) || ! is_array( $schema['shell'] ) ) {
 			$schema['shell'] = array();
 		}

-		if ( is_array( $schema['shell'] ) && ! isset( $schema['shell']['sectionNavigation'] ) ) {
-			$schema['shell']['sectionNavigation'] = SettingsSectionNavigation::build_default( $this->settings_page, $this->section );
+		if ( ! $this->is_drill_down() || ! isset( $schema['shell']['sectionNavigation'] ) ) {
+			$schema['shell']['sectionNavigation'] = array();
 		}

 		return $schema;
 	}

+	/**
+	 * Set the shell header visibility from the page registration.
+	 *
+	 * The header is reserved for drill-down pages. Pages registered at the top
+	 * level of settings always hide it, regardless of what their schema asks
+	 * for.
+	 *
+	 * @param array $schema Resolved settings UI schema.
+	 * @return array
+	 */
+	private function apply_shell_header_visibility( array $schema ): array {
+		if ( ! isset( $schema['shell'] ) || ! is_array( $schema['shell'] ) ) {
+			$schema['shell'] = array();
+		}
+
+		$schema['shell']['header'] = $this->is_drill_down() ? 'visible' : 'hidden';
+
+		return $schema;
+	}
+
+	/**
+	 * Ensure a drill-down schema carries breadcrumbs back to its parent tab.
+	 *
+	 * Schemas that omit `shell.breadcrumbs` get a single crumb linking to the
+	 * parent settings tab, since the header breadcrumbs replace the top-level
+	 * settings tabs on drill-down pages.
+	 *
+	 * @param array $schema Resolved settings UI schema.
+	 * @return array
+	 */
+	private function ensure_drill_down_breadcrumbs( array $schema ): array {
+		if ( ! $this->is_drill_down() || isset( $schema['shell']['breadcrumbs'] ) ) {
+			return $schema;
+		}
+
+		$schema['shell']['breadcrumbs'] = array(
+			array(
+				'label' => wp_strip_all_tags( html_entity_decode( $this->settings_page->get_label(), ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401 ) ),
+				'href'  => add_query_arg(
+					array(
+						'page' => 'wc-settings',
+						'tab'  => sanitize_title( $this->settings_page->get_id() ),
+					),
+					admin_url( 'admin.php' )
+				),
+			),
+		);
+
+		return $schema;
+	}
+
 	/**
 	 * Filter extension-provided script handles to valid WordPress script handle strings.
 	 *
diff --git a/plugins/woocommerce/tests/php/src/Admin/Settings/SettingsSectionRegistryTest.php b/plugins/woocommerce/tests/php/src/Admin/Settings/SettingsSectionRegistryTest.php
index 0321d2df70b..05e2142c553 100644
--- a/plugins/woocommerce/tests/php/src/Admin/Settings/SettingsSectionRegistryTest.php
+++ b/plugins/woocommerce/tests/php/src/Admin/Settings/SettingsSectionRegistryTest.php
@@ -132,6 +132,10 @@ class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
 		$this->assertSame( 'checkout', $settings_ui_page->get_page_id() );
 		$this->assertSame( array( 'acme-payments-settings-ui' ), $settings_ui_page->get_script_handles( 'acme_payments' ) );
 		$this->assertSame( 'form_post', $settings_ui_page->get_save_adapter( 'acme_payments' ) );
+
+		$schema = $settings_ui_page->get_schema( 'acme_payments' );
+		$this->assertSame( 'Acme Payments', $schema['title'] );
+		$this->assertSame( 'Acme Payments', $schema['shell']['title'] );
 	}

 	/**
@@ -179,9 +183,9 @@ class SettingsSectionRegistryTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Should inject default section navigation when a native Settings UI schema omits it.
+	 * @testdox Should default drill-down pages to no section navigation when a native Settings UI schema omits it.
 	 */
-	public function test_injects_default_section_navigation_when_native_settings_ui_schema_omits_it(): void {
+	public function test_defaults_drill_down_pages_to_no_section_navigation_when_native_settings_ui_schema_omits_it(): void {
 		$page = $this->get_parent_page();
 		SettingsSectionRegistry::get_instance()->register(
 			$this->get_registered_section_with_native_settings_ui_page( null, array( 'title' => 'Acme native settings' ) )
@@ -189,11 +193,7 @@ class SettingsSectionRegistryTest extends WC_Unit_Test_Case {

 		$schema = SettingsUIRequestContext::for_settings_page( $page, 'acme_payments' )->get_schema();

-		$navigation = $schema['shell']['sectionNavigation'];
-		$this->assertSame( array( 'default', 'acme_payments' ), array_column( $navigation, 'id' ) );
-		$this->assertSame( array( false, true ), array_column( $navigation, 'active' ) );
-		$this->assertStringContainsString( 'tab=checkout', $navigation[1]['href'] );
-		$this->assertStringContainsString( 'section=acme_payments', $navigation[1]['href'] );
+		$this->assertSame( array(), $schema['shell']['sectionNavigation'], 'Drill-down pages replace section navigation with header breadcrumbs.' );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUIFeatureFlagTest.php b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUIFeatureFlagTest.php
index 2c7e19a9184..0398ec41074 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUIFeatureFlagTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Admin/Settings/SettingsUIFeatureFlagTest.php
@@ -241,19 +241,96 @@ class SettingsUIFeatureFlagTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * It exposes section navigation metadata from legacy settings pages.
+	 * @testdox Should clear shell section navigation for top-level pages, which keep the classic section links.
 	 */
-	public function test_legacy_adapter_adds_shell_navigation_metadata(): void {
+	public function test_request_context_clears_section_navigation_for_top_level_pages(): void {
 		$page    = $this->get_settings_ui_page_with_sections();
-		$adapter = new \Automattic\WooCommerce\Admin\Settings\LegacySettingsPageAdapter( $page );
-		$schema  = $adapter->get_schema( '' );
+		$context = SettingsUIRequestContext::for_settings_page( $page, '' );
+
+		$schema = $context->get_schema();

 		$this->assertSame( 'Settings UI flag test', $schema['shell']['title'] );
 		$this->assertArrayNotHasKey( 'breadcrumbs', $schema['shell'] );
 		$this->assertArrayNotHasKey( 'navigation', $schema['shell'] );
-		$this->assertSame( 'General', $schema['shell']['sectionNavigation'][0]['label'] );
-		$this->assertTrue( $schema['shell']['sectionNavigation'][0]['active'] );
-		$this->assertSame( 'inventory', $schema['shell']['sectionNavigation'][1]['id'] );
+		$this->assertSame( array(), $schema['shell']['sectionNavigation'] );
+	}
+
+	/**
+	 * @testdox Should hide the shell header for pages registered at the top level of settings.
+	 */
+	public function test_request_context_hides_shell_header_for_top_level_pages(): void {
+		$page    = $this->get_settings_ui_test_page();
+		$context = SettingsUIRequestContext::for_settings_page( $page, '' );
+
+		$schema = $context->get_schema();
+
+		$this->assertSame( 'hidden', $schema['shell']['header'] );
+	}
+
+	/**
+	 * @testdox Should override a schema-provided shell header for top-level pages.
+	 */
+	public function test_request_context_overrides_a_schema_provided_shell_header(): void {
+		$page    = $this->get_settings_ui_test_page_with_visible_shell_header();
+		$context = SettingsUIRequestContext::for_settings_page( $page, '' );
+
+		$schema = $context->get_schema();
+
+		$this->assertSame( 'hidden', $schema['shell']['header'], 'Top-level pages cannot opt into the shell header.' );
+	}
+
+	/**
+	 * @testdox Should show the shell header for payments drill-down pages.
+	 */
+	public function test_request_context_shows_shell_header_for_drill_down_pages(): void {
+		$page    = $this->get_settings_ui_test_page_for_drill_down();
+		$context = SettingsUIRequestContext::for_settings_page( $page, 'test_gateway' );
+
+		$schema = $context->get_schema();
+
+		$this->assertTrue( $context->is_drill_down() );
+		$this->assertSame( 'visible', $schema['shell']['header'] );
+		$this->assertSame( array(), $schema['shell']['sectionNavigation'], 'Drill-down pages default to no section navigation.' );
+	}
+
+	/**
+	 * @testdox Should default drill-down breadcrumbs to the parent settings tab.
+	 */
+	public function test_request_context_defaults_drill_down_breadcrumbs_to_the_parent_tab(): void {
+		$page    = $this->get_settings_ui_test_page_for_drill_down();
+		$context = SettingsUIRequestContext::for_settings_page( $page, 'test_gateway' );
+
+		$schema = $context->get_schema();
+
+		$this->assertCount( 1, $schema['shell']['breadcrumbs'] );
+		$this->assertSame( 'Payments drill-down test', $schema['shell']['breadcrumbs'][0]['label'] );
+		$this->assertStringContainsString( 'tab=checkout', $schema['shell']['breadcrumbs'][0]['href'] );
+	}
+
+	/**
+	 * @testdox Should keep schema-provided breadcrumbs on drill-down pages.
+	 */
+	public function test_request_context_keeps_schema_breadcrumbs_on_drill_down_pages(): void {
+		$breadcrumbs = array( array( 'label' => 'Custom crumb' ) );
+		$page        = $this->get_settings_ui_test_page_for_drill_down( $breadcrumbs );
+		$context     = SettingsUIRequestContext::for_settings_page( $page, 'test_gateway' );
+
+		$schema = $context->get_schema();
+
+		$this->assertSame( $breadcrumbs, $schema['shell']['breadcrumbs'] );
+	}
+
+	/**
+	 * @testdox Should treat the default payments section as a top-level page.
+	 */
+	public function test_request_context_hides_shell_header_for_default_payments_section(): void {
+		$page    = $this->get_settings_ui_test_page_for_drill_down();
+		$context = SettingsUIRequestContext::for_settings_page( $page, '' );
+
+		$schema = $context->get_schema();
+
+		$this->assertFalse( $context->is_drill_down() );
+		$this->assertSame( 'hidden', $schema['shell']['header'] );
 	}

 	/**
@@ -408,6 +485,150 @@ class SettingsUIFeatureFlagTest extends WC_Unit_Test_Case {
 		};
 	}

+	/**
+	 * Build a settings page whose settings UI schema asks for a visible shell header.
+	 *
+	 * @return \WC_Settings_Page
+	 */
+	private function get_settings_ui_test_page_with_visible_shell_header(): \WC_Settings_Page {
+		return new class() extends \WC_Settings_Page {
+			/**
+			 * Constructor.
+			 */
+			public function __construct() {
+				$this->id    = 'settings_ui_flag_test';
+				$this->label = 'Settings UI flag test';
+			}
+
+			/**
+			 * Get the settings UI page adapter.
+			 *
+			 * @return \Automattic\WooCommerce\Admin\Settings\SettingsUIPageInterface|null
+			 */
+			public function get_settings_ui_page(): ?\Automattic\WooCommerce\Admin\Settings\SettingsUIPageInterface {
+				return new class( $this ) extends \Automattic\WooCommerce\Admin\Settings\LegacySettingsPageAdapter {
+					/**
+					 * Get the schema for a section.
+					 *
+					 * @param string $section Section id.
+					 * @return array
+					 */
+					public function get_schema( string $section ): array {
+						$schema                    = parent::get_schema( $section );
+						$schema['shell']['header'] = 'visible';
+
+						return $schema;
+					}
+				};
+			}
+
+			/**
+			 * Get settings for the default section.
+			 *
+			 * @return array
+			 */
+			protected function get_settings_for_default_section() {
+				return array(
+					array(
+						'id'    => 'woocommerce_settings_ui_flag_test',
+						'type'  => 'text',
+						'title' => 'Settings UI flag test',
+					),
+				);
+			}
+		};
+	}
+
+	/**
+	 * Build a payments-tab settings page that opts into the settings UI renderer.
+	 *
+	 * @param array|null $breadcrumbs Optional schema-provided breadcrumbs.
+	 * @return \WC_Settings_Page
+	 */
+	private function get_settings_ui_test_page_for_drill_down( ?array $breadcrumbs = null ): \WC_Settings_Page {
+		return new class( $breadcrumbs ) extends \WC_Settings_Page {
+			/**
+			 * Schema-provided breadcrumbs, if any.
+			 *
+			 * @var array|null
+			 */
+			private ?array $breadcrumbs;
+
+			/**
+			 * Constructor.
+			 *
+			 * @param array|null $breadcrumbs Optional schema-provided breadcrumbs.
+			 */
+			public function __construct( ?array $breadcrumbs ) {
+				$this->id          = 'checkout';
+				$this->label       = 'Payments drill-down test';
+				$this->breadcrumbs = $breadcrumbs;
+			}
+
+			/**
+			 * Get the settings UI page adapter.
+			 *
+			 * @return \Automattic\WooCommerce\Admin\Settings\SettingsUIPageInterface|null
+			 */
+			public function get_settings_ui_page(): ?\Automattic\WooCommerce\Admin\Settings\SettingsUIPageInterface {
+				return new class( $this, $this->breadcrumbs ) extends \Automattic\WooCommerce\Admin\Settings\LegacySettingsPageAdapter {
+					/**
+					 * Schema-provided breadcrumbs, if any.
+					 *
+					 * @var array|null
+					 */
+					private ?array $breadcrumbs;
+
+					/**
+					 * Constructor.
+					 *
+					 * @param \WC_Settings_Page $settings_page Settings page.
+					 * @param array|null        $breadcrumbs Optional schema-provided breadcrumbs.
+					 */
+					public function __construct( \WC_Settings_Page $settings_page, ?array $breadcrumbs ) {
+						parent::__construct( $settings_page );
+						$this->breadcrumbs = $breadcrumbs;
+					}
+
+					/**
+					 * Get the schema for a section.
+					 *
+					 * @param string $section Section id.
+					 * @return array
+					 */
+					public function get_schema( string $section ): array {
+						$schema = parent::get_schema( $section );
+
+						if ( null !== $this->breadcrumbs ) {
+							$schema['shell']['breadcrumbs'] = $this->breadcrumbs;
+						}
+
+						return $schema;
+					}
+				};
+			}
+
+			/**
+			 * Get settings for any section.
+			 *
+			 * @param string $section_id Section id.
+			 * @return array
+			 */
+			protected function get_settings_for_section_core( $section_id ) {
+				// Avoid parameter not used PHPCS errors.
+				unset( $section_id );
+
+				return array(
+					array(
+						'id'    => 'woocommerce_settings_ui_drill_down_test',
+						'type'  => 'text',
+						'title' => 'Drill-down test',
+					),
+				);
+			}
+		};
+	}
+
 	/**
 	 * Get captured doing-it-wrong notices emitted by the settings page output method.
 	 *
@@ -640,6 +861,15 @@ class SettingsUIFeatureFlagTest extends WC_Unit_Test_Case {
 				$this->label = 'Settings UI flag test';
 			}

+			/**
+			 * Get the settings UI page adapter.
+			 *
+			 * @return \Automattic\WooCommerce\Admin\Settings\SettingsUIPageInterface|null
+			 */
+			public function get_settings_ui_page(): ?\Automattic\WooCommerce\Admin\Settings\SettingsUIPageInterface {
+				return new \Automattic\WooCommerce\Admin\Settings\LegacySettingsPageAdapter( $this );
+			}
+
 			/**
 			 * Get sections for this test page.
 			 *