Skip to content

Light and dark variants for every ad, picked automatically - #211

Merged
ralyodio merged 3 commits into
masterfrom
worktree-ad-theme-variants
Aug 19, 2026
Merged

ralyodio merged 3 commits into
masterfrom
worktree-ad-theme-variants

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

The bug

Every creative carried one palette, and the generator's prompt pushed it dark. On a black-on-white publisher page (a plain blog with no CSS) the unit rendered as a 40px bar of near black, sitting at the bottom of a white page like a hole punched in it. Nothing errored and the tests passed, so it was invisible until somebody ran the tag on a white site.

Found by installing the text_link unit on dev.profullstack.com/~anthony/blog, which is deliberately black on white.

What changed

Every creative now carries two palettes, and the tag works out which to ask for.

Detection (app/ad.js/route.ts). Walks up from the ad container for the first element with a painted background, converts it to relative luminance, and sends &theme=light|dark. If nothing paints a background anywhere, the answer is light, because that is what the browser actually paints. prefers-color-scheme deliberately does not decide this: a page with no CSS is white on a dark desktop too. data-theme="light|dark" on the unit overrides everything, which is the knob a publisher reaches for when the guess is wrong.

Generation (lib/ads/creative.ts). The model is asked for both trios and trusted for neither: each is checked for real contrast against the background it will sit on (4.5:1 text, 3:1 for the CTA chip) and a failing trio is replaced by a derived one. A wrong light palette is worse than a computed one, because it ships an unreadable ad to a real publisher.

Derivation (lib/ads/theme.ts). Hue is preserved, lightness and a little saturation move. An accent picked to glow on near black is almost always too pale to read on near white. This is the single source of colour maths for the renderer, the editor preview and the backfill, so a backfilled palette and a freshly generated one cannot disagree.

Fallback. paletteFor() derives a missing variant on the fly, so a creative that predates this renders correctly without waiting for the backfill. Slots also get a default polarity for surfaces that cannot measure anything: a MOTD over curl, a feed spliced at build time, a page with JS blocked.

Alpha in the editor. The colour pickers gained an opacity slider writing #rrggbbaa (with a checkerboard swatch so translucent reads as translucent). hexToRgba now multiplies alpha rather than replacing it, and inks that punch out of a chip go through solid() so a translucent background cannot make a CTA label see-through.

Hairlines. The hard-coded rgba(255,255,255,.08) border on every unit is now theme-aware. It was a white haze: invisible on a dark page and a grey smear on a light one.

Already applied to production

  • Migration 20260819090000_ad_theme_variants applied.
  • 678/678 creatives backfilled, 0 left without a light palette. Verified by md5 checksum of the distinct palette rows against the locally generated values: identical.
  • 28/28 slots given a default from their own site: 17 dark, 11 light, none undecided.

Three real bugs the backfill surfaced

Each is now a regression test:

  1. A neutral accent turned red. Flooring saturation invents a hue from an achromatic colour, so a grey #a3a3a3 CTA came out reddish. Greys now stay grey and get contrast from lightness alone.
  2. Layered backgrounds read the wrong layer. radial-gradient(..., var(--accent), ...), var(--bg) was read as the accent. CSS only permits a colour on the final layer, and the base is what a reader sees behind text.
  3. prefers-color-scheme blocks flipped the verdict. A light site with a dark mode was being read as dark, because the override won as "last declaration". Those blocks describe a particular visitor, not the site, and are now stripped when deciding a default.

Also added: CSS custom-property resolution, without which almost every modern stylesheet (body{background:var(--bg)}) reported "no declared background".

Heads-up before merge

114 creatives had a primary palette that was actually light. The backfill moved it into the light columns and derived a dark counterpart, which is correct once this deploys. Until it deploys, those 114 render in the derived dark palette, because the running code only reads bg_color. It self-corrects on deploy, so this is worth shipping promptly rather than sitting on the branch.

