Skip to content

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

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

feat(webapp): ad-monetised post template for paid and SEO traffic#6500
tsahimatsliah wants to merge 45 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 at /posts/[id]/read, built for paid-acquisition landing traffic where the goal is ad impressions rather than signups, plus two flagged AdSense units on the organic post page. The two existing templates' layouts are untouched.

Live AdSense units on the preview (test creatives on any non-production host via data-adtest): /posts/qojM1enSN/read.

Audience and switches: both surfaces serve anonymous visitors only (gated on isAuthReady, so a logged-in boot never flashes an ad; Plus stays ad-free). read_adsense (default on) is the /read kill switch — not an experiment; post_adsense (default off) gates the organic units and only enrolls when a real unit id exists. /read renders only Article/Video/Collection posts carrying a summary or body; other ids 404.

Architecture

  • Forked from the classic layout and rebuilt on the production components (PostWidgets, PostEngagements, PostComments, GoBackHeaderMobile) so everything between the ads is pixel-identical to /posts/[id]. For scraped articles contentHtml is empty on both templates, so the focus card's body-rendering advantage never applies to this traffic, while its missing widget column would cost three always-viewable slots.
  • Unit ids live in code (arbitrage/slots.ts), not remote config — they're public in any page's source, and a GrowthBook JSON value shipped the map in every surface's boot payload. /read units are always on (the route is only reachable via paid placements); the organic units sit behind post_adsense (default off) and render for anonymous visitors only.
  • adsbygoogle loads only on routes that render units, and leaving /read forces a full page load so Google globals can never follow a client-side navigation into the app.
  • Signup surfaces removed on /read (PostAuthBanner, CustomAuthBanner, PostSignupWidget); header login/signup unaffected. Route is noindex (no canonical — mixed signals) until it's decided as the organic canonical.

Placements

/read — leaderboard above the article (pins for 10s after first scroll, then releases; on mobile it pins as one block with the back/Read-post header and hands the top over to it); 336×280 beside the cover, bottom-aligned with it; native unit every 5 comments in long threads; two multiplex grids after the discussion; rail: one slot after every real widget (none after the house ad or sponsored-tools card — already commercial) closed by a sticky 300×600 (desktop) / multiplex grid (mobile); dismissible compact unit under the sidebar nav; dismissible floating leaderboard aligned to the article column, delayed 10s, offsetting the mobile footer nav by its measured height.

Organic (post_adsense + anonymous) — leaderboard above the container (never in post modals), sticky half page closing the widget column.

Live at merge

