Stage 7 polish: UI/UX refinements (shell, board zoom, hint-on-board, history, ...) #8

Merged
developer merged 11 commits from feature/stage-7-ui-polish into master 2026-06-03 15:39:41 +00:00
44 changed files with 1978 additions and 607 deletions
+11 -8
View File
@@ -1,8 +1,8 @@
name: Tests · UI
# Hermetic UI checks: type-check, Vitest unit tests, production build with a
# bundle-size budget, and a Playwright smoke against the in-memory mock transport
# (no backend/gateway/Postgres). The committed src/gen/ codegen is built, not
# bundle-size budget, and a Playwright smoke (Chromium + WebKit) against the in-memory
# mock transport (no backend/gateway/Postgres). The committed src/gen/ codegen is built, not
# regenerated (the same model as the Go committed jet/fbs output).
on:
@@ -51,12 +51,15 @@ jobs:
# The Playwright system libraries are provisioned once on the runner host
# (`sudo npx playwright@<version> install-deps chromium`), so the job needs no
# apt and no sudo: it only downloads the browser binary into the runner cache
# (persisted by the host executor) and runs the smoke. The timeouts guard
# against a future hang. Keep this in lockstep with @playwright/test in
# package.json — re-run install-deps on the host after a major bump.
- name: Install Playwright browser
run: pnpm exec playwright install chromium
# apt and no sudo: it only downloads the browser binaries into the runner cache
# (persisted by the host executor) and runs the suite. WebKit's Debian build
# bundles most of its own libraries and runs headless without extra host deps; if
# a runner ever lacks one, provision it once on the host with
# `sudo npx playwright install-deps webkit`. The timeouts guard against a future
# hang. Keep this in lockstep with @playwright/test in package.json — re-run
# install-deps on the host after a major bump.
- name: Install Playwright browsers
run: pnpm exec playwright install chromium webkit
timeout-minutes: 5
- name: E2E smoke (mock)
+3
View File
@@ -10,6 +10,9 @@
.idea/
.DS_Store
# Playwright MCP scratch output (snapshots / screenshots written during inspection)
.playwright-mcp/
# Local, unstaged env overrides
**/.env.local
**/.env.*.local
+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)
mirror) — per-domain user stories. English authoritative.
- [`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
+20 -1
View File
@@ -40,7 +40,7 @@ independent (see ARCHITECTURE §9.1).
| 4 | Lobby & social (matchmaking, friends, block, chat, profile, nudge) | **done** |
| 5 | Robot opponent | **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 |
| 9 | Telegram integration (bot side-service, deep-link, push) | todo |
| 10 | Admin & dictionary ops (complaint review, version reload) | todo |
@@ -525,6 +525,18 @@ Open details: deployment target/host; dashboards; load expectations.
**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`
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 width-based zoom in a fixed viewport (real native
scroll, double-tap; pinch/swipe dropped) with constant `cqw` labels, corner-letter
tiles, contrasting grid lines, last-word dark-tile highlight, 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)
@@ -551,3 +563,10 @@ Open details: deployment target/host; dashboards; load expectations.
row behind. Add a periodic reaper (or a finished-and-idle sweep) that deletes
guest accounts with no active games once their last session is gone; the
`ON DELETE CASCADE` foreign keys clean up the dependent rows.
- **TODO-4 — put the per-game alphabet on the wire (owner's idea, Stage 7).** Today the
client hardcodes each variant's letters/values (ported into `ui/src/lib/premiums.ts`
from `scrabble-solver/rules/rules.go`) and the edge exchanges plays/hints by concrete
letters. Consider extending `game.state` to carry the variant's `(letter, index,
value)` table so the UI stops duplicating it, and optionally moving tile exchange to
letter **indices** end-to-end. Caveat (as for the dictionaries, TODO-2): the wire table
must stay pinned to the same `rules.Alphabet` the engine uses, or indices drift.
+2
View File
@@ -51,6 +51,8 @@ func TestStatusForError(t *testing.T) {
"code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"},
"session gone": {session.ErrNotFound, http.StatusUnauthorized, "session_invalid"},
"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"},
}
for name, tc := range cases {
+5 -1
View File
@@ -121,7 +121,11 @@ func statusForError(err error) (int, string) {
return http.StatusConflict, "already_queued"
case errors.Is(err, game.ErrInvalidConfig):
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"
case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver):
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
in-memory mock transport (`pnpm start`) runs the whole slice with no backend.
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
first): deep-link invites and platform-native push notifications. They talk
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
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
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
dictionary; each result offers a **complaint** (complainant, game, variant,
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
(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
- 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
read-only profile. Managing friends and blocks, creating friend games (invitations),
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)*
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) и темы и
профиль только для чтения. Управление друзьями и блоками, создание дружеских игр
(приглашения), редактирование профиля, экран статистики и просмотр истории/GCG
появятся в Stage 8.
появятся в Stage 8. В настройках также выбирается стиль подписей бонус-клеток
(новичок / классика / без текста). Подсказка **выставляет предложенные фишки на
доску** — игрок сам решает сделать ход, и подсказка не тратится, если ходов нет.
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
и ограничивает частоту повторов.
### Личность и сессии *(Stage 1 / 6)*
Игрок приходит с платформы (сначала Telegram), через email-вход или как
+84
View File
@@ -0,0 +1,84 @@
# 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 full-height flex column: the nav bar, the announcement strip, the content, and an
optional bottom tab bar (the tab bar always sits at the screen bottom). On most screens
the nav is minimal and the **content fills** between nav and tab bar. **Only in the
game** (`growNav`) does the nav bar grow to absorb spare height (buttons top-aligned),
pinning the board and controls to the **bottom** for thumb reach. 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) by **growing
the board's width** inside a fixed-size viewport (a real layout change → native scroll
that works consistently across browsers; no `transform`, which broke scrolling
differently in Safari/Chrome). Labels are sized in `cqw` against the fixed viewport, so
they stay a constant size as the cells grow (relatively smaller at higher zoom).
**Double-tap** toggles zoom and, on touch, placing a tile auto-zooms in centred on the
target; the custom pinch and swipe-to-open-history gestures were dropped because they
fight native scroll — history opens from the menu.
- **Highlights**: pending tiles use a slightly darker tile background (no outline). The
last completed word gets a dark tile background — static while it is the opponent's
turn (our word), and a 1 s flash when it is our turn (their word). While placing, only
the pending tiles are highlighted.
- **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).
+86
View File
@@ -0,0 +1,86 @@
import { expect, test, type Page } from '@playwright/test';
// Behaviour/display coverage for the polished game screen, driven entirely by the mock
// transport (no backend). These lock the round-1..4 interactions so future UI edits
// surface as a failing assertion — to be re-agreed or fixed. The pure logic behind them
// (placement, check-word, board labels, result badges) is unit-tested separately.
async function openGame(page: Page): Promise<void> {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /Ann/ }).click(); // the seeded active game vs Ann
await expect(page.locator('[data-cell]').first()).toBeVisible();
}
test('placing a tile and confirming via 🏁 commits the move', async ({ page }) => {
await openGame(page);
await page.locator('.rack .tile').first().click();
await page.locator('[data-cell]:not(.filled)').nth(30).click();
await expect(page.locator('[data-cell].pending')).toHaveCount(1);
await page.locator('.make').click(); // open the MakeMove popover (short tap)
await page.locator('.pop.go').click(); // "Make move ✅"
// After the commit the placement is cleared: no pending tile, no 🏁 control.
await expect(page.locator('[data-cell].pending')).toHaveCount(0);
await expect(page.locator('.make')).toBeHidden();
});
test('history slides the board down and closes on a board tap', async ({ page }) => {
await openGame(page);
await page.locator('.burger').click();
await page.locator('.dropdown button').nth(0).click(); // History
await expect(page.locator('.history')).toBeVisible();
await expect(page.locator('.boardwrap.slid')).toBeVisible();
await page.locator('.boardwrap').click(); // tapping the board closes it
await expect(page.locator('.history')).toBeHidden();
});
test('Draw opens the exchange dialog and confirms a selection', async ({ page }) => {
await openGame(page);
await page.locator('button:has-text("🔄")').click(); // Draw tab
await expect(page.locator('.exch')).toBeVisible();
await page.locator('.etile').first().click();
await expect(page.locator('.etile.sel')).toHaveCount(1);
await page.locator('button.confirm').click();
await expect(page.locator('.exch')).toBeHidden();
});
test('check-word sanitises input and shows a verdict', async ({ page }) => {
await openGame(page);
await page.locator('.burger').click();
await page.locator('.dropdown button').nth(2).click(); // Check word
const input = page.locator('.check input');
await input.fill('qz9!a'); // digits/punctuation dropped, letters upper-cased
await expect(input).toHaveValue('QZA');
await page.locator('.check button').click(); // Check (enabled: length 3)
await expect(page.locator('.ok, .bad')).toBeVisible();
});
test('dropping the game ends it and shows the result', async ({ page }) => {
await openGame(page);
await page.locator('.burger').click();
await page.locator('.dropdown button').nth(3).click(); // Drop game
await page.locator('button.danger').click(); // confirm in the modal
await expect(page.locator('.status .over')).toBeVisible();
});
test('the board-label mode in Settings changes the on-board labels', async ({ page }) => {
await openGame(page);
// beginner (default) renders split "3× / word" labels.
await expect(page.locator('.bsplit').first()).toBeVisible();
await expect(page.locator('.b1')).toHaveCount(0);
// Switch to "classic" in Settings (in-SPA hash nav keeps the guest session).
await page.evaluate(() => (location.hash = '/settings'));
await page.locator('.seg').nth(2).locator('.opt').nth(1).click(); // board labels -> classic
await page.evaluate(() => (location.hash = '/game/g1'));
// classic renders single "3W"/"2L" labels and no split labels.
await expect(page.locator('.b1').first()).toBeVisible();
await expect(page.locator('.bsplit')).toHaveCount(0);
});
+4 -3
View File
@@ -22,8 +22,9 @@ test('guest reaches a board and previews a placement', async ({ page }) => {
await rackTile.click();
await page.locator('[data-cell]:not(.filled)').nth(30).click();
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.
await expect(page.getByRole('button', { name: /make move/i })).toBeVisible();
// The contextual MakeMove control (🏁) appears once a tile is pending.
await expect(page.locator('.make')).toBeVisible();
});
+26
View File
@@ -0,0 +1,26 @@
import { expect, test } from '@playwright/test';
// Item 5: zooming the board must enlarge the labels too (a magnifying-glass zoom).
// cqw is sized against the zoom-scaled board, so the font grows with the cells.
test('zoom enlarges the board labels with the board', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /Ann/ }).click();
const letter = page.locator('[data-cell] .letter').first();
await expect(letter).toBeVisible();
const base = await letter.evaluate((el) => parseFloat(getComputedStyle(el).fontSize));
// Double-tap an empty cell to zoom in (two synchronous clicks = a double-tap).
await page
.locator('[data-cell]:not(.filled)')
.nth(20)
.evaluate((el: HTMLElement) => {
el.click();
el.click();
});
await page.waitForTimeout(400); // let the width transition settle
const zoomed = await letter.evaluate((el) => parseFloat(getComputedStyle(el).fontSize));
expect(zoomed).toBeGreaterThan(base * 1.4);
});
+8 -1
View File
@@ -18,5 +18,12 @@ export default defineConfig({
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
// Run the same hermetic specs in Chromium and WebKit (Safari's engine) so the UI is
// exercised in both rendering/JS engines. Note: desktop WebKit on Linux does not
// reproduce iOS Safari's text auto-inflation, so the `text-size-adjust` guard in
// app.css is not regression-covered here — but engine-level CSS/JS differences are.
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});
+20 -6
View File
@@ -23,12 +23,12 @@
/* board + tiles (all drawn with CSS primitives) */
--board-bg: #cdd6cf;
--cell-bg: #e7ece8;
--cell-line: #b6c0b8;
--cell-line: #7f8d83;
--tile-bg: #f4e2b8;
--tile-edge: #d8c190;
--tile-text: #2a2113;
--tile-pending: #ffe7a3;
--tile-recent: #fff6d8;
--tile-pending: #f2cf73;
--tile-recent: #c8a85c;
--prem-tw: #e06a5b; /* triple word */
--prem-dw: #efa6a0; /* double word + centre */
--prem-tl: #4f8fd6; /* triple letter */
@@ -62,12 +62,12 @@
--board-bg: #2a3330;
--cell-bg: #222a27;
--cell-line: #38433d;
--cell-line: #56655c;
--tile-bg: #d9c79a;
--tile-edge: #b6a473;
--tile-text: #20190d;
--tile-pending: #f0d98f;
--tile-recent: #4a4636;
--tile-pending: #d8b75e;
--tile-recent: #7a6638;
--prem-tw: #b1493d;
--prem-dw: #8c5450;
--prem-tl: #34608f;
@@ -123,6 +123,9 @@ body {
line-height: 1.4;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
/* Stop iOS/Safari from auto-inflating text (e.g. the long marquee message). */
text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
/* never let the page scroll/zoom out from under the board */
overscroll-behavior: none;
touch-action: manipulation;
@@ -130,12 +133,23 @@ body {
#app {
height: 100%;
/* No text selection anywhere by default; inputs opt back in below. */
user-select: none;
-webkit-user-select: none;
}
input,
textarea {
user-select: text;
-webkit-user-select: text;
}
button {
font: inherit;
color: inherit;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
}
.reduce-motion * {
+80
View File
@@ -0,0 +1,80 @@
<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 0;
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;
/* The side inset lives on the track (not the clipping .ad) so the scroll distance
(scrollWidth - viewport.clientWidth) reaches the very end of a long message. */
padding: 0 var(--pad);
will-change: transform;
}
.track :global(a) {
color: var(--accent);
text-decoration: underline;
}
</style>
+48 -21
View File
@@ -2,30 +2,45 @@
import type { Snippet } from 'svelte';
import { navigate } from '../lib/router.svelte';
let { title, back, menu }: { title: string; back?: string; menu?: Snippet } = $props();
let { title, back, menu, grow = false }: { title: string; back?: string; menu?: Snippet; grow?: boolean } =
$props();
</script>
<header class="topbar">
{#if back}
<button class="icon" onclick={() => back && navigate(back)} aria-label="Back"></button>
{:else}
<span class="spacer"></span>
{/if}
<h1>{title}</h1>
<div class="end">{#if menu}{@render menu()}{/if}</div>
<header class="nav" class:grow>
<div class="bar">
{#if back}
<button class="icon back" onclick={() => back && navigate(back)} aria-label="Back">
<span class="chev"></span>
</button>
{:else}
<span class="spacer"></span>
{/if}
<h1>{title}</h1>
<div class="end">{#if menu}{@render menu()}{/if}</div>
</div>
</header>
<style>
.topbar {
/* By default the nav bar is minimal and the content fills the screen. In the game
it grows (class `grow`) to push the board and controls to the bottom. */
.nav {
flex: 0 0 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;
}
.nav.grow {
flex: 1 1 auto;
}
.bar {
display: flex;
align-items: center;
gap: var(--gap);
padding: 10px var(--pad);
background: var(--bg-elev);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 5;
}
h1 {
font-size: 1.05rem;
@@ -33,28 +48,40 @@
flex: 1;
text-align: center;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.icon,
.spacer,
.end {
width: 40px;
height: 32px;
min-width: 40px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.end {
width: auto;
min-width: 40px;
justify-content: flex-end;
}
.icon {
.back {
background: none;
border: none;
font-size: 1.1rem;
color: var(--text);
border-radius: var(--radius-sm);
padding: 0 8px;
}
.icon:hover {
.back:hover {
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>
+108
View File
@@ -0,0 +1,108 @@
<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);
right: 0;
z-index: 19;
display: flex;
flex-direction: column;
gap: 2px;
white-space: nowrap;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
padding: 4px;
min-width: 132px;
}
</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>
+56
View File
@@ -0,0 +1,56 @@
<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,
growNav = false,
}: {
title: string;
back?: string;
menu?: Snippet;
tabbar?: Snippet;
children?: Snippet;
scroll?: boolean;
growNav?: boolean;
} = $props();
</script>
<div class="screen">
<Header {title} {back} {menu} grow={growNav} />
<AdBanner />
<main class="content" class:scroll class:fill={!growNav}>{@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.fill {
flex: 1 1 auto;
}
.content.scroll {
overflow-y: auto;
}
.tabbar {
flex: 0 0 auto;
}
</style>
+64
View File
@@ -0,0 +1,64 @@
<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: 0;
background: none;
border: none;
padding: 1px 0;
color: var(--text);
width: 100%;
user-select: none;
-webkit-user-select: none;
}
:global(.tab:disabled) {
opacity: 0.4;
}
/* The icon square hugs the emoji (just a little padding) so it is the press-highlight
target and the badge can sit on its corner. */
:global(.tab .sq) {
position: relative;
display: inline-grid;
place-items: center;
padding: 3px 10px;
border-radius: 12px;
font-size: 1.75rem;
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>
+143 -112
View File
@@ -3,39 +3,64 @@
import type { Premium } from '../lib/premiums';
import { tileValue } from '../lib/premiums';
import type { Variant } from '../lib/model';
import { bonusLabel, type BoardLabelMode } from '../lib/boardlabels';
import type { Locale } from '../lib/i18n/catalog';
let {
board,
premium,
pending,
recent,
highlight,
flash,
centre,
zoomed,
variant,
labelMode,
locale,
focus,
oncell,
ontogglezoom,
}: {
board: (BoardCell | null)[][];
premium: Premium[][];
pending: Map<string, { letter: string; blank: boolean }>;
recent: Set<string>;
highlight: Set<string>;
flash: boolean;
centre: { row: number; col: number };
zoomed: boolean;
variant: Variant;
labelMode: BoardLabelMode;
locale: Locale;
focus: { row: number; col: number } | null;
oncell: (row: number, col: number) => void;
ontogglezoom: () => void;
} = $props();
const premClass: Record<Premium, string> = {
'': '',
TW: 'tw',
DW: 'dw',
TL: 'tl',
DL: 'dl',
};
const premLabel: Record<Premium, string> = { '': '', TW: '3W', DW: '2W', TL: '3L', DL: '2L' };
const Z = 1.85;
const z = $derived(zoomed ? Z : 1);
const premClass: Record<Premium, string> = { '': '', TW: 'tw', DW: 'dw', TL: 'tl', DL: 'dl' };
// Double-tap toggles zoom.
let viewport = $state<HTMLElement>();
// Genuine layout zoom (the board grows; cqw labels stay constant), so native scroll
// works in every browser. Keep the focus cell centred on every frame of the zoom-in
// (the board widens over ~0.25s) so it magnifies *into* that cell, rather than growing
// from the top-left corner and then jumping to centre once the transition ends.
$effect(() => {
const vp = viewport;
if (!vp || !zoomed || !focus) return;
const f = focus;
const start = performance.now();
let raf = requestAnimationFrame(function tick(now) {
const cell = vp.scrollWidth / 15; // grows frame by frame as the board widens
vp.scrollLeft = (f.col + 0.5) * cell - vp.clientWidth / 2;
vp.scrollTop = (f.row + 0.5) * cell - vp.clientHeight / 2;
if (now - start < 300) raf = requestAnimationFrame(tick);
});
return () => cancelAnimationFrame(raf);
});
// Double-tap toggles zoom (pinch was dropped — it conflicts with native scroll).
let lastTap = 0;
function onTap(row: number, col: number) {
const now = Date.now();
@@ -48,116 +73,81 @@
oncell(row, col);
}
// Minimal pinch: two pointers spreading apart zoom in, pinching together zoom out.
const pts = new Map<number, { x: number; y: number }>();
let startDist = 0;
function dist(): number {
const p = [...pts.values()];
if (p.length < 2) return 0;
return Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y);
}
function onPointerDown(e: PointerEvent) {
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pts.size === 2) startDist = dist();
}
function onPointerMove(e: PointerEvent) {
if (!pts.has(e.pointerId)) return;
pts.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pts.size === 2 && startDist > 0) {
const d = dist();
if (!zoomed && d > startDist * 1.25) {
ontogglezoom();
startDist = 0;
} else if (zoomed && d < startDist * 0.8) {
ontogglezoom();
startDist = 0;
}
}
}
function onPointerUp(e: PointerEvent) {
pts.delete(e.pointerId);
if (pts.size < 2) startDist = 0;
}
function key(r: number, c: number): string {
return `${r},${c}`;
}
const key = (r: number, c: number) => `${r},${c}`;
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="viewport"
class:zoomed
onpointerdown={onPointerDown}
onpointermove={onPointerMove}
onpointerup={onPointerUp}
onpointercancel={onPointerUp}
>
<div class="grid" class:zoomed>
{#each board as rowCells, r (r)}
{#each rowCells as cell, c (c)}
{@const p = pending.get(key(r, c))}
{@const letter = cell?.letter ?? p?.letter ?? ''}
{@const blank = cell?.blank ?? p?.blank ?? false}
<button
class="cell {premClass[premium[r][c]]}"
class:filled={!!cell}
class:pending={!!p && !cell}
class:recent={recent.has(key(r, c))}
data-cell
data-row={r}
data-col={c}
onclick={() => onTap(r, c)}
>
{#if letter}
<span class="letter">{letter}</span>
{#if !blank}<span class="val">{tileValue(variant, letter)}</span>{/if}
{:else if r === centre.row && c === centre.col}
<span class="star"></span>
{:else if premLabel[premium[r][c]]}
<span class="plabel">{premLabel[premium[r][c]]}</span>
{/if}
</button>
<div class="viewport" class:zoomed bind:this={viewport}>
<div class="scaler" style="--z: {z};">
<div class="grid">
{#each board as rowCells, r (r)}
{#each rowCells as cell, c (c)}
{@const p = pending.get(key(r, c))}
{@const letter = cell?.letter ?? p?.letter ?? ''}
{@const blank = cell?.blank ?? p?.blank ?? false}
{@const bl = letter ? null : bonusLabel(labelMode, premium[r][c], locale)}
<button
class="cell {premClass[premium[r][c]]}"
class:filled={!!cell}
class:pending={!!p && !cell}
class:hl={!!cell && highlight.has(key(r, c)) && !flash}
class:flash={!!cell && flash && highlight.has(key(r, c))}
data-cell
data-row={r}
data-col={c}
onclick={() => onTap(r, c)}
>
{#if letter}
<span class="letter">{letter}</span>
{#if !blank}<span class="val">{tileValue(variant, letter)}</span>{/if}
{:else if r === centre.row && c === centre.col}
<span class="star"></span>
{: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}
</button>
{/each}
{/each}
{/each}
</div>
</div>
</div>
<style>
.viewport {
width: 100%;
aspect-ratio: 1;
overflow: hidden;
background: var(--board-bg);
padding: 4px;
border-radius: var(--radius-sm);
touch-action: none;
}
.viewport.zoomed {
overflow: auto;
max-height: 70vh;
}
/* The query container is the (zoom-scaled) board, so cqw labels scale WITH the board
— a magnifying-glass zoom. */
.scaler {
width: calc(100% * var(--z));
transition: width 0.25s ease;
container-type: inline-size;
}
.grid {
display: grid;
grid-template-columns: repeat(15, 1fr);
gap: 2px;
width: 100%;
}
.grid.zoomed {
grid-template-columns: repeat(15, 2.6rem);
width: max-content;
gap: 1px;
background: var(--cell-line);
padding: 1px;
}
.cell {
position: relative;
aspect-ratio: 1;
border: none;
border-radius: 2px;
border-radius: 1px;
background: var(--cell-bg);
color: var(--prem-text);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.62rem;
padding: 0;
overflow: hidden;
font-size: 0;
}
.cell.tw {
background: var(--prem-tw);
@@ -179,35 +169,76 @@
}
.cell.pending {
background: var(--tile-pending);
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.cell.recent {
box-shadow:
inset 0 -2px 0 var(--tile-edge),
0 0 0 2px var(--warn);
.cell.hl {
background: var(--tile-recent);
}
.cell.flash {
/* Two flashes to draw the eye, then settle back to normal so it does not distract. */
animation: tileflash 1s ease-in-out 2;
}
@keyframes tileflash {
0%,
100% {
background: var(--tile-bg);
}
50% {
background: var(--tile-recent);
}
}
/* cqw fonts are sized against the fixed viewport, so labels stay a constant size as
the board grows on zoom (relatively smaller, never overflowing). */
.letter {
font-size: 1.05em;
position: absolute;
top: 5%;
left: 8%;
font-size: 4.2cqw;
font-weight: 700;
line-height: 1;
}
.grid:not(.zoomed) .letter {
font-size: 2.6vw;
}
.val {
position: absolute;
right: 1px;
bottom: 0;
font-size: 0.55em;
font-weight: 600;
}
.plabel {
opacity: 0.85;
right: 5%;
bottom: 3%;
font-size: 2.4cqw;
font-weight: 600;
}
.star {
font-size: 1.1em;
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 3.6cqw;
opacity: 0.7;
}
.b1 {
position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 2.7cqw;
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.05;
opacity: 0.92;
overflow: hidden;
padding: 0 1px;
}
.bt {
font-size: 1.7cqw;
font-weight: 600;
}
.bb {
font-size: 1.9cqw;
font-weight: 700;
white-space: nowrap;
}
</style>
+284 -201
View File
@@ -1,23 +1,26 @@
<script lang="ts">
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 Board from './Board.svelte';
import Rack from './Rack.svelte';
import MakeMove from './MakeMove.svelte';
import Controls from './Controls.svelte';
import Chat from './Chat.svelte';
import { gateway } from '../lib/gateway';
import { app, handleError, showToast } from '../lib/app.svelte';
import { GatewayError } from '../lib/client';
import { t } from '../lib/i18n/index.svelte';
import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model';
import { lastPlayTiles, replay } from '../lib/board';
import { replay } from '../lib/board';
import { alphabet, centre, premiumGrid } from '../lib/premiums';
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
import {
BLANK,
direction,
newPlacement,
place,
placementFromHint,
rackView,
recallAt,
reset,
@@ -35,8 +38,9 @@
let busy = $state(false);
let zoomed = $state(false);
let selected = $state<number | null>(null);
let panel = $state<'none' | 'chat' | 'history'>('none');
let menuOpen = $state(false);
let focus = $state<{ row: number; col: number } | null>(null);
let panel = $state<'none' | 'chat'>('none');
let historyOpen = $state(false);
let blankPrompt = $state<{ rackIndex: number; row: number; col: number } | null>(null);
let exchangeOpen = $state(false);
let exchangeSel = $state<number[]>([]);
@@ -47,6 +51,9 @@
let messages = $state<ChatMessage[]>([]);
let drag = $state<{ letter: string; blank: boolean; x: number; y: number } | null>(null);
const checkedWords = new Map<string, boolean>();
let cooling = $state(false);
const variant = $derived(view?.game.variant ?? 'english');
const board = $derived(replay(moves));
const premium = $derived(premiumGrid(variant));
@@ -54,14 +61,25 @@
const pendingMap = $derived(
new Map(placement.pending.map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }])),
);
const recent = $derived(new Set(lastPlayTiles(moves).map((tt) => `${tt.row},${tt.col}`)));
const slots = $derived(rackView(placement));
const isMyTurn = $derived(
!!view && view.game.status === 'active' && view.game.toMove === view.seat,
const lastPlay = $derived([...moves].reverse().find((m) => m.action === 'play') ?? null);
// Highlight the last word with a dark tile bg; while placing, only the pending tiles
// are highlighted. It flashes when the opponent just moved and it is now our turn.
const highlight = $derived(
placement.pending.length > 0 || !lastPlay
? new Set<string>()
: new Set(lastPlay.tiles.map((tt) => `${tt.row},${tt.col}`)),
);
const flash = $derived(
!!lastPlay &&
!!view &&
view.game.status === 'active' &&
lastPlay.player !== view.seat &&
view.game.toMove === view.seat,
);
const slots = $derived(rackView(placement));
const isMyTurn = $derived(!!view && view.game.status === 'active' && view.game.toMove === view.seat);
const gameOver = $derived(!!view && view.game.status !== 'active');
const dir = $derived(dirOverride ?? direction(placement) ?? 'H');
const ambiguous = $derived(placement.pending.length === 1);
const bagEmpty = $derived((view?.bagLen ?? 0) === 0);
async function load() {
try {
@@ -76,7 +94,6 @@
handleError(e);
}
}
async function loadChat() {
try {
messages = await gateway.chatList(id);
@@ -84,7 +101,6 @@
handleError(e);
}
}
onMount(load);
$effect(() => {
@@ -99,7 +115,7 @@
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 dragMoved = false;
let swallowClick = false;
@@ -117,7 +133,8 @@
dragMoved = true;
const slot = placement.rack[downInfo.index];
drag = { letter: slot, blank: slot === BLANK, x: e.clientX, y: e.clientY };
if (isCoarse() && !zoomed) zoomed = true; // auto zoom-in on touch placement
// No zoom on drag start: the player may still change their mind. The zoom
// (and centring) happens on drop, in attemptPlace.
}
if (drag) drag = { ...drag, x: e.clientX, y: e.clientY };
}
@@ -154,14 +171,18 @@
return;
}
if (selected != null) {
// A committed tile already sits here: keep the rack selection so a stray tap
// on an occupied cell doesn't cancel placement — wait for an empty cell.
if (board[row]?.[col]) return;
attemptPlace(selected, row, col);
selected = null;
}
}
function attemptPlace(index: number, row: number, col: number) {
if (board[row]?.[col]) return;
if (pendingMap.has(`${row},${col}`)) return;
focus = { row, col };
if (isCoarse() && !zoomed) zoomed = true;
if (placement.rack[index] === BLANK) {
blankPrompt = { rackIndex: index, row, col };
return;
@@ -169,7 +190,6 @@
placement = place(placement, index, row, col);
recompute();
}
function chooseBlank(letter: string) {
if (!blankPrompt) return;
placement = place(placement, blankPrompt.rackIndex, blankPrompt.row, blankPrompt.col, letter);
@@ -187,7 +207,7 @@
try {
preview = await gateway.evaluate(id, sub.dir, sub.tiles);
} catch {
/* preview is best-effort */
/* best-effort */
}
}, 250);
}
@@ -198,6 +218,7 @@
busy = true;
try {
await gateway.submitPlay(id, sub.dir, sub.tiles);
zoomed = false;
await load();
} catch (e) {
handleError(e);
@@ -205,7 +226,6 @@
busy = false;
}
}
function resetPlacement() {
placement = reset(placement);
preview = null;
@@ -224,7 +244,6 @@
busy = false;
}
}
async function doResign() {
resignOpen = false;
busy = true;
@@ -237,18 +256,24 @@
busy = false;
}
}
async function doHint() {
try {
const h = await gateway.hint(id);
const word = h.move.words[0] ?? h.move.tiles.map((x) => x.letter).join('');
showToast(t('game.hintShown', { word, n: h.move.score }));
if (view) view = { ...view, hintsRemaining: h.hintsRemaining };
if (h.move.tiles.length && view) {
placement = placementFromHint(h.move.tiles, view.rack);
if (isCoarse()) zoomed = true;
view = { ...view, hintsRemaining: h.hintsRemaining };
recompute();
}
} 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() {
if (placement.pending.length > 0) return;
const r = [...placement.rack];
@@ -258,14 +283,7 @@
}
placement = newPlacement(r);
}
function toggleDir() {
dirOverride = dir === 'H' ? 'V' : 'H';
recompute();
}
function openExchange() {
menuOpen = false;
resetPlacement();
exchangeSel = [];
exchangeOpen = true;
@@ -289,16 +307,27 @@
}
function openCheck() {
menuOpen = false;
checkWord = '';
checkResult = null;
checkOpen = true;
}
function onCheckInput(e: Event) {
checkWord = sanitizeCheckWord((e.target as HTMLInputElement).value, alphabet(variant));
}
// Check is disabled while cooling down, for an already-checked word, or an out-of-range
// length. The input filter already restricts to the variant's alphabet.
function canCheck(): boolean {
return canCheckWord(checkWord, checkedWords.has(checkWord.trim().toUpperCase()), cooling);
}
async function runCheck() {
const w = checkWord.trim();
if (!w) return;
if (!canCheck()) return;
const w = checkWord.trim().toUpperCase();
cooling = true;
setTimeout(() => (cooling = false), 5000);
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) {
handleError(e);
}
@@ -308,28 +337,26 @@
try {
await gateway.complaint(id, checkResult.word, '');
showToast(t('game.complaintSent'));
checkOpen = false;
} catch (e) {
handleError(e);
}
}
function openChat() {
menuOpen = false;
panel = 'chat';
void loadChat();
}
async function sendChat(text: string) {
try {
const m = await gateway.chatPost(id, text);
messages = [...messages, m];
messages = [...messages, await gateway.chatPost(id, text)];
} catch (e) {
handleError(e);
}
}
async function nudge() {
try {
const m = await gateway.nudge(id);
messages = [...messages, m];
messages = [...messages, await gateway.nudge(id)];
} catch (e) {
handleError(e);
}
@@ -341,90 +368,127 @@
if (me?.isWinner) return t('game.won');
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>
<Header title={t('app.title')} back="/">
<Screen title={t('app.title')} back="/" growNav scroll={!historyOpen}>
{#snippet menu()}
<button class="icon" onclick={() => (menuOpen = !menuOpen)} aria-label="Menu"></button>
{#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}
<Menu items={menuItems} />
{/snippet}
</Header>
{#if view}
<div class="scoreboard">
{#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="nm">{s.accountId === app.session?.userId ? t('common.you') : s.displayName}</div>
<div class="sc">{s.score}</div>
</div>
{/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}
{#if view}
<div class="scoreboard">
{#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="nm">{s.accountId === app.session?.userId ? t('common.you') : s.displayName}</div>
<div class="sc">{s.score}</div>
</div>
{/each}
</div>
<Controls
{preview}
hints={view.hintsRemaining}
busy={busy || !isMyTurn}
{ambiguous}
{dir}
ondraw={openExchange}
onskip={doPass}
onshuffle={shuffle}
onhint={doHint}
ondir={toggleDir}
/>
<div class="stage">
{#if historyOpen}
<div class="history">
<ol>
{#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}
{#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}
onclick={() => historyOpen && (historyOpen = false)}
>
<Board
{board}
{premium}
pending={pendingMap}
{highlight}
{flash}
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}
{:else}
<p class="loading">{t('common.loading')}</p>
{/if}
{#snippet tabbar()}
{#if view && !gameOver}
<TabBar>
<button class="tab" disabled={busy || !isMyTurn || bagEmpty} onclick={openExchange}>
<span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>
</button>
<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}
<div class="ghost" style="left:{drag.x}px; top:{drag.y}px">
@@ -460,8 +524,13 @@
{#if checkOpen}
<Modal title={t('game.checkWord')} onclose={() => (checkOpen = false)}>
<div class="check">
<input placeholder={t('game.checkWordPrompt')} bind:value={checkWord} onkeydown={(e) => e.key === 'Enter' && runCheck()} />
<button onclick={runCheck}>{t('game.checkWord')}</button>
<input
value={checkWord}
oninput={onCheckInput}
onkeydown={(e) => e.key === 'Enter' && runCheck()}
placeholder={t('game.checkWordPrompt')}
/>
<button onclick={runCheck} disabled={!canCheck()}>{t('game.check')}</button>
</div>
{#if checkResult}
<p class:ok={checkResult.legal} class:bad={!checkResult.legal}>
@@ -489,27 +558,12 @@
</Modal>
{/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>
.scoreboard {
display: flex;
gap: 2px;
padding: 6px var(--pad);
background: var(--bg-elev);
border-bottom: 1px solid var(--border);
}
.seat {
flex: 1;
@@ -535,14 +589,61 @@
font-weight: 700;
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 {
padding: 8px;
padding: 6px;
transition: transform 0.3s ease;
}
.boardwrap.slid {
transform: translateY(62%);
}
.status {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--pad) 8px;
padding: 2px var(--pad) 6px;
color: var(--text-muted);
font-size: 0.85rem;
}
@@ -553,60 +654,64 @@
.over {
color: var(--accent);
}
.scores {
font-weight: 600;
color: var(--ok);
min-width: 64px;
text-align: right;
}
.rack-row {
display: flex;
gap: 8px;
align-items: stretch;
padding: 0 var(--pad);
padding: 0 var(--pad) 6px;
}
.rack-wrap {
flex: 1;
min-width: 0;
}
:global(.rack-row .wrap) {
display: flex;
.flag {
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 14px;
border: none;
background: none;
color: var(--text);
border-radius: var(--radius-sm);
font-weight: 500;
text-align: left;
}
.pop:hover {
background: var(--surface-2);
}
.badge {
position: absolute;
top: -3px;
right: -3px;
font-size: 0.68rem;
font-weight: 700;
background: var(--accent);
color: var(--accent-text);
border-radius: 999px;
min-width: 15px;
padding: 0 3px;
line-height: 1.4;
text-align: center;
}
.loading {
text-align: center;
color: var(--text-muted);
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 {
position: fixed;
width: 40px;
@@ -677,6 +782,7 @@
border-radius: var(--radius-sm);
background: var(--bg);
color: var(--text);
text-transform: uppercase;
}
.check button {
padding: 10px 12px;
@@ -715,27 +821,4 @@
color: #fff !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>
+26 -25
View File
@@ -15,45 +15,41 @@
selected: number | null;
ondown: (e: PointerEvent, index: number) => void;
} = $props();
// Used slots are hidden (the rack shifts left, freeing room on the right for the
// MakeMove control); the slot still exists in the model for per-tile recall.
const visible = $derived(slots.filter((s) => !s.used));
</script>
<div class="rack">
{#each slots as slot (slot.index)}
{#if slot.used}
<span class="slot empty"></span>
{:else}
<button
class="slot tile"
class:selected={selected === slot.index}
data-rack-index={slot.index}
onpointerdown={(e) => ondown(e, slot.index)}
>
<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 visible as slot (slot.index)}
<button
class="tile"
class:selected={selected === slot.index}
data-rack-index={slot.index}
onpointerdown={(e) => ondown(e, slot.index)}
>
<span class="letter">{slot.letter === BLANK ? '' : slot.letter}</span>
{#if slot.letter !== BLANK}<span class="val">{tileValue(variant, slot.letter)}</span>{/if}
</button>
{/each}
</div>
<style>
.rack {
display: grid;
grid-template-columns: repeat(7, 1fr);
display: flex;
gap: 5px;
}
.slot {
aspect-ratio: 1;
border-radius: 5px;
}
.empty {
background: var(--surface-2);
border: 1px dashed var(--border);
align-items: center;
}
.tile {
position: relative;
flex: 0 0 auto;
width: min(12.5vw, 46px);
aspect-ratio: 1;
background: var(--tile-bg);
color: var(--tile-text);
border: none;
border-radius: 5px;
box-shadow: inset 0 -3px 0 var(--tile-edge);
font-weight: 700;
font-size: 1.4rem;
@@ -64,9 +60,14 @@
outline: 3px solid var(--accent);
outline-offset: -3px;
}
.letter {
position: absolute;
top: 8%;
left: 14%;
}
.val {
position: absolute;
right: 3px;
right: 4px;
bottom: 1px;
font-size: 0.7rem;
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 { applyReduceMotion, applyTheme, type ThemePref } from './theme';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
import type { BoardLabelMode } from './boardlabels';
export interface Toast {
kind: 'error' | 'info';
@@ -25,6 +26,7 @@ export const app = $state<{
theme: ThemePref;
locale: Locale;
reduceMotion: boolean;
boardLabels: BoardLabelMode;
localeLocked: boolean;
}>({
ready: false,
@@ -35,6 +37,7 @@ export const app = $state<{
theme: 'auto',
locale: 'en',
reduceMotion: false,
boardLabels: 'beginner',
localeLocked: false,
});
@@ -101,6 +104,7 @@ export async function bootstrap(): Promise<void> {
const prefs = await loadPrefs();
app.theme = prefs.theme ?? 'auto';
app.reduceMotion = prefs.reduceMotion ?? false;
app.boardLabels = prefs.boardLabels ?? 'beginner';
applyTheme(app.theme);
applyReduceMotion(app.reduceMotion);
if (prefs.locale) {
@@ -163,7 +167,12 @@ export async function logout(): Promise<void> {
}
function persistPrefs(): void {
void savePrefs({ theme: app.theme, locale: app.locale, reduceMotion: app.reduceMotion });
void savePrefs({
theme: app.theme,
locale: app.locale,
reduceMotion: app.reduceMotion,
boardLabels: app.boardLabels,
});
}
export function setTheme(theme: ThemePref): void {
@@ -184,3 +193,8 @@ export function setReduceMotion(on: boolean): void {
applyReduceMotion(on);
persistPrefs();
}
export function setBoardLabels(mode: BoardLabelMode): void {
app.boardLabels = mode;
persistPrefs();
}
+90
View File
@@ -0,0 +1,90 @@
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');
});
it('keeps root-relative links and renders several in one string', () => {
expect(linkify('go [home](/lobby) or [docs](https://x.com)')).toBe(
'go <a href="/lobby" target="_blank" rel="noopener noreferrer">home</a> or ' +
'<a href="https://x.com" target="_blank" rel="noopener noreferrer">docs</a>',
);
});
});
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();
});
it('repeats the scroll cycle while under holdMs, then advances', () => {
vi.useFakeTimers();
const cfg = { holdMs: 2500, edgePauseMs: 100, fadeMs: 10, scrollPxPerSec: 100 };
const shown: number[] = [];
let scrolls = 0;
const r = createBannerRotator(
[{ md: 'long' }, { md: 'short' }],
{ overflowPx: (i) => (i === 0 ? 100 : 0), show: (i) => shown.push(i), scrollTo: () => scrolls++ },
cfg,
);
r.start();
vi.advanceTimersByTime(110); // fade(10) + edgePause(100) -> first scroll
expect(scrolls).toBe(1);
expect(shown).toEqual([0]);
vi.advanceTimersByTime(1200); // scrollDur(1000) + edgePause + edgePause -> re-show + second scroll
expect(scrolls).toBe(2);
expect(shown).toEqual([0, 0]); // re-shown to reset scroll, still item 0 (under holdMs)
vi.advanceTimersByTime(10_000);
expect(shown).toContain(1); // eventually exceeds holdMs and advances to the fitting message
r.stop();
});
it('stop() halts further advancement', () => {
vi.useFakeTimers();
const cfg = { ...defaultBannerConfig, holdMs: 100, fadeMs: 5, edgePauseMs: 5 };
const shown: number[] = [];
const r = createBannerRotator(
[{ md: 'a' }, { md: 'b' }],
{ overflowPx: () => 0, show: (i) => shown.push(i), scrollTo: () => {} },
cfg,
);
r.start();
vi.advanceTimersByTime(cfg.fadeMs);
r.stop();
vi.advanceTimersByTime(10_000);
expect(shown).toEqual([0]);
});
});
+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 };
}
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import { canCheckWord, MAX_WORD_LEN, sanitizeCheckWord } from './checkword';
const EN = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
describe('sanitizeCheckWord', () => {
it('upper-cases and keeps only letters of the alphabet', () => {
expect(sanitizeCheckWord('ca7t!', EN)).toBe('CAT');
expect(sanitizeCheckWord(' Hi 9 ', EN)).toBe('HI');
});
it('drops characters outside the active alphabet', () => {
expect(sanitizeCheckWord('cat', ['C', 'A'])).toBe('CA'); // T not in this alphabet
const RU = 'КОТ'.split('');
expect(sanitizeCheckWord('коt', RU)).toBe('КО'); // cyrillic kept, latin "t" dropped
});
it('caps the length at MAX_WORD_LEN', () => {
expect(sanitizeCheckWord('A'.repeat(30), EN)).toHaveLength(MAX_WORD_LEN);
});
});
describe('canCheckWord', () => {
it('allows a fresh, in-range word', () => {
expect(canCheckWord('CAT', false, false)).toBe(true);
});
it('rejects an out-of-range length', () => {
expect(canCheckWord('A', false, false)).toBe(false); // too short
expect(canCheckWord('A'.repeat(MAX_WORD_LEN + 1), false, false)).toBe(false); // too long
});
it('rejects an already-checked word or a cooling-down state', () => {
expect(canCheckWord('CAT', true, false)).toBe(false);
expect(canCheckWord('CAT', false, true)).toBe(false);
});
it('trims surrounding whitespace before measuring length', () => {
expect(canCheckWord(' ok ', false, false)).toBe(true);
expect(canCheckWord(' a ', false, false)).toBe(false);
});
});
+31
View File
@@ -0,0 +1,31 @@
// Pure helpers for the in-game "check a word" panel: input sanitising and the gate on
// when a check may be sent. Kept separate from Game.svelte so the constraints (the
// variant alphabet, the length bounds, the answered-word cache and the cool-down
// throttle) are unit-testable and stay in lockstep with the UI.
/** The longest word that fits on a standard 15-cell board line. */
export const MAX_WORD_LEN = 15;
/** The shortest word worth checking. */
export const MIN_WORD_LEN = 2;
/**
* sanitizeCheckWord upper-cases the raw input and keeps only characters of the active
* variant's alphabet, capped at MAX_WORD_LEN — so the field can never hold something the
* dictionary could not contain.
*/
export function sanitizeCheckWord(raw: string, alphabet: string[]): string {
const allowed = new Set(alphabet);
return Array.from(raw.toUpperCase())
.filter((ch) => allowed.has(ch))
.slice(0, MAX_WORD_LEN)
.join('');
}
/**
* canCheckWord gates the Check action: the trimmed word must be of valid length, must not
* have been answered already (cached), and must not fall inside the cool-down window.
*/
export function canCheckWord(word: string, alreadyChecked: boolean, cooling: boolean): boolean {
const w = word.trim();
return w.length >= MIN_WORD_LEN && w.length <= MAX_WORD_LEN && !alreadyChecked && !cooling;
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { GatewayError } from './client';
describe('GatewayError', () => {
it('carries a stable code and is a real Error', () => {
const e = new GatewayError('no_hint_available');
expect(e).toBeInstanceOf(Error);
expect(e.name).toBe('GatewayError');
expect(e.code).toBe('no_hint_available');
expect(e.message).toBe('no_hint_available'); // message defaults to the code
});
it('keeps a custom message while the code stays the i18n lookup key', () => {
const e = new GatewayError('not_your_turn', 'It is not your turn');
expect(e.code).toBe('not_your_turn');
expect(e.message).toBe('It is not your turn');
});
});
+20
View File
@@ -76,6 +76,20 @@ export const en = {
'game.wordIllegal': '“{word}” is not valid',
'game.complain': 'Disagree',
'game.complaintSent': 'Thanks, sent for review.',
'game.confirm': 'Ok',
'game.check': 'Check',
'game.checkWait': 'Please wait a moment.',
'game.noHintOptions': 'No options with your letters.',
'game.scores': 'Scores: {n}',
'result.victory': 'Victory',
'result.defeat': 'Defeat',
'result.draw': 'Draw',
'result.place2': 'II place',
'result.place3': 'III place',
'result.place4': 'IV place',
'result.yourMove': 'Your move',
'result.oppMove': "Opponent's move",
'chat.placeholder': 'Quick message…',
'chat.send': 'Send',
@@ -96,6 +110,11 @@ export const en = {
'settings.themeLight': 'Light',
'settings.themeDark': 'Dark',
'settings.language': 'Interface language',
'settings.boardStyle': 'Board style',
'settings.boardLabels': 'Bonus labels',
'settings.labelsBeginner': 'Beginner',
'settings.labelsClassic': 'Classic',
'settings.labelsNone': 'None',
'settings.reduceMotion': 'Reduce motion',
'about.title': 'About',
@@ -108,6 +127,7 @@ export const en = {
'error.not_your_turn': "It is not your turn.",
'error.illegal_play': 'That is not a legal play.',
'error.hint_unavailable': 'No hints available.',
'error.no_hint_available': 'No options with your letters.',
'error.chat_rejected': 'Message rejected (too long or contains contact info).',
'error.game_finished': 'This game is finished.',
'error.not_a_player': 'You are not a player in this game.',
+20
View File
@@ -77,6 +77,20 @@ export const ru: Record<MessageKey, string> = {
'game.wordIllegal': '«{word}» недопустимо',
'game.complain': 'Не согласен',
'game.complaintSent': 'Спасибо, отправлено на проверку.',
'game.confirm': 'Да',
'game.check': 'Проверить',
'game.checkWait': 'Секунду, пожалуйста.',
'game.noHintOptions': 'Нет вариантов с вашим набором.',
'game.scores': 'Очков: {n}',
'result.victory': 'Победа',
'result.defeat': 'Поражение',
'result.draw': 'Ничья',
'result.place2': 'II место',
'result.place3': 'III место',
'result.place4': 'IV место',
'result.yourMove': 'Ваш ход',
'result.oppMove': 'Ход соперника',
'chat.placeholder': 'Короткое сообщение…',
'chat.send': 'Отправить',
@@ -97,6 +111,11 @@ export const ru: Record<MessageKey, string> = {
'settings.themeLight': 'Светлая',
'settings.themeDark': 'Тёмная',
'settings.language': 'Язык интерфейса',
'settings.boardStyle': 'Стиль доски',
'settings.boardLabels': 'Подписи бонусов',
'settings.labelsBeginner': 'Новичок',
'settings.labelsClassic': 'Классика',
'settings.labelsNone': 'Без текста',
'settings.reduceMotion': 'Меньше анимаций',
'about.title': 'О программе',
@@ -109,6 +128,7 @@ export const ru: Record<MessageKey, string> = {
'error.not_your_turn': 'Сейчас не ваш ход.',
'error.illegal_play': 'Это недопустимый ход.',
'error.hint_unavailable': 'Подсказки недоступны.',
'error.no_hint_available': 'Нет вариантов с вашим набором.',
'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).',
'error.game_finished': 'Эта игра уже завершена.',
'error.not_a_player': 'Вы не участник этой игры.',
+60
View File
@@ -1,11 +1,15 @@
import { describe, expect, it } from 'vitest';
import {
BLANK,
cellOccupied,
direction,
isBlankSlot,
newPlacement,
place,
placementFromHint,
rackView,
recallAt,
recallIndex,
reset,
toSubmit,
} from './placement';
@@ -61,4 +65,60 @@ describe('placement state machine', () => {
expect(toSubmit(place(newPlacement(rack), 0, 7, 7), 'V')?.dir).toBe('V');
expect(toSubmit(newPlacement(rack))).toBeNull();
});
it('recalls a tile by rack index and reports occupied cells / blank slots', () => {
let p = place(newPlacement(rack), 0, 7, 7);
p = place(p, 1, 7, 8);
expect(cellOccupied(p, 7, 7)).toBe(true);
expect(cellOccupied(p, 6, 6)).toBe(false);
p = recallIndex(p, 0);
expect(p.pending.map((t) => t.rackIndex)).toEqual([1]);
expect(isBlankSlot(newPlacement(rack), 2)).toBe(true); // '?' slot
expect(isBlankSlot(newPlacement(rack), 0)).toBe(false);
});
it('treats a non-linear placement as no inferred direction', () => {
let p = place(newPlacement(rack), 0, 7, 7);
p = place(p, 1, 8, 8); // diagonal
expect(direction(p)).toBeNull();
});
it('defaults a single-tile submit to H without an override', () => {
const sub = toSubmit(place(newPlacement(rack), 0, 7, 7));
expect(sub?.dir).toBe('H');
expect(sub?.tiles).toHaveLength(1);
});
});
describe('placementFromHint', () => {
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 });
});
it('falls back to a blank slot when the hint letter is not in the rack', () => {
const p = placementFromHint([{ row: 7, col: 7, letter: 'Z', blank: false }], ['A', BLANK]);
expect(p.pending).toHaveLength(1);
expect(p.pending[0]).toMatchObject({ rackIndex: 1, letter: 'Z', blank: true });
});
it('skips hint tiles once the rack is exhausted', () => {
const p = placementFromHint(
[
{ row: 7, col: 7, letter: 'A', blank: false },
{ row: 7, col: 8, letter: 'B', blank: false },
],
['A'],
);
expect(p.pending.map((t) => t.letter)).toEqual(['A']);
});
});
+20 -1
View File
@@ -4,7 +4,7 @@
// payload. It is board-agnostic (the gateway/engine does full legality validation at
// submit), which keeps it trivially unit-testable.
import type { Direction } from './model';
import type { Direction, Tile } from './model';
import type { PlacedTile } from './client';
export interface PendingTile {
@@ -36,6 +36,25 @@ export function newPlacement(rack: string[]): Placement {
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> {
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 { ThemePref } from './theme';
import type { Locale } from './i18n/catalog';
import type { BoardLabelMode } from './boardlabels';
const DB_NAME = 'scrabble';
const STORE = 'kv';
@@ -122,6 +123,7 @@ export interface Prefs {
theme: ThemePref;
locale: Locale;
reduceMotion: boolean;
boardLabels: BoardLabelMode;
}
export async function loadPrefs(): Promise<Partial<Prefs>> {
+8 -7
View File
@@ -1,16 +1,17 @@
<script lang="ts">
import Header from '../components/Header.svelte';
import Screen from '../components/Screen.svelte';
import { t } from '../lib/i18n/index.svelte';
const version = '0.7.0';
</script>
<Header title={t('about.title')} back="/" />
<main class="page">
<h2>{t('app.title')}</h2>
<p>{t('about.description')}</p>
<p class="muted">{t('about.version', { v: version })}</p>
</main>
<Screen title={t('about.title')} back="/">
<div class="page">
<h2>{t('app.title')}</h2>
<p>{t('about.description')}</p>
<p class="muted">{t('about.version', { v: version })}</p>
</div>
</Screen>
<style>
.page {
+59 -143
View File
@@ -1,14 +1,16 @@
<script lang="ts">
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 { gateway } from '../lib/gateway';
import { navigate } from '../lib/router.svelte';
import { t } from '../lib/i18n/index.svelte';
import { resultBadge } from '../lib/result';
import type { GameView } from '../lib/model';
let games = $state<GameView[]>([]);
let menuOpen = $state(false);
async function load() {
try {
@@ -19,8 +21,6 @@
}
onMount(load);
// Refresh the lists when a live event lands (move / your-turn / match-found).
$effect(() => {
if (app.lastEvent) void load();
});
@@ -29,95 +29,72 @@
const active = $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 {
return g.seats
.filter((s) => s.accountId !== myId)
.map((s) => s.displayName)
.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 {
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);
return `${me?.score ?? 0} : ${opp.join(', ')}`;
}
function go(path: string) {
menuOpen = false;
navigate(path);
}
const menuItems = $derived([
{ label: t('lobby.profile'), onclick: () => navigate('/profile') },
{ label: t('lobby.settings'), onclick: () => navigate('/settings') },
{ label: t('lobby.about'), onclick: () => navigate('/about') },
]);
</script>
<Header title={app.profile?.displayName ?? t('app.title')}>
<Screen title={app.profile?.displayName ?? t('app.title')}>
{#snippet menu()}
<button class="icon" onclick={() => (menuOpen = !menuOpen)} aria-label="Menu"></button>
{#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}
<Menu items={menuItems} />
{/snippet}
</Header>
<main class="lobby">
<section>
<h2>{t('lobby.activeGames')}</h2>
{#if active.length === 0}
<div class="lobby">
{#each [{ h: 'lobby.activeGames', list: active }, { h: 'lobby.finishedGames', list: finished }] as group (group.h)}
{#if group.list.length}
<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>
{/if}
{#each active as g (g.id)}
<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>
</div>
<section>
<h2>{t('lobby.finishedGames')}</h2>
{#if finished.length === 0}
<p class="empty">{t('lobby.noFinished')}</p>
{/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>
{#snippet tabbar()}
<TabBar>
<button class="tab" onclick={() => navigate('/new')}>
<span class="sq">🎲</span><span class="lbl">{t('lobby.new')}</span>
</button>
{/each}
</section>
</main>
<nav class="tabs">
<button class="tab primary" onclick={() => navigate('/new')}>{t('lobby.new')}</button>
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>{t('lobby.stats')}</button>
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>{t('lobby.tournaments')}</button>
</nav>
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>
<span class="sq">⚔️</span><span class="lbl">{t('lobby.tournaments')}</span>
</button>
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>
<span class="sq">🧮</span><span class="lbl">{t('lobby.stats')}</span>
</button>
</TabBar>
{/snippet}
</Screen>
<style>
.lobby {
padding: var(--pad);
padding-bottom: 84px;
display: flex;
flex-direction: column;
gap: 18px;
@@ -147,88 +124,27 @@
background: var(--surface);
color: var(--text);
border-radius: var(--radius);
user-select: none;
}
.info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.who {
font-weight: 600;
}
.meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sub {
font-size: 0.85rem;
color: var(--text-muted);
}
.sub.turn {
color: var(--accent);
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;
.emoji {
font-size: 1.8rem;
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);
flex: 0 0 auto;
}
</style>
+20 -20
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import Header from '../components/Header.svelte';
import Screen from '../components/Screen.svelte';
import { gateway } from '../lib/gateway';
import { handleError } from '../lib/app.svelte';
import { navigate } from '../lib/router.svelte';
@@ -31,8 +31,6 @@
navigate(`/game/${r.game.id}`);
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 () => {
try {
const p = await gateway.lobbyPoll();
@@ -53,23 +51,24 @@
onDestroy(stop);
</script>
<Header title={t('new.title')} back="/" />
<main class="page">
{#if searching}
<div class="searching">
<div class="spinner"></div>
<p>{t('new.searching')}</p>
<button class="cancel" onclick={() => { stop(); navigate('/'); }}>{t('common.cancel')}</button>
</div>
{:else}
<p class="subtitle">{t('new.subtitle')}</p>
<div class="variants">
{#each variants as v (v.id)}
<button class="variant" onclick={() => find(v.id)}>{t(v.label)}</button>
{/each}
</div>
{/if}
</main>
<Screen title={t('new.title')} back="/">
<div class="page">
{#if searching}
<div class="searching">
<div class="spinner"></div>
<p>{t('new.searching')}</p>
<button class="cancel" onclick={() => { stop(); navigate('/'); }}>{t('common.cancel')}</button>
</div>
{:else}
<p class="subtitle">{t('new.subtitle')}</p>
<div class="variants">
{#each variants as v (v.id)}
<button class="variant" onclick={() => find(v.id)}>{t(v.label)}</button>
{/each}
</div>
{/if}
</div>
</Screen>
<style>
.page {
@@ -92,6 +91,7 @@
border-radius: var(--radius);
font-size: 1.05rem;
font-weight: 600;
user-select: none;
}
.searching {
display: grid;
+19 -18
View File
@@ -1,26 +1,27 @@
<script lang="ts">
import Header from '../components/Header.svelte';
import Screen from '../components/Screen.svelte';
import { app, logout } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte';
</script>
<Header title={t('profile.title')} back="/" />
<main class="page">
{#if app.profile}
<div class="name">{app.profile.displayName}</div>
{#if app.profile.isGuest}<span class="badge">{t('profile.guest')}</span>{/if}
<dl>
<dt>{t('profile.language')}</dt>
<dd>{app.profile.preferredLanguage}</dd>
<dt>{t('profile.timezone')}</dt>
<dd>{app.profile.timeZone}</dd>
<dt>{t('profile.hintBalance')}</dt>
<dd>{app.profile.hintBalance}</dd>
</dl>
<p class="muted">{t('profile.readonly')}</p>
<button class="logout" onclick={() => logout()}>{t('login.title')} / logout</button>
{/if}
</main>
<Screen title={t('profile.title')} back="/">
<div class="page">
{#if app.profile}
<div class="name">{app.profile.displayName}</div>
{#if app.profile.isGuest}<span class="badge">{t('profile.guest')}</span>{/if}
<dl>
<dt>{t('profile.language')}</dt>
<dd>{app.profile.preferredLanguage}</dd>
<dt>{t('profile.timezone')}</dt>
<dd>{app.profile.timeZone}</dd>
<dt>{t('profile.hintBalance')}</dt>
<dd>{app.profile.hintBalance}</dd>
</dl>
<p class="muted">{t('profile.readonly')}</p>
<button class="logout" onclick={() => logout()}>{t('login.title')} / logout</button>
{/if}
</div>
</Screen>
<style>
.page {
+67 -35
View File
@@ -1,8 +1,15 @@
<script lang="ts">
import Header from '../components/Header.svelte';
import { app, setLocalePref, setReduceMotion, setTheme } from '../lib/app.svelte';
import Screen from '../components/Screen.svelte';
import {
app,
setBoardLabels,
setLocalePref,
setReduceMotion,
setTheme,
} from '../lib/app.svelte';
import { t, type Locale, type MessageKey } from '../lib/i18n/index.svelte';
import type { ThemePref } from '../lib/theme';
import type { BoardLabelMode } from '../lib/boardlabels';
const themes: ThemePref[] = ['auto', 'light', 'dark'];
const themeLabel: Record<ThemePref, MessageKey> = {
@@ -11,43 +18,62 @@
dark: 'settings.themeDark',
};
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>
<Header title={t('settings.title')} back="/" />
<main class="page">
<section>
<h3>{t('settings.theme')}</h3>
<div class="seg">
{#each themes as th (th)}
<button class="opt" class:active={app.theme === th} onclick={() => setTheme(th)}>
{t(themeLabel[th])}
</button>
{/each}
</div>
</section>
<Screen title={t('settings.title')} back="/">
<div class="page">
<section>
<h3>{t('settings.theme')}</h3>
<div class="seg">
{#each themes as th (th)}
<button class="opt" class:active={app.theme === th} onclick={() => setTheme(th)}>
{t(themeLabel[th])}
</button>
{/each}
</div>
</section>
<section>
<h3>{t('settings.language')}</h3>
<div class="seg">
{#each locales as lc (lc)}
<button class="opt" class:active={app.locale === lc} onclick={() => setLocalePref(lc)}>
{t(lc === 'en' ? 'lang.en' : 'lang.ru')}
</button>
{/each}
</div>
</section>
<section>
<h3>{t('settings.language')}</h3>
<div class="seg">
{#each locales as lc (lc)}
<button class="opt" class:active={app.locale === lc} onclick={() => setLocalePref(lc)}>
{t(lc === 'en' ? 'lang.en' : 'lang.ru')}
</button>
{/each}
</div>
</section>
<section>
<label class="row">
<span>{t('settings.reduceMotion')}</span>
<input
type="checkbox"
checked={app.reduceMotion}
onchange={(e) => setReduceMotion(e.currentTarget.checked)}
/>
</label>
</section>
</main>
<section>
<h3>{t('settings.boardStyle')}</h3>
<div class="sub">{t('settings.boardLabels')}</div>
<div class="seg">
{#each labelModes as lm (lm)}
<button class="opt" class:active={app.boardLabels === lm} onclick={() => setBoardLabels(lm)}>
{t(labelModeKey[lm])}
</button>
{/each}
</div>
</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>
.page {
@@ -61,6 +87,11 @@
font-size: 0.95rem;
color: var(--text-muted);
}
.sub {
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 6px;
}
.seg {
display: flex;
gap: 8px;
@@ -72,6 +103,7 @@
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
user-select: none;
}
.opt.active {
background: var(--accent);