Commit 5905801758b for woocommerce

commit 5905801758b2f509c00520f72e08ec518ea87849
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date:   Sat Aug 8 01:15:33 2026 +0300

    [Payments NOX] Payments settings UI and UX polish (#67464)

    * Extend the Payments settings panel background to the page bottom

    #mainform is only as tall as its content while #wpcontent is held to the
    viewport height, so on a tall browser window the body grey showed as a
    band between the end of the content and the footer.

    Every other Settings tab leaves #mainform grey, matching #wpcontent, so
    the seam is invisible there; the Payments tab is the only one that paints
    #mainform white, which is what makes it show. The fix is scoped to that
    tab's body class for the same reason.

    Painting #wpcontent white is safe here: the tab strip above sets its own
    grey in this same block, and the page header is fixed with its own, so
    neither relies on what shows through from #wpcontent.

    * Make the offline payments header title part of the back control

    Only the 24x24 chevron was clickable, so the obvious target — the title
    sitting right beside it — did nothing.

    The button now renders the title inside itself and sits within the
    existing <h1>, which keeps the heading level and its text intact: a
    button is phrasing content, so nesting it in a heading is valid, and the
    whole label becomes the target. Measured, that takes the hit area from
    24x24 to 188x24 on the gateway screen without moving the chevron or the
    title by a pixel. The chevron glyph sits 8px inside its own 24px box, so
    the label gets the same 8px after it and the focus ring reads evenly
    around both.

    This also corrects the control's accessible name. BackButton took a
    `title` prop that it never rendered, so both offline screens passed
    "Return to payments settings" into nothing and the button fell back to
    labelling itself "WooCommerce Settings". The visible title is now the
    accessible name, which is what WCAG 2.5.3 asks for, and the destination
    moves to the tooltip, which the component exposes as aria-describedby.
    The dead prop is removed rather than left to mislead.

    The header.tsx caller renders its chevron and title into separate
    WooHeader slots, so they are not siblings and cannot nest this way; it
    keeps the icon-only button and its tooltip-derived label.

    * Match the Payments settings headings to the surrounding heading colour

    Two headings on the screen never declared a colour, so each fell to a
    different default: "Payment providers" inherited wp-admin's body grey
    (#3c434a), which reads noticeably lighter than everything around it, and
    "More payment options" landed on the browser's black (#000) because its
    header is a bare <button>. The gateway titles, the category headings and
    the offline header all use $gray-900 (#1e1e1e).

    Both now state $gray-900, so every heading on the screen measures the
    same colour.

    * Restore the header divider on individual offline method pages

    The offline payment methods list page separates its header from the list
    with a hairline, drawn as the border-top of its .woocommerce-list. The
    individual method pages lost their equivalent and have had none since.

    That line used to come from a border-bottom on .woocommerce-layout__header
    added in PR #57999, back when these pages rendered their title into the
    floating admin header. PR #59163 moved the title into an in-page header so
    navigation would not trigger a full refresh, which left that rule drawing
    a line above the tab strip instead; review spotted the misplaced line and
    the rule was dropped in 7688e46. Removing it was right for the symptom,
    but nothing put the intended divider back.

    Scoped to .settings-payment-gateways, which only the individual pages nest
    their header inside, so the list page keeps drawing its own line and does
    not end up with two.

    * Align the settings section columns and restore the form's top spacing

    A design QA pass asked for the controls column to start on the same line
    as its section heading. It had drifted 10px above it.

    The cause was that neither side stated its own offset: the heading took
    wp-admin's default h2 margin, and the controls column carried a
    hand-tuned "ensure visual alignment" nudge measured against it. When one
    moved, the other had no way to follow. Both now take the same declared
    value, so they line up by construction rather than by coincidence, and
    the nudge is gone.

    The offline pages also had no space between the page header and the form:
    the override that lines the form up with the header horizontally sets all
    four margins, which zeroed the top. It now leaves the same room the
    section heading needs, putting the first line of the form roughly back
    where it sat before the header was reworked.

    The columns stack below $break-medium, where there is no heading to align
    against, so the offset is dropped there.

    * Let the form layout own field spacing on the offline method pages

    Fields were 48px apart where the previous field had help text and 16px
    where it did not, so the form had no consistent rhythm.

    These controls carried their own 32px bottom margins from before the
    forms moved to DataForm in PR #65731. DataForm separates its fields with
    a gap, so those margins stacked on top of it: 32px of margin plus 16px of
    gap wherever a control or its help text ended a field. Neither value was
    wrong on its own; they were two spacing systems doing the same job.

    The margins are gone and the gap is the only thing setting that rhythm,
    which is also the direction @wordpress/components is moving: its own
    field margin is behind __nextHasNoMarginBottom and on the way out. Help
    text keeps sitting 8px under its field, so label, field and help still
    read as one unit.

    Verified on all three method pages. The rest of Payments is unaffected:
    the Settings layout these rules belong to is used only by bacs, cheque
    and cod, and the stylesheet does not load on the payments list, the
    offline list, or the WooPayments screens.

    * Let each Payments settings header draw its own divider

    The line under a header was drawn by whatever rendered below it: the
    providers list and the offline methods list each carried a border-top,
    and the skeleton loader's rows carried one too. Which of those was on
    screen varied by page and by loading state, and the offline list's rule
    shipped in a lazily loaded chunk, so the line was drawn twice while a
    list was loading and not at all once the skeleton stood alone.

    Give the line to the headers, which are present in every state, and stop
    the lists and the skeleton's first row from drawing their own. The
    headers already sat flush against what follows them, so the line stays
    exactly where it was on every screen.

    * Match the offline methods list separators to the rest of the screen

    The rows on the offline payment methods list take their separators from
    @woocommerce/components' List, which draws them in $gray-100. The lines
    bracketing that list use $gray-200, as do the row separators on the main
    providers list, so the interior lines read a shade lighter than the ones
    around them.

    Recolour them to $gray-200. The skeleton loader needs the same override
    of its own: it renders on the main Payments screen too, where the offline
    list's stylesheet isn't necessarily loaded.

    * Point the offline method back link tooltip at its actual destination

    The back control on an individual offline payment method page links to
    the offline payment methods list, but its tooltip read "Return to
    payments settings" — the destination of the back control one level up,
    on the list page itself.

    * Add a changelog entry for the Payments settings styling fixes

    * Give the offline page headings the in-page heading scale

    Both offline headers took the floating settings header's 15px/500. They
    do not render in the floating header, though — they sit in the page
    content, in the slot the main Payments screen fills with "Payment
    providers" at 16px/600. So each h1 came out smaller and lighter than the
    section headings and payment method rows directly beneath it, which read
    as a page title subordinate to its own contents.

    Take the scale from the heading these actually stand alongside.

    * Land the Payments header titles in the same place on every screen

    The main screen sized its header row against the business location
    control, the tallest thing in it, and centred the title in that row. The
    offline headers had no such control, so their row was whatever the
    heading came out to: a text line box holding an inline-flex back button,
    which left the button's descender gap in the measurement. The title
    landed 3px higher than the main screen's and the divider 1.2px higher,
    so both jumped on the way back from an offline page.

    State that row height once and give it to every header, laying each title
    out as a centred flex row so none of them depends on a baseline. Titles
    and dividers now agree to the pixel across all three screens, at both
    wide and narrow widths.

    * Cover the back button's accessible name with tests

    The button takes its accessible name from the visible label when one is
    given, and from the tooltip text when it renders icon-only. That
    conditional is easy to undo by reflex: collapsing it back to a plain
    aria-label reads as a simplification, but it silently hides the
    on-screen label from the accessible name.

    Nothing guarded it — the component had no test file at all. Add
    role-based cases for both paths plus the default tooltip fallback,
    asserting through getByRole so the assertion is the accessible name
    itself rather than the markup that happens to produce it.

    Verified by mutation: reverting the conditional to aria-label={
    tooltipText } fails the labelled case.

    * Use a logical property for the back button's label padding

    The root AGENTS.md now states a repo-wide preference for logical CSS
    properties over physical ones; the guideline was promoted there from the
    blocks coding guidelines in #67455.

    This is not an RTL bug fix. wc-admin stylesheets are mirrored at build
    time — WebpackRTLPlugin runs the sheet through rtlcss and emits a -rtl.css
    sibling, which WordPress serves via wp_style_add_data( $handle, 'rtl',
    'replace' ). The project's rtlcss rewrites padding-right to padding-left
    in that sibling, so the rendering was already correct in both directions
    and stays unchanged.

    What the change buys is consistency: padding-right was this file's only
    directional physical property, so converting it leaves the file entirely
    logical rather than mixed-idiom.

    * Drop the unreachable back link branch from the Payments header

    backLink dates to the original NOX commit (#52785, November 2024), where
    the floating header carried the back control for the offline payment
    method screens.

    #59163 moved offline payments to soft routing in July 2025 and relocated
    that affordance into the page body, adding in-page BackButton instances.
    It removed every backLink={...} from the call sites but left the prop and
    its guarded branch behind. Nothing has passed backLink in the thirteen
    months since, so the branch cannot render.

    Remove it, along with the BackButton and WooHeaderNavigationItem imports
    it was the only user of. The context prop goes with it: its sole consumer
    was from={ context } on that button, so it is dead once the branch is.

    Header is imported by direct path and re-exported from no barrel, and its
    two call sites both live in this module, so there is nothing outside it to
    account for.

    * Let the Payments panel fill the window instead of painting #wpcontent

    6192110c82 closed the grey seam below the content panel by painting
    #wpcontent white. That reached further than intended.

    #wpbody clears the fixed page header with a top margin, and a margin paints
    the parent's background, not its own. So the white filled the strip between
    the page header and the tab strip as soon as the content moved off its
    resting position — visible on any overscroll. That strip is the continuous
    grey chrome region the tab strip above is establishing, so the earlier fix
    quietly undid it. At rest the gap measures 0px, which is why it did not
    show up in the first round of checks.

    Paint and stretch #wpbody instead. It starts below the margin, so it cannot
    reach that strip, and #wpfooter is absolutely positioned against the bottom
    of #wpwrap and overlaps it, so the footer lands on the white too — the panel
    reads as one surface all the way down rather than stopping short of it.

    #wpcontent is already held to the window height, so making it a column and
    letting #wpbody take the remainder needs no admin-bar, page-header, or
    footer heights in the stylesheet. An earlier attempt did subtract them from
    100vh and needed a second set of numbers below 782px, where the admin bar
    grows to 46px and the footer is hidden; it also disagreed with itself by a
    few pixels between a short page and a long one.

    Measured on the built stylesheet at 1600x941: #wpbody and #wpcontent both
    end at the window bottom, the footer sits on white, and the page adds no
    scroll — with the panel list collapsed and expanded alike. At 700px wide it
    takes 4px off the document height rather than adding any.

    * Cover the back button's click target with tests

    Moving the label inside the Button widened the click target: the words
    next to the chevron now go back too, where before only the icon did.
    That is the point of rendering children inside the button rather than
    beside it, but nothing held it in place — pulling the label back out of
    the Button would still leave every existing test green, because the
    suite only asserted accessible names and never clicked.

    Add a Going back block that clicks both shapes — the visible label and
    the icon-only button — and asserts the route push and the tracking
    event. The route path is used rather than window.location so the
    assertion is a real one; jsdom does not implement navigation.

    @woocommerce/navigation cannot be replaced wholesale here: @woocommerce/data
    binds onHistoryChange to getPersistedQuery at import, so the suite dies
    on load. Spread requireActual and stub only getHistory. Clicks go
    through fireEvent to match the sibling button tests.

    Verified by mutation: rendering children outside the Button fails the
    label case, and dropping the recordPaymentsEvent call fails both.

    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

diff --git a/plugins/woocommerce/changelog/fix-payments-settings-styling b/plugins/woocommerce/changelog/fix-payments-settings-styling
new file mode 100644
index 00000000000..f9541fb3b24
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-payments-settings-styling
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix a set of styling issues on the Payments settings screens: the white content background now reaches the bottom of the page, headings match the colour used elsewhere, the dividers under the headers are drawn consistently while the screen loads, the whole offline payments header title acts as the back control, and the offline payment method settings form is aligned and spaced evenly.
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/components/buttons/back-button.scss b/plugins/woocommerce/client/admin/client/settings-payments/components/buttons/back-button.scss
index 76e08f4792e..9ed5f44a417 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/components/buttons/back-button.scss
+++ b/plugins/woocommerce/client/admin/client/settings-payments/components/buttons/back-button.scss
@@ -4,3 +4,18 @@
 	width: 24px;
 	min-width: 24px;
 }
+
+// With a visible label the button sits inside the page heading and stands in
+// for it, so it takes the heading's own typography and colour rather than the
+// component defaults, and carries the gap the heading used to hold.
+.woocommerce-settings-payments__back-button--with-label.has-icon {
+	height: auto;
+	width: auto;
+	min-width: 0;
+	// The chevron glyph sits $gap-smaller inside its own 24px box, so the same
+	// amount after the label keeps the focus ring visually even around both.
+	padding-inline-end: $gap-smaller;
+	gap: $gap-smaller * 2;
+	color: inherit;
+	font: inherit;
+}
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/components/buttons/back-button.tsx b/plugins/woocommerce/client/admin/client/settings-payments/components/buttons/back-button.tsx
index 732d9ea938b..820253e61c3 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/components/buttons/back-button.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-payments/components/buttons/back-button.tsx
@@ -1,8 +1,10 @@
 /**
  * External dependencies
  */
+import { type ReactNode } from 'react';
 import { __, isRTL } from '@wordpress/i18n';
 import { Button, Tooltip } from '@wordpress/components';
+import clsx from 'clsx';
 import { chevronLeft, chevronRight } from '@wordpress/icons';
 import { getHistory } from '@woocommerce/navigation';

@@ -13,10 +15,6 @@ import './back-button.scss';
 import { recordPaymentsEvent } from '~/settings-payments/utils';

 interface BackButtonProps {
-	/**
-	 * The title of the back button.
-	 */
-	title: string;
 	/**
 	 * The URL to navigate to when the back button is clicked.
 	 */
@@ -33,6 +31,13 @@ interface BackButtonProps {
 	 * The identifier of the screen from which the user is navigating back (e.g., 'woopayments_payment_methods').
 	 */
 	from?: string;
+	/**
+	 * Visible label rendered next to the chevron, inside the button. Supplying
+	 * it makes the whole label part of the click target, and the button takes
+	 * its accessible name from the label rather than from `tooltipText`, so the
+	 * name always matches what is on screen.
+	 */
+	children?: ReactNode;
 }

 /**
@@ -44,6 +49,7 @@ export const BackButton = ( {
 	tooltipText = __( 'WooCommerce Settings', 'woocommerce' ),
 	isRoute = false,
 	from = '',
+	children,
 }: BackButtonProps ) => {
 	const onGoBack = () => {
 		// Record the event when the user clicks the button.
@@ -62,11 +68,24 @@ export const BackButton = ( {
 	return (
 		<Tooltip text={ tooltipText }>
 			<Button
-				className="woocommerce-settings-payments__back-button"
+				// Button only sets its own has-text when the children are a
+				// plain string, so carry the distinction explicitly.
+				className={ clsx(
+					'woocommerce-settings-payments__back-button',
+					{
+						'woocommerce-settings-payments__back-button--with-label':
+							!! children,
+					}
+				) }
 				icon={ isRTL() ? chevronRight : chevronLeft }
 				onClick={ onGoBack }
-				aria-label={ tooltipText }
-			/>
+				// Without a visible label the chevron alone carries no name, so
+				// the tooltip text has to supply one. With a label, overriding
+				// the name would hide the on-screen text from it.
+				aria-label={ children ? undefined : tooltipText }
+			>
+				{ children }
+			</Button>
 		</Tooltip>
 	);
 };
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/components/buttons/test/back-button.test.tsx b/plugins/woocommerce/client/admin/client/settings-payments/components/buttons/test/back-button.test.tsx
new file mode 100644
index 00000000000..8b50c38b5ab
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-payments/components/buttons/test/back-button.test.tsx
@@ -0,0 +1,103 @@
+/**
+ * External dependencies
+ */
+import { fireEvent, render } from '@testing-library/react';
+import { recordEvent } from '@woocommerce/tracks';
+import { getHistory } from '@woocommerce/navigation';
+
+/**
+ * Internal dependencies
+ */
+import { BackButton } from '..';
+
+jest.mock( '@woocommerce/tracks', () => ( {
+	recordEvent: jest.fn(),
+} ) );
+
+const push = jest.fn();
+
+// Only `getHistory` is stubbed — the rest of the module has to stay real,
+// because `@woocommerce/data` wires itself up against it on import.
+jest.mock( '@woocommerce/navigation', () => ( {
+	...jest.requireActual( '@woocommerce/navigation' ),
+	getHistory: jest.fn(),
+} ) );
+
+beforeEach( () => {
+	jest.clearAllMocks();
+	( getHistory as jest.Mock ).mockReturnValue( { push } );
+} );
+
+describe( 'BackButton', () => {
+	describe( 'Accessible name', () => {
+		// The label is what a sighted user reads, so it has to be what a
+		// screen reader announces too — the tooltip must not override it.
+		it( 'takes its accessible name from the visible label when one is given', () => {
+			const { getByRole } = render(
+				<BackButton href="/offline" tooltipText="Back to Payments">
+					Bank transfer
+				</BackButton>
+			);
+
+			expect(
+				getByRole( 'button', { name: 'Bank transfer' } )
+			).toBeInTheDocument();
+		} );
+
+		it( 'takes its accessible name from the tooltip text when it renders icon-only', () => {
+			const { getByRole } = render(
+				<BackButton href="/offline" tooltipText="Back to Payments" />
+			);
+
+			expect(
+				getByRole( 'button', { name: 'Back to Payments' } )
+			).toBeInTheDocument();
+		} );
+
+		it( 'falls back to the default tooltip text when no tooltip text is given', () => {
+			const { getByRole } = render( <BackButton href="/offline" /> );
+
+			expect(
+				getByRole( 'button', { name: 'WooCommerce Settings' } )
+			).toBeInTheDocument();
+		} );
+	} );
+
+	describe( 'Going back', () => {
+		// The label sits inside the button, so clicking the words has to go
+		// back just like clicking the chevron does.
+		it( 'navigates and records the click when the visible label is clicked', () => {
+			const { getByText } = render(
+				<BackButton href="/offline" isRoute from="offline_gateway">
+					Bank transfer
+				</BackButton>
+			);
+
+			fireEvent.click( getByText( 'Bank transfer' ) );
+
+			expect( push ).toHaveBeenCalledWith( '/offline' );
+			expect( recordEvent ).toHaveBeenCalledWith(
+				'settings_payments_back_button_click',
+				expect.objectContaining( { from: 'offline_gateway' } )
+			);
+		} );
+
+		it( 'navigates and records the click when it renders icon-only', () => {
+			const { getByRole } = render(
+				<BackButton
+					href="/offline"
+					isRoute
+					tooltipText="Back to Payments"
+				/>
+			);
+
+			fireEvent.click( getByRole( 'button' ) );
+
+			expect( push ).toHaveBeenCalledWith( '/offline' );
+			expect( recordEvent ).toHaveBeenCalledWith(
+				'settings_payments_back_button_click',
+				expect.any( Object )
+			);
+		} );
+	} );
+} );
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/components/header/header.tsx b/plugins/woocommerce/client/admin/client/settings-payments/components/header/header.tsx
index e712e41b373..664705bc905 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/components/header/header.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-payments/components/header/header.tsx
@@ -6,17 +6,12 @@ import {
 	registerPlugin,
 	getPlugins,
 } from '@wordpress/plugins';
-import {
-	WooHeaderNavigationItem,
-	WooHeaderPageTitle,
-	WooHeaderItem,
-} from '@woocommerce/admin-layout';
+import { WooHeaderPageTitle, WooHeaderItem } from '@woocommerce/admin-layout';
 import { Button } from '@wordpress/components';

 /**
  * Internal dependencies
  */
-import { BackButton } from '../buttons/back-button';
 import './header.scss';

 interface HeaderProps {
@@ -24,10 +19,6 @@ interface HeaderProps {
 	 * The title of the header.
 	 */
 	title: string;
-	/**
-	 * The link to go back to. If not provided, the back button will not be shown.
-	 */
-	backLink?: string;
 	/**
 	 * The description of the header.
 	 */
@@ -44,10 +35,6 @@ interface HeaderProps {
 	 * The callback function when the button is clicked.
 	 */
 	onButtonClick?: () => void;
-	/**
-	 * The context in which the header is used, e.g., 'wc_settings_payments'.
-	 */
-	context?: string;
 }

 const HEADER_PLUGIN_NAME = 'settings-payments-offline-header';
@@ -59,12 +46,10 @@ let hasRegisteredPlugins = false;
  */
 export const Header = ( {
 	title,
-	backLink,
 	description,
 	hasButton,
 	buttonLabel,
 	onButtonClick,
-	context = '',
 }: HeaderProps ) => {
 	if ( ! hasRegisteredPlugins ) {
 		/**
@@ -84,15 +69,6 @@ export const Header = ( {
 		registerPlugin( HEADER_PLUGIN_NAME, {
 			render: () => (
 				<>
-					{ backLink && (
-						<WooHeaderNavigationItem>
-							<BackButton
-								href={ backLink }
-								title={ title }
-								from={ context }
-							/>
-						</WooHeaderNavigationItem>
-					) }
 					<WooHeaderPageTitle>
 						<span className="woocommerce-settings-payments-header__title">
 							{ title }
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/components/list-placeholder/list-placeholder.scss b/plugins/woocommerce/client/admin/client/settings-payments/components/list-placeholder/list-placeholder.scss
index 17e91967c8b..e8018a2f228 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/components/list-placeholder/list-placeholder.scss
+++ b/plugins/woocommerce/client/admin/client/settings-payments/components/list-placeholder/list-placeholder.scss
@@ -3,6 +3,19 @@
 	border-bottom: none; // This way we avoid a double border between items.
 	padding: 0 48px;

+	// The header above draws the line that closes it off, so the first row
+	// would stack a second one directly on top of it.
+	&:first-child {
+		border-top: none;
+	}
+
+	// @woocommerce/components' List draws the separator over this one in the
+	// lighter $gray-100, which would leave the skeleton's lines a shade off
+	// from the ones the loaded list settles on.
+	.woocommerce-list > &:not(:first-child) {
+		border-top-color: $gray-200;
+	}
+
 	// Add back the bottom border for the last item.
 	&:nth-last-child(1 of .woocommerce-item__payment-gateway-placeholder) {
 		border-bottom: 1px solid $gray-200;
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/components/offline-payment-gateway-list/offline-payment-gateway-list.scss b/plugins/woocommerce/client/admin/client/settings-payments/components/offline-payment-gateway-list/offline-payment-gateway-list.scss
index 06e51e21537..c88f5eae29a 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/components/offline-payment-gateway-list/offline-payment-gateway-list.scss
+++ b/plugins/woocommerce/client/admin/client/settings-payments/components/offline-payment-gateway-list/offline-payment-gateway-list.scss
@@ -1,9 +1,17 @@
 .woocommerce-list {
-	border-top: 1px solid $gray-200;
+	// No border-top: the header above closes itself off with its own
+	// border-bottom, in every loading state.

 	.woocommerce-list__item {
 		padding: 0 48px;

+		// The separator comes from @woocommerce/components' List, which draws
+		// it in $gray-100 — a shade lighter than the lines bracketing the list
+		// and than the separators on the main providers list.
+		&:not(:first-child) {
+			border-top-color: $gray-200;
+		}
+
 		&.is-last {
 			border-bottom: 1px solid $gray-200;
 		}
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/components/payment-gateway-list/payment-gateway-list.scss b/plugins/woocommerce/client/admin/client/settings-payments/components/payment-gateway-list/payment-gateway-list.scss
index ada5ffc6872..f4a40a3ba4b 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/components/payment-gateway-list/payment-gateway-list.scss
+++ b/plugins/woocommerce/client/admin/client/settings-payments/components/payment-gateway-list/payment-gateway-list.scss
@@ -1,5 +1,6 @@
 .settings-payment-gateways__list {
-	border-top: 1px solid $gray-200;
+	// No border-top: the header above closes itself off with its own
+	// border-bottom, in every loading state.
 	// Without this, the window.innerWidth is not calculated correctly when elements are pushed outside the container.
 	overflow: hidden;

diff --git a/plugins/woocommerce/client/admin/client/settings-payments/components/settings/settings.scss b/plugins/woocommerce/client/admin/client/settings-payments/components/settings/settings.scss
index 0cc5bcde446..feb8c33379f 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/components/settings/settings.scss
+++ b/plugins/woocommerce/client/admin/client/settings-payments/components/settings/settings.scss
@@ -1,3 +1,9 @@
+// Space above the first line of a section, applied to both columns so the
+// details heading and the first control start on the same line. Declared once
+// so the two cannot drift apart — previously the heading took wp-admin's
+// default h2 margin and the controls carried a hand-tuned counter-nudge.
+$settings-section-leading: $gap;
+
 .settings-layout {
 	margin: 48px 12px 0;
 	display: flex;
@@ -48,6 +54,9 @@
 		h2 {
 			font-size: 16px;
 			line-height: 24px;
+			// Stated rather than inherited from wp-admin, so the controls
+			// column has a fixed value to align against.
+			margin-top: $settings-section-leading;
 		}

 		p {
@@ -76,8 +85,13 @@
 	}

 	&__controls {
-		margin-top: 6px;	// Ensure visual alignment.
+		margin-top: $settings-section-leading;
 		flex: 1 1 auto;
+
+		// The columns stack here, so there is no heading to align against.
+		@include breakpoint( "<782px" ) {
+			margin-top: 0;
+		}
 	}

 	.form-field:not(:first-child) {
@@ -85,17 +99,15 @@
 	}
 }

+// These controls used to carry their own 32px bottom margins, from before the
+// forms moved to DataForm. DataForm spaces its fields with a gap, so those
+// margins stacked on top of it and every field ended up 48px apart. The gap is
+// now the only thing setting that rhythm.
 .components-base-control {
-	margin-bottom: 32px;
-
 	+ .components-base-control__help {
 		margin-top: 0;
 		margin-bottom: 0;
 	}
-
-	&__help {
-		margin-bottom: 32px;
-	}
 }

 .woocommerce-tree-select-control {
@@ -115,7 +127,10 @@
 	}

 	&__help {
+		// Matches the 8px that Gutenberg's own help text sits below its field.
+		// No bottom margin, for the same reason as the base control above: the
+		// DataForm gap already separates this field from the next.
 		margin-top: calc(8px);
-		margin-bottom: 32px;
+		margin-bottom: 0;
 	}
 }
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/index.tsx b/plugins/woocommerce/client/admin/client/settings-payments/index.tsx
index 9c540e6a5e6..cce739d4b8f 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/index.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-payments/index.tsx
@@ -90,19 +90,20 @@ const OfflinePaymentGatewayWrapper = ( {
 			<div className="settings-payments-offline__container">
 				<div className="settings-payment-gateways">
 					<div className="settings-payments-offline__header">
-						<BackButton
-							href={ getNewPath( {}, '/offline' ) }
-							title={ __(
-								'Return to payments settings',
-								'woocommerce'
-							) }
-							isRoute={ true }
-							from={ 'woopayments_payment_methods' }
-						/>
 						<h1 className="components-truncate components-text woocommerce-layout__header-heading woocommerce-layout__header-left-align settings-payments-offline__header-title">
-							<span className="woocommerce-settings-payments-header__title">
-								{ title }
-							</span>
+							<BackButton
+								href={ getNewPath( {}, '/offline' ) }
+								tooltipText={ __(
+									'Return to offline payment methods',
+									'woocommerce'
+								) }
+								isRoute={ true }
+								from={ 'woopayments_payment_methods' }
+							>
+								<span className="woocommerce-settings-payments-header__title">
+									{ title }
+								</span>
+							</BackButton>
 						</h1>
 					</div>
 					<Suspense fallback={ <Placeholder /> }>
@@ -216,23 +217,24 @@ export const SettingsPaymentsOfflineWrapper = () => {
 		<>
 			<div className="settings-payments-offline__container">
 				<div className="settings-payments-offline__header">
-					<BackButton
-						href={ getNewPath(
-							{ page: 'wc-settings', tab: 'checkout' },
-							'/',
-							{}
-						) }
-						title={ __(
-							'Return to payments settings',
-							'woocommerce'
-						) }
-						isRoute={ true }
-						from={ 'woopayments_payment_methods' }
-					/>
 					<h1 className="components-truncate components-text woocommerce-layout__header-heading woocommerce-layout__header-left-align">
-						<span className="woocommerce-settings-payments-header__title">
-							{ __( 'Take offline payments', 'woocommerce' ) }
-						</span>
+						<BackButton
+							href={ getNewPath(
+								{ page: 'wc-settings', tab: 'checkout' },
+								'/',
+								{}
+							) }
+							tooltipText={ __(
+								'Return to payments settings',
+								'woocommerce'
+							) }
+							isRoute={ true }
+							from={ 'woopayments_payment_methods' }
+						>
+							<span className="woocommerce-settings-payments-header__title">
+								{ __( 'Take offline payments', 'woocommerce' ) }
+							</span>
+						</BackButton>
 					</h1>
 				</div>
 				<Suspense fallback={ <ListPlaceholder rows={ 3 } /> }>
@@ -249,10 +251,7 @@ export const SettingsPaymentsOfflineWrapper = () => {
 export const SettingsPaymentsWooPaymentsWrapper = () => {
 	return (
 		<>
-			<Header
-				title={ __( 'Settings', 'woocommerce' ) }
-				context={ 'wc_settings_payments__woopayments' }
-			/>
+			<Header title={ __( 'Settings', 'woocommerce' ) } />
 			<Suspense
 				fallback={
 					<div>
@@ -294,10 +293,7 @@ export const SettingsPaymentsChequeWrapper = () =>
 export const SettingsPaymentsMainWrapper = () => {
 	return (
 		<>
-			<Header
-				title={ __( 'Settings', 'woocommerce' ) }
-				context={ 'wc_settings_payments__main' }
-			/>
+			<Header title={ __( 'Settings', 'woocommerce' ) } />
 			<HistoryRouter history={ getHistory() }>
 				<Routes>
 					<Route
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/settings-payments-body.scss b/plugins/woocommerce/client/admin/client/settings-payments/settings-payments-body.scss
index c6a553ba411..66c5f520a10 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/settings-payments-body.scss
+++ b/plugins/woocommerce/client/admin/client/settings-payments/settings-payments-body.scss
@@ -18,6 +18,30 @@ body.woocommerce_page_wc-settings.woocommerce-settings-payments-tab {
 	.nav-tab-wrapper {
 		background-color: #f0f0f1;
 	}
+	// The panel has to reach the bottom of the window, footer included, but
+	// #mainform is only as tall as its content, so the body grey used to show
+	// beneath it. Other tabs leave #mainform grey, so the seam only appears
+	// here. #wpcontent is already held to the window height, so making it a
+	// column lets #wpbody take whatever the content leaves over — no
+	// hardcoded admin-bar, page-header, or footer heights to drift when any
+	// of them change, and no arithmetic that a short page rounds differently
+	// from a long one.
+	#wpcontent {
+		display: flex;
+		flex-direction: column;
+	}
+	// The white goes on #wpbody rather than #wpcontent because #wpbody clears
+	// the fixed page header with a top margin, and a margin paints the
+	// parent's background. A white #wpcontent therefore leaks into the strip
+	// between the page header and the tab strip as soon as the content moves
+	// off its resting position, which is the continuous grey chrome region
+	// the tab strip above is establishing. #wpbody starts below that margin,
+	// so it cannot reach it. The footer is absolutely positioned against the
+	// bottom of #wpwrap and overlaps #wpbody, so it lands on the white too.
+	#wpbody {
+		background-color: #fff;
+		flex: 1;
+	}
 	#mainform {
 		background-color: #fff;
 	}
@@ -27,6 +51,9 @@ body.woocommerce_page_wc-settings.woocommerce-settings-payments-tab {
 	margin: 0 -30px;

 	.settings-layout {
-		margin: 0 48px 0;
+		// The horizontal 48px lines the form up with the header above it. The
+		// top, plus the section heading's own leading, puts the first line of
+		// the form roughly where it sat before the header was reworked.
+		margin: $gap 48px 0;
 	}
 }
diff --git a/plugins/woocommerce/client/admin/client/settings-payments/settings-payments-main.scss b/plugins/woocommerce/client/admin/client/settings-payments/settings-payments-main.scss
index 427e1eeedae..36c84624fa7 100644
--- a/plugins/woocommerce/client/admin/client/settings-payments/settings-payments-main.scss
+++ b/plugins/woocommerce/client/admin/client/settings-payments/settings-payments-main.scss
@@ -4,6 +4,13 @@
 /* This is imported here to avoid build errors when importing the same file in multiple places. */
 @import "./components/modals/modals.scss";

+// Every Payments screen lays its header title out on a row this tall — the
+// height of the business location control, which is the tallest thing any of
+// these headers carries. Stated once and applied to each header, so the title
+// and the divider below it land in the same place on all of them and nothing
+// shifts when moving between screens.
+$payments-header-row-height: 38px;
+
 /* These keyframes and spinner style are copied from the Stripe Spinner component https://docs.stripe.com/stripe-apps/components/spinner?app-sdk-version=8  */
 @keyframes SpinnerAnimationShow {
 	0% {
@@ -53,6 +60,12 @@
 			justify-content: space-between;
 			padding: $gap-smaller 48px $gap-large;
 			align-items: center;
+			// Every header on this screen closes itself off with this line,
+			// rather than leaving it to whatever renders below. What renders
+			// below varies — the provider list, or the skeleton while they
+			// load — and each drawing its own top border is how the line ended
+			// up doubled in one state and missing in another.
+			border-bottom: 1px solid $gray-200;

 			@media screen and (max-width: $break-xlarge) {
 				padding: $gap-smaller $gap-large $gap-large;
@@ -68,9 +81,15 @@
 		}

 		&__header-title {
+			display: flex;
+			align-items: center;
+			min-height: $payments-header-row-height;
 			font-size: 16px;
 			font-weight: 600;
 			line-height: 24px;
+			// Without this it inherits wp-admin's body grey, which is lighter
+			// than every other heading on the screen.
+			color: $gray-900;
 		}

 		&__header-select-container {
@@ -222,6 +241,10 @@
 				font-weight: 600;
 				line-height: 24px;
 				align-items: center;
+				// The header is a bare <button>, so without this the title
+				// falls to the browser's black rather than the heading colour
+				// used elsewhere on the screen.
+				color: $gray-900;

 				&-image {
 					width: $icon-size;
@@ -608,16 +631,30 @@
 		display: flex;
 		padding: 8px 48px 24px;
 		align-items: center;
+		// Same ownership as the main screen's header: the line belongs to the
+		// header, on both the methods list and an individual method's page.
+		border-bottom: 1px solid $gray-200;

 		h1 {
-			padding-left: $gap-smaller * 2;
 			// This heading renders outside the floating header, so the shared
-			// header typography doesn't reach it — restate it to match the other
-			// settings headers. padding-top trims wp-admin's default h1 padding
-			// so the title centers against the back button.
-			padding-top: 4px;
-			font-size: 15px;
-			font-weight: 500;
+			// header typography doesn't reach it. It occupies the slot the main
+			// screen gives "Payment providers", so it takes that heading's
+			// scale — the floating header's lighter 15px/500 left this h1
+			// smaller than the section headings and list rows beneath it.
+			// The gap to the chevron lives inside the back button, which sits
+			// within this heading.
+			// Laying the title out as a centred flex row, rather than letting
+			// it sit on a text baseline, is what keeps it level with the main
+			// screen's title: the back button is an inline-flex box, so a
+			// baseline would leave the descender gap below it in the heading's
+			// height. Zeroing the padding drops wp-admin's own h1 padding,
+			// which the row height now covers.
+			display: flex;
+			align-items: center;
+			min-height: $payments-header-row-height;
+			padding: 0;
+			font-size: 16px;
+			font-weight: 600;
 			color: $gray-900;

 			.woocommerce-settings-payments-header__title {