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
+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