Stage 7 polish: app shell + nav + lobby + settings (Parts A/B/C)
- Screen.svelte shell: nav bar grows, ad+content+tabbar pinned bottom (mobile feel) - AdBanner.svelte + banner.ts rotator (params, mock long/short, linkify); Header CSS chevron + grow; Menu (bigger CSS hamburger); TabBar + HoldConfirm shared components; user-select:none - Lobby: hide-empty sections, tab order New/Tournaments/Stats, place-based result badges (result.ts) - Settings: Board style > Labels (beginner/classic/none) + prefs plumbing (boardlabels.ts); i18n keys + ru mirror
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
// Announcement / "ad" banner — a parameterised rotator plus a tiny markdown linkifier.
|
||||
// The rotator is DOM-agnostic (the host measures overflow and applies the visual
|
||||
// effects through callbacks), so its timing is unit-testable with fake timers. Today
|
||||
// the content is a mock long↔short rotation; later it becomes a server-driven
|
||||
// announcements channel (see ARCHITECTURE).
|
||||
|
||||
export interface BannerConfig {
|
||||
/** How long one message is shown before advancing (short text), ms. */
|
||||
holdMs: number;
|
||||
/** Pause at each end before/after scrolling a long message, ms. */
|
||||
edgePauseMs: number;
|
||||
/** Scroll speed for a long (overflowing) message, px/sec. */
|
||||
scrollPxPerSec: number;
|
||||
/** Cross-fade duration between messages, ms. */
|
||||
fadeMs: number;
|
||||
}
|
||||
|
||||
export const defaultBannerConfig: BannerConfig = {
|
||||
holdMs: 60_000,
|
||||
edgePauseMs: 5_000,
|
||||
scrollPxPerSec: 40,
|
||||
fadeMs: 400,
|
||||
};
|
||||
|
||||
export interface BannerItem {
|
||||
/** Minimal markdown: plain text + `[label](url)` links. */
|
||||
md: string;
|
||||
}
|
||||
|
||||
/** The host the rotator drives; the Svelte component supplies the DOM measurements. */
|
||||
export interface BannerHost {
|
||||
/** Overflow width of item `index` in px (0 when it fits). */
|
||||
overflowPx(index: number): number;
|
||||
/** Render item `index` (the host fades it in and resets scroll to the start). */
|
||||
show(index: number): void;
|
||||
/** Animate the horizontal scroll to `toPx` over `durationMs`. */
|
||||
scrollTo(toPx: number, durationMs: number): void;
|
||||
}
|
||||
|
||||
export interface Rotator {
|
||||
start(): void;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* createBannerRotator drives a list of messages: a fitting message holds `holdMs`
|
||||
* then advances; an overflowing one pauses, scrolls to its right edge, pauses, then
|
||||
* repeats while the elapsed cycle is under `holdMs`, else advances.
|
||||
*/
|
||||
export function createBannerRotator(
|
||||
items: BannerItem[],
|
||||
host: BannerHost,
|
||||
config: BannerConfig = defaultBannerConfig,
|
||||
): Rotator {
|
||||
let index = 0;
|
||||
let running = false;
|
||||
let cycleStart = 0;
|
||||
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||
|
||||
const at = (ms: number, fn: () => void) => {
|
||||
timers.push(setTimeout(fn, ms));
|
||||
};
|
||||
const clear = () => {
|
||||
for (const t of timers) clearTimeout(t);
|
||||
timers.length = 0;
|
||||
};
|
||||
|
||||
function advance() {
|
||||
if (!running) return;
|
||||
index = (index + 1) % items.length;
|
||||
present();
|
||||
}
|
||||
|
||||
function present() {
|
||||
if (!running) return;
|
||||
clear();
|
||||
host.show(index);
|
||||
// Let the swapped-in message render before measuring its overflow.
|
||||
at(config.fadeMs, () => {
|
||||
const over = host.overflowPx(index);
|
||||
if (over <= 0) {
|
||||
at(config.holdMs, advance);
|
||||
return;
|
||||
}
|
||||
cycleStart = Date.now();
|
||||
scrollCycle(over);
|
||||
});
|
||||
}
|
||||
|
||||
function scrollCycle(over: number) {
|
||||
const dur = (over / config.scrollPxPerSec) * 1000;
|
||||
at(config.edgePauseMs, () => {
|
||||
host.scrollTo(over, dur);
|
||||
at(dur + config.edgePauseMs, () => {
|
||||
if (Date.now() - cycleStart >= config.holdMs) {
|
||||
advance();
|
||||
} else {
|
||||
host.show(index); // resets scroll to the start
|
||||
scrollCycle(over);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
if (running || items.length === 0) return;
|
||||
running = true;
|
||||
index = 0;
|
||||
present();
|
||||
},
|
||||
stop() {
|
||||
running = false;
|
||||
clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const URL_RE = /^(https?:\/\/|\/)/i;
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]!);
|
||||
}
|
||||
|
||||
/**
|
||||
* linkify renders minimal markdown to a safe HTML string: everything is escaped, then
|
||||
* `[label](url)` becomes a link (only http(s):// or root-relative URLs are allowed).
|
||||
*/
|
||||
export function linkify(md: string): string {
|
||||
const parts: string[] = [];
|
||||
const re = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(md)) !== null) {
|
||||
parts.push(escapeHtml(md.slice(last, m.index)));
|
||||
const label = escapeHtml(m[1]);
|
||||
const url = m[2].trim();
|
||||
if (URL_RE.test(url)) {
|
||||
parts.push(`<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${label}</a>`);
|
||||
} else {
|
||||
parts.push(label);
|
||||
}
|
||||
last = re.lastIndex;
|
||||
}
|
||||
parts.push(escapeHtml(md.slice(last)));
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
/** mockBanners is the placeholder rotation (long ↔ short) to demo the mechanics. */
|
||||
export function mockBanners(): BannerItem[] {
|
||||
return [
|
||||
{ md: 'New season starts soon — [learn more](https://example.com/season).' },
|
||||
{
|
||||
md: 'Tip: a 7-tile play earns a +50 bonus. Try the daily tournament, climb the leaderboard, and challenge friends — more modes are coming, [stay tuned](https://example.com/news)!',
|
||||
},
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user