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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)!',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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.',
|
||||
|
||||
@@ -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': 'Вы не участник этой игры.',
|
||||
|
||||
@@ -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: '🏅' };
|
||||
}
|
||||
@@ -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>> {
|
||||
|
||||
Reference in New Issue
Block a user