// Banner colour resolution. A campaign may carry two optional colour sets (see // lib/model BannerColors): one for every theme and one that further overrides the // dark theme. resolveBannerColors collapses them to the colours the strip paints on // the currently-rendered theme, or null when the campaign keeps the neutral // --ad-bg / --text-muted / --accent tokens. Pure and DOM-free, so it is unit-tested // directly; AdBanner.svelte applies the result as inline CSS variables. import type { BannerColors } from './model'; export type ThemeMode = 'light' | 'dark'; /** ResolvedBannerColors is the strip's colours for one theme, border already derived. */ export interface ResolvedBannerColors { bg: string; fg: string; link: string; border: string; } /** * resolveBannerColors picks the colour set for the rendered theme — dark ← dark ?? all, * light ← all — and returns null when there is no override for that theme (the strip then * keeps the neutral tokens). The border is derived from the background so it stays subtle * on any colour. */ export function resolveBannerColors(colors: BannerColors, mode: ThemeMode): ResolvedBannerColors | null { const set = mode === 'dark' ? (colors.dark ?? colors.all) : colors.all; if (!set) return null; return { bg: set.bg, fg: set.fg, link: set.link, border: derivedBorder(set.bg) }; } /** * derivedBorder nudges the background 14% toward black on a light background and toward * white on a dark one — a subtle edge that suits any override colour. It is computed in * JS (no CSS color-mix) so it works on the old Android WebView floor (Chrome 67), and * mirrors the admin console preview (banner_detail.gohtml). A malformed colour is * returned unchanged. */ export function derivedBorder(bgHex: string): string { const rgb = hexToRgb(bgHex); if (!rgb) return bgHex; const target: RGB = luminance(rgb) > 0.5 ? [0, 0, 0] : [255, 255, 255]; return rgbToHex(mix(rgb, target, 0.14)); } type RGB = [number, number, number]; function hexToRgb(h: string): RGB | null { const m = /^#([0-9a-fA-F]{6})$/.exec(h.trim()); if (!m) return null; const n = m[1]; return [parseInt(n.slice(0, 2), 16), parseInt(n.slice(2, 4), 16), parseInt(n.slice(4, 6), 16)]; } function mix(a: RGB, b: RGB, t: number): RGB { return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; } function luminance(r: RGB): number { return (0.2126 * r[0] + 0.7152 * r[1] + 0.0722 * r[2]) / 255; } function pad(x: number): string { return Math.max(0, Math.min(255, Math.round(x))).toString(16).padStart(2, '0'); } function rgbToHex(r: RGB): string { return '#' + pad(r[0]) + pad(r[1]) + pad(r[2]); }