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:
Ilia Denisov
2026-06-03 13:20:56 +02:00
parent 03347c5a91
commit 38be7fea96
18 changed files with 871 additions and 244 deletions
+15 -1
View File
@@ -10,6 +10,7 @@ import { navigate, router } from './router.svelte';
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
import { applyReduceMotion, applyTheme, type ThemePref } from './theme';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
import type { BoardLabelMode } from './boardlabels';
export interface Toast {
kind: 'error' | 'info';
@@ -25,6 +26,7 @@ export const app = $state<{
theme: ThemePref;
locale: Locale;
reduceMotion: boolean;
boardLabels: BoardLabelMode;
localeLocked: boolean;
}>({
ready: false,
@@ -35,6 +37,7 @@ export const app = $state<{
theme: 'auto',
locale: 'en',
reduceMotion: false,
boardLabels: 'beginner',
localeLocked: false,
});
@@ -101,6 +104,7 @@ export async function bootstrap(): Promise<void> {
const prefs = await loadPrefs();
app.theme = prefs.theme ?? 'auto';
app.reduceMotion = prefs.reduceMotion ?? false;
app.boardLabels = prefs.boardLabels ?? 'beginner';
applyTheme(app.theme);
applyReduceMotion(app.reduceMotion);
if (prefs.locale) {
@@ -163,7 +167,12 @@ export async function logout(): Promise<void> {
}
function persistPrefs(): void {
void savePrefs({ theme: app.theme, locale: app.locale, reduceMotion: app.reduceMotion });
void savePrefs({
theme: app.theme,
locale: app.locale,
reduceMotion: app.reduceMotion,
boardLabels: app.boardLabels,
});
}
export function setTheme(theme: ThemePref): void {
@@ -184,3 +193,8 @@ export function setReduceMotion(on: boolean): void {
applyReduceMotion(on);
persistPrefs();
}
export function setBoardLabels(mode: BoardLabelMode): void {
app.boardLabels = mode;
persistPrefs();
}
+157
View File
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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)!',
},
];
}
+36
View File
@@ -0,0 +1,36 @@
// Bonus-square label modes (a client setting, separate from the theme). The board
// renders these locally — premiums are not on the wire. Default is "beginner".
import type { Premium } from './premiums';
import type { Locale } from './i18n/catalog';
export type BoardLabelMode = 'beginner' | 'classic' | 'none';
export type BonusLabel =
| { kind: 'single'; text: string }
| { kind: 'split'; top: string; bottom: string }
| null;
function multiplier(p: Premium): number {
return p === 'TW' || p === 'TL' ? 3 : 2;
}
function isWord(p: Premium): boolean {
return p === 'TW' || p === 'DW';
}
/**
* bonusLabel returns how a premium square is labelled: `classic` "3W"/"3С", `beginner`
* a split "3×" / "word" (localized), or nothing.
*/
export function bonusLabel(mode: BoardLabelMode, p: Premium, locale: Locale): BonusLabel {
if (mode === 'none' || p === '') return null;
const n = multiplier(p);
const word = isWord(p);
if (mode === 'classic') {
const tag = locale === 'ru' ? (word ? 'С' : 'Б') : word ? 'W' : 'L';
return { kind: 'single', text: `${n}${tag}` };
}
const bottom = locale === 'ru' ? (word ? 'слово' : 'буква') : word ? 'word' : 'letter';
return { kind: 'split', top: `${n}×`, bottom };
}
+20
View File
@@ -76,6 +76,20 @@ export const en = {
'game.wordIllegal': '“{word}” is not valid',
'game.complain': 'Disagree',
'game.complaintSent': 'Thanks, sent for review.',
'game.confirm': 'Ok',
'game.check': 'Check',
'game.checkWait': 'Please wait a moment.',
'game.noHintOptions': 'No options with your letters.',
'game.scores': 'Scores: {n}',
'result.victory': 'Victory',
'result.defeat': 'Defeat',
'result.draw': 'Draw',
'result.place2': 'II place',
'result.place3': 'III place',
'result.place4': 'IV place',
'result.yourMove': 'Your move',
'result.oppMove': "Opponent's move",
'chat.placeholder': 'Quick message…',
'chat.send': 'Send',
@@ -96,6 +110,11 @@ export const en = {
'settings.themeLight': 'Light',
'settings.themeDark': 'Dark',
'settings.language': 'Interface language',
'settings.boardStyle': 'Board style',
'settings.boardLabels': 'Bonus labels',
'settings.labelsBeginner': 'Beginner',
'settings.labelsClassic': 'Classic',
'settings.labelsNone': 'None',
'settings.reduceMotion': 'Reduce motion',
'about.title': 'About',
@@ -108,6 +127,7 @@ export const en = {
'error.not_your_turn': "It is not your turn.",
'error.illegal_play': 'That is not a legal play.',
'error.hint_unavailable': 'No hints available.',
'error.no_hint_available': 'No options with your letters.',
'error.chat_rejected': 'Message rejected (too long or contains contact info).',
'error.game_finished': 'This game is finished.',
'error.not_a_player': 'You are not a player in this game.',
+20
View File
@@ -77,6 +77,20 @@ export const ru: Record<MessageKey, string> = {
'game.wordIllegal': '«{word}» недопустимо',
'game.complain': 'Не согласен',
'game.complaintSent': 'Спасибо, отправлено на проверку.',
'game.confirm': 'Да',
'game.check': 'Проверить',
'game.checkWait': 'Секунду, пожалуйста.',
'game.noHintOptions': 'Нет вариантов с вашим набором.',
'game.scores': 'Очков: {n}',
'result.victory': 'Победа',
'result.defeat': 'Поражение',
'result.draw': 'Ничья',
'result.place2': 'II место',
'result.place3': 'III место',
'result.place4': 'IV место',
'result.yourMove': 'Ваш ход',
'result.oppMove': 'Ход соперника',
'chat.placeholder': 'Короткое сообщение…',
'chat.send': 'Отправить',
@@ -97,6 +111,11 @@ export const ru: Record<MessageKey, string> = {
'settings.themeLight': 'Светлая',
'settings.themeDark': 'Тёмная',
'settings.language': 'Язык интерфейса',
'settings.boardStyle': 'Стиль доски',
'settings.boardLabels': 'Подписи бонусов',
'settings.labelsBeginner': 'Новичок',
'settings.labelsClassic': 'Классика',
'settings.labelsNone': 'Без текста',
'settings.reduceMotion': 'Меньше анимаций',
'about.title': 'О программе',
@@ -109,6 +128,7 @@ export const ru: Record<MessageKey, string> = {
'error.not_your_turn': 'Сейчас не ваш ход.',
'error.illegal_play': 'Это недопустимый ход.',
'error.hint_unavailable': 'Подсказки недоступны.',
'error.no_hint_available': 'Нет вариантов с вашим набором.',
'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).',
'error.game_finished': 'Эта игра уже завершена.',
'error.not_a_player': 'Вы не участник этой игры.',
+30
View File
@@ -0,0 +1,30 @@
// Pure mapping from a game view (for the viewer) to a status/result badge: a label key
// and a place-based emoji. Used by the lobby lists.
import type { GameView } from './model';
import type { MessageKey } from './i18n/catalog';
export interface ResultBadge {
key: MessageKey;
emoji: string;
}
export function resultBadge(game: GameView, myId: string): ResultBadge {
const me = game.seats.find((s) => s.accountId === myId);
if (game.status === 'active') {
return game.toMove === me?.seat
? { key: 'result.yourMove', emoji: '🟢' }
: { key: 'result.oppMove', emoji: '⏳' };
}
if (me?.isWinner) return { key: 'result.victory', emoji: '🏆' };
if (!game.seats.some((s) => s.isWinner)) return { key: 'result.draw', emoji: '🏅' };
// Someone else won — place the viewer by score (1 + number of higher scores).
const rank = 1 + game.seats.filter((s) => s.score > (me?.score ?? 0)).length;
if (rank <= 1) return { key: 'result.victory', emoji: '🏆' };
if (rank === 2) return game.players === 2 ? { key: 'result.defeat', emoji: '🥈' } : { key: 'result.place2', emoji: '🥈' };
if (rank === 3) return { key: 'result.place3', emoji: '🥉' };
return { key: 'result.place4', emoji: '🏅' };
}
+2
View File
@@ -6,6 +6,7 @@
import type { Session } from './model';
import type { ThemePref } from './theme';
import type { Locale } from './i18n/catalog';
import type { BoardLabelMode } from './boardlabels';
const DB_NAME = 'scrabble';
const STORE = 'kv';
@@ -122,6 +123,7 @@ export interface Prefs {
theme: ThemePref;
locale: Locale;
reduceMotion: boolean;
boardLabels: BoardLabelMode;
}
export async function loadPrefs(): Promise<Partial<Prefs>> {