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
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:
@@ -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
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user