From 62f460a8cf13207bb6a396d9235aa5db9325f736 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 19 Aug 2026 08:57:58 +0000 Subject: [PATCH 1/2] feat(ads): light and dark variants for every ad, picked automatically 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) --- .../dashboard/ads/[id]/edit/edit-form.tsx | 76 ++++- app/(app)/dashboard/ads/[id]/edit/page.tsx | 8 +- app/(app)/dashboard/ads/[id]/page.tsx | 8 +- app/(app)/dashboard/ads/new/form.tsx | 86 +++-- app/actions/ads.ts | 19 +- app/ad.js/route.ts | 47 +++ app/api/ads/serve/route.ts | 4 + components/ads/ad-preview.tsx | 57 ++-- components/ads/color-field.tsx | 90 +++++ lib/ads/creative.ts | 134 ++++++-- lib/ads/formats.ts | 68 +++- lib/ads/house.ts | 73 ++-- lib/ads/serve.ts | 55 ++- lib/ads/siteTheme.ts | 321 ++++++++++++++++++ lib/ads/terminal.ts | 20 +- lib/ads/theme.ts | 250 ++++++++++++++ scripts/backfill-ad-themes.ts | 244 +++++++++++++ scripts/detect-slot-themes.ts | 34 ++ scripts/emit-theme-backfill-sql.ts | 78 +++++ .../20260819090000_ad_theme_variants.sql | 43 +++ tests/ads-site-theme.test.ts | 114 +++++++ tests/ads-theme-variants.test.ts | 240 +++++++++++++ 22 files changed, 1926 insertions(+), 143 deletions(-) create mode 100644 components/ads/color-field.tsx create mode 100644 lib/ads/siteTheme.ts create mode 100644 lib/ads/theme.ts create mode 100644 scripts/backfill-ad-themes.ts create mode 100644 scripts/detect-slot-themes.ts create mode 100644 scripts/emit-theme-backfill-sql.ts create mode 100644 supabase/migrations/20260819090000_ad_theme_variants.sql create mode 100644 tests/ads-site-theme.test.ts create mode 100644 tests/ads-theme-variants.test.ts diff --git a/app/(app)/dashboard/ads/[id]/edit/edit-form.tsx b/app/(app)/dashboard/ads/[id]/edit/edit-form.tsx index d12006eb..bd13d86d 100644 --- a/app/(app)/dashboard/ads/[id]/edit/edit-form.tsx +++ b/app/(app)/dashboard/ads/[id]/edit/edit-form.tsx @@ -3,8 +3,10 @@ import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { updateCampaign, updateCreatives, uploadAdAsset } from "@/app/actions/ads"; -import { AD_FORMATS, type AdCreative, type AdFormatId } from "@/lib/ads/formats"; +import { AD_FORMATS, paletteFor, type AdCreative, type AdFormatId } from "@/lib/ads/formats"; import { AdPreview } from "@/components/ads/ad-preview"; +import { ColorField } from "@/components/ads/color-field"; +import type { AdTheme } from "@/lib/ads/theme"; type Campaign = { id: string; @@ -33,9 +35,17 @@ export function EditCampaignForm({ const [bid, setBid] = useState((campaign.bidCredits * 5) / 100); // credits → $ const [creatives, setCreatives] = useState(initial); const [active, setActive] = useState(initial[0]?.format ?? "banner_300x250"); + // Which polarity the previews show and the colour pickers edit. Publishers + // get whichever one matches their page, so both are editable here. + const [theme, setTheme] = useState("dark"); const [uploading, setUploading] = useState(false); const current = creatives.find((c) => c.format === active) ?? creatives[0]; + // What the pickers show: the stored trio for this theme, or the derived one + // when the creative predates theme variants and has no light trio yet. + const palette = current + ? paletteFor(current, theme) + : { bgColor: "#0b0d10", fgColor: "#e7e9ee", accentColor: "#6ee7b7" }; function patchActive(patch: Partial) { setCreatives((cs) => cs.map((c) => (c.format === active ? { ...c, ...patch } : c))); @@ -44,6 +54,28 @@ export function EditCampaignForm({ setCreatives((cs) => cs.map((c) => ({ ...c, ...patch }))); } + // Colours apply to every format at once (one brand, five sizes), and write to + // whichever trio the theme switch has selected. Editing the light trio for + // the first time seeds it from the derived palette, so a publisher never sees + // two of three colours change. + function patchPalette(key: "bg" | "fg" | "accent", v: string) { + if (theme === "dark") { + const field = key === "bg" ? "bgColor" : key === "fg" ? "fgColor" : "accentColor"; + return patchAll({ [field]: v } as Partial); + } + setCreatives((cs) => + cs.map((c) => { + const seed = paletteFor(c, "light"); + return { + ...c, + lightBgColor: key === "bg" ? v : (c.lightBgColor ?? seed.bgColor), + lightFgColor: key === "fg" ? v : (c.lightFgColor ?? seed.fgColor), + lightAccentColor: key === "accent" ? v : (c.lightAccentColor ?? seed.accentColor), + }; + }), + ); + } + function onUpload(kind: "logoUrl" | "imageUrl") { return async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; @@ -137,7 +169,7 @@ export function EditCampaignForm({ style={{ maxWidth: f.w > 360 ? 360 : f.w + 16 }} >
- +
); @@ -159,10 +191,31 @@ export function EditCampaignForm({ +
+ Theme + {(["dark", "light"] as AdTheme[]).map((t) => ( + + ))} + + Publishers are served whichever matches their page. + +
+
- patchAll({ bgColor: v })} /> - patchAll({ fgColor: v })} /> - patchAll({ accentColor: v })} /> + patchPalette("bg", v)} /> + patchPalette("fg", v)} alpha={false} /> + patchPalette("accent", v)} />
); } - -function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) { - return ( - - ); -} diff --git a/app/(app)/dashboard/ads/[id]/edit/page.tsx b/app/(app)/dashboard/ads/[id]/edit/page.tsx index 3090cf4f..d5326b92 100644 --- a/app/(app)/dashboard/ads/[id]/edit/page.tsx +++ b/app/(app)/dashboard/ads/[id]/edit/page.tsx @@ -17,6 +17,9 @@ type CreativeRow = { bg_color: string; fg_color: string; accent_color: string; + light_bg_color: string | null; + light_fg_color: string | null; + light_accent_color: string | null; font_family: string; }; @@ -42,7 +45,7 @@ export default async function EditCampaignPage({ const { data: creativeRows } = await supabase .from("ad_creatives") - .select("id, format, headline, body, cta_text, image_url, logo_url, bg_color, fg_color, accent_color, font_family") + .select("id, format, headline, body, cta_text, image_url, logo_url, bg_color, fg_color, accent_color, light_bg_color, light_fg_color, light_accent_color, font_family") .eq("campaign_id", id) .order("format"); @@ -56,6 +59,9 @@ export default async function EditCampaignPage({ bgColor: r.bg_color, fgColor: r.fg_color, accentColor: r.accent_color, + lightBgColor: r.light_bg_color, + lightFgColor: r.light_fg_color, + lightAccentColor: r.light_accent_color, fontFamily: r.font_family, logoUrl: r.logo_url, imageUrl: r.image_url, diff --git a/app/(app)/dashboard/ads/[id]/page.tsx b/app/(app)/dashboard/ads/[id]/page.tsx index 964e61cf..3f07c456 100644 --- a/app/(app)/dashboard/ads/[id]/page.tsx +++ b/app/(app)/dashboard/ads/[id]/page.tsx @@ -21,6 +21,9 @@ type CreativeRow = { bg_color: string; fg_color: string; accent_color: string; + light_bg_color: string | null; + light_fg_color: string | null; + light_accent_color: string | null; font_family: string; }; @@ -64,7 +67,7 @@ export default async function CampaignDetailPage({ supabase .from("ad_creatives") .select( - "id, format, headline, body, cta_text, image_url, logo_url, bg_color, fg_color, accent_color, font_family", + "id, format, headline, body, cta_text, image_url, logo_url, bg_color, fg_color, accent_color, light_bg_color, light_fg_color, light_accent_color, font_family", ) .eq("campaign_id", id) .order("format"), @@ -95,6 +98,9 @@ export default async function CampaignDetailPage({ bgColor: r.bg_color, fgColor: r.fg_color, accentColor: r.accent_color, + lightBgColor: r.light_bg_color, + lightFgColor: r.light_fg_color, + lightAccentColor: r.light_accent_color, fontFamily: r.font_family, logoUrl: r.logo_url, imageUrl: r.image_url, diff --git a/app/(app)/dashboard/ads/new/form.tsx b/app/(app)/dashboard/ads/new/form.tsx index 2f4153f4..f737486d 100644 --- a/app/(app)/dashboard/ads/new/form.tsx +++ b/app/(app)/dashboard/ads/new/form.tsx @@ -3,9 +3,11 @@ import { useState, useTransition, useRef } from "react"; import { useRouter } from "next/navigation"; import { previewAds, saveCampaign, uploadAdAsset } from "@/app/actions/ads"; -import { AD_FORMATS, type AdCreative, type AdFormatId } from "@/lib/ads/formats"; +import { AD_FORMATS, paletteFor, type AdCreative, type AdFormatId } from "@/lib/ads/formats"; import type { SiteBrand } from "@/lib/ads/brand"; import { AdPreview } from "@/components/ads/ad-preview"; +import { ColorField } from "@/components/ads/color-field"; +import type { AdTheme } from "@/lib/ads/theme"; function dollars(cents: number): string { return `$${(cents / 100).toFixed(2)}`; @@ -27,8 +29,13 @@ export function NewAdForm() { const [uploading, setUploading] = useState(false); const [error, setError] = useState(null); const fileRef = useRef(null); + // Which polarity the previews show and the colour pickers edit. + const [theme, setTheme] = useState("dark"); const current = creatives.find((c) => c.format === active) ?? creatives[0]; + const palette = current + ? paletteFor(current, theme) + : { bgColor: "#0b0d10", fgColor: "#e7e9ee", accentColor: "#6ee7b7" }; function generate(e: React.FormEvent) { e.preventDefault(); @@ -54,6 +61,27 @@ export function NewAdForm() { setCreatives((cs) => cs.map((c) => ({ ...c, ...patch }))); } + // Writes to whichever trio the theme switch has selected. Editing the light + // trio for the first time seeds the other two from the derived palette, so a + // publisher never sees two of three colours jump. + function patchPalette(key: "bg" | "fg" | "accent", v: string) { + if (theme === "dark") { + const field = key === "bg" ? "bgColor" : key === "fg" ? "fgColor" : "accentColor"; + return patchAll({ [field]: v } as Partial); + } + setCreatives((cs) => + cs.map((c) => { + const seed = paletteFor(c, "light"); + return { + ...c, + lightBgColor: key === "bg" ? v : (c.lightBgColor ?? seed.bgColor), + lightFgColor: key === "fg" ? v : (c.lightFgColor ?? seed.fgColor), + lightAccentColor: key === "accent" ? v : (c.lightAccentColor ?? seed.accentColor), + }; + }), + ); + } + function onUpload(kind: "logoUrl" | "imageUrl") { return async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; @@ -186,7 +214,7 @@ export function NewAdForm() { style={{ maxWidth: f.w > 360 ? 360 : f.w + 16 }} >
- +
{f.label} · {f.w}×{f.h} @@ -238,10 +266,31 @@ export function NewAdForm() {
+
+ Theme + {(["dark", "light"] as AdTheme[]).map((t) => ( + + ))} + + Publishers are served whichever matches their page. + +
+
- patchAll({ bgColor: v })} /> - patchAll({ fgColor: v })} /> - patchAll({ accentColor: v })} /> + patchPalette("bg", v)} /> + patchPalette("fg", v)} alpha={false} /> + patchPalette("accent", v)} /> Colours apply to all formats
@@ -310,30 +359,3 @@ export function NewAdForm() { ); } - -function ColorField({ - label, - value, - onChange, -}: { - label: string; - value: string; - onChange: (v: string) => void; -}) { - return ( - - ); -} diff --git a/app/actions/ads.ts b/app/actions/ads.ts index a0aef4a8..41753a1f 100644 --- a/app/actions/ads.ts +++ b/app/actions/ads.ts @@ -113,7 +113,8 @@ export async function previewAds(input: { url: string }): Promise< } const ALLOWED_FORMATS = new Set(AD_FORMAT_IDS); -const HEX = /^#[0-9a-fA-F]{6}$/; +// 6- or 8-digit: the editor's opacity slider writes #rrggbbaa. +const HEX = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; function cleanCreative(c: Partial): AdCreative | null { if (!c.format || !ALLOWED_FORMATS.has(c.format)) return null; @@ -125,6 +126,13 @@ function cleanCreative(c: Partial): AdCreative | null { bgColor: HEX.test(c.bgColor ?? "") ? c.bgColor! : "#0b0d10", fgColor: HEX.test(c.fgColor ?? "") ? c.fgColor! : "#e7e9ee", accentColor: HEX.test(c.accentColor ?? "") ? c.accentColor! : "#6ee7b7", + // Light trio is optional: null means "derive it at render time", which is + // what every creative predating theme variants does. An invalid value is + // dropped to null rather than defaulted, so a bad edit falls back to the + // derived palette instead of pinning a wrong colour. + lightBgColor: HEX.test(c.lightBgColor ?? "") ? c.lightBgColor! : null, + lightFgColor: HEX.test(c.lightFgColor ?? "") ? c.lightFgColor! : null, + lightAccentColor: HEX.test(c.lightAccentColor ?? "") ? c.lightAccentColor! : null, fontFamily: (c.fontFamily ?? "system-ui, sans-serif").slice(0, 200), logoUrl: c.logoUrl ?? null, imageUrl: c.imageUrl ?? null, @@ -233,6 +241,9 @@ export async function saveCampaign(input: { bg_color: c.bgColor, fg_color: c.fgColor, accent_color: c.accentColor, + light_bg_color: c.lightBgColor ?? null, + light_fg_color: c.lightFgColor ?? null, + light_accent_color: c.lightAccentColor ?? null, font_family: c.fontFamily, })); const { error: cErr } = await supabase.from("ad_creatives").insert(rows); @@ -310,6 +321,9 @@ export async function updateCreatives(input: { bg_color: cleaned.bgColor, fg_color: cleaned.fgColor, accent_color: cleaned.accentColor, + light_bg_color: cleaned.lightBgColor, + light_fg_color: cleaned.lightFgColor, + light_accent_color: cleaned.lightAccentColor, }) .eq("id", c.id) .eq("owner_id", user.id); @@ -389,6 +403,9 @@ export async function regenerateCampaign(input: { bg_color: c.bgColor, fg_color: c.fgColor, accent_color: c.accentColor, + light_bg_color: c.lightBgColor ?? null, + light_fg_color: c.lightFgColor ?? null, + light_accent_color: c.lightAccentColor ?? null, font_family: c.fontFamily, }; const existingId = idByFormat.get(c.format); diff --git a/app/ad.js/route.ts b/app/ad.js/route.ts index ea7dbfb2..e5466a35 100644 --- a/app/ad.js/route.ts +++ b/app/ad.js/route.ts @@ -28,6 +28,45 @@ ${VISITOR_SNIPPET} if (w >= 300) return 'banner_300x250'; return 'banner_320x50'; } + // --- theme detection ------------------------------------------------- + // Which polarity the unit should render in. A dark ad on a black-on-white + // blog reads as a hole punched in the page, so we measure rather than + // assume: the first ancestor with a real background colour wins. + function luma(c) { + var m = /rgba?\\(([^)]+)\\)/.exec(c || ''); + if (!m) return null; + var p = m[1].split(',').map(function (x) { return parseFloat(x); }); + if (p.length < 3) return null; + // Fully transparent tells us nothing about what the viewer sees. + if (p.length > 3 && p[3] === 0) return null; + var a = p.length > 3 ? p[3] : 1; + // Composite over white — an unstyled page is white, whatever the OS says. + var f = function (v) { var s = (v * a + 255 * (1 - a)) / 255; + return s <= 0.04045 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); }; + return 0.2126 * f(p[0]) + 0.7152 * f(p[1]) + 0.0722 * f(p[2]); + } + function detectTheme(el) { + // 1. An explicit data-theme on the unit always wins — this is the knob a + // publisher reaches for when our guess is wrong for their page. + var want = (el.getAttribute('data-theme') || '').toLowerCase(); + if (want === 'light' || want === 'dark') return want; + try { + // 2. Walk up for the first painted background. + for (var n = el; n && n.nodeType === 1; n = n.parentElement) { + var l = luma(getComputedStyle(n).backgroundColor); + if (l !== null) return l >= 0.5 ? 'light' : 'dark'; + } + // 3. Nothing painted anywhere — the browser is showing its own canvas. + // A page that opted into a dark canvas says so via color-scheme; + // otherwise what the viewer sees is white, regardless of their OS + // preference, so prefers-color-scheme must NOT decide this. + var cs = getComputedStyle(document.documentElement).colorScheme || ''; + if (cs.indexOf('dark') !== -1 && cs.indexOf('light') === -1) return 'dark'; + return 'light'; + } catch (_) { + return 'auto'; + } + } function fill(el) { if (el.getAttribute('data-cp-filled')) return; var slot = el.getAttribute('data-slot'); @@ -41,6 +80,8 @@ ${VISITOR_SNIPPET} // publisher reported every impression as an anonymous visitor. var v = getVisitorId(); if (v) q += '&v=' + encodeURIComponent(v); + var theme = detectTheme(el); + if (theme === 'light' || theme === 'dark') q += '&theme=' + theme; el.setAttribute('data-cp-filled', '1'); fetch(ORIGIN + '/api/ads/serve' + q, { mode: 'cors', credentials: 'omit', cache: 'no-store' }) .then(function(r){ return r.json(); }) @@ -88,6 +129,12 @@ ${VISITOR_SNIPPET} scan(); } // Expose a manual trigger for SPA/late-inserted slots. + // + // Deliberately NOT wired to a prefers-color-scheme listener: re-filling a + // unit is a fresh /api/ads/serve call, and serving meters an impression. + // Auto-refilling on every theme toggle would bill advertisers for ads + // nobody newly saw. A site with a live theme switcher can call scan() + // itself after clearing data-cp-filled, and pay the impression knowingly. window.crawlproofAds = { scan: scan }; } catch (_) {} })();`; diff --git a/app/api/ads/serve/route.ts b/app/api/ads/serve/route.ts index 44403f1b..d70e6c28 100644 --- a/app/api/ads/serve/route.ts +++ b/app/api/ads/serve/route.ts @@ -33,6 +33,9 @@ export async function GET(request: NextRequest) { const slotId = url.searchParams.get("slot"); const format = url.searchParams.get("format"); const visitorId = url.searchParams.get("v"); + // What the tag measured on the publisher's page. Anything other than + // light/dark (including absent) defers to the slot's stored default. + const theme = url.searchParams.get("theme"); if (!slotId || !isAdFormat(format)) { return NextResponse.json({ ok: false }, { status: 200, headers }); } @@ -46,6 +49,7 @@ export async function GET(request: NextRequest) { ip, country: geo?.countryCode ?? null, device, + theme, }); if (!fill) return NextResponse.json({ ok: false }, { status: 200, headers }); diff --git a/components/ads/ad-preview.tsx b/components/ads/ad-preview.tsx index d0974e53..2ea5c826 100644 --- a/components/ads/ad-preview.tsx +++ b/components/ads/ad-preview.tsx @@ -6,9 +6,11 @@ import { brandInitial, formatSpec, hexToRgba, + paletteFor, FEED_FORMAT_ID, TERMINAL_FORMAT_ID, } from "@/lib/ads/formats"; +import { hairline, overImageInk, overlayInk, solid, type AdTheme } from "@/lib/ads/theme"; import { renderCreativeText } from "@/lib/ads/terminal"; import { ATTRIBUTION, ctaLabel, DEFAULT_LABEL, oneLine } from "@/lib/ads/feeditem"; @@ -18,8 +20,19 @@ const PREVIEW_CLICK_URL = "https://crawlproof.com/a/00000000-0000-0000-0000-0000 // Live React mirror of renderCreativeHtml (lib/ads/creative.ts). Kept visually // in sync with the served HTML so the editor preview matches production. -export function AdPreview({ creative, scale = 1 }: { creative: AdCreative; scale?: number }) { +export function AdPreview({ + creative, + scale = 1, + theme = "dark", +}: { + creative: AdCreative; + scale?: number; + /** Polarity to preview in. Mirrors renderCreativeHtml's `theme` option. */ + theme?: AdTheme; +}) { const { w, h } = formatSpec(creative.format); + const p = paletteFor(creative, theme); + const edge = hairline(theme); // Terminal ad — render the exact ASCII the MOTD endpoint serves, monospaced. if (creative.format === TERMINAL_FORMAT_ID) { @@ -28,9 +41,9 @@ export function AdPreview({ creative, scale = 1 }: { creative: AdCreative; scale
             Sponsored
           
-          
+          
             {creative.headline}
           
           {creative.body && (
             
           )}
-          
+          
             {creative.ctaText} →
           
         
@@ -173,8 +186,8 @@ export function AdPreview({ creative, scale = 1 }: { creative: AdCreative; scale
         alignItems: "center",
         justifyContent: "center",
         borderRadius: 6,
-        background: creative.accentColor,
-        color: creative.bgColor,
+        background: p.accentColor,
+        color: solid(p.bgColor),
         fontWeight: 800,
         fontSize: Math.round(markSize * 0.55),
         lineHeight: 1,
@@ -187,8 +200,8 @@ export function AdPreview({ creative, scale = 1 }: { creative: AdCreative; scale
   const cta = (
     
   );
 
-  const heroText = row ? creative.fgColor : creative.imageUrl ? "#f4f7fb" : creative.fgColor;
+  const heroText = row ? p.fgColor : creative.imageUrl ? overImageInk(theme) : p.fgColor;
   const text = (
     
diff --git a/components/ads/color-field.tsx b/components/ads/color-field.tsx new file mode 100644 index 00000000..66e1c04f --- /dev/null +++ b/components/ads/color-field.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { parseColor, toHex } from "@/lib/ads/theme"; + +// A colour picker with an alpha channel. +// +// `` is RGB-only in every browser — it silently drops the +// alpha of a #rrggbbaa value and hands back 6 digits. So the swatch edits the +// hue and a companion slider edits opacity, and the two are recombined into an +// 8-digit hex. Fully opaque colours stay 6-digit, so nothing that never touches +// the slider changes shape in the database. + +const OPAQUE = 1; + +function withAlpha(hex: string, alpha: number): string { + const c = parseColor(hex); + if (!c) return hex; + return toHex({ ...c, a: alpha }); +} + +function alphaOf(hex: string): number { + return parseColor(hex)?.a ?? OPAQUE; +} + +/** The 6-digit form, which is all `` will accept. */ +function rgbOf(hex: string): string { + const c = parseColor(hex); + return c ? toHex({ ...c, a: OPAQUE }) : "#000000"; +} + +export function ColorField({ + label, + value, + onChange, + alpha = true, +}: { + label: string; + value: string; + onChange: (v: string) => void; + /** Hide the opacity slider where a translucent value would make no sense. */ + alpha?: boolean; +}) { + const a = alphaOf(value); + + return ( + + ); +} diff --git a/lib/ads/creative.ts b/lib/ads/creative.ts index df3d47cb..0eaab38c 100644 --- a/lib/ads/creative.ts +++ b/lib/ads/creative.ts @@ -18,13 +18,25 @@ import { brandInitial, formatSpec, hexToRgba, + paletteFor, type AdCreative, type AdFormatId, } from "./formats"; +import { + contrastRatio, + derivePalette, + hairline, + overImageInk, + overlayInk, + solid, + themeOfBackground, + type AdPalette, + type AdTheme, +} from "./theme"; // Re-export the client-safe format primitives so existing server importers of // this module keep working; client components should import from ./formats. -export { AD_FORMATS, AD_FORMAT_IDS, formatSpec }; +export { AD_FORMATS, AD_FORMAT_IDS, formatSpec, paletteFor }; export { renderCreativeText, renderTerminalHtml }; export type { AdCreative, AdFormatId }; @@ -35,7 +47,9 @@ export type { AdCreative, AdFormatId }; const CLAUDE_MODEL = "claude-sonnet-5"; const OPENAI_MODEL = "gpt-5-mini"; -const HEX = /^#([0-9a-fA-F]{6})$/; +// 6- or 8-digit: the editor's alpha slider writes #rrggbbaa, and a creative +// saved with a translucent wash must survive a regeneration round-trip. +const HEX = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; function safeHex(v: string | undefined, fallback: string): string { return v && HEX.test(v.trim()) ? v.trim().toLowerCase() : fallback; } @@ -60,9 +74,20 @@ const CopySchema = z.object({ .string() .max(18) .describe("Button label, e.g. 'Try it free', 'Get started', 'Learn more'."), - bgColor: z.string().describe("Background hex like #0b0d10 — on-brand, good contrast with fg."), - fgColor: z.string().describe("Text hex with strong contrast against bg."), - accentColor: z.string().describe("Accent/CTA hex — the brand's signature colour if visible."), + bgColor: z.string().describe("DARK-mode background hex like #0b0d10 — on-brand, good contrast with fgColor."), + fgColor: z.string().describe("DARK-mode text hex with strong contrast against bgColor."), + accentColor: z.string().describe("DARK-mode accent/CTA hex — the brand's signature colour if visible."), + lightBgColor: z + .string() + .describe("LIGHT-mode background hex — near-white (e.g. #f7f9fc), same brand hue family as bgColor."), + lightFgColor: z + .string() + .describe("LIGHT-mode text hex — near-black, strong contrast against lightBgColor."), + lightAccentColor: z + .string() + .describe( + "LIGHT-mode accent/CTA hex — the same brand hue as accentColor, deepened so it reads on near-white.", + ), // The two prose lengths. Everything above is display copy sized for a box; // these are for placements that sit *inside* somebody's writing, where the ad // is read rather than glanced at. @@ -129,11 +154,15 @@ const SYSTEM_PROMPT = [ "You are a senior performance-marketing copywriter and brand designer.", "Given a company's website content and detected brand colours, write a single", "display-ad concept: a headline, a tiny mobile headline, one benefit line, a CTA,", - "and an on-brand colour trio (background, foreground, accent).", + "and TWO on-brand colour trios (background, foreground, accent): one for dark", + "publisher pages and one for light ones.", "Rules: infer voice and value proposition ONLY from the provided content — never", "invent features, prices, or claims. Keep it concrete and specific to this product.", "Colours must be readable: high contrast between background and foreground.", "Prefer the site's real brand/accent colour when the palette makes it obvious.", + "The two trios must share the same brand hues — the light one is not a different", + "design, it is the same ad on a near-white card. Do not merely invert the hex:", + "an accent chosen to glow on near-black needs deepening to read on near-white.", ...SUMMARY_RULES, ].join(" "); @@ -156,10 +185,30 @@ function copyToCreatives(brand: SiteBrand, copy: AdCopy, heroUrl: string | null) const bg = safeHex(copy.bgColor, brand.themeColor && HEX.test(brand.themeColor) ? brand.themeColor : "#0b0d10"); const fg = safeHex(copy.fgColor, "#e7e9ee"); const accent = safeHex(copy.accentColor, brand.palette[0] ?? "#6ee7b7"); + + // The model is asked for a light trio too, but it is the least-constrained + // thing in the schema and the easiest for a small model to fumble — so every + // value is validated against the background it will actually sit on, and a + // trio that fails is replaced by the derived one. A wrong light palette is + // worse than a computed one: it ships an unreadable ad to a real publisher. + const derivedLight = derivePalette({ bgColor: bg, fgColor: fg, accentColor: accent }, "light"); + const lightBg = safeHex(copy.lightBgColor, derivedLight.bgColor); + const lightFg = safeHex(copy.lightFgColor, derivedLight.fgColor); + const lightAccent = safeHex(copy.lightAccentColor, derivedLight.accentColor); + const light: AdPalette = + themeOfBackground(lightBg) === "light" && + contrastRatio(lightFg, lightBg) >= 4.5 && + contrastRatio(lightAccent, lightBg) >= 3 + ? { bgColor: lightBg, fgColor: lightFg, accentColor: lightAccent } + : derivedLight; + const base = { bgColor: bg, fgColor: fg, accentColor: accent, + lightBgColor: light.bgColor, + lightFgColor: light.fgColor, + lightAccentColor: light.accentColor, fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif", logoUrl: brand.logoUrl, imageUrl: heroUrl, @@ -428,24 +477,41 @@ function esc(s: string): string { // The brand mark: a real logo when we have one, otherwise an accent-tinted // monogram tile. Never renders empty. Sandboxed served ads can't run JS, so we // only show the when the URL was verified at generation time. -function markHtml(creative: AdCreative, size: number): string { +function markHtml(creative: AdCreative, size: number, p: AdPalette): string { if (creative.logoUrl) { return ``; } const fs = Math.round(size * 0.55); - return `${esc(brandInitial(creative.headline))}`; + // The monogram punches out of the accent tile, so the ink must be opaque even + // when the advertiser gave the background an alpha wash. + return `${esc(brandInitial(creative.headline))}`; } +export type RenderOptions = { + /** + * Polarity of the page the unit will sit on. Defaults to dark, which is what + * every creative rendered as before theme variants existed. + */ + theme?: AdTheme; +}; + // Self-contained HTML for a creative — used by the served ad unit inside an // isolated iframe (and mirrored by the React ). clickUrl is the // destination with ?ref= already applied. -export function renderCreativeHtml(creative: AdCreative, clickUrl: string): string { +export function renderCreativeHtml( + creative: AdCreative, + clickUrl: string, + opts: RenderOptions = {}, +): string { const { w, h } = formatSpec(creative.format); + const theme: AdTheme = opts.theme ?? "dark"; + const p = paletteFor(creative, theme); + const edge = hairline(theme); // Terminal ad — the ASCII artwork in a
, so the same creative can also
   // fill a web slot. The canonical delivery is /api/ads/motd (text/plain).
   if (creative.format === "terminal_ascii") {
-    return renderTerminalHtml(creative, clickUrl);
+    return renderTerminalHtml(creative, clickUrl, { theme });
   }
 
   // Feed ad — the sponsored line as it will appear inside somebody's reader.
@@ -456,32 +522,32 @@ export function renderCreativeHtml(creative: AdCreative, clickUrl: string): stri
   // own stylesheet rather than by ours.
   if (creative.format === "feed_item") {
     return `${renderFeedHtml(creative, clickUrl)}`;
   }
 
   // Native text link — a borderless, full-width single line. No image/box.
   if (creative.format === "text_link") {
     const body = creative.body
-      ? `— ${esc(creative.body)}`
+      ? `— ${esc(creative.body)}`
       : "";
     return `
       
-        Sponsored
-        ${esc(creative.headline)}
+        Sponsored
+        ${esc(creative.headline)}
         ${body}
-        ${esc(creative.ctaText)} →
+        ${esc(creative.ctaText)} →
       
     `;
   }
@@ -490,12 +556,15 @@ export function renderCreativeHtml(creative: AdCreative, clickUrl: string): stri
   const isMobile = creative.format === "banner_320x50";
   const row = isLeaderboard || isMobile;
   const showBody = !isMobile;
-  const mark = markHtml(creative, isMobile ? 20 : 28);
-  const cta = `${esc(creative.ctaText)}`;
-
-  // On the rectangle a hero image reads best full-bleed with a bottom gradient
-  // (matches the house ad); text colour stays readable over it.
-  const heroText = row ? creative.fgColor : creative.imageUrl ? "#f4f7fb" : creative.fgColor;
+  const mark = markHtml(creative, isMobile ? 20 : 28, p);
+  // The CTA label punches out of the accent chip, so it takes the background as
+  // opaque ink — an alpha wash there would make the label see-through.
+  const cta = `${esc(creative.ctaText)}`;
+
+  // On the rectangle a hero image reads best full-bleed with a gradient
+  // (matches the house ad); text over it takes the theme's over-image ink,
+  // which is the side the gradient is mixed from.
+  const heroText = row ? p.fgColor : creative.imageUrl ? overImageInk(theme) : p.fgColor;
   const text = `
     
${esc(creative.headline)}
@@ -512,22 +581,27 @@ export function renderCreativeHtml(creative: AdCreative, clickUrl: string): stri // Rectangle background: hero image + readability gradient, or a subtle // accent-tinted brand wash so the middle is never a dead flat block. + // + // The gradient is mixed from the theme's own ink rather than the creative's + // background: a light unit needs to fade the image to white, and reusing an + // alpha-washed background here would leave the headline sitting on raw photo. + const scrim = overlayInk(theme); const rectBg = creative.imageUrl ? `
-
` +
` : ""; const bg = row - ? creative.bgColor + ? p.bgColor : creative.imageUrl - ? creative.bgColor - : `radial-gradient(120% 80% at 100% 0%, ${hexToRgba(creative.accentColor, 0.18)} 0%, ${hexToRgba(creative.bgColor, 0)} 60%), ${creative.bgColor}`; + ? p.bgColor + : `radial-gradient(120% 80% at 100% 0%, ${hexToRgba(p.accentColor, 0.18)} 0%, ${hexToRgba(p.bgColor, 0)} 60%), ${p.bgColor}`; return ` ${rectBg}${inner} `; diff --git a/lib/ads/formats.ts b/lib/ads/formats.ts index 19d250ab..afa2a3b8 100644 --- a/lib/ads/formats.ts +++ b/lib/ads/formats.ts @@ -3,6 +3,14 @@ // import format metadata without dragging the whole creative pipeline — and // the Tor/socks stack — into the browser bundle. +import { + derivePalette, + parseColor, + themeOfBackground, + type AdPalette, + type AdTheme, +} from "./theme"; + export const AD_FORMATS = [ { id: "banner_300x250", label: "Medium Rectangle", w: 300, h: 250 }, { id: "banner_728x90", label: "Leaderboard", w: 728, h: 90 }, @@ -78,13 +86,18 @@ export function brandInitial(s: string | null | undefined): string { return m ? m[0].toUpperCase() : "★"; } -// #rrggbb → rgba(). Used for the image overlay and the no-image brand tint so -// the medium-rectangle never renders as a dead flat block. +// #rrggbb (or #rrggbbaa) → rgba(). Used for the image overlay and the no-image +// brand tint so the medium-rectangle never renders as a dead flat block. +// +// `a` MULTIPLIES the colour's own alpha rather than replacing it, so a +// half-transparent brand colour stays half-transparent when it is asked for at +// 18%. Call sites that need an opaque ink should pass the colour through +// `solid()` first. export function hexToRgba(hex: string, a: number): string { - const m = /^#?([0-9a-f]{6})$/i.exec((hex ?? "").trim()); - if (!m) return `rgba(7,10,16,${a})`; - const n = parseInt(m[1], 16); - return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`; + const c = parseColor(hex); + if (!c) return `rgba(7,10,16,${a})`; + const alpha = Math.round(c.a * a * 1000) / 1000; + return `rgba(${c.r},${c.g},${c.b},${alpha})`; } export type AdCreative = { @@ -92,10 +105,53 @@ export type AdCreative = { headline: string; body: string; ctaText: string; + /** + * The advertiser's primary palette. Historically — and still, for every + * creative that predates theme variants — this is the dark-page palette. + */ bgColor: string; fgColor: string; accentColor: string; + /** + * Counterpart palette for light publisher pages. Null on creatives that + * predate theme variants; `paletteFor()` derives one on the fly so a missing + * variant is a quality difference, never a broken render. + */ + lightBgColor?: string | null; + lightFgColor?: string | null; + lightAccentColor?: string | null; fontFamily: string; logoUrl: string | null; imageUrl: string | null; }; + +/** + * The colour trio to render this creative with on a page of `theme`. + * + * Light is stored when we have it and derived when we don't. Dark reads the + * primary trio — unless the advertiser picked a light background there, in + * which case rendering it on a dark page would be the very glare this feature + * exists to remove, so it gets derived too. + */ +export function paletteFor(creative: AdCreative, theme: AdTheme): AdPalette { + const primary: AdPalette = { + bgColor: creative.bgColor, + fgColor: creative.fgColor, + accentColor: creative.accentColor, + }; + + if (theme === "light") { + if (creative.lightBgColor && creative.lightFgColor && creative.lightAccentColor) { + return { + bgColor: creative.lightBgColor, + fgColor: creative.lightFgColor, + accentColor: creative.lightAccentColor, + }; + } + return themeOfBackground(primary.bgColor) === "light" + ? primary + : derivePalette(primary, "light"); + } + + return themeOfBackground(primary.bgColor) === "dark" ? primary : derivePalette(primary, "dark"); +} diff --git a/lib/ads/house.ts b/lib/ads/house.ts index 9f9812f7..2e472648 100644 --- a/lib/ads/house.ts +++ b/lib/ads/house.ts @@ -1,6 +1,8 @@ import crypto from "node:crypto"; import { env } from "@/lib/env"; import { formatSpec, type AdCreative, type AdFormatId } from "./creative"; +import { hexToRgba } from "./formats"; +import { hairline, overImageInk, overlayInk, solid, type AdPalette, type AdTheme } from "./theme"; import { FEED_FORMAT_ID, TERMINAL_FORMAT_ID } from "./formats"; import { renderCreativeText, renderTerminalHtml } from "./terminal"; import { renderFeedHtml } from "./feeditem"; @@ -106,14 +108,22 @@ export function renderHouseAdHtml( format: AdFormatId, clickUrl: string, copy: HouseCopy = pickHouse(), + theme: AdTheme = "dark", ): string { const { w, h } = formatSpec(format); const HOUSE = copy; + const p = housePalette(theme); + const edge = hairline(theme); + // Muted ink for the small-print label and the body line, derived from the + // theme's foreground rather than hard-coded — the old #9fb0c3 / #c7d2de pair + // were mid-greys tuned for near-black and vanished on a light card. + const muted = hexToRgba(p.fgColor, 0.68); + const bodyInk = hexToRgba(p.fgColor, 0.82); // Terminal ad — same ASCII artwork the /api/ads/motd endpoint serves, in a //
 for the web/iframe paths.
   if (format === TERMINAL_FORMAT_ID) {
-    return renderTerminalHtml(houseCreative(format, copy), clickUrl);
+    return renderTerminalHtml(houseCreative(format, copy), clickUrl, { theme });
   }
 
   // Feed ad — the same sponsored line the /api/ads/feed body carries, so an
@@ -127,15 +137,15 @@ export function renderHouseAdHtml(
     return `
       
-        CrawlProof Ads
-        ${esc(HOUSE.headline)}
-        — ${esc(HOUSE.body)}
-        ${esc(HOUSE.cta)}
+        CrawlProof Ads
+        ${esc(HOUSE.headline)}
+        — ${esc(HOUSE.body)}
+        ${esc(HOUSE.cta)}
       
     `;
   }
@@ -145,12 +155,17 @@ export function renderHouseAdHtml(
   const isRect = format === "banner_300x250";
   const row = !isRect; // leaderboard + mobile are horizontal
 
-  const label = `CrawlProof Ads`;
-  const headline = `
${esc(HOUSE.headline)}
`; + // Over the hero image the ink is the theme's over-image colour, not the + // palette foreground: the scrim below is mixed from the same side, so on a + // light unit the artwork fades to white and the copy has to go dark. + const ink = overImageInk(theme); + const inkMuted = hexToRgba(ink, 0.78); + const label = `CrawlProof Ads`; + const headline = `
${esc(HOUSE.headline)}
`; const body = isMobile ? "" - : `
${esc(HOUSE.body)}
`; - const cta = `${esc(HOUSE.cta)}`; + : `
${esc(HOUSE.body)}
`; + const cta = `${esc(HOUSE.cta)}`; const content = row ? `
@@ -162,15 +177,16 @@ export function renderHouseAdHtml(
${cta}
`; + const scrim = overlayInk(theme); const overlay = isRect - ? "linear-gradient(180deg, rgba(7,10,16,.12) 0%, rgba(7,10,16,.86) 76%)" - : "linear-gradient(90deg, rgba(7,10,16,.92) 0%, rgba(7,10,16,.5) 62%, rgba(7,10,16,.2) 100%)"; + ? `linear-gradient(180deg, ${hexToRgba(scrim, 0.12)} 0%, ${hexToRgba(scrim, 0.86)} 76%)` + : `linear-gradient(90deg, ${hexToRgba(scrim, 0.92)} 0%, ${hexToRgba(scrim, 0.5)} 62%, ${hexToRgba(scrim, 0.2)} 100%)`; return `
${esc(artwork)}
`; diff --git a/lib/ads/theme.ts b/lib/ads/theme.ts new file mode 100644 index 00000000..6610c298 --- /dev/null +++ b/lib/ads/theme.ts @@ -0,0 +1,250 @@ +// Light/dark palettes for ad creatives. +// +// Every creative carries two colour trios: the one the advertiser picked, and +// a counterpart for publisher pages of the opposite polarity. A single palette +// cannot work everywhere — a dark unit on a plain black-on-white blog reads as +// a hole punched in the page, and a light unit on a dark dashboard glares. +// +// Pure and client-safe: no I/O, no server-only imports. The renderer, the +// editor preview and the backfill script all derive colours through here, so a +// backfilled palette and a freshly generated one can never disagree. + +export type AdTheme = "light" | "dark"; + +/** What a slot or a request may ask for. 'auto' means "work it out". */ +export type AdThemePref = AdTheme | "auto"; + +export type AdPalette = { + bgColor: string; + fgColor: string; + accentColor: string; +}; + +export type Rgba = { r: number; g: number; b: number; a: number }; + +export const AD_THEMES: AdTheme[] = ["light", "dark"]; + +const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n)); +const clamp255 = (n: number) => clamp(Math.round(n), 0, 255); + +export function isAdTheme(v: string | null | undefined): v is AdTheme { + return v === "light" || v === "dark"; +} + +export function isAdThemePref(v: string | null | undefined): v is AdThemePref { + return isAdTheme(v) || v === "auto"; +} + +/** + * Parse #rgb, #rgba, #rrggbb or #rrggbbaa. + * + * The 4- and 8-digit forms are what the editor's alpha slider writes: CSS has + * understood them since 2017 and they survive a `text` column untouched, which + * an `rgba()` string would not — every call site here expects a hex. + */ +export function parseColor(input: string | null | undefined): Rgba | null { + const s = String(input ?? "") + .trim() + .replace(/^#/, ""); + if (!/^[0-9a-f]+$/i.test(s)) return null; + const hex = + s.length === 3 || s.length === 4 + ? s + .split("") + .map((c) => c + c) + .join("") + : s; + if (hex.length !== 6 && hex.length !== 8) return null; + const n = parseInt(hex.slice(0, 6), 16); + const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1; + return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255, a }; +} + +/** Back to #rrggbb, or #rrggbbaa when the colour is not fully opaque. */ +export function toHex({ r, g, b, a }: Rgba): string { + const two = (n: number) => clamp255(n).toString(16).padStart(2, "0"); + const base = `#${two(r)}${two(g)}${two(b)}`; + return a >= 1 ? base : `${base}${two(a * 255)}`; +} + +/** True when the colour carries an alpha channel below 1. */ +export function hasAlpha(color: string): boolean { + const c = parseColor(color); + return !!c && c.a < 1; +} + +/** + * Strip alpha. + * + * Used where a colour is being borrowed as *ink* — the label inside a CTA chip + * takes the background colour so it punches out of the accent. A translucent + * background there would render the label see-through over the chip. + */ +export function solid(color: string): string { + const c = parseColor(color); + return c ? toHex({ ...c, a: 1 }) : color; +} + +const overWhite = (v: number, a: number) => v * a + 255 * (1 - a); + +/** Composite a translucent colour over white — what a bare page actually shows. */ +export function flatten(color: string): string { + const c = parseColor(color); + if (!c) return color; + if (c.a >= 1) return toHex(c); + return toHex({ r: overWhite(c.r, c.a), g: overWhite(c.g, c.a), b: overWhite(c.b, c.a), a: 1 }); +} + +/** WCAG relative luminance, 0 (black) → 1 (white). Alpha is flattened first. */ +export function luminance(color: string): number { + const c = parseColor(flatten(color)); + if (!c) return 0; + const lin = (v: number) => { + const s = v / 255; + return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * lin(c.r) + 0.7152 * lin(c.g) + 0.0722 * lin(c.b); +} + +/** WCAG contrast ratio between two colours, 1 → 21. */ +export function contrastRatio(a: string, b: string): number { + const la = luminance(a); + const lb = luminance(b); + return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); +} + +/** + * Which theme a background colour belongs to. + * + * A translucent background is composited over white before judging: that is + * what a viewer actually sees on a page with no CSS of its own, which is the + * exact case this whole feature exists for. + */ +export function themeOfBackground(color: string): AdTheme { + if (!parseColor(color)) return "dark"; + return luminance(color) >= 0.5 ? "light" : "dark"; +} + +// --------------------------------------------------------------------------- +// HSL, for deriving a counterpart palette that keeps the brand's hue +// --------------------------------------------------------------------------- + +export type Hsl = { h: number; s: number; l: number; a: number }; + +export function toHsl(color: string): Hsl { + const c = parseColor(color) ?? { r: 0, g: 0, b: 0, a: 1 }; + const r = c.r / 255; + const g = c.g / 255; + const b = c.b / 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const d = max - min; + const l = (max + min) / 2; + let h = 0; + let s = 0; + if (d !== 0) { + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6; + else if (max === g) h = ((b - r) / d + 2) / 6; + else h = ((r - g) / d + 4) / 6; + } + return { h, s, l, a: c.a }; +} + +export function fromHsl({ h, s, l, a }: Hsl): string { + const sat = clamp(s, 0, 1); + const lig = clamp(l, 0, 1); + const q = lig < 0.5 ? lig * (1 + sat) : lig + sat - lig * sat; + const p = 2 * lig - q; + const channel = (t: number) => { + let x = t; + if (x < 0) x += 1; + if (x > 1) x -= 1; + if (x < 1 / 6) return p + (q - p) * 6 * x; + if (x < 1 / 2) return q; + if (x < 2 / 3) return p + (q - p) * (2 / 3 - x) * 6; + return p; + }; + const r = sat === 0 ? lig : channel(h + 1 / 3); + const g = sat === 0 ? lig : channel(h); + const b = sat === 0 ? lig : channel(h - 1 / 3); + return toHex({ r: r * 255, g: g * 255, b: b * 255, a: clamp(a, 0, 1) }); +} + +/** Push a colour's lightness until it clears `min` contrast against `against`. */ +export function ensureContrast(color: string, against: string, min = 4.5): string { + if (contrastRatio(color, against) >= min) return color; + const src = toHsl(color); + // Move away from the background: darken on light, lighten on dark. + const dir = themeOfBackground(against) === "light" ? -1 : 1; + for (let step = 1; step <= 20; step++) { + const candidate = fromHsl({ ...src, l: clamp(src.l + dir * step * 0.05, 0, 1) }); + if (contrastRatio(candidate, against) >= min) return candidate; + } + // Nothing in this hue worked — plain ink always clears it. + return themeOfBackground(against) === "light" ? "#111418" : "#f5f7fa"; +} + +/** + * Build the counterpart palette for `target`, keeping the brand's hues. + * + * Only lightness and a little saturation move: an advertiser's teal stays teal + * in both themes, it just stops being a neon on white. The accent needs the + * most care — a colour chosen to glow on near-black is almost always too pale + * to read as a CTA on near-white, so it is clamped into a band that works and + * then contrast-checked. + */ +export function derivePalette(source: AdPalette, target: AdTheme): AdPalette { + const bgSrc = toHsl(source.bgColor); + const fgSrc = toHsl(source.fgColor); + const accentSrc = toHsl(source.accentColor); + + const bg = + target === "light" + ? fromHsl({ ...bgSrc, s: Math.min(bgSrc.s, 0.18), l: 0.97, a: bgSrc.a }) + : fromHsl({ ...bgSrc, s: Math.min(bgSrc.s, 0.35), l: 0.06, a: bgSrc.a }); + + const fg = + target === "light" + ? fromHsl({ ...fgSrc, s: Math.min(fgSrc.s, 0.25), l: 0.13, a: fgSrc.a }) + : fromHsl({ ...fgSrc, s: Math.min(fgSrc.s, 0.2), l: 0.93, a: fgSrc.a }); + + // Saturation is floored so a washed-out accent still reads as a CTA — but + // only when there is a hue to preserve. An achromatic accent (a grey, a + // white) has an arbitrary hue of 0, and forcing saturation onto it invents a + // red the advertiser never chose. Greys stay grey and get their contrast + // from lightness alone. + const accentSat = accentSrc.s < 0.12 ? accentSrc.s : Math.max(accentSrc.s, 0.45); + const accent = + target === "light" + ? fromHsl({ ...accentSrc, s: accentSat, l: clamp(accentSrc.l, 0.28, 0.46), a: accentSrc.a }) + : fromHsl({ ...accentSrc, s: accentSat, l: clamp(accentSrc.l, 0.55, 0.72), a: accentSrc.a }); + + return { + bgColor: bg, + fgColor: ensureContrast(fg, bg), + // 3:1 is the WCAG threshold for large text and UI components, which is + // exactly what a CTA chip is. + accentColor: ensureContrast(accent, bg, 3), + }; +} + +/** + * Hairline border for a unit on `theme`. + * + * Every creative used to draw `rgba(255,255,255,.08)` — a white haze that is + * invisible against a dark page and a grey smear against a light one. + */ +export function hairline(theme: AdTheme): string { + return theme === "light" ? "rgba(0,0,0,.12)" : "rgba(255,255,255,.08)"; +} + +/** The ink a readability gradient over hero imagery should be mixed from. */ +export function overlayInk(theme: AdTheme): string { + return theme === "light" ? "#ffffff" : "#070a10"; +} + +/** Text colour that sits legibly on top of hero imagery for this theme. */ +export function overImageInk(theme: AdTheme): string { + return theme === "light" ? "#10151b" : "#f4f7fb"; +} diff --git a/scripts/backfill-ad-themes.ts b/scripts/backfill-ad-themes.ts new file mode 100644 index 00000000..c94f589f --- /dev/null +++ b/scripts/backfill-ad-themes.ts @@ -0,0 +1,244 @@ +#!/usr/bin/env -S npx tsx +// +// Backfill light/dark variants for every ad creative, and a default polarity +// for every ad slot. No human in the loop: colours are derived from the +// creative's own palette, and each slot's default is read off the publisher's +// live site. +// +// Usage: +// npx tsx scripts/backfill-ad-themes.ts --dry-run # show, change nothing +// npx tsx scripts/backfill-ad-themes.ts # creatives + slots +// npx tsx scripts/backfill-ad-themes.ts --creatives # just creatives +// npx tsx scripts/backfill-ad-themes.ts --slots # just slots +// npx tsx scripts/backfill-ad-themes.ts --slots --force # re-decide slots +// already set +// +// Idempotent. Creatives that already carry a light trio are skipped, and so +// are slots whose theme is not 'auto' (unless --force). Safe to re-run after +// adding advertisers. +// +// Colours come from lib/ads/theme.ts — the same module the renderer uses — so +// a backfilled palette and a freshly generated one can never disagree. + +import { readFileSync } from "node:fs"; +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import { derivePalette, themeOfBackground, type AdPalette } from "../lib/ads/theme"; +import { detectSiteTheme } from "../lib/ads/siteTheme"; + +const argv = process.argv.slice(2); +const DRY = argv.includes("--dry-run"); +const FORCE = argv.includes("--force"); +const only = { + creatives: argv.includes("--creatives"), + slots: argv.includes("--slots"), +}; +// Neither flag given → do both. +const doCreatives = only.creatives || !only.slots; +const doSlots = only.slots || !only.creatives; + +function loadEnv(): Record { + // Prefer a real environment (Railway, CI); fall back to the checkout's .env. + if (process.env.NEXT_PUBLIC_SUPABASE_URL && process.env.SUPABASE_SERVICE_ROLE_KEY) { + return process.env as Record; + } + try { + const raw = readFileSync(new URL("../.env", import.meta.url), "utf8"); + const parsed = Object.fromEntries( + raw + .split("\n") + .filter((l) => l.trim() && !l.trimStart().startsWith("#") && l.includes("=")) + .map((l) => { + const i = l.indexOf("="); + return [l.slice(0, i).trim(), l.slice(i + 1).trim().replace(/^["']|["']$/g, "")]; + }), + ); + return { ...parsed, ...process.env } as Record; + } catch { + return process.env as Record; + } +} + +const env = loadEnv(); +const url = env.NEXT_PUBLIC_SUPABASE_URL; +const key = env.SUPABASE_SERVICE_ROLE_KEY; +if (!url || !key) { + console.error("Missing NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY."); + process.exit(1); +} +const sb: SupabaseClient = createClient(url, key, { auth: { persistSession: false } }); + +type CreativeRow = { + id: string; + format: string; + headline: string; + bg_color: string; + fg_color: string; + accent_color: string; + light_bg_color: string | null; + light_fg_color: string | null; + light_accent_color: string | null; +}; + +/** + * What to write for one creative. + * + * The stored trio is *documented* as the dark palette, but it was never + * enforced — an advertiser could always pick a white background. So the + * polarity is measured rather than assumed: a creative whose primary palette + * is actually light has it moved into the light columns and gets a derived + * dark one, instead of being handed a "light" variant that is already light + * and a dark slot that glares. + */ +function planCreative(r: CreativeRow): { patch: Record; note: string } | null { + if (r.light_bg_color && r.light_fg_color && r.light_accent_color) return null; + + const primary: AdPalette = { + bgColor: r.bg_color, + fgColor: r.fg_color, + accentColor: r.accent_color, + }; + + if (themeOfBackground(primary.bgColor) === "light") { + const dark = derivePalette(primary, "dark"); + return { + patch: { + bg_color: dark.bgColor, + fg_color: dark.fgColor, + accent_color: dark.accentColor, + light_bg_color: primary.bgColor, + light_fg_color: primary.fgColor, + light_accent_color: primary.accentColor, + }, + note: `primary was LIGHT (${primary.bgColor}) → moved to light_*, derived dark ${dark.bgColor}`, + }; + } + + const light = derivePalette(primary, "light"); + return { + patch: { + light_bg_color: light.bgColor, + light_fg_color: light.fgColor, + light_accent_color: light.accentColor, + }, + note: `dark ${primary.bgColor} → derived light ${light.bgColor}`, + }; +} + +async function backfillCreatives(): Promise { + const { data, error } = await sb + .from("ad_creatives") + .select("id, format, headline, bg_color, fg_color, accent_color, light_bg_color, light_fg_color, light_accent_color") + .order("created_at", { ascending: true }); + if (error) { + console.error("read ad_creatives failed:", error.message); + process.exitCode = 1; + return; + } + + const rows = (data ?? []) as CreativeRow[]; + let changed = 0; + let skipped = 0; + + for (const r of rows) { + const plan = planCreative(r); + if (!plan) { + skipped++; + continue; + } + const label = `${r.format.padEnd(15)} ${(r.headline || "").slice(0, 32).padEnd(32)}`; + if (DRY) { + console.log(` would update ${label} ${plan.note}`); + changed++; + continue; + } + const { error: uErr } = await sb.from("ad_creatives").update(plan.patch).eq("id", r.id); + if (uErr) { + console.error(` FAILED ${r.id}: ${uErr.message}`); + process.exitCode = 1; + continue; + } + console.log(` updated ${label} ${plan.note}`); + changed++; + } + + console.log( + `creatives: ${changed} ${DRY ? "would change" : "updated"}, ${skipped} already had a light palette, ${rows.length} total`, + ); +} + +type SlotRow = { + id: string; + theme: string | null; + status: string; + projects: { name: string | null; url: string | null } | { name: string | null; url: string | null }[] | null; +}; + +async function backfillSlots(): Promise { + const { data, error } = await sb + .from("ad_slots") + .select("id, theme, status, projects(name, url)") + .order("created_at", { ascending: true }); + if (error) { + console.error("read ad_slots failed:", error.message); + process.exitCode = 1; + return; + } + + const rows = (data ?? []) as unknown as SlotRow[]; + let changed = 0; + let skipped = 0; + let unknown = 0; + + for (const r of rows) { + if (!FORCE && r.theme && r.theme !== "auto") { + skipped++; + continue; + } + const project = Array.isArray(r.projects) ? r.projects[0] : r.projects; + const site = project?.url; + const label = (project?.name ?? r.id).padEnd(28); + if (!site) { + console.log(` ${label} no project url — left as auto`); + unknown++; + continue; + } + + const verdict = await detectSiteTheme(site); + if (!verdict.theme) { + console.log(` ${label} ${site} → undecided (${verdict.reason}) — left as auto`); + unknown++; + continue; + } + if (r.theme === verdict.theme) { + skipped++; + continue; + } + if (DRY) { + console.log(` would set ${label} ${verdict.theme.padEnd(5)} (${verdict.reason})`); + changed++; + continue; + } + const { error: uErr } = await sb.from("ad_slots").update({ theme: verdict.theme }).eq("id", r.id); + if (uErr) { + console.error(` FAILED ${r.id}: ${uErr.message}`); + process.exitCode = 1; + continue; + } + console.log(` set ${label} ${verdict.theme.padEnd(5)} (${verdict.reason})`); + changed++; + } + + console.log( + `slots: ${changed} ${DRY ? "would change" : "updated"}, ${skipped} already decided, ${unknown} undecided, ${rows.length} total`, + ); +} + +console.log(DRY ? "DRY RUN — nothing will be written\n" : ""); +if (doCreatives) { + console.log("== creatives =="); + await backfillCreatives(); +} +if (doSlots) { + console.log("\n== slots =="); + await backfillSlots(); +} diff --git a/scripts/detect-slot-themes.ts b/scripts/detect-slot-themes.ts new file mode 100644 index 00000000..4edaad5b --- /dev/null +++ b/scripts/detect-slot-themes.ts @@ -0,0 +1,34 @@ +#!/usr/bin/env -S npx tsx +// +// Companion to backfill-ad-themes.ts for a box with no Supabase credentials: +// takes a JSON array of {id, name, url} on argv[2], reads each publisher's +// site, and prints the verdicts plus the SQL to apply them. +// +// Same detectSiteTheme the backfill uses, so the two agree. + +import { readFileSync } from "node:fs"; +import { detectSiteTheme } from "../lib/ads/siteTheme"; + +type Slot = { id: string; name: string | null; url: string | null }; +const slots: Slot[] = JSON.parse(readFileSync(process.argv[2], "utf8")); + +const byTheme: Record = { light: [], dark: [] }; + +for (const s of slots) { + if (!s.url) { + console.error(`${(s.name ?? s.id).padEnd(30)} no url — left auto`); + continue; + } + const v = await detectSiteTheme(s.url); + console.error(`${(s.name ?? s.id).padEnd(30)} ${String(v.theme ?? "auto").padEnd(6)} ${v.reason}`); + if (v.theme) byTheme[v.theme].push(s.id); +} + +for (const theme of ["light", "dark"]) { + const ids = byTheme[theme]; + if (!ids.length) continue; + console.log( + `update ad_slots set theme = '${theme}' where id in (${ids.map((i) => `'${i}'`).join(",")});`, + ); +} +console.error(`\nlight: ${byTheme.light.length} dark: ${byTheme.dark.length}`); diff --git a/scripts/emit-theme-backfill-sql.ts b/scripts/emit-theme-backfill-sql.ts new file mode 100644 index 00000000..e7599583 --- /dev/null +++ b/scripts/emit-theme-backfill-sql.ts @@ -0,0 +1,78 @@ +#!/usr/bin/env -S npx tsx +// +// Emit the SQL the backfill would run, for the case where the box has no +// Supabase credentials and the writes have to go over a privileged SQL channel +// instead. Takes a JSON array of [bg, fg, accent] trios on argv[2]. +// +// It calls the same derivePalette/themeOfBackground the renderer uses, so the +// statements it prints and a later run of backfill-ad-themes.ts agree exactly. + +import { readFileSync } from "node:fs"; +import { contrastRatio, derivePalette, themeOfBackground } from "../lib/ads/theme"; + +const trios: [string, string, string][] = JSON.parse(readFileSync(process.argv[2], "utf8")); +const q = (s: string) => `'${s.replace(/'/g, "''")}'`; + +let movedToLight = 0; +const rows: string[] = []; + +for (const [bg, fg, accent] of trios) { + const primary = { bgColor: bg, fgColor: fg, accentColor: accent }; + + // Both cases are expressed as the same 9-column row so the whole backfill is + // one atomic statement: the dark trio to end up with, and the light trio to + // end up with. For an already-dark creative the dark trio is unchanged. + let dark = primary; + let light; + if (themeOfBackground(bg) === "light") { + // The stored trio is actually a LIGHT palette. It moves into the light + // columns and gets a derived dark counterpart, rather than being handed a + // "light variant" that is already light and a dark slot that glares. + dark = derivePalette(primary, "dark"); + light = primary; + movedToLight++; + } else { + light = derivePalette(primary, "light"); + } + + // Only palettes WE derived are asserted on. A pass-through primary that + // already fails contrast is the advertiser's own choice, and quietly + // rewriting their brand colours is not what a theme backfill is for — it is + // reported and left alone. + if (light !== primary) check(light.fgColor, light.bgColor, `${bg} -> light`); + if (dark !== primary) check(dark.fgColor, dark.bgColor, `${bg} -> dark`); + else note(primary.fgColor, primary.bgColor, bg); + rows.push( + `(${[bg, fg, accent, dark.bgColor, dark.fgColor, dark.accentColor, light.bgColor, light.fgColor, light.accentColor].map(q).join(",")})`, + ); +} + +const out = [ + "update ad_creatives c set", + " bg_color = v.dbg, fg_color = v.dfg, accent_color = v.dac,", + " light_bg_color = v.lbg, light_fg_color = v.lfg, light_accent_color = v.lac", + "from (values", + rows.join(",\n"), + ") as v(bg, fg, ac, dbg, dfg, dac, lbg, lfg, lac)", + "where c.bg_color = v.bg and c.fg_color = v.fg and c.accent_color = v.ac", + " and c.light_bg_color is null;", +]; + +function check(fg: string, bg: string, label: string): void { + const ratio = contrastRatio(fg, bg); + if (ratio < 4.5) { + console.error(`-- WARNING: derived ${label} text contrast ${ratio.toFixed(2)} below 4.5`); + process.exitCode = 1; + } +} + +/** A pre-existing palette we are leaving alone, flagged for the operator. */ +function note(fg: string, bg: string, label: string): void { + const ratio = contrastRatio(fg, bg); + if (ratio < 4.5) { + console.error(`-- NOTE: existing palette ${label} already at ${ratio.toFixed(2)} (left as-is)`); + } +} + +console.error(`-- ${trios.length} trios, ${movedToLight} had a light primary palette (moved)`); +console.log(out.join("\n")); diff --git a/supabase/migrations/20260819090000_ad_theme_variants.sql b/supabase/migrations/20260819090000_ad_theme_variants.sql new file mode 100644 index 00000000..afb6dcbd --- /dev/null +++ b/supabase/migrations/20260819090000_ad_theme_variants.sql @@ -0,0 +1,43 @@ +-- Light/dark variants for ad creatives, and a per-slot default. +-- +-- Every creative used to carry one palette, and the generator's prompt pushed +-- it dark. On a publisher page that is black-on-white — a plain blog with no +-- CSS of its own — the unit rendered as a hole punched in the page. +-- +-- The existing bg/fg/accent trio keeps its meaning as the DARK palette. The +-- light_* trio is nullable: a creative without one still renders, because +-- paletteFor() derives a light palette from the dark one on the fly. The +-- backfill fills these in so the derivation is not paid for on every request. + +alter table public.ad_creatives + add column if not exists light_bg_color text, + add column if not exists light_fg_color text, + add column if not exists light_accent_color text; + +comment on column public.ad_creatives.light_bg_color is + 'Background for light publisher pages. Null → derived from bg_color at render time.'; +comment on column public.ad_creatives.light_fg_color is + 'Foreground for light publisher pages. Null → derived from fg_color at render time.'; +comment on column public.ad_creatives.light_accent_color is + 'Accent/CTA for light publisher pages. Null → derived from accent_color at render time.'; + +-- Per-slot default polarity, used when the request cannot say what it found: +-- a MOTD fetched over curl, a feed spliced at build time, a page whose script +-- was blocked. 'auto' means "no opinion" and resolves to dark, which is what +-- every unit rendered as before this migration. +alter table public.ad_slots + add column if not exists theme text not null default 'auto'; + +do $$ +begin + if not exists ( + select 1 from pg_constraint + where conrelid = 'public.ad_slots'::regclass and conname = 'ad_slots_theme_check' + ) then + alter table public.ad_slots + add constraint ad_slots_theme_check check (theme in ('auto', 'light', 'dark')); + end if; +end $$; + +comment on column public.ad_slots.theme is + 'Default polarity for fills on this slot when the request does not specify one. Set by scripts/backfill-ad-themes.ts from the publisher site''s own background.'; diff --git a/tests/ads-site-theme.test.ts b/tests/ads-site-theme.test.ts new file mode 100644 index 00000000..dc60cc8e --- /dev/null +++ b/tests/ads-site-theme.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; + +// detectSiteTheme fetches through lib/onion's smartFetch. Stub it so these are +// pure parser tests with no network. +const fetchMock = vi.fn(); +vi.mock("@/lib/onion", () => ({ smartFetch: (...a: unknown[]) => fetchMock(...a) })); + +const { detectSiteTheme } = await import("@/lib/ads/siteTheme"); + +function reply(body: string) { + return { ok: true, text: async () => body }; +} + +/** Serve an HTML document plus a map of stylesheet path -> css. */ +function serve(html: string, sheets: Record = {}) { + fetchMock.mockImplementation(async (url: string) => { + for (const [path, css] of Object.entries(sheets)) { + if (url.includes(path)) return reply(css); + } + if (url.endsWith(".css")) return { ok: false, text: async () => "" }; + return reply(html); + }); +} + +afterEach(() => fetchMock.mockReset()); + +describe("detectSiteTheme", () => { + it("calls a page with no declared background light", async () => { + // The case the whole feature exists for: a plain blog with no CSS. The + // browser paints white no matter what the visitor's OS prefers. + serve("

hi

"); + const v = await detectSiteTheme("https://example.com"); + expect(v.theme).toBe("light"); + }); + + it("reads a background off an inline style block", async () => { + serve(""); + expect((await detectSiteTheme("https://example.com")).theme).toBe("dark"); + }); + + it("reads a background out of a linked stylesheet", async () => { + serve('', { + "/a.css": "body{background-color:#ffffff}", + }); + expect((await detectSiteTheme("https://example.com")).theme).toBe("light"); + }); + + it("resolves a custom property, which is how real stylesheets declare it", async () => { + // body { background: var(--bg) } with --bg on :root. Without resolution + // this finds no background and wrongly falls through to light. + serve('', { + "/a.css": ":root{--bg:#0d1117}body{background:var(--bg)}", + }); + const v = await detectSiteTheme("https://example.com"); + expect(v.theme).toBe("dark"); + expect(v.reason).toContain("#0d1117"); + }); + + it("takes the last layer of a layered background, not the first colour it sees", async () => { + // `radial-gradient(... accent ...), var(--bg)` — the accent is a decorative + // wash painted OVER the base layer. CSS only allows a colour on the final + // layer, and the base is what a reader sees behind the text. + serve('', { + "/a.css": + ":root{--bg:#0d1117;--accent:#3fb98a}body{background:radial-gradient(1200px 620px at 50% -10%, var(--accent), transparent 70%), var(--bg)}", + }); + const v = await detectSiteTheme("https://example.com"); + expect(v.theme).toBe("dark"); + expect(v.reason).toContain("#0d1117"); + }); + + it("ignores a prefers-color-scheme override when deciding the default", async () => { + // A light site with a dark mode is a light site. Which one a given visitor + // sees is measured by the tag at fill time, not guessed from the CSS. + serve('', { + "/a.css": + ":root{--bg:#fbfaf8}body{background:var(--bg)}@media (prefers-color-scheme: dark){:root{--bg:#12110f}}", + }); + const v = await detectSiteTheme("https://example.com"); + expect(v.theme).toBe("light"); + expect(v.reason).toContain("#fbfaf8"); + }); + + it("does not let a component background decide the page", async () => { + // Only html/body/:root count. A dark card on a white page is not a dark page. + serve('', { + "/a.css": ".card{background:#0b0d10}.nav{background-color:#111}", + }); + expect((await detectSiteTheme("https://example.com")).theme).toBe("light"); + }); + + it("honours an explicit dark color-scheme", async () => { + serve(""); + expect((await detectSiteTheme("https://example.com")).theme).toBe("dark"); + }); + + it("treats 'light dark' as no verdict, because it adapts", async () => { + serve(""); + // Falls through to the browser-default answer rather than guessing. + expect((await detectSiteTheme("https://example.com")).reason).toContain("browser default"); + }); + + it("reports an unreachable site rather than guessing", async () => { + fetchMock.mockImplementation(async () => ({ ok: false, text: async () => "" })); + const v = await detectSiteTheme("https://example.com"); + expect(v.theme).toBeNull(); + expect(v.reason).toBe("unreachable"); + }); + + it("ignores a commented-out rule", async () => { + serve(""); + expect((await detectSiteTheme("https://example.com")).theme).toBe("light"); + }); +}); diff --git a/tests/ads-theme-variants.test.ts b/tests/ads-theme-variants.test.ts new file mode 100644 index 00000000..cd336e6d --- /dev/null +++ b/tests/ads-theme-variants.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it } from "vitest"; +import { + contrastRatio, + derivePalette, + hairline, + luminance, + parseColor, + solid, + themeOfBackground, + toHex, + type AdPalette, +} from "@/lib/ads/theme"; +import { hexToRgba, paletteFor, type AdCreative } from "@/lib/ads/formats"; +import { renderCreativeHtml } from "@/lib/ads/creative"; +import { resolveTheme } from "@/lib/ads/serve"; +import { renderHouseAdHtml } from "@/lib/ads/house"; + +const DARK: AdPalette = { bgColor: "#0b0d10", fgColor: "#e7e9ee", accentColor: "#6ee7b7" }; + +function creative(over: Partial = {}): AdCreative { + return { + format: "text_link", + headline: "Ship it", + body: "One benefit line", + ctaText: "Try it", + ...DARK, + fontFamily: "system-ui, sans-serif", + logoUrl: null, + imageUrl: null, + ...over, + }; +} + +describe("colour parsing", () => { + it("reads 3, 4, 6 and 8 digit hex", () => { + expect(parseColor("#fff")).toEqual({ r: 255, g: 255, b: 255, a: 1 }); + expect(parseColor("#0b0d10")).toEqual({ r: 11, g: 13, b: 16, a: 1 }); + expect(parseColor("#00000080")?.a).toBeCloseTo(0.502, 2); + expect(parseColor("not a colour")).toBeNull(); + }); + + it("round-trips through hex, keeping alpha only when it is not opaque", () => { + expect(toHex({ r: 11, g: 13, b: 16, a: 1 })).toBe("#0b0d10"); + expect(toHex({ r: 11, g: 13, b: 16, a: 0.5 })).toBe("#0b0d1080"); + }); + + it("strips alpha for inks that punch out of a chip", () => { + expect(solid("#0b0d1080")).toBe("#0b0d10"); + expect(solid("#0b0d10")).toBe("#0b0d10"); + }); + + it("flattens a translucent colour over white when judging polarity", () => { + // 20%-opacity black over white is a light grey, not a dark colour — this is + // what a viewer actually sees on a page with no background of its own. + expect(themeOfBackground("#00000033")).toBe("light"); + expect(themeOfBackground("#000000")).toBe("dark"); + }); + + it("multiplies alpha rather than replacing it", () => { + // A half-transparent brand colour asked for at 50% is 25%, not 50%. + expect(hexToRgba("#ffffff80", 0.5)).toBe("rgba(255,255,255,0.251)"); + expect(hexToRgba("#ffffff", 0.5)).toBe("rgba(255,255,255,0.5)"); + }); +}); + +describe("derivePalette", () => { + it("produces a readable light counterpart from a dark palette", () => { + const light = derivePalette(DARK, "light"); + expect(themeOfBackground(light.bgColor)).toBe("light"); + expect(contrastRatio(light.fgColor, light.bgColor)).toBeGreaterThanOrEqual(4.5); + // 3:1 is the WCAG bar for a UI component, which is what the CTA chip is. + expect(contrastRatio(light.accentColor, light.bgColor)).toBeGreaterThanOrEqual(3); + }); + + it("produces a readable dark counterpart from a light palette", () => { + const source: AdPalette = { bgColor: "#ffffff", fgColor: "#111111", accentColor: "#0a7d55" }; + const dark = derivePalette(source, "dark"); + expect(themeOfBackground(dark.bgColor)).toBe("dark"); + expect(contrastRatio(dark.fgColor, dark.bgColor)).toBeGreaterThanOrEqual(4.5); + expect(contrastRatio(dark.accentColor, dark.bgColor)).toBeGreaterThanOrEqual(3); + }); + + it("keeps the brand hue rather than inventing a new colour", () => { + // A neon mint accent must still be a green on the light variant. + const light = derivePalette(DARK, "light"); + const c = parseColor(light.accentColor)!; + expect(c.g).toBeGreaterThan(c.r); + expect(c.g).toBeGreaterThan(c.b); + }); + + it("keeps a neutral accent neutral instead of inventing a hue", () => { + // A grey has an arbitrary hue of 0. Flooring its saturation would turn the + // advertiser's grey CTA red — this happened to a real creative (#a3a3a3). + const light = derivePalette( + { bgColor: "#0b0d10", fgColor: "#ffffff", accentColor: "#a3a3a3" }, + "light", + ); + const c = parseColor(light.accentColor)!; + const spread = Math.max(c.r, c.g, c.b) - Math.min(c.r, c.g, c.b); + expect(spread).toBeLessThanOrEqual(8); + expect(contrastRatio(light.accentColor, light.bgColor)).toBeGreaterThanOrEqual(3); + }); + + it("stays readable across a spread of real-world brand colours", () => { + const brands = ["#0b0d10", "#1a1a2e", "#101820", "#2d1b4e", "#0f172a", "#111111", "#003049"]; + for (const bg of brands) { + for (const target of ["light", "dark"] as const) { + const p = derivePalette({ bgColor: bg, fgColor: "#e7e9ee", accentColor: "#6ee7b7" }, target); + expect(themeOfBackground(p.bgColor), `${bg} → ${target}`).toBe(target); + expect(contrastRatio(p.fgColor, p.bgColor), `${bg} → ${target} fg`).toBeGreaterThanOrEqual(4.5); + } + } + }); +}); + +describe("paletteFor", () => { + it("uses the stored light trio when there is one", () => { + const c = creative({ + lightBgColor: "#f7f9fc", + lightFgColor: "#101418", + lightAccentColor: "#0f7a5a", + }); + expect(paletteFor(c, "light")).toEqual({ + bgColor: "#f7f9fc", + fgColor: "#101418", + accentColor: "#0f7a5a", + }); + }); + + it("derives a light palette for a creative that predates theme variants", () => { + const p = paletteFor(creative(), "light"); + expect(themeOfBackground(p.bgColor)).toBe("light"); + expect(contrastRatio(p.fgColor, p.bgColor)).toBeGreaterThanOrEqual(4.5); + }); + + it("derives a dark palette when the primary trio is actually light", () => { + // The stored trio is documented as dark but was never enforced. Serving a + // white unit onto a dark page is the same glare bug in reverse. + const c = creative({ bgColor: "#ffffff", fgColor: "#111111", accentColor: "#0a7d55" }); + expect(themeOfBackground(paletteFor(c, "dark").bgColor)).toBe("dark"); + // ...and the light request gets the advertiser's own colours untouched. + expect(paletteFor(c, "light").bgColor).toBe("#ffffff"); + }); + + it("partial light columns are ignored — all three or none", () => { + const c = creative({ lightBgColor: "#f7f9fc" }); + expect(paletteFor(c, "light").bgColor).not.toBe("#f7f9fc"); + }); +}); + +describe("resolveTheme", () => { + it("prefers what the tag measured over the slot's stored default", () => { + expect(resolveTheme("light", "dark")).toBe("light"); + }); + + it("falls back to the slot default when the request says nothing", () => { + expect(resolveTheme(null, "light")).toBe("light"); + expect(resolveTheme("auto", "light")).toBe("light"); + }); + + it("defaults to dark, which is what every unit rendered as before", () => { + expect(resolveTheme(null, null)).toBe("dark"); + expect(resolveTheme("auto", "auto")).toBe("dark"); + expect(resolveTheme("nonsense", undefined)).toBe("dark"); + }); +}); + +describe("rendered HTML", () => { + it("paints a light unit with the light palette", () => { + const c = creative({ + lightBgColor: "#f7f9fc", + lightFgColor: "#101418", + lightAccentColor: "#0f7a5a", + }); + const html = renderCreativeHtml(c, "https://example.com", { theme: "light" }); + expect(html).toContain("#f7f9fc"); + expect(html).not.toContain(DARK.bgColor); + }); + + it("defaults to dark when no theme is asked for", () => { + const html = renderCreativeHtml(creative(), "https://example.com"); + expect(html).toContain(DARK.bgColor); + }); + + it("uses a dark hairline on dark and a dark-ink hairline on light", () => { + expect(hairline("dark")).toBe("rgba(255,255,255,.08)"); + expect(hairline("light")).toBe("rgba(0,0,0,.12)"); + const light = renderCreativeHtml(creative(), "https://example.com", { theme: "light" }); + // The white haze was invisible on a dark page and a grey smear on a light one. + expect(light).not.toContain("rgba(255,255,255,.08)"); + expect(light).toContain("rgba(0,0,0,.12)"); + }); + + it("renders every format in both themes without leaking the other palette", () => { + for (const format of ["banner_300x250", "banner_728x90", "banner_320x50", "text_link"] as const) { + const c = creative({ + format, + lightBgColor: "#f7f9fc", + lightFgColor: "#101418", + lightAccentColor: "#0f7a5a", + }); + const light = renderCreativeHtml(c, "https://example.com", { theme: "light" }); + expect(light, format).toContain("#f7f9fc"); + expect(light, format).not.toContain(DARK.bgColor); + } + }); + + it("keeps a translucent background from making the CTA label see-through", () => { + const c = creative({ format: "banner_728x90", bgColor: "#0b0d1080" }); + const html = renderCreativeHtml(c, "https://example.com"); + // The chip's ink is the background colour, and must be opaque. + expect(html).toContain("color:#0b0d10;"); + }); +}); + +describe("house ads", () => { + it("has a light variant, not just the near-black one", () => { + const dark = renderHouseAdHtml("text_link", "https://crawlproof.com", undefined, "dark"); + const light = renderHouseAdHtml("text_link", "https://crawlproof.com", undefined, "light"); + expect(dark).toContain("#070a10"); + expect(light).not.toContain("#070a10"); + expect(light).toContain("#f6f9fb"); + }); + + it("is readable in both polarities", () => { + for (const [bg, fg] of [ + ["#070a10", "#eef3f8"], + ["#f6f9fb", "#0d1620"], + ]) { + expect(contrastRatio(fg, bg)).toBeGreaterThanOrEqual(4.5); + } + }); +}); + +describe("luminance", () => { + it("orders black, mid grey and white", () => { + expect(luminance("#000000")).toBeLessThan(luminance("#808080")); + expect(luminance("#808080")).toBeLessThan(luminance("#ffffff")); + }); +}); From b0574be036f019b7aba004d57d54827f9912aaf2 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 19 Aug 2026 08:59:46 +0000 Subject: [PATCH 2/2] Add a side-by-side theme preview script 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) --- scripts/preview-themes.ts | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 scripts/preview-themes.ts diff --git a/scripts/preview-themes.ts b/scripts/preview-themes.ts new file mode 100644 index 00000000..47bc4d6f --- /dev/null +++ b/scripts/preview-themes.ts @@ -0,0 +1,38 @@ +#!/usr/bin/env -S npx tsx +// Render one creative in both polarities into a single page, so the pair can +// be eyeballed the way a publisher would see them. Writes HTML to stdout. + +import { renderCreativeHtml } from "../lib/ads/creative"; +import type { AdCreative, AdFormatId } from "../lib/ads/formats"; + +const base: Omit = { + headline: "Ship it on Friday", + body: "Pay per click, set a daily cap, stop whenever you want.", + ctaText: "Try it free", + bgColor: "#0b0d10", + fgColor: "#ffffff", + accentColor: "#6ee7b7", + lightBgColor: null, + lightFgColor: null, + lightAccentColor: null, + fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif", + logoUrl: null, + imageUrl: null, +}; + +const formats: AdFormatId[] = ["text_link", "banner_728x90", "banner_300x250", "banner_320x50"]; +const cell = (f: AdFormatId, theme: "light" | "dark") => + ``; + +console.log(` +
+

On a white page (light variant)

+

This is the case that was broken: a plain black-on-white blog.

+ ${formats.map((f) => cell(f, "light")).join("

")} +
+
+

On a dark page (dark variant)

+

Unchanged from before.

+ ${formats.map((f) => cell(f, "dark")).join("

")} +
+`);