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 name: Tests · UI
# Hermetic UI checks: type-check, Vitest unit tests, production build with a # 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 # bundle-size budget, and a Playwright smoke (Chromium + WebKit) against the in-memory
# (no backend/gateway/Postgres). The committed src/gen/ codegen is built, not # 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). # regenerated (the same model as the Go committed jet/fbs output).
on: on:
@@ -51,12 +51,15 @@ jobs:
# The Playwright system libraries are provisioned once on the runner host # The Playwright system libraries are provisioned once on the runner host
# (`sudo npx playwright@<version> install-deps chromium`), so the job needs no # (`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 # apt and no sudo: it only downloads the browser binaries into the runner cache
# (persisted by the host executor) and runs the smoke. The timeouts guard # (persisted by the host executor) and runs the suite. WebKit's Debian build
# against a future hang. Keep this in lockstep with @playwright/test in # bundles most of its own libraries and runs headless without extra host deps; if
# package.json — re-run install-deps on the host after a major bump. # a runner ever lacks one, provision it once on the host with
- name: Install Playwright browser # `sudo npx playwright install-deps webkit`. The timeouts guard against a future
run: pnpm exec playwright install chromium # 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 timeout-minutes: 5
- name: E2E smoke (mock) - name: E2E smoke (mock)
+3
View File
@@ -10,6 +10,9 @@
.idea/ .idea/
.DS_Store .DS_Store
# Playwright MCP scratch output (snapshots / screenshots written during inspection)
.playwright-mcp/
# Local, unstaged env overrides # Local, unstaged env overrides
**/.env.local **/.env.local
**/.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) - [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md)
mirror) — per-domain user stories. English authoritative. mirror) — per-domain user stories. English authoritative.
- [`docs/TESTING.md`](docs/TESTING.md) — test layers + the per-stage CI gate. - [`docs/TESTING.md`](docs/TESTING.md) — test layers + the per-stage CI gate.
- [`docs/UI_DESIGN.md`](docs/UI_DESIGN.md) — the `ui` visual/interaction design system.
## Mandatory per-stage workflow ## Mandatory per-stage workflow
+20 -1
View File
@@ -40,7 +40,7 @@ independent (see ARCHITECTURE §9.1).
| 4 | Lobby & social (matchmaking, friends, block, chat, profile, nudge) | **done** | | 4 | Lobby & social (matchmaking, friends, block, chat, profile, nudge) | **done** |
| 5 | Robot opponent | **done** | | 5 | Robot opponent | **done** |
| 6 | Gateway edge (Connect/FB, platform auth, sessions, push bridge, admin) | **done** | | 6 | Gateway edge (Connect/FB, platform auth, sessions, push bridge, admin) | **done** |
| 7 | UI — playable slice (Svelte+Vite, board, lobby, chat, hint/word-check, i18n) | todo | | 7 | UI — playable slice + UX polish (Svelte+Vite, board, lobby, chat, hint/word-check, i18n) | **done** |
| 8 | UI — social/account/history (friends, blocks, invitations, profile edit, stats, history/GCG) | todo | | 8 | UI — social/account/history (friends, blocks, invitations, profile edit, stats, history/GCG) | todo |
| 9 | Telegram integration (bot side-service, deep-link, push) | todo | | 9 | Telegram integration (bot side-service, deep-link, push) | todo |
| 10 | Admin & dictionary ops (complaint review, version reload) | todo | | 10 | Admin & dictionary ops (complaint review, version reload) | todo |
@@ -525,6 +525,18 @@ Open details: deployment target/host; dashboards; load expectations.
**bundle-size budget** — prod is ~67 KB gzip JS — and a chromium e2e). The Go **bundle-size budget** — prod is ~67 KB gzip JS — and a chromium e2e). The Go
workflows already cover the new backend/gateway/pkg code; a `game.ListForAccount` workflows already cover the new backend/gateway/pkg code; a `game.ListForAccount`
integration test and gateway transcode tests for the new ops were added. integration test and gateway transcode tests for the new ops were added.
- **UX polish** (follow-up PR): a mobile-app **app shell** (growing nav bar, content
pinned to the bottom) + a one-line **announcement banner** (client-side mock
rotation now; server-driven channel later — §10); a mobile-OS **tab bar** and a
reusable **HoldConfirm** press-and-hold control (MakeMove 🏁 + game-action confirms);
board **zoom reworked** to a 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) ## 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 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 guest accounts with no active games once their last session is gone; the
`ON DELETE CASCADE` foreign keys clean up the dependent rows. `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"}, "code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"},
"session gone": {session.ErrNotFound, http.StatusUnauthorized, "session_invalid"}, "session gone": {session.ErrNotFound, http.StatusUnauthorized, "session_invalid"},
"chat forbidden": {social.ErrForbiddenContent, http.StatusUnprocessableEntity, "chat_rejected"}, "chat forbidden": {social.ErrForbiddenContent, http.StatusUnprocessableEntity, "chat_rejected"},
"no hint move": {game.ErrNoHintAvailable, http.StatusConflict, "no_hint_available"},
"no hints left": {game.ErrNoHintsLeft, http.StatusConflict, "hint_unavailable"},
"unknown -> 500": {context_deadline, http.StatusInternalServerError, "internal"}, "unknown -> 500": {context_deadline, http.StatusInternalServerError, "internal"},
} }
for name, tc := range cases { for name, tc := range cases {
+5 -1
View File
@@ -121,7 +121,11 @@ func statusForError(err error) (int, string) {
return http.StatusConflict, "already_queued" return http.StatusConflict, "already_queued"
case errors.Is(err, game.ErrInvalidConfig): case errors.Is(err, game.ErrInvalidConfig):
return http.StatusBadRequest, "invalid_config" return http.StatusBadRequest, "invalid_config"
case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft), errors.Is(err, game.ErrNoHintAvailable): case errors.Is(err, game.ErrNoHintAvailable):
// No legal move for the rack — distinct from a budget/disabled hint so the UI
// can say "no options" (and the service spends nothing in this case).
return http.StatusConflict, "no_hint_available"
case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft):
return http.StatusConflict, "hint_unavailable" return http.StatusConflict, "hint_unavailable"
case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver): case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver):
return http.StatusUnprocessableEntity, "illegal_play" return http.StatusUnprocessableEntity, "illegal_play"
+14 -1
View File
@@ -38,6 +38,11 @@ Three executables plus per-platform side-services:
is a hash router and the session token is held in memory + IndexedDB. A build-flagged is a hash router and the session token is held in memory + IndexedDB. A build-flagged
in-memory mock transport (`pnpm start`) runs the whole slice with no backend. in-memory mock transport (`pnpm start`) runs the whole slice with no backend.
Embeddable in platform webviews; packageable to native (iOS/Android) via Capacitor. Embeddable in platform webviews; packageable to native (iOS/Android) via Capacitor.
The client uses a mobile-app shell (a growing nav bar; content pinned to the bottom),
a one-line **announcement banner** under the nav (a client-side mock rotation today —
a server-driven channel later, §10), and a client **board-style** setting (bonus-label
mode). The visual/interaction design system is documented in
[`UI_DESIGN.md`](UI_DESIGN.md).
- **`platform/<name>`** *(planned)* — per-platform side-services (Telegram bot - **`platform/<name>`** *(planned)* — per-platform side-services (Telegram bot
first): deep-link invites and platform-native push notifications. They talk first): deep-link invites and platform-native push notifications. They talk
to `backend` over an internal API. to `backend` over an internal API.
@@ -209,7 +214,11 @@ Key points:
(`hint_balance`, spent after the allowance; top-ups are a later feature). A hint (`hint_balance`, spent after the allowance; top-ups are a later feature). A hint
reveals the top-1 ranked move (`GenerateMoves[0]`). The lobby/tournament caller reveals the top-1 ranked move (`GenerateMoves[0]`). The lobby/tournament caller
picks the per-game defaults (e.g. one in casual random games, none in picks the per-game defaults (e.g. one in casual random games, none in
tournaments). tournaments). The client **lays the hinted tiles onto the board** as a pending
placement and leaves the commit to the player. When the rack has no legal move the
service spends **nothing** and returns `ErrNoHintAvailable` — surfaced as the distinct
result code `no_hint_available` (separate from `hint_unavailable`) so the UI can say
"no options" rather than "no hints left".
- **Word-check tool**: unlimited dictionary lookups against the game's pinned - **Word-check tool**: unlimited dictionary lookups against the game's pinned
dictionary; each result offers a **complaint** (complainant, game, variant, dictionary; each result offers a **complaint** (complainant, game, variant,
dict_version, word, the disputed result, an optional note) that lands in an dict_version, word, the disputed result, an optional note) that lands in an
@@ -363,6 +372,10 @@ match-found. Out-of-app platform push (your-turn, nudge) is wired in Stage 9;
session-revocation events and cursor-based stream resume are deferred session-revocation events and cursor-based stream resume are deferred
(single-instance MVP). (single-instance MVP).
A separate **announcements channel** feeds the client's one-line banner (UI_DESIGN.md).
It is a client-side **mock** rotation today; a server-driven source (operational notices,
promotions) is future work and would deliver short markdown messages (text + links).
## 11. Observability ## 11. Observability
- Structured logging with `go.uber.org/zap` (JSON). OpenTelemetry tracer and - Structured logging with `go.uber.org/zap` (JSON). OpenTelemetry tracer and
+4 -1
View File
@@ -17,7 +17,10 @@ the top-1 hint, the unlimited word-check with complaint, per-game chat and nudge
real-time in-app updates, switching interface language (en/ru) and theme, and a real-time in-app updates, switching interface language (en/ru) and theme, and a
read-only profile. Managing friends and blocks, creating friend games (invitations), read-only profile. Managing friends and blocks, creating friend games (invitations),
editing the profile, the statistics screen and the history/GCG viewer arrive in editing the profile, the statistics screen and the history/GCG viewer arrive in
Stage 8. Stage 8. Settings also pick the board's bonus-label style (beginner / classic /
none). A hint **lays the suggested tiles on the board** for the player to confirm and
costs nothing when the rack has no legal move. The word-check accepts only the
variant's alphabet, remembers answers within the session and rate-limits repeats.
### Identity & sessions *(Stage 1 / 6)* ### Identity & sessions *(Stage 1 / 6)*
A player arrives from a platform (Telegram first), via email login, or as an A player arrives from a platform (Telegram first), via email login, or as an
+5 -1
View File
@@ -16,7 +16,11 @@ top-1 подсказку, безлимитную проверку слова с
обновления в реальном времени, переключение языка интерфейса (en/ru) и темы и обновления в реальном времени, переключение языка интерфейса (en/ru) и темы и
профиль только для чтения. Управление друзьями и блоками, создание дружеских игр профиль только для чтения. Управление друзьями и блоками, создание дружеских игр
(приглашения), редактирование профиля, экран статистики и просмотр истории/GCG (приглашения), редактирование профиля, экран статистики и просмотр истории/GCG
появятся в Stage 8. появятся в Stage 8. В настройках также выбирается стиль подписей бонус-клеток
(новичок / классика / без текста). Подсказка **выставляет предложенные фишки на
доску** — игрок сам решает сделать ход, и подсказка не тратится, если ходов нет.
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
и ограничивает частоту повторов.
### Личность и сессии *(Stage 1 / 6)* ### Личность и сессии *(Stage 1 / 6)*
Игрок приходит с платформы (сначала Telegram), через email-вход или как Игрок приходит с платформы (сначала Telegram), через email-вход или как
+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 rackTile.click();
await page.locator('[data-cell]:not(.filled)').nth(30).click(); await page.locator('[data-cell]:not(.filled)').nth(30).click();
await expect(page.locator('[data-cell].pending')).toHaveCount(1); await expect(page.locator('[data-cell].pending')).toHaveCount(1);
await expect(page.locator('.preview')).toContainText(/\d/); // The score preview appears where the hints count used to be.
await expect(page.locator('.scores')).toContainText(/\d/);
// The contextual MakeMove control appears once a tile is pending. // The contextual MakeMove control (🏁) appears once a tile is pending.
await expect(page.getByRole('button', { name: /make move/i })).toBeVisible(); await expect(page.locator('.make')).toBeVisible();
}); });
+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, reuseExistingServer: !process.env.CI,
timeout: 60_000, 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 + tiles (all drawn with CSS primitives) */
--board-bg: #cdd6cf; --board-bg: #cdd6cf;
--cell-bg: #e7ece8; --cell-bg: #e7ece8;
--cell-line: #b6c0b8; --cell-line: #7f8d83;
--tile-bg: #f4e2b8; --tile-bg: #f4e2b8;
--tile-edge: #d8c190; --tile-edge: #d8c190;
--tile-text: #2a2113; --tile-text: #2a2113;
--tile-pending: #ffe7a3; --tile-pending: #f2cf73;
--tile-recent: #fff6d8; --tile-recent: #c8a85c;
--prem-tw: #e06a5b; /* triple word */ --prem-tw: #e06a5b; /* triple word */
--prem-dw: #efa6a0; /* double word + centre */ --prem-dw: #efa6a0; /* double word + centre */
--prem-tl: #4f8fd6; /* triple letter */ --prem-tl: #4f8fd6; /* triple letter */
@@ -62,12 +62,12 @@
--board-bg: #2a3330; --board-bg: #2a3330;
--cell-bg: #222a27; --cell-bg: #222a27;
--cell-line: #38433d; --cell-line: #56655c;
--tile-bg: #d9c79a; --tile-bg: #d9c79a;
--tile-edge: #b6a473; --tile-edge: #b6a473;
--tile-text: #20190d; --tile-text: #20190d;
--tile-pending: #f0d98f; --tile-pending: #d8b75e;
--tile-recent: #4a4636; --tile-recent: #7a6638;
--prem-tw: #b1493d; --prem-tw: #b1493d;
--prem-dw: #8c5450; --prem-dw: #8c5450;
--prem-tl: #34608f; --prem-tl: #34608f;
@@ -123,6 +123,9 @@ body {
line-height: 1.4; line-height: 1.4;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility; 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 */ /* never let the page scroll/zoom out from under the board */
overscroll-behavior: none; overscroll-behavior: none;
touch-action: manipulation; touch-action: manipulation;
@@ -130,12 +133,23 @@ body {
#app { #app {
height: 100%; 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 { button {
font: inherit; font: inherit;
color: inherit; color: inherit;
cursor: pointer; cursor: pointer;
user-select: none;
-webkit-user-select: none;
} }
.reduce-motion * { .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 type { Snippet } from 'svelte';
import { navigate } from '../lib/router.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> </script>
<header class="topbar"> <header class="nav" class:grow>
{#if back} <div class="bar">
<button class="icon" onclick={() => back && navigate(back)} aria-label="Back"></button> {#if back}
{:else} <button class="icon back" onclick={() => back && navigate(back)} aria-label="Back">
<span class="spacer"></span> <span class="chev"></span>
{/if} </button>
<h1>{title}</h1> {:else}
<div class="end">{#if menu}{@render menu()}{/if}</div> <span class="spacer"></span>
{/if}
<h1>{title}</h1>
<div class="end">{#if menu}{@render menu()}{/if}</div>
</div>
</header> </header>
<style> <style>
.topbar { /* 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; display: flex;
align-items: center; align-items: center;
gap: var(--gap); gap: var(--gap);
padding: 10px var(--pad); padding: 10px var(--pad);
background: var(--bg-elev);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 5;
} }
h1 { h1 {
font-size: 1.05rem; font-size: 1.05rem;
@@ -33,28 +48,40 @@
flex: 1; flex: 1;
text-align: center; text-align: center;
font-weight: 600; font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.icon, .icon,
.spacer, .spacer,
.end { .end {
width: 40px; min-width: 40px;
height: 32px; height: 36px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.end { .end {
width: auto; width: auto;
min-width: 40px; justify-content: flex-end;
} }
.icon { .back {
background: none; background: none;
border: none; border: none;
font-size: 1.1rem;
color: var(--text); color: var(--text);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
padding: 0 8px;
} }
.icon:hover { .back:hover {
background: var(--surface-2); background: var(--surface-2);
} }
/* A thin, compact "<" drawn from two borders — lighter than a glyph. */
.chev {
width: 11px;
height: 11px;
border-left: 2.5px solid currentColor;
border-bottom: 2.5px solid currentColor;
transform: rotate(45deg);
margin-left: 3px;
}
</style> </style>
+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 type { Premium } from '../lib/premiums';
import { tileValue } from '../lib/premiums'; import { tileValue } from '../lib/premiums';
import type { Variant } from '../lib/model'; import type { Variant } from '../lib/model';
import { bonusLabel, type BoardLabelMode } from '../lib/boardlabels';
import type { Locale } from '../lib/i18n/catalog';
let { let {
board, board,
premium, premium,
pending, pending,
recent, highlight,
flash,
centre, centre,
zoomed, zoomed,
variant, variant,
labelMode,
locale,
focus,
oncell, oncell,
ontogglezoom, ontogglezoom,
}: { }: {
board: (BoardCell | null)[][]; board: (BoardCell | null)[][];
premium: Premium[][]; premium: Premium[][];
pending: Map<string, { letter: string; blank: boolean }>; pending: Map<string, { letter: string; blank: boolean }>;
recent: Set<string>; highlight: Set<string>;
flash: boolean;
centre: { row: number; col: number }; centre: { row: number; col: number };
zoomed: boolean; zoomed: boolean;
variant: Variant; variant: Variant;
labelMode: BoardLabelMode;
locale: Locale;
focus: { row: number; col: number } | null;
oncell: (row: number, col: number) => void; oncell: (row: number, col: number) => void;
ontogglezoom: () => void; ontogglezoom: () => void;
} = $props(); } = $props();
const premClass: Record<Premium, string> = { const Z = 1.85;
'': '', const z = $derived(zoomed ? Z : 1);
TW: 'tw', const premClass: Record<Premium, string> = { '': '', TW: 'tw', DW: 'dw', TL: 'tl', DL: 'dl' };
DW: 'dw',
TL: 'tl',
DL: 'dl',
};
const premLabel: Record<Premium, string> = { '': '', TW: '3W', DW: '2W', TL: '3L', DL: '2L' };
// 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; let lastTap = 0;
function onTap(row: number, col: number) { function onTap(row: number, col: number) {
const now = Date.now(); const now = Date.now();
@@ -48,116 +73,81 @@
oncell(row, col); oncell(row, col);
} }
// Minimal pinch: two pointers spreading apart zoom in, pinching together zoom out. const key = (r: number, c: number) => `${r},${c}`;
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}`;
}
</script> </script>
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="viewport" class:zoomed bind:this={viewport}>
<div <div class="scaler" style="--z: {z};">
class="viewport" <div class="grid">
class:zoomed {#each board as rowCells, r (r)}
onpointerdown={onPointerDown} {#each rowCells as cell, c (c)}
onpointermove={onPointerMove} {@const p = pending.get(key(r, c))}
onpointerup={onPointerUp} {@const letter = cell?.letter ?? p?.letter ?? ''}
onpointercancel={onPointerUp} {@const blank = cell?.blank ?? p?.blank ?? false}
> {@const bl = letter ? null : bonusLabel(labelMode, premium[r][c], locale)}
<div class="grid" class:zoomed> <button
{#each board as rowCells, r (r)} class="cell {premClass[premium[r][c]]}"
{#each rowCells as cell, c (c)} class:filled={!!cell}
{@const p = pending.get(key(r, c))} class:pending={!!p && !cell}
{@const letter = cell?.letter ?? p?.letter ?? ''} class:hl={!!cell && highlight.has(key(r, c)) && !flash}
{@const blank = cell?.blank ?? p?.blank ?? false} class:flash={!!cell && flash && highlight.has(key(r, c))}
<button data-cell
class="cell {premClass[premium[r][c]]}" data-row={r}
class:filled={!!cell} data-col={c}
class:pending={!!p && !cell} onclick={() => onTap(r, c)}
class:recent={recent.has(key(r, c))} >
data-cell {#if letter}
data-row={r} <span class="letter">{letter}</span>
data-col={c} {#if !blank}<span class="val">{tileValue(variant, letter)}</span>{/if}
onclick={() => onTap(r, c)} {:else if r === centre.row && c === centre.col}
> <span class="star"></span>
{#if letter} {:else if bl?.kind === 'single'}
<span class="letter">{letter}</span> <span class="b1">{bl.text}</span>
{#if !blank}<span class="val">{tileValue(variant, letter)}</span>{/if} {:else if bl?.kind === 'split'}
{:else if r === centre.row && c === centre.col} <span class="bsplit"><span class="bt">{bl.top}</span><span class="bb">{bl.bottom}</span></span>
<span class="star"></span> {/if}
{:else if premLabel[premium[r][c]]} </button>
<span class="plabel">{premLabel[premium[r][c]]}</span> {/each}
{/if}
</button>
{/each} {/each}
{/each} </div>
</div> </div>
</div> </div>
<style> <style>
.viewport { .viewport {
width: 100%; width: 100%;
aspect-ratio: 1;
overflow: hidden;
background: var(--board-bg); background: var(--board-bg);
padding: 4px;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
touch-action: none;
} }
.viewport.zoomed { .viewport.zoomed {
overflow: auto; 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 { .grid {
display: grid; display: grid;
grid-template-columns: repeat(15, 1fr); grid-template-columns: repeat(15, 1fr);
gap: 2px; gap: 1px;
width: 100%; background: var(--cell-line);
} padding: 1px;
.grid.zoomed {
grid-template-columns: repeat(15, 2.6rem);
width: max-content;
} }
.cell { .cell {
position: relative; position: relative;
aspect-ratio: 1; aspect-ratio: 1;
border: none; border: none;
border-radius: 2px; border-radius: 1px;
background: var(--cell-bg); background: var(--cell-bg);
color: var(--prem-text); color: var(--prem-text);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.62rem;
padding: 0; padding: 0;
overflow: hidden; overflow: hidden;
font-size: 0;
} }
.cell.tw { .cell.tw {
background: var(--prem-tw); background: var(--prem-tw);
@@ -179,35 +169,76 @@
} }
.cell.pending { .cell.pending {
background: var(--tile-pending); background: var(--tile-pending);
outline: 2px solid var(--accent);
outline-offset: -2px;
} }
.cell.recent { .cell.hl {
box-shadow: background: var(--tile-recent);
inset 0 -2px 0 var(--tile-edge),
0 0 0 2px var(--warn);
} }
.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 { .letter {
font-size: 1.05em; position: absolute;
top: 5%;
left: 8%;
font-size: 4.2cqw;
font-weight: 700; font-weight: 700;
line-height: 1; line-height: 1;
} }
.grid:not(.zoomed) .letter {
font-size: 2.6vw;
}
.val { .val {
position: absolute; position: absolute;
right: 1px; right: 5%;
bottom: 0; bottom: 3%;
font-size: 0.55em; font-size: 2.4cqw;
font-weight: 600;
}
.plabel {
opacity: 0.85;
font-weight: 600; font-weight: 600;
} }
.star { .star {
font-size: 1.1em; position: absolute;
inset: 0;
display: grid;
place-items: center;
font-size: 3.6cqw;
opacity: 0.7; 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> </style>
+284 -201
View File
@@ -1,23 +1,26 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import Header from '../components/Header.svelte'; import Screen from '../components/Screen.svelte';
import Menu from '../components/Menu.svelte';
import TabBar from '../components/TabBar.svelte';
import HoldConfirm from '../components/HoldConfirm.svelte';
import Modal from '../components/Modal.svelte'; import Modal from '../components/Modal.svelte';
import Board from './Board.svelte'; import Board from './Board.svelte';
import Rack from './Rack.svelte'; import Rack from './Rack.svelte';
import MakeMove from './MakeMove.svelte';
import Controls from './Controls.svelte';
import Chat from './Chat.svelte'; import Chat from './Chat.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { app, handleError, showToast } from '../lib/app.svelte'; import { app, handleError, showToast } from '../lib/app.svelte';
import { GatewayError } from '../lib/client';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model'; import type { ChatMessage, Direction, EvalResult, MoveRecord, StateView } from '../lib/model';
import { lastPlayTiles, replay } from '../lib/board'; import { replay } from '../lib/board';
import { alphabet, centre, premiumGrid } from '../lib/premiums'; import { alphabet, centre, premiumGrid } from '../lib/premiums';
import { canCheckWord, sanitizeCheckWord } from '../lib/checkword';
import { import {
BLANK, BLANK,
direction,
newPlacement, newPlacement,
place, place,
placementFromHint,
rackView, rackView,
recallAt, recallAt,
reset, reset,
@@ -35,8 +38,9 @@
let busy = $state(false); let busy = $state(false);
let zoomed = $state(false); let zoomed = $state(false);
let selected = $state<number | null>(null); let selected = $state<number | null>(null);
let panel = $state<'none' | 'chat' | 'history'>('none'); let focus = $state<{ row: number; col: number } | null>(null);
let menuOpen = $state(false); let panel = $state<'none' | 'chat'>('none');
let historyOpen = $state(false);
let blankPrompt = $state<{ rackIndex: number; row: number; col: number } | null>(null); let blankPrompt = $state<{ rackIndex: number; row: number; col: number } | null>(null);
let exchangeOpen = $state(false); let exchangeOpen = $state(false);
let exchangeSel = $state<number[]>([]); let exchangeSel = $state<number[]>([]);
@@ -47,6 +51,9 @@
let messages = $state<ChatMessage[]>([]); let messages = $state<ChatMessage[]>([]);
let drag = $state<{ letter: string; blank: boolean; x: number; y: number } | null>(null); let drag = $state<{ letter: string; blank: boolean; x: number; y: number } | null>(null);
const checkedWords = new Map<string, boolean>();
let cooling = $state(false);
const variant = $derived(view?.game.variant ?? 'english'); const variant = $derived(view?.game.variant ?? 'english');
const board = $derived(replay(moves)); const board = $derived(replay(moves));
const premium = $derived(premiumGrid(variant)); const premium = $derived(premiumGrid(variant));
@@ -54,14 +61,25 @@
const pendingMap = $derived( const pendingMap = $derived(
new Map(placement.pending.map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }])), new Map(placement.pending.map((p) => [`${p.row},${p.col}`, { letter: p.letter, blank: p.blank }])),
); );
const recent = $derived(new Set(lastPlayTiles(moves).map((tt) => `${tt.row},${tt.col}`))); const lastPlay = $derived([...moves].reverse().find((m) => m.action === 'play') ?? null);
const slots = $derived(rackView(placement)); // Highlight the last word with a dark tile bg; while placing, only the pending tiles
const isMyTurn = $derived( // are highlighted. It flashes when the opponent just moved and it is now our turn.
!!view && view.game.status === 'active' && view.game.toMove === view.seat, 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 gameOver = $derived(!!view && view.game.status !== 'active');
const dir = $derived(dirOverride ?? direction(placement) ?? 'H'); const bagEmpty = $derived((view?.bagLen ?? 0) === 0);
const ambiguous = $derived(placement.pending.length === 1);
async function load() { async function load() {
try { try {
@@ -76,7 +94,6 @@
handleError(e); handleError(e);
} }
} }
async function loadChat() { async function loadChat() {
try { try {
messages = await gateway.chatList(id); messages = await gateway.chatList(id);
@@ -84,7 +101,6 @@
handleError(e); handleError(e);
} }
} }
onMount(load); onMount(load);
$effect(() => { $effect(() => {
@@ -99,7 +115,7 @@
return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches; return typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
} }
// --- tile placement: pointer drag + tap, both feeding the placement model --- // --- tile placement: pointer drag + tap ---
let downInfo: { index: number; x0: number; y0: number } | null = null; let downInfo: { index: number; x0: number; y0: number } | null = null;
let dragMoved = false; let dragMoved = false;
let swallowClick = false; let swallowClick = false;
@@ -117,7 +133,8 @@
dragMoved = true; dragMoved = true;
const slot = placement.rack[downInfo.index]; const slot = placement.rack[downInfo.index];
drag = { letter: slot, blank: slot === BLANK, x: e.clientX, y: e.clientY }; drag = { letter: slot, blank: slot === BLANK, x: e.clientX, y: e.clientY };
if (isCoarse() && !zoomed) zoomed = true; // auto zoom-in on touch placement // 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 }; if (drag) drag = { ...drag, x: e.clientX, y: e.clientY };
} }
@@ -154,14 +171,18 @@
return; return;
} }
if (selected != null) { 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); attemptPlace(selected, row, col);
selected = null; selected = null;
} }
} }
function attemptPlace(index: number, row: number, col: number) { function attemptPlace(index: number, row: number, col: number) {
if (board[row]?.[col]) return; if (board[row]?.[col]) return;
if (pendingMap.has(`${row},${col}`)) return; if (pendingMap.has(`${row},${col}`)) return;
focus = { row, col };
if (isCoarse() && !zoomed) zoomed = true;
if (placement.rack[index] === BLANK) { if (placement.rack[index] === BLANK) {
blankPrompt = { rackIndex: index, row, col }; blankPrompt = { rackIndex: index, row, col };
return; return;
@@ -169,7 +190,6 @@
placement = place(placement, index, row, col); placement = place(placement, index, row, col);
recompute(); recompute();
} }
function chooseBlank(letter: string) { function chooseBlank(letter: string) {
if (!blankPrompt) return; if (!blankPrompt) return;
placement = place(placement, blankPrompt.rackIndex, blankPrompt.row, blankPrompt.col, letter); placement = place(placement, blankPrompt.rackIndex, blankPrompt.row, blankPrompt.col, letter);
@@ -187,7 +207,7 @@
try { try {
preview = await gateway.evaluate(id, sub.dir, sub.tiles); preview = await gateway.evaluate(id, sub.dir, sub.tiles);
} catch { } catch {
/* preview is best-effort */ /* best-effort */
} }
}, 250); }, 250);
} }
@@ -198,6 +218,7 @@
busy = true; busy = true;
try { try {
await gateway.submitPlay(id, sub.dir, sub.tiles); await gateway.submitPlay(id, sub.dir, sub.tiles);
zoomed = false;
await load(); await load();
} catch (e) { } catch (e) {
handleError(e); handleError(e);
@@ -205,7 +226,6 @@
busy = false; busy = false;
} }
} }
function resetPlacement() { function resetPlacement() {
placement = reset(placement); placement = reset(placement);
preview = null; preview = null;
@@ -224,7 +244,6 @@
busy = false; busy = false;
} }
} }
async function doResign() { async function doResign() {
resignOpen = false; resignOpen = false;
busy = true; busy = true;
@@ -237,18 +256,24 @@
busy = false; busy = false;
} }
} }
async function doHint() { async function doHint() {
try { try {
const h = await gateway.hint(id); const h = await gateway.hint(id);
const word = h.move.words[0] ?? h.move.tiles.map((x) => x.letter).join(''); if (h.move.tiles.length && view) {
showToast(t('game.hintShown', { word, n: h.move.score })); placement = placementFromHint(h.move.tiles, view.rack);
if (view) view = { ...view, hintsRemaining: h.hintsRemaining }; if (isCoarse()) zoomed = true;
view = { ...view, hintsRemaining: h.hintsRemaining };
recompute();
}
} catch (e) { } catch (e) {
handleError(e); // The backend does not spend a hint when there is no move.
if (e instanceof GatewayError && e.code === 'no_hint_available') {
showToast(t('game.noHintOptions'), 'info');
} else {
handleError(e);
}
} }
} }
function shuffle() { function shuffle() {
if (placement.pending.length > 0) return; if (placement.pending.length > 0) return;
const r = [...placement.rack]; const r = [...placement.rack];
@@ -258,14 +283,7 @@
} }
placement = newPlacement(r); placement = newPlacement(r);
} }
function toggleDir() {
dirOverride = dir === 'H' ? 'V' : 'H';
recompute();
}
function openExchange() { function openExchange() {
menuOpen = false;
resetPlacement(); resetPlacement();
exchangeSel = []; exchangeSel = [];
exchangeOpen = true; exchangeOpen = true;
@@ -289,16 +307,27 @@
} }
function openCheck() { function openCheck() {
menuOpen = false;
checkWord = ''; checkWord = '';
checkResult = null; checkResult = null;
checkOpen = true; 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() { async function runCheck() {
const w = checkWord.trim(); if (!canCheck()) return;
if (!w) return; const w = checkWord.trim().toUpperCase();
cooling = true;
setTimeout(() => (cooling = false), 5000);
try { try {
checkResult = await gateway.checkWord(id, w); const r = await gateway.checkWord(id, w);
checkedWords.set(r.word.toUpperCase(), r.legal);
checkResult = r;
} catch (e) { } catch (e) {
handleError(e); handleError(e);
} }
@@ -308,28 +337,26 @@
try { try {
await gateway.complaint(id, checkResult.word, ''); await gateway.complaint(id, checkResult.word, '');
showToast(t('game.complaintSent')); showToast(t('game.complaintSent'));
checkOpen = false;
} catch (e) { } catch (e) {
handleError(e); handleError(e);
} }
} }
function openChat() { function openChat() {
menuOpen = false;
panel = 'chat'; panel = 'chat';
void loadChat(); void loadChat();
} }
async function sendChat(text: string) { async function sendChat(text: string) {
try { try {
const m = await gateway.chatPost(id, text); messages = [...messages, await gateway.chatPost(id, text)];
messages = [...messages, m];
} catch (e) { } catch (e) {
handleError(e); handleError(e);
} }
} }
async function nudge() { async function nudge() {
try { try {
const m = await gateway.nudge(id); messages = [...messages, await gateway.nudge(id)];
messages = [...messages, m];
} catch (e) { } catch (e) {
handleError(e); handleError(e);
} }
@@ -341,90 +368,127 @@
if (me?.isWinner) return t('game.won'); if (me?.isWinner) return t('game.won');
return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied'); return view.game.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied');
} }
const menuItems = $derived([
{ label: t('game.history'), onclick: () => (historyOpen = true) },
{ label: t('game.chat'), onclick: openChat },
{ label: t('game.checkWord'), onclick: openCheck },
{ label: t('game.dropGame'), onclick: () => (resignOpen = true) },
]);
</script> </script>
<Header title={t('app.title')} back="/"> <Screen title={t('app.title')} back="/" growNav scroll={!historyOpen}>
{#snippet menu()} {#snippet menu()}
<button class="icon" onclick={() => (menuOpen = !menuOpen)} aria-label="Menu"></button> <Menu items={menuItems} />
{#if menuOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="backdrop" onclick={() => (menuOpen = false)}></div>
<div class="dropdown">
<button onclick={() => { menuOpen = false; panel = 'history'; }}>{t('game.history')}</button>
<button onclick={openChat}>{t('game.chat')}</button>
<button onclick={openCheck}>{t('game.checkWord')}</button>
<button onclick={() => { menuOpen = false; resignOpen = true; }}>{t('game.dropGame')}</button>
</div>
{/if}
{/snippet} {/snippet}
</Header>
{#if view} {#if view}
<div class="scoreboard"> <div class="scoreboard">
{#each view.game.seats as s (s.seat)} {#each view.game.seats as s (s.seat)}
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}> <div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
<div class="nm">{s.accountId === app.session?.userId ? t('common.you') : s.displayName}</div> <div class="nm">{s.accountId === app.session?.userId ? t('common.you') : s.displayName}</div>
<div class="sc">{s.score}</div> <div class="sc">{s.score}</div>
</div> </div>
{/each} {/each}
</div>
<div class="boardwrap">
<Board
{board}
{premium}
pending={pendingMap}
{recent}
centre={ctr}
{zoomed}
{variant}
oncell={onCell}
ontogglezoom={() => (zoomed = !zoomed)}
/>
</div>
<div class="status">
<span>{t('game.bag', { n: view.bagLen })}</span>
{#if gameOver}
<strong class="over">{t('game.over')}{resultText()}</strong>
{:else}
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : t('game.waiting', { name: view.game.seats[view.game.toMove]?.displayName ?? '' })}</span>
{/if}
<span>{t('game.hints', { n: view.hintsRemaining })}</span>
</div>
{#if !gameOver}
<div class="rack-row">
<div class="rack-wrap">
<Rack {slots} {variant} {selected} ondown={onRackDown} />
</div>
{#if placement.pending.length > 0}
<MakeMove
label={t('game.makeMove')}
resetLabel={t('game.reset')}
onmake={commit}
onreset={resetPlacement}
/>
{/if}
</div> </div>
<Controls <div class="stage">
{preview} {#if historyOpen}
hints={view.hintsRemaining} <div class="history">
busy={busy || !isMyTurn} <ol>
{ambiguous} {#each moves as m, i (i)}
{dir} <li>
ondraw={openExchange} <span class="hp">{view.game.seats[m.player]?.displayName ?? m.player}</span>
onskip={doPass} <span class="ha">{m.action === 'play' ? m.words.join(', ') : m.action}</span>
onshuffle={shuffle} <span class="hs">{m.score}</span>
onhint={doHint} </li>
ondir={toggleDir} {/each}
/> {#if moves.length === 0}<li class="hempty"></li>{/if}
</ol>
</div>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="boardwrap"
class:slid={historyOpen}
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} {/if}
{:else}
<p class="loading">{t('common.loading')}</p> {#snippet tabbar()}
{/if} {#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} {#if drag}
<div class="ghost" style="left:{drag.x}px; top:{drag.y}px"> <div class="ghost" style="left:{drag.x}px; top:{drag.y}px">
@@ -460,8 +524,13 @@
{#if checkOpen} {#if checkOpen}
<Modal title={t('game.checkWord')} onclose={() => (checkOpen = false)}> <Modal title={t('game.checkWord')} onclose={() => (checkOpen = false)}>
<div class="check"> <div class="check">
<input placeholder={t('game.checkWordPrompt')} bind:value={checkWord} onkeydown={(e) => e.key === 'Enter' && runCheck()} /> <input
<button onclick={runCheck}>{t('game.checkWord')}</button> value={checkWord}
oninput={onCheckInput}
onkeydown={(e) => e.key === 'Enter' && runCheck()}
placeholder={t('game.checkWordPrompt')}
/>
<button onclick={runCheck} disabled={!canCheck()}>{t('game.check')}</button>
</div> </div>
{#if checkResult} {#if checkResult}
<p class:ok={checkResult.legal} class:bad={!checkResult.legal}> <p class:ok={checkResult.legal} class:bad={!checkResult.legal}>
@@ -489,27 +558,12 @@
</Modal> </Modal>
{/if} {/if}
{#if panel === 'history' && view}
<Modal title={t('game.history')} onclose={() => (panel = 'none')}>
<ol class="history">
{#each moves as m, i (i)}
<li>
<span class="hp">{view.game.seats[m.player]?.displayName ?? m.player}</span>
<span class="ha">{m.action === 'play' ? m.words.join(', ') : m.action}</span>
<span class="hs">{m.score}</span>
</li>
{/each}
</ol>
</Modal>
{/if}
<style> <style>
.scoreboard { .scoreboard {
display: flex; display: flex;
gap: 2px; gap: 2px;
padding: 6px var(--pad); padding: 6px var(--pad);
background: var(--bg-elev); background: var(--bg-elev);
border-bottom: 1px solid var(--border);
} }
.seat { .seat {
flex: 1; flex: 1;
@@ -535,14 +589,61 @@
font-weight: 700; font-weight: 700;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
.stage {
position: relative;
overflow: hidden;
}
.history {
position: absolute;
inset: 0 0 auto 0;
z-index: 2;
max-height: 60%;
overflow: auto;
background: var(--surface-2);
box-shadow: inset 0 -6px 10px -8px rgba(0, 0, 0, 0.5);
border-bottom: 1px solid var(--border);
}
.history ol {
margin: 0;
padding: 8px 14px;
list-style: decimal;
display: flex;
flex-direction: column;
gap: 4px;
}
.history li {
display: flex;
justify-content: space-between;
gap: 10px;
font-size: 0.9rem;
}
.hp {
color: var(--text-muted);
}
.ha {
flex: 1;
text-align: center;
}
.hs {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.hempty {
justify-content: center;
color: var(--text-muted);
}
.boardwrap { .boardwrap {
padding: 8px; padding: 6px;
transition: transform 0.3s ease;
}
.boardwrap.slid {
transform: translateY(62%);
} }
.status { .status {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0 var(--pad) 8px; padding: 2px var(--pad) 6px;
color: var(--text-muted); color: var(--text-muted);
font-size: 0.85rem; font-size: 0.85rem;
} }
@@ -553,60 +654,64 @@
.over { .over {
color: var(--accent); color: var(--accent);
} }
.scores {
font-weight: 600;
color: var(--ok);
min-width: 64px;
text-align: right;
}
.rack-row { .rack-row {
display: flex; display: flex;
gap: 8px; gap: 8px;
align-items: stretch; align-items: stretch;
padding: 0 var(--pad); padding: 0 var(--pad) 6px;
} }
.rack-wrap { .rack-wrap {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
} }
:global(.rack-row .wrap) { .flag {
display: flex; font-size: 1.6rem;
}
:global(.make) {
min-width: 56px;
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius-sm);
display: grid;
place-items: center;
}
.pop {
padding: 9px 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 { .loading {
text-align: center; text-align: center;
color: var(--text-muted); color: var(--text-muted);
padding: 40px; padding: 40px;
} }
.icon {
background: none;
border: none;
color: var(--text);
font-size: 1.3rem;
line-height: 1;
}
.backdrop {
position: fixed;
inset: 0;
z-index: 8;
}
.dropdown {
position: absolute;
right: 8px;
top: 44px;
z-index: 9;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
min-width: 160px;
overflow: hidden;
}
.dropdown button {
padding: 11px 14px;
text-align: left;
background: none;
border: none;
color: var(--text);
}
.dropdown button:hover {
background: var(--surface-2);
}
.ghost { .ghost {
position: fixed; position: fixed;
width: 40px; width: 40px;
@@ -677,6 +782,7 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
text-transform: uppercase;
} }
.check button { .check button {
padding: 10px 12px; padding: 10px 12px;
@@ -715,27 +821,4 @@
color: #fff !important; color: #fff !important;
border-color: var(--danger) !important; border-color: var(--danger) !important;
} }
.history {
margin: 0;
padding-left: 20px;
display: flex;
flex-direction: column;
gap: 4px;
}
.history li {
display: flex;
justify-content: space-between;
gap: 10px;
}
.hp {
color: var(--text-muted);
}
.ha {
flex: 1;
text-align: center;
}
.hs {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
</style> </style>
+26 -25
View File
@@ -15,45 +15,41 @@
selected: number | null; selected: number | null;
ondown: (e: PointerEvent, index: number) => void; ondown: (e: PointerEvent, index: number) => void;
} = $props(); } = $props();
// Used slots are hidden (the rack shifts left, freeing room on the right for the
// MakeMove control); the slot still exists in the model for per-tile recall.
const visible = $derived(slots.filter((s) => !s.used));
</script> </script>
<div class="rack"> <div class="rack">
{#each slots as slot (slot.index)} {#each visible as slot (slot.index)}
{#if slot.used} <button
<span class="slot empty"></span> class="tile"
{:else} class:selected={selected === slot.index}
<button data-rack-index={slot.index}
class="slot tile" onpointerdown={(e) => ondown(e, slot.index)}
class:selected={selected === slot.index} >
data-rack-index={slot.index} <span class="letter">{slot.letter === BLANK ? '' : slot.letter}</span>
onpointerdown={(e) => ondown(e, slot.index)} {#if slot.letter !== BLANK}<span class="val">{tileValue(variant, slot.letter)}</span>{/if}
> </button>
<span class="letter">{slot.letter === BLANK ? '' : slot.letter}</span>
{#if slot.letter !== BLANK}<span class="val">{tileValue(variant, slot.letter)}</span>{/if}
</button>
{/if}
{/each} {/each}
</div> </div>
<style> <style>
.rack { .rack {
display: grid; display: flex;
grid-template-columns: repeat(7, 1fr);
gap: 5px; gap: 5px;
} align-items: center;
.slot {
aspect-ratio: 1;
border-radius: 5px;
}
.empty {
background: var(--surface-2);
border: 1px dashed var(--border);
} }
.tile { .tile {
position: relative; position: relative;
flex: 0 0 auto;
width: min(12.5vw, 46px);
aspect-ratio: 1;
background: var(--tile-bg); background: var(--tile-bg);
color: var(--tile-text); color: var(--tile-text);
border: none; border: none;
border-radius: 5px;
box-shadow: inset 0 -3px 0 var(--tile-edge); box-shadow: inset 0 -3px 0 var(--tile-edge);
font-weight: 700; font-weight: 700;
font-size: 1.4rem; font-size: 1.4rem;
@@ -64,9 +60,14 @@
outline: 3px solid var(--accent); outline: 3px solid var(--accent);
outline-offset: -3px; outline-offset: -3px;
} }
.letter {
position: absolute;
top: 8%;
left: 14%;
}
.val { .val {
position: absolute; position: absolute;
right: 3px; right: 4px;
bottom: 1px; bottom: 1px;
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 600; font-weight: 600;
+15 -1
View File
@@ -10,6 +10,7 @@ import { navigate, router } from './router.svelte';
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte'; import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
import { applyReduceMotion, applyTheme, type ThemePref } from './theme'; import { applyReduceMotion, applyTheme, type ThemePref } from './theme';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session'; import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
import type { BoardLabelMode } from './boardlabels';
export interface Toast { export interface Toast {
kind: 'error' | 'info'; kind: 'error' | 'info';
@@ -25,6 +26,7 @@ export const app = $state<{
theme: ThemePref; theme: ThemePref;
locale: Locale; locale: Locale;
reduceMotion: boolean; reduceMotion: boolean;
boardLabels: BoardLabelMode;
localeLocked: boolean; localeLocked: boolean;
}>({ }>({
ready: false, ready: false,
@@ -35,6 +37,7 @@ export const app = $state<{
theme: 'auto', theme: 'auto',
locale: 'en', locale: 'en',
reduceMotion: false, reduceMotion: false,
boardLabels: 'beginner',
localeLocked: false, localeLocked: false,
}); });
@@ -101,6 +104,7 @@ export async function bootstrap(): Promise<void> {
const prefs = await loadPrefs(); const prefs = await loadPrefs();
app.theme = prefs.theme ?? 'auto'; app.theme = prefs.theme ?? 'auto';
app.reduceMotion = prefs.reduceMotion ?? false; app.reduceMotion = prefs.reduceMotion ?? false;
app.boardLabels = prefs.boardLabels ?? 'beginner';
applyTheme(app.theme); applyTheme(app.theme);
applyReduceMotion(app.reduceMotion); applyReduceMotion(app.reduceMotion);
if (prefs.locale) { if (prefs.locale) {
@@ -163,7 +167,12 @@ export async function logout(): Promise<void> {
} }
function persistPrefs(): void { function persistPrefs(): void {
void savePrefs({ theme: app.theme, locale: app.locale, reduceMotion: app.reduceMotion }); void savePrefs({
theme: app.theme,
locale: app.locale,
reduceMotion: app.reduceMotion,
boardLabels: app.boardLabels,
});
} }
export function setTheme(theme: ThemePref): void { export function setTheme(theme: ThemePref): void {
@@ -184,3 +193,8 @@ export function setReduceMotion(on: boolean): void {
applyReduceMotion(on); applyReduceMotion(on);
persistPrefs(); persistPrefs();
} }
export function setBoardLabels(mode: BoardLabelMode): void {
app.boardLabels = mode;
persistPrefs();
}
+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.wordIllegal': '“{word}” is not valid',
'game.complain': 'Disagree', 'game.complain': 'Disagree',
'game.complaintSent': 'Thanks, sent for review.', 'game.complaintSent': 'Thanks, sent for review.',
'game.confirm': 'Ok',
'game.check': 'Check',
'game.checkWait': 'Please wait a moment.',
'game.noHintOptions': 'No options with your letters.',
'game.scores': 'Scores: {n}',
'result.victory': 'Victory',
'result.defeat': 'Defeat',
'result.draw': 'Draw',
'result.place2': 'II place',
'result.place3': 'III place',
'result.place4': 'IV place',
'result.yourMove': 'Your move',
'result.oppMove': "Opponent's move",
'chat.placeholder': 'Quick message…', 'chat.placeholder': 'Quick message…',
'chat.send': 'Send', 'chat.send': 'Send',
@@ -96,6 +110,11 @@ export const en = {
'settings.themeLight': 'Light', 'settings.themeLight': 'Light',
'settings.themeDark': 'Dark', 'settings.themeDark': 'Dark',
'settings.language': 'Interface language', 'settings.language': 'Interface language',
'settings.boardStyle': 'Board style',
'settings.boardLabels': 'Bonus labels',
'settings.labelsBeginner': 'Beginner',
'settings.labelsClassic': 'Classic',
'settings.labelsNone': 'None',
'settings.reduceMotion': 'Reduce motion', 'settings.reduceMotion': 'Reduce motion',
'about.title': 'About', 'about.title': 'About',
@@ -108,6 +127,7 @@ export const en = {
'error.not_your_turn': "It is not your turn.", 'error.not_your_turn': "It is not your turn.",
'error.illegal_play': 'That is not a legal play.', 'error.illegal_play': 'That is not a legal play.',
'error.hint_unavailable': 'No hints available.', 'error.hint_unavailable': 'No hints available.',
'error.no_hint_available': 'No options with your letters.',
'error.chat_rejected': 'Message rejected (too long or contains contact info).', 'error.chat_rejected': 'Message rejected (too long or contains contact info).',
'error.game_finished': 'This game is finished.', 'error.game_finished': 'This game is finished.',
'error.not_a_player': 'You are not a player in this game.', 'error.not_a_player': 'You are not a player in this game.',
+20
View File
@@ -77,6 +77,20 @@ export const ru: Record<MessageKey, string> = {
'game.wordIllegal': '«{word}» недопустимо', 'game.wordIllegal': '«{word}» недопустимо',
'game.complain': 'Не согласен', 'game.complain': 'Не согласен',
'game.complaintSent': 'Спасибо, отправлено на проверку.', 'game.complaintSent': 'Спасибо, отправлено на проверку.',
'game.confirm': 'Да',
'game.check': 'Проверить',
'game.checkWait': 'Секунду, пожалуйста.',
'game.noHintOptions': 'Нет вариантов с вашим набором.',
'game.scores': 'Очков: {n}',
'result.victory': 'Победа',
'result.defeat': 'Поражение',
'result.draw': 'Ничья',
'result.place2': 'II место',
'result.place3': 'III место',
'result.place4': 'IV место',
'result.yourMove': 'Ваш ход',
'result.oppMove': 'Ход соперника',
'chat.placeholder': 'Короткое сообщение…', 'chat.placeholder': 'Короткое сообщение…',
'chat.send': 'Отправить', 'chat.send': 'Отправить',
@@ -97,6 +111,11 @@ export const ru: Record<MessageKey, string> = {
'settings.themeLight': 'Светлая', 'settings.themeLight': 'Светлая',
'settings.themeDark': 'Тёмная', 'settings.themeDark': 'Тёмная',
'settings.language': 'Язык интерфейса', 'settings.language': 'Язык интерфейса',
'settings.boardStyle': 'Стиль доски',
'settings.boardLabels': 'Подписи бонусов',
'settings.labelsBeginner': 'Новичок',
'settings.labelsClassic': 'Классика',
'settings.labelsNone': 'Без текста',
'settings.reduceMotion': 'Меньше анимаций', 'settings.reduceMotion': 'Меньше анимаций',
'about.title': 'О программе', 'about.title': 'О программе',
@@ -109,6 +128,7 @@ export const ru: Record<MessageKey, string> = {
'error.not_your_turn': 'Сейчас не ваш ход.', 'error.not_your_turn': 'Сейчас не ваш ход.',
'error.illegal_play': 'Это недопустимый ход.', 'error.illegal_play': 'Это недопустимый ход.',
'error.hint_unavailable': 'Подсказки недоступны.', 'error.hint_unavailable': 'Подсказки недоступны.',
'error.no_hint_available': 'Нет вариантов с вашим набором.',
'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).', 'error.chat_rejected': 'Сообщение отклонено (слишком длинное или содержит контакты).',
'error.game_finished': 'Эта игра уже завершена.', 'error.game_finished': 'Эта игра уже завершена.',
'error.not_a_player': 'Вы не участник этой игры.', 'error.not_a_player': 'Вы не участник этой игры.',
+60
View File
@@ -1,11 +1,15 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { import {
BLANK, BLANK,
cellOccupied,
direction, direction,
isBlankSlot,
newPlacement, newPlacement,
place, place,
placementFromHint,
rackView, rackView,
recallAt, recallAt,
recallIndex,
reset, reset,
toSubmit, toSubmit,
} from './placement'; } from './placement';
@@ -61,4 +65,60 @@ describe('placement state machine', () => {
expect(toSubmit(place(newPlacement(rack), 0, 7, 7), 'V')?.dir).toBe('V'); expect(toSubmit(place(newPlacement(rack), 0, 7, 7), 'V')?.dir).toBe('V');
expect(toSubmit(newPlacement(rack))).toBeNull(); 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 // payload. It is board-agnostic (the gateway/engine does full legality validation at
// submit), which keeps it trivially unit-testable. // submit), which keeps it trivially unit-testable.
import type { Direction } from './model'; import type { Direction, Tile } from './model';
import type { PlacedTile } from './client'; import type { PlacedTile } from './client';
export interface PendingTile { export interface PendingTile {
@@ -36,6 +36,25 @@ export function newPlacement(rack: string[]): Placement {
return { rack: [...rack], pending: [] }; return { rack: [...rack], pending: [] };
} }
/**
* placementFromHint turns a hint move's tiles into a pending placement by matching each
* tile to a rack slot (a blank "?" for blank tiles, else the matching letter), so the
* player sees the suggested move laid out and decides whether to commit it.
*/
export function placementFromHint(tiles: Tile[], rack: string[]): Placement {
const used = new Set<number>();
const pending: PendingTile[] = [];
const take = (pred: (letter: string, i: number) => boolean) => rack.findIndex((l, i) => !used.has(i) && pred(l, i));
for (const t of tiles) {
let idx = t.blank ? take((l) => l === BLANK) : take((l) => l === t.letter.toUpperCase());
if (idx < 0) idx = take((l) => l === BLANK); // fall back to a blank
if (idx < 0) continue;
used.add(idx);
pending.push({ rackIndex: idx, row: t.row, col: t.col, letter: t.letter.toUpperCase(), blank: rack[idx] === BLANK });
}
return { rack: [...rack], pending };
}
function usedIndexes(p: Placement): Set<number> { function usedIndexes(p: Placement): Set<number> {
return new Set(p.pending.map((t) => t.rackIndex)); return new Set(p.pending.map((t) => t.rackIndex));
} }
+57
View File
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { resultBadge } from './result';
import type { GameView, Seat } from './model';
const seat = (s: number, accountId: string, score: number, isWinner = false): Seat => ({
seat: s,
accountId,
displayName: accountId,
score,
hintsUsed: 0,
isWinner,
});
function game(seats: Seat[], status = 'finished', toMove = 0): GameView {
return {
id: 'g',
variant: 'english',
dictVersion: 'v1',
status,
players: seats.length,
toMove,
turnTimeoutSecs: 0,
moveCount: 0,
endReason: '',
seats,
};
}
describe('resultBadge', () => {
it('active: your move vs opponent', () => {
const g = game([seat(0, 'me', 5), seat(1, 'a', 3)], 'active', 0);
expect(resultBadge(g, 'me')).toEqual({ key: 'result.yourMove', emoji: '🟢' });
expect(resultBadge({ ...g, toMove: 1 }, 'me').key).toBe('result.oppMove');
});
it('finished two-player: victory / defeat / draw', () => {
expect(resultBadge(game([seat(0, 'me', 300, true), seat(1, 'a', 200)]), 'me')).toEqual({
key: 'result.victory',
emoji: '🏆',
});
expect(resultBadge(game([seat(0, 'me', 200), seat(1, 'a', 300, true)]), 'me')).toEqual({
key: 'result.defeat',
emoji: '🥈',
});
expect(resultBadge(game([seat(0, 'me', 200), seat(1, 'a', 200)]), 'me')).toEqual({
key: 'result.draw',
emoji: '🏅',
});
});
it('finished four-player: places by score', () => {
const last = game([seat(0, 'me', 100), seat(1, 'a', 400, true), seat(2, 'b', 300), seat(3, 'c', 200)]);
expect(resultBadge(last, 'me')).toEqual({ key: 'result.place4', emoji: '🏅' });
const second = game([seat(0, 'me', 300), seat(1, 'a', 400, true), seat(2, 'b', 200), seat(3, 'c', 100)]);
expect(resultBadge(second, 'me')).toEqual({ key: 'result.place2', emoji: '🥈' });
});
});
+30
View File
@@ -0,0 +1,30 @@
// Pure mapping from a game view (for the viewer) to a status/result badge: a label key
// and a place-based emoji. Used by the lobby lists.
import type { GameView } from './model';
import type { MessageKey } from './i18n/catalog';
export interface ResultBadge {
key: MessageKey;
emoji: string;
}
export function resultBadge(game: GameView, myId: string): ResultBadge {
const me = game.seats.find((s) => s.accountId === myId);
if (game.status === 'active') {
return game.toMove === me?.seat
? { key: 'result.yourMove', emoji: '🟢' }
: { key: 'result.oppMove', emoji: '⏳' };
}
if (me?.isWinner) return { key: 'result.victory', emoji: '🏆' };
if (!game.seats.some((s) => s.isWinner)) return { key: 'result.draw', emoji: '🏅' };
// Someone else won — place the viewer by score (1 + number of higher scores).
const rank = 1 + game.seats.filter((s) => s.score > (me?.score ?? 0)).length;
if (rank <= 1) return { key: 'result.victory', emoji: '🏆' };
if (rank === 2) return game.players === 2 ? { key: 'result.defeat', emoji: '🥈' } : { key: 'result.place2', emoji: '🥈' };
if (rank === 3) return { key: 'result.place3', emoji: '🥉' };
return { key: 'result.place4', emoji: '🏅' };
}
+2
View File
@@ -6,6 +6,7 @@
import type { Session } from './model'; import type { Session } from './model';
import type { ThemePref } from './theme'; import type { ThemePref } from './theme';
import type { Locale } from './i18n/catalog'; import type { Locale } from './i18n/catalog';
import type { BoardLabelMode } from './boardlabels';
const DB_NAME = 'scrabble'; const DB_NAME = 'scrabble';
const STORE = 'kv'; const STORE = 'kv';
@@ -122,6 +123,7 @@ export interface Prefs {
theme: ThemePref; theme: ThemePref;
locale: Locale; locale: Locale;
reduceMotion: boolean; reduceMotion: boolean;
boardLabels: BoardLabelMode;
} }
export async function loadPrefs(): Promise<Partial<Prefs>> { export async function loadPrefs(): Promise<Partial<Prefs>> {
+8 -7
View File
@@ -1,16 +1,17 @@
<script lang="ts"> <script lang="ts">
import Header from '../components/Header.svelte'; import Screen from '../components/Screen.svelte';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
const version = '0.7.0'; const version = '0.7.0';
</script> </script>
<Header title={t('about.title')} back="/" /> <Screen title={t('about.title')} back="/">
<main class="page"> <div class="page">
<h2>{t('app.title')}</h2> <h2>{t('app.title')}</h2>
<p>{t('about.description')}</p> <p>{t('about.description')}</p>
<p class="muted">{t('about.version', { v: version })}</p> <p class="muted">{t('about.version', { v: version })}</p>
</main> </div>
</Screen>
<style> <style>
.page { .page {
+59 -143
View File
@@ -1,14 +1,16 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Header from '../components/Header.svelte'; import Screen from '../components/Screen.svelte';
import Menu from '../components/Menu.svelte';
import TabBar from '../components/TabBar.svelte';
import { app, handleError, showToast } from '../lib/app.svelte'; import { app, handleError, showToast } from '../lib/app.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { navigate } from '../lib/router.svelte'; import { navigate } from '../lib/router.svelte';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import { resultBadge } from '../lib/result';
import type { GameView } from '../lib/model'; import type { GameView } from '../lib/model';
let games = $state<GameView[]>([]); let games = $state<GameView[]>([]);
let menuOpen = $state(false);
async function load() { async function load() {
try { try {
@@ -19,8 +21,6 @@
} }
onMount(load); onMount(load);
// Refresh the lists when a live event lands (move / your-turn / match-found).
$effect(() => { $effect(() => {
if (app.lastEvent) void load(); if (app.lastEvent) void load();
}); });
@@ -29,95 +29,72 @@
const active = $derived(games.filter((g) => g.status === 'active')); const active = $derived(games.filter((g) => g.status === 'active'));
const finished = $derived(games.filter((g) => g.status !== 'active')); const finished = $derived(games.filter((g) => g.status !== 'active'));
function mySeat(g: GameView) {
return g.seats.find((s) => s.accountId === myId);
}
function opponents(g: GameView): string { function opponents(g: GameView): string {
return g.seats return g.seats
.filter((s) => s.accountId !== myId) .filter((s) => s.accountId !== myId)
.map((s) => s.displayName) .map((s) => s.displayName)
.join(', '); .join(', ');
} }
function subtitle(g: GameView): string {
const me = mySeat(g);
if (g.status === 'active') {
return g.toMove === me?.seat ? t('lobby.yourTurn') : t('lobby.theirTurn');
}
if (me?.isWinner) return t('game.won');
return g.seats.some((s) => s.isWinner) ? t('game.lost') : t('game.tied');
}
function scoreline(g: GameView): string { function scoreline(g: GameView): string {
const me = mySeat(g); const me = g.seats.find((s) => s.accountId === myId);
const opp = g.seats.filter((s) => s.accountId !== myId).map((s) => s.score); const opp = g.seats.filter((s) => s.accountId !== myId).map((s) => s.score);
return `${me?.score ?? 0} : ${opp.join(', ')}`; return `${me?.score ?? 0} : ${opp.join(', ')}`;
} }
function go(path: string) { const menuItems = $derived([
menuOpen = false; { label: t('lobby.profile'), onclick: () => navigate('/profile') },
navigate(path); { label: t('lobby.settings'), onclick: () => navigate('/settings') },
} { label: t('lobby.about'), onclick: () => navigate('/about') },
]);
</script> </script>
<Header title={app.profile?.displayName ?? t('app.title')}> <Screen title={app.profile?.displayName ?? t('app.title')}>
{#snippet menu()} {#snippet menu()}
<button class="icon" onclick={() => (menuOpen = !menuOpen)} aria-label="Menu"></button> <Menu items={menuItems} />
{#if menuOpen}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="backdrop" onclick={() => (menuOpen = false)}></div>
<div class="dropdown">
<button onclick={() => go('/profile')}>{t('lobby.profile')}</button>
<button onclick={() => go('/settings')}>{t('lobby.settings')}</button>
<button onclick={() => go('/about')}>{t('lobby.about')}</button>
</div>
{/if}
{/snippet} {/snippet}
</Header>
<main class="lobby"> <div class="lobby">
<section> {#each [{ h: 'lobby.activeGames', list: active }, { h: 'lobby.finishedGames', list: finished }] as group (group.h)}
<h2>{t('lobby.activeGames')}</h2> {#if group.list.length}
{#if active.length === 0} <section>
<h2>{t(group.h as 'lobby.activeGames')}</h2>
{#each group.list as g (g.id)}
{@const b = resultBadge(g, myId)}
<button class="row" onclick={() => navigate(`/game/${g.id}`)}>
<span class="info">
<span class="who">{opponents(g) || '—'}</span>
<span class="sub">{t(b.key)} · {scoreline(g)}</span>
</span>
<span class="emoji">{b.emoji}</span>
</button>
{/each}
</section>
{/if}
{/each}
{#if !active.length && !finished.length}
<p class="empty">{t('lobby.noActive')}</p> <p class="empty">{t('lobby.noActive')}</p>
{/if} {/if}
{#each active as g (g.id)} </div>
<button class="row" onclick={() => navigate(`/game/${g.id}`)}>
<span class="who">{opponents(g) || '—'}</span>
<span class="meta">
<span class="sub" class:turn={g.toMove === mySeat(g)?.seat}>{subtitle(g)}</span>
<span class="score">{scoreline(g)}</span>
</span>
</button>
{/each}
</section>
<section> {#snippet tabbar()}
<h2>{t('lobby.finishedGames')}</h2> <TabBar>
{#if finished.length === 0} <button class="tab" onclick={() => navigate('/new')}>
<p class="empty">{t('lobby.noFinished')}</p> <span class="sq">🎲</span><span class="lbl">{t('lobby.new')}</span>
{/if}
{#each finished as g (g.id)}
<button class="row" onclick={() => navigate(`/game/${g.id}`)}>
<span class="who">{opponents(g) || '—'}</span>
<span class="meta">
<span class="sub">{subtitle(g)}</span>
<span class="score">{scoreline(g)}</span>
</span>
</button> </button>
{/each} <button class="tab" onclick={() => showToast(t('lobby.soon'))}>
</section> <span class="sq">⚔️</span><span class="lbl">{t('lobby.tournaments')}</span>
</main> </button>
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>
<nav class="tabs"> <span class="sq">🧮</span><span class="lbl">{t('lobby.stats')}</span>
<button class="tab primary" onclick={() => navigate('/new')}>{t('lobby.new')}</button> </button>
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>{t('lobby.stats')}</button> </TabBar>
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>{t('lobby.tournaments')}</button> {/snippet}
</nav> </Screen>
<style> <style>
.lobby { .lobby {
padding: var(--pad); padding: var(--pad);
padding-bottom: 84px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 18px; gap: 18px;
@@ -147,88 +124,27 @@
background: var(--surface); background: var(--surface);
color: var(--text); color: var(--text);
border-radius: var(--radius); border-radius: var(--radius);
user-select: none;
}
.info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
} }
.who { .who {
font-weight: 600; font-weight: 600;
} white-space: nowrap;
.meta { overflow: hidden;
display: flex; text-overflow: ellipsis;
flex-direction: column;
align-items: flex-end;
gap: 2px;
} }
.sub { .sub {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--text-muted); color: var(--text-muted);
} }
.sub.turn { .emoji {
color: var(--accent); font-size: 1.8rem;
font-weight: 600;
}
.score {
font-variant-numeric: tabular-nums;
color: var(--text-muted);
font-size: 0.85rem;
}
.tabs {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 8px;
padding: 10px var(--pad);
background: var(--bg-elev);
border-top: 1px solid var(--border);
}
.tab {
flex: 1;
padding: 12px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
font-weight: 600;
}
.tab.primary {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
.icon {
background: none;
border: none;
color: var(--text);
font-size: 1.3rem;
line-height: 1; line-height: 1;
} flex: 0 0 auto;
.backdrop {
position: fixed;
inset: 0;
z-index: 8;
}
.dropdown {
position: absolute;
right: 8px;
top: 44px;
z-index: 9;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
min-width: 160px;
overflow: hidden;
}
.dropdown button {
padding: 11px 14px;
text-align: left;
background: none;
border: none;
color: var(--text);
}
.dropdown button:hover {
background: var(--surface-2);
} }
</style> </style>
+20 -20
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import Header from '../components/Header.svelte'; import Screen from '../components/Screen.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { handleError } from '../lib/app.svelte'; import { handleError } from '../lib/app.svelte';
import { navigate } from '../lib/router.svelte'; import { navigate } from '../lib/router.svelte';
@@ -31,8 +31,6 @@
navigate(`/game/${r.game.id}`); navigate(`/game/${r.game.id}`);
return; return;
} }
// The match also arrives via the live stream (handled in app), but poll as a
// fallback for a client that is not currently streaming.
poll = setInterval(async () => { poll = setInterval(async () => {
try { try {
const p = await gateway.lobbyPoll(); const p = await gateway.lobbyPoll();
@@ -53,23 +51,24 @@
onDestroy(stop); onDestroy(stop);
</script> </script>
<Header title={t('new.title')} back="/" /> <Screen title={t('new.title')} back="/">
<main class="page"> <div class="page">
{#if searching} {#if searching}
<div class="searching"> <div class="searching">
<div class="spinner"></div> <div class="spinner"></div>
<p>{t('new.searching')}</p> <p>{t('new.searching')}</p>
<button class="cancel" onclick={() => { stop(); navigate('/'); }}>{t('common.cancel')}</button> <button class="cancel" onclick={() => { stop(); navigate('/'); }}>{t('common.cancel')}</button>
</div> </div>
{:else} {:else}
<p class="subtitle">{t('new.subtitle')}</p> <p class="subtitle">{t('new.subtitle')}</p>
<div class="variants"> <div class="variants">
{#each variants as v (v.id)} {#each variants as v (v.id)}
<button class="variant" onclick={() => find(v.id)}>{t(v.label)}</button> <button class="variant" onclick={() => find(v.id)}>{t(v.label)}</button>
{/each} {/each}
</div> </div>
{/if} {/if}
</main> </div>
</Screen>
<style> <style>
.page { .page {
@@ -92,6 +91,7 @@
border-radius: var(--radius); border-radius: var(--radius);
font-size: 1.05rem; font-size: 1.05rem;
font-weight: 600; font-weight: 600;
user-select: none;
} }
.searching { .searching {
display: grid; display: grid;
+19 -18
View File
@@ -1,26 +1,27 @@
<script lang="ts"> <script lang="ts">
import Header from '../components/Header.svelte'; import Screen from '../components/Screen.svelte';
import { app, logout } from '../lib/app.svelte'; import { app, logout } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
</script> </script>
<Header title={t('profile.title')} back="/" /> <Screen title={t('profile.title')} back="/">
<main class="page"> <div class="page">
{#if app.profile} {#if app.profile}
<div class="name">{app.profile.displayName}</div> <div class="name">{app.profile.displayName}</div>
{#if app.profile.isGuest}<span class="badge">{t('profile.guest')}</span>{/if} {#if app.profile.isGuest}<span class="badge">{t('profile.guest')}</span>{/if}
<dl> <dl>
<dt>{t('profile.language')}</dt> <dt>{t('profile.language')}</dt>
<dd>{app.profile.preferredLanguage}</dd> <dd>{app.profile.preferredLanguage}</dd>
<dt>{t('profile.timezone')}</dt> <dt>{t('profile.timezone')}</dt>
<dd>{app.profile.timeZone}</dd> <dd>{app.profile.timeZone}</dd>
<dt>{t('profile.hintBalance')}</dt> <dt>{t('profile.hintBalance')}</dt>
<dd>{app.profile.hintBalance}</dd> <dd>{app.profile.hintBalance}</dd>
</dl> </dl>
<p class="muted">{t('profile.readonly')}</p> <p class="muted">{t('profile.readonly')}</p>
<button class="logout" onclick={() => logout()}>{t('login.title')} / logout</button> <button class="logout" onclick={() => logout()}>{t('login.title')} / logout</button>
{/if} {/if}
</main> </div>
</Screen>
<style> <style>
.page { .page {
+67 -35
View File
@@ -1,8 +1,15 @@
<script lang="ts"> <script lang="ts">
import Header from '../components/Header.svelte'; import Screen from '../components/Screen.svelte';
import { app, setLocalePref, setReduceMotion, setTheme } from '../lib/app.svelte'; import {
app,
setBoardLabels,
setLocalePref,
setReduceMotion,
setTheme,
} from '../lib/app.svelte';
import { t, type Locale, type MessageKey } from '../lib/i18n/index.svelte'; import { t, type Locale, type MessageKey } from '../lib/i18n/index.svelte';
import type { ThemePref } from '../lib/theme'; import type { ThemePref } from '../lib/theme';
import type { BoardLabelMode } from '../lib/boardlabels';
const themes: ThemePref[] = ['auto', 'light', 'dark']; const themes: ThemePref[] = ['auto', 'light', 'dark'];
const themeLabel: Record<ThemePref, MessageKey> = { const themeLabel: Record<ThemePref, MessageKey> = {
@@ -11,43 +18,62 @@
dark: 'settings.themeDark', dark: 'settings.themeDark',
}; };
const locales: Locale[] = ['en', 'ru']; const locales: Locale[] = ['en', 'ru'];
const labelModes: BoardLabelMode[] = ['beginner', 'classic', 'none'];
const labelModeKey: Record<BoardLabelMode, MessageKey> = {
beginner: 'settings.labelsBeginner',
classic: 'settings.labelsClassic',
none: 'settings.labelsNone',
};
</script> </script>
<Header title={t('settings.title')} back="/" /> <Screen title={t('settings.title')} back="/">
<main class="page"> <div class="page">
<section> <section>
<h3>{t('settings.theme')}</h3> <h3>{t('settings.theme')}</h3>
<div class="seg"> <div class="seg">
{#each themes as th (th)} {#each themes as th (th)}
<button class="opt" class:active={app.theme === th} onclick={() => setTheme(th)}> <button class="opt" class:active={app.theme === th} onclick={() => setTheme(th)}>
{t(themeLabel[th])} {t(themeLabel[th])}
</button> </button>
{/each} {/each}
</div> </div>
</section> </section>
<section> <section>
<h3>{t('settings.language')}</h3> <h3>{t('settings.language')}</h3>
<div class="seg"> <div class="seg">
{#each locales as lc (lc)} {#each locales as lc (lc)}
<button class="opt" class:active={app.locale === lc} onclick={() => setLocalePref(lc)}> <button class="opt" class:active={app.locale === lc} onclick={() => setLocalePref(lc)}>
{t(lc === 'en' ? 'lang.en' : 'lang.ru')} {t(lc === 'en' ? 'lang.en' : 'lang.ru')}
</button> </button>
{/each} {/each}
</div> </div>
</section> </section>
<section> <section>
<label class="row"> <h3>{t('settings.boardStyle')}</h3>
<span>{t('settings.reduceMotion')}</span> <div class="sub">{t('settings.boardLabels')}</div>
<input <div class="seg">
type="checkbox" {#each labelModes as lm (lm)}
checked={app.reduceMotion} <button class="opt" class:active={app.boardLabels === lm} onclick={() => setBoardLabels(lm)}>
onchange={(e) => setReduceMotion(e.currentTarget.checked)} {t(labelModeKey[lm])}
/> </button>
</label> {/each}
</section> </div>
</main> </section>
<section>
<label class="row">
<span>{t('settings.reduceMotion')}</span>
<input
type="checkbox"
checked={app.reduceMotion}
onchange={(e) => setReduceMotion(e.currentTarget.checked)}
/>
</label>
</section>
</div>
</Screen>
<style> <style>
.page { .page {
@@ -61,6 +87,11 @@
font-size: 0.95rem; font-size: 0.95rem;
color: var(--text-muted); color: var(--text-muted);
} }
.sub {
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 6px;
}
.seg { .seg {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -72,6 +103,7 @@
background: var(--surface); background: var(--surface);
color: var(--text); color: var(--text);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
user-select: none;
} }
.opt.active { .opt.active {
background: var(--accent); background: var(--accent);