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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user