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 && (
+
+ Advertisement
+
+ )}
@@ -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,