From fabf37b36668b18cead927cdcf9d3dc93584d7a3 Mon Sep 17 00:00:00 2001 From: Keith So Date: Wed, 3 Jun 2026 22:23:47 +0100 Subject: [PATCH 1/2] fix: eliminate mobile CLS on calculator pages via fixed-height ad slots In-flow AdSense slots in CalculatorLayout reserved only min-height:100px with responsive fill, so a filled creative (~250px) expanded the box and shifted content (mobile CLS ~0.45 vs 0.045 on the ad-free homepage). - AdUnit: add reserveHeight prop for a fixed-height box + fixed-height ins (data-ad-format/data-full-width-responsive omitted per AdSense 9183363), aria-hidden placeholder, collapse scoped to :not(.ad-reserved), drop the animated max-height transition - CalculatorLayout: reserveHeight=100 on the two in-flow mobile slots - BaseLayout: skip reserved slots in the collapse loop; set body.has-anchor-ad only when the anchor fills - global.css: define the previously no-op .safe-area-bottom; conditional anchor padding Playwright: in-flow slots hold 100px (CLS 0); legacy sim expands to 250px (CLS 0.91). Build green, 898 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/common/AdUnit.astro | 71 ++++++++++++++++++++++++++---- src/layouts/BaseLayout.astro | 19 +++++--- src/layouts/CalculatorLayout.astro | 16 ++++++- src/styles/global.css | 17 +++++++ 4 files changed, 106 insertions(+), 17 deletions(-) diff --git a/src/components/common/AdUnit.astro b/src/components/common/AdUnit.astro index eb50a53..42243d1 100644 --- a/src/components/common/AdUnit.astro +++ b/src/components/common/AdUnit.astro @@ -16,6 +16,12 @@ export interface Props { className?: string; style?: string; lazy?: boolean; + /** + * When set (px), this is an in-flow slot: reserve a fixed-height box and HOLD it + * (never collapse) so a filled creative fits exactly and unfilled slots do not + * shift layout. Keeps mobile CLS at zero on the two in-content calculator slots. + */ + reserveHeight?: number; } const { @@ -26,23 +32,44 @@ const { className = '', style = 'display:block', lazy = true, + reserveHeight, } = Astro.props; const isProd = import.meta.env.PROD; const clientId = import.meta.env.PUBLIC_ADSENSE_ID || 'ca-pub-8014911033000505'; const uniqueId = `ad-${slot}-${Math.random().toString(36).slice(2, 8)}`; + +// In-flow reserve-and-hold slot: deterministic fixed-height box, no collapse. +const isReserved = typeof reserveHeight === 'number' && reserveHeight > 0; +const containerClass = `ad-container${isReserved ? ' ad-reserved' : ''} ${className}`.trim(); +const containerStyle = isReserved + ? `min-height:${reserveHeight}px;height:${reserveHeight}px` + : undefined; +// Fixed-height, responsive-width unit (Google AdSense answer 9183363): omitting +// data-ad-format + data-full-width-responsive bounds the served creative to the box. +const insStyle = isReserved ? `display:block;width:100%;height:${reserveHeight}px` : style; --- { isProd && ( -
+
+ {isReserved && ( + + )} @@ -61,22 +88,50 @@ const uniqueId = `ad-${slot}-${Math.random().toString(36).slice(2, 8)}`; } diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 9b956dc..11fb2e2 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -122,23 +122,28 @@ const ogImageURL = ogImage } catch (e) {} } - // Collapse unfilled ad containers after AdSense processes them + // Resolve ad fill state after AdSense processes the slots. + // Reserved in-flow slots (.ad-reserved) are NEVER collapsed: they hold a + // fixed-height box so the page does not shift (CLS). We only collapse the + // out-of-flow mobile anchor and the desktop sidebar/top slots when unfilled. setTimeout(function () { var containers = document.querySelectorAll('.ad-container'); for (var i = 0; i < containers.length; i++) { + if (containers[i].classList.contains('ad-reserved')) continue; var ins = containers[i].querySelector('ins.adsbygoogle'); if (!ins) continue; var status = ins.getAttribute('data-ad-status'); var iframe = ins.querySelector('iframe'); var hasContent = iframe && iframe.clientHeight > 0; - if ( - status === 'unfilled' || - (!hasContent && ins.getAttribute('data-adsbygoogle-status')) - ) { + var unfilled = + status === 'unfilled' || (!hasContent && ins.getAttribute('data-adsbygoogle-status')); + var anchor = containers[i].closest('.mobile-anchor-ad'); + if (unfilled) { containers[i].classList.add('ad-unfilled'); - // Also collapse parent mobile anchor wrapper if present - var anchor = containers[i].closest('.mobile-anchor-ad'); if (anchor) anchor.style.display = 'none'; + } else if (anchor) { + // Anchor filled: reserve bottom space so the fixed bar doesn't cover content. + document.body.classList.add('has-anchor-ad'); } } }, 3000); diff --git a/src/layouts/CalculatorLayout.astro b/src/layouts/CalculatorLayout.astro index 5521480..7fb4624 100644 --- a/src/layouts/CalculatorLayout.astro +++ b/src/layouts/CalculatorLayout.astro @@ -122,8 +122,14 @@ const colorClasses: Record = { +
- +
@@ -185,8 +191,14 @@ const colorClasses: Record = { } +
- +
diff --git a/src/styles/global.css b/src/styles/global.css index 07674bd..7fb804b 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -99,6 +99,23 @@ body { overflow-x: hidden; } +/* Safe-area utility (referenced by the mobile anchor ad wrapper) */ +.safe-area-bottom { + padding-bottom: env(safe-area-inset-bottom); +} + +/* + * When the mobile anchor ad is FILLED, BaseLayout's ad init adds .has-anchor-ad + * to so page content clears the fixed bar. Added conditionally, so an + * unfilled (hidden) anchor leaves no permanent bottom gap. padding-bottom does + * not reposition existing content, so applying it post-load is CLS-safe. + */ +@media (max-width: 1023px) { + body.has-anchor-ad { + padding-bottom: calc(100px + env(safe-area-inset-bottom)); + } +} + /* Remove 300ms tap delay on mobile for all interactive elements */ button, a, From d9ec74edaeee379e671a734bfb13114e11ea7700 Mon Sep 17 00:00:00 2001 From: Keith So Date: Wed, 3 Jun 2026 22:31:49 +0100 Subject: [PATCH 2/2] fix: gate mobile anchor padding on observed fill (codex review P2) The has-anchor-ad branch fired on !unfilled, but at the 3s check the anchor's AdSense status is often still pending (delayed script, slow network, ad blocker). Pending was treated as filled, adding 100px bottom padding that nothing removed if the ad never filled -> a permanent bottom gap on ad-blocked/unfilled pages. Gate on a positively-observed filled state (status==='filled' || hasContent). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/layouts/BaseLayout.astro | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 11fb2e2..8df4f41 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -137,12 +137,15 @@ const ogImageURL = ogImage var hasContent = iframe && iframe.clientHeight > 0; var unfilled = status === 'unfilled' || (!hasContent && ins.getAttribute('data-adsbygoogle-status')); + var filled = status === 'filled' || hasContent; var anchor = containers[i].closest('.mobile-anchor-ad'); if (unfilled) { containers[i].classList.add('ad-unfilled'); if (anchor) anchor.style.display = 'none'; - } else if (anchor) { - // Anchor filled: reserve bottom space so the fixed bar doesn't cover content. + } else if (anchor && filled) { + // Anchor POSITIVELY filled: reserve bottom space so the fixed bar doesn't + // cover content. Gated on an observed fill (not !unfilled) so a pending, + // blocked, or never-loaded anchor never leaves a permanent bottom gap. document.body.classList.add('has-anchor-ad'); } }