From 38be7fea968232ae5804db6922a340f316d82438 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 3 Jun 2026 13:20:56 +0200 Subject: [PATCH 01/11] 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 --- ui/src/components/AdBanner.svelte | 77 ++++++++++ ui/src/components/Header.svelte | 63 ++++++--- ui/src/components/HoldConfirm.svelte | 102 ++++++++++++++ ui/src/components/Menu.svelte | 83 +++++++++++ ui/src/components/Screen.svelte | 51 +++++++ ui/src/components/TabBar.svelte | 62 ++++++++ ui/src/lib/app.svelte.ts | 16 ++- ui/src/lib/banner.ts | 157 +++++++++++++++++++++ ui/src/lib/boardlabels.ts | 36 +++++ ui/src/lib/i18n/en.ts | 20 +++ ui/src/lib/i18n/ru.ts | 20 +++ ui/src/lib/result.ts | 30 ++++ ui/src/lib/session.ts | 2 + ui/src/screens/About.svelte | 15 +- ui/src/screens/Lobby.svelte | 202 ++++++++------------------- ui/src/screens/NewGame.svelte | 40 +++--- ui/src/screens/Profile.svelte | 37 ++--- ui/src/screens/Settings.svelte | 102 +++++++++----- 18 files changed, 871 insertions(+), 244 deletions(-) create mode 100644 ui/src/components/AdBanner.svelte create mode 100644 ui/src/components/HoldConfirm.svelte create mode 100644 ui/src/components/Menu.svelte create mode 100644 ui/src/components/Screen.svelte create mode 100644 ui/src/components/TabBar.svelte create mode 100644 ui/src/lib/banner.ts create mode 100644 ui/src/lib/boardlabels.ts create mode 100644 ui/src/lib/result.ts diff --git a/ui/src/components/AdBanner.svelte b/ui/src/components/AdBanner.svelte new file mode 100644 index 0000000..daae87a --- /dev/null +++ b/ui/src/components/AdBanner.svelte @@ -0,0 +1,77 @@ + + +
+ {#key current} +
+ {@html linkify(items[current]?.md ?? '')} +
+ {/key} +
+ + diff --git a/ui/src/components/Header.svelte b/ui/src/components/Header.svelte index 692b282..28b55c3 100644 --- a/ui/src/components/Header.svelte +++ b/ui/src/components/Header.svelte @@ -5,27 +5,38 @@ let { title, back, menu }: { title: string; back?: string; menu?: Snippet } = $props(); -
- {#if back} - - {:else} - - {/if} -

{title}

-
{#if menu}{@render menu()}{/if}
+ diff --git a/ui/src/components/HoldConfirm.svelte b/ui/src/components/HoldConfirm.svelte new file mode 100644 index 0000000..402d92a --- /dev/null +++ b/ui/src/components/HoldConfirm.svelte @@ -0,0 +1,102 @@ + + +
+ + + {#if open} + + +
+
{@render popover(close)}
+ {/if} +
+ + diff --git a/ui/src/components/Menu.svelte b/ui/src/components/Menu.svelte new file mode 100644 index 0000000..3fb4a08 --- /dev/null +++ b/ui/src/components/Menu.svelte @@ -0,0 +1,83 @@ + + + + + diff --git a/ui/src/components/Screen.svelte b/ui/src/components/Screen.svelte new file mode 100644 index 0000000..ef0f2a6 --- /dev/null +++ b/ui/src/components/Screen.svelte @@ -0,0 +1,51 @@ + + +
+
+ +
{@render children?.()}
+ {#if tabbar} + + {/if} +
+ + diff --git a/ui/src/components/TabBar.svelte b/ui/src/components/TabBar.svelte new file mode 100644 index 0000000..4810ae9 --- /dev/null +++ b/ui/src/components/TabBar.svelte @@ -0,0 +1,62 @@ + + +
{@render children?.()}
+ + diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index d164955..2fbce2f 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -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 { 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 { } 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(); +} diff --git a/ui/src/lib/banner.ts b/ui/src/lib/banner.ts new file mode 100644 index 0000000..e942752 --- /dev/null +++ b/ui/src/lib/banner.ts @@ -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[] = []; + + 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(`${label}`); + } 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)!', + }, + ]; +} diff --git a/ui/src/lib/boardlabels.ts b/ui/src/lib/boardlabels.ts new file mode 100644 index 0000000..f774ddf --- /dev/null +++ b/ui/src/lib/boardlabels.ts @@ -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 }; +} diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index aeef360..1279f19 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -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.', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index b8c075e..f541894 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -77,6 +77,20 @@ export const ru: Record = { '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 = { '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 = { 'error.not_your_turn': 'Сейчас не ваш ход.', 'error.illegal_play': 'Это недопустимый ход.', 'error.hint_unavailable': 'Подсказки недоступны.', + 'error.no_hint_available': 'Нет вариантов с вашим набором.', 'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).', 'error.game_finished': 'Эта игра уже завершена.', 'error.not_a_player': 'Вы не участник этой игры.', diff --git a/ui/src/lib/result.ts b/ui/src/lib/result.ts new file mode 100644 index 0000000..479db04 --- /dev/null +++ b/ui/src/lib/result.ts @@ -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: '🏅' }; +} diff --git a/ui/src/lib/session.ts b/ui/src/lib/session.ts index fac640e..f4f703d 100644 --- a/ui/src/lib/session.ts +++ b/ui/src/lib/session.ts @@ -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> { diff --git a/ui/src/screens/About.svelte b/ui/src/screens/About.svelte index a641724..874f0da 100644 --- a/ui/src/screens/About.svelte +++ b/ui/src/screens/About.svelte @@ -1,16 +1,17 @@ -
-
-

{t('app.title')}

-

{t('about.description')}

-

{t('about.version', { v: version })}

-
+ +
+

{t('app.title')}

+

{t('about.description')}

+

{t('about.version', { v: version })}

+
+
diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index 74ee12a..40d9c04 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -1,6 +1,6 @@ -
-
- {#if searching} -
-
-

{t('new.searching')}

- -
- {:else} -

{t('new.subtitle')}

-
- {#each variants as v (v.id)} - - {/each} -
- {/if} -
+ +
+ {#if searching} +
+
+

{t('new.searching')}

+ +
+ {:else} +

{t('new.subtitle')}

+
+ {#each variants as v (v.id)} + + {/each} +
+ {/if} +
+
diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 95726f3..49e452a 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -1,14 +1,16 @@ -
+ {#snippet menu()} - - {#if menuOpen} - - -
(menuOpen = false)}>
- - {/if} + {/snippet} -
-{#if view} -
- {#each view.game.seats as s (s.seat)} -
-
{s.accountId === app.session?.userId ? t('common.you') : s.displayName}
-
{s.score}
-
- {/each} -
- -
- (zoomed = !zoomed)} - /> -
- -
- {t('game.bag', { n: view.bagLen })} - {#if gameOver} - {t('game.over')} — {resultText()} - {:else} - {isMyTurn ? t('game.yourTurn') : t('game.waiting', { name: view.game.seats[view.game.toMove]?.displayName ?? '' })} - {/if} - {t('game.hints', { n: view.hintsRemaining })} -
- - {#if !gameOver} -
-
- -
- {#if placement.pending.length > 0} - - {/if} + {#if view} +
+ {#each view.game.seats as s (s.seat)} +
+
{s.accountId === app.session?.userId ? t('common.you') : s.displayName}
+
{s.score}
+
+ {/each}
- +
+ {#if historyOpen} +
+
    + {#each moves as m, i (i)} +
  1. + {view.game.seats[m.player]?.displayName ?? m.player} + {m.action === 'play' ? m.words.join(', ') : m.action} + {m.score} +
  2. + {/each} + {#if moves.length === 0}
  3. {/if} +
+
+ {/if} + + + +
historyOpen && (historyOpen = false)} + > + (zoomed = !zoomed)} + /> +
+
+ +
+ {t('game.bag', { n: view.bagLen })} + {#if gameOver} + {t('game.over')} — {resultText()} + {:else} + {isMyTurn ? t('game.yourTurn') : view.game.seats[view.game.toMove]?.displayName ?? ''} + {/if} + + {#if preview}{preview.legal ? t('game.scores', { n: preview.score }) : t('game.previewIllegal')}{/if} + +
+ + {#if !gameOver} +
+
+ +
+ {#if placement.pending.length > 0} + + {#snippet trigger()}🏁{/snippet} + {#snippet popover(close)} + + + {/snippet} + + {/if} +
+ {/if} + {:else} +

{t('common.loading')}

{/if} -{:else} -

{t('common.loading')}

-{/if} + + {#snippet tabbar()} + {#if view && !gameOver} + + + {#snippet trigger()}🔄{t('game.draw')}{/snippet} + {#snippet popover(close)}{/snippet} + + + {#snippet trigger()}🥺{t('game.skip')}{/snippet} + {#snippet popover(close)}{/snippet} + + + {#snippet trigger()} + 🛟{#if (view?.hintsRemaining ?? 0) > 0}{view?.hintsRemaining}{/if} + {t('game.hint')} + {/snippet} + {#snippet popover(close)}{/snippet} + + + + {/if} + {/snippet} + {#if drag}
@@ -432,6 +504,10 @@
{/if} +{#if ambiguous && placement.pending.length > 0} + +{/if} + {#if blankPrompt} (blankPrompt = null)}>
@@ -460,8 +536,13 @@ {#if checkOpen} (checkOpen = false)}>
- e.key === 'Enter' && runCheck()} /> - + e.key === 'Enter' && runCheck()} + placeholder={t('game.checkWordPrompt')} + /> +
{#if checkResult}

@@ -489,27 +570,12 @@ {/if} -{#if panel === 'history' && view} - (panel = 'none')}> -

    - {#each moves as m, i (i)} -
  1. - {view.game.seats[m.player]?.displayName ?? m.player} - {m.action === 'play' ? m.words.join(', ') : m.action} - {m.score} -
  2. - {/each} -
-
-{/if} - diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte index e00beae..9a690bb 100644 --- a/ui/src/game/Rack.svelte +++ b/ui/src/game/Rack.svelte @@ -15,45 +15,41 @@ selected: number | null; ondown: (e: PointerEvent, index: number) => void; } = $props(); + + // Used slots are hidden (the rack shifts left, freeing room on the right for the + // MakeMove control); the slot still exists in the model for per-tile recall. + const visible = $derived(slots.filter((s) => !s.used));
- {#each slots as slot (slot.index)} - {#if slot.used} - - {:else} - - {/if} + {#each visible as slot (slot.index)} + {/each}
diff --git a/ui/src/components/Screen.svelte b/ui/src/components/Screen.svelte index ef0f2a6..12a2b93 100644 --- a/ui/src/components/Screen.svelte +++ b/ui/src/components/Screen.svelte @@ -13,6 +13,7 @@ tabbar, children, scroll = true, + growNav = false, }: { title: string; back?: string; @@ -20,13 +21,14 @@ tabbar?: Snippet; children?: Snippet; scroll?: boolean; + growNav?: boolean; } = $props();
-
+
-
{@render children?.()}
+
{@render children?.()}
{#if tabbar} {/if} @@ -42,6 +44,9 @@ flex: 0 1 auto; min-height: 0; } + .content.fill { + flex: 1 1 auto; + } .content.scroll { overflow-y: auto; } diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index 63d5403..17f307e 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -10,7 +10,8 @@ board, premium, pending, - recent, + highlight, + flash, centre, zoomed, variant, @@ -23,7 +24,8 @@ board: (BoardCell | null)[][]; premium: Premium[][]; pending: Map; - recent: Set; + highlight: Set; + flash: boolean; centre: { row: number; col: number }; zoomed: boolean; variant: Variant; @@ -40,19 +42,20 @@ let viewport = $state(); - // When zoomed in (typically on a placement), centre the focus cell. + // Genuine layout zoom (the board grows; cqw labels stay constant), so native scroll + // works in every browser. Centre the focus cell when zoomed in. $effect(() => { const vp = viewport; if (!vp || !zoomed || !focus) return; - const cell = vp.clientWidth / 15; + const cell = (vp.clientWidth * Z) / 15; vp.scrollTo({ - left: (focus.col + 0.5) * cell * Z - vp.clientWidth / 2, - top: (focus.row + 0.5) * cell * Z - vp.clientHeight / 2, + left: (focus.col + 0.5) * cell - vp.clientWidth / 2, + top: (focus.row + 0.5) * cell - vp.clientHeight / 2, behavior: 'smooth', }); }); - // Double-tap toggles zoom. + // Double-tap toggles zoom (pinch was dropped — it conflicts with native scroll). let lastTap = 0; function onTap(row: number, col: number) { const now = Date.now(); @@ -65,50 +68,11 @@ oncell(row, col); } - // Minimal pinch: spread zooms in, pinch zooms out (two-state). - const pts = new Map(); - let startDist = 0; - function dist(): number { - const p = [...pts.values()]; - return p.length < 2 ? 0 : Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y); - } - function onPointerDown(e: PointerEvent) { - pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); - if (pts.size === 2) startDist = dist(); - } - function onPointerMove(e: PointerEvent) { - if (!pts.has(e.pointerId)) return; - pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); - if (pts.size === 2 && startDist > 0) { - const d = dist(); - if (!zoomed && d > startDist * 1.25) { - ontogglezoom(); - startDist = 0; - } else if (zoomed && d < startDist * 0.8) { - ontogglezoom(); - startDist = 0; - } - } - } - function onPointerUp(e: PointerEvent) { - pts.delete(e.pointerId); - if (pts.size < 2) startDist = 0; - } - const key = (r: number, c: number) => `${r},${c}`; - -
-
+
+
{#each board as rowCells, r (r)} {#each rowCells as cell, c (c)} @@ -120,24 +84,23 @@ class="cell {premClass[premium[r][c]]}" class:filled={!!cell} class:pending={!!p && !cell} - class:recent={recent.has(key(r, c))} + class:hl={!!cell && highlight.has(key(r, c))} + class:flash={!!cell && flash && highlight.has(key(r, c))} data-cell data-row={r} data-col={c} onclick={() => onTap(r, c)} > - - {#if letter} - {letter} - {#if !blank}{tileValue(variant, letter)}{/if} - {:else if r === centre.row && c === centre.col} - - {:else if bl?.kind === 'single'} - {bl.text} - {:else if bl?.kind === 'split'} - {bl.top}{bl.bottom} - {/if} - + {#if letter} + {letter} + {#if !blank}{tileValue(variant, letter)}{/if} + {:else if r === centre.row && c === centre.col} + + {:else if bl?.kind === 'single'} + {bl.text} + {:else if bl?.kind === 'split'} + {bl.top}{bl.bottom} + {/if} {/each} {/each} @@ -153,15 +116,13 @@ background: var(--board-bg); border-radius: var(--radius-sm); container-type: inline-size; - touch-action: none; } .viewport.zoomed { overflow: auto; } .scaler { - width: 100%; - transform-origin: 0 0; - transition: transform 0.25s ease; + width: calc(100% * var(--z)); + transition: width 0.25s ease; } .grid { display: grid; @@ -179,6 +140,7 @@ color: var(--prem-text); padding: 0; overflow: hidden; + font-size: 0; } .cell.tw { background: var(--prem-tw); @@ -200,35 +162,37 @@ } .cell.pending { background: var(--tile-pending); - outline: 2px solid var(--accent); - outline-offset: -2px; } - .cell.recent { - box-shadow: - inset 0 -2px 0 var(--tile-edge), - 0 0 0 2px var(--warn); + .cell.hl { + background: var(--tile-recent); } - /* Counter-scaled text layer: stays constant size as the board scales. */ - .ct { - position: absolute; - inset: 0; - transform: scale(var(--inv)); - transform-origin: center; - pointer-events: none; + .cell.flash { + animation: tileflash 1s ease-in-out infinite; } + @keyframes tileflash { + 0%, + 100% { + background: var(--tile-bg); + } + 50% { + background: var(--tile-recent); + } + } + /* cqw fonts are sized against the fixed viewport, so labels stay a constant size as + the board grows on zoom (relatively smaller, never overflowing). */ .letter { position: absolute; - top: 6%; - left: 9%; - font-size: 4.3cqw; + top: 5%; + left: 8%; + font-size: 4.2cqw; font-weight: 700; line-height: 1; } .val { position: absolute; right: 5%; - bottom: 2%; - font-size: 2.5cqw; + bottom: 3%; + font-size: 2.4cqw; font-weight: 600; } .star { @@ -236,7 +200,7 @@ inset: 0; display: grid; place-items: center; - font-size: 4cqw; + font-size: 3.6cqw; opacity: 0.7; } .b1 { @@ -244,7 +208,7 @@ inset: 0; display: grid; place-items: center; - font-size: 3cqw; + font-size: 2.7cqw; font-weight: 600; opacity: 0.9; } @@ -255,15 +219,18 @@ flex-direction: column; align-items: center; justify-content: center; - line-height: 1; + line-height: 1.05; opacity: 0.92; + overflow: hidden; + padding: 0 1px; } .bt { - font-size: 2.4cqw; + font-size: 1.7cqw; font-weight: 600; } .bb { - font-size: 3.1cqw; + font-size: 1.9cqw; font-weight: 700; + white-space: nowrap; } diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 49e452a..6926893 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -13,11 +13,10 @@ import { GatewayError } from '../lib/client'; import { t } from '../lib/i18n/index.svelte'; import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model'; - import { lastPlayTiles, replay } from '../lib/board'; + import { replay } from '../lib/board'; import { alphabet, centre, premiumGrid } from '../lib/premiums'; import { BLANK, - direction, newPlacement, place, placementFromHint, @@ -52,7 +51,7 @@ let drag = $state<{ letter: string; blank: boolean; x: number; y: number } | null>(null); const checkedWords = new Map(); - let lastCheckAt = 0; + let cooling = $state(false); const variant = $derived(view?.game.variant ?? 'english'); const board = $derived(replay(moves)); @@ -61,12 +60,24 @@ const pendingMap = $derived( new Map(placement.pending.map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }])), ); - const recent = $derived(new Set(lastPlayTiles(moves).map((tt) => `${tt.row},${tt.col}`))); + const lastPlay = $derived([...moves].reverse().find((m) => m.action === 'play') ?? null); + // Highlight the last word with a dark tile bg; while placing, only the pending tiles + // are highlighted. It flashes when the opponent just moved and it is now our turn. + const highlight = $derived( + placement.pending.length > 0 || !lastPlay + ? new Set() + : new Set(lastPlay.tiles.map((tt) => `${tt.row},${tt.col}`)), + ); + const flash = $derived( + !!lastPlay && + !!view && + view.game.status === 'active' && + lastPlay.player !== view.seat && + view.game.toMove === view.seat, + ); const slots = $derived(rackView(placement)); const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat); const gameOver = $derived(!!view && view.game.status !== 'active'); - const dir = $derived(dirOverride ?? direction(placement) ?? 'H'); - const ambiguous = $derived(placement.pending.length === 1); const bagEmpty = $derived((view?.bagLen ?? 0) === 0); async function load() { @@ -267,11 +278,6 @@ } placement = newPlacement(r); } - function toggleDir() { - dirOverride = dir === 'H' ? 'V' : 'H'; - recompute(); - } - function openExchange() { resetPlacement(); exchangeSel = []; @@ -305,18 +311,17 @@ const raw = (e.target as HTMLInputElement).value.toUpperCase(); checkWord = Array.from(raw).filter((ch) => allowed.has(ch)).slice(0, 15).join(''); } + // Check is disabled while cooling down, for an already-checked word, or an out-of-range + // length. The input filter already restricts to the variant's alphabet. + function canCheck(): boolean { + const w = checkWord.trim(); + return w.length >= 2 && w.length <= 15 && !checkedWords.has(w.toUpperCase()) && !cooling; + } async function runCheck() { + if (!canCheck()) return; const w = checkWord.trim().toUpperCase(); - if (!w) return; - if (checkedWords.has(w)) { - checkResult = { word: w, legal: checkedWords.get(w)! }; - return; - } - if (Date.now() - lastCheckAt < 5000) { - showToast(t('game.checkWait'), 'info'); - return; - } - lastCheckAt = Date.now(); + cooling = true; + setTimeout(() => (cooling = false), 5000); try { const r = await gateway.checkWord(id, w); checkedWords.set(r.word.toUpperCase(), r.legal); @@ -355,19 +360,6 @@ } } - // History slide-down: swipe down on the board to open, up / tap to close. - let swipeY: number | null = null; - function boardSwipeStart(e: PointerEvent) { - swipeY = e.clientY; - } - function boardSwipeEnd(e: PointerEvent) { - if (swipeY == null) return; - const dy = e.clientY - swipeY; - swipeY = null; - if (dy > 40) historyOpen = true; - else if (dy < -40) historyOpen = false; - } - function resultText(): string { if (!view) return ''; const me = view.game.seats[view.seat]; @@ -383,7 +375,7 @@ ]); - + {#snippet menu()} {/snippet} @@ -419,15 +411,14 @@
historyOpen && (historyOpen = false)} > - - {#snippet trigger()}🔄{t('game.draw')}{/snippet} - {#snippet popover(close)}{/snippet} - + {#snippet trigger()}🥺{t('game.skip')}{/snippet} {#snippet popover(close)}{/snippet} @@ -504,10 +494,6 @@
{/if} -{#if ambiguous && placement.pending.length > 0} - -{/if} - {#if blankPrompt} (blankPrompt = null)}>
@@ -542,7 +528,7 @@ onkeydown={(e) => e.key === 'Enter' && runCheck()} placeholder={t('game.checkWordPrompt')} /> - +
{#if checkResult}

@@ -694,17 +680,16 @@ place-items: center; } .pop { - padding: 9px 12px; - border: 1px solid var(--border); - background: var(--surface); + padding: 9px 14px; + border: none; + background: none; color: var(--text); border-radius: var(--radius-sm); - font-weight: 600; + font-weight: 500; + text-align: left; } - .pop.go { - background: var(--accent); - color: var(--accent-text); - border-color: var(--accent); + .pop:hover { + background: var(--surface-2); } .badge { position: absolute; @@ -738,20 +723,6 @@ pointer-events: none; z-index: 60; } - .dirtoggle { - position: fixed; - right: 12px; - bottom: 84px; - z-index: 30; - width: 40px; - height: 40px; - border-radius: 50%; - border: 1px solid var(--border); - background: var(--surface); - color: var(--text); - box-shadow: var(--shadow); - font-size: 1.1rem; - } .alpha { display: grid; grid-template-columns: repeat(6, 1fr); From 331213048303b6f6d42784c000f12401fef74a6b Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 3 Jun 2026 14:56:19 +0200 Subject: [PATCH 05/11] Stage 7 polish (round 2b): tab-bar spacing + hint badge on icon corner (items 2,3) --- ui/src/components/TabBar.svelte | 14 ++++++++------ ui/src/game/Game.svelte | 13 ++++++++----- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/ui/src/components/TabBar.svelte b/ui/src/components/TabBar.svelte index 4810ae9..9865762 100644 --- a/ui/src/components/TabBar.svelte +++ b/ui/src/components/TabBar.svelte @@ -26,10 +26,10 @@ display: flex; flex-direction: column; align-items: center; - gap: 2px; + gap: 0; background: none; border: none; - padding: 2px 0; + padding: 1px 0; color: var(--text); width: 100%; user-select: none; @@ -38,13 +38,15 @@ :global(.tab:disabled) { opacity: 0.4; } + /* The icon square hugs the emoji (just a little padding) so it is the press-highlight + target and the badge can sit on its corner. */ :global(.tab .sq) { - width: 48px; - height: 40px; - display: grid; + position: relative; + display: inline-grid; place-items: center; + padding: 3px 10px; border-radius: 12px; - font-size: 1.5rem; + font-size: 1.75rem; line-height: 1; transition: background-color 0.12s; } diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 6926893..f128732 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -693,14 +693,17 @@ } .badge { position: absolute; - top: 0; - right: 4px; - font-size: 0.6rem; + top: -3px; + right: -3px; + font-size: 0.68rem; + font-weight: 700; background: var(--accent); color: var(--accent-text); border-radius: 999px; - padding: 0 4px; - line-height: 1.3; + min-width: 15px; + padding: 0 3px; + line-height: 1.4; + text-align: center; } .loading { text-align: center; From 10d48884ac45f97e05c35cc86e848f73c562be44 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 3 Jun 2026 15:52:28 +0200 Subject: [PATCH 06/11] Stage 7 polish (round 3): zoom magnifies labels, popover edge-anchor, flash x2 - item 5: move container-type to the zoom-scaled .scaler so cqw labels grow WITH the board (magnifying-glass zoom); new e2e measures the font grows ~1.85x - item 8: confirm popovers anchor to the trigger's right edge (no longer run off-screen) - item 9: last-word flash runs 2 cycles then settles to normal (was infinite) --- ui/e2e/zoom.spec.ts | 26 ++++++++++++++++++++++++++ ui/src/components/HoldConfirm.svelte | 3 +-- ui/src/game/Board.svelte | 9 ++++++--- 3 files changed, 33 insertions(+), 5 deletions(-) create mode 100644 ui/e2e/zoom.spec.ts diff --git a/ui/e2e/zoom.spec.ts b/ui/e2e/zoom.spec.ts new file mode 100644 index 0000000..866452a --- /dev/null +++ b/ui/e2e/zoom.spec.ts @@ -0,0 +1,26 @@ +import { expect, test } from '@playwright/test'; + +// Item 5: zooming the board must enlarge the labels too (a magnifying-glass zoom). +// cqw is sized against the zoom-scaled board, so the font grows with the cells. +test('zoom enlarges the board labels with the board', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: /guest/i }).click(); + await page.getByRole('button', { name: /Ann/ }).click(); + + const letter = page.locator('[data-cell] .letter').first(); + await expect(letter).toBeVisible(); + const base = await letter.evaluate((el) => parseFloat(getComputedStyle(el).fontSize)); + + // Double-tap an empty cell to zoom in (two synchronous clicks = a double-tap). + await page + .locator('[data-cell]:not(.filled)') + .nth(20) + .evaluate((el: HTMLElement) => { + el.click(); + el.click(); + }); + await page.waitForTimeout(400); // let the width transition settle + + const zoomed = await letter.evaluate((el) => parseFloat(getComputedStyle(el).fontSize)); + expect(zoomed).toBeGreaterThan(base * 1.4); +}); diff --git a/ui/src/components/HoldConfirm.svelte b/ui/src/components/HoldConfirm.svelte index 4f0e156..6db8eb6 100644 --- a/ui/src/components/HoldConfirm.svelte +++ b/ui/src/components/HoldConfirm.svelte @@ -92,8 +92,7 @@ .popover { position: absolute; bottom: calc(100% + 6px); - left: 50%; - transform: translateX(-50%); + right: 0; z-index: 19; display: flex; flex-direction: column; diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index 17f307e..2f2688a 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -84,7 +84,7 @@ class="cell {premClass[premium[r][c]]}" class:filled={!!cell} class:pending={!!p && !cell} - class:hl={!!cell && highlight.has(key(r, c))} + class:hl={!!cell && highlight.has(key(r, c)) && !flash} class:flash={!!cell && flash && highlight.has(key(r, c))} data-cell data-row={r} @@ -115,14 +115,16 @@ overflow: hidden; background: var(--board-bg); border-radius: var(--radius-sm); - container-type: inline-size; } .viewport.zoomed { overflow: auto; } + /* The query container is the (zoom-scaled) board, so cqw labels scale WITH the board + — a magnifying-glass zoom. */ .scaler { width: calc(100% * var(--z)); transition: width 0.25s ease; + container-type: inline-size; } .grid { display: grid; @@ -167,7 +169,8 @@ background: var(--tile-recent); } .cell.flash { - animation: tileflash 1s ease-in-out infinite; + /* Two flashes to draw the eye, then settle back to normal so it does not distract. */ + animation: tileflash 1s ease-in-out 2; } @keyframes tileflash { 0%, From 1e7da5925a6b70d50e0d555368d28a0b653afd0b Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 3 Jun 2026 16:22:01 +0200 Subject: [PATCH 07/11] Stage 7 UI polish: fix ad-marquee end, drop-time zoom, focus centring, sticky rack selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdBanner: move the side inset onto the scrolling track so the long message scrolls to its very end; pin body text-size-adjust:100% so iOS/Safari stops inflating the long marquee text. - Game: do not zoom on drag start (the player may change their mind) — zoom and centre happen on drop, in attemptPlace; a stray tap on an occupied cell no longer cancels the rack selection (wait for an empty cell). - Board: centre the focus cell after the zoom width transition finishes (was clamping to top-left mid-transition); compute the cell from the rendered scrollWidth. --- ui/src/app.css | 3 +++ ui/src/components/AdBanner.svelte | 5 ++++- ui/src/game/Board.svelte | 29 +++++++++++++++++++++++------ ui/src/game/Game.svelte | 6 +++++- 4 files changed, 35 insertions(+), 8 deletions(-) diff --git a/ui/src/app.css b/ui/src/app.css index e796d77..2bde4aa 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -123,6 +123,9 @@ body { line-height: 1.4; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; + /* Stop iOS/Safari from auto-inflating text (e.g. the long marquee message). */ + text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; /* never let the page scroll/zoom out from under the board */ overscroll-behavior: none; touch-action: manipulation; diff --git a/ui/src/components/AdBanner.svelte b/ui/src/components/AdBanner.svelte index daae87a..843d2b0 100644 --- a/ui/src/components/AdBanner.svelte +++ b/ui/src/components/AdBanner.svelte @@ -56,7 +56,7 @@ .ad { overflow: hidden; white-space: nowrap; - padding: 6px var(--pad); + padding: 6px 0; background: var(--surface-2); color: var(--text-muted); font-size: 0.85rem; @@ -68,6 +68,9 @@ } .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) { diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index 2f2688a..7c842de 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -47,12 +47,29 @@ $effect(() => { const vp = viewport; if (!vp || !zoomed || !focus) return; - const cell = (vp.clientWidth * Z) / 15; - vp.scrollTo({ - left: (focus.col + 0.5) * cell - vp.clientWidth / 2, - top: (focus.row + 0.5) * cell - vp.clientHeight / 2, - behavior: 'smooth', - }); + const f = focus; + const scaler = vp.firstElementChild as HTMLElement | null; + const center = () => { + // Use the rendered scrollable width so the maths stays correct (gaps, padding). + const cell = vp.scrollWidth / 15; + vp.scrollTo({ + left: (f.col + 0.5) * cell - vp.clientWidth / 2, + top: (f.row + 0.5) * cell - vp.clientHeight / 2, + behavior: 'smooth', + }); + }; + // When zoom has just turned on the board is still widening; centring now would + // clamp to the still-small scroll range and land top-left. Wait for the width + // transition to finish. If already zoomed (only the focus changed), centre at once. + if (scaler && scaler.clientWidth < vp.clientWidth * Z - 1) { + const onEnd = () => { + scaler.removeEventListener('transitionend', onEnd); + center(); + }; + scaler.addEventListener('transitionend', onEnd); + return () => scaler.removeEventListener('transitionend', onEnd); + } + center(); }); // Double-tap toggles zoom (pinch was dropped — it conflicts with native scroll). diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index f128732..43f0e81 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -132,7 +132,8 @@ dragMoved = true; const slot = placement.rack[downInfo.index]; drag = { letter: slot, blank: slot === BLANK, x: e.clientX, y: e.clientY }; - if (isCoarse() && !zoomed) zoomed = true; + // No zoom on drag start: the player may still change their mind. The zoom + // (and centring) happens on drop, in attemptPlace. } if (drag) drag = { ...drag, x: e.clientX, y: e.clientY }; } @@ -169,6 +170,9 @@ return; } if (selected != null) { + // A committed tile already sits here: keep the rack selection so a stray tap + // on an occupied cell doesn't cancel placement — wait for an empty cell. + if (board[row]?.[col]) return; attemptPlace(selected, row, col); selected = null; } From 8ec71a68161e73d7ad2125b667910e2c13c74534 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 3 Jun 2026 16:35:39 +0200 Subject: [PATCH 08/11] Stage 7 UI polish: zoom-in magnifies into the focus cell (no top-left jump) Drive the focus-centring with requestAnimationFrame across the ~0.25s width transition instead of a single scrollTo after transitionend. The board now stays locked on the placed cell as it grows, removing the visible 'centre top-left, then correct' double motion. --- ui/src/game/Board.svelte | 34 +++++++++++----------------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index 7c842de..6256e04 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -43,33 +43,21 @@ let viewport = $state(); // Genuine layout zoom (the board grows; cqw labels stay constant), so native scroll - // works in every browser. Centre the focus cell when zoomed in. + // works in every browser. Keep the focus cell centred on every frame of the zoom-in + // (the board widens over ~0.25s) so it magnifies *into* that cell, rather than growing + // from the top-left corner and then jumping to centre once the transition ends. $effect(() => { const vp = viewport; if (!vp || !zoomed || !focus) return; const f = focus; - const scaler = vp.firstElementChild as HTMLElement | null; - const center = () => { - // Use the rendered scrollable width so the maths stays correct (gaps, padding). - const cell = vp.scrollWidth / 15; - vp.scrollTo({ - left: (f.col + 0.5) * cell - vp.clientWidth / 2, - top: (f.row + 0.5) * cell - vp.clientHeight / 2, - behavior: 'smooth', - }); - }; - // When zoom has just turned on the board is still widening; centring now would - // clamp to the still-small scroll range and land top-left. Wait for the width - // transition to finish. If already zoomed (only the focus changed), centre at once. - if (scaler && scaler.clientWidth < vp.clientWidth * Z - 1) { - const onEnd = () => { - scaler.removeEventListener('transitionend', onEnd); - center(); - }; - scaler.addEventListener('transitionend', onEnd); - return () => scaler.removeEventListener('transitionend', onEnd); - } - center(); + const start = performance.now(); + let raf = requestAnimationFrame(function tick(now) { + const cell = vp.scrollWidth / 15; // grows frame by frame as the board widens + vp.scrollLeft = (f.col + 0.5) * cell - vp.clientWidth / 2; + vp.scrollTop = (f.row + 0.5) * cell - vp.clientHeight / 2; + if (now - start < 300) raf = requestAnimationFrame(tick); + }); + return () => cancelAnimationFrame(raf); }); // Double-tap toggles zoom (pinch was dropped — it conflicts with native scroll). From 1b7b767576976addbedcb5ead3fff4ed70132b23 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 3 Jun 2026 16:50:36 +0200 Subject: [PATCH 09/11] chore: gitignore Playwright MCP scratch output (.playwright-mcp/) The Playwright MCP writes page snapshots/screenshots into a .playwright-mcp/ directory while inspecting the UI; keep that scratch output out of the tree. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 0578a85..259564b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ .idea/ .DS_Store +# Playwright MCP scratch output (snapshots / screenshots written during inspection) +.playwright-mcp/ + # Local, unstaged env overrides **/.env.local **/.env.*.local From 4c475f2b0ed8832c615675814df7af736e92c162 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 3 Jun 2026 17:13:19 +0200 Subject: [PATCH 10/11] Stage 7: run the e2e suite in WebKit too (Safari-engine coverage) Add a webkit project to the Playwright config so the hermetic mock-mode specs run in both Chromium and WebKit, and install both browsers in CI. WebKit's Debian build runs headless without extra host system libraries (verified locally: smoke + zoom pass in webkit); the workflow comment records the one-time host install-deps fallback if a runner ever lacks a library. Desktop WebKit does not reproduce iOS Safari's text auto-inflation, so the app.css text-size-adjust guard stays outside e2e coverage. --- .gitea/workflows/ui-test.yaml | 19 +++++++++++-------- ui/playwright.config.ts | 9 ++++++++- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/.gitea/workflows/ui-test.yaml b/.gitea/workflows/ui-test.yaml index fc3657a..ae54c63 100644 --- a/.gitea/workflows/ui-test.yaml +++ b/.gitea/workflows/ui-test.yaml @@ -1,8 +1,8 @@ name: Tests · UI # Hermetic UI checks: type-check, Vitest unit tests, production build with a -# bundle-size budget, and a Playwright smoke against the in-memory mock transport -# (no backend/gateway/Postgres). The committed src/gen/ codegen is built, not +# bundle-size budget, and a Playwright smoke (Chromium + WebKit) against the in-memory +# mock transport (no backend/gateway/Postgres). The committed src/gen/ codegen is built, not # regenerated (the same model as the Go committed jet/fbs output). on: @@ -51,12 +51,15 @@ jobs: # The Playwright system libraries are provisioned once on the runner host # (`sudo npx playwright@ install-deps chromium`), so the job needs no - # apt and no sudo: it only downloads the browser binary into the runner cache - # (persisted by the host executor) and runs the smoke. The timeouts guard - # against a future hang. Keep this in lockstep with @playwright/test in - # package.json — re-run install-deps on the host after a major bump. - - name: Install Playwright browser - run: pnpm exec playwright install chromium + # apt and no sudo: it only downloads the browser binaries into the runner cache + # (persisted by the host executor) and runs the suite. WebKit's Debian build + # bundles most of its own libraries and runs headless without extra host deps; if + # a runner ever lacks one, provision it once on the host with + # `sudo npx playwright install-deps webkit`. The timeouts guard against a future + # hang. Keep this in lockstep with @playwright/test in package.json — re-run + # install-deps on the host after a major bump. + - name: Install Playwright browsers + run: pnpm exec playwright install chromium webkit timeout-minutes: 5 - name: E2E smoke (mock) diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts index bac1679..cc28f70 100644 --- a/ui/playwright.config.ts +++ b/ui/playwright.config.ts @@ -18,5 +18,12 @@ export default defineConfig({ reuseExistingServer: !process.env.CI, timeout: 60_000, }, - projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + // Run the same hermetic specs in Chromium and WebKit (Safari's engine) so the UI is + // exercised in both rendering/JS engines. Note: desktop WebKit on Linux does not + // reproduce iOS Safari's text auto-inflation, so the `text-size-adjust` guard in + // app.css is not regression-covered here — but engine-level CSS/JS differences are. + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + { name: 'webkit', use: { ...devices['Desktop Safari'] } }, + ], }); From f8f7d39364991b3e9b89cd3bc90ddbbbc3415d7f Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Wed, 3 Jun 2026 17:33:47 +0200 Subject: [PATCH 11/11] Stage 7: regression tests for the polished UI (logic + behaviour) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lock the polish branch's behaviour so a future UI edit surfaces as a failing assertion to re-agree or fix. Unit (vitest, node env): - placement: recallIndex, cellOccupied/isBlankSlot, non-linear direction, the single-tile submit default, and placementFromHint blank-fallback / rack-exhausted. - banner: the marquee scroll-cycle repeat-then-advance, stop(), root-relative and multiple links. - client.GatewayError. Extract the check-word constraints out of Game.svelte into a pure lib/checkword.ts (sanitize + canCheck) and cover them. E2E (playwright mock, Chromium + WebKit): - commit via the 🏁 control, history slide-down + close, the exchange dialog, check-word input sanitising + verdict, resign-to-finished, and the Settings board-label mode changing the on-board labels. --- ui/e2e/game.spec.ts | 86 ++++++++++++++++++++++++++++++++++++ ui/src/game/Game.svelte | 8 ++-- ui/src/lib/banner.test.ts | 44 ++++++++++++++++++ ui/src/lib/checkword.test.ts | 42 ++++++++++++++++++ ui/src/lib/checkword.ts | 31 +++++++++++++ ui/src/lib/client.test.ts | 18 ++++++++ ui/src/lib/placement.test.ts | 43 ++++++++++++++++++ 7 files changed, 267 insertions(+), 5 deletions(-) create mode 100644 ui/e2e/game.spec.ts create mode 100644 ui/src/lib/checkword.test.ts create mode 100644 ui/src/lib/checkword.ts create mode 100644 ui/src/lib/client.test.ts diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts new file mode 100644 index 0000000..d8c7abb --- /dev/null +++ b/ui/e2e/game.spec.ts @@ -0,0 +1,86 @@ +import { expect, test, type Page } from '@playwright/test'; + +// Behaviour/display coverage for the polished game screen, driven entirely by the mock +// transport (no backend). These lock the round-1..4 interactions so future UI edits +// surface as a failing assertion — to be re-agreed or fixed. The pure logic behind them +// (placement, check-word, board labels, result badges) is unit-tested separately. + +async function openGame(page: Page): Promise { + await page.goto('/'); + await page.getByRole('button', { name: /guest/i }).click(); + await page.getByRole('button', { name: /Ann/ }).click(); // the seeded active game vs Ann + await expect(page.locator('[data-cell]').first()).toBeVisible(); +} + +test('placing a tile and confirming via 🏁 commits the move', async ({ page }) => { + await openGame(page); + await page.locator('.rack .tile').first().click(); + await page.locator('[data-cell]:not(.filled)').nth(30).click(); + await expect(page.locator('[data-cell].pending')).toHaveCount(1); + + await page.locator('.make').click(); // open the MakeMove popover (short tap) + await page.locator('.pop.go').click(); // "Make move ✅" + + // After the commit the placement is cleared: no pending tile, no 🏁 control. + await expect(page.locator('[data-cell].pending')).toHaveCount(0); + await expect(page.locator('.make')).toBeHidden(); +}); + +test('history slides the board down and closes on a board tap', async ({ page }) => { + await openGame(page); + await page.locator('.burger').click(); + await page.locator('.dropdown button').nth(0).click(); // History + await expect(page.locator('.history')).toBeVisible(); + await expect(page.locator('.boardwrap.slid')).toBeVisible(); + + await page.locator('.boardwrap').click(); // tapping the board closes it + await expect(page.locator('.history')).toBeHidden(); +}); + +test('Draw opens the exchange dialog and confirms a selection', async ({ page }) => { + await openGame(page); + await page.locator('button:has-text("🔄")').click(); // Draw tab + await expect(page.locator('.exch')).toBeVisible(); + + await page.locator('.etile').first().click(); + await expect(page.locator('.etile.sel')).toHaveCount(1); + await page.locator('button.confirm').click(); + await expect(page.locator('.exch')).toBeHidden(); +}); + +test('check-word sanitises input and shows a verdict', async ({ page }) => { + await openGame(page); + await page.locator('.burger').click(); + await page.locator('.dropdown button').nth(2).click(); // Check word + + const input = page.locator('.check input'); + await input.fill('qz9!a'); // digits/punctuation dropped, letters upper-cased + await expect(input).toHaveValue('QZA'); + + await page.locator('.check button').click(); // Check (enabled: length 3) + await expect(page.locator('.ok, .bad')).toBeVisible(); +}); + +test('dropping the game ends it and shows the result', async ({ page }) => { + await openGame(page); + await page.locator('.burger').click(); + await page.locator('.dropdown button').nth(3).click(); // Drop game + await page.locator('button.danger').click(); // confirm in the modal + await expect(page.locator('.status .over')).toBeVisible(); +}); + +test('the board-label mode in Settings changes the on-board labels', async ({ page }) => { + await openGame(page); + // beginner (default) renders split "3× / word" labels. + await expect(page.locator('.bsplit').first()).toBeVisible(); + await expect(page.locator('.b1')).toHaveCount(0); + + // Switch to "classic" in Settings (in-SPA hash nav keeps the guest session). + await page.evaluate(() => (location.hash = '/settings')); + await page.locator('.seg').nth(2).locator('.opt').nth(1).click(); // board labels -> classic + await page.evaluate(() => (location.hash = '/game/g1')); + + // classic renders single "3W"/"2L" labels and no split labels. + await expect(page.locator('.b1').first()).toBeVisible(); + await expect(page.locator('.bsplit')).toHaveCount(0); +}); diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 43f0e81..6701bc8 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -15,6 +15,7 @@ import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model'; import { replay } from '../lib/board'; import { alphabet, centre, premiumGrid } from '../lib/premiums'; + import { canCheckWord, sanitizeCheckWord } from '../lib/checkword'; import { BLANK, newPlacement, @@ -311,15 +312,12 @@ checkOpen = true; } function onCheckInput(e: Event) { - const allowed = new Set(alphabet(variant)); - const raw = (e.target as HTMLInputElement).value.toUpperCase(); - checkWord = Array.from(raw).filter((ch) => allowed.has(ch)).slice(0, 15).join(''); + checkWord = sanitizeCheckWord((e.target as HTMLInputElement).value, alphabet(variant)); } // Check is disabled while cooling down, for an already-checked word, or an out-of-range // length. The input filter already restricts to the variant's alphabet. function canCheck(): boolean { - const w = checkWord.trim(); - return w.length >= 2 && w.length <= 15 && !checkedWords.has(w.toUpperCase()) && !cooling; + return canCheckWord(checkWord, checkedWords.has(checkWord.trim().toUpperCase()), cooling); } async function runCheck() { if (!canCheck()) return; diff --git a/ui/src/lib/banner.test.ts b/ui/src/lib/banner.test.ts index c01e674..6028bd6 100644 --- a/ui/src/lib/banner.test.ts +++ b/ui/src/lib/banner.test.ts @@ -12,6 +12,12 @@ describe('linkify', () => { expect(linkify('[x](ftp://evil)')).toBe('x'); expect(linkify('[y](javascript:boom)')).toBe('y'); }); + it('keeps root-relative links and renders several in one string', () => { + expect(linkify('go [home](/lobby) or [docs](https://x.com)')).toBe( + 'go home or ' + + 'docs', + ); + }); }); describe('banner rotator', () => { @@ -43,4 +49,42 @@ describe('banner rotator', () => { expect(scrolled).toBe(1); r.stop(); }); + + it('repeats the scroll cycle while under holdMs, then advances', () => { + vi.useFakeTimers(); + const cfg = { holdMs: 2500, edgePauseMs: 100, fadeMs: 10, scrollPxPerSec: 100 }; + const shown: number[] = []; + let scrolls = 0; + const r = createBannerRotator( + [{ md: 'long' }, { md: 'short' }], + { overflowPx: (i) => (i === 0 ? 100 : 0), show: (i) => shown.push(i), scrollTo: () => scrolls++ }, + cfg, + ); + r.start(); + vi.advanceTimersByTime(110); // fade(10) + edgePause(100) -> first scroll + expect(scrolls).toBe(1); + expect(shown).toEqual([0]); + vi.advanceTimersByTime(1200); // scrollDur(1000) + edgePause + edgePause -> re-show + second scroll + expect(scrolls).toBe(2); + expect(shown).toEqual([0, 0]); // re-shown to reset scroll, still item 0 (under holdMs) + vi.advanceTimersByTime(10_000); + expect(shown).toContain(1); // eventually exceeds holdMs and advances to the fitting message + r.stop(); + }); + + it('stop() halts further advancement', () => { + vi.useFakeTimers(); + const cfg = { ...defaultBannerConfig, holdMs: 100, fadeMs: 5, edgePauseMs: 5 }; + const shown: number[] = []; + const r = createBannerRotator( + [{ md: 'a' }, { md: 'b' }], + { overflowPx: () => 0, show: (i) => shown.push(i), scrollTo: () => {} }, + cfg, + ); + r.start(); + vi.advanceTimersByTime(cfg.fadeMs); + r.stop(); + vi.advanceTimersByTime(10_000); + expect(shown).toEqual([0]); + }); }); diff --git a/ui/src/lib/checkword.test.ts b/ui/src/lib/checkword.test.ts new file mode 100644 index 0000000..5a05ccf --- /dev/null +++ b/ui/src/lib/checkword.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { canCheckWord, MAX_WORD_LEN, sanitizeCheckWord } from './checkword'; + +const EN = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); + +describe('sanitizeCheckWord', () => { + it('upper-cases and keeps only letters of the alphabet', () => { + expect(sanitizeCheckWord('ca7t!', EN)).toBe('CAT'); + expect(sanitizeCheckWord(' Hi 9 ', EN)).toBe('HI'); + }); + + it('drops characters outside the active alphabet', () => { + expect(sanitizeCheckWord('cat', ['C', 'A'])).toBe('CA'); // T not in this alphabet + const RU = 'КОТ'.split(''); + expect(sanitizeCheckWord('коt', RU)).toBe('КО'); // cyrillic kept, latin "t" dropped + }); + + it('caps the length at MAX_WORD_LEN', () => { + expect(sanitizeCheckWord('A'.repeat(30), EN)).toHaveLength(MAX_WORD_LEN); + }); +}); + +describe('canCheckWord', () => { + it('allows a fresh, in-range word', () => { + expect(canCheckWord('CAT', false, false)).toBe(true); + }); + + it('rejects an out-of-range length', () => { + expect(canCheckWord('A', false, false)).toBe(false); // too short + expect(canCheckWord('A'.repeat(MAX_WORD_LEN + 1), false, false)).toBe(false); // too long + }); + + it('rejects an already-checked word or a cooling-down state', () => { + expect(canCheckWord('CAT', true, false)).toBe(false); + expect(canCheckWord('CAT', false, true)).toBe(false); + }); + + it('trims surrounding whitespace before measuring length', () => { + expect(canCheckWord(' ok ', false, false)).toBe(true); + expect(canCheckWord(' a ', false, false)).toBe(false); + }); +}); diff --git a/ui/src/lib/checkword.ts b/ui/src/lib/checkword.ts new file mode 100644 index 0000000..cdb32d6 --- /dev/null +++ b/ui/src/lib/checkword.ts @@ -0,0 +1,31 @@ +// Pure helpers for the in-game "check a word" panel: input sanitising and the gate on +// when a check may be sent. Kept separate from Game.svelte so the constraints (the +// variant alphabet, the length bounds, the answered-word cache and the cool-down +// throttle) are unit-testable and stay in lockstep with the UI. + +/** The longest word that fits on a standard 15-cell board line. */ +export const MAX_WORD_LEN = 15; +/** The shortest word worth checking. */ +export const MIN_WORD_LEN = 2; + +/** + * sanitizeCheckWord upper-cases the raw input and keeps only characters of the active + * variant's alphabet, capped at MAX_WORD_LEN — so the field can never hold something the + * dictionary could not contain. + */ +export function sanitizeCheckWord(raw: string, alphabet: string[]): string { + const allowed = new Set(alphabet); + return Array.from(raw.toUpperCase()) + .filter((ch) => allowed.has(ch)) + .slice(0, MAX_WORD_LEN) + .join(''); +} + +/** + * canCheckWord gates the Check action: the trimmed word must be of valid length, must not + * have been answered already (cached), and must not fall inside the cool-down window. + */ +export function canCheckWord(word: string, alreadyChecked: boolean, cooling: boolean): boolean { + const w = word.trim(); + return w.length >= MIN_WORD_LEN && w.length <= MAX_WORD_LEN && !alreadyChecked && !cooling; +} diff --git a/ui/src/lib/client.test.ts b/ui/src/lib/client.test.ts new file mode 100644 index 0000000..cc08a94 --- /dev/null +++ b/ui/src/lib/client.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { GatewayError } from './client'; + +describe('GatewayError', () => { + it('carries a stable code and is a real Error', () => { + const e = new GatewayError('no_hint_available'); + expect(e).toBeInstanceOf(Error); + expect(e.name).toBe('GatewayError'); + expect(e.code).toBe('no_hint_available'); + expect(e.message).toBe('no_hint_available'); // message defaults to the code + }); + + it('keeps a custom message while the code stays the i18n lookup key', () => { + const e = new GatewayError('not_your_turn', 'It is not your turn'); + expect(e.code).toBe('not_your_turn'); + expect(e.message).toBe('It is not your turn'); + }); +}); diff --git a/ui/src/lib/placement.test.ts b/ui/src/lib/placement.test.ts index 337c307..c2efbc4 100644 --- a/ui/src/lib/placement.test.ts +++ b/ui/src/lib/placement.test.ts @@ -1,12 +1,15 @@ import { describe, expect, it } from 'vitest'; import { BLANK, + cellOccupied, direction, + isBlankSlot, newPlacement, place, placementFromHint, rackView, recallAt, + recallIndex, reset, toSubmit, } from './placement'; @@ -62,6 +65,29 @@ describe('placement state machine', () => { expect(toSubmit(place(newPlacement(rack), 0, 7, 7), 'V')?.dir).toBe('V'); expect(toSubmit(newPlacement(rack))).toBeNull(); }); + + it('recalls a tile by rack index and reports occupied cells / blank slots', () => { + let p = place(newPlacement(rack), 0, 7, 7); + p = place(p, 1, 7, 8); + expect(cellOccupied(p, 7, 7)).toBe(true); + expect(cellOccupied(p, 6, 6)).toBe(false); + p = recallIndex(p, 0); + expect(p.pending.map((t) => t.rackIndex)).toEqual([1]); + expect(isBlankSlot(newPlacement(rack), 2)).toBe(true); // '?' slot + expect(isBlankSlot(newPlacement(rack), 0)).toBe(false); + }); + + it('treats a non-linear placement as no inferred direction', () => { + let p = place(newPlacement(rack), 0, 7, 7); + p = place(p, 1, 8, 8); // diagonal + expect(direction(p)).toBeNull(); + }); + + it('defaults a single-tile submit to H without an override', () => { + const sub = toSubmit(place(newPlacement(rack), 0, 7, 7)); + expect(sub?.dir).toBe('H'); + expect(sub?.tiles).toHaveLength(1); + }); }); describe('placementFromHint', () => { @@ -78,4 +104,21 @@ describe('placementFromHint', () => { expect(p.pending[0]).toMatchObject({ rackIndex: 0, letter: 'C', blank: false }); expect(p.pending[2]).toMatchObject({ rackIndex: 2, letter: 'B', blank: true }); }); + + it('falls back to a blank slot when the hint letter is not in the rack', () => { + const p = placementFromHint([{ row: 7, col: 7, letter: 'Z', blank: false }], ['A', BLANK]); + expect(p.pending).toHaveLength(1); + expect(p.pending[0]).toMatchObject({ rackIndex: 1, letter: 'Z', blank: true }); + }); + + it('skips hint tiles once the rack is exhausted', () => { + const p = placementFromHint( + [ + { row: 7, col: 7, letter: 'A', blank: false }, + { row: 7, col: 8, letter: 'B', blank: false }, + ], + ['A'], + ); + expect(p.pending.map((t) => t.letter)).toEqual(['A']); + }); });