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..8df4f41 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -122,23 +122,31 @@ 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 filled = status === 'filled' || hasContent; + 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 && 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'); } } }, 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,