feat(webapp): ad-monetised post template for paid and SEO traffic - #6500
Conversation
|
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
|
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 |
|
Update (e0c3245): the placeholder boxes are now dev-only. Production with an empty 🤖 Generated with Claude Code |
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 |
The iframe-based emptiness check collapsed every below-the-fold slot: grace ran from mount, and a lazy slot four screens down has no iframe four seconds after mount because it has not been asked yet. Collapsed means display:none, display:none never intersects, and a slot that cannot intersect never requests — so every slot past the first viewport died before its first request. The request and the collapse now share one effect and one clock: the grace timer starts when adsbygoogle.push actually runs, eager or on intersection, so a slot is only judged empty after Google has had its window to answer.
… unit SidebarRail, Video and RichMedia had no call sites left. The organic half page pinned at a hardcoded 5rem, wrong whenever a top banner adds 2rem above the header — it reads MainLayout's --sticky-header-offset like every other pinned unit.
Review blockers, in order:
Internal data out of the public repo: plans/ad-arbitrage/** is removed
from every commit (history rewritten, not deleted on top — the branch
is public), .gitignore covers the whole plans subtree, and the shipped
FORMAT_SPEC loses its cpm figures along with the reach annotations —
review aids, not runtime data.
Flag hygiene: useOrganicAdsenseSlots evaluates post_adsense through
useConditionalFeature gated on the visitor being anonymous, and
ArbitrageAdSlot splits per surface so /read slots never touch the
organic hook — no more enrolling users who structurally cannot see a
unit.
Script gating: live-ness now means at least one unit id, not key
presence, via hasLiveAdsenseUnits — flipping post_adsense on while the
organic ids are still empty no longer loads adsbygoogle.js for
inventory that cannot fill. Same gate on /read and the anchor.
Push binding: adsbygoogle.push({}) initialises the first uninitialised
<ins> in document order, not the slot that pushed — so with every ins
mounted up front and lazy pushes firing in intersection order, a push
from low on the page could initialise an earlier slot instead. The ins
now mounts only when its slot becomes eligible, keeping the invariant
that every uninitialised ins is one that should be processed right now,
which makes the pushes interchangeable. The wrapper keeps the min-height
so the page reserves the same space.
And the non-blocking notes: the /read departure interceptor ignores
shallow navigations; the sidebar unit rides in from the /read page as
MainLayout's sidebarTrailing node instead of living in the shared nav
(which the extension also bundles); FurtherReading narrows its missing-
source guard at the boundary instead of optional-chaining into "More
posts from undefined"; and the slots get first-party analytics — a
per-placement fill event and dismiss events on the two closable units.
Found in final self-review: a fill arriving after the four second grace found its slot already display:none, where everything measures zero, so the height check re-judged the creative as empty and the slot stayed collapsed forever — the mutation observer's whole reopen path was dead. The height check now applies only while the ins is actually displayed (getClientRects, empty exactly under display:none); a hidden slot reopens on the creative alone and the resize observer re-judges it at real geometry. Regression test simulates the late fill.
- Mobile density: only the first rail unit keeps its phone placement, bringing the phone run from ~40% of page height to ~27%, inside the Better Ads Standards 30% cap whose violation filters ads domain-wide. - /read is generated only for articles, videos and collections that carry a summary or body; title-plus-ads shells 404, because AdSense's low-value-content enforcement is account-level. - read_adsense boolean returns as an emergency kill switch, default on by design (documented as a non-experiment). - Both surfaces now serve anonymous visitors only, gated on isAuthReady so a logged-in boot can never flash an ad before the user resolves. - post_adsense enrollment additionally requires a live unit id, so the experiment population can't fill with users who could never see it. - Back/forward from /read goes through beforePopState with an in-place load, keeping history intact instead of navigating forward. - Non-blocking picks: plans/ ignored wholesale, one-shot telemetry event when data-adtest engages so silent test-mode in production is visible. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
On top of the blocker fixes already on the branch: the fork's rationale and fold-back debt recorded on ArbitragePostContent; the push-ordering invariant documented at the push site so nobody adds `eager` and quietly re-opens the mis-binding race; the two near-identical timer hooks merged into useTimedRelease; and the anchor offset on the shared footer made an explicit opt-in from /read instead of compensating CSS in a component every page renders.
The box rendered whether or not a creative arrived, so the bottom of the nav carried a bordered empty band with a dismiss button and nothing to dismiss — measured live at 41px of border-plus-padding around a collapsed slot, and taller while the format still reserved a min-height. It now stays out of the layout until the slot reports a creative, and reserves nothing while hidden; the slot itself stays mounted so it can still request and report. Fill is reported before the collapse grace, so the box reveals when the ad lands rather than on the four second timer. Both dismiss buttons go subtle and extra small — the anchor's solid primary was the loudest thing on the page.
The /read container padded its bottom by 7rem plus the anchor's height. The 7rem predates the anchor measuring itself and also double-counted the mobile footer nav, which FooterNavBarLayout already spaces for — so on desktop, where neither exists, the page simply ended in 112px of nothing below the rail's last slot. Measured live: rail bottom 788, container bottom 900. Now the container's own pb-6 plus whatever the floating leaderboard actually occupies, which is nothing while it is absent or unfilled.
Cancelling it with p-0 did not work — Tailwind emits the padding shorthand before the per-side utilities, so px-2/pt-1/pb-3 won and the hidden box still measured 16px under the nav, h-0 notwithstanding. The padding now only exists in the filled state, so hidden is genuinely zero.
It behaved like layout, not like a floating placement. The page padded its bottom by the ad's measured height and the footer wrapper was lifted by it at every width — so on desktop, where the ad is confined to the article column and the wrapper's only occupant is the scroll-to-top button over on the right, the button was parked in mid-air above a gap it had no reason to clear. The offset now applies only below laptop, where the ad does span the width and the nav bar would otherwise sit on top of it, and the page reserves nothing. Also stops slots collapsing mid-request. Emptiness was judged four seconds after the push whether or not AdSense had touched the element, so a slow auction looked exactly like a declined one — and once collapsed the slot is display:none, which Google will not render into, so it never came back. That is the "loads very slowly, or not at all" behaviour. A slot now collapses on the tag's own data-adsbygoogle-status being 'done', with a far longer window before an untouched slot (blocked or still-loading script) is written off.
I added two heuristics that guessed a slot was empty — no iframe yet, and a measured height below a threshold — and both mistake a slow auction for a declined ad. Guessing wrong is not recoverable: a hidden slot is display:none, Google will not render into one, and the ad then never arrives at all. That is why ads stopped showing up. Emptiness is back to the one signal that actually means no ad: data-ad-status="unfilled", which the wrapper's CSS rule already collapses on, with no timers and nothing to get wrong. A creative now appears the moment AdSense delivers it. The sidebar box had the same fault in a worse place — it hid the slot behind visibility:hidden and h-0 while waiting for the fill it was waiting on. The slot is never hidden or zero-sized now; only the border, padding and dismiss button wait for the creative, and an empty slot has no height of its own, so the nav ends where it always did.
It is the last thing in a column that otherwise ends in breathing room, so it should not sit flush against the bottom.
Squares off every slot, drops the sidebar unit, the two closing multiplex grids and the half-page rail tower, and makes the rail's last unit the one that travels with the visitor. - No radius or overflow clipping on a slot: the box only centres the unit and reserves its request-time height, so a taller creative grows it. - The sidebar goes back to production's, forced open. The nav no longer takes a trailing node at all, so nothing ad-shaped reaches the shared component the extension also renders. - The rail unit after the further reading widget pins under the fixed chrome. Sticky is bounded by the containing block, so it moved out of FurtherReading and back to rail level, where the stretched column gives it the whole page to travel. - Slot numbers 1, 8, 9 and 10 are retired rather than reused, so their AdSense reporting rows stay readable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The onFilledChange callback, the multiplex unit type and the window latch declaration all lost their last consumer in the placement trim. The fill observer now also disconnects after the one impression event it exists to send, instead of watching a refreshing unit mutate forever. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The slot moves from after the whole further-reading widget to between its two sections. FurtherReading flattens to `contents` while hosting it, so the sticky containing block stays the full-height rail and the unit keeps the whole page to travel; consumers without a slot keep their original box. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
expandSidebar forced only the rendered state, so the menu row showed collapsed affordances on an expanded sidebar, the label disappeared and clicking the toggle silently flipped a preference nothing was reading. It now seeds the stored preference once per mount, after settings load — the sidebar opens on landing while the toggle, its label, analytics and persistence keep production behavior, and MainLayout's padding follows the real setting on both layout variants. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…racked Extracts the AdSense engine from the arbitrage template into features/monetization/ProgrammaticAd, preserving the current engine semantics exactly (CSS-only unfilled collapse, mount-at-eligibility, one-shot outcome observer, module test-mode latch) and adding what the engine never reported: request, unfilled, push-error and MRC viewable impression events, all with one standardized extras shape. Callers pass a remount key when unit identity changes, and a ref guard means an ins can never be pushed twice. ArbitrageAdSlot stays as the thin per-surface wrapper so /read pages cannot enroll in the organic experiment. The ad article gains /articles/[id] (307 from /posts/:id/read, X-Robots-Tag noindex on both routes), drops the comments prefetch and JSON-LD a noindexed page cannot use, gates the image preload on the image existing, and forces light mode while mounted — display-only, the stored preference is untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The custom floating leaderboard was a publisher-implemented sticky ad, which AdSense caps at 300px wide, desktop only, always-present and one per viewport — our 728x90/320x50 bar, mounting ten seconds in beside a sticky rail unit, was outside the spec on every count. Google's own Anchor format serves a full-width bottom leaderboard on every breakpoint, renders its own dismiss control (which we never touch), and is exempt from the publisher-sticky rules because Google draws it. Slot 13 retires with the component, along with everything that existed only to serve it: the footer-nav height offset, the column-geometry custom properties and their ResizeObserver publisher, and the mount-delay branch of useTimedRelease. Enabling "Anchor ads" under Auto ads in the AdSense account is the remaining account-side step; the script's hard-navigation boundary already confines anchors to the ad pages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The share preview now carries the article's own cover instead of no image at all — an ad-bought click should land on exactly the image that sold it. The sidebar goes entirely: it has carried no ad unit since the placement trim, and its post-boot mount was the page's last source of layout shift. showSidebar: false also releases the reserved left padding, so the article column centres cleanly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The TCF stub loaded for every visitor worldwide, and Google's ad tags hold every request on any page where __tcfapi exists until the CMP delivers a terminal answer — which iubenda only produces once the banner is actioned. That gated ads on consent in countries where no consent is required and where Google mandates no CMP at all: an Israeli or US visitor saw no ads until they interacted with the banner. The TCF layer (stub, safe-tcf, enableTcf, Google Additional Consent) now loads only for GDPR-covered visitors per the boot geo, failing safe to covered when the geo is unknown. Everyone still sees the banner and their preference is still recorded; ads outside GDPR scope simply no longer wait for it. EEA/UK/CH behavior is unchanged: certified CMP, consent-gated requests, Limited ads on rejection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The previous fix scoped the TCF layer to checkIfGdprCovered, which counts everyone outside a two-entry allowlist (US, IL) as GDPR-covered — so a South African visitor still had ads held until they actioned the banner. Google's certified-CMP mandate covers the EEA, the UK and Switzerland; continent Europe is the superset that errs safe. Everyone else gets the banner without the ad gate, and an unknown geo keeps the full treatment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
window.__gpp is a consent API Google's tags can hold requests on, just like __tcfapi — and the US state privacy laws it carries only apply to US visitors. Everyone else now gets neither consent API, so nothing on the page can make an ad request wait for the banner outside the regions whose law requires it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The redesigned banner clamps .iubenda-banner-content to a scrollable max-height, and iubenda refuses Accept/Reject while that element is scrollable and not scrolled to bottom — the press-again counter. The one-time poke at onBannerShown resolved that state for the initial render only; every re-measure (resize, webfonts landing) could re-clamp after the poke and swallow the next consent press. Poking on each measure keeps the first press binding under all of them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Consent scope (blocker 2): the certified-CMP gate keys on an explicit EEA + UK + Switzerland country list (EU outermost regions included by their own ISO codes) instead of continent Europe, which missed GDPR territory on three other continents. enableUspr is scoped with the GPP stub it depends on, and the spec now covers all four regimes: mandate, outside it, US, unknown geo. Preconditions in the map (blockers 3 and 4): slot 7's TODO now gates its layoutKey on a visible ad label and a phone density re-measure with the interval live; the Auto-ads anchor TODO requires the /articles URL-group scoping and a real-phone check against the fixed footer bar before the account switch flips. Cleanup: the implementation moves to pages/articles/[id].tsx so the X-Robots-Tag header fires on the shipping route and no SPA transition can render the template under the legacy URL; comment rows keep one stable element type so a moving interleave boundary cannot remount a live comment subtree; expandSidebar leaves MainLayout with its last consumer gone; the beforePopState reset carries its single-global-slot caveat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The round-3 precondition for slot 7's layoutKey included a visible ad label; a precondition that lives in a comment can be missed on the day the id is pasted in. ProgrammaticAd now renders the policy-permitted "Advertisements" caption above every inFeed unit inside the collapsing wrapper, and the slot-map gate marks that item done. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
a39ffbe to
8295abc
Compare
Ad-monetised article template for paid-acquisition traffic, a reusable programmatic ad layer, and — called out explicitly because it affects every page and every visitor — a scoping change to the consent stack.
Route
Ships at
/articles/:id(implementation atpages/articles/[id].tsx). The earlier/posts/:id/readpath 307s to it. Noindexed twice over:noindex,nofollowmeta (no canonical — mixed signals) and anX-Robots-Tagheader on/articles/:path*.getStaticProps404s anything that is not an article / video / collection carrying a summary or body — AdSense's low-value-content enforcement is account-level, so title-plus-ads shells are not generatable. The page forces light mode while mounted (display-only; the stored preference is untouched) and uses the post's own cover asog:image. No sidebar (showSidebar: false).Placements (live at merge)
layoutKeyis filled; preconditions inslots.ts(label ships in code; phone density re-measure required)Retired since earlier drafts: slot 1 (sidebar), 8/9 (multiplex grids), 10 (half-page tower), 13 (custom floating leaderboard — a publisher sticky of that shape violates AdSense placement policy; Google's Anchor is the compliant equivalent).
Phone ad density measured at ~27% of page height against the Better Ads 30% cap (violation = Chrome filters ads domain-wide).
Gating
isAuthReady && !user) — logged-in members and Plus never see programmatic ads./articles:read_adsenseboolean, default on (documented non-experiment kill switch — never ramp with it).post_adsenseboolean (default off) + 2 slots (15/16, ids still empty); enrollment additionally requires a live unit id so the experiment cannot fill with users who can't see it.routeChangeStartguard +beforePopState), so ad code cannot follow SPA navigation into the app.Iubenda.tsxpreviously loaded the IAB TCF stub and the GPP stub for every visitor worldwide. Google's ad tags hold all ad requests on any page carrying__tcfapi/__gppuntil the CMP answers — which iubenda only does once the banner is actioned — so ads were consent-gated in countries where no law and no Google policy requires it (reported from South Africa and Israel).Now: the TCF stack loads only for visitors in the certified-CMP mandate's scope — an explicit EEA + UK + Switzerland country list (
certifiedCmpRegions, including EU outermost regions whose ISO codes sit on other continents: GF/GP/MQ/RE/YT, plus AX/GI) — and the GPP/USPR stack only for US visitors. Unknown geo fails safe to the full stack. The banner itself still shows everywhere and preferences are still recorded; ads outside those scopes simply no longer wait for it. EEA/UK/CH behavior is unchanged: certified CMP, consent-gated requests, Limited ads on rejection. Covered byIubenda.spec.tsx(mandate / outermost-region / US / outside / unknown-geo paths).Sign-off requested: privacy/legal on the scoping list, analytics owner on the six new
LogEventvalues (request/fill/empty/view adsense slot,adsense slot error,adsense test mode) and theirextrashape.Auto ads account configuration (not expressible in this repo)
The bottom leaderboard requires, in the AdSense UI: Auto ads → Anchor format only (all in-page formats OFF), "wide screens" enabled for desktop anchors, URL group scoped to
app.daily.dev/articles/*only — the script also loads on/posts/[id]whenpost_adsenseis on, so the exclusion is what keeps anchors off the organic page. Before flipping the switch: verify the anchor against the fixed mobile footer nav on a real phone (they share the bottom edge; an obscured ad is a policy violation).Reusable ad layer
features/monetization/ProgrammaticAd— neutral component with the full lifecycle in first-party telemetry (request, fill, unfilled, push error, test-mode alarm, IAB viewable impression via the existinguseViewability), standardized extras, remount key on unit identity, mount-at-eligibility to keepadsbygoogle.pushbinding safe, CSS-only collapse on Google'sunfilledverdict.ArbitrageAdSlotis a thin per-surface wrapper. inFeed units carry the policy-permitted "Advertisements" label in code.Verification
WorldGuideSheetfailure, present before this branch's changes)typecheck_strict_changed, lint green; CI green on head{region: ZA, continent: AF}→ no consent APIs load🤖 Generated with Claude Code
Preview domain
https://claude-ad-arbitrage-assets-analy.preview.app.daily.dev