feat(banner): per-campaign colour overrides and urgent alerts
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).
This commit is contained in:
Ilia Denisov
2026-07-05 15:36:35 +02:00
parent ac383880b7
commit 6db9178449
32 changed files with 1297 additions and 152 deletions
+43 -5
View File
@@ -3,12 +3,14 @@
import {
attachBannerHost,
bannerCurrent,
bannerCurrentColors,
configureBanner,
detachBannerHost,
remeasureBanner,
} from '../lib/bannerEngine';
import { defaultBannerTimings, linkify, type BannerHost } from '../lib/banner';
import type { BannerCampaign, BannerTimings } from '../lib/model';
import { resolveBannerColors, type ThemeMode } from '../lib/bannerColors';
import type { BannerCampaign, BannerColors, BannerTimings } from '../lib/model';
import { onExternalLinkClick } from '../lib/links';
let {
@@ -21,12 +23,33 @@
// current message and is visible at once (no fade — see inFade), so a screen change does not
// replay the fade. Empty on the very first mount (engine not yet started).
let current = $state(bannerCurrent());
let currentColors = $state<BannerColors>(bannerCurrentColors());
let visible = $state(bannerCurrent() !== '');
let tx = $state(0);
let txDur = $state(0);
let track = $state<HTMLElement>();
let viewport = $state<HTMLElement>();
// The rendered theme (light/dark), tracked so a campaign's colour override resolves against the
// theme actually on screen and re-resolves live when the operator/OS flips it. It follows the
// [data-theme] attribute (theme.ts), or the OS preference when unset ('auto').
let themeMode = $state<ThemeMode>(resolveThemeMode());
function resolveThemeMode(): ThemeMode {
if (typeof document === 'undefined') return 'light';
const attr = document.documentElement.getAttribute('data-theme');
if (attr === 'dark' || attr === 'light') return attr;
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
// The strip's colours for the current campaign + theme (null = the neutral tokens). Applied as
// inline CSS variables scoped to .ad, so an override never leaks to the rest of the page.
const adColors = $derived(resolveBannerColors(currentColors, themeMode));
const adStyle = $derived(
adColors
? `--ad-bg:${adColors.bg};--text-muted:${adColors.fg};--accent:${adColors.link};--ad-border:${adColors.border}`
: '',
);
// The first appearance after mounting onto an already-running cycle is instant; every later
// message change fades. (Consumed by the first in:fade.)
let instantOnce = bannerCurrent() !== '';
@@ -50,8 +73,9 @@
// The DOM host the engine drives. The fade lives on the {#if} layer (transition:fade), the scroll
// on the inner track's transform, so a long message's scroll never blocks its fade in/out.
const host: BannerHost = {
show(md) {
show(md, colors) {
current = md;
currentColors = colors;
tx = 0;
txDur = 0;
visible = true;
@@ -93,6 +117,20 @@
$effect(() => {
configureBanner(campaigns, eff);
});
// Track the rendered theme so a colour override re-resolves when the theme flips: watch the
// [data-theme] attribute (Settings toggle / Telegram) and, for 'auto', the OS colour scheme.
$effect(() => {
const update = () => (themeMode = resolveThemeMode());
update();
const obs = new MutationObserver(update);
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
const mq = window.matchMedia?.('(prefers-color-scheme: dark)');
mq?.addEventListener('change', update);
return () => {
obs.disconnect();
mq?.removeEventListener('change', update);
};
});
// Re-measure on a viewport size change (e.g. a portrait↔landscape rotation): a message that fit
// may now overflow, or vice versa, so the scroll must be re-evaluated. Debounced.
$effect(() => {
@@ -116,7 +154,7 @@
confirmation, the VK away-redirect keeps the Android WebView on the game. -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="ad" bind:this={viewport} onclick={onExternalLinkClick}>
<div class="ad" bind:this={viewport} style={adStyle} onclick={onExternalLinkClick}>
<!-- An always-present, invisible spacer reserves exactly one line of height, so the strip never
collapses while the message layer is absent during the fade gap (the message is overlaid
absolutely, so its presence/absence does not change the strip height). -->
@@ -145,8 +183,8 @@
font-size: 0.85rem;
line-height: 1.2;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
border-top: 1px solid var(--ad-border, var(--border));
border-bottom: 1px solid var(--ad-border, var(--border));
user-select: none;
}
/* Reserves one line of height inside the padding so .ad keeps a constant height even during the
+74 -2
View File
@@ -37,8 +37,50 @@ messagesLength():number {
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
overrideBg():string|null
overrideBg(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
overrideBg(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 8);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
overrideFg():string|null
overrideFg(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
overrideFg(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 10);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
overrideLink():string|null
overrideLink(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
overrideLink(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 12);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
overrideBgDark():string|null
overrideBgDark(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
overrideBgDark(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 14);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
overrideFgDark():string|null
overrideFgDark(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
overrideFgDark(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 16);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
overrideLinkDark():string|null
overrideLinkDark(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
overrideLinkDark(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 18);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startBannerCampaign(builder:flatbuffers.Builder) {
builder.startObject(2);
builder.startObject(8);
}
static addWeight(builder:flatbuffers.Builder, weight:number) {
@@ -61,15 +103,45 @@ static startMessagesVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static addOverrideBg(builder:flatbuffers.Builder, overrideBgOffset:flatbuffers.Offset) {
builder.addFieldOffset(2, overrideBgOffset, 0);
}
static addOverrideFg(builder:flatbuffers.Builder, overrideFgOffset:flatbuffers.Offset) {
builder.addFieldOffset(3, overrideFgOffset, 0);
}
static addOverrideLink(builder:flatbuffers.Builder, overrideLinkOffset:flatbuffers.Offset) {
builder.addFieldOffset(4, overrideLinkOffset, 0);
}
static addOverrideBgDark(builder:flatbuffers.Builder, overrideBgDarkOffset:flatbuffers.Offset) {
builder.addFieldOffset(5, overrideBgDarkOffset, 0);
}
static addOverrideFgDark(builder:flatbuffers.Builder, overrideFgDarkOffset:flatbuffers.Offset) {
builder.addFieldOffset(6, overrideFgDarkOffset, 0);
}
static addOverrideLinkDark(builder:flatbuffers.Builder, overrideLinkDarkOffset:flatbuffers.Offset) {
builder.addFieldOffset(7, overrideLinkDarkOffset, 0);
}
static endBannerCampaign(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createBannerCampaign(builder:flatbuffers.Builder, weight:number, messagesOffset:flatbuffers.Offset):flatbuffers.Offset {
static createBannerCampaign(builder:flatbuffers.Builder, weight:number, messagesOffset:flatbuffers.Offset, overrideBgOffset:flatbuffers.Offset, overrideFgOffset:flatbuffers.Offset, overrideLinkOffset:flatbuffers.Offset, overrideBgDarkOffset:flatbuffers.Offset, overrideFgDarkOffset:flatbuffers.Offset, overrideLinkDarkOffset:flatbuffers.Offset):flatbuffers.Offset {
BannerCampaign.startBannerCampaign(builder);
BannerCampaign.addWeight(builder, weight);
BannerCampaign.addMessages(builder, messagesOffset);
BannerCampaign.addOverrideBg(builder, overrideBgOffset);
BannerCampaign.addOverrideFg(builder, overrideFgOffset);
BannerCampaign.addOverrideLink(builder, overrideLinkOffset);
BannerCampaign.addOverrideBgDark(builder, overrideBgDarkOffset);
BannerCampaign.addOverrideFgDark(builder, overrideFgDarkOffset);
BannerCampaign.addOverrideLinkDark(builder, overrideLinkDarkOffset);
return BannerCampaign.endBannerCampaign(builder);
}
}
+18 -4
View File
@@ -29,7 +29,7 @@ describe('createScheduler', () => {
{ weight: 7, messages: ['b'] },
]);
expect(sched.total).toBe(2);
const cycle = Array.from({ length: 10 }, () => sched.next());
const cycle = Array.from({ length: 10 }, () => sched.next().md);
const a = cycle.filter((m) => m === 'a').length;
const b = cycle.filter((m) => m === 'b').length;
expect(a).toBe(3);
@@ -42,7 +42,7 @@ describe('createScheduler', () => {
it('round-robins messages within a campaign', () => {
const sched = createScheduler([{ weight: 1, messages: ['m0', 'm1', 'm2'] }]);
expect(sched.total).toBe(3);
expect([sched.next(), sched.next(), sched.next(), sched.next()]).toEqual(['m0', 'm1', 'm2', 'm0']);
expect([sched.next().md, sched.next().md, sched.next().md, sched.next().md]).toEqual(['m0', 'm1', 'm2', 'm0']);
});
it('drops empty and non-positive-weight campaigns', () => {
@@ -52,13 +52,27 @@ describe('createScheduler', () => {
{ weight: 2, messages: ['keep'] },
]);
expect(sched.total).toBe(1);
expect(sched.next()).toBe('keep');
expect(sched.next().md).toBe('keep');
});
it('is empty for no campaigns', () => {
const sched = createScheduler([]);
expect(sched.total).toBe(0);
expect(sched.next()).toBe('');
expect(sched.next().md).toBe('');
});
it('carries each campaign colour override with its message', () => {
const red = { bg: '#aa0000', fg: '#ffffff', link: '#ffdd00' };
const darkRed = { bg: '#330000', fg: '#eeeeee', link: '#ffcc00' };
const sched = createScheduler([
{ weight: 1, messages: ['plain'] },
{ weight: 1, messages: ['branded'], overrideAll: red, overrideDark: darkRed },
]);
const two = [sched.next(), sched.next()];
const branded = two.find((it) => it.md === 'branded');
const plain = two.find((it) => it.md === 'plain');
expect(branded?.colors).toEqual({ all: red, dark: darkRed });
expect(plain?.colors).toEqual({ all: null, dark: null });
});
});
+21 -12
View File
@@ -4,7 +4,7 @@
// unit-testable with fake timers. The campaigns + timings come from the profile's
// banner block (see lib/model, ARCHITECTURE §10); the host is AdBanner.svelte.
import type { BannerCampaign, BannerTimings } from './model';
import type { BannerCampaign, BannerColors, BannerTimings } from './model';
/** Fallback display timings, matching the seeded ad_settings defaults. */
export const defaultBannerTimings: BannerTimings = {
@@ -16,9 +16,15 @@ export const defaultBannerTimings: BannerTimings = {
fadeInMs: 1_000,
};
/** BannerItem is one scheduled message plus its campaign's colour override (for the strip). */
export interface BannerItem {
md: string;
colors: BannerColors;
}
/** Scheduler yields the next message to show, fairly across weighted campaigns. */
export interface Scheduler {
next(): string;
next(): BannerItem;
/** Total messages across all (non-empty, positive-weight) campaigns. */
readonly total: number;
}
@@ -33,15 +39,17 @@ export interface Scheduler {
*/
export function createScheduler(campaigns: BannerCampaign[]): Scheduler {
const cs = campaigns.filter((c) => c.weight > 0 && c.messages.length > 0);
const colors: BannerColors[] = cs.map((c) => ({ all: c.overrideAll ?? null, dark: c.overrideDark ?? null }));
const current = cs.map(() => 0); // SWRR running weights
const cursor = cs.map(() => 0); // per-campaign round-robin position
const totalWeight = cs.reduce((sum, c) => sum + c.weight, 0);
const total = cs.reduce((sum, c) => sum + c.messages.length, 0);
const empty: BannerColors = { all: null, dark: null };
return {
total,
next(): string {
if (cs.length === 0) return '';
next(): BannerItem {
if (cs.length === 0) return { md: '', colors: empty };
let best = 0;
for (let i = 0; i < cs.length; i++) {
current[i] += cs[i].weight;
@@ -51,15 +59,16 @@ export function createScheduler(campaigns: BannerCampaign[]): Scheduler {
const c = cs[best];
const md = c.messages[cursor[best] % c.messages.length];
cursor[best]++;
return md;
return { md, colors: colors[best] };
},
};
}
/** The host the rotator drives; AdBanner.svelte supplies the DOM measurements and effects. */
export interface BannerHost {
/** Render md and fade it in (over fadeInMs), with the scroll reset to the start. */
show(md: string): void;
/** Render md and fade it in (over fadeInMs), with the scroll reset to the start. colors is the
* source campaign's colour override, applied to the strip (its neutral tokens when unset). */
show(md: string, colors: BannerColors): void;
/** Reset the scroll to the start without re-fading (to loop a long message). */
resetScroll(): void;
/** Fade the current message out over durationMs. */
@@ -98,7 +107,7 @@ export function createBannerRotator(
const sched = createScheduler(campaigns);
let running = false;
let cycleStart = 0;
let lastShown = ''; // the message currently presented, for restart() (re-measure)
let lastShown: BannerItem | null = null; // the item currently presented, for restart() (re-measure)
const timers: ReturnType<typeof setTimeout>[] = [];
const at = (ms: number, fn: () => void) => {
@@ -109,11 +118,11 @@ export function createBannerRotator(
timers.length = 0;
};
function present(md: string) {
function present(item: BannerItem) {
if (!running) return;
clear();
lastShown = md;
host.show(md);
lastShown = item;
host.show(item.md, item.colors);
at(config.fadeInMs, measure);
}
@@ -143,7 +152,7 @@ export function createBannerRotator(
// the same fade as a message change, not a hard jump.
host.hide(config.fadeOutMs);
at(config.fadeOutMs + config.gapMs, () => {
host.show(lastShown);
if (lastShown) host.show(lastShown.md, lastShown.colors);
at(config.fadeInMs, () => scrollCycle(over));
});
}
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { derivedBorder, resolveBannerColors } from './bannerColors';
import type { BannerColors } from './model';
const red = { bg: '#aa0000', fg: '#ffffff', link: '#ffdd00' };
const darkRed = { bg: '#330000', fg: '#eeeeee', link: '#ffcc00' };
describe('resolveBannerColors', () => {
it('returns null when the campaign has no override (neutral tokens)', () => {
const none: BannerColors = { all: null, dark: null };
expect(resolveBannerColors(none, 'light')).toBeNull();
expect(resolveBannerColors(none, 'dark')).toBeNull();
});
it('applies the all-themes set on both themes when no dark override', () => {
const c: BannerColors = { all: red, dark: null };
expect(resolveBannerColors(c, 'light')).toMatchObject({ bg: red.bg, fg: red.fg, link: red.link });
expect(resolveBannerColors(c, 'dark')).toMatchObject({ bg: red.bg, fg: red.fg, link: red.link });
});
it('prefers the dark override on the dark theme, and the all set on light', () => {
const c: BannerColors = { all: red, dark: darkRed };
expect(resolveBannerColors(c, 'light')).toMatchObject({ bg: red.bg });
expect(resolveBannerColors(c, 'dark')).toMatchObject({ bg: darkRed.bg });
});
it('supports a dark-only override (light keeps the neutral tokens)', () => {
const c: BannerColors = { all: null, dark: darkRed };
expect(resolveBannerColors(c, 'light')).toBeNull();
expect(resolveBannerColors(c, 'dark')).toMatchObject({ bg: darkRed.bg });
});
it('derives the border from the resolved background', () => {
const c: BannerColors = { all: red, dark: null };
expect(resolveBannerColors(c, 'light')?.border).toBe(derivedBorder(red.bg));
});
});
describe('derivedBorder', () => {
it('darkens a light background and lightens a dark one', () => {
expect(derivedBorder('#ffffff')).toBe('#dbdbdb');
expect(derivedBorder('#000000')).toBe('#242424');
});
it('returns a malformed colour unchanged', () => {
expect(derivedBorder('nope')).toBe('nope');
});
});
+69
View File
@@ -0,0 +1,69 @@
// 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]);
}
+17 -3
View File
@@ -4,11 +4,14 @@
// independent of route transitions (the cycle is not restarted on navigation).
import { createBannerRotator, type BannerHost, type Rotator } from './banner';
import type { BannerCampaign, BannerTimings } from './model';
import type { BannerCampaign, BannerColors, BannerTimings } from './model';
const noColors: BannerColors = { all: null, dark: null };
let rotator: Rotator | null = null;
let mounted: BannerHost | null = null;
let current = '';
let currentColors: BannerColors = noColors;
let key = '';
// The in-flight horizontal scroll of the current message, so a view mounted by navigation can
// resume it from the same offset instead of restarting at the left. Null when not scrolling.
@@ -17,10 +20,11 @@ let activeScroll: { toPx: number; dur: number; start: number } | null = null;
// proxy is the rotator's host: it records the current message + scroll (so a freshly-mounted view
// can resume them) and forwards every effect to the currently-attached DOM host, if any.
const proxy: BannerHost = {
show(md) {
show(md, colors) {
current = md;
currentColors = colors;
activeScroll = null;
mounted?.show(md);
mounted?.show(md, colors);
},
resetScroll() {
activeScroll = null;
@@ -58,6 +62,7 @@ export function configureBanner(campaigns: BannerCampaign[], timings: BannerTimi
key = k;
rotator?.stop();
current = '';
currentColors = noColors;
rotator = createBannerRotator(campaigns, proxy, timings);
rotator.start();
}
@@ -71,6 +76,15 @@ export function bannerCurrent(): string {
return current;
}
/**
* bannerCurrentColors returns the colour override of the campaign whose message is currently
* displayed (neutral tokens — both sets null — before the first message). A freshly-mounted view
* reads it alongside bannerCurrent so the strip paints the right colours immediately on mount.
*/
export function bannerCurrentColors(): BannerColors {
return currentColors;
}
/**
* attachBannerHost connects a freshly-mounted view as the DOM host. It does NOT re-show the
* current message (the view renders it itself from bannerCurrent on mount): re-showing here would
+26 -2
View File
@@ -647,7 +647,10 @@ describe('codec', () => {
const uid = b.createString('u-1');
const m0 = b.createString('promo-en');
const msgs = fb.BannerCampaign.createMessagesVector(b, [m0]);
const camp = fb.BannerCampaign.createBannerCampaign(b, 3, msgs);
fb.BannerCampaign.startBannerCampaign(b);
fb.BannerCampaign.addWeight(b, 3);
fb.BannerCampaign.addMessages(b, msgs);
const camp = fb.BannerCampaign.endBannerCampaign(b);
const camps = fb.BannerInfo.createCampaignsVector(b, [camp]);
const banner = fb.BannerInfo.createBannerInfo(b, camps, 60000, 5000, 40, 1000, 250, 1000);
fb.Profile.startProfile(b);
@@ -656,7 +659,8 @@ describe('codec', () => {
b.finish(fb.Profile.endProfile(b));
const p = decodeProfile(b.asUint8Array());
expect(p.banner?.campaigns).toEqual([{ weight: 3, messages: ['promo-en'] }]);
// A plain campaign carries no override (both sets null → the neutral theme tokens).
expect(p.banner?.campaigns).toEqual([{ weight: 3, messages: ['promo-en'], overrideAll: null, overrideDark: null }]);
expect(p.banner?.timings).toEqual({
holdMs: 60000,
edgePauseMs: 5000,
@@ -667,6 +671,26 @@ describe('codec', () => {
});
});
it('decodes a campaign colour override (both sets)', () => {
const b = new Builder(256);
const uid = b.createString('u-1');
const m0 = b.createString('branded');
const msgs = fb.BannerCampaign.createMessagesVector(b, [m0]);
const s = (v: string) => b.createString(v);
const [obg, ofg, ol, obgd, ofgd, old] = ['#aa0000', '#ffffff', '#ffdd00', '#330000', '#eeeeee', '#ffcc00'].map(s);
const camp = fb.BannerCampaign.createBannerCampaign(b, 1, msgs, obg, ofg, ol, obgd, ofgd, old);
const camps = fb.BannerInfo.createCampaignsVector(b, [camp]);
const banner = fb.BannerInfo.createBannerInfo(b, camps, 60000, 5000, 40, 1000, 250, 1000);
fb.Profile.startProfile(b);
fb.Profile.addUserId(b, uid);
fb.Profile.addBanner(b, banner);
b.finish(fb.Profile.endProfile(b));
const p = decodeProfile(b.asUint8Array());
expect(p.banner?.campaigns[0].overrideAll).toEqual({ bg: '#aa0000', fg: '#ffffff', link: '#ffdd00' });
expect(p.banner?.campaigns[0].overrideDark).toEqual({ bg: '#330000', fg: '#eeeeee', link: '#ffcc00' });
});
it('leaves the profile banner undefined when absent', () => {
const b = new Builder(64);
const uid = b.createString('u-1');
+13 -1
View File
@@ -19,6 +19,7 @@ import type {
BestMoveTile,
BlockStatus,
ChatMessage,
ColorTriple,
EvalResult,
FeedbackState,
FriendCode,
@@ -368,6 +369,12 @@ function decodeVariantPreferences(p: fb.Profile): Variant[] {
return out;
}
// bannerTriple builds a colour override from the three wire fields, or null when the
// set is absent (any field empty/missing) — the strip then keeps the neutral tokens.
function bannerTriple(bg: string | null, fg: string | null, link: string | null): ColorTriple | null {
return bg && fg && link ? { bg, fg, link } : null;
}
// decodeBanner projects the optional advertising-banner block of a Profile, or
// undefined when the viewer is not eligible (the field is absent).
function decodeBanner(p: fb.Profile): Banner | undefined {
@@ -379,7 +386,12 @@ function decodeBanner(p: fb.Profile): Banner | undefined {
if (!c) continue;
const messages: string[] = [];
for (let j = 0; j < c.messagesLength(); j++) messages.push(c.messages(j));
campaigns.push({ weight: c.weight(), messages });
campaigns.push({
weight: c.weight(),
messages,
overrideAll: bannerTriple(c.overrideBg(), c.overrideFg(), c.overrideLink()),
overrideDark: bannerTriple(c.overrideBgDark(), c.overrideFgDark(), c.overrideLinkDark()),
});
}
return {
campaigns,
+21
View File
@@ -169,12 +169,33 @@ export interface Banner {
timings: BannerTimings;
}
/** ColorTriple is a banner colour override's three "#rrggbb" colours. */
export interface ColorTriple {
bg: string;
fg: string;
link: string;
}
/**
* BannerColors is a campaign's optional palette: `all` overrides every theme, `dark`
* further overrides the dark theme (either may be null — the neutral tokens then apply).
* resolveBannerColors (lib/bannerColors) collapses it to the rendered theme's colours.
*/
export interface BannerColors {
all: ColorTriple | null;
dark: ColorTriple | null;
}
/** BannerCampaign is one campaign in the rotation feed. */
export interface BannerCampaign {
/** GCD-reduced show weight for the client's smooth weighted round-robin. */
weight: number;
/** Messages in display order, already resolved to the viewer's bot language. */
messages: string[];
/** Colour override for every theme; null (or absent) keeps the neutral tokens. */
overrideAll?: ColorTriple | null;
/** Colour override for the dark theme only; null (or absent) falls back to overrideAll/tokens. */
overrideDark?: ColorTriple | null;
}
/** BannerTimings are the global banner display timings (ms, except scrollPxPerSec). */