Merge pull request 'Stage 7 polish: UI/UX refinements (shell, board zoom, hint-on-board, history, ...)' (#8) from feature/stage-7-ui-polish into master
This commit was merged in pull request #8.
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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-вход или как
|
||||||
|
|||||||
@@ -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 3–4-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).
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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
@@ -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 * {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
<div class="bar">
|
||||||
{#if back}
|
{#if back}
|
||||||
<button class="icon" onclick={() => back && navigate(back)} aria-label="Back">◄</button>
|
<button class="icon back" onclick={() => back && navigate(back)} aria-label="Back">
|
||||||
|
<span class="chev"></span>
|
||||||
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
{/if}
|
{/if}
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
<div class="end">{#if menu}{@render menu()}{/if}</div>
|
<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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
+121
-90
@@ -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,62 +73,24 @@
|
|||||||
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
|
|
||||||
onpointerdown={onPointerDown}
|
|
||||||
onpointermove={onPointerMove}
|
|
||||||
onpointerup={onPointerUp}
|
|
||||||
onpointercancel={onPointerUp}
|
|
||||||
>
|
|
||||||
<div class="grid" class:zoomed>
|
|
||||||
{#each board as rowCells, r (r)}
|
{#each board as rowCells, r (r)}
|
||||||
{#each rowCells as cell, c (c)}
|
{#each rowCells as cell, c (c)}
|
||||||
{@const p = pending.get(key(r, c))}
|
{@const p = pending.get(key(r, c))}
|
||||||
{@const letter = cell?.letter ?? p?.letter ?? ''}
|
{@const letter = cell?.letter ?? p?.letter ?? ''}
|
||||||
{@const blank = cell?.blank ?? p?.blank ?? false}
|
{@const blank = cell?.blank ?? p?.blank ?? false}
|
||||||
|
{@const bl = letter ? null : bonusLabel(labelMode, premium[r][c], locale)}
|
||||||
<button
|
<button
|
||||||
class="cell {premClass[premium[r][c]]}"
|
class="cell {premClass[premium[r][c]]}"
|
||||||
class:filled={!!cell}
|
class:filled={!!cell}
|
||||||
class:pending={!!p && !cell}
|
class:pending={!!p && !cell}
|
||||||
class:recent={recent.has(key(r, c))}
|
class:hl={!!cell && highlight.has(key(r, c)) && !flash}
|
||||||
|
class:flash={!!cell && flash && highlight.has(key(r, c))}
|
||||||
data-cell
|
data-cell
|
||||||
data-row={r}
|
data-row={r}
|
||||||
data-col={c}
|
data-col={c}
|
||||||
@@ -114,50 +101,53 @@
|
|||||||
{#if !blank}<span class="val">{tileValue(variant, letter)}</span>{/if}
|
{#if !blank}<span class="val">{tileValue(variant, letter)}</span>{/if}
|
||||||
{:else if r === centre.row && c === centre.col}
|
{:else if r === centre.row && c === centre.col}
|
||||||
<span class="star">★</span>
|
<span class="star">★</span>
|
||||||
{:else if premLabel[premium[r][c]]}
|
{:else if bl?.kind === 'single'}
|
||||||
<span class="plabel">{premLabel[premium[r][c]]}</span>
|
<span class="b1">{bl.text}</span>
|
||||||
|
{:else if bl?.kind === 'split'}
|
||||||
|
<span class="bsplit"><span class="bt">{bl.top}</span><span class="bb">{bl.bottom}</span></span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.viewport {
|
.viewport {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
overflow: hidden;
|
||||||
background: var(--board-bg);
|
background: var(--board-bg);
|
||||||
padding: 4px;
|
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
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>
|
||||||
|
|||||||
+244
-161
@@ -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) {
|
||||||
|
// 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);
|
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,24 +368,19 @@
|
|||||||
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">
|
||||||
@@ -370,28 +392,57 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="boardwrap">
|
<div class="stage">
|
||||||
|
{#if historyOpen}
|
||||||
|
<div class="history">
|
||||||
|
<ol>
|
||||||
|
{#each moves as m, i (i)}
|
||||||
|
<li>
|
||||||
|
<span class="hp">{view.game.seats[m.player]?.displayName ?? m.player}</span>
|
||||||
|
<span class="ha">{m.action === 'play' ? m.words.join(', ') : m.action}</span>
|
||||||
|
<span class="hs">{m.score}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
{#if moves.length === 0}<li class="hempty">—</li>{/if}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<div
|
||||||
|
class="boardwrap"
|
||||||
|
class:slid={historyOpen}
|
||||||
|
onclick={() => historyOpen && (historyOpen = false)}
|
||||||
|
>
|
||||||
<Board
|
<Board
|
||||||
{board}
|
{board}
|
||||||
{premium}
|
{premium}
|
||||||
pending={pendingMap}
|
pending={pendingMap}
|
||||||
{recent}
|
{highlight}
|
||||||
|
{flash}
|
||||||
centre={ctr}
|
centre={ctr}
|
||||||
{zoomed}
|
{zoomed}
|
||||||
{variant}
|
{variant}
|
||||||
|
labelMode={app.boardLabels}
|
||||||
|
locale={app.locale}
|
||||||
|
{focus}
|
||||||
oncell={onCell}
|
oncell={onCell}
|
||||||
ontogglezoom={() => (zoomed = !zoomed)}
|
ontogglezoom={() => (zoomed = !zoomed)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="status">
|
<div class="status">
|
||||||
<span>{t('game.bag', { n: view.bagLen })}</span>
|
<span>{t('game.bag', { n: view.bagLen })}</span>
|
||||||
{#if gameOver}
|
{#if gameOver}
|
||||||
<strong class="over">{t('game.over')} — {resultText()}</strong>
|
<strong class="over">{t('game.over')} — {resultText()}</strong>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : t('game.waiting', { name: view.game.seats[view.game.toMove]?.displayName ?? '' })}</span>
|
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : view.game.seats[view.game.toMove]?.displayName ?? ''}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span>{t('game.hints', { n: view.hintsRemaining })}</span>
|
<span class="scores">
|
||||||
|
{#if preview}{preview.legal ? t('game.scores', { n: preview.score }) : t('game.previewIllegal')}{/if}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !gameOver}
|
{#if !gameOver}
|
||||||
@@ -400,32 +451,45 @@
|
|||||||
<Rack {slots} {variant} {selected} ondown={onRackDown} />
|
<Rack {slots} {variant} {selected} ondown={onRackDown} />
|
||||||
</div>
|
</div>
|
||||||
{#if placement.pending.length > 0}
|
{#if placement.pending.length > 0}
|
||||||
<MakeMove
|
<HoldConfirm triggerClass="make" onhold={commit}>
|
||||||
label={t('game.makeMove')}
|
{#snippet trigger()}<span class="flag">🏁</span>{/snippet}
|
||||||
resetLabel={t('game.reset')}
|
{#snippet popover(close)}
|
||||||
onmake={commit}
|
<button class="pop go" onclick={() => { close(); commit(); }}>{t('game.makeMove')} ✅</button>
|
||||||
onreset={resetPlacement}
|
<button class="pop rs" onclick={() => { close(); resetPlacement(); }}>{t('game.reset')} ❌</button>
|
||||||
/>
|
{/snippet}
|
||||||
|
</HoldConfirm>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Controls
|
|
||||||
{preview}
|
|
||||||
hints={view.hintsRemaining}
|
|
||||||
busy={busy || !isMyTurn}
|
|
||||||
{ambiguous}
|
|
||||||
{dir}
|
|
||||||
ondraw={openExchange}
|
|
||||||
onskip={doPass}
|
|
||||||
onshuffle={shuffle}
|
|
||||||
onhint={doHint}
|
|
||||||
ondir={toggleDir}
|
|
||||||
/>
|
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<p class="loading">{t('common.loading')}</p>
|
<p class="loading">{t('common.loading')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#snippet tabbar()}
|
||||||
|
{#if view && !gameOver}
|
||||||
|
<TabBar>
|
||||||
|
<button class="tab" disabled={busy || !isMyTurn || bagEmpty} onclick={openExchange}>
|
||||||
|
<span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>
|
||||||
|
</button>
|
||||||
|
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn} onhold={doPass}>
|
||||||
|
{#snippet trigger()}<span class="sq">🥺</span><span class="lbl">{t('game.skip')}</span>{/snippet}
|
||||||
|
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doPass(); }}>{t('game.confirm')} ✅</button>{/snippet}
|
||||||
|
</HoldConfirm>
|
||||||
|
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn} onhold={doHint}>
|
||||||
|
{#snippet trigger()}
|
||||||
|
<span class="sq">🛟{#if (view?.hintsRemaining ?? 0) > 0}<span class="badge">{view?.hintsRemaining}</span>{/if}</span>
|
||||||
|
<span class="lbl">{t('game.hint')}</span>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doHint(); }}>{t('game.confirm')} ✅</button>{/snippet}
|
||||||
|
</HoldConfirm>
|
||||||
|
<button class="tab" disabled={busy || placement.pending.length > 0} onclick={shuffle}>
|
||||||
|
<span class="sq">🔀</span>
|
||||||
|
</button>
|
||||||
|
</TabBar>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</Screen>
|
||||||
|
|
||||||
{#if drag}
|
{#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">
|
||||||
<span>{drag.blank ? '' : drag.letter}</span>
|
<span>{drag.blank ? '' : drag.letter}</span>
|
||||||
@@ -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>
|
||||||
|
|||||||
+18
-17
@@ -15,15 +15,16 @@
|
|||||||
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}
|
|
||||||
<span class="slot empty"></span>
|
|
||||||
{:else}
|
|
||||||
<button
|
<button
|
||||||
class="slot tile"
|
class="tile"
|
||||||
class:selected={selected === slot.index}
|
class:selected={selected === slot.index}
|
||||||
data-rack-index={slot.index}
|
data-rack-index={slot.index}
|
||||||
onpointerdown={(e) => ondown(e, slot.index)}
|
onpointerdown={(e) => ondown(e, slot.index)}
|
||||||
@@ -31,29 +32,24 @@
|
|||||||
<span class="letter">{slot.letter === BLANK ? '' : slot.letter}</span>
|
<span class="letter">{slot.letter === BLANK ? '' : slot.letter}</span>
|
||||||
{#if slot.letter !== BLANK}<span class="val">{tileValue(variant, slot.letter)}</span>{/if}
|
{#if slot.letter !== BLANK}<span class="val">{tileValue(variant, slot.letter)}</span>{/if}
|
||||||
</button>
|
</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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 < b & 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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// Announcement / "ad" banner — a parameterised rotator plus a tiny markdown linkifier.
|
||||||
|
// The rotator is DOM-agnostic (the host measures overflow and applies the visual
|
||||||
|
// effects through callbacks), so its timing is unit-testable with fake timers. Today
|
||||||
|
// the content is a mock long↔short rotation; later it becomes a server-driven
|
||||||
|
// announcements channel (see ARCHITECTURE).
|
||||||
|
|
||||||
|
export interface BannerConfig {
|
||||||
|
/** How long one message is shown before advancing (short text), ms. */
|
||||||
|
holdMs: number;
|
||||||
|
/** Pause at each end before/after scrolling a long message, ms. */
|
||||||
|
edgePauseMs: number;
|
||||||
|
/** Scroll speed for a long (overflowing) message, px/sec. */
|
||||||
|
scrollPxPerSec: number;
|
||||||
|
/** Cross-fade duration between messages, ms. */
|
||||||
|
fadeMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultBannerConfig: BannerConfig = {
|
||||||
|
holdMs: 60_000,
|
||||||
|
edgePauseMs: 5_000,
|
||||||
|
scrollPxPerSec: 40,
|
||||||
|
fadeMs: 400,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface BannerItem {
|
||||||
|
/** Minimal markdown: plain text + `[label](url)` links. */
|
||||||
|
md: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The host the rotator drives; the Svelte component supplies the DOM measurements. */
|
||||||
|
export interface BannerHost {
|
||||||
|
/** Overflow width of item `index` in px (0 when it fits). */
|
||||||
|
overflowPx(index: number): number;
|
||||||
|
/** Render item `index` (the host fades it in and resets scroll to the start). */
|
||||||
|
show(index: number): void;
|
||||||
|
/** Animate the horizontal scroll to `toPx` over `durationMs`. */
|
||||||
|
scrollTo(toPx: number, durationMs: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Rotator {
|
||||||
|
start(): void;
|
||||||
|
stop(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* createBannerRotator drives a list of messages: a fitting message holds `holdMs`
|
||||||
|
* then advances; an overflowing one pauses, scrolls to its right edge, pauses, then
|
||||||
|
* repeats while the elapsed cycle is under `holdMs`, else advances.
|
||||||
|
*/
|
||||||
|
export function createBannerRotator(
|
||||||
|
items: BannerItem[],
|
||||||
|
host: BannerHost,
|
||||||
|
config: BannerConfig = defaultBannerConfig,
|
||||||
|
): Rotator {
|
||||||
|
let index = 0;
|
||||||
|
let running = false;
|
||||||
|
let cycleStart = 0;
|
||||||
|
const timers: ReturnType<typeof setTimeout>[] = [];
|
||||||
|
|
||||||
|
const at = (ms: number, fn: () => void) => {
|
||||||
|
timers.push(setTimeout(fn, ms));
|
||||||
|
};
|
||||||
|
const clear = () => {
|
||||||
|
for (const t of timers) clearTimeout(t);
|
||||||
|
timers.length = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
function advance() {
|
||||||
|
if (!running) return;
|
||||||
|
index = (index + 1) % items.length;
|
||||||
|
present();
|
||||||
|
}
|
||||||
|
|
||||||
|
function present() {
|
||||||
|
if (!running) return;
|
||||||
|
clear();
|
||||||
|
host.show(index);
|
||||||
|
// Let the swapped-in message render before measuring its overflow.
|
||||||
|
at(config.fadeMs, () => {
|
||||||
|
const over = host.overflowPx(index);
|
||||||
|
if (over <= 0) {
|
||||||
|
at(config.holdMs, advance);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cycleStart = Date.now();
|
||||||
|
scrollCycle(over);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollCycle(over: number) {
|
||||||
|
const dur = (over / config.scrollPxPerSec) * 1000;
|
||||||
|
at(config.edgePauseMs, () => {
|
||||||
|
host.scrollTo(over, dur);
|
||||||
|
at(dur + config.edgePauseMs, () => {
|
||||||
|
if (Date.now() - cycleStart >= config.holdMs) {
|
||||||
|
advance();
|
||||||
|
} else {
|
||||||
|
host.show(index); // resets scroll to the start
|
||||||
|
scrollCycle(over);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start() {
|
||||||
|
if (running || items.length === 0) return;
|
||||||
|
running = true;
|
||||||
|
index = 0;
|
||||||
|
present();
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
running = false;
|
||||||
|
clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const URL_RE = /^(https?:\/\/|\/)/i;
|
||||||
|
|
||||||
|
function escapeHtml(s: string): string {
|
||||||
|
return s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* linkify renders minimal markdown to a safe HTML string: everything is escaped, then
|
||||||
|
* `[label](url)` becomes a link (only http(s):// or root-relative URLs are allowed).
|
||||||
|
*/
|
||||||
|
export function linkify(md: string): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const re = /\[([^\]]+)\]\(([^)]+)\)/g;
|
||||||
|
let last = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(md)) !== null) {
|
||||||
|
parts.push(escapeHtml(md.slice(last, m.index)));
|
||||||
|
const label = escapeHtml(m[1]);
|
||||||
|
const url = m[2].trim();
|
||||||
|
if (URL_RE.test(url)) {
|
||||||
|
parts.push(`<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${label}</a>`);
|
||||||
|
} else {
|
||||||
|
parts.push(label);
|
||||||
|
}
|
||||||
|
last = re.lastIndex;
|
||||||
|
}
|
||||||
|
parts.push(escapeHtml(md.slice(last)));
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** mockBanners is the placeholder rotation (long ↔ short) to demo the mechanics. */
|
||||||
|
export function mockBanners(): BannerItem[] {
|
||||||
|
return [
|
||||||
|
{ md: 'New season starts soon — [learn more](https://example.com/season).' },
|
||||||
|
{
|
||||||
|
md: 'Tip: a 7-tile play earns a +50 bonus. Try the daily tournament, climb the leaderboard, and challenge friends — more modes are coming, [stay tuned](https://example.com/news)!',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,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: 'буква' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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': 'Вы не участник этой игры.',
|
||||||
|
|||||||
@@ -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
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: '🥈' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Pure mapping from a game view (for the viewer) to a status/result badge: a label key
|
||||||
|
// and a place-based emoji. Used by the lobby lists.
|
||||||
|
|
||||||
|
import type { GameView } from './model';
|
||||||
|
import type { MessageKey } from './i18n/catalog';
|
||||||
|
|
||||||
|
export interface ResultBadge {
|
||||||
|
key: MessageKey;
|
||||||
|
emoji: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resultBadge(game: GameView, myId: string): ResultBadge {
|
||||||
|
const me = game.seats.find((s) => s.accountId === myId);
|
||||||
|
|
||||||
|
if (game.status === 'active') {
|
||||||
|
return game.toMove === me?.seat
|
||||||
|
? { key: 'result.yourMove', emoji: '🟢' }
|
||||||
|
: { key: 'result.oppMove', emoji: '⏳' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (me?.isWinner) return { key: 'result.victory', emoji: '🏆' };
|
||||||
|
if (!game.seats.some((s) => s.isWinner)) return { key: 'result.draw', emoji: '🏅' };
|
||||||
|
|
||||||
|
// Someone else won — place the viewer by score (1 + number of higher scores).
|
||||||
|
const rank = 1 + game.seats.filter((s) => s.score > (me?.score ?? 0)).length;
|
||||||
|
if (rank <= 1) return { key: 'result.victory', emoji: '🏆' };
|
||||||
|
if (rank === 2) return game.players === 2 ? { key: 'result.defeat', emoji: '🥈' } : { key: 'result.place2', emoji: '🥈' };
|
||||||
|
if (rank === 3) return { key: 'result.place3', emoji: '🥉' };
|
||||||
|
return { key: 'result.place4', emoji: '🏅' };
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
import type { Session } from './model';
|
import type { 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>> {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+58
-142
@@ -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">
|
||||||
|
{#each [{ h: 'lobby.activeGames', list: active }, { h: 'lobby.finishedGames', list: finished }] as group (group.h)}
|
||||||
|
{#if group.list.length}
|
||||||
<section>
|
<section>
|
||||||
<h2>{t('lobby.activeGames')}</h2>
|
<h2>{t(group.h as 'lobby.activeGames')}</h2>
|
||||||
{#if active.length === 0}
|
{#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>
|
||||||
|
|||||||
@@ -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,8 +51,8 @@
|
|||||||
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>
|
||||||
@@ -69,7 +67,8 @@
|
|||||||
{/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;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<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}
|
||||||
@@ -20,7 +20,8 @@
|
|||||||
<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 {
|
||||||
|
|||||||
@@ -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,10 +18,16 @@
|
|||||||
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">
|
||||||
@@ -37,6 +50,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3>{t('settings.boardStyle')}</h3>
|
||||||
|
<div class="sub">{t('settings.boardLabels')}</div>
|
||||||
|
<div class="seg">
|
||||||
|
{#each labelModes as lm (lm)}
|
||||||
|
<button class="opt" class:active={app.boardLabels === lm} onclick={() => setBoardLabels(lm)}>
|
||||||
|
{t(labelModeKey[lm])}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<label class="row">
|
<label class="row">
|
||||||
<span>{t('settings.reduceMotion')}</span>
|
<span>{t('settings.reduceMotion')}</span>
|
||||||
@@ -47,7 +72,8 @@
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</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);
|
||||||
|
|||||||
Reference in New Issue
Block a user