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(/]*>/)?.[0] ?? ''; + expect(tag).toContain('format=banner_300x250'); + expect(tag).toContain('width="300"'); + expect(tag).toContain('height="250"'); + }); + + test('it does not ask for a theme, because this end cannot know one', async () => { + // The site's theme is a stored preference the server never sees. The frame + // defaults to shipping both palettes behind prefers-color-scheme, which it + // answers from the reader's own browser — so naming one here would be us + // overriding a better signal with a guess. + const tag = (await (await get('/')).text()).match(/]*>/)?.[0] ?? ''; + expect(tag).toContain('/api/ads/frame'); + expect(tag).not.toContain('theme='); }); test('the frame is sandboxed to opening its own link and nothing else', async () => {