State Placements
Live (/read) 1 sidebar · 2 top leaderboard · 3 beside cover (borrows rail unit id) · 9 grid (slot 8's second grid shares it) · 10 rail half page · 11/12 rail rectangles · 13 anchor (borrows leaderboard unit id)
Inert until units exist 4/5/6 new rail positions · 7 comment native (needs layoutKey) · both organic slots (15/16)

Empty ids collapse to nothing and never load the script; the placements ship reviewed once and switch on by filling in slots.ts.

Review round 1 — resolved

All five blockers from the internal review are addressed in dfbe38ff1: internal strategy docs removed via history rewrite (+ subtree-wide .gitignore); cpm/reach stripped from shipped code; post_adsense evaluated conditionally (anonymous only) and never from /read slots; script gated on a real unit id existing; and the adsbygoogle.push ordering race fixed by mounting each <ins> only at eligibility. Non-blocking notes handled too: shallow-navigation guard, sidebar inverted to a sidebarTrailing node (extension no longer bundles slot code via the nav), FurtherReading boundary guard, and first-party fill/dismiss analytics.

Mechanics worth knowing

  • Fill grace runs from the ad request, not mount — lazy slots below the fold collapse only after Google had its window to answer.
  • Empty-slot collapse keys on the creative iframe (fixed-size slots measure their booked height even when unfilled), watched via MutationObserver so late fills reopen.
  • --sticky-header-offset is published by MainLayout for every pinned unit; hardcoded offsets overshoot wherever the chrome differs (v2 sidebar, banner, mobile).
  • Formats constrain the AdSense response shape (data-ad-format: rectangle|horizontal|vertical) — a width cap alone happily returns a 300×600 into a 300×250 placement. A unit's type is fixed at creation in AdSense: in-article units are fluid regardless of the <ins> attributes, which is why slot 3 borrows a Display unit id (TODO in slots.ts to create dedicated units for s03, and per-placement ids for s13/second grid to unblend reporting).
  • Better Ads: the phone run is inside the 30% mobile cap (~27%: only the first rail unit and neither closing-grid extra keep phone placements), and both persistent placements (anchor, sidebar) are user-dismissible.

Shared-component changes (all gated, existing consumers unchanged)

PostWidgets gains hideSignupWidget/hideToc/getRailAd(position)/trailing; PostComments/PostEngagements gain interleaveEvery/renderInterleaved; FurtherReading gains hideToc/betweenSections; MainLayout/Sidebar gain opt-in expandSidebar/forceExpanded.

Verification

  • 2478 shared / 580 webapp tests green, both packages lint clean, strict typecheck on changed files, production build compiles.
  • Behavior verified in-browser on the preview: sticky pin/release timings, mobile header handoff, column alignment of the floating unit, 336×280 shape binding, empty-slot collapse.

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 9:13pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
storybook Ignored Ignored Aug 21, 2026 9:13pm

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>
… 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.

@tsahimatsliah tsahimatsliah left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

A third post template plus flagged AdSense units on the organic post page. The layout work is careful and the shared-component additions are opt-in, so existing consumers are unchanged. Two things block: internal commercial data committed to this public repo, and the flag/ad-request mechanics. Detail inline.

Note: this is a draft — reviewed on explicit request.

Blocking

  1. plans/ad-arbitrage/** — internal commercial strategy docs in a public repository.
  2. FORMAT_SPEC.cpm — internal rate expectations shipped in the production bundle.
  3. useOrganicAdsenseSlots — unconditional GrowthBook evaluation enrolls users who can never see the feature (including every /read slot, a surface the flag does not govern).
  4. Organic AdSense script loads with post_adsense on while both unit ids are empty.
  5. adsbygoogle.push({}) binds to the first uninitialised <ins> in document order, not to the slot that triggered it.

Non-blocking

Route interception and shallow navigations, ads in the shared sidebar, nullability widening in FurtherReading, missing analytics.

Verification

  • CI green (shared/webapp/extension tests, lint, strict typecheck on changed files)
  • Shared-component consumers enumerated (PostWidgets, PostComments, PostEngagements, FurtherReading, MainLayout, Sidebar)
  • Not verified: behaviour with post_adsense on for an anonymous visitor once real unit ids exist; /read on a real phone; extension build with the sidebar change

Verdict

Changes needed — items 1–5. (Posted as a comment review: the review credential is the PR author, so a formal change request is not possible.)

Reviewed by AI.

Comment thread plans/ad-arbitrage/README.md Outdated
@@ -0,0 +1,454 @@
# Ad Arbitrage Initiative: Asset Map, Analytics, Economics & Launch Plan

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking (privacy). dailydotdev/apps is public. These files carry internal commercial data: named advertisers with their booked deal values and CPMs, internal remnant RPM/fill figures, per-surface impression volumes, revenue modelling, and paths/references to an internal private knowledge repo. That is exactly the category we keep out of public PRs, and anyone can read the branch today.

Note .gitignore has plans/*.md, which only covers the top level — the plans/ad-arbitrage/** subtree slipped past it. plans/ in main contains only .gitkeep, so this is the first content added there.

Please drop plans/ad-arbitrage/** (docs + asset-volume-queries.sql) from the branch, keep the material in the internal knowledge repo, and extend the ignore to plans/**/*.md so this cannot recur. Since the branch is already public, rewrite the commits rather than adding a deletion commit on top.

Reviewed by AI.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in dfbe38f: history rewritten — plans/ad-arbitrage/** (docs + SQL) removed from every commit on the branch, not deleted on top — and .gitignore now covers plans/**/*.md + plans/**/*.sql. The material is preserved locally for the internal knowledge repo. Note: the pre-rewrite commit objects may remain fetchable by SHA on GitHub until garbage collection; if that matters, a support request to purge them is the remaining step.

[ArbitrageAdFormat.Leaderboard]: {
label: 'Leaderboard',
size: '728x90 · 320x100 mobile',
cpm: '$2.50',

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking. cpm (and reach on the props) are internal rate expectations, and FORMAT_SPEC is a module-level constant. The dashed placeholder only renders in development, but the map itself is in the production bundle — and since SidebarDesktop now imports this module, that bundle is shipped on effectively every page of the webapp and the extension. Flag-gating hides UI, not shipped constants.

Suggest dropping cpm/reach from the shipped code entirely (they are review aids, not runtime data), or moving the placeholder plus its spec into a module that is only imported behind isDevelopment so it tree-shakes out.

Reviewed by AI.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in dfbe38f: cpm is gone from FORMAT_SPEC and reach from the props and every call site — dropped from the shipped code entirely rather than moved behind a dev-only import.

// Raw context rather than useAuthContext: this runs inside every slot
// (read surface included), where an AuthContext provider isn't guaranteed.
const isAnonymous = !useContext(AuthContext)?.user;
const enabled = useFeature(featurePostAdsense);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking. useFeature evaluates unconditionally, and evaluation enrolls. Two consequences:

  1. On /posts/[id] the hook runs for every visitor, including logged-in and Plus users who return NO_SLOTS right after — they get enrolled in post_adsense while being structurally unable to see a unit, so the experiment population is diluted.
  2. ArbitrageAdSlot calls both hooks regardless of surface, so every slot on /read (13+ instances, and one per interleaved comment unit) evaluates post_adsense too — a flag that does not govern that route at all.

useConditionalFeature is the established primitive and is already used a few lines away from the new code in pages/posts/[id]/index.tsx for featurePostRedesign. Please gate on shouldEvaluate: isAnonymous, and in ArbitrageAdSlot skip the organic hook when surface === 'read' (a small split into two components, or passing the slot map in as a prop, avoids the conditional-hook problem).

Reviewed by AI.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in dfbe38f: useOrganicAdsenseSlots now goes through useConditionalFeature with shouldEvaluate: isAnonymous, and ArbitrageAdSlot splits per surface (ReadArbitrageAdSlot / OrganicArbitrageAdSlot, shared MappedAdSlot body) so /read slots never call the organic hook at all — no conditional-hook problem, no enrollment from either direction.

// Empty for Plus members and while post_adsense is off; the slot components
// check the same hook, so with it empty neither markup nor script exists.
const adsenseSlots = useOrganicAdsenseSlots();
const adsenseActive = Object.keys(adsenseSlots).length > 0;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking. adsenseActive is derived from key count, but both entries in ORGANIC_ADSENSE_SLOTS currently have id: ''. So flipping post_adsense on today loads adsbygoogle.js plus the preconnects/preload on every anonymous post page and renders zero units — cost and third-party surface with no inventory, and the slot components correctly return null, so nothing shows the problem.

Derive the script gate from at least one non-empty id (Object.values(slots).some((s) => !!s.id)) rather than key presence, and apply the same to ArbitrageAdSlot's isLive and ArbitrageAnchor.

Also, the comment above says "Empty for Plus members" — the hook actually gates on anonymous, i.e. all logged-in users, not just Plus.

Reviewed by AI.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in dfbe38f: added hasLiveAdsenseUnits (some non-empty id) in adsense.ts and gated adsenseActive, ArbitrageAdSlot's live mode and ArbitrageAnchor on it — flipping post_adsense on with empty ids now loads nothing. The "Plus members" comment is corrected to every logged-in visitor.

const requestAd = (): void => {
try {
window.adsbygoogle = window.adsbygoogle || [];
window.adsbygoogle.push({});

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking (needs verification on the preview with all units live). adsbygoogle.push({}) does not bind to insRef.current. The tag processes the pushed request against the first uninitialised ins.adsbygoogle in document order. With per-slot lazy pushes firing in intersection order across 13 placements plus repeated comment natives, a push triggered by a slot low on the page can initialise an earlier, not-yet-pushed slot instead: the request goes out with the wrong unit id and the wrong format, the triggering slot stays unprocessed, and the querySelector('iframe') check then collapses it as empty. Reporting is attributed to the wrong placement too.

This is easy to miss in manual QA because most slots do eventually fill.

Options: keep one shared queue that pushes strictly in DOM order as slots become eligible, or make every slot eager and rely on the tag's own lazy loading, or pass the element explicitly via the params object if we confirm that is supported for this account. Worth confirming empirically (log data-ad-slot on each filled iframe's parent) before merge.

Reviewed by AI.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in dfbe38f, structurally rather than by queueing: the <ins> no longer mounts until its slot becomes eligible (eager → mount, lazy → on intersection), so at any moment every uninitialised ins in the document is one that should be processed right now — which makes the pushes interchangeable and the first-in-document-order binding harmless. Each processed ins uses its own data-ad-slot, so attribution follows the element, and the wrapper keeps the format's min-height so reserved space is unchanged. Pinned by a test (mounts no <ins> before the slot becomes eligible). Will verify on the preview by checking every filled iframe sits under the ins whose id requested it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified empirically on the preview deploy of dfbe38f: at load, only eligible slots have an <ins> mounted (the second grid's is absent until scroll); after making every placement eligible — 10 ins total, 0 unprocessed, 0 iframes outside their own ins, and the late-mounted grid processed and filled (447px). No stranded slots, attribution follows each element.

return undefined;
}
const forceHardNavigation = (url: string): void => {
if (/^\/posts\/[^/]+\/read(?:[/?#]|$)/.test(url)) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking, but please handle shallow navigations. This handler treats every routeChangeStart as a departure. It does not receive/inspect the second { shallow } argument, so a same-page query update — a comment permalink, a URL-masking modal, or anything doing router.replace(..., { shallow: true }) — becomes a full page reload mid-interaction, and the thrown error lands in the console/error reporting once per navigation.

Suggest (url, { shallow }) => { if (shallow) return; ... } plus a same-pathname check, so only genuine departures from /read force the hard load. Global routing interceptors have bitten us before (the middleware matcher that broke feed-card modals), so it is worth enumerating what on this page can push a route: the comment flows, the auth modal, and PostHeaderActions.

Reviewed by AI.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in dfbe38f: the handler now receives { shallow } and returns early on shallow navigations, so comment permalinks and URL-masking modals stay client-side; only genuine departures from /read force the hard load. The same-pathname case is covered by the existing /read URL test.

</Nav>
</SidebarScrollWrapper>

{forceExpanded && <ArbitrageSidebarAd />}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (architecture). The global desktop nav — shared by the webapp and the extension — now imports the arbitrage module and decides to render an ad based on forceExpanded. Two costs: the extension bundle pulls in the AdSense slot code even though AdSense is prohibited there, and forceExpanded silently means "also show an ad", so any future caller of expandSidebar inherits an ad placement (with no script loaded, since only /read renders it).

Inverting it keeps the nav dumb: MainLayout already takes nodes (topBanner), so a sidebarTrailing?: ReactNode passed from the /read page would put the ad policy on the page that owns it.

Reviewed by AI.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in dfbe38f as suggested: SidebarDesktop no longer imports the arbitrage module — it renders an opaque trailing node threaded from MainLayoutProps.sidebarTrailing, and the /read page passes <ArbitrageSidebarAd /> via its layoutProps. The nav stays dumb and the extension bundle no longer pulls AdSense slot code through the sidebar.


const publicSquadProps: Partial<SimilarPostsProps> = {
title: `More posts from ${currentPost.source.name}`,
title: `More posts from ${currentPost.source?.name}`,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking. These ?. additions widen a shared component (all post pages and modals) to tolerate a missing source rather than guarding at the boundary. As written, a post without a source renders the literal title "More posts from undefined", a More button with href={undefined}, and — line 54 — a similar-posts query with source: undefined, which drops the source filter instead of skipping the query.

If strict typecheck on the changed file is what forced this, prefer an early guard (const squad = isPublicSquad ? currentPost.source : undefined and only build publicSquadProps when it exists) so the types narrow without changing rendered output.

Reviewed by AI.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in dfbe38f: replaced the optional chains with a single boundary narrow — publicSquad is set only when the source is a public squad, the query uses publicSquad.id (no silently-dropped filter), and publicSquadProps is built only when it exists, so "More posts from undefined" can't render. Output for existing consumers is unchanged.

addition is a native unit every few comments in a long thread. */}
<PostEngagements
post={post}
onCopyLinkClick={onCopyPostLink}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking (analytics). The template ships with no instrumentation: no impression, fill, or dismiss events for the slots, and no event distinguishing /read sessions from organic ones. Combined with the shared unit ids (slot 3 borrowing the rail unit, slot 8 reusing the first grid, slot 13 reusing the top leaderboard), AdSense reporting will blend placements and we will have no first-party signal at all on which placement earned what or how often the anchor/sidebar units get dismissed.

Worth at least a dismissal event on the two closable units and a per-slot fill event before this carries paid traffic.

Reviewed by AI.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially resolved in dfbe38f: added LogEvent.FillAdsenseSlot (once per slot, extra carries {slot, unit, format} — the first-party per-placement signal the blended AdSense reporting can't give) and LogEvent.DismissAdsenseSlot on both closable units (anchor + sidebar). A /read-vs-organic session marker beyond the page path, and impression/viewability events, are left for a follow-up before paid traffic ramps.

// TODO(chris): create the three new rail units (suggested names
// read_s04_rail_creator, read_s05_rail_share, read_s06_rail_highlights) as
// Display 300x250. They stay collapsed until their ids are filled in.
[ARBITRAGE_SLOT.railAfterCreator]: { id: '', type: 'display' },

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question. Five /read slots (4, 5, 6, 7) and both organic slots ship with empty ids, so a decent share of the placements described in the PR body are inert at merge, and the post_adsense flag has nothing to turn on. Can you state in the description which placements are actually live on merge, and whether the empty ones should land in this PR at all versus following once the units exist in AdSense? The TODO(chris) markers also point at a person rather than a ticket — a Linear reference would survive better.

Reviewed by AI.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Answered in the updated PR description: live at merge on /read are slots 1 (sidebar), 2 (top leaderboard), 3 (beside cover, borrowing the rail unit id), 9 (grid — slot 8's second grid shares its unit), 10 (rail half page), 11/12 (rail rectangles), 13 (anchor, borrowing the leaderboard unit id). Inert until their AdSense units exist: 4/5/6 (new rail positions), 7 (comment native, needs layoutKey) and both organic slots — kept in this PR so the placements and their gating ship reviewed once, with each empty id collapsing to nothing. On the TODO(chris) markers: agreed a ticket reference is better; I can't create Linear issues from this repo, so that's on us to file and swap in.

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.
@tsahimatsliah
tsahimatsliah force-pushed the claude/ad-arbitrage-assets-analytics-87b2db branch 3 times, most recently from 3d35381 to aada78a Compare August 21, 2026 14:05
@tsahimatsliah
tsahimatsliah force-pushed the claude/ad-arbitrage-assets-analytics-87b2db branch from aada78a to 55bee60 Compare August 21, 2026 14:07
@rebelchris
rebelchris marked this pull request as ready for review August 21, 2026 14:07

@rebelchris rebelchris left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Re-review of the current head (organic slots + boot-light config). Round 1's five blockers are genuinely fixed: plans/** is out of the tree and ignored subtree-wide, no cpm/reach values remain in shipped code, post_adsense is now evaluated conditionally, the script is gated on a real unit id, and the adsbygoogle.push ordering race is handled by mounting each <ins> only at eligibility. The layout engineering is careful and the shared-component additions are all opt-in, so existing consumers stay unchanged.

What blocks now is not the layout — it is the risk this puts on the whole domain and the loss of the kill switch.

Blocking

1. The mobile ad density is knowingly over the Better Ads Standards cap. ArbitragePostContent.tsx:43-48 states the phone run measures ~40% of page height against the 30% cap and is kept "by product decision". The PR description says the opposite ("mobile keeps density near the 30% cap"). Chrome's ad filter is applied site-wide to the domain, not per route, so a /read violation filters ads across app.daily.dev — including the in-feed inventory we sell. Either bring the phone inside the cap or get an explicit, written product sign-off on filtering the whole domain, and fix the description so nobody merges this on the wrong understanding.

2. AdSense policy exposure on scraped-article pages. /read is generated for any post id with fallback: 'blocking'. For scraped articles contentHtml is empty, so the page is a title, a summary, a cover image and 13 ad placements. That is the shape AdSense's low-value/scraped-content policy targets, and enforcement is account-level. Please state which post types /read is allowed to render for (and enforce it in getStaticProps) rather than shipping it for the entire catalogue.

3. There is no longer a kill switch. useReadAdsenseSlots returns the hardcoded map in every non-development build, and the read_adsense boolean from the previous round is gone. Turning /read ads off — policy warning, bad creative, broken layout, revenue anomaly — now needs a code change, a deploy and ISR revalidation. Keep the boolean (default on if that is the product call); the boot-payload argument was about the JSON map, not about a single flag.

4. /read serves programmatic ads to logged-in and Plus members. The organic surface correctly restricts to anonymous visitors; /read has no auth check at all. Any logged-in user who lands on a /read link — and any Plus subscriber, for whom ad-free is a paid promise — gets the full 13-slot page. Please gate /read the same way, or say explicitly that Plus users are in scope.

5. post_adsense enrolls a population that cannot possibly see the feature. Every id in ORGANIC_ADSENSE_SLOTS is empty, so hasLiveAdsenseUnits is false and nothing renders — yet useOrganicAdsenseSlots is called at /posts/[id] page level and evaluates the flag for every anonymous post-page visitor. useConditionalFeature evaluation is enrollment, so the experiment fills with users for whom variant and control are byte-identical. Add hasLiveAdsenseUnits(ORGANIC_ADSENSE_SLOTS) to shouldEvaluate.

6. The anonymity check races boot. useReadAdsenseSlots.ts:28 reads useContext(AuthContext)?.user with no readiness gate. user is undefined until boot resolves, so a logged-in visitor is classified anonymous first, and the gate depends on GrowthBook's ready and boot landing in a specific order — an implicit invariant, not a guarantee. Once real unit ids exist this means an ad rendered then removed on a Plus user's post page (plus the layout shift). Gate on isAuthReady/isBootLoaded rather than on user being falsy.

7. Route interception breaks browser back/forward. read/index.tsx:95-110 intercepts routeChangeStart and calls window.location.assign(url). routeChangeStart also fires for popstate, so pressing Back from /read performs a forward navigation to the previous URL, leaving /read in the forward stack — Back appears broken and can trap. The throw inside the handler also surfaces as an uncaught error in the console and in error reporting. Please handle popstate via router.beforePopState (and/or history.replaceState + location.replace) and confirm Back, Forward and a browser refresh from /read on desktop and a phone.

Non-blocking

  • ArbitragePostContent is a 391-line fork of the classic layout. Every future fix to PostContent now has to be applied twice, with nothing in CI asserting parity. Worth recording why variant props on PostContent were rejected — and if the /read experiment wins, this fork is the follow-up debt.
  • Shared unit ids across placements (2/13, 3/12, 8/9, with 8's unit rendered twice on desktop) blend reporting and rest on the "one uninitialised <ins> at a time" invariant, which any future eager slot or same-tick intersection can violate. The TODOs cover the reporting side; a short comment on the invariant at the push site would stop someone adding eager and quietly mis-binding requests.
  • data-adtest derives from webappUrl. A misconfigured webappUrl in production silently turns every impression into a test impression — zero revenue, no alarm. Log once when test mode engages so it is visible in production telemetry.
  • FooterWrapper now reads --arbitrage-anchor-height on every page for one route's benefit. Harmless with the fallback, but it is compensating CSS in a shared component; a wrapper on /read would keep the shared footer clean.
  • useDelayedReveal and useStickyRelease are near-identical timer hooks. One hook with an optional trigger would do.
  • .gitignore ignores plans/**/*.md and plans/**/*.sql only. Any other extension under plans/ is still tracked — ignore the directory and un-ignore .gitkeep.
  • CI is still in flight on this head (lint_shared, test_shared, test_webapp, build pending). Not blocking on its own, just not yet green as described.

Verification

  • Round 1 blockers re-checked against the current head — all five fixed
  • Shared consumers enumerated (MainLayout, Sidebar, PostWidgets, PostComments, PostEngagements, PostContent, FurtherReading, FooterWrapper)
  • Extension path checked — sidebar unit is injected from the page via sidebarTrailing, so the nav no longer pulls slot code into the extension bundle
  • Flag/enrollment semantics traced through useConditionalFeature
  • Not verified: back/forward from /read, a real phone at the measured density, /read as a logged-in Plus member, organic slots with real unit ids

Verdict

Changes needed — items 1-7. The layout work is solid; the objections are about domain-level ad-policy risk, the missing kill switch, and who sees the ads.

Reviewed by AI.

* product decision — the phone gets the same run of slots the desktop rail
* does. The second closing grid is still desktop only, which is what holds
* the phone anywhere near the cap.
*/

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking. This says the phone run measures ~40% of page height against the Better Ads Standards' 30% cap and is kept anyway. The PR description claims the opposite ("mobile keeps density near the 30% cap").

Chrome's ad filter is applied to the domain, not the route: a /read violation filters ads across app.daily.dev, including the in-feed inventory we sell to advertisers. That is a blast radius far beyond this template.

Either drop enough phone slots to land under the cap, or attach an explicit written product sign-off that accepts site-wide ad filtering as the downside — and correct the description either way, so this does not get merged on the wrong premise.

Reviewed by AI.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved on the branch (0d82b56): only the first rail unit keeps its phone placement, bringing the phone run to roughly 27% against the 30% cap — under it rather than signed-off over it, given the domain-wide blast radius. The comment at ArbitragePostContent now states the numbers and the reason, and the PR description's density line is corrected to match.

* map there.
*/
export const useReadAdsenseSlots = (): ReadAdsenseSlots =>
isDevelopment ? NO_SLOTS : READ_ADSENSE_SLOTS;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking. This is the kill switch gone: every non-development build serves the hardcoded map, and the read_adsense boolean from the previous round no longer exists. An AdSense policy warning, a broken creative, a layout regression or a revenue anomaly now needs a code change, a deploy and ISR revalidation before ads stop.

The boot-payload argument that justified moving the map into code applies to the JSON map, not to a single boolean. Please keep read_adsense (default on if that is the product call) so the route can be switched off from GrowthBook.

Related: nothing here checks auth, so a logged-in visitor — including a Plus member, for whom ad-free is a paid promise — gets the full 13-slot page on /read. The organic surface restricts to anonymous; /read should either do the same or the exception should be stated.

Reviewed by AI.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved (0d82b56): read_adsense is back as the emergency kill switch, default on and documented as a non-experiment — /read ads can be stopped from GrowthBook without a deploy or ISR wait. The related point too: both surfaces now serve anonymous visitors only, so no logged-in member — Plus included — sees a programmatic unit on /read; a logged-in visitor gets the plain template. Pinned by tests ('never renders for logged-in users', 'goes dark when the read_adsense kill switch is off').

export const useOrganicAdsenseSlots = (): ReadAdsenseSlots => {
// Raw context rather than useAuthContext: this runs inside every slot
// (read surface included), where an AuthContext provider isn't guaranteed.
const isAnonymous = !useContext(AuthContext)?.user;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking. Two problems in this gate:

  1. Boot race. AuthContext.user is undefined until boot resolves, so a logged-in visitor is classified anonymous on the first passes. The gate only holds because GrowthBook's ready happens to land after boot — an implicit ordering invariant, not a guarantee. Once real unit ids exist, the failure mode is an ad rendered and then removed on a Plus member's post page, with the layout shift that comes with it. Gate on isAuthReady/isBootLoaded instead of on user being falsy.

  2. Enrollment with zero possible exposure. Every id in ORGANIC_ADSENSE_SLOTS is empty, so hasLiveAdsenseUnits is false and nothing can render — but this hook is called at /posts/[id] page level, so post_adsense is evaluated (and therefore enrolled) for every anonymous post-page visitor while variant and control are byte-identical. Add hasLiveAdsenseUnits(ORGANIC_ADSENSE_SLOTS) to shouldEvaluate.

Reviewed by AI.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved (0d82b56): the gate is isAuthReady && !user via a shared useIsAnonymous — nobody gets slots while boot is in flight, so a member's session can never land after an ad rendered. And post_adsense only evaluates when exposure is possible: shouldEvaluate: isAnonymous && hasLiveAdsenseUnits(ORGANIC_ADSENSE_SLOTS), so the empty-id map enrolls no one ('never enrolls into post_adsense while no unit has an id' test).

return;
}
router.events.emit('routeChangeError');
window.location.assign(url);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking. routeChangeStart also fires for popstate, so this intercepts browser Back and Forward. window.location.assign pushes a new entry, so pressing Back from /read navigates forward to the previous URL and leaves /read in the forward stack — Back reads as broken and repeated presses can trap the user.

The throw also escapes as an uncaught error into the console and error reporting on every intercepted navigation, which makes real errors harder to see.

Suggested direction: handle popstate through router.beforePopState (returning false after a location.replace), and keep assign only for genuine forward navigations. Please confirm Back, Forward and a refresh from /read on desktop and on a phone before this lands — this is the same class of global-routing interception that broke every feed-card modal in apps#6439.

Reviewed by AI.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved (0d82b56): popstate goes through router.beforePopState — /read-internal moves pass through, genuine departures cancel the SPA transition and load the URL the history pointer already moved to in place, so Back keeps meaning back and /read doesn't linger in the forward stack. location.assign remains only for link/push navigations, where a push is what was asked for, with the shallow guard intact. Will confirm Back/Forward/refresh on the preview desktop + phone once it deploys.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified on the deployed preview (head with 0d82b56 + 53c93c9), desktop 1440px and phone 375px, same results on both:

  • Back from /read → lands on /posts/qojM1enSN (the actual previous entry), performance.navigation.type = back_forward, window.adsbygoogle gone (full unload), history length unchanged — Back means back, nothing pushed.
  • Forward → returns to /read with ads reloading, history length still unchanged — /read is not stranded in the forward stack.
  • Refresh on /read → stays on /read, type = reload, ads reinitialise.

Also confirmed while there: the phone renders the reduced density set (rail units' wrappers display:none below laptop).

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

Copy link
Copy Markdown
Contributor

Re-review blockers resolved (0d82b56)

All seven blocking items from the changes-requested review:

  1. Mobile density — only the first rail unit keeps its phone placement (the other four are desktop-only), bringing the phone run from the measured ~40% to ~27% of page height, inside the Better Ads 30% cap. PR description updated to match the code.
  2. Scraped-page policygetStaticProps now 404s anything that isn't an article / video / collection carrying a summary or body. Title-plus-ads shells are no longer generatable; squad/UGC and internal types are excluded outright.
  3. Kill switch restoredread_adsense boolean, default on (documented as a non-experiment emergency switch; never ramp/target with it). Turning /read ads off is a flag flip again, no deploy.
  4. /read audience — both surfaces are now anonymous-only. Logged-in members and Plus subscribers never see programmatic ads anywhere.
  5. Enrollmentpost_adsense evaluation now also requires hasLiveAdsenseUnits(ORGANIC_ADSENSE_SLOTS), so nobody enrolls while the organic ids are empty.
  6. Boot race — anonymity is isAuthReady && !user; before boot resolves the page is ad-free, so a logged-in visitor can never get an ad flashed and torn out.
  7. Back/forward — popstate is handled via router.beforePopState with an in-place load; the hard-navigation guard now only covers imperative SPA navigations. Back/Forward/refresh keep their history positions.

Non-blocking picks in the same push: plans/ ignored wholesale, and a one-shot adsense test mode telemetry event fires when data-adtest engages, so a misconfigured webappUrl silently serving test creatives in production becomes visible. Remaining non-blocking items (PostContent fork parity, timer-hook merge, FooterWrapper CSS, shared-unit reporting) noted as follow-up debt.

Verification: 109 shared post+sidebar tests (7 new: logged-in/read, auth-not-ready, kill switch, empty-id enrollment, and the existing gating set), webapp suite green except the pre-existing WorldGuideSheet failure (present on the branch before these changes), strict typecheck + lint clean.

🤖 Generated with Claude Code

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.
@tsahimatsliah

Copy link
Copy Markdown
Member Author

@rebelchris — round 2 is resolved on the branch. All seven blockers are addressed in 0d82b564f (replies on each thread), and 53c93c95f picks up the remaining notes from the review body:

  • Fork debt — the variant-props-rejected rationale and the fold-back-if-/read-wins debt are now recorded in ArbitragePostContent's doc.
  • Push-ordering invariant — documented at the push site itself, so adding eager to a slot that isn't genuinely requested-at-mount reads as the race re-opener it is.
  • Silent test modedata-adtest engaging now logs a one-shot AdsenseTestMode telemetry event with the host.
  • FooterWrapper — the anchor offset is an explicit offsetByAnchorAd opt-in threaded from /read; the shared footer's default is a plain bottom-0 again.
  • Timer hooksuseDelayedReveal + useStickyRelease merged into one useTimedRelease(delay, 'mount' | 'scroll').
  • .gitignoreplans/ ignored wholesale; the tracked .gitkeep stays.
  • Blocker 2 scope — /read renders only Article / VideoYouTube / Collection posts that carry a summary or body; everything else 404s at getStaticProps.
  • CI — full suites green locally (2496 shared / 580 webapp / 43 extension, lints, strict typecheck); the branch run is in flight on this head.

Remaining from your "not verified" list, on us: Back/Forward/refresh on the deployed preview (desktop + phone) once this head deploys, and the organic slots can only be exercised once real unit ids exist.

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.
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