6db9178449
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).
214 lines
8.3 KiB
Svelte
214 lines
8.3 KiB
Svelte
<script lang="ts">
|
|
import { fade } from 'svelte/transition';
|
|
import {
|
|
attachBannerHost,
|
|
bannerCurrent,
|
|
bannerCurrentColors,
|
|
configureBanner,
|
|
detachBannerHost,
|
|
remeasureBanner,
|
|
} from '../lib/bannerEngine';
|
|
import { defaultBannerTimings, linkify, type BannerHost } from '../lib/banner';
|
|
import { resolveBannerColors, type ThemeMode } from '../lib/bannerColors';
|
|
import type { BannerCampaign, BannerColors, BannerTimings } from '../lib/model';
|
|
import { onExternalLinkClick } from '../lib/links';
|
|
|
|
let {
|
|
campaigns,
|
|
timings = defaultBannerTimings,
|
|
reduceMotion = false,
|
|
}: { campaigns: BannerCampaign[]; timings?: BannerTimings; reduceMotion?: boolean } = $props();
|
|
|
|
// Initialise from the persistent engine's live message: a view mounted by navigation shows the
|
|
// 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() !== '';
|
|
|
|
// Effective timings: reduce-motion collapses the fades (instant swap) and, via overflowPx, the
|
|
// scroll. The engine is configured with the same effective timings, so the fade durations stay in
|
|
// step with the rotator's hold/transition scheduling.
|
|
const eff = $derived<BannerTimings>(
|
|
reduceMotion ? { ...timings, fadeOutMs: 0, gapMs: 0, fadeInMs: 0 } : timings,
|
|
);
|
|
|
|
// in:fade with a reliable from-state (Svelte forces opacity 0 → 1), so even the first message
|
|
// fades — unlike a bare opacity toggle, which can paint 0 and 1 in one frame and skip the fade.
|
|
// The resync mount renders the live message instantly (duration 0).
|
|
function inFade(node: Element) {
|
|
const duration = instantOnce ? 0 : eff.fadeInMs;
|
|
instantOnce = false;
|
|
return fade(node, { duration });
|
|
}
|
|
|
|
// 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, colors) {
|
|
current = md;
|
|
currentColors = colors;
|
|
tx = 0;
|
|
txDur = 0;
|
|
visible = true;
|
|
},
|
|
resetScroll() {
|
|
tx = 0;
|
|
txDur = 0;
|
|
},
|
|
hide() {
|
|
visible = false;
|
|
},
|
|
overflowPx() {
|
|
return reduceMotion ? 0 : Math.max(0, (track?.scrollWidth ?? 0) - (viewport?.clientWidth ?? 0));
|
|
},
|
|
scrollTo(toPx, durationMs) {
|
|
txDur = durationMs;
|
|
tx = -toPx;
|
|
},
|
|
resumeScroll(fromTx, toPx, durationMs) {
|
|
// Jump to the carried-over offset instantly, then continue to the end over the remaining
|
|
// time — so a long message keeps its scroll position across a navigation.
|
|
txDur = 0;
|
|
tx = fromTx;
|
|
requestAnimationFrame(() => {
|
|
txDur = durationMs;
|
|
tx = -toPx;
|
|
});
|
|
},
|
|
};
|
|
|
|
// Attach to the persistent engine on mount, detach on unmount (without stopping it). Declared
|
|
// before configure so the host is attached when the rotator first measures overflow.
|
|
$effect(() => {
|
|
attachBannerHost(host);
|
|
return () => detachBannerHost(host);
|
|
});
|
|
// (Re)configure the engine when the campaigns or effective timings change; a no-op when
|
|
// unchanged, so a remount continues the running cycle rather than restarting it.
|
|
$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(() => {
|
|
let t: ReturnType<typeof setTimeout>;
|
|
const onResize = () => {
|
|
clearTimeout(t);
|
|
t = setTimeout(remeasureBanner, 250);
|
|
};
|
|
window.addEventListener('resize', onResize);
|
|
window.addEventListener('orientationchange', onResize);
|
|
return () => {
|
|
clearTimeout(t);
|
|
window.removeEventListener('resize', onResize);
|
|
window.removeEventListener('orientationchange', onResize);
|
|
};
|
|
});
|
|
</script>
|
|
|
|
<!-- The banner links are rendered via {@html}; a delegated click routes any of them through the
|
|
platform opener (onExternalLinkClick uses closest('a')): the Telegram SDK skips the WebView
|
|
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} 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). -->
|
|
<span class="reserve" aria-hidden="true"> </span>
|
|
{#if visible}
|
|
<div class="fadewrap" in:inFade out:fade={{ duration: eff.fadeOutMs }}>
|
|
<div
|
|
class="track"
|
|
bind:this={track}
|
|
style="transform:translateX({tx}px); transition:transform {txDur}ms linear"
|
|
>
|
|
{@html linkify(current)}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.ad {
|
|
position: relative;
|
|
overflow: hidden;
|
|
white-space: nowrap;
|
|
padding: 6px 0;
|
|
background: var(--ad-bg);
|
|
color: var(--text-muted);
|
|
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(--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
|
|
fade gap; invisible and non-interactive. */
|
|
.reserve {
|
|
visibility: hidden;
|
|
}
|
|
/* The message layer is overlaid on the reserved line, so showing/hiding it never resizes .ad. */
|
|
.fadewrap {
|
|
position: absolute;
|
|
top: 6px;
|
|
left: 0;
|
|
right: 0;
|
|
}
|
|
.track {
|
|
display: inline-block;
|
|
/* The side inset lives on the track (not the clipping .ad) so the scroll distance
|
|
(scrollWidth - viewport.clientWidth) reaches the very end of a long message. */
|
|
padding: 0 var(--pad);
|
|
will-change: transform;
|
|
}
|
|
.track :global(a) {
|
|
color: var(--accent);
|
|
text-decoration: underline;
|
|
}
|
|
</style>
|