diff --git a/apps/web/public/static/css/style.css b/apps/web/public/static/css/style.css
index 8b00818..ad1c9b9 100644
--- a/apps/web/public/static/css/style.css
+++ b/apps/web/public/static/css/style.css
@@ -778,17 +778,18 @@ a:hover {
/* ------------------------------------------------------------------- ads */
-/* One sponsored line, sold through CrawlProof. Sized here rather than by an ad
- script, so it occupies the same space before and after it loads and moves
- nothing underneath it.
+/* One ad, sold through CrawlProof. Sized here rather than by an ad script, so
+ it occupies the same space before and after it loads and moves nothing
+ underneath it.
An unsold slot answers with a blank document, and nothing on this side can
- see that across an origin — so the unit is styled as a strip of whitespace
- with no border, no ground and no label of its own. Empty, it reads as the gap
- between two sections, which is where it was put. */
+ see that across an origin — so the unit carries no border and no ground of
+ its own. Empty, it is whitespace in a gap between two sections, which is
+ where it was put. */
.ad-unit {
display: block;
margin: 2.6rem 0;
+ text-align: center;
}
.ad-unit iframe {
display: block;
@@ -801,12 +802,22 @@ a:hover {
width: 100%;
height: 40px;
}
-/* The banner creatives are laid out at a fixed pixel width inside the frame, so
- a narrowed frame would crop one rather than reflow it. They keep their own
- width and centre instead. */
+/* Every banner creative is laid out at its format's fixed pixel width inside
+ the frame, so a narrower frame crops it rather than reflowing it. They keep
+ their own width and centre. */
.ad-unit:not([data-ad-format="text_link"]) iframe {
margin-inline: auto;
}
+/* Below this the column is narrower than the 300px rectangle — 250px on a test
+ page at 320px, which would have cut a fifth off the creative — so the unit
+ escapes the column's padding and centres against the viewport instead. The
+ ad does not get bigger; it just stops being cropped by a gutter. Safe against
+ a stray pixel because body already hides horizontal overflow. */
+@media (max-width: 640px) {
+ .ad-unit:not([data-ad-format="text_link"]) {
+ margin-inline: calc(50% - 50vw);
+ }
+}
.ad-label {
display: block;
margin-bottom: 0.3rem;
diff --git a/apps/web/src/components/AdUnit.jsx b/apps/web/src/components/AdUnit.jsx
index 2573614..7623a6f 100644
--- a/apps/web/src/components/AdUnit.jsx
+++ b/apps/web/src/components/AdUnit.jsx
@@ -16,11 +16,22 @@ import { config } from '@d3vices/config';
* mints a `crawlproof.visitor` id — writes nothing into this origin's local
* storage. The privacy page can go on saying what it says.
*
- * The cost of having no script is that nothing here can measure the viewport,
- * and every banner creative is laid out at a fixed pixel width inside the frame
- * (a narrowed iframe clips it rather than reflowing it). `text_link` is the one
- * format built to fill its container, so it is the default and the only one
- * that belongs in a column whose width is not known in advance.
+ * The cost of having no script is that nothing here can measure anything, and
+ * that shapes both remaining choices.
+ *
+ * The format is one fixed size for every viewport, because picking by width
+ * would need a script and rendering two units would bill two impressions for
+ * one reader. It is the rectangle: 300px is the widest fixed creative that
+ * still fits a 320px phone, and every creative is laid out at its format's
+ * exact pixel width inside the frame, so a narrower column crops it instead of
+ * reflowing it. The stylesheet handles the columns that are narrower than that
+ * by letting the unit escape their padding — see `.ad-unit` there.
+ *
+ * The theme is not passed at all. `/api/ads/frame` defaults to shipping both
+ * palettes behind a `prefers-color-scheme` query, which the frame answers from
+ * the reader's own browser — a better signal than anything this end could
+ * guess, given the site's theme is itself a stored preference the server never
+ * sees.
*/
const SIZES = {
banner_300x250: [300, 250],
@@ -30,7 +41,7 @@ const SIZES = {
text_link: [null, 40],
};
-export function AdUnit({ format = 'text_link' }) {
+export function AdUnit({ format = 'banner_300x250' }) {
const { origin, slot } = config.ads;
// No slot configured — the desktop export, and any checkout that has not set
// one — renders nothing at all rather than an empty box.
diff --git a/apps/web/src/pages/TestPage.jsx b/apps/web/src/pages/TestPage.jsx
index 9309171..e286440 100644
--- a/apps/web/src/pages/TestPage.jsx
+++ b/apps/web/src/pages/TestPage.jsx
@@ -126,7 +126,7 @@ export function TestPage({ test }) {
{/* Below the instrument, its permissions and its FAQ: the reading is
done and the next thing on the page is a list of links away, so a
- sponsored line here interrupts nothing. Never above the test — a
+ unit here interrupts nothing. Never above the test — a
reader who came to find out whether their microphone works should
reach it without passing an ad. */}
diff --git a/test/routes.test.js b/test/routes.test.js
index 7c85b24..f815106 100644
--- a/test/routes.test.js
+++ b/test/routes.test.js
@@ -293,10 +293,29 @@ describe('the ad unit', () => {
expect(html).not.toContain('data-cp-ad');
});
- test('it asks for the one format that is actually fluid', async () => {
- // Every banner creative is laid out at its format's fixed pixel width, so a
- // narrower frame clips it instead of reflowing.
- expect(await (await get('/')).text()).toContain('format=text_link');
+ test('it asks for one format on every viewport, and reserves that exact box', async () => {
+ // Picking a size by width would need a script, and rendering two units so
+ // CSS can hide one bills two impressions for one reader. So it is one fixed
+ // format everywhere — the rectangle, 300px being the widest creative that
+ // still fits a 320px phone.
+ //
+ // The box has to match the format: every creative is laid out at its
+ // format's exact pixel width inside the frame, so a frame of any other size
+ // crops it rather than reflowing it.
+ const tag = (await (await get('/')).text()).match(/