Skip to content

Commit 905b5fb

Browse files
ralyodioclaude
andcommitted
Stop serving ad units that cannot fit the container
Two failures with the same shape, both mobile-only, both measured across the whole network: 728x90 on mobile 6,836 impressions 1 click 0.01% 728x90 on desktop 6,167 impressions 12 clicks 0.19% text_link mobile 15,798 impressions 7 clicks 0.04% text_link desktop 5,458 impressions 44 clicks 0.81% The tag clamps its iframe to max-width:100% with scrolling off, so a unit wider than its container does not shrink — it loses its right-hand side, which is where the CTA lives. The click target was off-screen. An explicit data-format is now honoured only while it fits. The tag sends the width it measured and the response says what was actually served, because the decision has to happen server-side: serve.ts refuses any format outside the slot's own list, so a downgrade chosen by the tag would have returned an empty fill instead of a clipped ad. fitAdFormat picks by area rather than width — at 390px both the rectangle (300) and the mobile strip (320) fit, and the rectangle converts ~7x better. Nothing fits at all → the narrowest unit the slot offers, since a clipped ad still beats a blank one. The text link is full-width by design, so it is never downgraded; its layout was the problem. Everything but the body was flex:0 0 auto, so a long headline pushed the CTA past overflow:hidden. Now the headline is the piece that yields, the body line drops below 520px, and the CTA is pinned right. The "Sponsored" disclosure is never conditional. Body gets flex:1 1 0 rather than 1 1 auto so it grows into slack instead of squeezing the headline on desktop, where this format is the best-converting one we have. Verified by rendering the text link at 320/390/500/600/900px in both polarities, and by compiling the ad.js template — it is a string TypeScript never parses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RBf6k3tUKdQg82bLJ3LGpi
1 parent ffc6b62 commit 905b5fb

8 files changed

Lines changed: 194 additions & 16 deletions

File tree

