Compare commits

..

3 Commits

Author SHA1 Message Date
Ilia Denisov 92a4de3bf4 Stage 7 polish: docs + mark Stage 7 done (Part H)
Tests · Go / test (pull_request) Successful in 7s
Tests · Integration / integration (pull_request) Successful in 10s
Tests · UI / test (pull_request) Successful in 12s
- new docs/UI_DESIGN.md (design system: shell, nav, tab-bar, tiles, board zoom/labels, HoldConfirm, banner rotator, result iconography)
- ARCHITECTURE: app-shell + announcement channel (mock->server) + hint place-on-board / no_hint_available contract + board-style setting
- FUNCTIONAL(+ru): hint-on-board, word-check constraints/throttle, board style
- PLAN: Stage 7 -> done + polish refinement note; CLAUDE sources list
2026-06-03 13:38:17 +02:00
Ilia Denisov 2c96c19aac Stage 7 polish: game rework + board zoom + tests (Parts D/E/F/I)
- Board: fixed-viewport transform-scale zoom (animated) with counter-scaled cqw labels, corner letters, bonus-label modes (boardlabels), contrasting grid lines
- Game: Screen shell + game tab-bar (Draw/Skip/Hint/Shuffle) via HoldConfirm popovers; MakeMove 🏁 + compact popup; rack collapses used slots; hint places tiles on board (placementFromHint) + no_hint_available toast; Scores:N replaces Hints; history slide-down (swipe/click, scroll-locked); check-word alphabet/length limit + in-memory cache + 5s throttle
- backend: no_hint_available result code split + test
- vitest: banner rotator + linkify, resultBadge, boardlabels, placementFromHint (29 tests); Playwright smoke updated; prod bundle ~74 KB gzip
2026-06-03 13:33:03 +02:00
Ilia Denisov 38be7fea96 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
2026-06-03 13:20:56 +02:00
36 changed files with 1604 additions and 535 deletions
+1
View File
@@ -13,6 +13,7 @@ conversation memory — is the source of continuity. Keep it that way.
- [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md) - [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md)
mirror) — per-domain user stories. English authoritative. mirror) — per-domain user stories. English authoritative.
- [`docs/TESTING.md`](docs/TESTING.md) — test layers + the per-stage CI gate. - [`docs/TESTING.md`](docs/TESTING.md) — test layers + the per-stage CI gate.
- [`docs/UI_DESIGN.md`](docs/UI_DESIGN.md) — the `ui` visual/interaction design system.
## Mandatory per-stage workflow ## Mandatory per-stage workflow
+11 -1
View File
@@ -40,7 +40,7 @@ independent (see ARCHITECTURE §9.1).
| 4 | Lobby & social (matchmaking, friends, block, chat, profile, nudge) | **done** | | 4 | Lobby & social (matchmaking, friends, block, chat, profile, nudge) | **done** |
| 5 | Robot opponent | **done** | | 5 | Robot opponent | **done** |
| 6 | Gateway edge (Connect/FB, platform auth, sessions, push bridge, admin) | **done** | | 6 | Gateway edge (Connect/FB, platform auth, sessions, push bridge, admin) | **done** |
| 7 | UI — playable slice (Svelte+Vite, board, lobby, chat, hint/word-check, i18n) | todo | | 7 | UI — playable slice + UX polish (Svelte+Vite, board, lobby, chat, hint/word-check, i18n) | **done** |
| 8 | UI — social/account/history (friends, blocks, invitations, profile edit, stats, history/GCG) | todo | | 8 | UI — social/account/history (friends, blocks, invitations, profile edit, stats, history/GCG) | todo |
| 9 | Telegram integration (bot side-service, deep-link, push) | todo | | 9 | Telegram integration (bot side-service, deep-link, push) | todo |
| 10 | Admin & dictionary ops (complaint review, version reload) | todo | | 10 | Admin & dictionary ops (complaint review, version reload) | todo |
@@ -525,6 +525,16 @@ Open details: deployment target/host; dashboards; load expectations.
**bundle-size budget** — prod is ~67 KB gzip JS — and a chromium e2e). The Go **bundle-size budget** — prod is ~67 KB gzip JS — and a chromium e2e). The Go
workflows already cover the new backend/gateway/pkg code; a `game.ListForAccount` workflows already cover the new backend/gateway/pkg code; a `game.ListForAccount`
integration test and gateway transcode tests for the new ops were added. integration test and gateway transcode tests for the new ops were added.
- **UX polish** (follow-up PR): a mobile-app **app shell** (growing nav bar, content
pinned to the bottom) + a one-line **announcement banner** (client-side mock
rotation now; server-driven channel later — §10); a mobile-OS **tab bar** and a
reusable **HoldConfirm** press-and-hold control (MakeMove 🏁 + game-action confirms);
board **zoom reworked** to a fixed viewport with counter-scaled labels, corner-letter
tiles, contrasting grid lines, and a Settings **bonus-label style** (beginner/
classic/none); **hint lays its tiles on the board** (no spend when no move — a new
`no_hint_available` result code); the history opens as an in-place **slide-down**
(not a modal); word-check is alphabet/length-limited, cached and throttled. Design
details live in the new [`docs/UI_DESIGN.md`](docs/UI_DESIGN.md).
## Deferred TODOs (cross-stage) ## Deferred TODOs (cross-stage)
+2
View File
@@ -51,6 +51,8 @@ func TestStatusForError(t *testing.T) {
"code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"}, "code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"},
"session gone": {session.ErrNotFound, http.StatusUnauthorized, "session_invalid"}, "session gone": {session.ErrNotFound, http.StatusUnauthorized, "session_invalid"},
"chat forbidden": {social.ErrForbiddenContent, http.StatusUnprocessableEntity, "chat_rejected"}, "chat forbidden": {social.ErrForbiddenContent, http.StatusUnprocessableEntity, "chat_rejected"},
"no hint move": {game.ErrNoHintAvailable, http.StatusConflict, "no_hint_available"},
"no hints left": {game.ErrNoHintsLeft, http.StatusConflict, "hint_unavailable"},
"unknown -> 500": {context_deadline, http.StatusInternalServerError, "internal"}, "unknown -> 500": {context_deadline, http.StatusInternalServerError, "internal"},
} }
for name, tc := range cases { for name, tc := range cases {
+5 -1
View File
@@ -121,7 +121,11 @@ func statusForError(err error) (int, string) {
return http.StatusConflict, "already_queued" return http.StatusConflict, "already_queued"
case errors.Is(err, game.ErrInvalidConfig): case errors.Is(err, game.ErrInvalidConfig):
return http.StatusBadRequest, "invalid_config" return http.StatusBadRequest, "invalid_config"
case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft), errors.Is(err, game.ErrNoHintAvailable): case errors.Is(err, game.ErrNoHintAvailable):
// No legal move for the rack — distinct from a budget/disabled hint so the UI
// can say "no options" (and the service spends nothing in this case).
return http.StatusConflict, "no_hint_available"
case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft):
return http.StatusConflict, "hint_unavailable" return http.StatusConflict, "hint_unavailable"
case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver): case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver):
return http.StatusUnprocessableEntity, "illegal_play" return http.StatusUnprocessableEntity, "illegal_play"
+14 -1
View File
@@ -38,6 +38,11 @@ Three executables plus per-platform side-services:
is a hash router and the session token is held in memory + IndexedDB. A build-flagged is a hash router and the session token is held in memory + IndexedDB. A build-flagged
in-memory mock transport (`pnpm start`) runs the whole slice with no backend. in-memory mock transport (`pnpm start`) runs the whole slice with no backend.
Embeddable in platform webviews; packageable to native (iOS/Android) via Capacitor. Embeddable in platform webviews; packageable to native (iOS/Android) via Capacitor.
The client uses a mobile-app shell (a growing nav bar; content pinned to the bottom),
a one-line **announcement banner** under the nav (a client-side mock rotation today —
a server-driven channel later, §10), and a client **board-style** setting (bonus-label
mode). The visual/interaction design system is documented in
[`UI_DESIGN.md`](UI_DESIGN.md).
- **`platform/<name>`** *(planned)* — per-platform side-services (Telegram bot - **`platform/<name>`** *(planned)* — per-platform side-services (Telegram bot
first): deep-link invites and platform-native push notifications. They talk first): deep-link invites and platform-native push notifications. They talk
to `backend` over an internal API. to `backend` over an internal API.
@@ -209,7 +214,11 @@ Key points:
(`hint_balance`, spent after the allowance; top-ups are a later feature). A hint (`hint_balance`, spent after the allowance; top-ups are a later feature). A hint
reveals the top-1 ranked move (`GenerateMoves[0]`). The lobby/tournament caller reveals the top-1 ranked move (`GenerateMoves[0]`). The lobby/tournament caller
picks the per-game defaults (e.g. one in casual random games, none in picks the per-game defaults (e.g. one in casual random games, none in
tournaments). tournaments). The client **lays the hinted tiles onto the board** as a pending
placement and leaves the commit to the player. When the rack has no legal move the
service spends **nothing** and returns `ErrNoHintAvailable` — surfaced as the distinct
result code `no_hint_available` (separate from `hint_unavailable`) so the UI can say
"no options" rather than "no hints left".
- **Word-check tool**: unlimited dictionary lookups against the game's pinned - **Word-check tool**: unlimited dictionary lookups against the game's pinned
dictionary; each result offers a **complaint** (complainant, game, variant, dictionary; each result offers a **complaint** (complainant, game, variant,
dict_version, word, the disputed result, an optional note) that lands in an dict_version, word, the disputed result, an optional note) that lands in an
@@ -363,6 +372,10 @@ match-found. Out-of-app platform push (your-turn, nudge) is wired in Stage 9;
session-revocation events and cursor-based stream resume are deferred session-revocation events and cursor-based stream resume are deferred
(single-instance MVP). (single-instance MVP).
A separate **announcements channel** feeds the client's one-line banner (UI_DESIGN.md).
It is a client-side **mock** rotation today; a server-driven source (operational notices,
promotions) is future work and would deliver short markdown messages (text + links).
## 11. Observability ## 11. Observability
- Structured logging with `go.uber.org/zap` (JSON). OpenTelemetry tracer and - Structured logging with `go.uber.org/zap` (JSON). OpenTelemetry tracer and
+4 -1
View File
@@ -17,7 +17,10 @@ the top-1 hint, the unlimited word-check with complaint, per-game chat and nudge
real-time in-app updates, switching interface language (en/ru) and theme, and a real-time in-app updates, switching interface language (en/ru) and theme, and a
read-only profile. Managing friends and blocks, creating friend games (invitations), read-only profile. Managing friends and blocks, creating friend games (invitations),
editing the profile, the statistics screen and the history/GCG viewer arrive in editing the profile, the statistics screen and the history/GCG viewer arrive in
Stage 8. Stage 8. Settings also pick the board's bonus-label style (beginner / classic /
none). A hint **lays the suggested tiles on the board** for the player to confirm and
costs nothing when the rack has no legal move. The word-check accepts only the
variant's alphabet, remembers answers within the session and rate-limits repeats.
### Identity & sessions *(Stage 1 / 6)* ### Identity & sessions *(Stage 1 / 6)*
A player arrives from a platform (Telegram first), via email login, or as an A player arrives from a platform (Telegram first), via email login, or as an
+5 -1
View File
@@ -16,7 +16,11 @@ top-1 подсказку, безлимитную проверку слова с
обновления в реальном времени, переключение языка интерфейса (en/ru) и темы и обновления в реальном времени, переключение языка интерфейса (en/ru) и темы и
профиль только для чтения. Управление друзьями и блоками, создание дружеских игр профиль только для чтения. Управление друзьями и блоками, создание дружеских игр
(приглашения), редактирование профиля, экран статистики и просмотр истории/GCG (приглашения), редактирование профиля, экран статистики и просмотр истории/GCG
появятся в Stage 8. появятся в Stage 8. В настройках также выбирается стиль подписей бонус-клеток
(новичок / классика / без текста). Подсказка **выставляет предложенные фишки на
доску** — игрок сам решает сделать ход, и подсказка не тратится, если ходов нет.
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
и ограничивает частоту повторов.
### Личность и сессии *(Stage 1 / 6)* ### Личность и сессии *(Stage 1 / 6)*
Игрок приходит с платформы (сначала Telegram), через email-вход или как Игрок приходит с платформы (сначала Telegram), через email-вход или как
+77
View File
@@ -0,0 +1,77 @@
# Scrabble Game — UI design system
Visual and interaction conventions for the `ui` client. Behaviour lives in
[`FUNCTIONAL.md`](FUNCTIONAL.md); cross-service architecture (including the global
points this doc references) lives in [`ARCHITECTURE.md`](ARCHITECTURE.md). The client
is **pure HTML5/CSS + Unicode** — no image/font/SVG assets; icons are CSS shapes or
emoji glyphs. Tokens are CSS custom properties (`ui/src/app.css`), light/dark via
`prefers-color-scheme` or an explicit Settings choice, and **Telegram-themeParams-ready**
(the tokens can be overridden at runtime).
## Layout shell (`components/Screen.svelte`)
A mobile-app feel: the screen is a full-height flex column where the **nav bar grows**
to absorb spare vertical space (its buttons stay top-aligned) and everything else —
the announcement strip, the content, and the optional bottom tab bar — **pins to the
bottom**, the strip directly above the content. Tall content scrolls within the content
region. Every screen except Login uses `Screen`.
## Navigation
- **Back**: a thin, compact `<` drawn from two rotated CSS borders (`Header.svelte`
`.chev`) — lighter than a glyph.
- **Hamburger**: a CSS three-bar (`Menu.svelte`), deliberately larger; opens a dropdown
of items (lobby: Profile/Settings/About; game: History/Chat/Check word/Drop game).
- **Tab bar** (`TabBar.svelte`): square, borderless, evenly distributed buttons — a large
emoji icon over a tiny truncated label. A press highlights a rounded **square** behind
the icon (slightly larger than it) until release; spacing keeps adjacent labels from
touching. No text selection on nav / tab-bar / buttons (`user-select: none`).
## Tiles & board
- **Tiles**: the letter sits in the **top-left** corner (offset a touch more than the
value), the point value bottom-right; blanks show no value.
- **Board zoom** (`Board.svelte`): a two-state zoom (full 15×15 ↔ ~9 cells) via
`transform: scale()` on an inner layer inside a **fixed-size viewport** (the page never
reflows; the viewport scrolls when zoomed), with a smooth transition. Cell/tile **text
lives in a counter-scaled layer** (`scale(1/z)`) sized in `cqw`, so labels stay a
constant size (relatively smaller at higher zoom). On touch, attempting to place a tile
auto-zooms in centred on the target; double-tap and pinch toggle.
- **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a
split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none`
nothing. Default **beginner**.
- **Grid lines**: the inter-cell gap shows a contrasting `--cell-line` (darker in light,
lighter in dark) to avoid a wavy-line optical illusion.
## Controls
- **HoldConfirm** (`components/HoldConfirm.svelte`): the shared press-and-hold control. A
short tap opens a small popover above the button; a ~0.7 s hold runs the primary action
immediately. Reused by:
- **MakeMove** (appears when ≥1 tile is pending; the rack collapses its used slots and
shifts left to free room): a **🏁** button whose popover offers **Make move ✅** /
**Reset ❌**.
- **Game tab bar**: 🔄 Draw (disabled when the bag is empty), 🥺 Skip, 🛟 Hint (with a
remaining-count badge) — each confirmed by an **Ok ✅** popover; 🔀 Shuffle has no
label and no confirm. The under-board slot shows the **Scores: N** preview.
## Announcement banner (`components/AdBanner.svelte`, `lib/banner.ts`)
A one-line inset strip under the nav bar. Content is minimal markdown (text + links,
escaped + linkified). A parameterised **rotator** drives messages: a fitting message
holds `holdMs` (default 60 s) then cross-fades to the next; a message wider than the strip
pauses (`edgePauseMs`), scrolls to its right edge at `scrollPxPerSec`, pauses, and repeats
until the cycle exceeds `holdMs`. Today a **mock** provider rotates a long and a short
message; the source becomes a server-driven channel later (see ARCHITECTURE).
## Result / status iconography (`lib/result.ts`)
Lobby rows show two lines (opponents, then result + score) with a large place-based emoji
on the right: Victory 🏆 / Defeat 🥈 / Draw 🏅, and for 34-player games II 🥈 / III 🥉 /
IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use 💌.
## Caveat
Emoji are rendered by the platform's system emoji font, so their exact look varies across
OSes — acceptable for the MVP, and consistent with the no-asset rule (no glyphs are
downloaded).
+4 -3
View File
@@ -22,8 +22,9 @@ test('guest reaches a board and previews a placement', async ({ page }) => {
await rackTile.click(); await rackTile.click();
await page.locator('[data-cell]:not(.filled)').nth(30).click(); await page.locator('[data-cell]:not(.filled)').nth(30).click();
await expect(page.locator('[data-cell].pending')).toHaveCount(1); await expect(page.locator('[data-cell].pending')).toHaveCount(1);
await expect(page.locator('.preview')).toContainText(/\d/); // The score preview appears where the hints count used to be.
await expect(page.locator('.scores')).toContainText(/\d/);
// The contextual MakeMove control appears once a tile is pending. // The contextual MakeMove control (🏁) appears once a tile is pending.
await expect(page.getByRole('button', { name: /make move/i })).toBeVisible(); await expect(page.locator('.make')).toBeVisible();
}); });
+4 -2
View File
@@ -23,7 +23,7 @@
/* board + tiles (all drawn with CSS primitives) */ /* board + tiles (all drawn with CSS primitives) */
--board-bg: #cdd6cf; --board-bg: #cdd6cf;
--cell-bg: #e7ece8; --cell-bg: #e7ece8;
--cell-line: #b6c0b8; --cell-line: #7f8d83;
--tile-bg: #f4e2b8; --tile-bg: #f4e2b8;
--tile-edge: #d8c190; --tile-edge: #d8c190;
--tile-text: #2a2113; --tile-text: #2a2113;
@@ -62,7 +62,7 @@
--board-bg: #2a3330; --board-bg: #2a3330;
--cell-bg: #222a27; --cell-bg: #222a27;
--cell-line: #38433d; --cell-line: #56655c;
--tile-bg: #d9c79a; --tile-bg: #d9c79a;
--tile-edge: #b6a473; --tile-edge: #b6a473;
--tile-text: #20190d; --tile-text: #20190d;
@@ -136,6 +136,8 @@ button {
font: inherit; font: inherit;
color: inherit; color: inherit;
cursor: pointer; cursor: pointer;
user-select: none;
-webkit-user-select: none;
} }
.reduce-motion * { .reduce-motion * {
+77
View File
@@ -0,0 +1,77 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { fade } from 'svelte/transition';
import {
createBannerRotator,
defaultBannerConfig,
linkify,
mockBanners,
type BannerConfig,
type BannerItem,
} from '../lib/banner';
let { items = mockBanners(), config = defaultBannerConfig }: { items?: BannerItem[]; config?: BannerConfig } =
$props();
let current = $state(0);
let tx = $state(0);
let txDur = $state(0);
let track = $state<HTMLElement>();
let viewport = $state<HTMLElement>();
let rotator: ReturnType<typeof createBannerRotator> | null = null;
onMount(() => {
rotator = createBannerRotator(items, {
overflowPx: () => Math.max(0, (track?.scrollWidth ?? 0) - (viewport?.clientWidth ?? 0)),
show: (i) => {
current = i;
tx = 0;
txDur = 0;
},
scrollTo: (toPx, durationMs) => {
txDur = durationMs;
tx = -toPx;
},
}, config);
rotator.start();
});
onDestroy(() => rotator?.stop());
</script>
<div class="ad" bind:this={viewport}>
{#key current}
<div
class="track"
bind:this={track}
in:fade={{ duration: config.fadeMs }}
style="transform:translateX({tx}px); transition:transform {txDur}ms linear"
>
{@html linkify(items[current]?.md ?? '')}
</div>
{/key}
</div>
<style>
.ad {
overflow: hidden;
white-space: nowrap;
padding: 6px var(--pad);
background: var(--surface-2);
color: var(--text-muted);
font-size: 0.85rem;
line-height: 1.2;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
user-select: none;
}
.track {
display: inline-block;
will-change: transform;
}
.track :global(a) {
color: var(--accent);
text-decoration: underline;
}
</style>
+43 -20
View File
@@ -5,27 +5,38 @@
let { title, back, menu }: { title: string; back?: string; menu?: Snippet } = $props(); let { title, back, menu }: { title: string; back?: string; menu?: Snippet } = $props();
</script> </script>
<header class="topbar"> <header class="nav">
{#if back} <div class="bar">
<button class="icon" onclick={() => back && navigate(back)} aria-label="Back"></button> {#if back}
{:else} <button class="icon back" onclick={() => back && navigate(back)} aria-label="Back">
<span class="spacer"></span> <span class="chev"></span>
{/if} </button>
<h1>{title}</h1> {:else}
<div class="end">{#if menu}{@render menu()}{/if}</div> <span class="spacer"></span>
{/if}
<h1>{title}</h1>
<div class="end">{#if menu}{@render menu()}{/if}</div>
</div>
</header> </header>
<style> <style>
.topbar { /* The nav bar grows to fill the spare vertical space (buttons stay at the top), so
the rest of the screen pins to the bottom — a mobile-app layout. */
.nav {
flex: 1 1 auto;
min-height: 52px;
background: var(--bg-elev);
border-bottom: 1px solid var(--border);
display: flex;
flex-direction: column;
user-select: none;
-webkit-user-select: none;
}
.bar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--gap); gap: var(--gap);
padding: 10px var(--pad); padding: 10px var(--pad);
background: var(--bg-elev);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 5;
} }
h1 { h1 {
font-size: 1.05rem; font-size: 1.05rem;
@@ -33,28 +44,40 @@
flex: 1; flex: 1;
text-align: center; text-align: center;
font-weight: 600; font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.icon, .icon,
.spacer, .spacer,
.end { .end {
width: 40px; min-width: 40px;
height: 32px; height: 36px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.end { .end {
width: auto; width: auto;
min-width: 40px; justify-content: flex-end;
} }
.icon { .back {
background: none; background: none;
border: none; border: none;
font-size: 1.1rem;
color: var(--text); color: var(--text);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: 0 8px;
} }
.icon:hover { .back:hover {
background: var(--surface-2); background: var(--surface-2);
} }
/* A thin, compact "<" drawn from two borders — lighter than a glyph. */
.chev {
width: 11px;
height: 11px;
border-left: 2.5px solid currentColor;
border-bottom: 2.5px solid currentColor;
transform: rotate(45deg);
margin-left: 3px;
}
</style> </style>
+102
View File
@@ -0,0 +1,102 @@
<script lang="ts">
import type { Snippet } from 'svelte';
// A press-and-hold control: a short tap opens a popover (the consumer renders its
// buttons), a ~holdMs hold runs `onhold` immediately. Reused by MakeMove and the
// game tab-bar confirmations. The popover snippet receives a `close` callback.
let {
onhold,
holdMs = 700,
disabled = false,
triggerClass = '',
trigger,
popover,
}: {
onhold: () => void;
holdMs?: number;
disabled?: boolean;
triggerClass?: string;
trigger: Snippet;
popover: Snippet<[() => void]>;
} = $props();
let open = $state(false);
let timer: ReturnType<typeof setTimeout> | null = null;
let held = false;
function clear() {
if (timer) {
clearTimeout(timer);
timer = null;
}
}
function down() {
if (disabled) return;
held = false;
clear();
timer = setTimeout(() => {
held = true;
open = false;
onhold();
}, holdMs);
}
function up() {
clear();
if (!held && !disabled) open = true;
}
function leave() {
clear();
}
const close = () => (open = false);
</script>
<div class="hc">
<button
class="trigger {triggerClass}"
{disabled}
onpointerdown={down}
onpointerup={up}
onpointerleave={leave}
onpointercancel={leave}
>
{@render trigger()}
</button>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="backdrop" onclick={close}></div>
<div class="popover">{@render popover(close)}</div>
{/if}
</div>
<style>
.hc {
position: relative;
display: flex;
}
.trigger {
width: 100%;
background: none;
border: none;
padding: 0;
color: inherit;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
.backdrop {
position: fixed;
inset: 0;
z-index: 18;
}
.popover {
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
z-index: 19;
display: flex;
gap: 4px;
white-space: nowrap;
}
</style>
+83
View File
@@ -0,0 +1,83 @@
<script lang="ts">
// The header hamburger + dropdown, shared by the lobby and game screens.
let { items }: { items: { label: string; onclick: () => void }[] } = $props();
let open = $state(false);
function pick(fn: () => void) {
open = false;
fn();
}
</script>
<div class="menu">
<button class="burger" onclick={() => (open = !open)} aria-label="Menu">
<span></span><span></span><span></span>
</button>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="backdrop" onclick={() => (open = false)}></div>
<div class="dropdown">
{#each items as it (it.label)}
<button onclick={() => pick(it.onclick)}>{it.label}</button>
{/each}
</div>
{/if}
</div>
<style>
.menu {
position: relative;
display: inline-flex;
}
.burger {
background: none;
border: none;
width: 44px;
height: 38px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 5px;
padding: 0 10px;
user-select: none;
-webkit-user-select: none;
}
.burger span {
display: block;
height: 3px;
background: var(--text);
border-radius: 2px;
}
.backdrop {
position: fixed;
inset: 0;
z-index: 8;
}
.dropdown {
position: absolute;
right: 0;
top: calc(100% + 6px);
z-index: 9;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
min-width: 170px;
overflow: hidden;
}
.dropdown button {
padding: 12px 16px;
text-align: left;
background: none;
border: none;
color: var(--text);
user-select: none;
-webkit-user-select: none;
}
.dropdown button:hover {
background: var(--surface-2);
}
</style>
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Header from './Header.svelte';
import AdBanner from './AdBanner.svelte';
// The app-shell layout (all screens): the nav bar grows; the ad strip, content and
// optional tab bar pin to the bottom (ad directly above the content). Pass `scroll`
// false for screens that own their vertical fit (the game board).
let {
title,
back,
menu,
tabbar,
children,
scroll = true,
}: {
title: string;
back?: string;
menu?: Snippet;
tabbar?: Snippet;
children?: Snippet;
scroll?: boolean;
} = $props();
</script>
<div class="screen">
<Header {title} {back} {menu} />
<AdBanner />
<main class="content" class:scroll>{@render children?.()}</main>
{#if tabbar}
<nav class="tabbar">{@render tabbar()}</nav>
{/if}
</div>
<style>
.screen {
display: flex;
flex-direction: column;
height: 100%;
}
.content {
flex: 0 1 auto;
min-height: 0;
}
.content.scroll {
overflow-y: auto;
}
.tabbar {
flex: 0 0 auto;
}
</style>
+62
View File
@@ -0,0 +1,62 @@
<script lang="ts">
import type { Snippet } from 'svelte';
// The bottom tab bar: square borderless buttons, evenly distributed, mobile-OS feel.
// Direct children (plain `.tab` buttons or HoldConfirm wrappers) share the width.
let { children }: { children?: Snippet } = $props();
</script>
<div class="tabbar">{@render children?.()}</div>
<style>
.tabbar {
display: flex;
gap: 12px;
padding: 8px var(--pad);
background: var(--bg-elev);
border-top: 1px solid var(--border);
user-select: none;
-webkit-user-select: none;
}
:global(.tabbar > *) {
flex: 1 1 0;
min-width: 0;
}
/* Tab face: an icon square (the press-highlight target) + a tiny truncated label. */
:global(.tab) {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
background: none;
border: none;
padding: 2px 0;
color: var(--text);
width: 100%;
user-select: none;
-webkit-user-select: none;
}
:global(.tab:disabled) {
opacity: 0.4;
}
:global(.tab .sq) {
width: 48px;
height: 40px;
display: grid;
place-items: center;
border-radius: 12px;
font-size: 1.5rem;
line-height: 1;
transition: background-color 0.12s;
}
:global(.tab:active:not(:disabled) .sq) {
background: var(--surface-2);
}
:global(.tab .lbl) {
font-size: 0.62rem;
color: var(--text-muted);
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
+121 -65
View File
@@ -3,6 +3,8 @@
import type { Premium } from '../lib/premiums'; import type { Premium } from '../lib/premiums';
import { tileValue } from '../lib/premiums'; import { tileValue } from '../lib/premiums';
import type { Variant } from '../lib/model'; import type { Variant } from '../lib/model';
import { bonusLabel, type BoardLabelMode } from '../lib/boardlabels';
import type { Locale } from '../lib/i18n/catalog';
let { let {
board, board,
@@ -12,6 +14,9 @@
centre, centre,
zoomed, zoomed,
variant, variant,
labelMode,
locale,
focus,
oncell, oncell,
ontogglezoom, ontogglezoom,
}: { }: {
@@ -22,18 +27,30 @@
centre: { row: number; col: number }; centre: { row: number; col: number };
zoomed: boolean; zoomed: boolean;
variant: Variant; variant: Variant;
labelMode: BoardLabelMode;
locale: Locale;
focus: { row: number; col: number } | null;
oncell: (row: number, col: number) => void; oncell: (row: number, col: number) => void;
ontogglezoom: () => void; ontogglezoom: () => void;
} = $props(); } = $props();
const premClass: Record<Premium, string> = { const Z = 1.85;
'': '', const z = $derived(zoomed ? Z : 1);
TW: 'tw', const premClass: Record<Premium, string> = { '': '', TW: 'tw', DW: 'dw', TL: 'tl', DL: 'dl' };
DW: 'dw',
TL: 'tl', let viewport = $state<HTMLElement>();
DL: 'dl',
}; // When zoomed in (typically on a placement), centre the focus cell.
const premLabel: Record<Premium, string> = { '': '', TW: '3W', DW: '2W', TL: '3L', DL: '2L' }; $effect(() => {
const vp = viewport;
if (!vp || !zoomed || !focus) return;
const cell = vp.clientWidth / 15;
vp.scrollTo({
left: (focus.col + 0.5) * cell * Z - vp.clientWidth / 2,
top: (focus.row + 0.5) * cell * Z - vp.clientHeight / 2,
behavior: 'smooth',
});
});
// Double-tap toggles zoom. // Double-tap toggles zoom.
let lastTap = 0; let lastTap = 0;
@@ -48,13 +65,12 @@
oncell(row, col); oncell(row, col);
} }
// Minimal pinch: two pointers spreading apart zoom in, pinching together zoom out. // Minimal pinch: spread zooms in, pinch zooms out (two-state).
const pts = new Map<number, { x: number; y: number }>(); const pts = new Map<number, { x: number; y: number }>();
let startDist = 0; let startDist = 0;
function dist(): number { function dist(): number {
const p = [...pts.values()]; const p = [...pts.values()];
if (p.length < 2) return 0; return p.length < 2 ? 0 : Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y);
return Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y);
} }
function onPointerDown(e: PointerEvent) { function onPointerDown(e: PointerEvent) {
pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
@@ -79,83 +95,88 @@
if (pts.size < 2) startDist = 0; if (pts.size < 2) startDist = 0;
} }
function key(r: number, c: number): string { const key = (r: number, c: number) => `${r},${c}`;
return `${r},${c}`;
}
</script> </script>
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div <div
class="viewport" class="viewport"
class:zoomed class:zoomed
bind:this={viewport}
onpointerdown={onPointerDown} onpointerdown={onPointerDown}
onpointermove={onPointerMove} onpointermove={onPointerMove}
onpointerup={onPointerUp} onpointerup={onPointerUp}
onpointercancel={onPointerUp} onpointercancel={onPointerUp}
> >
<div class="grid" class:zoomed> <div class="scaler" style="transform: scale({z}); --inv: {1 / z};">
{#each board as rowCells, r (r)} <div class="grid">
{#each rowCells as cell, c (c)} {#each board as rowCells, r (r)}
{@const p = pending.get(key(r, c))} {#each rowCells as cell, c (c)}
{@const letter = cell?.letter ?? p?.letter ?? ''} {@const p = pending.get(key(r, c))}
{@const blank = cell?.blank ?? p?.blank ?? false} {@const letter = cell?.letter ?? p?.letter ?? ''}
<button {@const blank = cell?.blank ?? p?.blank ?? false}
class="cell {premClass[premium[r][c]]}" {@const bl = letter ? null : bonusLabel(labelMode, premium[r][c], locale)}
class:filled={!!cell} <button
class:pending={!!p && !cell} class="cell {premClass[premium[r][c]]}"
class:recent={recent.has(key(r, c))} class:filled={!!cell}
data-cell class:pending={!!p && !cell}
data-row={r} class:recent={recent.has(key(r, c))}
data-col={c} data-cell
onclick={() => onTap(r, c)} data-row={r}
> data-col={c}
{#if letter} onclick={() => onTap(r, c)}
<span class="letter">{letter}</span> >
{#if !blank}<span class="val">{tileValue(variant, letter)}</span>{/if} <span class="ct">
{:else if r === centre.row && c === centre.col} {#if letter}
<span class="star"></span> <span class="letter">{letter}</span>
{:else if premLabel[premium[r][c]]} {#if !blank}<span class="val">{tileValue(variant, letter)}</span>{/if}
<span class="plabel">{premLabel[premium[r][c]]}</span> {:else if r === centre.row && c === centre.col}
{/if} <span class="star"></span>
</button> {:else if bl?.kind === 'single'}
<span class="b1">{bl.text}</span>
{:else if bl?.kind === 'split'}
<span class="bsplit"><span class="bt">{bl.top}</span><span class="bb">{bl.bottom}</span></span>
{/if}
</span>
</button>
{/each}
{/each} {/each}
{/each} </div>
</div> </div>
</div> </div>
<style> <style>
.viewport { .viewport {
width: 100%; width: 100%;
aspect-ratio: 1;
overflow: hidden;
background: var(--board-bg); background: var(--board-bg);
padding: 4px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
container-type: inline-size;
touch-action: none; touch-action: none;
} }
.viewport.zoomed { .viewport.zoomed {
overflow: auto; overflow: auto;
max-height: 70vh; }
.scaler {
width: 100%;
transform-origin: 0 0;
transition: transform 0.25s ease;
} }
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(15, 1fr); grid-template-columns: repeat(15, 1fr);
gap: 2px; gap: 1px;
width: 100%; background: var(--cell-line);
} padding: 1px;
.grid.zoomed {
grid-template-columns: repeat(15, 2.6rem);
width: max-content;
} }
.cell { .cell {
position: relative; position: relative;
aspect-ratio: 1; aspect-ratio: 1;
border: none; border: none;
border-radius: 2px; border-radius: 1px;
background: var(--cell-bg); background: var(--cell-bg);
color: var(--prem-text); color: var(--prem-text);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.62rem;
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
} }
@@ -187,27 +208,62 @@
inset 0 -2px 0 var(--tile-edge), inset 0 -2px 0 var(--tile-edge),
0 0 0 2px var(--warn); 0 0 0 2px var(--warn);
} }
/* 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;
}
.letter { .letter {
font-size: 1.05em; position: absolute;
top: 6%;
left: 9%;
font-size: 4.3cqw;
font-weight: 700; font-weight: 700;
line-height: 1; line-height: 1;
} }
.grid:not(.zoomed) .letter {
font-size: 2.6vw;
}
.val { .val {
position: absolute; position: absolute;
right: 1px; right: 5%;
bottom: 0; bottom: 2%;
font-size: 0.55em; font-size: 2.5cqw;
font-weight: 600;
}
.plabel {
opacity: 0.85;
font-weight: 600; font-weight: 600;
} }
.star { .star {
font-size: 1.1em; position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 4cqw;
opacity: 0.7; opacity: 0.7;
} }
.b1 {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 3cqw;
font-weight: 600;
opacity: 0.9;
}
.bsplit {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
line-height: 1;
opacity: 0.92;
}
.bt {
font-size: 2.4cqw;
font-weight: 600;
}
.bb {
font-size: 3.1cqw;
font-weight: 700;
}
</style> </style>
+297 -190
View File
@@ -1,14 +1,16 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import Header from '../components/Header.svelte'; import Screen from '../components/Screen.svelte';
import Menu from '../components/Menu.svelte';
import TabBar from '../components/TabBar.svelte';
import HoldConfirm from '../components/HoldConfirm.svelte';
import Modal from '../components/Modal.svelte'; import Modal from '../components/Modal.svelte';
import Board from './Board.svelte'; import Board from './Board.svelte';
import Rack from './Rack.svelte'; import Rack from './Rack.svelte';
import MakeMove from './MakeMove.svelte';
import Controls from './Controls.svelte';
import Chat from './Chat.svelte'; import Chat from './Chat.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { app, handleError, showToast } from '../lib/app.svelte'; import { app, handleError, showToast } from '../lib/app.svelte';
import { GatewayError } from '../lib/client';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model'; import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model';
import { lastPlayTiles, replay } from '../lib/board'; import { lastPlayTiles, replay } from '../lib/board';
@@ -18,6 +20,7 @@
direction, direction,
newPlacement, newPlacement,
place, place,
placementFromHint,
rackView, rackView,
recallAt, recallAt,
reset, reset,
@@ -35,8 +38,9 @@
let busy = $state(false); let busy = $state(false);
let zoomed = $state(false); let zoomed = $state(false);
let selected = $state<number | null>(null); let selected = $state<number | null>(null);
let panel = $state<'none' | 'chat' | 'history'>('none'); let focus = $state<{ row: number; col: number } | null>(null);
let menuOpen = $state(false); let panel = $state<'none' | 'chat'>('none');
let historyOpen = $state(false);
let blankPrompt = $state<{ rackIndex: number; row: number; col: number } | null>(null); let blankPrompt = $state<{ rackIndex: number; row: number; col: number } | null>(null);
let exchangeOpen = $state(false); let exchangeOpen = $state(false);
let exchangeSel = $state<number[]>([]); let exchangeSel = $state<number[]>([]);
@@ -47,6 +51,9 @@
let messages = $state<ChatMessage[]>([]); let messages = $state<ChatMessage[]>([]);
let drag = $state<{ letter: string; blank: boolean; x: number; y: number } | null>(null); let drag = $state<{ letter: string; blank: boolean; x: number; y: number } | null>(null);
const checkedWords = new Map<string, boolean>();
let lastCheckAt = 0;
const variant = $derived(view?.game.variant ?? 'english'); const variant = $derived(view?.game.variant ?? 'english');
const board = $derived(replay(moves)); const board = $derived(replay(moves));
const premium = $derived(premiumGrid(variant)); const premium = $derived(premiumGrid(variant));
@@ -56,12 +63,11 @@
); );
const recent = $derived(new Set(lastPlayTiles(moves).map((tt) => `${tt.row},${tt.col}`))); const recent = $derived(new Set(lastPlayTiles(moves).map((tt) => `${tt.row},${tt.col}`)));
const slots = $derived(rackView(placement)); const slots = $derived(rackView(placement));
const isMyTurn = $derived( const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat);
!!view && view.game.status === 'active' && view.game.toMove === view.seat,
);
const gameOver = $derived(!!view && view.game.status !== 'active'); const gameOver = $derived(!!view && view.game.status !== 'active');
const dir = $derived(dirOverride ?? direction(placement) ?? 'H'); const dir = $derived(dirOverride ?? direction(placement) ?? 'H');
const ambiguous = $derived(placement.pending.length === 1); const ambiguous = $derived(placement.pending.length === 1);
const bagEmpty = $derived((view?.bagLen ?? 0) === 0);
async function load() { async function load() {
try { try {
@@ -76,7 +82,6 @@
handleError(e); handleError(e);
} }
} }
async function loadChat() { async function loadChat() {
try { try {
messages = await gateway.chatList(id); messages = await gateway.chatList(id);
@@ -84,7 +89,6 @@
handleError(e); handleError(e);
} }
} }
onMount(load); onMount(load);
$effect(() => { $effect(() => {
@@ -99,7 +103,7 @@
return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches; return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
} }
// --- tile placement: pointer drag + tap, both feeding the placement model --- // --- tile placement: pointer drag + tap ---
let downInfo: { index: number; x0: number; y0: number } | null = null; let downInfo: { index: number; x0: number; y0: number } | null = null;
let dragMoved = false; let dragMoved = false;
let swallowClick = false; let swallowClick = false;
@@ -117,7 +121,7 @@
dragMoved = true; dragMoved = true;
const slot = placement.rack[downInfo.index]; const slot = placement.rack[downInfo.index];
drag = { letter: slot, blank: slot === BLANK, x: e.clientX, y: e.clientY }; drag = { letter: slot, blank: slot === BLANK, x: e.clientX, y: e.clientY };
if (isCoarse() && !zoomed) zoomed = true; // auto zoom-in on touch placement if (isCoarse() && !zoomed) zoomed = true;
} }
if (drag) drag = { ...drag, x: e.clientX, y: e.clientY }; if (drag) drag = { ...drag, x: e.clientX, y: e.clientY };
} }
@@ -158,10 +162,11 @@
selected = null; selected = null;
} }
} }
function attemptPlace(index: number, row: number, col: number) { function attemptPlace(index: number, row: number, col: number) {
if (board[row]?.[col]) return; if (board[row]?.[col]) return;
if (pendingMap.has(`${row},${col}`)) return; if (pendingMap.has(`${row},${col}`)) return;
focus = { row, col };
if (isCoarse() && !zoomed) zoomed = true;
if (placement.rack[index] === BLANK) { if (placement.rack[index] === BLANK) {
blankPrompt = { rackIndex: index, row, col }; blankPrompt = { rackIndex: index, row, col };
return; return;
@@ -169,7 +174,6 @@
placement = place(placement, index, row, col); placement = place(placement, index, row, col);
recompute(); recompute();
} }
function chooseBlank(letter: string) { function chooseBlank(letter: string) {
if (!blankPrompt) return; if (!blankPrompt) return;
placement = place(placement, blankPrompt.rackIndex, blankPrompt.row, blankPrompt.col, letter); placement = place(placement, blankPrompt.rackIndex, blankPrompt.row, blankPrompt.col, letter);
@@ -187,7 +191,7 @@
try { try {
preview = await gateway.evaluate(id, sub.dir, sub.tiles); preview = await gateway.evaluate(id, sub.dir, sub.tiles);
} catch { } catch {
/* preview is best-effort */ /* best-effort */
} }
}, 250); }, 250);
} }
@@ -198,6 +202,7 @@
busy = true; busy = true;
try { try {
await gateway.submitPlay(id, sub.dir, sub.tiles); await gateway.submitPlay(id, sub.dir, sub.tiles);
zoomed = false;
await load(); await load();
} catch (e) { } catch (e) {
handleError(e); handleError(e);
@@ -205,7 +210,6 @@
busy = false; busy = false;
} }
} }
function resetPlacement() { function resetPlacement() {
placement = reset(placement); placement = reset(placement);
preview = null; preview = null;
@@ -224,7 +228,6 @@
busy = false; busy = false;
} }
} }
async function doResign() { async function doResign() {
resignOpen = false; resignOpen = false;
busy = true; busy = true;
@@ -237,18 +240,24 @@
busy = false; busy = false;
} }
} }
async function doHint() { async function doHint() {
try { try {
const h = await gateway.hint(id); const h = await gateway.hint(id);
const word = h.move.words[0] ?? h.move.tiles.map((x) => x.letter).join(''); if (h.move.tiles.length && view) {
showToast(t('game.hintShown', { word, n: h.move.score })); placement = placementFromHint(h.move.tiles, view.rack);
if (view) view = { ...view, hintsRemaining: h.hintsRemaining }; if (isCoarse()) zoomed = true;
view = { ...view, hintsRemaining: h.hintsRemaining };
recompute();
}
} catch (e) { } catch (e) {
handleError(e); // The backend does not spend a hint when there is no move.
if (e instanceof GatewayError && e.code === 'no_hint_available') {
showToast(t('game.noHintOptions'), 'info');
} else {
handleError(e);
}
} }
} }
function shuffle() { function shuffle() {
if (placement.pending.length > 0) return; if (placement.pending.length > 0) return;
const r = [...placement.rack]; const r = [...placement.rack];
@@ -258,14 +267,12 @@
} }
placement = newPlacement(r); placement = newPlacement(r);
} }
function toggleDir() { function toggleDir() {
dirOverride = dir === 'H' ? 'V' : 'H'; dirOverride = dir === 'H' ? 'V' : 'H';
recompute(); recompute();
} }
function openExchange() { function openExchange() {
menuOpen = false;
resetPlacement(); resetPlacement();
exchangeSel = []; exchangeSel = [];
exchangeOpen = true; exchangeOpen = true;
@@ -289,16 +296,31 @@
} }
function openCheck() { function openCheck() {
menuOpen = false;
checkWord = ''; checkWord = '';
checkResult = null; checkResult = null;
checkOpen = true; 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('');
}
async function runCheck() { async function runCheck() {
const w = checkWord.trim(); const w = checkWord.trim().toUpperCase();
if (!w) return; 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();
try { try {
checkResult = await gateway.checkWord(id, w); const r = await gateway.checkWord(id, w);
checkedWords.set(r.word.toUpperCase(), r.legal);
checkResult = r;
} catch (e) { } catch (e) {
handleError(e); handleError(e);
} }
@@ -308,123 +330,173 @@
try { try {
await gateway.complaint(id, checkResult.word, ''); await gateway.complaint(id, checkResult.word, '');
showToast(t('game.complaintSent')); showToast(t('game.complaintSent'));
checkOpen = false;
} catch (e) { } catch (e) {
handleError(e); handleError(e);
} }
} }
function openChat() { function openChat() {
menuOpen = false;
panel = 'chat'; panel = 'chat';
void loadChat(); void loadChat();
} }
async function sendChat(text: string) { async function sendChat(text: string) {
try { try {
const m = await gateway.chatPost(id, text); messages = [...messages, await gateway.chatPost(id, text)];
messages = [...messages, m];
} catch (e) { } catch (e) {
handleError(e); handleError(e);
} }
} }
async function nudge() { async function nudge() {
try { try {
const m = await gateway.nudge(id); messages = [...messages, await gateway.nudge(id)];
messages = [...messages, m];
} catch (e) { } catch (e) {
handleError(e); handleError(e);
} }
} }
// 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 { function resultText(): string {
if (!view) return ''; if (!view) return '';
const me = view.game.seats[view.seat]; const me = view.game.seats[view.seat];
if (me?.isWinner) return t('game.won'); if (me?.isWinner) return t('game.won');
return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied'); return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied');
} }
const menuItems = $derived([
{ label: t('game.history'), onclick: () => (historyOpen = true) },
{ label: t('game.chat'), onclick: openChat },
{ label: t('game.checkWord'), onclick: openCheck },
{ label: t('game.dropGame'), onclick: () => (resignOpen = true) },
]);
</script> </script>
<Header title={t('app.title')} back="/"> <Screen title={t('app.title')} back="/" scroll={!historyOpen}>
{#snippet menu()} {#snippet menu()}
<button class="icon" onclick={() => (menuOpen = !menuOpen)} aria-label="Menu"></button> <Menu items={menuItems} />
{#if menuOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="backdrop" onclick={() => (menuOpen = false)}></div>
<div class="dropdown">
<button onclick={() => { menuOpen = false; panel = 'history'; }}>{t('game.history')}</button>
<button onclick={openChat}>{t('game.chat')}</button>
<button onclick={openCheck}>{t('game.checkWord')}</button>
<button onclick={() => { menuOpen = false; resignOpen = true; }}>{t('game.dropGame')}</button>
</div>
{/if}
{/snippet} {/snippet}
</Header>
{#if view} {#if view}
<div class="scoreboard"> <div class="scoreboard">
{#each view.game.seats as s (s.seat)} {#each view.game.seats as s (s.seat)}
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}> <div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
<div class="nm">{s.accountId === app.session?.userId ? t('common.you') : s.displayName}</div> <div class="nm">{s.accountId === app.session?.userId ? t('common.you') : s.displayName}</div>
<div class="sc">{s.score}</div> <div class="sc">{s.score}</div>
</div> </div>
{/each} {/each}
</div>
<div class="boardwrap">
<Board
{board}
{premium}
pending={pendingMap}
{recent}
centre={ctr}
{zoomed}
{variant}
oncell={onCell}
ontogglezoom={() => (zoomed = !zoomed)}
/>
</div>
<div class="status">
<span>{t('game.bag', { n: view.bagLen })}</span>
{#if gameOver}
<strong class="over">{t('game.over')}{resultText()}</strong>
{:else}
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : t('game.waiting', { name: view.game.seats[view.game.toMove]?.displayName ?? '' })}</span>
{/if}
<span>{t('game.hints', { n: view.hintsRemaining })}</span>
</div>
{#if !gameOver}
<div class="rack-row">
<div class="rack-wrap">
<Rack {slots} {variant} {selected} ondown={onRackDown} />
</div>
{#if placement.pending.length > 0}
<MakeMove
label={t('game.makeMove')}
resetLabel={t('game.reset')}
onmake={commit}
onreset={resetPlacement}
/>
{/if}
</div> </div>
<Controls <div class="stage">
{preview} {#if historyOpen}
hints={view.hintsRemaining} <div class="history">
busy={busy || !isMyTurn} <ol>
{ambiguous} {#each moves as m, i (i)}
{dir} <li>
ondraw={openExchange} <span class="hp">{view.game.seats[m.player]?.displayName ?? m.player}</span>
onskip={doPass} <span class="ha">{m.action === 'play' ? m.words.join(', ') : m.action}</span>
onshuffle={shuffle} <span class="hs">{m.score}</span>
onhint={doHint} </li>
ondir={toggleDir} {/each}
/> {#if moves.length === 0}<li class="hempty"></li>{/if}
</ol>
</div>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="boardwrap"
class:slid={historyOpen}
onpointerdown={boardSwipeStart}
onpointerup={boardSwipeEnd}
onclick={() => historyOpen && (historyOpen = false)}
>
<Board
{board}
{premium}
pending={pendingMap}
{recent}
centre={ctr}
{zoomed}
{variant}
labelMode={app.boardLabels}
locale={app.locale}
{focus}
oncell={onCell}
ontogglezoom={() => (zoomed = !zoomed)}
/>
</div>
</div>
<div class="status">
<span>{t('game.bag', { n: view.bagLen })}</span>
{#if gameOver}
<strong class="over">{t('game.over')}{resultText()}</strong>
{:else}
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : view.game.seats[view.game.toMove]?.displayName ?? ''}</span>
{/if}
<span class="scores">
{#if preview}{preview.legal ? t('game.scores', { n: preview.score }) : t('game.previewIllegal')}{/if}
</span>
</div>
{#if !gameOver}
<div class="rack-row">
<div class="rack-wrap">
<Rack {slots} {variant} {selected} ondown={onRackDown} />
</div>
{#if placement.pending.length > 0}
<HoldConfirm triggerClass="make" onhold={commit}>
{#snippet trigger()}<span class="flag">🏁</span>{/snippet}
{#snippet popover(close)}
<button class="pop go" onclick={() => { close(); commit(); }}>{t('game.makeMove')} ✅</button>
<button class="pop rs" onclick={() => { close(); resetPlacement(); }}>{t('game.reset')} ❌</button>
{/snippet}
</HoldConfirm>
{/if}
</div>
{/if}
{:else}
<p class="loading">{t('common.loading')}</p>
{/if} {/if}
{:else}
<p class="loading">{t('common.loading')}</p> {#snippet tabbar()}
{/if} {#if view && !gameOver}
<TabBar>
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn || bagEmpty} onhold={openExchange}>
{#snippet trigger()}<span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>{/snippet}
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); openExchange(); }}>{t('game.confirm')} ✅</button>{/snippet}
</HoldConfirm>
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn} onhold={doPass}>
{#snippet trigger()}<span class="sq">🥺</span><span class="lbl">{t('game.skip')}</span>{/snippet}
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doPass(); }}>{t('game.confirm')} ✅</button>{/snippet}
</HoldConfirm>
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn} onhold={doHint}>
{#snippet trigger()}
<span class="sq">🛟{#if (view?.hintsRemaining ?? 0) > 0}<span class="badge">{view?.hintsRemaining}</span>{/if}</span>
<span class="lbl">{t('game.hint')}</span>
{/snippet}
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doHint(); }}>{t('game.confirm')} ✅</button>{/snippet}
</HoldConfirm>
<button class="tab" disabled={busy || placement.pending.length > 0} onclick={shuffle}>
<span class="sq">🔀</span>
</button>
</TabBar>
{/if}
{/snippet}
</Screen>
{#if drag} {#if drag}
<div class="ghost" style="left:{drag.x}px; top:{drag.y}px"> <div class="ghost" style="left:{drag.x}px; top:{drag.y}px">
@@ -432,6 +504,10 @@
</div> </div>
{/if} {/if}
{#if ambiguous && placement.pending.length > 0}
<button class="dirtoggle" onclick={toggleDir} aria-label="direction">{dir === 'H' ? '↔' : '↕'}</button>
{/if}
{#if blankPrompt} {#if blankPrompt}
<Modal title={t('game.chooseBlank')} onclose={() => (blankPrompt = null)}> <Modal title={t('game.chooseBlank')} onclose={() => (blankPrompt = null)}>
<div class="alpha"> <div class="alpha">
@@ -460,8 +536,13 @@
{#if checkOpen} {#if checkOpen}
<Modal title={t('game.checkWord')} onclose={() => (checkOpen = false)}> <Modal title={t('game.checkWord')} onclose={() => (checkOpen = false)}>
<div class="check"> <div class="check">
<input placeholder={t('game.checkWordPrompt')} bind:value={checkWord} onkeydown={(e) => e.key === 'Enter' && runCheck()} /> <input
<button onclick={runCheck}>{t('game.checkWord')}</button> value={checkWord}
oninput={onCheckInput}
onkeydown={(e) => e.key === 'Enter' && runCheck()}
placeholder={t('game.checkWordPrompt')}
/>
<button onclick={runCheck}>{t('game.check')}</button>
</div> </div>
{#if checkResult} {#if checkResult}
<p class:ok={checkResult.legal} class:bad={!checkResult.legal}> <p class:ok={checkResult.legal} class:bad={!checkResult.legal}>
@@ -489,27 +570,12 @@
</Modal> </Modal>
{/if} {/if}
{#if panel === 'history' && view}
<Modal title={t('game.history')} onclose={() => (panel = 'none')}>
<ol class="history">
{#each moves as m, i (i)}
<li>
<span class="hp">{view.game.seats[m.player]?.displayName ?? m.player}</span>
<span class="ha">{m.action === 'play' ? m.words.join(', ') : m.action}</span>
<span class="hs">{m.score}</span>
</li>
{/each}
</ol>
</Modal>
{/if}
<style> <style>
.scoreboard { .scoreboard {
display: flex; display: flex;
gap: 2px; gap: 2px;
padding: 6px var(--pad); padding: 6px var(--pad);
background: var(--bg-elev); background: var(--bg-elev);
border-bottom: 1px solid var(--border);
} }
.seat { .seat {
flex: 1; flex: 1;
@@ -535,14 +601,61 @@
font-weight: 700; font-weight: 700;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.stage {
position: relative;
overflow: hidden;
}
.history {
position: absolute;
inset: 0 0 auto 0;
z-index: 2;
max-height: 60%;
overflow: auto;
background: var(--surface-2);
box-shadow: inset 0 -6px 10px -8px rgba(0, 0, 0, 0.5);
border-bottom: 1px solid var(--border);
}
.history ol {
margin: 0;
padding: 8px 14px;
list-style: decimal;
display: flex;
flex-direction: column;
gap: 4px;
}
.history li {
display: flex;
justify-content: space-between;
gap: 10px;
font-size: 0.9rem;
}
.hp {
color: var(--text-muted);
}
.ha {
flex: 1;
text-align: center;
}
.hs {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.hempty {
justify-content: center;
color: var(--text-muted);
}
.boardwrap { .boardwrap {
padding: 8px; padding: 6px;
transition: transform 0.3s ease;
}
.boardwrap.slid {
transform: translateY(62%);
} }
.status { .status {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0 var(--pad) 8px; padding: 2px var(--pad) 6px;
color: var(--text-muted); color: var(--text-muted);
font-size: 0.85rem; font-size: 0.85rem;
} }
@@ -553,60 +666,62 @@
.over { .over {
color: var(--accent); color: var(--accent);
} }
.scores {
font-weight: 600;
color: var(--ok);
min-width: 64px;
text-align: right;
}
.rack-row { .rack-row {
display: flex; display: flex;
gap: 8px; gap: 8px;
align-items: stretch; align-items: stretch;
padding: 0 var(--pad); padding: 0 var(--pad) 6px;
} }
.rack-wrap { .rack-wrap {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
:global(.rack-row .wrap) { .flag {
display: flex; font-size: 1.6rem;
}
:global(.make) {
min-width: 56px;
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius-sm);
display: grid;
place-items: center;
}
.pop {
padding: 9px 12px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
font-weight: 600;
}
.pop.go {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
.badge {
position: absolute;
top: 0;
right: 4px;
font-size: 0.6rem;
background: var(--accent);
color: var(--accent-text);
border-radius: 999px;
padding: 0 4px;
line-height: 1.3;
} }
.loading { .loading {
text-align: center; text-align: center;
color: var(--text-muted); color: var(--text-muted);
padding: 40px; padding: 40px;
} }
.icon {
background: none;
border: none;
color: var(--text);
font-size: 1.3rem;
line-height: 1;
}
.backdrop {
position: fixed;
inset: 0;
z-index: 8;
}
.dropdown {
position: absolute;
right: 8px;
top: 44px;
z-index: 9;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
min-width: 160px;
overflow: hidden;
}
.dropdown button {
padding: 11px 14px;
text-align: left;
background: none;
border: none;
color: var(--text);
}
.dropdown button:hover {
background: var(--surface-2);
}
.ghost { .ghost {
position: fixed; position: fixed;
width: 40px; width: 40px;
@@ -623,6 +738,20 @@
pointer-events: none; pointer-events: none;
z-index: 60; 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 { .alpha {
display: grid; display: grid;
grid-template-columns: repeat(6, 1fr); grid-template-columns: repeat(6, 1fr);
@@ -677,6 +806,7 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
text-transform: uppercase;
} }
.check button { .check button {
padding: 10px 12px; padding: 10px 12px;
@@ -715,27 +845,4 @@
color: #fff !important; color: #fff !important;
border-color: var(--danger) !important; border-color: var(--danger) !important;
} }
.history {
margin: 0;
padding-left: 20px;
display: flex;
flex-direction: column;
gap: 4px;
}
.history li {
display: flex;
justify-content: space-between;
gap: 10px;
}
.hp {
color: var(--text-muted);
}
.ha {
flex: 1;
text-align: center;
}
.hs {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
</style> </style>
+26 -25
View File
@@ -15,45 +15,41 @@
selected: number | null; selected: number | null;
ondown: (e: PointerEvent, index: number) => void; ondown: (e: PointerEvent, index: number) => void;
} = $props(); } = $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));
</script> </script>
<div class="rack"> <div class="rack">
{#each slots as slot (slot.index)} {#each visible as slot (slot.index)}
{#if slot.used} <button
<span class="slot empty"></span> class="tile"
{:else} class:selected={selected === slot.index}
<button data-rack-index={slot.index}
class="slot tile" onpointerdown={(e) => ondown(e, slot.index)}
class:selected={selected === slot.index} >
data-rack-index={slot.index} <span class="letter">{slot.letter === BLANK ? '' : slot.letter}</span>
onpointerdown={(e) => ondown(e, slot.index)} {#if slot.letter !== BLANK}<span class="val">{tileValue(variant, slot.letter)}</span>{/if}
> </button>
<span class="letter">{slot.letter === BLANK ? '' : slot.letter}</span>
{#if slot.letter !== BLANK}<span class="val">{tileValue(variant, slot.letter)}</span>{/if}
</button>
{/if}
{/each} {/each}
</div> </div>
<style> <style>
.rack { .rack {
display: grid; display: flex;
grid-template-columns: repeat(7, 1fr);
gap: 5px; gap: 5px;
} align-items: center;
.slot {
aspect-ratio: 1;
border-radius: 5px;
}
.empty {
background: var(--surface-2);
border: 1px dashed var(--border);
} }
.tile { .tile {
position: relative; position: relative;
flex: 0 0 auto;
width: min(12.5vw, 46px);
aspect-ratio: 1;
background: var(--tile-bg); background: var(--tile-bg);
color: var(--tile-text); color: var(--tile-text);
border: none; border: none;
border-radius: 5px;
box-shadow: inset 0 -3px 0 var(--tile-edge); box-shadow: inset 0 -3px 0 var(--tile-edge);
font-weight: 700; font-weight: 700;
font-size: 1.4rem; font-size: 1.4rem;
@@ -64,9 +60,14 @@
outline: 3px solid var(--accent); outline: 3px solid var(--accent);
outline-offset: -3px; outline-offset: -3px;
} }
.letter {
position: absolute;
top: 8%;
left: 14%;
}
.val { .val {
position: absolute; position: absolute;
right: 3px; right: 4px;
bottom: 1px; bottom: 1px;
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 600; font-weight: 600;
+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 { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
import { applyReduceMotion, applyTheme, type ThemePref } from './theme'; import { applyReduceMotion, applyTheme, type ThemePref } from './theme';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session'; import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
import type { BoardLabelMode } from './boardlabels';
export interface Toast { export interface Toast {
kind: 'error' | 'info'; kind: 'error' | 'info';
@@ -25,6 +26,7 @@ export const app = $state<{
theme: ThemePref; theme: ThemePref;
locale: Locale; locale: Locale;
reduceMotion: boolean; reduceMotion: boolean;
boardLabels: BoardLabelMode;
localeLocked: boolean; localeLocked: boolean;
}>({ }>({
ready: false, ready: false,
@@ -35,6 +37,7 @@ export const app = $state<{
theme: 'auto', theme: 'auto',
locale: 'en', locale: 'en',
reduceMotion: false, reduceMotion: false,
boardLabels: 'beginner',
localeLocked: false, localeLocked: false,
}); });
@@ -101,6 +104,7 @@ export async function bootstrap(): Promise<void> {
const prefs = await loadPrefs(); const prefs = await loadPrefs();
app.theme = prefs.theme ?? 'auto'; app.theme = prefs.theme ?? 'auto';
app.reduceMotion = prefs.reduceMotion ?? false; app.reduceMotion = prefs.reduceMotion ?? false;
app.boardLabels = prefs.boardLabels ?? 'beginner';
applyTheme(app.theme); applyTheme(app.theme);
applyReduceMotion(app.reduceMotion); applyReduceMotion(app.reduceMotion);
if (prefs.locale) { if (prefs.locale) {
@@ -163,7 +167,12 @@ export async function logout(): Promise<void> {
} }
function persistPrefs(): 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 { export function setTheme(theme: ThemePref): void {
@@ -184,3 +193,8 @@ export function setReduceMotion(on: boolean): void {
applyReduceMotion(on); applyReduceMotion(on);
persistPrefs(); persistPrefs();
} }
export function setBoardLabels(mode: BoardLabelMode): void {
app.boardLabels = mode;
persistPrefs();
}
+46
View File
@@ -0,0 +1,46 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createBannerRotator, defaultBannerConfig, linkify } from './banner';
describe('linkify', () => {
it('escapes html and renders markdown links', () => {
expect(linkify('a < b & c')).toBe('a &lt; b &amp; c');
expect(linkify('see [docs](https://x.com) now')).toBe(
'see <a href="https://x.com" target="_blank" rel="noopener noreferrer">docs</a> now',
);
});
it('drops a non-http(s) link target (keeps the label)', () => {
expect(linkify('[x](ftp://evil)')).toBe('x');
expect(linkify('[y](javascript:boom)')).toBe('y');
});
});
describe('banner rotator', () => {
afterEach(() => vi.useRealTimers());
it('holds a fitting message then advances, and scrolls an overflowing one', () => {
vi.useFakeTimers();
const cfg = { ...defaultBannerConfig, holdMs: 1000, edgePauseMs: 100, fadeMs: 10, scrollPxPerSec: 50 };
const shown: number[] = [];
let scrolled = 0;
const overflow = [0, 200]; // item 0 fits, item 1 overflows
const r = createBannerRotator(
[{ md: 'a' }, { md: 'b' }],
{
overflowPx: (i) => overflow[i],
show: (i) => shown.push(i),
scrollTo: () => scrolled++,
},
cfg,
);
r.start();
expect(shown).toEqual([0]);
vi.advanceTimersByTime(cfg.fadeMs); // settle + measure item 0
vi.advanceTimersByTime(cfg.holdMs); // advance to item 1
expect(shown).toEqual([0, 1]);
vi.advanceTimersByTime(cfg.fadeMs); // settle + measure item 1 (overflows)
vi.advanceTimersByTime(cfg.edgePauseMs); // edge pause -> scrollTo
expect(scrolled).toBe(1);
r.stop();
});
});
+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)!',
},
];
}
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { bonusLabel } from './boardlabels';
describe('bonusLabel', () => {
it('none mode and plain squares have no label', () => {
expect(bonusLabel('none', 'TW', 'en')).toBeNull();
expect(bonusLabel('beginner', '', 'en')).toBeNull();
});
it('classic is a localized single tag', () => {
expect(bonusLabel('classic', 'TW', 'en')).toEqual({ kind: 'single', text: '3W' });
expect(bonusLabel('classic', 'DL', 'en')).toEqual({ kind: 'single', text: '2L' });
expect(bonusLabel('classic', 'TW', 'ru')).toEqual({ kind: 'single', text: '3С' });
expect(bonusLabel('classic', 'DL', 'ru')).toEqual({ kind: 'single', text: '2Б' });
});
it('beginner is a localized split label', () => {
expect(bonusLabel('beginner', 'TW', 'en')).toEqual({ kind: 'split', top: '3×', bottom: 'word' });
expect(bonusLabel('beginner', 'DL', 'en')).toEqual({ kind: 'split', top: '2×', bottom: 'letter' });
expect(bonusLabel('beginner', 'TL', 'ru')).toEqual({ kind: 'split', top: '3×', bottom: 'буква' });
});
});
+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.wordIllegal': '“{word}” is not valid',
'game.complain': 'Disagree', 'game.complain': 'Disagree',
'game.complaintSent': 'Thanks, sent for review.', '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.placeholder': 'Quick message…',
'chat.send': 'Send', 'chat.send': 'Send',
@@ -96,6 +110,11 @@ export const en = {
'settings.themeLight': 'Light', 'settings.themeLight': 'Light',
'settings.themeDark': 'Dark', 'settings.themeDark': 'Dark',
'settings.language': 'Interface language', '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', 'settings.reduceMotion': 'Reduce motion',
'about.title': 'About', 'about.title': 'About',
@@ -108,6 +127,7 @@ export const en = {
'error.not_your_turn': "It is not your turn.", 'error.not_your_turn': "It is not your turn.",
'error.illegal_play': 'That is not a legal play.', 'error.illegal_play': 'That is not a legal play.',
'error.hint_unavailable': 'No hints available.', '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.chat_rejected': 'Message rejected (too long or contains contact info).',
'error.game_finished': 'This game is finished.', 'error.game_finished': 'This game is finished.',
'error.not_a_player': 'You are not a player in this game.', '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.wordIllegal': '«{word}» недопустимо',
'game.complain': 'Не согласен', 'game.complain': 'Не согласен',
'game.complaintSent': 'Спасибо, отправлено на проверку.', '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.placeholder': 'Короткое сообщение…',
'chat.send': 'Отправить', 'chat.send': 'Отправить',
@@ -97,6 +111,11 @@ export const ru: Record<MessageKey, string> = {
'settings.themeLight': 'Светлая', 'settings.themeLight': 'Светлая',
'settings.themeDark': 'Тёмная', 'settings.themeDark': 'Тёмная',
'settings.language': 'Язык интерфейса', 'settings.language': 'Язык интерфейса',
'settings.boardStyle': 'Стиль доски',
'settings.boardLabels': 'Подписи бонусов',
'settings.labelsBeginner': 'Новичок',
'settings.labelsClassic': 'Классика',
'settings.labelsNone': 'Без текста',
'settings.reduceMotion': 'Меньше анимаций', 'settings.reduceMotion': 'Меньше анимаций',
'about.title': 'О программе', 'about.title': 'О программе',
@@ -109,6 +128,7 @@ export const ru: Record<MessageKey, string> = {
'error.not_your_turn': 'Сейчас не ваш ход.', 'error.not_your_turn': 'Сейчас не ваш ход.',
'error.illegal_play': 'Это недопустимый ход.', 'error.illegal_play': 'Это недопустимый ход.',
'error.hint_unavailable': 'Подсказки недоступны.', 'error.hint_unavailable': 'Подсказки недоступны.',
'error.no_hint_available': 'Нет вариантов с вашим набором.',
'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).', 'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).',
'error.game_finished': 'Эта игра уже завершена.', 'error.game_finished': 'Эта игра уже завершена.',
'error.not_a_player': 'Вы не участник этой игры.', 'error.not_a_player': 'Вы не участник этой игры.',
+17
View File
@@ -4,6 +4,7 @@ import {
direction, direction,
newPlacement, newPlacement,
place, place,
placementFromHint,
rackView, rackView,
recallAt, recallAt,
reset, reset,
@@ -62,3 +63,19 @@ describe('placement state machine', () => {
expect(toSubmit(newPlacement(rack))).toBeNull(); expect(toSubmit(newPlacement(rack))).toBeNull();
}); });
}); });
describe('placementFromHint', () => {
it('maps hint letters and blanks onto rack slots', () => {
const p = placementFromHint(
[
{ row: 7, col: 7, letter: 'C', blank: false },
{ row: 7, col: 8, letter: 'A', blank: false },
{ row: 7, col: 9, letter: 'B', blank: true },
],
['C', 'A', BLANK, 'T'],
);
expect(p.pending).toHaveLength(3);
expect(p.pending[0]).toMatchObject({ rackIndex: 0, letter: 'C', blank: false });
expect(p.pending[2]).toMatchObject({ rackIndex: 2, letter: 'B', blank: true });
});
});
+20 -1
View File
@@ -4,7 +4,7 @@
// payload. It is board-agnostic (the gateway/engine does full legality validation at // payload. It is board-agnostic (the gateway/engine does full legality validation at
// submit), which keeps it trivially unit-testable. // submit), which keeps it trivially unit-testable.
import type { Direction } from './model'; import type { Direction, Tile } from './model';
import type { PlacedTile } from './client'; import type { PlacedTile } from './client';
export interface PendingTile { export interface PendingTile {
@@ -36,6 +36,25 @@ export function newPlacement(rack: string[]): Placement {
return { rack: [...rack], pending: [] }; return { rack: [...rack], pending: [] };
} }
/**
* placementFromHint turns a hint move's tiles into a pending placement by matching each
* tile to a rack slot (a blank "?" for blank tiles, else the matching letter), so the
* player sees the suggested move laid out and decides whether to commit it.
*/
export function placementFromHint(tiles: Tile[], rack: string[]): Placement {
const used = new Set<number>();
const pending: PendingTile[] = [];
const take = (pred: (letter: string, i: number) => boolean) => rack.findIndex((l, i) => !used.has(i) && pred(l, i));
for (const t of tiles) {
let idx = t.blank ? take((l) => l === BLANK) : take((l) => l === t.letter.toUpperCase());
if (idx < 0) idx = take((l) => l === BLANK); // fall back to a blank
if (idx < 0) continue;
used.add(idx);
pending.push({ rackIndex: idx, row: t.row, col: t.col, letter: t.letter.toUpperCase(), blank: rack[idx] === BLANK });
}
return { rack: [...rack], pending };
}
function usedIndexes(p: Placement): Set<number> { function usedIndexes(p: Placement): Set<number> {
return new Set(p.pending.map((t) => t.rackIndex)); return new Set(p.pending.map((t) => t.rackIndex));
} }
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { resultBadge } from './result';
import type { GameView, Seat } from './model';
const seat = (s: number, accountId: string, score: number, isWinner = false): Seat => ({
seat: s,
accountId,
displayName: accountId,
score,
hintsUsed: 0,
isWinner,
});
function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
return {
id: 'g',
variant: 'english',
dictVersion: 'v1',
status,
players: seats.length,
toMove,
turnTimeoutSecs: 0,
moveCount: 0,
endReason: '',
seats,
};
}
describe('resultBadge', () => {
it('active: your move vs opponent', () => {
const g = game([seat(0, 'me', 5), seat(1, 'a', 3)], 'active', 0);
expect(resultBadge(g, 'me')).toEqual({ key: 'result.yourMove', emoji: '🟢' });
expect(resultBadge({ ...g, toMove: 1 }, 'me').key).toBe('result.oppMove');
});
it('finished two-player: victory / defeat / draw', () => {
expect(resultBadge(game([seat(0, 'me', 300, true), seat(1, 'a', 200)]), 'me')).toEqual({
key: 'result.victory',
emoji: '🏆',
});
expect(resultBadge(game([seat(0, 'me', 200), seat(1, 'a', 300, true)]), 'me')).toEqual({
key: 'result.defeat',
emoji: '🥈',
});
expect(resultBadge(game([seat(0, 'me', 200), seat(1, 'a', 200)]), 'me')).toEqual({
key: 'result.draw',
emoji: '🏅',
});
});
it('finished four-player: places by score', () => {
const last = game([seat(0, 'me', 100), seat(1, 'a', 400, true), seat(2, 'b', 300), seat(3, 'c', 200)]);
expect(resultBadge(last, 'me')).toEqual({ key: 'result.place4', emoji: '🏅' });
const second = game([seat(0, 'me', 300), seat(1, 'a', 400, true), seat(2, 'b', 200), seat(3, 'c', 100)]);
expect(resultBadge(second, 'me')).toEqual({ key: 'result.place2', emoji: '🥈' });
});
});
+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 { Session } from './model';
import type { ThemePref } from './theme'; import type { ThemePref } from './theme';
import type { Locale } from './i18n/catalog'; import type { Locale } from './i18n/catalog';
import type { BoardLabelMode } from './boardlabels';
const DB_NAME = 'scrabble'; const DB_NAME = 'scrabble';
const STORE = 'kv'; const STORE = 'kv';
@@ -122,6 +123,7 @@ export interface Prefs {
theme: ThemePref; theme: ThemePref;
locale: Locale; locale: Locale;
reduceMotion: boolean; reduceMotion: boolean;
boardLabels: BoardLabelMode;
} }
export async function loadPrefs(): Promise<Partial<Prefs>> { export async function loadPrefs(): Promise<Partial<Prefs>> {
+8 -7
View File
@@ -1,16 +1,17 @@
<script lang="ts"> <script lang="ts">
import Header from '../components/Header.svelte'; import Screen from '../components/Screen.svelte';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
const version = '0.7.0'; const version = '0.7.0';
</script> </script>
<Header title={t('about.title')} back="/" /> <Screen title={t('about.title')} back="/">
<main class="page"> <div class="page">
<h2>{t('app.title')}</h2> <h2>{t('app.title')}</h2>
<p>{t('about.description')}</p> <p>{t('about.description')}</p>
<p class="muted">{t('about.version', { v: version })}</p> <p class="muted">{t('about.version', { v: version })}</p>
</main> </div>
</Screen>
<style> <style>
.page { .page {
+59 -143
View File
@@ -1,14 +1,16 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Header from '../components/Header.svelte'; import Screen from '../components/Screen.svelte';
import Menu from '../components/Menu.svelte';
import TabBar from '../components/TabBar.svelte';
import { app, handleError, showToast } from '../lib/app.svelte'; import { app, handleError, showToast } from '../lib/app.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { navigate } from '../lib/router.svelte'; import { navigate } from '../lib/router.svelte';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import { resultBadge } from '../lib/result';
import type { GameView } from '../lib/model'; import type { GameView } from '../lib/model';
let games = $state<GameView[]>([]); let games = $state<GameView[]>([]);
let menuOpen = $state(false);
async function load() { async function load() {
try { try {
@@ -19,8 +21,6 @@
} }
onMount(load); onMount(load);
// Refresh the lists when a live event lands (move / your-turn / match-found).
$effect(() => { $effect(() => {
if (app.lastEvent) void load(); if (app.lastEvent) void load();
}); });
@@ -29,95 +29,72 @@
const active = $derived(games.filter((g) => g.status === 'active')); const active = $derived(games.filter((g) => g.status === 'active'));
const finished = $derived(games.filter((g) => g.status !== 'active')); const finished = $derived(games.filter((g) => g.status !== 'active'));
function mySeat(g: GameView) {
return g.seats.find((s) => s.accountId === myId);
}
function opponents(g: GameView): string { function opponents(g: GameView): string {
return g.seats return g.seats
.filter((s) => s.accountId !== myId) .filter((s) => s.accountId !== myId)
.map((s) => s.displayName) .map((s) => s.displayName)
.join(', '); .join(', ');
} }
function subtitle(g: GameView): string {
const me = mySeat(g);
if (g.status === 'active') {
return g.toMove === me?.seat ? t('lobby.yourTurn') : t('lobby.theirTurn');
}
if (me?.isWinner) return t('game.won');
return g.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied');
}
function scoreline(g: GameView): string { function scoreline(g: GameView): string {
const me = mySeat(g); const me = g.seats.find((s) => s.accountId === myId);
const opp = g.seats.filter((s) => s.accountId !== myId).map((s) => s.score); const opp = g.seats.filter((s) => s.accountId !== myId).map((s) => s.score);
return `${me?.score ?? 0} : ${opp.join(', ')}`; return `${me?.score ?? 0} : ${opp.join(', ')}`;
} }
function go(path: string) { const menuItems = $derived([
menuOpen = false; { label: t('lobby.profile'), onclick: () => navigate('/profile') },
navigate(path); { label: t('lobby.settings'), onclick: () => navigate('/settings') },
} { label: t('lobby.about'), onclick: () => navigate('/about') },
]);
</script> </script>
<Header title={app.profile?.displayName ?? t('app.title')}> <Screen title={app.profile?.displayName ?? t('app.title')}>
{#snippet menu()} {#snippet menu()}
<button class="icon" onclick={() => (menuOpen = !menuOpen)} aria-label="Menu"></button> <Menu items={menuItems} />
{#if menuOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="backdrop" onclick={() => (menuOpen = false)}></div>
<div class="dropdown">
<button onclick={() => go('/profile')}>{t('lobby.profile')}</button>
<button onclick={() => go('/settings')}>{t('lobby.settings')}</button>
<button onclick={() => go('/about')}>{t('lobby.about')}</button>
</div>
{/if}
{/snippet} {/snippet}
</Header>
<main class="lobby"> <div class="lobby">
<section> {#each [{ h: 'lobby.activeGames', list: active }, { h: 'lobby.finishedGames', list: finished }] as group (group.h)}
<h2>{t('lobby.activeGames')}</h2> {#if group.list.length}
{#if active.length === 0} <section>
<h2>{t(group.h as 'lobby.activeGames')}</h2>
{#each group.list as g (g.id)}
{@const b = resultBadge(g, myId)}
<button class="row" onclick={() => navigate(`/game/${g.id}`)}>
<span class="info">
<span class="who">{opponents(g) || '—'}</span>
<span class="sub">{t(b.key)} · {scoreline(g)}</span>
</span>
<span class="emoji">{b.emoji}</span>
</button>
{/each}
</section>
{/if}
{/each}
{#if !active.length && !finished.length}
<p class="empty">{t('lobby.noActive')}</p> <p class="empty">{t('lobby.noActive')}</p>
{/if} {/if}
{#each active as g (g.id)} </div>
<button class="row" onclick={() => navigate(`/game/${g.id}`)}>
<span class="who">{opponents(g) || '—'}</span>
<span class="meta">
<span class="sub" class:turn={g.toMove === mySeat(g)?.seat}>{subtitle(g)}</span>
<span class="score">{scoreline(g)}</span>
</span>
</button>
{/each}
</section>
<section> {#snippet tabbar()}
<h2>{t('lobby.finishedGames')}</h2> <TabBar>
{#if finished.length === 0} <button class="tab" onclick={() => navigate('/new')}>
<p class="empty">{t('lobby.noFinished')}</p> <span class="sq">🎲</span><span class="lbl">{t('lobby.new')}</span>
{/if}
{#each finished as g (g.id)}
<button class="row" onclick={() => navigate(`/game/${g.id}`)}>
<span class="who">{opponents(g) || '—'}</span>
<span class="meta">
<span class="sub">{subtitle(g)}</span>
<span class="score">{scoreline(g)}</span>
</span>
</button> </button>
{/each} <button class="tab" onclick={() => showToast(t('lobby.soon'))}>
</section> <span class="sq">⚔️</span><span class="lbl">{t('lobby.tournaments')}</span>
</main> </button>
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>
<nav class="tabs"> <span class="sq">🧮</span><span class="lbl">{t('lobby.stats')}</span>
<button class="tab primary" onclick={() => navigate('/new')}>{t('lobby.new')}</button> </button>
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>{t('lobby.stats')}</button> </TabBar>
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>{t('lobby.tournaments')}</button> {/snippet}
</nav> </Screen>
<style> <style>
.lobby { .lobby {
padding: var(--pad); padding: var(--pad);
padding-bottom: 84px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 18px; gap: 18px;
@@ -147,88 +124,27 @@
background: var(--surface); background: var(--surface);
color: var(--text); color: var(--text);
border-radius: var(--radius); border-radius: var(--radius);
user-select: none;
}
.info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
} }
.who { .who {
font-weight: 600; font-weight: 600;
} white-space: nowrap;
.meta { overflow: hidden;
display: flex; text-overflow: ellipsis;
flex-direction: column;
align-items: flex-end;
gap: 2px;
} }
.sub { .sub {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-muted); color: var(--text-muted);
} }
.sub.turn { .emoji {
color: var(--accent); font-size: 1.8rem;
font-weight: 600;
}
.score {
font-variant-numeric: tabular-nums;
color: var(--text-muted);
font-size: 0.85rem;
}
.tabs {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 8px;
padding: 10px var(--pad);
background: var(--bg-elev);
border-top: 1px solid var(--border);
}
.tab {
flex: 1;
padding: 12px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
font-weight: 600;
}
.tab.primary {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
.icon {
background: none;
border: none;
color: var(--text);
font-size: 1.3rem;
line-height: 1; line-height: 1;
} flex: 0 0 auto;
.backdrop {
position: fixed;
inset: 0;
z-index: 8;
}
.dropdown {
position: absolute;
right: 8px;
top: 44px;
z-index: 9;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
min-width: 160px;
overflow: hidden;
}
.dropdown button {
padding: 11px 14px;
text-align: left;
background: none;
border: none;
color: var(--text);
}
.dropdown button:hover {
background: var(--surface-2);
} }
</style> </style>
+20 -20
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import Header from '../components/Header.svelte'; import Screen from '../components/Screen.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { handleError } from '../lib/app.svelte'; import { handleError } from '../lib/app.svelte';
import { navigate } from '../lib/router.svelte'; import { navigate } from '../lib/router.svelte';
@@ -31,8 +31,6 @@
navigate(`/game/${r.game.id}`); navigate(`/game/${r.game.id}`);
return; return;
} }
// The match also arrives via the live stream (handled in app), but poll as a
// fallback for a client that is not currently streaming.
poll = setInterval(async () => { poll = setInterval(async () => {
try { try {
const p = await gateway.lobbyPoll(); const p = await gateway.lobbyPoll();
@@ -53,23 +51,24 @@
onDestroy(stop); onDestroy(stop);
</script> </script>
<Header title={t('new.title')} back="/" /> <Screen title={t('new.title')} back="/">
<main class="page"> <div class="page">
{#if searching} {#if searching}
<div class="searching"> <div class="searching">
<div class="spinner"></div> <div class="spinner"></div>
<p>{t('new.searching')}</p> <p>{t('new.searching')}</p>
<button class="cancel" onclick={() => { stop(); navigate('/'); }}>{t('common.cancel')}</button> <button class="cancel" onclick={() => { stop(); navigate('/'); }}>{t('common.cancel')}</button>
</div> </div>
{:else} {:else}
<p class="subtitle">{t('new.subtitle')}</p> <p class="subtitle">{t('new.subtitle')}</p>
<div class="variants"> <div class="variants">
{#each variants as v (v.id)} {#each variants as v (v.id)}
<button class="variant" onclick={() => find(v.id)}>{t(v.label)}</button> <button class="variant" onclick={() => find(v.id)}>{t(v.label)}</button>
{/each} {/each}
</div> </div>
{/if} {/if}
</main> </div>
</Screen>
<style> <style>
.page { .page {
@@ -92,6 +91,7 @@
border-radius: var(--radius); border-radius: var(--radius);
font-size: 1.05rem; font-size: 1.05rem;
font-weight: 600; font-weight: 600;
user-select: none;
} }
.searching { .searching {
display: grid; display: grid;
+19 -18
View File
@@ -1,26 +1,27 @@
<script lang="ts"> <script lang="ts">
import Header from '../components/Header.svelte'; import Screen from '../components/Screen.svelte';
import { app, logout } from '../lib/app.svelte'; import { app, logout } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
</script> </script>
<Header title={t('profile.title')} back="/" /> <Screen title={t('profile.title')} back="/">
<main class="page"> <div class="page">
{#if app.profile} {#if app.profile}
<div class="name">{app.profile.displayName}</div> <div class="name">{app.profile.displayName}</div>
{#if app.profile.isGuest}<span class="badge">{t('profile.guest')}</span>{/if} {#if app.profile.isGuest}<span class="badge">{t('profile.guest')}</span>{/if}
<dl> <dl>
<dt>{t('profile.language')}</dt> <dt>{t('profile.language')}</dt>
<dd>{app.profile.preferredLanguage}</dd> <dd>{app.profile.preferredLanguage}</dd>
<dt>{t('profile.timezone')}</dt> <dt>{t('profile.timezone')}</dt>
<dd>{app.profile.timeZone}</dd> <dd>{app.profile.timeZone}</dd>
<dt>{t('profile.hintBalance')}</dt> <dt>{t('profile.hintBalance')}</dt>
<dd>{app.profile.hintBalance}</dd> <dd>{app.profile.hintBalance}</dd>
</dl> </dl>
<p class="muted">{t('profile.readonly')}</p> <p class="muted">{t('profile.readonly')}</p>
<button class="logout" onclick={() => logout()}>{t('login.title')} / logout</button> <button class="logout" onclick={() => logout()}>{t('login.title')} / logout</button>
{/if} {/if}
</main> </div>
</Screen>
<style> <style>
.page { .page {
+67 -35
View File
@@ -1,8 +1,15 @@
<script lang="ts"> <script lang="ts">
import Header from '../components/Header.svelte'; import Screen from '../components/Screen.svelte';
import { app, setLocalePref, setReduceMotion, setTheme } from '../lib/app.svelte'; import {
app,
setBoardLabels,
setLocalePref,
setReduceMotion,
setTheme,
} from '../lib/app.svelte';
import { t, type Locale, type MessageKey } from '../lib/i18n/index.svelte'; import { t, type Locale, type MessageKey } from '../lib/i18n/index.svelte';
import type { ThemePref } from '../lib/theme'; import type { ThemePref } from '../lib/theme';
import type { BoardLabelMode } from '../lib/boardlabels';
const themes: ThemePref[] = ['auto', 'light', 'dark']; const themes: ThemePref[] = ['auto', 'light', 'dark'];
const themeLabel: Record<ThemePref, MessageKey> = { const themeLabel: Record<ThemePref, MessageKey> = {
@@ -11,43 +18,62 @@
dark: 'settings.themeDark', dark: 'settings.themeDark',
}; };
const locales: Locale[] = ['en', 'ru']; const locales: Locale[] = ['en', 'ru'];
const labelModes: BoardLabelMode[] = ['beginner', 'classic', 'none'];
const labelModeKey: Record<BoardLabelMode, MessageKey> = {
beginner: 'settings.labelsBeginner',
classic: 'settings.labelsClassic',
none: 'settings.labelsNone',
};
</script> </script>
<Header title={t('settings.title')} back="/" /> <Screen title={t('settings.title')} back="/">
<main class="page"> <div class="page">
<section> <section>
<h3>{t('settings.theme')}</h3> <h3>{t('settings.theme')}</h3>
<div class="seg"> <div class="seg">
{#each themes as th (th)} {#each themes as th (th)}
<button class="opt" class:active={app.theme === th} onclick={() => setTheme(th)}> <button class="opt" class:active={app.theme === th} onclick={() => setTheme(th)}>
{t(themeLabel[th])} {t(themeLabel[th])}
</button> </button>
{/each} {/each}
</div> </div>
</section> </section>
<section> <section>
<h3>{t('settings.language')}</h3> <h3>{t('settings.language')}</h3>
<div class="seg"> <div class="seg">
{#each locales as lc (lc)} {#each locales as lc (lc)}
<button class="opt" class:active={app.locale === lc} onclick={() => setLocalePref(lc)}> <button class="opt" class:active={app.locale === lc} onclick={() => setLocalePref(lc)}>
{t(lc === 'en' ? 'lang.en' : 'lang.ru')} {t(lc === 'en' ? 'lang.en' : 'lang.ru')}
</button> </button>
{/each} {/each}
</div> </div>
</section> </section>
<section> <section>
<label class="row"> <h3>{t('settings.boardStyle')}</h3>
<span>{t('settings.reduceMotion')}</span> <div class="sub">{t('settings.boardLabels')}</div>
<input <div class="seg">
type="checkbox" {#each labelModes as lm (lm)}
checked={app.reduceMotion} <button class="opt" class:active={app.boardLabels === lm} onclick={() => setBoardLabels(lm)}>
onchange={(e) => setReduceMotion(e.currentTarget.checked)} {t(labelModeKey[lm])}
/> </button>
</label> {/each}
</section> </div>
</main> </section>
<section>
<label class="row">
<span>{t('settings.reduceMotion')}</span>
<input
type="checkbox"
checked={app.reduceMotion}
onchange={(e) => setReduceMotion(e.currentTarget.checked)}
/>
</label>
</section>
</div>
</Screen>
<style> <style>
.page { .page {
@@ -61,6 +87,11 @@
font-size: 0.95rem; font-size: 0.95rem;
color: var(--text-muted); color: var(--text-muted);
} }
.sub {
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 6px;
}
.seg { .seg {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -72,6 +103,7 @@
background: var(--surface); background: var(--surface);
color: var(--text); color: var(--text);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
user-select: none;
} }
.opt.active { .opt.active {
background: var(--accent); background: var(--accent);