6db9178449
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
Non-default campaigns gain an optional colour override (background / text / link) in two sets — one for every theme, one for the dark theme only — and an "urgent" flag. - Colours ride profile.get as six trailing FlatBuffers strings on BannerCampaign (backward-compatible). The client resolves the cascade (dark <- dark ?? all, light <- all) per rendered theme and derives the strip border from the background in JS (no CSS color-mix, for the old Android WebView floor); AdBanner applies them as inline vars scoped to the strip. - Urgent is resolved entirely server-side: while any enabled, in-window urgent campaign exists, computeActiveSet returns only the urgent campaigns and bannerFor skips the eligibility gate — so a system notice reaches every viewer (paid / hint-holding / no_banner included) and preempts the ordinary feed. No wire field; it appears on each viewer's next profile.get. - Admin console (/_gm/banners): native colour pickers + a live light/dark preview of the strip, and an urgent toggle. The default campaign stays plain, enforced by the service and a DB CHECK. Migration 00009 is additive (nullable colour columns + a bool default + all-or-nothing / hex / default-plain CHECKs) — expand-contract, rollback-safe. Docs: ARCHITECTURE §10, UI_DESIGN, FUNCTIONAL (+ru). Tests: ads unit (urgent preempt + colour validation), codec + resolver unit, gateway transcode, and integration (colour round-trip + urgent bypass against real Postgres).
70 lines
2.6 KiB
TypeScript
70 lines
2.6 KiB
TypeScript
// 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]);
|
|
}
|