Skip to content

Latest Site Hints: Fix mobile horizontal scroll and stop the banner covering page content - #1765

Open
wputasic wants to merge 2 commits into
WordPress:productionfrom
wputasic:fix/latest-site-hints-banner-overlap
Open

Latest Site Hints: Fix mobile horizontal scroll and stop the banner covering page content#1765
wputasic wants to merge 2 commits into
WordPress:productionfrom
wputasic:fix/latest-site-hints-banner-overlap

Conversation

@wputasic

Copy link
Copy Markdown
Contributor

Fixes #1764

What's broken

The "«WordCamp X» is over. Check out the next edition!" banner prints at wp_footer as a position: fixed overlay, and the page makes room for it with a hardcoded margin-top: 44px on <html> — sized for exactly one line of text (10px ×2 padding + 24px line height).

  1. Page can be panned sideways on mobile (the bug reported in .wordcamp-latest-site-notify causes horizontal scroll on mobile (<=600px) #1764). The banner is width: 100% plus padding: 10px 20px with no box-sizing: border-box, so under the default content-box it renders 40px wider than the viewport. Below 600px the banner is position: absolute, which counts toward the document's scrollable overflow — users can pan the page sideways and content appears shifted/clipped. (Above 600px, position: fixed is excluded from scrollable overflow, so desktop never surfaced this.) Reproduced e.g. on asia.wordcamp.org/2026 (per the issue) and us.wordcamp.org.

  2. Content hidden behind the banner — an additional bug in the same banner, fixed here too. Whenever the text wraps past one line, the banner is taller than the hardcoded 44px reservation and covers the top of the site. On phones the message virtually always wraps to two lines (~68px), hiding the top ~24px of every past camp's site — clipped logos and headers. It isn't strictly mobile-only: longer site names and verbose locales wrap on mid-size viewports too (measured: "WordCamp Kathmandu Valley 2025 ha finalizado…" at a 625px viewport → banner 68px, reserved 44px, 24px of content hidden on the desktop code path). Reproduced in production on europe.wordcamp.org (2025 and 2026), valencia.wordcamp.org and us.wordcamp.org.

Root cause

CSS on its own can't size the <html> margin to another element's rendered height, and the banner's height is content-dependent: it varies with viewport width, site-name length, translation length, and theme font. Any hardcoded reservation is a guess that fails as soon as the text wraps.

The fix (CSS-only, no JS)

The one-line box-sizing: border-box suggested in #1764 is included verbatim and resolves the horizontal scroll. This PR additionally moves the banner into normal flow to fix the overlap bug, since both stem from the same overlay markup:

Print the banner at wp_body_open instead, so it sits in the normal document flow at the top of the page. Layout then reserves exactly the space the text needs — any number of lines, any locale — and position: sticky keeps it pinned while scrolling. On ≤600px screens it becomes position: static so it scrolls away instead of permanently taking up small-screen space, preserving the previous mobile behavior. box-sizing: border-box fixes the sideways-pan overflow.

One known, graceful degradation: on themes that set overflow-x: hidden on html/body (a common theme pattern that disables position: sticky), the banner won't stay pinned while scrolling on desktop — it simply scrolls away with the page, like the mobile behavior. It still reserves exact space and never covers content.

Themes that never call wp_body_open() keep the previous behavior as a fallback: the banner still prints at wp_footer (guarded by did_action( 'wp_body_open' )) with a --overlay modifier class restoring position: fixed and the <html> margin reservation — now bumped to two lines (68px) under 600px, where the text almost always wraps. That path remains an estimate by nature (a three-line banner on a legacy theme could still overlap slightly), but every theme that supports wp_body_open() — including all block themes — is exact at every width.

Measured before/after

Headless Chrome, exact plugin CSS/markup, WCEU string:

Scenario Before After
390px (2-line banner) banner 68px, reserved 44px, 24px content hidden, 40px sideways overflow in flow, 0px hidden, 0px overflow
625px, long name (desktop path) banner 68px, reserved 44px, 24px content hidden in flow, 0px hidden, 0px overflow
≥700px (1-line banner) OK (44px = 44px) in flow, 0px hidden, 0px overflow
Fallback path (no wp_body_open), 390px absolute, reserved 68px = banner 68px, 0px hidden

Also verified end-to-end on a local sandbox: past camp with a newer sibling site, banner renders in flow right after <body>, two lines, nothing clipped, scrollWidth == clientWidth (no element extends past the viewport).

Testing instructions

  1. Visit a past camp that has a newer edition (e.g. europe.wordcamp.org/2025/) in a ≤600px viewport — the site header/logo should start fully below the banner, however many lines it wraps to.
  2. Same page: confirm the page cannot be panned sideways (no horizontal scroll).
  3. Narrow a desktop window to ~625px on a camp with a long name/verbose locale — no content hidden.
  4. Log in and confirm the banner clears the admin bar (sticky top uses --wp-admin--admin-bar--height).
  5. Legacy check: switch a test site to a theme whose header.php doesn't call wp_body_open() — the banner should fall back to the fixed overlay with reserved space (one line ≥600px, two lines below).

Screenshots

The mobile screenshots attached to #1764 show the horizontal-scroll symptom. The content-overlap symptom (site header/logo clipped behind the banner) is visible on any past camp where the banner text wraps — e.g. europe.wordcamp.org/2025, valencia.wordcamp.org or us.wordcamp.org at a phone-width viewport.

wputasic added 2 commits July 13, 2026 13:59
…overing content

Print the banner at wp_body_open so it sits in the normal document flow
and layout reserves exactly the space its text needs at any width or
locale, with position:sticky keeping it pinned (static below 600px so it
scrolls away on phones). Add box-sizing:border-box so width:100% plus
padding no longer overflows the viewport and lets the page pan sideways
on mobile.

Themes that never call wp_body_open() fall back to the previous
wp_footer fixed-overlay behavior, with the reserved margin bumped to two
lines below 600px where the text usually wraps.

Fixes WordPress#1764

@obenland obenland left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough writeup — the root-cause analysis is right, and moving the banner into normal flow is the correct structural fix. box-sizing: border-box landing on the shared base class means #1764 is fixed on both code paths, which is a nice bonus.

My main concern is that the wp_footer fallback is framed as a rare legacy case, but it's actually the path taken by every classic theme in this repo — and it regresses under this change. Details inline; the big one is the layout shift.

Theme audit

I checked every *.php template in every bundled theme. None of them calls wp_body_open():

Theme wp_body_open() <body> emitted at Path
campsite-2017 no header.php:20 fallback
wordcamp-base no lib/structure/class-wcb-body.php:5-7 fallback
wordcamp-base-v2 no header.php:54 fallback
plan (child of twentytwelve) no — overrides the parent's header.php:34 fallback
wordcamp-central-2012 (child of twentyten) no — overrides the parent's header.php:38 fallback
campus-connect, student-clubs, wporg-events-2023, wporg-flagship-landing yes, via core's template canvas flow

New sites default to twentytwentyfour (wordcamp-new-site.php:590-597) and all core twenty* themes have called wp_body_open() since WP 5.2, so recent camps do take the flow path. But this banner only ever shows on past camps, and the older cohort skews toward campsite-2017 / wordcamp-base. There's also the gitignored themes-private/ root registered at wcorg-misc.php:37-38, which can't be audited from here.

Suggestion: add <?php wp_body_open(); ?> right after <body> in all five classic themes as part of this PR. Five one-line edits, core-recommended since WP 5.2, and it makes the rest of the design's assumption actually true — collapsing the fallback to a genuine edge case.

Testing instruction 5 is inverted

Legacy check: switch a test site to a theme whose header.php doesn't call wp_body_open()

The bundled classic themes are already that case. Unless the test site was on a block or twenty* theme, steps 1–4 would have exercised the fallback path, not the flow path — which may be why the layout shift didn't surface.

Tests

mu-plugins/tests/test-latest-site-hints.php exists and covers get_latest_home_url() plus the comment filters, but nothing covers the changed code. Adding has_action( 'wp_body_open', … ) / has_action( 'wp_footer', … ) assertions to test_comments_closed_on_past_site() costs nothing and locks in the regression this PR is most exposed to (a future refactor silently dropping one hook).

Smaller notes

  • Block-theme tab order: the banner is now the first element in <body>, and core injects the skip link relative to .wp-site-blocks (i.e. after it). "Check out the next edition" becomes the first tab stop, ahead of "Skip to content".
  • Sticky-header overlap: campus-connect/parts/header.html:1 and student-clubs/parts/header.html:1 both use "position":{"type":"sticky","top":"0px"}. They pin to the same offset as the banner and paint underneath it. Not a new regression — the old fixed banner covered them worse — but it's unfixed, and these are the only two themes that both take the wp_body_open path and ship a sticky header.
  • Scope: #1764 asked for one line. If the overlap fix needs iteration, the box-sizing one-liner could land on its own and unblock the reported bug immediately.

Verified clean

  • Hook ordering is sound: wp fires in WP::main() before template-loader.php is required, on every front-end request type including 404s, block themes, and this repo's template_include override at theme-templates/bootstrap.php:22.
  • The offline/500 template (theme-templates/templates/offline.php:56) has no wp_body_open() and correctly falls through to the overlay path, even on block-theme sites.
  • No overflow: hidden or display: flex|grid on html/body in any bundled theme — only wordcamp-base-v2/style.css:45 html { overflow-y: scroll }, which is harmless for sticky. Block themes use useRootPaddingAwareAlignments, so the flow-level width: 100% banner does render full-bleed.
  • Query count unchanged — the guard returns before get_latest_home_url().
  • No new security surface$is_overlay is a bool with string-literal branches; message escaping unchanged.
  • phpcs — the only new report is Squiz.PHP.EmbeddedPhp.ContentAfterOpen, explicitly excluded in phpcs.xml.dist.

Suggested order

  1. Add wp_body_open() to the five classic themes — collapses the two biggest findings to an edge case.
  2. Decide the remaining fallback's shape (a bottom-anchored bar is cleanest); fix the cascade specificity if any styles move to wp_head.
  3. Swap did_action() for static $printed.
  4. Comment fixes + has_action() assertions.
  5. Correct testing instruction 5 in the description.

Comment on lines +33 to +35
// Add a banner with a link to the latest WordCamp. It prints at `wp_body_open` so it sits in
// the normal document flow; `wp_footer` is only a fallback for themes that never call
// `wp_body_open()`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment style: three stacked // lines. // is for single-line comments only; multi-line comments inside a function body use /* */ with * continuations.

Suggested change
// Add a banner with a link to the latest WordCamp. It prints at `wp_body_open` so it sits in
// the normal document flow; `wp_footer` is only a fallback for themes that never call
// `wp_body_open()`.
/*
* Add a banner with a link to the latest WordCamp. It prints at `wp_body_open` so it sits in
* the normal document flow; `wp_footer` is only a fallback for themes that never call
* `wp_body_open()`.
*/

The content itself is accurate — only the delimiter is wrong.

// the normal document flow; `wp_footer` is only a fallback for themes that never call
// `wp_body_open()`.
add_action( 'wp_head', __NAMESPACE__ . '\add_notification_styles' );
add_action( 'wp_body_open', __NAMESPACE__ . '\show_notification_about_latest_site' );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wp_footer fallback isn't a legacy edge case — it's the path every classic theme in this repo takes.

I grepped every *.php template in every bundled theme: none of campsite-2017, wordcamp-base, wordcamp-base-v2, plan, or wordcamp-central-2012 calls wp_body_open(). Note that wordcamp-base/-v2 emit <body> from a PHP class (lib/structure/class-wcb-body.php:5-7) rather than a template, and plan / wordcamp-central-2012 override their parents' header.php, so the parent theme's wp_body_open() is never reached. A header-only audit would miss both.

New sites default to twentytwentyfour, which does call it — but this banner only ever shows on past camps, and the older cohort skews to the classic themes. So in production the "fallback" is a heavily populated path, and it's the one that (a) keeps the hardcoded-height estimate this PR set out to fix, and (b) newly gains the layout shift flagged below.

Suggested fix in this PR: add <?php wp_body_open(); ?> immediately after <body> in all five classic themes. Five one-line edits, core-recommended since WP 5.2, and it makes the fallback genuinely rare — which is what the rest of this design assumes.

Comment on lines +78 to +81
*
* The banner sits in the normal document flow at the top of the page, so layout reserves exactly
* as much room as the text needs no matter how many lines it wraps to, and `position: sticky`
* keeps it pinned while scrolling.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This docblock is accurate for the wp_body_open path but false for the other consumer of the very same stylesheet. add_notification_styles() is hooked to wp_head unconditionally (line 36), so it also styles the wp_footer overlay banner — where position is overridden to fixed/absolute, nothing is in flow, and layout reserves nothing (that's exactly why the html margin hack exists).

Rot hazard: a maintainer reading only this would believe box-sizing: border-box and width: 100% only matter for an in-flow element, when they're load-bearing for the fixed variant too. Worth reframing as "base styles shared by both variants; add_notification_overlay_styles() overrides position for the wp_footer fallback" — and noting that the padding / font-size / line-height declared here are the source of the 44 / 68 constants in that function.

The summary line "Simple styles for the notification" is also stale now that there are two style functions.

<?php }

/**
* Overlay styles for themes that never call `wp_body_open()`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: "themes that never call wp_body_open()" is narrower than the actual guard. The code branches on did_action( 'wp_body_open' ), which covers any request where the hook didn't fire — including the PWA offline/500 template (mu-plugins/theme-templates/templates/offline.php:56), which emits its own <body> with no wp_body_open() and replaces the block-template canvas entirely. Even block-theme sites take the overlay path there (correctly — the guard handles it).

Prefer wording tied to the condition ("when wp_body_open didn't fire") rather than to a presumed cause.

*/
function add_notification_overlay_styles() {
?>
<style type="text/css">

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the one blocking issue for me: it introduces a layout shift that doesn't exist on production.

Today html:not(#specificity-hack) { margin-top: calc(44px + …) !important } is printed inside add_notification_styles() on wp_head, so the space is reserved before first paint. This PR moves it into add_notification_overlay_styles(), which is only ever invoked from inside the wp_footer callback — i.e. emitted after the entire body has been parsed and progressively painted.

On the fallback path the browser now lays out and paints at margin-top: 0, then every pixel of content jumps down 44px (68px ≤600px) when this <style> parses. That's a guaranteed CLS hit and visible jank on every page load of every classic-theme site. The measured before/after table in the description captures final geometry, not paint sequence, so it wouldn't have surfaced this.

One thing to rule out up front: html:has(.wordcamp-latest-site-notify--overlay) in wp_head does not fix it. :has() is live, but the matching element still isn't parsed until the end of <body>, so the shift lands at exactly the same moment. It only fixes the validity problem below.

Options that do fix it, in rough order of preference:

  1. Make the fallback rare — add wp_body_open() to the five classic themes (see the comment on line 37). Then this path only affects genuinely foreign themes.
  2. Anchor the fallback bar to the bottom of the viewportposition: fixed; bottom: 0; left: 0; right: 0, no html margin at all. It's already printing at wp_footer; a bottom bar needs zero height reservation, causes zero shift, and structurally eliminates the "CSS can't measure the rendered banner" problem this docblock calls out as unsolvable. Trade-off is that it can cover the last ~44px of the page footer, which is much less harmful than covering the site header.

Separately: <style> is metadata content and isn't valid inside <body> (the scoped attribute that once permitted it was removed from the spec). Browsers honour it, but every classic-theme page will now fail validation where previously all styles were in wp_head. Both options above resolve this too.

margin-top: calc(44px + var(--wp-admin--admin-bar--height, 0px)) !important;
}

.wordcamp-latest-site-notify--overlay {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.wordcamp-latest-site-notify and .wordcamp-latest-site-notify--overlay have identical specificity (0,1,0). The overlay's fixed / absolute only beats the base sticky / static because this stylesheet is emitted later in the document (footer after head).

That works today, but it's load-bearing and undocumented — and any fix for the layout shift above that moves these rules into wp_head will silently invert the cascade and leave the fallback position: sticky, with no test failure and no visible cause until someone loads a classic-theme site.

Writing the modifier with real specificity makes it robust for free:

Suggested change
.wordcamp-latest-site-notify--overlay {
.wordcamp-latest-site-notify.wordcamp-latest-site-notify--overlay {

(and the same for the @media block below).


@media screen and (max-width: 600px) {
html:not(#specificity-hack) {
/* 68 = padding plus two 24px lines, since the text usually wraps here. */

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth calling out as a deliberate trade rather than leaving it implicit: the old code reserved 44px at all widths, so camps whose banner fits on one line at ≤600px (short names like "WordCamp Oslo") now get a ~24px dead gap at the top of every page on the fallback path.

Over-reserving beats hiding content, so I think this is the right call — but it is a behaviour change on the path most past sites take, not purely a fix. Also, "since the text usually wraps here" is an observation no future reader can verify (it depends on camp name length and the active translation). Framing it as the choice it is — reserve two lines because a wrap is likely and over-reserving is far less damaging than under-reserving — would age better.

<?php }

/**
* Show the actual notification containing link to latest site to user.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This docblock is unchanged, but the function's contract materially changed: it's now hooked to two different actions, branches on current_action(), self-suppresses on one of them, and emits a <style> block as a side effect. The last one is especially surprising given the name — worth documenting all four.

function show_notification_about_latest_site() {
global $current_blog;

$is_overlay = 'wp_footer' === current_action();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deriving the mode from current_action() makes this function untestable by direct invocation: called outside a hook, current_action() returns false and it silently takes the non-overlay branch. An $is_overlay = false parameter — or two thin wrapper callbacks, one per hook — would be both cleaner and unit-testable.

Comment on lines +164 to +167
// The banner was already printed in the normal document flow at `wp_body_open`.
if ( $is_overlay && did_action( 'wp_body_open' ) ) {
return;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

did_action() answers "did the hook fire", not "did we print" — so the comment claims a guarantee the condition doesn't provide, and there's no idempotency guard on the wp_body_open side at all. Two failure modes:

  • A theme or plugin calling wp_body_open() twice (several header/footer-script plugins do do_action( 'wp_body_open' ) directly) → two stacked banners. The old wp_footer-only code was structurally immune to this.
  • A theme calling wp_body_open() inside a discarded output buffer (a pattern some themes use to post-process header markup) → the counter increments, the footer fallback suppresses itself, and the banner disappears entirely.

Neither is reachable in this repo today — I checked, there's no do_action( 'wp_body_open' ) anywhere and the only non-theme get_header() call (wordcamp-payments/views/sponsor-payment/main.php:9) renders once — but themes-private/ is gitignored and can't be audited.

Core's own wp_admin_bar_render(), which is the exact wp_body_open + wp_footer pattern this copies, guards with static $rendered for precisely this reason:

function show_notification_about_latest_site() {
	global $current_blog;
	static $printed = false;

	if ( $printed ) {
		return;
	}
	…
	$printed = true;
	echo '<div class="wordcamp-latest-site-notify…';
}

This subsumes the did_action() check entirely — if wp_body_open printed, $printed is already true by wp_footer — and is immune to both failure modes. current_action() is then only needed to pick the --overlay modifier.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

.wordcamp-latest-site-notify causes horizontal scroll on mobile (<=600px)

2 participants