feat(webapp): ad-monetised post template for paid and SEO traffic - #6500
feat(webapp): ad-monetised post template for paid and SEO traffic#6500tsahimatsliah wants to merge 25 commits into
Conversation
Adds a third post-page template on its own route so the existing two are untouched. Built for landing traffic where the goal is impressions, not signups. Route: /posts/[id]/read (noindexed, canonical points at /posts/[slug] so it never competes with the standard page in search). Forked from the classic PostContent layout rather than the focus card: for scraped articles neither renders a body, so the focus card's only advantage does not apply, while the widget column it lacks carries three always-viewable slots. Removed relative to the standard template — all by omission, no flags: - PostAuthBanner (300-400px of bottom viewport) - CustomAuthBanner (never passed as layoutProps.customBanner) - PostSignupWidget (top of the widget column) Header login/signup buttons are unaffected. Adds an opt-in `expandSidebar` layout prop so the rail renders expanded regardless of the stored collapse preference, carrying slot 1. Defaults preserve current behaviour everywhere else. Ad slots render as reserved placeholders — no ad tag exists in the app yet, so this is for reviewing density and layout. Slot heights are reserved the way live slots must be to avoid layout shift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Preview links — verified liveUse the assigned preview domain, not the raw Vercel URL (the Vercel one can't complete boot, so the sidebar and header stay empty there):
More posts to try: What the side-by-side showsCurrent template renders the geo-personalised New template has neither: no banner, sidebar expanded to 240px with slot 1 pinned at its bottom, and the article starting immediately under the leaderboard. Verified on the preview at 1440×900
CIAll 12 checks green — lint, strict typecheck, and the shared/webapp/extension test suites. Still open
|
… that route Slots go live per-slot via the read_adsense_slots GrowthBook JSON value (slot number -> unit id/type); the empty default renders the existing placeholders and loads no Google code anywhere. The adsbygoogle script is rendered only by the /read page, and leaving the route forces a full page load so Auto ads overlay state can never follow client-side navigation into the rest of the app. Units lazy-request via IntersectionObserver for viewability; non-production builds serve test creatives via data-adtest. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live AdSense layer (b5350c4)The template can now serve real ads, strictly scoped to `/posts/[id]/read`:
Go-live = paste this into GrowthBook `read_adsense_slots` with the real unit ids{
"1": { "id": "…", "type": "display", "width": 240, "height": 400 },
"2": { "id": "…", "type": "display" },
"3": { "id": "…", "type": "inArticle" },
"5": { "id": "…", "type": "inArticle" },
"6": { "id": "…", "type": "inArticle" },
"7": { "id": "…", "type": "inFeed", "layoutKey": "…from the generated snippet…" },
"8": { "id": "…", "type": "inArticle" },
"9": { "id": "…", "type": "multiplex" },
"10": { "id": "…", "type": "display", "width": 300, "height": 600 },
"11": { "id": "…", "type": "display" },
"12": { "id": "…", "type": "display" }
}Slots 13 (anchor) + 14 (vignette) come from AdSense Auto ads → overlay formats only, scoped in the AdSense UI to a URL group matching `/posts/*/read`. Verified: 7 new ArbitrageAdSlot/Anchor tests, shared post (42) + sidebar (37) suites, strict typecheck guard, lint — all green. 🤖 Generated with Claude Code |
noindex and a canonical to the parent post are mixed signals Google warns against; this page must never rank, so it keeps only noindex. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The dashed density-review placeholders were the draft state; production with an empty read_adsense_slots value now renders no ad surface at all. Placeholders remain a local-development tool only. Test creatives are now decided by hostname at request time instead of build env, since preview deployments are production builds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Update (e0c3245): the placeholder boxes are now dev-only. Production with an empty 🤖 Generated with Claude Code |
A sticky element slides down over siblings that follow it once the page scrolls, so slot 10 sitting first in the column painted over the source card. Last in the column it sticks for the whole read with nothing below to cover. Also drop overflow-hidden from the live slot wrapper: Google resizes responsive creatives post-request and clipping cut their bottom edge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Ad slots now use the feed Card treatment (rounded-16, subtle border and surface) instead of a dashed outline, so a filled slot reads as page furniture rather than a bolted-on frame. Slot number and size stay as small labels for review. - Anchor gets the same radius and a shadow so it reads as a floating card. - FurtherReading takes an opt-in `hideToc`; the ad template passes it so the rail's vertical space goes to slots 10-12 instead of the table of contents. Default behaviour is unchanged everywhere else. - Clears the pre-existing strict-null violations in FurtherReading that the changed-file guard surfaced once the file was touched. All behaviour preserving; the component's 7 tests still pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Applies the same rounded-16 treatment to the live AdSense container that the placeholder already had, so a filled slot reads as page furniture rather than a pasted-in iframe. Safe against the resize-after-request problem the previous comment warned about: the box has no fixed height, so a creative that grows pushes the container taller instead of being clipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
border-radius plus overflow-hidden on the wrapper alone does not reliably clip an ad: WebKit paints the injected iframe on its own compositing layer that escapes the rounded clip, so the corners come back square. - `isolate` on the wrapper forces a stacking context so the clip applies. - The radius is repeated on the <ins>, the closest ancestor of the iframe, so the creative's own corners are clipped rather than just the wrapper's. - overflow-hidden on the anchor shell for the same reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two placements from the ad partner's brief were specified but never built. - Floating leaderboard (slot 13) now renders in live mode. It previously returned null there, so there was no bottom leaderboard once ads were on. It mounts ten seconds after load, per the brief, and the delay gates the mount rather than visibility so the ad request fires when it appears. Renders only when slot 13 is configured, leaving the viewport bottom free for an Auto ads anchor otherwise — the two can never stack. - Top leaderboard (slot 2) now sticks while scrolling and releases after ten seconds, also per the brief. Sticky rather than fixed so it pins within its own container and cannot overlap the article. Also names every slot with the partner's own terminology (topLeaderboard, railMpu1, floatingLeaderboard...) so the remote config, this code and their brief all refer to the same units. Ad sizing and layout: - Each format is capped at its standard IAB width and centred. Slots were falling through to responsive `auto`, so Google picked whatever creative fit and two units came back different widths. - The template widens from 69.25rem to 72rem: the main column had 704px and a 728x90 leaderboard could not render at full size. - Unfilled slots collapse via data-ad-status, instead of leaving their reserved height as an empty band in the comment thread. Template fixes: - Restores the post action bar (upvote/comment/bookmark/copy) and engagement counts, which the standard template gets from PostEngagements. - Adds the missing tag-to-metadata spacing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tions The top leaderboard never stuck: PostContainer is overflow-hidden, which makes it the sticky element's scroll container, and that container never scrolls. Move the leaderboard above the two-column shell, which also gives it full page width — the partner asked for a leaderboard in the right rail, where a 728px unit cannot fit. Rebuild the action bar on PostUpvotesCommentsCount and PostActions in the same order PostEngagements uses, and move the read button to the header actions next to the options menu. Anchor the rail units to named widgets instead of the brief's block indices, which resolve to nothing in a column of this length: source card, MPU 1, You might like, MPU 2, Best discussions, half page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Full-bleed placement was only needed because PostContainer is overflow-hidden, which made it the sticky scroll container. overflow-x: clip with overflow-y: visible clips the column the same way without creating one, so the leaderboard can sit in the content flow above the source row and still pin for ten seconds. The column is 745px wide inside its padding at the layout's full width, so a 728x90 renders at its booked size; narrower viewports get a smaller responsive creative rather than a clipped one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured on a phone the template ran 12 in-flow slots against 2171px of content: 56% ad density, where the Better Ads Standards cap mobile at 30% and Chrome filters ads on sites that fail. Scraped posts have no body text to dilute the slots, so the fix is fewer units, not more content. The three rail MPUs, the second and third in-content MPUs, the comment native unit and the end-of-article unit now drop below tablet, leaving leaderboard, MPU 1, video, comment MPU and the anchor at 28%. Hidden rather than skipped so they never request either — the push only fires on intersection. Below laptop the page also carried no navigation at all, because MainLayoutHeader renders the feed nav there and a post route has nothing to fill it. Add the mobile footer nav, and pin the top leaderboard at top-0 below laptop where there is no fixed header to clear. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y has On layout v2 the sidebar owns the header, so <main> carries no top padding and the leaderboard's natural position is 0. A hardcoded top of 4rem then pushed the sticky element 4rem *below* where it sits in flow, painting it over the source row and the read button until the ten-second timer released it. MainLayout now publishes --sticky-header-offset alongside the padding it already computes, so the offset always matches the chrome on screen: 4rem for the v1 header, 0 under the v2 sidebar, 0 below laptop, more again with a banner. Because it is driven by the same flag as the header itself, it stays correct through the variant swap instead of flashing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sticky leaderboard counted its ten seconds from mount, so a visitor who spent that long on the headline had already used the whole window before their first scroll and never saw it pin — measured on the preview, the class was gone by 8s with no scrolling at all. Count from the first scroll instead, so every visitor gets the same pinned window. MPU 1 moves beside the tags, date and cover image rather than sitting above them, so the first ad shares the fold with real page furniture. It stays first in the DOM for the phone stack, where the brief puts it above the article, and moves to the right of the group from laptop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Everything from the action bar to the end of the thread now renders exactly as the standard post page does. The units that opened and closed the discussion move below it, so the empty band that sat between the heading and the first comment — a slot reserving height it never filled — is gone structurally. A long thread still earns: PostComments takes an optional interleave, and the ad template drops a native unit in after every fifth comment, never after the last one where the block below already sits. Short threads never reach the interval and stay clean. MPU 2 and 3 pair off from laptop instead of stacking. The column clears two 336px units side by side, so stacking only made the page longer. Empty slots now collapse on measurement rather than on the data-ad-status attribute alone: a slot that answers with a zero-height creative, or never answers because a blocker ate the script, kept its reserved height as a band of empty page. A ResizeObserver stays attached so a slow fill reopens it. Raise the pinned leaderboard to z-2: CommentContainer gives the author row and comment body z-1, and at equal z-index the later element in the DOM wins, so comments painted over the ad as it scrolled past them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The comment MPU and the end-of-article unit were the last pair still stacked, so they added their full height to a page that has nothing left to say by then. They now share a row from laptop. The rich media unit carries no width of its own and every flex child here is flex-shrink:0 from the global reset, so it takes min-w-0 with flex-1 to fill the rest of the row rather than overflow it: measured against the real column that is 336 + 24 + 385 in 745px. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The unit beside the cover image becomes a medium rectangle. At 300 wide the left column gets 421px, so the cover image also stops being squeezed and renders at its full 410px cap again. Each fixed-size format now carries its mobile counterpart from the IAB new ad portfolio, expressed as the width cap: AdSense sizes a responsive unit from the space it is given, so a leaderboard left uncapped on a phone came back as whatever else fit rather than as a banner. Leaderboard drops to the large mobile banner, the anchor to the mobile phone banner — shortest in the portfolio, since it shares the bottom of the screen with the footer nav — and the in-content rectangle to the medium rectangle, 336x280 being a Google size rather than an IAB one. Reserved heights follow each variant so neither breakpoint shifts as the creative lands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e cover One multiplex unit replaces the four stacked slots after the discussion: a single request returns a grid of creatives Google lays out for the width it is given, which is more inventory in less page than the column of separate units it replaces. Slots 4, 5, 6 and 8 are unplaced for now and marked as such. MPU 1 bottom-aligns with the cover image. Measured in the real column the cover renders 410x201, and with items-start the unit's bottom edge landed 47px short of the cover's; items-end brings both to the same line. The medium rectangle is also the closest standard height to the cover — 336x280 and either leaderboard are all further away. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ents The template was a hand-copy of PostContentRaw, and the copy had drifted: the widget column was missing eight of production's widgets, and the engagement block had been replaced with a bespoke action bar and a "Discussion" heading that production does not have — which is why the heading sat against the actions and the rail spacing looked wrong. Use the real PostEngagements and PostWidgets instead, with opt-in props for what the ad template needs: hide the signup widget and the table of contents, thread the rail units in, and interleave the thread. Every new prop is gated, so all other consumers keep their exact DOM. Metadata and cover spacing go back to the production values too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A pinned unit at the top of a small screen holds reading area for its whole window, and the bottom anchor already carries the persistent mobile placement. One pinned banner per screen, at the bottom, is the convention. The unit still scrolls with the content on a phone and still pins from tablet up, where there is room for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The unit beside the cover came back as a 300x600 half page: capping the container at 300 wide constrains nothing vertically, and on data-ad-format "auto" AdSense is free to answer a 300px slot with any shape that fits it. Each format now names the shape it accepts — rectangle, horizontal or vertical — so the wrong orientations cannot serve. That keeps the unit responsive across breakpoints, which a fixed pixel size could not: 728 wide is right on desktop and overflows a phone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It used a hardcoded 5rem, which is the v1 header plus a gap. Under the v2 sidebar there is no fixed header to clear, so the unit pinned 5rem into empty space. Reuse --sticky-header-offset, the same value the top leaderboard follows, and keep the 1rem gap on top of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ArbitrageAnchor now renders a real unit when the remote config carries slot 13, instead of assuming the Auto ads overlay covers the position. New optional 'format' field on the slot config constrains responsive display creatives; the anchor requires 'horizontal', since an unconstrained unit can render 250px+ tall pinned over the content. The dismiss button is a Better Ads requirement for anchors, not polish. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The slot maps move from the read_adsense_slots remote JSON into code: unit ids are public in any live page's source and stable after setup, and as a GrowthBook value the whole map shipped in every surface's boot payload. Remote config shrinks to two booleans, read_adsense and post_adsense, which stay no-deploy kill switches. The organic post page gains a leaderboard above the content and a sticky half page closing the widget column, gated by post_adsense and hidden from Plus members like the internal ads. Both are wired at the webapp page level so post modals and the extension (where AdSense is prohibited) can never render them; the units collapse until their AdSense ids are filled into slots.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Boot-light config + organic post page slots (e08939c)Per the boot-payload concern: the slot maps now live in code (`post/arbitrage/slots.ts`) and remote config shrinks to two booleans — unit ids are public in any live page's source, so the JSON bought nothing for its per-boot cost on every surface. GrowthBook migration (one-time)
Organic post page (`/posts/[id]`), behind `post_adsense`
Still needed before these slots serve (all in slots.ts TODOs)
Every slot with a missing id simply collapses — nothing breaks meanwhile. 🤖 Generated with Claude Code |
Adds a third post-page template on its own route, built for paid-acquisition and organic landing traffic where the goal is impressions and clicks rather than signups. The two existing templates are untouched.
Draft — ad slots are reserved placeholders, not live ad tags. This is for reviewing layout and density.
Preview
Append
/readto any post URL:/posts/qojM1enSN/read— article post/posts/nSmlLGJoe/read/posts/qHnVnVCHv/readCompare against the same post without
/readto see the difference.Which template this forks, and why
We have two today. I picked the classic layout (
PostContent+PostWidgets) over the focus-card redesign:The focus card's one real advantage doesn't apply to this traffic: for scraped articles
contentHtmlis empty, so both templates fall back to TLDR + "Read post". The redesign would cost us the entire sidebar column for a benefit we never get on those pages. The new template still renders a body when one exists (freeform/collection posts).What's removed
All three are opt-in in the existing code, so leaving them out needed no flags or refactoring:
PostAuthBannerCustomAuthBannerlayoutProps.customBanner)PostSignupWidgetHeader login/signup buttons are unaffected — they render automatically for anonymous visitors.
Sidebar
Adds an opt-in
expandSidebarprop toMainLayoutProps, threaded toSidebar→SidebarDesktop. The rail is collapsed by default via a stored user setting that anonymous visitors never change; this template needs the 240px column present on every visit to carry slot 1. The collapse toggle is hidden when forced. Defaults are unchanged everywhere else.Slots
13 in-page slots (slot 14, the exit vignette, is Google-rendered on the "Read post" click — the button deliberately stays same-window because vignettes only fire on same-window anchor clicks).
Each placeholder shows its size, price and expected reach so density is reviewable at a glance.
Verification
node ./scripts/typecheck-strict-changed.jspassesjest src/components/sidebar(37 tests) andjest src/components/post(35 tests) passKnown local limitation: boot can't complete against the local API, so the sidebar and header don't render locally. Slot 1 and the header buttons need checking on this preview deployment.
Follow-ups
SidebarDesktop. Iflayout_v2(default off) is enabled,SidebarDesktopV2needs the same prop.next.config.tsrewrite keyed on a UTM/query param can map/posts/:id→ this template for paid traffic only, mirroring the existing?userid→/sharerewrite.Full economics, slot pricing and the rollout plan are in
plans/ad-arbitrage/README.md, also included here.🤖 Generated with Claude Code
Preview domain
https://claude-ad-arbitrage-assets-analy.preview.app.daily.dev