Skip to content

Let the script-free ad frame ask the browser what colour it should be - #221

Merged
ralyodio merged 1 commit into
masterfrom
feat/ad-frame-auto-theme
Aug 29, 2026
Merged

Let the script-free ad frame ask the browser what colour it should be#221
ralyodio merged 1 commit into
masterfrom
feat/ad-frame-auto-theme

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

Fixes the light-theme case for /api/ads/frame, the script-free embed.

The problem

/ad.js walks up the DOM, finds the first painted background, and passes a concrete theme to serveAd. /api/ads/frame cannot do any of that — not running a script on the publisher's page is the entire point of it. So it rendered whatever the slot said, and a slot that says nothing renders dark.

A light publisher got a black bar punched into their page: the exact failure theme variants exist to prevent. Hit on d3vices.com, which embeds the frame precisely because its CSP allows one inline script by hash and ad.js would have needed 'unsafe-inline' in style-src site-wide.

The fix

The frame has one input it never used: the reader's own browser. prefers-color-scheme is answerable inside the frame without seeing the parent page, because the frame is a real document.

So 'auto' becomes a real rendering mode rather than a synonym for the default — the creative ships both palettes in one document and the media query picks — and it becomes the frame's default. One impression, no script, no client hint, no round trip.

What that required

No theme-dependent colour can be interpolated into the markup any more, or half the document would keep the base palette when the query fires. They all now go through a CSS custom property declared by themeStyle, which emits one :root block for a chosen theme and two for auto.

That covers the derived values, not just the palette — the hairline, the punch-out ink for the CTA chip, the over-image ink, the text shadow, the scrim and the brand wash move together or the unit comes apart at the seam. A test asserts no raw #rrggbb or rgba() survives anywhere after </style>, across every HTML format, with and without a hero image.

Light is the base, dark rides the media query — the same reasoning ad.js uses when it composites over white: a page with no styling of its own is white whatever the viewer's OS prefers.

Precedence

Request Slot Result
light / dark anything that — somebody measured or chose
absent light / dark the slot's — the publisher looked at their own page
auto light / dark the slot's
auto / absent auto / absent auto
absent absent dark, unchanged

resolveTheme keeps its old signature and contract for surfaces that cannot honour a media query — a MOTD over curl, a feed body in somebody's reader — and collapses auto to dark there. resolveThemePref is the new one.

/ad.js is untouched: it still detects and sends an explicit theme, which still wins.

Verified

  • npm run typecheck clean; full suite 1960 passed, 0 failed (11 new tests in tests/ads-auto-theme.test.ts).
  • Rendered the real documents in a headless browser under both prefers-color-scheme values. The auto units compute rgb(246,247,249) under light and rgb(11,13,16) under dark; the pinned light and dark sections are byte-identical in both. scripts/preview-themes.ts grows an auto row, which is how that was checked.

After deploy

d3vices.com needs no change — it already embeds /api/ads/frame, so it picks this up on deploy.

…ld be

`/api/ads/frame` is the embed for publishers who will not run our JavaScript —
a strict CSP, a Tor hidden service, a site that simply does not want a third
party's script. Not running a script also means it cannot do the one thing
`ad.js` does before it fills anything: read the publisher's background off the
DOM and pick a polarity from it.

So the frame rendered whatever the slot said, and a slot that says nothing
renders dark. A light publisher got a black bar punched into their page, which
is the exact failure theme variants were added to prevent. Nothing was broken;
the mechanism just had no input on this path.

It has one input it never used: the reader's own browser. `prefers-color-scheme`
inside the frame is answerable without seeing the parent page at all, because
the frame is a real document. This makes 'auto' a rendering mode rather than a
synonym for the default — the creative ships both palettes and the media query
picks — and makes it the frame's default.

Doing that means no theme-dependent colour can be interpolated into the markup
any more, or half the document would keep the base palette when the query fires.
Every one of them now goes through a CSS custom property declared by
`themeStyle`, which emits one `:root` block for a chosen theme and two for auto.
That covers the derived values as well as the palette: the hairline, the
punch-out ink for the CTA chip, the over-image ink, the text shadow, the scrim
and the brand wash all move together or the unit comes apart at the seam.

Light is the base and dark rides the media query, for the same reason `ad.js`
composites over white: a page with no styling of its own is white whatever the
viewer's OS prefers.

An explicit light or dark still wins from either side, since both mean somebody
looked — the tag measured the page, or a publisher set the slot. Surfaces that
cannot honour a media query at all, a MOTD over curl and a feed body in
somebody's reader, keep going through `resolveTheme` and collapse to dark.

The preview harness grows an auto row, which is how the pair above was checked.
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

40 finding(s)

HIGH/CRITICAL: 3 | MEDIUM: 28 | LOW: 9

Severity Rule Location
HIGH tls-verification-disabled lib/onion.ts:47
HIGH secret-generic-credential lib/sp/platforms/facebook.ts:32
HIGH sh-remote-script-execution prober/deploy/provision.sh:30
MEDIUM js-unescaped-html-sink app/(app)/dashboard/admin/email-broadcast/EmailBroadcastForm.tsx:125
MEDIUM js-unescaped-html-sink app/(app)/dashboard/projects/[id]/autoblog/articles/[articleId]/page.tsx:214
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:67
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:97
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:104
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:110
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:186
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:190
MEDIUM js-unescaped-html-sink app/c/[project]/[slug]/page.tsx:77
MEDIUM js-unescaped-html-sink app/c/[project]/page.tsx:57
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:228
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:285
MEDIUM js-unescaped-html-sink app/layout.tsx:129
MEDIUM js-open-redirect app/login/form.tsx:39
MEDIUM js-unescaped-html-sink app/r/[token]/page.tsx:176
MEDIUM js-open-redirect app/signup/form.tsx:43
MEDIUM js-open-redirect components/billing/buy-credits-modal.tsx:98
MEDIUM js-unescaped-html-sink components/json-ld.tsx:8
MEDIUM js-unescaped-html-sink components/report/markdown-view.tsx:15
MEDIUM redos-nested-quantifier lib/careers/jobs.ts:139
MEDIUM js-unescaped-html-sink lib/careers/page-templates.ts:198
MEDIUM redos-nested-quantifier lib/emailMarkdown.ts:130
MEDIUM redos-nested-quantifier lib/lx/articleGen.ts:98
MEDIUM redos-nested-quantifier lib/sp/parseHandle.ts:92
MEDIUM redos-nested-quantifier lib/tracker/agent-gate.ts:61
MEDIUM sql-template-interpolation scripts/detect-slot-themes.ts:31
MEDIUM sql-template-interpolation scripts/purge-constructed-keywords.ts:163
MEDIUM sql-template-interpolation scripts/purge-offniche-keywords.ts:124
LOW secret-generic-credential app/(marketing)/docs/autoblog-webhook/page.tsx:145
LOW secret-generic-credential lib/sp/platforms/linkedin.ts:25
LOW js-dynamic-code-execution tests/careers-page-templates.test.ts:21
LOW js-dynamic-code-execution tests/careers-widget-script.test.ts:19
LOW js-dynamic-code-execution tests/careers-widget-script.test.ts:69
LOW js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:51
LOW js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:52
LOW secret-generic-credential tests/contract/posthog-integration.test.ts:13
LOW secret-generic-credential tests/lead-campaign.test.ts:16

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit f858227 into master Aug 29, 2026
10 checks passed
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