Promote development → master (initial production release: pre-release line + Stage 18) #104
+29
-22
@@ -12,12 +12,13 @@ runtime; opened outside Telegram, the `/telegram/` path redirects to the site ro
|
||||
|
||||
## Layout shell (`components/Screen.svelte`)
|
||||
|
||||
A full-height flex column: the nav bar, the announcement strip, the content, and an
|
||||
optional bottom tab bar (the tab bar always sits at the screen bottom). On most screens
|
||||
the nav is minimal and the **content fills** between nav and tab bar. **Only in the
|
||||
game** (`growNav`) does the nav bar grow to absorb spare height (buttons top-aligned),
|
||||
pinning the board and controls to the **bottom** for thumb reach. Every screen except
|
||||
Login uses `Screen`.
|
||||
A full-height flex column: the nav bar, the content, and an optional bottom tab bar (the
|
||||
tab bar always sits at the screen bottom). The **advertising strip** is docked **inside the
|
||||
nav** (`Header`), directly under the title, so it sits in the same place on every screen. On
|
||||
most screens the nav is minimal and the **content fills** between nav and tab bar. **Only in
|
||||
the game** (`growNav`) does the nav bar grow to absorb spare height, so the strip stays under
|
||||
the title while the board and controls pin to the **bottom** for thumb reach. Every screen
|
||||
except Login uses `Screen`.
|
||||
|
||||
## Navigation
|
||||
|
||||
@@ -195,24 +196,30 @@ Login uses `Screen`.
|
||||
under-board slot shows the **Scores: N** preview. The screen **title** is the variant's
|
||||
display name (Scrabble / Скрэббл / Erudite / Эрудит), not a constant "Scrabble".
|
||||
|
||||
## Advertising banner (`components/AdBanner.svelte`, `lib/banner.ts`)
|
||||
## Advertising banner (`components/AdBanner.svelte` in `components/Header.svelte`, `lib/banner.ts` + `lib/bannerEngine.ts`)
|
||||
|
||||
A one-line inset strip under the nav bar, drawn on a dedicated `--ad-bg` token —
|
||||
a subtle accent, a touch darker than the surroundings in the light theme and a touch lighter
|
||||
in the dark theme, mapped to Telegram's `secondary_bg_color` inside the Mini App. Content is
|
||||
minimal markdown (text + links, escaped + linkified). It is **server-driven**: the campaigns and
|
||||
display timings ride the `profile.get` response (`app.profile.banner`, present only for an eligible
|
||||
viewer — see ARCHITECTURE §10), so `Screen` renders the strip only when that block is present, and
|
||||
a `notify` `banner` event re-fetches the profile to show or hide it in place.
|
||||
A one-line inset strip docked inside the nav bar, directly under the title (so its position never
|
||||
jumps between screens), drawn on a dedicated `--ad-bg` token — a subtle accent, a touch darker than
|
||||
the surroundings in the light theme and a touch lighter in the dark theme, mapped to Telegram's
|
||||
`secondary_bg_color` inside the Mini App. Content is minimal markdown (text + links, escaped +
|
||||
linkified). It is **server-driven**: the campaigns and display timings ride the `profile.get`
|
||||
response (`app.profile.banner`, present only for an eligible viewer — see ARCHITECTURE §10), so
|
||||
`Header` renders the strip only when that block is present, and a `notify` `banner` event re-fetches
|
||||
the profile to show or hide it in place.
|
||||
|
||||
A **smooth weighted round-robin** (`createScheduler`) picks the next message: campaigns compete by
|
||||
their weight (each appears its weight share per cycle, evenly interleaved, not at random), and a
|
||||
campaign's own messages advance round-robin. The **rotator** then drives one message: it fades in
|
||||
(`fadeInMs`), holds `holdMs` (a message wider than the strip pauses `edgePauseMs`, scrolls to its
|
||||
right edge at `scrollPxPerSec`, pauses, and repeats while under `holdMs`), then — when more than one
|
||||
message exists — fades out (`fadeOutMs`), waits `gapMs`, and fades the next in. A lone message stays
|
||||
put (a long one keeps scrolling). All timings are operator-set (`/_gm/banner-settings`). Under
|
||||
**reduce-motion** the fades collapse to an instant swap and a long message does not scroll.
|
||||
The rotation runs in a **persistent module engine** (`lib/bannerEngine`): the scheduler and timer
|
||||
live outside the components, so navigating between screens (which remounts the view) **continues the
|
||||
cycle** rather than restarting it — each mounted `AdBanner` only attaches as the DOM host and resyncs
|
||||
to the live message. A **smooth weighted round-robin** (`createScheduler`) picks the next message:
|
||||
campaigns compete by their weight (each appears its weight share per cycle, evenly interleaved, not
|
||||
at random), and a campaign's own messages advance round-robin. One message fades in (`fadeInMs`),
|
||||
holds `holdMs` (a message wider than the strip pauses `edgePauseMs`, scrolls to its right edge at
|
||||
`scrollPxPerSec`, pauses, and repeats while under `holdMs`), then — with more than one message —
|
||||
fades out (`fadeOutMs`), waits `gapMs`, and the next fades in. The fade (a `{#if}` `transition:fade`
|
||||
layer) is independent of the scroll (the inner track's transform), so a long, scrolling message
|
||||
still fades at both ends. A lone message stays put (a long one keeps scrolling). All timings are
|
||||
operator-set (`/_gm/banner-settings`). Under **reduce-motion** the fades collapse to an instant swap
|
||||
and a long message does not scroll.
|
||||
|
||||
## Result / status iconography (`lib/result.ts`)
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { createBannerRotator, defaultBannerTimings, linkify } from '../lib/banner';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { attachBannerHost, configureBanner, detachBannerHost } from '../lib/bannerEngine';
|
||||
import { defaultBannerTimings, linkify, type BannerHost } from '../lib/banner';
|
||||
import type { BannerCampaign, BannerTimings } from '../lib/model';
|
||||
import { onExternalLinkClick } from '../lib/telegram';
|
||||
|
||||
@@ -9,48 +11,57 @@
|
||||
reduceMotion = false,
|
||||
}: { campaigns: BannerCampaign[]; timings?: BannerTimings; reduceMotion?: boolean } = $props();
|
||||
|
||||
let visible = $state(false);
|
||||
let current = $state('');
|
||||
let opacity = $state(0);
|
||||
let opDur = $state(0); // opacity transition duration, ms (fade-in / fade-out)
|
||||
let tx = $state(0);
|
||||
let txDur = $state(0);
|
||||
let track = $state<HTMLElement>();
|
||||
let viewport = $state<HTMLElement>();
|
||||
|
||||
// Recreate the rotator whenever the campaigns, timings or reduce-motion change (a
|
||||
// `banner` notify re-fetches the profile, swapping the campaigns in place). Under
|
||||
// reduce-motion the fade/gap timings collapse to 0 (instant swap) and overflowPx
|
||||
// reports 0 (no scroll).
|
||||
// Effective timings: reduce-motion collapses the fades (instant swap) and, via overflowPx, the
|
||||
// scroll. The rotation engine is configured with the same effective timings, so the fade
|
||||
// transition durations here stay in step with the rotator's hold/transition scheduling.
|
||||
const eff = $derived<BannerTimings>(
|
||||
reduceMotion ? { ...timings, fadeOutMs: 0, gapMs: 0, fadeInMs: 0 } : timings,
|
||||
);
|
||||
|
||||
// The DOM host the rotation engine drives. The fade lives on the {#if visible} layer (Svelte's
|
||||
// transition:fade — reliable and independent of the scroll), 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) {
|
||||
current = md;
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
||||
// Attach this view to the persistent engine on mount (and resync to the live message); detach on
|
||||
// unmount without stopping the engine, so navigation continues the cycle. Declared before the
|
||||
// configure effect so the host is attached when the rotator first measures overflow.
|
||||
$effect(() => {
|
||||
const cfg: BannerTimings = reduceMotion ? { ...timings, fadeOutMs: 0, gapMs: 0, fadeInMs: 0 } : timings;
|
||||
const rotator = createBannerRotator(campaigns, {
|
||||
show: (md) => {
|
||||
current = md;
|
||||
tx = 0;
|
||||
txDur = 0;
|
||||
opDur = 0;
|
||||
opacity = 0;
|
||||
requestAnimationFrame(() => {
|
||||
opDur = cfg.fadeInMs;
|
||||
opacity = 1;
|
||||
});
|
||||
},
|
||||
resetScroll: () => {
|
||||
tx = 0;
|
||||
txDur = 0;
|
||||
},
|
||||
hide: (durationMs) => {
|
||||
opDur = durationMs;
|
||||
opacity = 0;
|
||||
},
|
||||
overflowPx: () => (reduceMotion ? 0 : Math.max(0, (track?.scrollWidth ?? 0) - (viewport?.clientWidth ?? 0))),
|
||||
scrollTo: (toPx, durationMs) => {
|
||||
txDur = durationMs;
|
||||
tx = -toPx;
|
||||
},
|
||||
}, cfg);
|
||||
rotator.start();
|
||||
return () => rotator.stop();
|
||||
attachBannerHost(host);
|
||||
return () => detachBannerHost(host);
|
||||
});
|
||||
// (Re)configure the engine when the campaigns or effective timings change; a no-op when
|
||||
// unchanged, so a remount does not restart the rotation.
|
||||
$effect(() => {
|
||||
configureBanner(campaigns, eff);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -59,13 +70,17 @@
|
||||
<!-- 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="track"
|
||||
bind:this={track}
|
||||
style="transform:translateX({tx}px); opacity:{opacity}; transition:transform {txDur}ms linear, opacity {opDur}ms ease"
|
||||
>
|
||||
{@html linkify(current)}
|
||||
</div>
|
||||
{#if visible}
|
||||
<div class="fadewrap" in:fade={{ duration: eff.fadeInMs }} 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>
|
||||
@@ -81,13 +96,16 @@
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
user-select: none;
|
||||
/* A stable height so the strip does not collapse during the fade gap (the message layer is
|
||||
removed between fade-out and fade-in). */
|
||||
min-height: calc(0.85rem * 1.2 + 12px);
|
||||
}
|
||||
.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, opacity;
|
||||
will-change: transform;
|
||||
}
|
||||
.track :global(a) {
|
||||
color: var(--accent);
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import { insideTelegram } from '../lib/telegram';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { app } from '../lib/app.svelte';
|
||||
import Spinner from './Spinner.svelte';
|
||||
import AdBanner from './AdBanner.svelte';
|
||||
|
||||
let { title, back, grow = false }: { title: string; back?: string; grow?: boolean } = $props();
|
||||
|
||||
@@ -29,6 +31,16 @@
|
||||
<!-- A right-hand spacer balances the back button so the title stays centred. -->
|
||||
<span class="spacer"></span>
|
||||
</div>
|
||||
<!-- The ad banner lives inside the nav, directly under the title bar, so it sits in the
|
||||
same place on every screen — and in the game (grown nav) the spare height falls below
|
||||
it (banner under the title, board pinned to the bottom). -->
|
||||
{#if app.profile?.banner && app.profile.banner.campaigns.length}
|
||||
<AdBanner
|
||||
campaigns={app.profile.banner.campaigns}
|
||||
timings={app.profile.banner.timings}
|
||||
reduceMotion={app.reduceMotion}
|
||||
/>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import Header from './Header.svelte';
|
||||
import AdBanner from './AdBanner.svelte';
|
||||
import { app } from '../lib/app.svelte';
|
||||
import { navigate } from '../lib/router.svelte';
|
||||
|
||||
// The app-shell layout (all screens): the nav bar grows; the ad strip, content and
|
||||
@@ -89,13 +87,6 @@
|
||||
|
||||
<div class="screen">
|
||||
<Header {title} {back} grow={growNav} />
|
||||
{#if app.profile?.banner && app.profile.banner.campaigns.length}
|
||||
<AdBanner
|
||||
campaigns={app.profile.banner.campaigns}
|
||||
timings={app.profile.banner.timings}
|
||||
reduceMotion={app.reduceMotion}
|
||||
/>
|
||||
{/if}
|
||||
<main class="content" class:scroll class:fill={!growNav} class:column>{@render children?.()}</main>
|
||||
{#if tabbar}
|
||||
<nav class="tabbar">{@render tabbar()}</nav>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createBannerRotator, createScheduler, defaultBannerTimings, linkify, type BannerHost } from './banner';
|
||||
import { attachBannerHost, configureBanner, detachBannerHost } from './bannerEngine';
|
||||
import type { BannerTimings } from './model';
|
||||
|
||||
describe('linkify', () => {
|
||||
@@ -165,3 +166,57 @@ describe('createBannerRotator', () => {
|
||||
r.stop();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bannerEngine', () => {
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
// A minimal host that records the messages it is told to show.
|
||||
function engineHost(): { host: BannerHost; shown: string[] } {
|
||||
const shown: string[] = [];
|
||||
return {
|
||||
shown,
|
||||
host: { show: (md) => shown.push(md), resetScroll() {}, hide() {}, overflowPx: () => 0, scrollTo() {} },
|
||||
};
|
||||
}
|
||||
|
||||
const period = (c: BannerTimings) => c.fadeInMs + c.holdMs + c.fadeOutMs + c.gapMs + 5;
|
||||
|
||||
it('continues the cycle across a host detach/attach (does not restart)', () => {
|
||||
vi.useFakeTimers();
|
||||
const cfg: BannerTimings = { holdMs: 1000, edgePauseMs: 100, scrollPxPerSec: 200, fadeOutMs: 100, gapMs: 50, fadeInMs: 100 };
|
||||
const camps = [{ weight: 1, messages: ['x0'] }, { weight: 1, messages: ['x1'] }];
|
||||
const a = engineHost();
|
||||
configureBanner(camps, cfg);
|
||||
attachBannerHost(a.host);
|
||||
expect(a.shown).toEqual(['x0']); // resynced to the live message
|
||||
|
||||
// Swap the host mid-cycle (as navigation remounts the view): the new host resyncs to the
|
||||
// same live message rather than restarting at the first.
|
||||
detachBannerHost(a.host);
|
||||
const b = engineHost();
|
||||
attachBannerHost(b.host);
|
||||
expect(b.shown).toEqual(['x0']);
|
||||
|
||||
// The sequence advances to the next message on the new host (it continued, not restarted).
|
||||
vi.advanceTimersByTime(period(cfg));
|
||||
expect(b.shown).toContain('x1');
|
||||
detachBannerHost(b.host);
|
||||
});
|
||||
|
||||
it('does not restart on a no-op reconfigure with unchanged data', () => {
|
||||
vi.useFakeTimers();
|
||||
const cfg: BannerTimings = { holdMs: 1000, edgePauseMs: 100, scrollPxPerSec: 200, fadeOutMs: 100, gapMs: 50, fadeInMs: 100 };
|
||||
const camps = [{ weight: 1, messages: ['y0'] }, { weight: 1, messages: ['y1'] }];
|
||||
const a = engineHost();
|
||||
configureBanner(camps, cfg);
|
||||
attachBannerHost(a.host);
|
||||
vi.advanceTimersByTime(period(cfg));
|
||||
expect(a.shown).toEqual(['y0', 'y1']);
|
||||
|
||||
// Re-configuring with identical data keeps the running cycle (no fresh 'y0' burst).
|
||||
configureBanner(camps, cfg);
|
||||
vi.advanceTimersByTime(5);
|
||||
expect(a.shown).toEqual(['y0', 'y1']);
|
||||
detachBannerHost(a.host);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// A single, persistent banner rotation engine. The rotator and scheduler live here at module
|
||||
// scope, so the rotation keeps running across screen navigations: each screen mounts its own
|
||||
// AdBanner view, which only attaches as the DOM host. This makes the banner continuous and
|
||||
// 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';
|
||||
|
||||
let rotator: Rotator | null = null;
|
||||
let mounted: BannerHost | null = null;
|
||||
let current = '';
|
||||
let key = '';
|
||||
|
||||
// proxy is the rotator's host: it records the current message (so a freshly-mounted view can
|
||||
// resync to it) and forwards every effect to the currently-attached DOM host, if any.
|
||||
const proxy: BannerHost = {
|
||||
show(md) {
|
||||
current = md;
|
||||
mounted?.show(md);
|
||||
},
|
||||
resetScroll() {
|
||||
mounted?.resetScroll();
|
||||
},
|
||||
hide(durationMs) {
|
||||
mounted?.hide(durationMs);
|
||||
},
|
||||
overflowPx() {
|
||||
return mounted?.overflowPx() ?? 0;
|
||||
},
|
||||
scrollTo(toPx, durationMs) {
|
||||
mounted?.scrollTo(toPx, durationMs);
|
||||
},
|
||||
};
|
||||
|
||||
// bannerKey identifies a campaigns+timings set so configureBanner restarts the cycle only on a
|
||||
// real change, not on every (re)mount.
|
||||
function bannerKey(campaigns: BannerCampaign[], timings: BannerTimings): string {
|
||||
return JSON.stringify({ campaigns, timings });
|
||||
}
|
||||
|
||||
/**
|
||||
* configureBanner (re)starts the rotation for the given campaigns and timings, or leaves the
|
||||
* running rotation untouched when they are unchanged — so navigating between screens (which
|
||||
* remounts the view with the same data) continues the cycle rather than restarting it.
|
||||
*/
|
||||
export function configureBanner(campaigns: BannerCampaign[], timings: BannerTimings): void {
|
||||
const k = bannerKey(campaigns, timings);
|
||||
if (k === key && rotator) return;
|
||||
key = k;
|
||||
rotator?.stop();
|
||||
current = '';
|
||||
rotator = createBannerRotator(campaigns, proxy, timings);
|
||||
rotator.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* attachBannerHost connects a freshly-mounted view as the DOM host and resyncs it to the live
|
||||
* message, so the banner resumes mid-cycle instead of restarting.
|
||||
*/
|
||||
export function attachBannerHost(host: BannerHost): void {
|
||||
mounted = host;
|
||||
if (current) host.show(current);
|
||||
}
|
||||
|
||||
/**
|
||||
* detachBannerHost disconnects a view on unmount without stopping the engine. It clears the host
|
||||
* only when it is still the current one, so an outgoing view leaving after the incoming view has
|
||||
* already attached does not detach the new host (the transition briefly double-mounts).
|
||||
*/
|
||||
export function detachBannerHost(host: BannerHost): void {
|
||||
if (mounted === host) mounted = null;
|
||||
}
|
||||
Reference in New Issue
Block a user