Skip to content

feat(shared): expert-round ad iteration on /articles - #6528

Closed
rebelchris wants to merge 1 commit into
mainfrom
claude/articles-ad-iteration
Closed

feat(shared): expert-round ad iteration on /articles#6528
rebelchris wants to merge 1 commit into
mainfrom
claude/articles-ad-iteration

Conversation

@rebelchris

@rebelchris rebelchris commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Implements the expert consult's ten action points on the /articles template.

# Ask Shipped
1 Remove internal ads on /articles hideInternalAd (AdAsComment) + hideAdWidget (PostSidebarAdWidget), opt-in props — every other surface unchanged
2 MPU every ~250 in the body, breaking nicely splitContentForAds: cuts only at top-level block boundaries (never inside a paragraph/list/code block), merges trailing slivers. Cadence is ~250 words (1500 chars) — see note below
3 MPU above comments Slot 18, directly above the engagement block
4 MPU every 6–8 comments Interleave is now an MPU every 7 (replaces the never-served native-every-5)
5 Rail: 3rd bottom unit as the only sticky Mid-rail sticky is in flow again; a fixed 300×600 closes the rail as the page's only sticky — compliant (300px wide, desktop-only, one per viewport, nothing below to overlap)
6 "Advertisement" label top-right on all ads Every unit, small gray, top-right — as "Advertisements", one of the two strings AdSense's ad-labeling policy permits (bare "Advertisement" is not)
7 Lazy at 50px, centered, lighter background rootMargin: 50px; units centered on a bg-surface-float block
8 Mobile leaderboard below title, sticky Already sticky on mobile (it pins with the header block, releasing after the 10s window). It sits just above the title rather than below — moving it inside the title flow would break the header-block pinning; shout if the below-title position is a hard requirement
9 Remove the image MPU Slot 3 retired (the unit id lives on in the body/comment MPUs)
10 Questions block missing PostAnsweredQuestions renders after the body, as on the post page; it self-hides for logged-in users, which matches this page's anonymous-only ads

Judgment call to confirm: the brief's cadence was taken as ~250 words (≈1500 characters) per MPU, matching the original partner brief. A literal 250 characters is one short paragraph per ad — an ad-to-content ratio far past the Better Ads 30% cap that gets ads filtered domain-wide. If the expert truly meant characters, that needs a written risk sign-off first.

Unit reuse note: the new placements share live Display units (6921226982 / 5249052667), so AdSense-side reporting blends them; our first-party events split by slot number, so per-placement RPM stays queryable in ClickHouse. Dedicated read_s17/read_s18 units are a marked TODO.

Verification

  • 7 new splitContentForAds specs (nesting, code blocks, void elements, byte preservation, sliver merge)
  • Shared post + monetization suites (130) and webapp suite green (pre-existing WorldGuideSheet failure only)
  • Strict typecheck + lint clean

🤖 Generated with Claude Code

Preview domain

https://claude-articles-ad-iteration.preview.app.daily.dev

- Internal ads leave the template: PostEngagements gains hideInternalAd
  (drops AdAsComment) and PostWidgets gains hideAdWidget (drops
  PostSidebarAdWidget), both opt-in so every other surface is unchanged.
- The article body carries an MPU per ~250 words of visible text via
  splitContentForAds, which only cuts where a top-level block closes —
  never inside a paragraph, list or code block — and folds a trailing
  sliver into the previous chunk so a page cannot end on an ad.
- An MPU sits directly above the comment section, and the thread
  interleave becomes an MPU every 7 comments (was a native every 5 that
  never served for want of its layoutKey).
- The rail's mid-column sticky (which slid over the widgets below it)
  is in flow again; a fixed 300x600 closes the rail as the page's ONLY
  sticky unit — compliant as a publisher sticky: 300px wide, desktop
  only, one per viewport, nothing after it to cover.
- Every unit carries the policy-permitted "Advertisements" label, small
  and gray, top-right, inside the collapsing wrapper.
- Units request 50px before entering the viewport and render centered
  on a lighter background block.
- The cover-side MPU (slot 3) retires, with 4, 5 and 6; the map now
  reuses live Display units for the new placements (first-party events
  split per slot; dedicated units are a TODO for clean AdSense rows).
- PostAnsweredQuestions renders after the body, as on the post page —
  it self-hides for logged-in users, matching this page's audience.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 25, 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 25, 2026 1:58pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
storybook Ignored Ignored Aug 25, 2026 1:58pm

Request Review

@rebelchris

Copy link
Copy Markdown
Contributor Author

Superseded by #6529 — same commit, renamed branch so the preview domain carries neither codename.

@rebelchris rebelchris closed this Aug 25, 2026
@rebelchris
rebelchris deleted the claude/articles-ad-iteration branch August 25, 2026 14:05

@rebelchris rebelchris left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Summary

Ten-point ad iteration on the /articles template: rail trimmed to two units, in-body MPU cadence, MPU above comments, comment interleave switched from a never-served native to an MPU every 7, a single closing sticky half page, universal ad label, 50px lazy margin, and the questions block. Scope is coherent and splitContentForAds is properly unit-tested.

