Skip to content

Commit fe44428

Browse files
committed
Put ad units at the edges of content, as thin bars only
Every placement is now text_link — a fluid 40px bar that carries its own "Sponsored" mark — and the square formats are gone entirely. The reason is not taste: every banner creative hard-codes its format's pixel width, so a narrower column CROPS it rather than reflowing, which makes a 300x250 in a content column broken on a phone rather than merely ugly. The obvious alternative — render a desktop banner and a mobile one and hide whichever does not fit — is wrong and expensive: both fill, and filling is what meters the impression, so the hidden one burns an impression nobody ever saw. A server cannot measure the viewport, so it picks one format that works at every width. banner_728x90 stays available for a board that knows its readers are on desktop, but it is never the default. Placements sit at the EDGES: under the forum list, under the topic list, under the last post. The one position inside the content — between posts — is off unless an admin asks for it, because it interrupts the only thing anyone came for. Header and footer bars are available and off. The unit itself has no chrome: no border, no card, no heading of its own, and a max width matched to the content column, so it reads as a footnote. Not merging PR #2. It stacks all four sizes at the bottom of <body> and loads ad.js from crawlproof.com — which this board's CSP (script-src 'self', no unsafe-inline) blocks outright, and ad.js renders its creative into a srcdoc iframe that would inherit that policy and show nothing, silently. The bundled plugin uses the cross-origin frame endpoint instead, which costs one frame-src entry and nothing else. 105 tests, 0 type errors.
1 parent ab9b56c commit fe44428

3 files changed

Lines changed: 99 additions & 44 deletions

File tree

‎packages/ui/src/styles/forum.css‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,3 +650,22 @@
650650
color: var(--muted-foreground);
651651
background: color-mix(in oklab, var(--muted) 40%, transparent);
652652
}
653+
654+
655+
/* --- Ad units ------------------------------------------------------------ */
656+
/*
657+
* Thin bars only, at the edges of content. The unit is centred on the content
658+
* column and given no chrome of its own: an advert that looks like part of the
659+
* board is worse than one that looks like an advert, and the creative already
660+
* carries its own "Sponsored" mark.
661+
*/
662+
.cp-ad-unit {
663+
width: 100%;
664+
max-width: 76rem;
665+
margin: 1rem auto;
666+
padding-inline: 1rem;
667+
opacity: 0.92;
668+
}
669+
.cp-ad-unit:hover { opacity: 1; }
670+
.site-header .cp-ad-unit { margin: 0 auto; padding-block: 0.375rem; }
671+
.cp-ad-unit iframe { border-radius: calc(var(--radius) - 4px); }

‎plugins/crawlproof-ads/src/index.ts‎

Lines changed: 57 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -36,55 +36,51 @@ type Placement = {
3636
/** Formats that cannot crop in this position. See the note on widths below. */
3737
format: string;
3838
};
39-
4039
/*
41-
* Every banner creative hard-codes its format's pixel width, so a narrower
42-
* container crops it rather than reflowing. `text_link` is the only fluid
43-
* format — full width, 40px tall, carries its own "Sponsored" mark — which
44-
* makes it the only safe choice for a full-width position on a board that is
45-
* read on phones. `banner_300x250` is the widest fixed creative that fits a
46-
* 320px viewport, so it is what the block positions use.
40+
* Every placement is a THIN BAR, and that is a constraint rather than a default.
41+
*
42+
* `text_link` is the only fluid format the network serves: full width, 40px
43+
* tall, carrying its own "Sponsored" mark inside the frame. Every banner
44+
* creative hard-codes its format's pixel width, so a narrower column CROPS it
45+
* rather than reflowing — which is why a 300x250 square in a content column is
46+
* not merely ugly, it is broken on a phone.
4747
*
48-
* The obvious alternative — render a desktop and a mobile unit and hide one
49-
* with CSS — is wrong and expensive: both fill, and filling is what meters the
50-
* impression, so the hidden one burns an impression nobody ever saw.
48+
* The obvious alternative — render a desktop banner and a mobile one and hide
49+
* whichever does not fit — is wrong and expensive: both fill, and filling is
50+
* what meters the impression, so the hidden one burns an impression nobody saw.
51+
* A server cannot measure the viewport, so it picks one format that works at
52+
* every width. That format is text_link.
53+
*
54+
* banner_728x90 is offered for a board that knows its readers are on desktop,
55+
* but it is never the default: it crops below 728px.
56+
*/
57+
const FORMAT_CHOICES = [
58+
{ value: 'text_link', label: 'Thin bar — fluid, 40px, works at any width' },
59+
{ value: 'banner_728x90', label: 'Leaderboard 728x90 — crops below 728px' },
60+
];
61+
62+
/*
63+
* Placements sit at the EDGES of content, never inside it. A unit between two
64+
* posts interrupts the only thing anybody came to the page for.
5165
*/
5266
const PLACEMENTS: Placement[] = [
53-
{
54-
slot: 'board:below_categories',
55-
key: 'placement.boardIndex',
56-
label: 'Board index, below the forum list',
57-
defaultOn: true,
58-
format: 'banner_300x250',
59-
},
60-
{
61-
slot: 'forum:below_topics',
62-
key: 'placement.forum',
63-
label: 'Forum page, below the topic list',
64-
defaultOn: false,
65-
format: 'text_link',
66-
},
67-
{
68-
slot: 'topic:above_posts',
69-
key: 'placement.topicTop',
70-
label: 'Topic page, above the first post',
71-
defaultOn: false,
72-
format: 'text_link',
73-
},
74-
{
75-
slot: 'topic:below_posts',
76-
key: 'placement.topicBottom',
77-
label: 'Topic page, below the last post',
78-
defaultOn: true,
79-
format: 'banner_300x250',
80-
},
67+
{ slot: 'layout:header', key: 'placement.header',
68+
label: 'Under the navigation, on every page', defaultOn: false, format: 'text_link' },
69+
{ slot: 'board:below_categories', key: 'placement.boardIndex',
70+
label: 'Board index, under the forum list', defaultOn: true, format: 'text_link' },
71+
{ slot: 'forum:below_topics', key: 'placement.forum',
72+
label: 'Forum page, under the topic list', defaultOn: true, format: 'text_link' },
73+
{ slot: 'topic:below_posts', key: 'placement.topicBottom',
74+
label: 'Thread page, under the last post', defaultOn: true, format: 'text_link' },
75+
{ slot: 'topic:between_posts', key: 'placement.betweenPosts',
76+
label: 'Thread page, between posts (interrupts reading)', defaultOn: false, format: 'text_link' },
77+
{ slot: 'layout:footer', key: 'placement.footer',
78+
label: 'Above the footer, on every page', defaultOn: false, format: 'text_link' },
8179
];
8280

