Skip to content

Commit 00a67df

Browse files
ralyodioclaude
andauthored
Light and dark variants for every ad, picked automatically (#211)
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent f40c68c commit 00a67df

23 files changed

Lines changed: 1967 additions & 146 deletions

‎app/(app)/dashboard/ads/[id]/edit/edit-form.tsx‎

Lines changed: 58 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
import { useState, useTransition } from "react";
44
import { useRouter } from "next/navigation";
55
import { updateCampaign, updateCreatives, uploadAdAsset } from "@/app/actions/ads";
6-
import { AD_FORMATS, type AdCreative, type AdFormatId } from "@/lib/ads/formats";
6+
import { AD_FORMATS, paletteFor, type AdCreative, type AdFormatId } from "@/lib/ads/formats";
77
import { AdPreview } from "@/components/ads/ad-preview";
8+
import { ColorField } from "@/components/ads/color-field";
9+
import type { AdTheme } from "@/lib/ads/theme";
810

911
type Campaign = {
1012
id: string;
@@ -33,9 +35,17 @@ export function EditCampaignForm({
3335
const [bid, setBid] = useState((campaign.bidCredits * 5) / 100); // credits → $
3436
const [creatives, setCreatives] = useState(initial);
3537
const [active, setActive] = useState<AdFormatId>(initial[0]?.format ?? "banner_300x250");
38+
// Which polarity the previews show and the colour pickers edit. Publishers
39+
// get whichever one matches their page, so both are editable here.
40+
const [theme, setTheme] = useState<AdTheme>("dark");
3641
const [uploading, setUploading] = useState(false);
3742

3843
const current = creatives.find((c) => c.format === active) ?? creatives[0];
44+
// What the pickers show: the stored trio for this theme, or the derived one
45+
// when the creative predates theme variants and has no light trio yet.
46+
const palette = current
47+
? paletteFor(current, theme)
48+
: { bgColor: "#0b0d10", fgColor: "#e7e9ee", accentColor: "#6ee7b7" };
3949

4050
function patchActive(patch: Partial<AdCreative>) {
4151
setCreatives((cs) => cs.map((c) => (c.format === active ? { ...c, ...patch } : c)));
@@ -44,6 +54,28 @@ export function EditCampaignForm({
4454
setCreatives((cs) => cs.map((c) => ({ ...c, ...patch })));
4555
}
4656

57+
// Colours apply to every format at once (one brand, five sizes), and write to
58+
// whichever trio the theme switch has selected. Editing the light trio for
59+
// the first time seeds it from the derived palette, so a publisher never sees
60+
// two of three colours change.
61+
function patchPalette(key: "bg" | "fg" | "accent", v: string) {
62+
if (theme === "dark") {
63+
const field = key === "bg" ? "bgColor" : key === "fg" ? "fgColor" : "accentColor";
64+
return patchAll({ [field]: v } as Partial<AdCreative>);
65+
}
66+
setCreatives((cs) =>
67+
cs.map((c) => {
68+
const seed = paletteFor(c, "light");
69+
return {
70+
...c,
71+
lightBgColor: key === "bg" ? v : (c.lightBgColor ?? seed.bgColor),
72+
lightFgColor: key === "fg" ? v : (c.lightFgColor ?? seed.fgColor),
73+
lightAccentColor: key === "accent" ? v : (c.lightAccentColor ?? seed.accentColor),
74+
};
75+
}),
76+
);
77+
}
78+
4779
function onUpload(kind: "logoUrl" | "imageUrl") {
4880
return async (e: React.ChangeEvent<HTMLInputElement>) => {
4981
const file = e.target.files?.[0];
@@ -137,7 +169,7 @@ export function EditCampaignForm({
137169
style={{ maxWidth: f.w > 360 ? 360 : f.w + 16 }}
138170
>
139171
<div style={{ overflowX: "auto" }}>
140-
<AdPreview creative={c} />
172+
<AdPreview creative={c} theme={theme} />
141173
</div>
142174
</button>
143175
);
@@ -159,10 +191,31 @@ export function EditCampaignForm({
159191
</label>
160192
</div>
161193

194+
<div className="flex flex-wrap items-center gap-3">
195+
<span className="text-xs uppercase tracking-wider text-[var(--color-muted)]">Theme</span>
196+
{(["dark", "light"] as AdTheme[]).map((t) => (
197+
<button
198+
key={t}
199+
type="button"
200+
onClick={() => setTheme(t)}
201+
className={`rounded-md px-3 py-1 text-sm capitalize ${
202+
theme === t
203+
? "bg-[var(--color-accent)] text-black"
204+
: "border border-[var(--color-border)] text-[var(--color-muted)]"
205+
}`}
206+
>
207+
{t}
208+
</button>
209+
))}
210+
<span className="text-xs text-[var(--color-muted)]">
211+
Publishers are served whichever matches their page.
212+
</span>
213+
</div>
214+
162215
<div className="flex flex-wrap items-center gap-5">
163-
<ColorField label="Background" value={current.bgColor} onChange={(v) => patchAll({ bgColor: v })} />
164-
<ColorField label="Text" value={current.fgColor} onChange={(v) => patchAll({ fgColor: v })} />
165-
<ColorField label="Accent" value={current.accentColor} onChange={(v) => patchAll({ accentColor: v })} />
216+
<ColorField label="Background" value={palette.bgColor} onChange={(v) => patchPalette("bg", v)} />
217+
<ColorField label="Text" value={palette.fgColor} onChange={(v) => patchPalette("fg", v)} alpha={false} />
218+
<ColorField label="Accent" value={palette.accentColor} onChange={(v) => patchPalette("accent", v)} />
166219
<label className="btn cursor-pointer text-sm">
167220
{uploading ? "Uploading…" : current.logoUrl ? "Replace logo" : "Upload logo"}
168221
<input type="file" accept="image/png,image/jpeg,image/webp,image/svg+xml" className="hidden" onChange={onUpload("logoUrl")} disabled={uploading} />
@@ -193,16 +246,3 @@ export function EditCampaignForm({
193246
</div>
194247
);
195248
}
196-
197-
function ColorField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
198-
return (
199-
<label className="flex items-center gap-2">
200-
<input type="color" value={value} onChange={(e) => onChange(e.target.value)} className="h-8 w-8 cursor-pointer rounded border border-[var(--color-border)] bg-transparent" aria-label={label} />
201-
<span className="text-xs text-[var(--color-muted)]">
202-
{label}
203-
<br />
204-
<span className="font-mono">{value}</span>
205-
</span>
206-
</label>
207-
);
208-
}

‎app/(app)/dashboard/ads/[id]/edit/page.tsx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ type CreativeRow = {
1717
bg_color: string;
1818
fg_color: string;
1919
accent_color: string;
20+
light_bg_color: string | null;
21+
light_fg_color: string | null;
22+
light_accent_color: string | null;
2023
font_family: string;
2124
};
2225

@@ -42,7 +45,7 @@ export default async function EditCampaignPage({
4245

4346
const { data: creativeRows } = await supabase
4447
.from("ad_creatives")
45-
.select("id, format, headline, body, cta_text, image_url, logo_url, bg_color, fg_color, accent_color, font_family")
48+
.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")
4649
.eq("campaign_id", id)
4750
.order("format");
4851

@@ -56,6 +59,9 @@ export default async function EditCampaignPage({
5659
bgColor: r.bg_color,
5760
fgColor: r.fg_color,
5861
accentColor: r.accent_color,
62+
lightBgColor: r.light_bg_color,
63+
lightFgColor: r.light_fg_color,
64+
lightAccentColor: r.light_accent_color,
5965
fontFamily: r.font_family,
6066
logoUrl: r.logo_url,
6167
imageUrl: r.image_url,

‎app/(app)/dashboard/ads/[id]/page.tsx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ type CreativeRow = {
2121
bg_color: string;
2222
fg_color: string;
2323
accent_color: string;
24+
light_bg_color: string | null;
25+
light_fg_color: string | null;
26+
light_accent_color: string | null;
2427
font_family: string;
2528
};
2629

@@ -64,7 +67,7 @@ export default async function CampaignDetailPage({
6467
supabase
6568
.from("ad_creatives")
6669
.select(
67-
"id, format, headline, body, cta_text, image_url, logo_url, bg_color, fg_color, accent_color, font_family",
70+
"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",
6871
)
6972
.eq("campaign_id", id)
7073
.order("format"),
@@ -95,6 +98,9 @@ export default async function CampaignDetailPage({
9598
bgColor: r.bg_color,
9699
fgColor: r.fg_color,
97100
accentColor: r.accent_color,
101+
lightBgColor: r.light_bg_color,
102+
lightFgColor: r.light_fg_color,
103+
lightAccentColor: r.light_accent_color,
98104
fontFamily: r.font_family,
99105
logoUrl: r.logo_url,
100106
imageUrl: r.image_url,

‎app/(app)/dashboard/ads/new/form.tsx‎

Lines changed: 54 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
import { useState, useTransition, useRef } from "react";
44
import { useRouter } from "next/navigation";
55
import { previewAds, saveCampaign, uploadAdAsset } from "@/app/actions/ads";
6-
import { AD_FORMATS, type AdCreative, type AdFormatId } from "@/lib/ads/formats";
6+
import { AD_FORMATS, paletteFor, type AdCreative, type AdFormatId } from "@/lib/ads/formats";
77
import type { SiteBrand } from "@/lib/ads/brand";
88
import { AdPreview } from "@/components/ads/ad-preview";
9+
import { ColorField } from "@/components/ads/color-field";
10+
import type { AdTheme } from "@/lib/ads/theme";
911

1012
function dollars(cents: number): string {
1113
return `$${(cents / 100).toFixed(2)}`;
@@ -27,8 +29,13 @@ export function NewAdForm() {
2729
const [uploading, setUploading] = useState(false);
2830
const [error, setError] = useState<string | null>(null);
2931
const fileRef = useRef<HTMLInputElement>(null);
32+
// Which polarity the previews show and the colour pickers edit.
33+
const [theme, setTheme] = useState<AdTheme>("dark");
3034

3135
const current = creatives.find((c) => c.format === active) ?? creatives[0];
36+
const palette = current
37+
? paletteFor(current, theme)
38+
: { bgColor: "#0b0d10", fgColor: "#e7e9ee", accentColor: "#6ee7b7" };
3239

3340
function generate(e: React.FormEvent) {
3441
e.preventDefault();
@@ -54,6 +61,27 @@ export function NewAdForm() {
5461
setCreatives((cs) => cs.map((c) => ({ ...c, ...patch })));
5562
}
5663

64+
// Writes to whichever trio the theme switch has selected. Editing the light
65+
// trio for the first time seeds the other two from the derived palette, so a
66+
// publisher never sees two of three colours jump.
67+
function patchPalette(key: "bg" | "fg" | "accent", v: string) {
68+
if (theme === "dark") {
69+
const field = key === "bg" ? "bgColor" : key === "fg" ? "fgColor" : "accentColor";
70+
return patchAll({ [field]: v } as Partial<AdCreative>);
71+
}
72+
setCreatives((cs) =>
73+
cs.map((c) => {
74+
const seed = paletteFor(c, "light");
75+
return {
76+
...c,
77+
lightBgColor: key === "bg" ? v : (c.lightBgColor ?? seed.bgColor),
78+
lightFgColor: key === "fg" ? v : (c.lightFgColor ?? seed.fgColor),
79+
lightAccentColor: key === "accent" ? v : (c.lightAccentColor ?? seed.accentColor),
80+
};
81+
}),
82+
);
83+
}
84+
5785
function onUpload(kind: "logoUrl" | "imageUrl") {
5886
return async (e: React.ChangeEvent<HTMLInputElement>) => {
5987
const file = e.target.files?.[0];
@@ -186,7 +214,7 @@ export function NewAdForm() {
186214
style={{ maxWidth: f.w > 360 ? 360 : f.w + 16 }}
187215
>
188216
<div style={{ overflowX: "auto" }}>
189-
<AdPreview creative={c} />
217+
<AdPreview creative={c} theme={theme} />
190218
</div>
191219
<div className="text-xs text-[var(--color-muted)]">
192220
{f.label} · {f.w}×{f.h}
@@ -238,10 +266,31 @@ export function NewAdForm() {
238266
</label>
239267
</div>
240268

269+
<div className="flex flex-wrap items-center gap-3">
270+
<span className="text-xs uppercase tracking-wider text-[var(--color-muted)]">Theme</span>
271+
{(["dark", "light"] as AdTheme[]).map((t) => (
272+
<button
273+
key={t}
274+
type="button"
275+
onClick={() => setTheme(t)}
276+
className={`rounded-md px-3 py-1 text-sm capitalize ${
277+
theme === t
278+
? "bg-[var(--color-accent)] text-black"
279+
: "border border-[var(--color-border)] text-[var(--color-muted)]"
280+
}`}
281+
>
282+
{t}
283+
</button>
284+
))}
285+
<span className="text-xs text-[var(--color-muted)]">
286+
Publishers are served whichever matches their page.
287+
</span>
288+
</div>
289+
241290
<div className="flex flex-wrap items-center gap-5">
242-
<ColorField label="Background" value={current.bgColor} onChange={(v) => patchAll({ bgColor: v })} />
243-
<ColorField label="Text" value={current.fgColor} onChange={(v) => patchAll({ fgColor: v })} />
244-
<ColorField label="Accent" value={current.accentColor} onChange={(v) => patchAll({ accentColor: v })} />
291+
<ColorField label="Background" value={palette.bgColor} onChange={(v) => patchPalette("bg", v)} />
292+
<ColorField label="Text" value={palette.fgColor} onChange={(v) => patchPalette("fg", v)} alpha={false} />
293+
<ColorField label="Accent" value={palette.accentColor} onChange={(v) => patchPalette("accent", v)} />
245294
<span className="text-xs text-[var(--color-muted)]">Colours apply to all formats</span>
246295
</div>
247296

@@ -310,30 +359,3 @@ export function NewAdForm() {
310359
</div>
311360
);
312361
}
313-
314-
function ColorField({
315-
label,
316-
value,
317-
onChange,
318-
}: {
319-
label: string;
320-
value: string;
321-
onChange: (v: string) => void;
322-
}) {
323-
return (
324-
<label className="flex items-center gap-2">
325-
<input
326-
type="color"
327-
value={value}
328-
onChange={(e) => onChange(e.target.value)}
329-
className="h-8 w-8 cursor-pointer rounded border border-[var(--color-border)] bg-transparent"
330-
aria-label={label}
331-
/>
332-
<span className="text-xs text-[var(--color-muted)]">
333-
{label}
334-
<br />
335-
<span className="font-mono">{value}</span>
336-
</span>
337-
</label>
338-
);
339-
}

‎app/actions/ads.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,8 @@ export async function previewAds(input: { url: string }): Promise<
113113
}
114114

115115
const ALLOWED_FORMATS = new Set<AdFormatId>(AD_FORMAT_IDS);
116-
const HEX = /^#[0-9a-fA-F]{6}$/;
116+
// 6- or 8-digit: the editor's opacity slider writes #rrggbbaa.
117+
const HEX = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
117118

118119
function cleanCreative(c: Partial<AdCreative>): AdCreative | null {
119120
if (!c.format || !ALLOWED_FORMATS.has(c.format)) return null;
@@ -125,6 +126,13 @@ function cleanCreative(c: Partial<AdCreative>): AdCreative | null {
125126
bgColor: HEX.test(c.bgColor ?? "") ? c.bgColor! : "#0b0d10",
126127
fgColor: HEX.test(c.fgColor ?? "") ? c.fgColor! : "#e7e9ee",
127128
accentColor: HEX.test(c.accentColor ?? "") ? c.accentColor! : "#6ee7b7",
129+
// Light trio is optional: null means "derive it at render time", which is
130+
// what every creative predating theme variants does. An invalid value is
131+
// dropped to null rather than defaulted, so a bad edit falls back to the
132+
// derived palette instead of pinning a wrong colour.
133+
lightBgColor: HEX.test(c.lightBgColor ?? "") ? c.lightBgColor! : null,
134+
lightFgColor: HEX.test(c.lightFgColor ?? "") ? c.lightFgColor! : null,
135+
lightAccentColor: HEX.test(c.lightAccentColor ?? "") ? c.lightAccentColor! : null,
128136
fontFamily: (c.fontFamily ?? "system-ui, sans-serif").slice(0, 200),
129137
logoUrl: c.logoUrl ?? null,
130138
imageUrl: c.imageUrl ?? null,
@@ -233,6 +241,9 @@ export async function saveCampaign(input: {
233241
bg_color: c.bgColor,
234242
fg_color: c.fgColor,
235243
accent_color: c.accentColor,
244+
light_bg_color: c.lightBgColor ?? null,
245+
light_fg_color: c.lightFgColor ?? null,
246+
light_accent_color: c.lightAccentColor ?? null,
236247
font_family: c.fontFamily,
237248
}));
238249
const { error: cErr } = await supabase.from("ad_creatives").insert(rows);
@@ -310,6 +321,9 @@ export async function updateCreatives(input: {
310321
bg_color: cleaned.bgColor,
311322
fg_color: cleaned.fgColor,
312323
accent_color: cleaned.accentColor,
324+
light_bg_color: cleaned.lightBgColor,
325+
light_fg_color: cleaned.lightFgColor,
326+
light_accent_color: cleaned.lightAccentColor,
313327
})
314328
.eq("id", c.id)
315329
.eq("owner_id", user.id);
@@ -389,6 +403,9 @@ export async function regenerateCampaign(input: {
389403
bg_color: c.bgColor,
390404
fg_color: c.fgColor,
391405
accent_color: c.accentColor,
406+
light_bg_color: c.lightBgColor ?? null,
407+
light_fg_color: c.lightFgColor ?? null,
408+
light_accent_color: c.lightAccentColor ?? null,
392409
font_family: c.fontFamily,
393410
};
394411
const existingId = idByFormat.get(c.format);

0 commit comments

Comments
 (0)