Four blocking items, all in ProgrammaticAd and in ad density — three of them are regressions to units the PR did not intend to change, because ProgrammaticAd is shared by every slot on the template (leaderboard, rail, in-body, comments).

Blocking

  1. ProgrammaticAd.tsx:490 — the new p-2 shrinks every unit's usable width below its IAB cap (border-box), which the FORMAT_SPEC docblock says the cap exists to guarantee.
  2. ProgrammaticAd.tsx:505 — label + padding are not in the reserved minHeight, so the box grows in-viewport when the slot requests.
  3. ProgrammaticAd.tsx:326rootMargin: 50px moves both the unfilled collapse and the auction into the visible area.
  4. slots.ts:62/70 — the phone-density precondition the previous code gated the comment unit behind was deleted rather than satisfied, while repeating in-body units were added with no phone cap.

Non-blocking

Raised inline on splitContentForAds.ts, ArbitragePostContent.tsx and slots.ts: pre-sanitization regex parsing vs. what Markdown actually renders, missing memoisation, per-chunk Markdown/DOMPurify/HoverCard cost, per-occurrence analytics, and doc drift.

Two description claims worth double-checking:

  • "the page's only sticky": ArbitrageTopLeaderboard is laptop:sticky for its first 10 seconds, so on a short article a fast desktop scroll can have two publisher stickies pinned at once — and the pinned leaderboard is well over the 300px width the same reasoning invokes.
  • point 8, "already sticky on mobile": the leaderboard's sticky class is laptop:sticky only. Please confirm where the mobile pinning actually comes from before reporting that point as shipped.

Verification

  • Read root/package AGENTS.md-relevant conventions, traced every ProgrammaticAd consumer (ArbitrageAdSlot → read + organic surfaces).
  • CI: test_shared, test_webapp, test_extension, lint_shared, typecheck_strict_changed green at review time; build still pending.
  • Not verified by me: rendered widths and phone ad-density percentages on the preview — those are the blocking items and need a real measurement.

Verdict

Request changes — the shared-wrapper padding, the in-viewport shift/collapse and the phone density gate need resolving before this goes live.

Reviewed by AI.

'mx-auto w-full text-center',
// Centered on a lighter block, so the unit reads as a deliberate
// frame rather than a creative floating on the page background.
'mx-auto w-full rounded-8 bg-surface-float p-2 text-center',

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Blocking: p-2 on the wrapper reduces the usable width of every unit below its IAB cap, because Tailwind's preflight makes these boxes border-box. max-w-[300px] becomes 284px of content, max-w-[336px] becomes 320px, max-w-[728px] becomes 712px.

Consequences:

  • Responsive units (data-ad-format="rectangle" etc.) are sized by AdSense from the container width. At 284px a 300x250 no longer fits, so the flagship MPU on the in-body, above-comments, comment and rail placements degrades to a 250x250 — a direct RPM loss on the exact placements this PR is adding. The leaderboard loses 728x90 the same way at 712px.
  • The fixed railBottomSticky (width: 300, height: 600, display: inline-block) is now a 300px child inside a 284px content box. The wrapper comment explicitly keeps overflow unclipped, so the creative spills 16px past its own background block — and past the 300px width the sticky-compliance argument in slots.ts relies on.

This affects units the PR did not intend to touch, since every slot on the template renders through here. Suggested direction: keep the wrapper's box at the IAB width and put the frame/padding on an inner element around the <ins>, or widen maxWidth by the padding (max-w-[316px] / max-w-[352px] / max-w-[744px]) so the ad keeps its full width. Worth checking the rendered <ins> width on the preview before/after.

Reviewed by AI.