8381
const FRAME_HEIGHT: Record<string, number> = {
8482
text_link: 40,
85-
banner_300x250: 250,
8683
banner_728x90: 90,
87-
banner_320x50: 50,
8884
};
8985

9086
const ORIGIN = 'https://crawlproof.com';
@@ -132,6 +128,15 @@ export default definePlugin({
132128
default: false,
133129
help: 'Most boards show ads to everyone. Turn this on to make an account ad-free.',
134130
},
131+
{
132+
key: 'format',
133+
label: 'Unit format',
134+
type: 'select',
135+
default: 'text_link',
136+
options: FORMAT_CHOICES,
137+
help:
138+
'The thin bar is fluid and safe at every width. The leaderboard is wider but crops below 728px, so pick it only if your readers are on desktop.',
139+
},
135140
...PLACEMENTS.map((p) => ({
136141
key: p.key,
137142
label: p.label,
@@ -159,14 +164,20 @@ export default definePlugin({
159164
// finished, not broken.
160165
if (!slotId) return null;
161166

162-
const height = FRAME_HEIGHT[format] ?? 250;
167+
const height = FRAME_HEIGHT[format] ?? 40;
163168
const src = `${ORIGIN}/api/ads/frame?slot=${encodeURIComponent(slotId)}&format=${encodeURIComponent(format)}`;
164169

170+
/*
171+
* Kept deliberately quiet: no border, no card, no heading, and a max
172+
* width matched to the content column so it reads as a footnote rather
173+
* than a billboard. The creative carries its own "Sponsored" mark, so
174+
* adding a label here would say it twice.
175+
*/
165176
return (
166-
`<div class="cp-ad-unit" style="display:flex;justify-content:center;margin:1.25rem 0">` +
177+
`<div class="cp-ad-unit" role="complementary" aria-label="Advertisement">` +
167178
`<iframe src="${escapeAttribute(src)}" width="100%" height="${height}" loading="lazy" ` +
168179
`title="Advertisement" scrolling="no" ` +
169-
`style="border:0;display:block;max-width:100%;height:${height}px;color-scheme:light dark" ` +
180+
`style="border:0;display:block;width:100%;height:${height}px;color-scheme:light dark" ` +
170181
`sandbox="allow-scripts allow-popups allow-popups-to-escape-sandbox"></iframe>` +
171182
`</div>`
172183
);
@@ -185,7 +196,9 @@ export default definePlugin({
185196
return null;
186197
}
187198

188-
return unit(placement.format);
199+
// One format across the whole board: mixing them per placement makes
200+
// a page look assembled out of spare parts.
201+
return unit(String(ctx.settings.get('format') ?? placement.format));
189202
},
190203
// A late weight keeps ads below whatever else a slot is carrying.
191204
100,

‎test/plugins.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,29 @@ describe('the plugin host', () => {
7474
assert.ok(!csp.includes('unsafe-inline'), 'and nothing else was loosened');
7575
});
7676

77+
it('only ever offers thin, fluid formats — never a square in a content column', async () => {
78+
/*
79+
* Every banner creative hard-codes its format's pixel width, so a narrower
80+
* column crops it rather than reflowing. text_link is the only fluid one,
81+
* which is why it is the default and the square formats are not offered at
82+
* all. Rendering a desktop and a mobile unit and hiding one is worse: both
83+
* fill, and filling is what meters the impression.
84+
*/
85+
const source = await import('node:fs').then((fs) =>
86+
fs.readFileSync(new URL('../plugins/crawlproof-ads/src/index.ts', import.meta.url), 'utf8'),
87+
);
88+
assert.ok(!source.includes('banner_300x250'), 'no 300x250 square anywhere');
89+
assert.ok(!source.includes('banner_320x50'), 'no fixed mobile banner either');
90+
91+
const manifest = registry.plugins.get('crawlproof-ads')?.manifest;
92+
const format = manifest?.settings?.find((s) => s.key === 'format');
93+
assert.equal(format?.default, 'text_link', 'the thin bar is the default');
94+
95+
// The one placement that sits inside the content is off unless asked for.
96+
const between = manifest?.settings?.find((s) => s.key === 'placement.betweenPosts');
97+
assert.equal(between?.default, false, 'nothing interrupts a thread by default');
98+
});
99+
77100
it('takes the CSP permission away again when the plugin is disabled', async () => {
78101
await registry.setEnabled('crawlproof-ads', false);
79102
const response = await app.fetch(new Request(url('/')));

0 commit comments

Comments
 (0)