Verification

  • tsc --noEmit clean.
  • 1673 tests pass, 36 of them new. The 2 failures are pre-existing and unrelated: tracker-geo needs the GeoLite2 download, and ads-feed-item cannot resolve its deps because pnpm install is broken at HEAD (@profullstack/autoblog#75e54af no longer resolves upstream, and fast-xml-parser was never installed). Both fail on master too.
  • npm run lint is broken at HEAD independently of this change (next lint reads "lint" as a directory).

🤖 Generated with Claude Code

ralyodio and others added 3 commits August 19, 2026 08:57
Every creative carried one palette, and the generator's prompt pushed it
dark. On a black-on-white publisher page the unit rendered as a hole
punched in the page. Nothing errored, so it was invisible until somebody
ran the tag on a white site.

Creatives now carry two palettes and the tag works out which to ask for:

- ad.js walks up from the container for the first painted background,
  converts it to relative luminance and sends theme=light|dark. With
  nothing painted anywhere the answer is light, because that is what the
  browser paints. prefers-color-scheme deliberately does not decide it:
  a page with no CSS is white on a dark desktop too. data-theme on the
  unit overrides everything.
- The generator asks the model for both trios and trusts neither: each
  is checked for real contrast against the background it will sit on
  (4.5:1 text, 3:1 CTA) and a failing trio is replaced by a derived one.
- paletteFor() derives a missing variant on the fly, so a creative that
  predates this renders correctly without waiting for the backfill.
- Slots get a default polarity for surfaces that cannot measure: a MOTD
  over curl, a feed spliced at build time, a page with JS blocked.

Colour maths lives in lib/ads/theme.ts and is the single source for the
renderer, the editor preview and the backfill, so a backfilled palette
and a freshly generated one cannot disagree.

Also: the editor's colour pickers gained an opacity slider (#rrggbbaa,
with a checkerboard swatch), and inks that punch out of a chip now strip
alpha so a translucent background cannot make a CTA label see-through.

The hard-coded rgba(255,255,255,.08) hairline on every unit is now
theme-aware. It was a white haze: invisible on a dark page, a grey smear
on a light one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Renders one creative in both polarities into a single page, so the pair
can be eyeballed the way a publisher sees them rather than inferred from
a hex in a diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#210 set border-radius to 0 on the five ad container rules, in exactly
the lines this branch rewrote for light/dark. Resolved by keeping both:
the containers stay square, and the palette and hairline stay
theme-aware.

Also squared off the three container radii in the React <AdPreview>,
which #210 missed. That component exists to mirror renderCreativeHtml,
so a rounded preview of a square unit is the drift it is there to
prevent. Inner chrome (logo, monogram, CTA pill) keeps its radius, and
so does the feed card, which mocks a reader's own sheet rather than an
ad container in an iframe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

36 finding(s)

HIGH/CRITICAL: 3 | MEDIUM: 24 | 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 sql-template-interpolation scripts/detect-slot-themes.ts:31
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

Copy link
Copy Markdown
Contributor Author

Merged master in and resolved the conflicts with #210.

#210 set border-radius: 0 on the five ad container rules, which are exactly the lines this branch rewrote for light/dark. Both intents are kept: the containers stay square, and the palette and hairline stay theme-aware. No rgba(255,255,255,.08) hairline survives in creative.ts, house.ts or terminal.ts, and all five .cp-ad rules are at radius 0.

One thing #210 missed, fixed here: the React <AdPreview> still rounded its three unit containers at 8px. That component exists to mirror renderCreativeHtml, so a rounded preview of a square unit is precisely the drift it is there to prevent. Inner chrome (logo, monogram, CTA pill) keeps its radius, and so does the feed card, which mocks a reader's own sheet rather than an ad container in an iframe.

Worth noting for later: the corner bleed #210 fixed was the host page showing through a rounded ad — which is the same root cause this PR addresses from the other end. Once the unit's background matches the page it sits on, a rounded corner would bleed a colour that matches instead of white. The radius could go back if you want it; that is a look decision, so I left #210's call alone.

tsc --noEmit clean, 1673 tests pass locally, and all CI checks are green.

@ralyodio
ralyodio merged commit 00a67df into master Aug 19, 2026
8 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