‎app/ad.js/route.ts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,14 @@ const snippet = `(function(){
2020
var ORIGIN = ${JSON.stringify(env.siteUrl)};
2121
var SIZES = ${JSON.stringify(FORMATS)};
2222
${VISITOR_SNIPPET}
23+
// What the publisher asked for, or a width-appropriate guess when they said
24+
// nothing. An explicit format is passed through as-is: whether it actually
25+
// fits is decided server-side, which is the only place that knows the other
26+
// formats the slot offers. The measured width rides along as ?w= and the
27+
// response says what was really served.
2328
function pickFormat(el, w) {
2429
var f = el.getAttribute('data-format');
2530
if (f && SIZES[f]) return f;
26-
// Choose by available width when unspecified.
2731
if (w >= 728) return 'banner_728x90';
2832
if (w >= 300) return 'banner_300x250';
2933
return 'banner_320x50';
@@ -73,8 +77,8 @@ ${VISITOR_SNIPPET}
7377
if (!slot) return;
7478
var w = el.clientWidth || el.offsetWidth || 300;
7579
var format = pickFormat(el, w);
76-
var dims = SIZES[format] || SIZES.banner_300x250;
77-
var q = '?slot=' + encodeURIComponent(slot) + '&format=' + encodeURIComponent(format);
80+
var q = '?slot=' + encodeURIComponent(slot) + '&format=' + encodeURIComponent(format)
81+
+ '&w=' + Math.round(w);
7882
// Mints the id if this is the first CrawlProof script on the page. It
7983
// used to only read one stats.js had already written, so an ad-tag-only
8084
// publisher reported every impression as an anonymous visitor.
@@ -87,6 +91,10 @@ ${VISITOR_SNIPPET}
8791
.then(function(r){ return r.json(); })
8892
.then(function(res){
8993
if (!res || !res.ok || !res.html) { el.removeAttribute('data-cp-filled'); return; }
94+
// Serving may downgrade a format that could not fit the measured
95+
// width, so size from what came back rather than what we asked for.
96+
if (res.format && SIZES[res.format]) format = res.format;
97+
var dims = SIZES[format] || SIZES.banner_300x250;
9098
var iframe = document.createElement('iframe');
9199
iframe.setAttribute('title', 'Advertisement');
92100
iframe.setAttribute('scrolling', 'no');

‎app/api/ads/serve/route.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ export async function GET(request: NextRequest) {
3636
// What the tag measured on the publisher's page. Anything other than
3737
// light/dark (including absent) defers to the slot's stored default.
3838
const theme = url.searchParams.get("theme");
39+
// Width of the container the tag measured, so serving can decline to send a
40+
// unit that cannot fit it. Absent on older tags — serveAd then honours the
41+
// requested format unchanged.
42+
const width = Number(url.searchParams.get("w"));
3943
if (!slotId || !isAdFormat(format)) {
4044
return NextResponse.json({ ok: false }, { status: 200, headers });
4145
}
@@ -50,6 +54,7 @@ export async function GET(request: NextRequest) {
5054
country: geo?.countryCode ?? null,
5155
device,
5256
theme,
57+
width: Number.isFinite(width) && width > 0 ? width : null,
5358
});
5459

5560
if (!fill) return NextResponse.json({ ok: false }, { status: 200, headers });
@@ -60,7 +65,9 @@ export async function GET(request: NextRequest) {
6065
impressionId: fill.impressionId,
6166
html: fill.html,
6267
clickUrl: fill.clickUrl,
63-
format,
68+
// What was actually served, which is not always what was asked for —
69+
// the tag sizes its iframe from this.
70+
format: fill.creative.format,
6471
},
6572
{ status: 200, headers },
6673
);

‎components/ads/ad-preview.tsx‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,19 @@ export function AdPreview({
138138
>
139139
Sponsored
140140
</span>
141-
<strong style={{ color: p.fgColor, flex: "0 0 auto", whiteSpace: "nowrap" }}>
141+
{/* Yields before the CTA does — mirrors the served unit, where a
142+
rigid headline used to push the CTA out of an overflow:hidden
143+
row on narrow containers. */}
144+
<strong
145+
style={{
146+
color: p.fgColor,
147+
flex: "0 1 auto",
148+
minWidth: 0,
149+
whiteSpace: "nowrap",
150+
overflow: "hidden",
151+
textOverflow: "ellipsis",
152+
}}
153+
>
142154
{creative.headline}
143155
</strong>
144156
{creative.body && (
@@ -150,13 +162,21 @@ export function AdPreview({
150162
textOverflow: "ellipsis",
151163
whiteSpace: "nowrap",
152164
minWidth: 0,
153-
flex: "1 1 auto",
165+
flex: "1 1 0",
154166
}}
155167
>
156168
— {creative.body}
157169
</span>
158170
)}
159-
<span style={{ color: p.accentColor, fontWeight: 600, flex: "0 0 auto", whiteSpace: "nowrap" }}>
171+
<span
172+
style={{
173+
color: p.accentColor,
174+
fontWeight: 600,
175+
flex: "0 0 auto",
176+
whiteSpace: "nowrap",
177+
marginLeft: "auto",
178+
}}
179+
>
160180
{creative.ctaText} →
161181
</span>
162182
</a>

‎lib/ads/creative.ts‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -534,21 +534,35 @@ export function renderCreativeHtml(
534534
// Native text link — a borderless, full-width single line. No image/box.
535535
if (creative.format === "text_link") {
536536
const body = creative.body
537-
? `<span style="color:${p.fgColor};opacity:.72;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1 1 auto">— ${esc(creative.body)}</span>`
537+
? `<span class="cp-body" style="color:${p.fgColor};opacity:.72;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1 1 0">— ${esc(creative.body)}</span>`
538538
: "";
539+
// The unit fills its container, so it renders at whatever width the
540+
// publisher's column happens to be — and this is the one format that used
541+
// to break there. Everything but the CTA was `flex:0 0 auto`, so on a phone
542+
// the headline pushed the CTA past `overflow:hidden` and the only clickable
543+
// affordance vanished: 0.04% CTR on mobile against 0.81% on desktop. Now
544+
// the headline is the piece that yields (it ellipsises), and the body line
545+
// — the least load-bearing part — drops out entirely when there is no room
546+
// for it. The label and the CTA always survive.
547+
//
548+
// The media query measures the iframe, which is the container, so it is
549+
// asking exactly the right question. There is no viewport involved.
539550
return `<!doctype html><html><head><meta charset="utf-8"><style>
540551
*{box-sizing:border-box;margin:0}
541552
a{text-decoration:none;display:block}
542553
.cp-ad{display:flex;align-items:center;gap:8px;width:100%;height:${h}px;
543554
background:${p.bgColor};font-family:${creative.fontFamily};font-size:13px;
544555
padding:0 12px;overflow:hidden;border-radius:0;
545556
border:1px solid ${edge};border-left:3px solid ${p.accentColor}}
557+
.cp-head{color:${p.fgColor};flex:0 1 auto;min-width:0;white-space:nowrap;
558+
overflow:hidden;text-overflow:ellipsis}
559+
@media (max-width:520px){.cp-body{display:none}}
546560
</style></head><body>
547561
<a class="cp-ad" href="${esc(clickUrl)}" target="_blank" rel="noopener sponsored">
548562
<span style="font-size:9px;letter-spacing:.12em;text-transform:uppercase;color:${p.accentColor};flex:0 0 auto">Sponsored</span>
549-
<strong style="color:${p.fgColor};flex:0 0 auto;white-space:nowrap">${esc(creative.headline)}</strong>
563+
<strong class="cp-head">${esc(creative.headline)}</strong>
550564
${body}
551-
<span style="color:${p.accentColor};font-weight:600;flex:0 0 auto;white-space:nowrap">${esc(creative.ctaText)} →</span>
565+
<span style="color:${p.accentColor};font-weight:600;flex:0 0 auto;white-space:nowrap;margin-left:auto">${esc(creative.ctaText)} →</span>
552566
</a>
553567
</body></html>`;
554568
}

‎lib/ads/formats.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,60 @@ export const PUBLISHER_FORMAT_IDS: AdFormatId[] = [
9292
"text_link",
9393
];
9494

95+
/**
96+
* Formats that occupy a fixed pixel box and can therefore fail to fit.
97+
*
98+
* `text_link` is deliberately absent: it renders full-width into whatever it is
99+
* given, so its nominal 600px is a preview size rather than a requirement. The
100+
* terminal and feed units have no pixel box at all.
101+
*/
102+
const FIXED_WIDTH_FORMATS: AdFormatId[] = ["banner_300x250", "banner_728x90", "banner_320x50"];
103+
104+
/**
105+
* The format to actually serve, given what the publisher asked for and how much
106+
* room their container turned out to have.
107+
*
108+
* The tag clamps its iframe to `max-width:100%` with scrolling off, so a unit
109+
* wider than its container does not shrink — it loses its right-hand side,
110+
* which is where the CTA sits. A 728x90 asked for on a phone was measured at
111+
* 0.01% CTR against 0.34% for a rectangle on the same devices: the click target
112+
* was simply off-screen. So an explicit `data-format` is honoured only while it
113+
* fits, and downgraded when it does not.
114+
*
115+
* `allowed` is the slot's own format list, because serving refuses anything
116+
* outside it — a downgrade to a format the slot never opted into would return
117+
* no fill at all, which is worse than the clipped ad we started with.
118+
*
119+
* Returns null only when the request was never servable (the slot does not
120+
* offer the requested format), preserving the previous refusal exactly.
121+
*/
122+
export function fitAdFormat(
123+
requested: AdFormatId,
124+
width: number | null | undefined,
125+
allowed: readonly string[] | null | undefined,
126+
): AdFormatId | null {
127+
const offers = (f: AdFormatId) => !Array.isArray(allowed) || allowed.includes(f);
128+
if (!offers(requested)) return null;
129+
130+
// No measurement (an older tag, or a non-web consumer like the MOTD and feed
131+
// endpoints) means no basis to second-guess the publisher.
132+
if (typeof width !== "number" || !Number.isFinite(width) || width <= 0) return requested;
133+
if (!FIXED_WIDTH_FORMATS.includes(requested)) return requested;
134+
if (formatSpec(requested).w <= width) return requested;
135+
136+
const candidates = FIXED_WIDTH_FORMATS.filter(offers);
137+
const fits = candidates.filter((f) => formatSpec(f).w <= width);
138+
if (fits.length === 0) {
139+
// Nothing fits. A slightly clipped unit still beats a blank one, so fall
140+
// back to the narrowest thing the slot offers rather than giving up.
141+
return candidates.reduce((a, b) => (formatSpec(b).w < formatSpec(a).w ? b : a), requested);
142+
}
143+
// Largest by area, not by width: at 390px both the rectangle (300) and the
144+
// mobile strip (320) fit, and the rectangle converts roughly 7x better.
145+
const area = (f: AdFormatId) => formatSpec(f).w * formatSpec(f).h;
146+
return fits.reduce((a, b) => (area(b) > area(a) ? b : a));
147+
}
148+
95149
// The terminal format id, kept as a named constant since several call sites
96150
// (serving, the MOTD endpoint, the publisher snippet) branch on it.
97151
export const TERMINAL_FORMAT_ID = "terminal_ascii" as const;

‎lib/ads/house.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,18 +134,24 @@ export function renderHouseAdHtml(
134134

135135
// Native text link — no artwork, single full-width line.
136136
if (format === "text_link") {
137+
// Same narrow-container handling as the paid text link — see the comment in
138+
// renderCreativeHtml. The headline yields, the body drops, the label and
139+
// the CTA always survive.
137140
return `<!doctype html><html><head><meta charset="utf-8"><style>
138141
*{box-sizing:border-box;margin:0}
139142
a{text-decoration:none;display:block}
140143
.cp-ad{display:flex;align-items:center;gap:8px;width:100%;height:${h}px;background:${p.bgColor};
141144
font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:13px;padding:0 12px;
142145
overflow:hidden;border-radius:0;border:1px solid ${edge};border-left:3px solid ${p.accentColor}}
146+
.cp-head{color:${p.fgColor};flex:0 1 auto;min-width:0;white-space:nowrap;
147+
overflow:hidden;text-overflow:ellipsis}
148+
@media (max-width:520px){.cp-body{display:none}}
143149
</style></head><body>
144150
<a class="cp-ad" href="${esc(clickUrl)}" target="_blank" rel="noopener sponsored">
145151
<span style="font-size:9px;letter-spacing:.12em;text-transform:uppercase;color:${p.accentColor};flex:0 0 auto">CrawlProof Ads</span>
146-
<strong style="color:${p.fgColor};flex:0 0 auto;white-space:nowrap">${esc(HOUSE.headline)}</strong>
147-
<span style="color:${bodyInk};overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1 1 auto">— ${esc(HOUSE.body)}</span>
148-
<span style="color:${p.accentColor};font-weight:600;flex:0 0 auto;white-space:nowrap">${esc(HOUSE.cta)}</span>
152+
<strong class="cp-head">${esc(HOUSE.headline)}</strong>
153+
<span class="cp-body" style="color:${bodyInk};overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:1 1 0">— ${esc(HOUSE.body)}</span>
154+
<span style="color:${p.accentColor};font-weight:600;flex:0 0 auto;white-space:nowrap;margin-left:auto">${esc(HOUSE.cta)}</span>
149155
</a>
150156
</body></html>`;
151157
}

‎lib/ads/serve.ts‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
type AdCreative,
1010
type AdFormatId,
1111
} from "./creative";
12-
import { FEED_FORMAT_ID, TERMINAL_FORMAT_ID } from "./formats";
12+
import { fitAdFormat, FEED_FORMAT_ID, TERMINAL_FORMAT_ID } from "./formats";
1313
import { isAdTheme, type AdTheme } from "./theme";
1414
import { houseFill, HOUSE_AD_ROTATION_RATE } from "./house";
1515
import { CREDIT_CENTS, DEFAULT_BID_CREDITS, PLATFORM_RATE } from "./pricing";
@@ -137,13 +137,19 @@ export type ServeContext = {
137137
* absent) falls back to the slot's stored default.
138138
*/
139139
theme?: string | null;
140+
/**
141+
* Width in CSS pixels of the container the unit will sit in, as measured by
142+
* the tag. Absent for older tags and for the non-web consumers (MOTD, feed),
143+
* in which case the requested format is served unchanged.
144+
*/
145+
width?: number | null;
140146
};
141147

142148
// Returns a rendered fill for the slot, or null if the slot is inactive /
143149
// no eligible ad exists. Records the impression as a side effect.
144150
export async function serveAd(
145151
slotId: string,
146-
format: AdFormatId,
152+
requestedFormat: AdFormatId,
147153
ctx: ServeContext = {},
148154
): Promise<Fill | null> {
149155
const sb = serviceClient();
@@ -154,7 +160,14 @@ export async function serveAd(
154160
.eq("id", slotId)
155161
.maybeSingle();
156162
if (!slot || slot.status !== "active") return null;
157-
if (Array.isArray(slot.formats) && !slot.formats.includes(format)) return null;
163+
164+
// A unit wider than the container it was asked for loses its right-hand side
165+
// rather than shrinking, so the publisher's `data-format` is honoured only
166+
// while it fits. Constrained to the slot's own list, so this can never turn a
167+
// servable request into an empty fill. Callers read the format actually
168+
// served back off `fill.creative.format`.
169+
const format = fitAdFormat(requestedFormat, ctx.width, slot.formats);
170+
if (!format) return null;
158171

159172
// `theme` rides behind `add column if not exists`, and migrations here are
160173
// applied by hand — a deploy that lands first would read undefined, which

‎tests/ads-fit-format.test.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, expect, it } from "vitest";
2+
import { fitAdFormat, PUBLISHER_FORMAT_IDS } from "@/lib/ads/formats";
3+
4+
const ALL = PUBLISHER_FORMAT_IDS;
5+
6+
describe("fitAdFormat", () => {
7+
it("keeps a format that fits", () => {
8+
expect(fitAdFormat("banner_728x90", 800, ALL)).toBe("banner_728x90");
9+
expect(fitAdFormat("banner_300x250", 300, ALL)).toBe("banner_300x250");
10+
});
11+
12+
it("downgrades a leaderboard that cannot fit a phone column", () => {
13+
// The failure this exists for: 6,836 mobile leaderboard impressions, one
14+
// click, because the CTA sat past the right edge of a clipped iframe.
15+
expect(fitAdFormat("banner_728x90", 390, ALL)).toBe("banner_300x250");
16+
});
17+
18+
it("prefers the rectangle over the mobile strip when both fit", () => {
19+
// Both fit at 390px (300 and 320 wide). The rectangle converts ~7x better,
20+
// so the choice is by area, not by width.
21+
expect(fitAdFormat("banner_728x90", 390, ALL)).toBe("banner_300x250");
22+
expect(fitAdFormat("banner_728x90", 325, ALL)).toBe("banner_300x250");
23+
});
24+
25+
it("falls back to the narrowest offered unit when nothing fits", () => {
26+
// A clipped ad still beats a blank slot.
27+
expect(fitAdFormat("banner_728x90", 200, ALL)).toBe("banner_300x250");
28+
});
29+
30+
it("never leaves the slot's own format list", () => {
31+
// Downgrading to a format the slot does not offer would be refused later
32+
// and return no fill at all.
33+
const only = ["banner_728x90", "banner_320x50"];
34+
expect(fitAdFormat("banner_728x90", 390, only)).toBe("banner_320x50");
35+
});
36+
37+
it("refuses a format the slot does not offer, as before", () => {
38+
expect(fitAdFormat("banner_728x90", 800, ["banner_300x250"])).toBeNull();
39+
});
40+
41+
it("leaves full-width and boxless formats alone", () => {
42+
// A text link fills whatever it is given; the terminal and feed units have
43+
// no pixel box at all.
44+
expect(fitAdFormat("text_link", 320, null)).toBe("text_link");
45+
expect(fitAdFormat("terminal_ascii", 100, null)).toBe("terminal_ascii");
46+
expect(fitAdFormat("feed_item", 100, null)).toBe("feed_item");
47+
});
48+
49+
it("honours the request when no width was measured", () => {
50+
// Older tags, and the MOTD/feed endpoints, send no width — there is no
51+
// basis to second-guess the publisher.
52+
for (const w of [null, undefined, 0, NaN]) {
53+
expect(fitAdFormat("banner_728x90", w, ALL)).toBe("banner_728x90");
54+
}
55+
});
56+
});

0 commit comments

Comments
 (0)