unfilled slot's collapse takes the label down with it. */}
{isRequested && config.type === 'inFeed' && (
<span className="mb-1 block text-left text-text-quaternary typo-caption2">
{/* Every unit is labeled so none can be confused with site content

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Blocking (layout shift): the label now renders for every unit, and FORMAT_SPEC[format].minHeight reserves space for the creative alone (min-h-[250px] for a 300x250). With the label (~16px) plus p-2 (16px), a filled MPU needs ~282px, so the box grows by roughly 32px at the moment isRequested flips.

Combined with the new 50px rootMargin, that flip now happens when the slot is already on screen, i.e. the article jumps under the reader mid-paragraph — several times per article with the in-body cadence. Previously the label was inFeed-only and no live slot used that type, so nothing shifted.

Suggested direction: fold the label height and the padding into the reserved minHeight per format, or render the label unconditionally so the space is reserved from first paint.

Reviewed by AI.

{ rootMargin: '600px' },
// Expert-tuned: request just as the slot approaches rather than a
// viewport ahead — viewability over prefetch.
{ rootMargin: '50px' },

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Blocking (needs evidence or a middle value): two behaviours regress at 50px, both of which the previous 600px margin was deliberately avoiding, and the comment immediately above this line still describes the old value ("roughly a viewport of scroll — enough for the auction round-trip to finish before the slot scrolls into view").

  1. has-[>ins[data-ad-status="unfilled"]]:!hidden collapses an unfilled slot. At 600px that verdict landed before the box was ever visible; at 50px the reserved 250px box collapses while the reader is looking at it, pulling the rest of the article up. This is the failure mode the collapse rule's own comment was trying to avoid, just moved into the viewport.
  2. The AdSense request now starts ~50px before the slot enters view, so the auction resolves after it is on screen. On a normal scroll the slot is already past by the time the creative lands, which loses the impression rather than improving viewability — the opposite of the stated intent.

If the expert's recommendation is specifically about viewable-impression rate, a middle value (200-300px) gets most of it without collapsing in view. Otherwise, please note the source/measurement so the trade-off is recorded, and update the stale comment above either way.

Reviewed by AI.

* says 6-8). Short threads never reach the interval, so they stay ad-free.
*/
export const COMMENTS_PER_INTERLEAVED_AD = 5;
export const COMMENTS_PER_INTERLEAVED_AD = 7;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Blocking (density gate removed, not satisfied): the version of this map being replaced kept slot 7 collapsed behind an explicit written precondition — "Re-measure phone ad density on a long thread with the interval live. The ~27% figure was measured with this slot inert... Chrome's Better Ads filter applies to the whole domain, direct-sold inventory included."

This PR fills the unit id (activating that repeating unit) and additionally adds an unbounded run of in-body MPUs with no hideOnPhone, plus the above-comments MPU. The RAIL_AD docblock in ArbitragePostContent records that the unfiltered phone run measured ~40% (and ArbitrageAdSlot's hideOnPhone doc says 56%) against the 30% cap, which is why all but one rail unit are desktop-only.

So the page now has more repeating phone inventory than when the gate was written, and the gate itself was deleted along with the comment. Please post a fresh measured phone density on a long article with a long thread before this ships, and consider a hard cap on in-body units (or hideOnPhone on all but the first) so density cannot scale with article length.

Reviewed by AI.

* A short tail is merged into the chunk before it, so the article never ends
* on an ad followed by a stray line.
*/
export function splitContentForAds(html: string, minChars: number): string[] {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Non-blocking (correctness of the split input): this parses post.contentHtml with a regex, but what actually renders is purify.sanitize(chunk) inside Markdown — each chunk is sanitized and repaired independently, so the string the boundaries were computed on is not the string that ships. Two concrete edges:

  • HTML comments do not match TAG_RE (it requires [a-zA-Z] after the optional /), but tags inside a comment do, so <!-- <div> --> in the body permanently offsets depth and the function stops finding top-level boundaries for the rest of the article — silently, with no ads and no error.
  • Any unbalanced markup in the source has the same effect, and DOMPurify would have fixed it before render.

A <template>/DOM pass over the sanitized HTML (or splitting after sanitization) would make boundaries match what renders and remove the depth bookkeeping entirely. If the regex stays, please note why the DOM route was rejected and add specs for a comment in the body, plain text with no tags at all, and text before the first tag.

Reviewed by AI.

top-level blocks — splitContentForAds cannot cut a paragraph, list
or code block in half. */}
{!!post.contentHtml &&
splitContentForAds(post.contentHtml, BODY_CHARS_PER_AD).map(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Non-blocking: splitContentForAds runs on every render of this component — the whole article body re-scanned on each comment-sort change, hover state, auth resolution, etc. Wrap it in useMemo keyed on post.contentHtml.

Also worth confirming the per-chunk cost of Markdown: each instance runs its own DOMPurify pass and mounts its own HoverCard (a dynamic(ssr: false) import) plus the mention useQuery. A long article now instantiates that 5-8 times instead of once. If mention hovercards or image-modal delegation behave differently across chunk boundaries, that would show up here first — did you check a body with mentions and images on the preview?

Reviewed by AI.

/>
{index < chunks.length - 1 && (
<ArbitrageAdSlot
slot={ARBITRAGE_SLOT.inBodyMpu}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Non-blocking (analytics + polish): every in-body occurrence renders with the same slot number, so first-party events cannot distinguish the first in-body MPU from the sixth. Since the whole point of the cadence is finding where in-body inventory pays, consider passing the occurrence index through the log extra. It also means several elements share data-testid="adsense-slot-17".

Minor: this slot has no spacing class while the above-comments MPU gets my-6, and the surrounding <div className="mb-6"><div className="min-w-0 flex-1"> nesting above is left over from the removed flex row — it can collapse to one element now.

Reviewed by AI.

@@ -292,11 +283,12 @@ export function ArbitragePostContent({
post={post}
onCopyLinkClick={onCopyPostLink}
logOrigin={Origin.ArticlePage}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Non-blocking (doc drift): a few comments no longer match the code after this change and will mislead the next reader:

  • here: "The only addition is a native unit every few comments" — it is an MPU now.
  • the RAIL_AD docblock: "One slot per real rail widget, in render order... every widget there earns a unit" and the ~40% phone measurement, both written for the five-unit rail.
  • ArbitrageAdSlot's hideOnPhone doc still quotes the 56% figure from that layout.
  • refreshes is now only used by the top leaderboard; worth a look at whether the prop still earns its place on ArbitrageAdSlot.

Reviewed by AI.

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.

1 participant