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