Skip to content

feat(webapp): ad-monetised post template for paid and SEO traffic - #6500

Draft
tsahimatsliah wants to merge 25 commits into
mainfrom
claude/ad-arbitrage-assets-analytics-87b2db
Draft

feat(webapp): ad-monetised post template for paid and SEO traffic#6500
tsahimatsliah wants to merge 25 commits into
mainfrom
claude/ad-arbitrage-assets-analytics-87b2db

Conversation

@tsahimatsliah

@tsahimatsliah tsahimatsliah commented Aug 20, 2026

Copy link
Copy Markdown
Member

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 /read to any post URL:

  • /posts/qojM1enSN/read — article post
  • /posts/nSmlLGJoe/read
  • /posts/qHnVnVCHv/read

Compare against the same post without /read to 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:

Classic Focus card
Right widget column ✅ 340px, carries 3 always-viewable slots ❌ none
Existing ad placements 2 1
Renders full article body ❌ TLDR + outbound link ✅ when one exists
Gating default flag off by default, post-type eligible

The focus card's one real advantage doesn't apply to this traffic: for scraped articles contentHtml is 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:

Element Space reclaimed
PostAuthBanner 300–400px of bottom viewport
CustomAuthBanner ~50px sticky top strip (simply not passed as layoutProps.customBanner)
PostSignupWidget ~300px at the top of the widget column

Header login/signup buttons are unaffected — they render automatically for anonymous visitors.

Sidebar

Adds an opt-in expandSidebar prop to MainLayoutProps, threaded to SidebarSidebarDesktop. 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).

# Slot Where
1 Sidebar 240×400 bottom of expanded rail
2 Leaderboard above source row
3 In-content after TLDR
4 Outstream video mid-article
5, 6 In-content 55% / 70% depth
7 Native first position in thread
8 In-content after thread
9 Rich media end of article
10 Sticky 300×600 top of widget column
11, 12 300×250 between widgets
13 Anchor pinned bottom

Each placeholder shows its size, price and expected reach so density is reviewable at a glance.

Verification

  • All 13 in-page slots render in the correct order against a real post
  • No signup banner text present anywhere on the page
  • node ./scripts/typecheck-strict-changed.js passes
  • Lint clean
  • jest src/components/sidebar (37 tests) and jest src/components/post (35 tests) pass

Known 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

  • Slot 1 only wires into SidebarDesktop. If layout_v2 (default off) is enabled, SidebarDesktopV2 needs the same prop.
  • Routing selector: currently a plain route. A next.config.ts rewrite keyed on a UTM/query param can map /posts/:id → this template for paid traffic only, mirroring the existing ?userid/share rewrite.
  • No Google ad tag exists in the codebase; the AdSense/GAM layer is net-new work and this template is the place to build it first.
  • SEO decision: currently noindexed. If it should serve organic search too, flip the canonical.

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

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>
@vercel

vercel Bot commented Aug 20, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
daily-webapp Ready Ready Preview Aug 21, 2026 10:20am
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
storybook Ignored Ignored Aug 21, 2026 10:20am

Request Review

@tsahimatsliah

Copy link
Copy Markdown
Member Author

Preview links — verified live

Use the assigned preview domain, not the raw Vercel URL (the Vercel one can't complete boot, so the sidebar and header stay empty there):

Link
New template https://claude-ad-arbitrage-assets-analy.preview.app.daily.dev/posts/qojM1enSN/read
Same post, current template https://claude-ad-arbitrage-assets-analy.preview.app.daily.dev/posts/qojM1enSN

More posts to try: nSmlLGJoe, qHnVnVCHv — append /read.

What the side-by-side shows

Current template renders the geo-personalised PostAuthBanner covering roughly half the viewport ("daily.dev is the fastest growing developer platform in Israel!"), with the sidebar collapsed to its icon rail.

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

  • All 13 in-page slots render in order (1–13)
  • Sidebar 240px expanded, slot 1 present, collapse toggle correctly hidden
  • Widget column 340px with slots 10, 11, 12
  • Anchor (slot 13) pinned to the viewport bottom, content clears it
  • Header shows "Log in" and "Sign up" as intended
  • No signup-banner text anywhere on the page
  • Standard /posts/[id] template unchanged

CI

All 12 checks green — lint, strict typecheck, and the shared/webapp/extension test suites.

Still open

  • Slot 1 only wires into SidebarDesktop. layout_v2 is off by default, so this is the shipping path, but SidebarDesktopV2 needs the same prop before that flag rolls out.
  • Ad slots are reserved placeholders — no ad tag exists in the codebase yet.
  • Routing selector is a plain route for now; a next.config.ts rewrite keyed on a UTM param can map paid traffic to it, mirroring the existing ?userid/share rewrite.

… 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>
@rebelchris

Copy link
Copy Markdown
Contributor

Live AdSense layer (b5350c4)

The template can now serve real ads, strictly scoped to `/posts/[id]/read`:

  • New GrowthBook JSON feature `read_adsense_slots` (slot number → AdSense unit). Empty default = the placeholder draft you see on the preview, zero Google code loaded anywhere. Any entry flips the template live. Clearing it is the kill switch — no deploy needed.
  • `adsbygoogle.js` is rendered only by the /read page, and leaving the route cancels client-side navigation in favor of a full page load, so Auto ads overlays (anchor/vignette) can never follow a soft navigation into the app. Extension untouched (AdSense is prohibited there).
  • Units lazy-request via IntersectionObserver (200px rootMargin) for viewability; non-production builds send `data-adtest=on` so previews never create billable impressions.
  • In live mode, unconfigured slots collapse (slot 4/outstream stays empty until GAM) and the placeholder anchor unmounts so the Auto ads anchor owns the viewport bottom.

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>
@rebelchris

Copy link
Copy Markdown
Contributor

Update (e0c3245): the placeholder boxes are now dev-only. Production with an empty read_adsense_slots value renders a completely clean page — no yellow boxes, no ad markup, no Google script. Populating the flag is the only thing that ever puts ad code in front of a visitor. Test creatives are decided by hostname at request time (anything ≠ app.daily.dev, previews included, gets data-adtest=on), since preview deployments are production builds.

🤖 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>
tsahimatsliah and others added 2 commits August 20, 2026 18:18
- 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>
tsahimatsliah and others added 2 commits August 21, 2026 00:33
…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>
@rebelchris

Copy link
Copy Markdown
Contributor

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)

  1. Delete the `read_adsense_slots` JSON feature (its values are now hardcoded).
  2. Create two boolean features, both defaulting off:
    • `read_adsense` — the /read arbitrage template
    • `post_adsense` — the organic post page (separate so organic can ramp/kill independently)

Organic post page (`/posts/[id]`), behind `post_adsense`

  • Leaderboard above the post content (slot 15)
  • Sticky 300×600 closing the widget column (slot 16)
  • Hidden from Plus members (same rule as the internal sidebar ad), wired at the webapp page level so post modals and the extension (AdSense-prohibited) can never render them, script loads only when active.

Still needed before these slots serve (all in slots.ts TODOs)

What Where to get it
Slot 7 `layoutKey` read_s07_comment_native → Get code → `data-ad-layout-key`
Slot 13 unit id was only in the retired remote config
Slot 15 + 16 unit ids create `post_s15_top_leaderboard`, `post_s16_rail_half_page` (Display)

Every slot with a missing id simply collapses — nothing breaks meanwhile.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants