diff --git a/.gitea/workflows/ui-test.yaml b/.gitea/workflows/ui-test.yaml index fc3657a..ae54c63 100644 --- a/.gitea/workflows/ui-test.yaml +++ b/.gitea/workflows/ui-test.yaml @@ -1,8 +1,8 @@ name: Tests · UI # Hermetic UI checks: type-check, Vitest unit tests, production build with a -# bundle-size budget, and a Playwright smoke against the in-memory mock transport -# (no backend/gateway/Postgres). The committed src/gen/ codegen is built, not +# bundle-size budget, and a Playwright smoke (Chromium + WebKit) against the in-memory +# mock transport (no backend/gateway/Postgres). The committed src/gen/ codegen is built, not # regenerated (the same model as the Go committed jet/fbs output). on: @@ -51,12 +51,15 @@ jobs: # The Playwright system libraries are provisioned once on the runner host # (`sudo npx playwright@ install-deps chromium`), so the job needs no - # apt and no sudo: it only downloads the browser binary into the runner cache - # (persisted by the host executor) and runs the smoke. The timeouts guard - # against a future hang. Keep this in lockstep with @playwright/test in - # package.json — re-run install-deps on the host after a major bump. - - name: Install Playwright browser - run: pnpm exec playwright install chromium + # apt and no sudo: it only downloads the browser binaries into the runner cache + # (persisted by the host executor) and runs the suite. WebKit's Debian build + # bundles most of its own libraries and runs headless without extra host deps; if + # a runner ever lacks one, provision it once on the host with + # `sudo npx playwright install-deps webkit`. The timeouts guard against a future + # hang. Keep this in lockstep with @playwright/test in package.json — re-run + # install-deps on the host after a major bump. + - name: Install Playwright browsers + run: pnpm exec playwright install chromium webkit timeout-minutes: 5 - name: E2E smoke (mock) diff --git a/.gitignore b/.gitignore index 0578a85..259564b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ .idea/ .DS_Store +# Playwright MCP scratch output (snapshots / screenshots written during inspection) +.playwright-mcp/ + # Local, unstaged env overrides **/.env.local **/.env.*.local diff --git a/CLAUDE.md b/CLAUDE.md index 9cad34b..10e9cf0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,7 @@ conversation memory — is the source of continuity. Keep it that way. - [`docs/FUNCTIONAL.md`](docs/FUNCTIONAL.md) (+ [`_ru`](docs/FUNCTIONAL_ru.md) mirror) — per-domain user stories. English authoritative. - [`docs/TESTING.md`](docs/TESTING.md) — test layers + the per-stage CI gate. +- [`docs/UI_DESIGN.md`](docs/UI_DESIGN.md) — the `ui` visual/interaction design system. ## Mandatory per-stage workflow diff --git a/PLAN.md b/PLAN.md index dcd43a5..75d4766 100644 --- a/PLAN.md +++ b/PLAN.md @@ -40,7 +40,7 @@ independent (see ARCHITECTURE §9.1). | 4 | Lobby & social (matchmaking, friends, block, chat, profile, nudge) | **done** | | 5 | Robot opponent | **done** | | 6 | Gateway edge (Connect/FB, platform auth, sessions, push bridge, admin) | **done** | -| 7 | UI — playable slice (Svelte+Vite, board, lobby, chat, hint/word-check, i18n) | todo | +| 7 | UI — playable slice + UX polish (Svelte+Vite, board, lobby, chat, hint/word-check, i18n) | **done** | | 8 | UI — social/account/history (friends, blocks, invitations, profile edit, stats, history/GCG) | todo | | 9 | Telegram integration (bot side-service, deep-link, push) | todo | | 10 | Admin & dictionary ops (complaint review, version reload) | todo | @@ -525,6 +525,18 @@ Open details: deployment target/host; dashboards; load expectations. **bundle-size budget** — prod is ~67 KB gzip JS — and a chromium e2e). The Go workflows already cover the new backend/gateway/pkg code; a `game.ListForAccount` integration test and gateway transcode tests for the new ops were added. + - **UX polish** (follow-up PR): a mobile-app **app shell** (growing nav bar, content + pinned to the bottom) + a one-line **announcement banner** (client-side mock + rotation now; server-driven channel later — §10); a mobile-OS **tab bar** and a + reusable **HoldConfirm** press-and-hold control (MakeMove 🏁 + game-action confirms); + board **zoom reworked** to a width-based zoom in a fixed viewport (real native + scroll, double-tap; pinch/swipe dropped) with constant `cqw` labels, corner-letter + tiles, contrasting grid lines, last-word dark-tile highlight, and a Settings + **bonus-label style** (beginner/ + classic/none); **hint lays its tiles on the board** (no spend when no move — a new + `no_hint_available` result code); the history opens as an in-place **slide-down** + (not a modal); word-check is alphabet/length-limited, cached and throttled. Design + details live in the new [`docs/UI_DESIGN.md`](docs/UI_DESIGN.md). ## Deferred TODOs (cross-stage) @@ -551,3 +563,10 @@ Open details: deployment target/host; dashboards; load expectations. row behind. Add a periodic reaper (or a finished-and-idle sweep) that deletes guest accounts with no active games once their last session is gone; the `ON DELETE CASCADE` foreign keys clean up the dependent rows. +- **TODO-4 — put the per-game alphabet on the wire (owner's idea, Stage 7).** Today the + client hardcodes each variant's letters/values (ported into `ui/src/lib/premiums.ts` + from `scrabble-solver/rules/rules.go`) and the edge exchanges plays/hints by concrete + letters. Consider extending `game.state` to carry the variant's `(letter, index, + value)` table so the UI stops duplicating it, and optionally moving tile exchange to + letter **indices** end-to-end. Caveat (as for the dictionaries, TODO-2): the wire table + must stay pinned to the same `rules.Alphabet` the engine uses, or indices drift. diff --git a/backend/internal/server/dto_test.go b/backend/internal/server/dto_test.go index fc6f7df..fea97e0 100644 --- a/backend/internal/server/dto_test.go +++ b/backend/internal/server/dto_test.go @@ -51,6 +51,8 @@ func TestStatusForError(t *testing.T) { "code mismatch": {account.ErrCodeMismatch, http.StatusUnauthorized, "code_invalid"}, "session gone": {session.ErrNotFound, http.StatusUnauthorized, "session_invalid"}, "chat forbidden": {social.ErrForbiddenContent, http.StatusUnprocessableEntity, "chat_rejected"}, + "no hint move": {game.ErrNoHintAvailable, http.StatusConflict, "no_hint_available"}, + "no hints left": {game.ErrNoHintsLeft, http.StatusConflict, "hint_unavailable"}, "unknown -> 500": {context_deadline, http.StatusInternalServerError, "internal"}, } for name, tc := range cases { diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index dc822aa..de8411a 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -121,7 +121,11 @@ func statusForError(err error) (int, string) { return http.StatusConflict, "already_queued" case errors.Is(err, game.ErrInvalidConfig): return http.StatusBadRequest, "invalid_config" - case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft), errors.Is(err, game.ErrNoHintAvailable): + case errors.Is(err, game.ErrNoHintAvailable): + // No legal move for the rack — distinct from a budget/disabled hint so the UI + // can say "no options" (and the service spends nothing in this case). + return http.StatusConflict, "no_hint_available" + case errors.Is(err, game.ErrHintsDisabled), errors.Is(err, game.ErrNoHintsLeft): return http.StatusConflict, "hint_unavailable" case errors.Is(err, engine.ErrIllegalPlay), errors.Is(err, engine.ErrTilesNotOnRack), errors.Is(err, engine.ErrGameOver): return http.StatusUnprocessableEntity, "illegal_play" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 81ae482..bc9ca4e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -38,6 +38,11 @@ Three executables plus per-platform side-services: is a hash router and the session token is held in memory + IndexedDB. A build-flagged in-memory mock transport (`pnpm start`) runs the whole slice with no backend. Embeddable in platform webviews; packageable to native (iOS/Android) via Capacitor. + The client uses a mobile-app shell (a growing nav bar; content pinned to the bottom), + a one-line **announcement banner** under the nav (a client-side mock rotation today — + a server-driven channel later, §10), and a client **board-style** setting (bonus-label + mode). The visual/interaction design system is documented in + [`UI_DESIGN.md`](UI_DESIGN.md). - **`platform/`** *(planned)* — per-platform side-services (Telegram bot first): deep-link invites and platform-native push notifications. They talk to `backend` over an internal API. @@ -209,7 +214,11 @@ Key points: (`hint_balance`, spent after the allowance; top-ups are a later feature). A hint reveals the top-1 ranked move (`GenerateMoves[0]`). The lobby/tournament caller picks the per-game defaults (e.g. one in casual random games, none in - tournaments). + tournaments). The client **lays the hinted tiles onto the board** as a pending + placement and leaves the commit to the player. When the rack has no legal move the + service spends **nothing** and returns `ErrNoHintAvailable` — surfaced as the distinct + result code `no_hint_available` (separate from `hint_unavailable`) so the UI can say + "no options" rather than "no hints left". - **Word-check tool**: unlimited dictionary lookups against the game's pinned dictionary; each result offers a **complaint** (complainant, game, variant, dict_version, word, the disputed result, an optional note) that lands in an @@ -363,6 +372,10 @@ match-found. Out-of-app platform push (your-turn, nudge) is wired in Stage 9; session-revocation events and cursor-based stream resume are deferred (single-instance MVP). +A separate **announcements channel** feeds the client's one-line banner (UI_DESIGN.md). +It is a client-side **mock** rotation today; a server-driven source (operational notices, +promotions) is future work and would deliver short markdown messages (text + links). + ## 11. Observability - Structured logging with `go.uber.org/zap` (JSON). OpenTelemetry tracer and diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 261e473..1dc59ed 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -17,7 +17,10 @@ the top-1 hint, the unlimited word-check with complaint, per-game chat and nudge real-time in-app updates, switching interface language (en/ru) and theme, and a read-only profile. Managing friends and blocks, creating friend games (invitations), editing the profile, the statistics screen and the history/GCG viewer arrive in -Stage 8. +Stage 8. Settings also pick the board's bonus-label style (beginner / classic / +none). A hint **lays the suggested tiles on the board** for the player to confirm and +costs nothing when the rack has no legal move. The word-check accepts only the +variant's alphabet, remembers answers within the session and rate-limits repeats. ### Identity & sessions *(Stage 1 / 6)* A player arrives from a platform (Telegram first), via email login, or as an diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index ee1cd09..ec4efd1 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -16,7 +16,11 @@ top-1 подсказку, безлимитную проверку слова с обновления в реальном времени, переключение языка интерфейса (en/ru) и темы и профиль только для чтения. Управление друзьями и блоками, создание дружеских игр (приглашения), редактирование профиля, экран статистики и просмотр истории/GCG -появятся в Stage 8. +появятся в Stage 8. В настройках также выбирается стиль подписей бонус-клеток +(новичок / классика / без текста). Подсказка **выставляет предложенные фишки на +доску** — игрок сам решает сделать ход, и подсказка не тратится, если ходов нет. +Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии +и ограничивает частоту повторов. ### Личность и сессии *(Stage 1 / 6)* Игрок приходит с платформы (сначала Telegram), через email-вход или как diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md new file mode 100644 index 0000000..719a251 --- /dev/null +++ b/docs/UI_DESIGN.md @@ -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). diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts new file mode 100644 index 0000000..d8c7abb --- /dev/null +++ b/ui/e2e/game.spec.ts @@ -0,0 +1,86 @@ +import { expect, test, type Page } from '@playwright/test'; + +// Behaviour/display coverage for the polished game screen, driven entirely by the mock +// transport (no backend). These lock the round-1..4 interactions so future UI edits +// surface as a failing assertion — to be re-agreed or fixed. The pure logic behind them +// (placement, check-word, board labels, result badges) is unit-tested separately. + +async function openGame(page: Page): Promise { + await page.goto('/'); + await page.getByRole('button', { name: /guest/i }).click(); + await page.getByRole('button', { name: /Ann/ }).click(); // the seeded active game vs Ann + await expect(page.locator('[data-cell]').first()).toBeVisible(); +} + +test('placing a tile and confirming via 🏁 commits the move', async ({ page }) => { + await openGame(page); + await page.locator('.rack .tile').first().click(); + await page.locator('[data-cell]:not(.filled)').nth(30).click(); + await expect(page.locator('[data-cell].pending')).toHaveCount(1); + + await page.locator('.make').click(); // open the MakeMove popover (short tap) + await page.locator('.pop.go').click(); // "Make move ✅" + + // After the commit the placement is cleared: no pending tile, no 🏁 control. + await expect(page.locator('[data-cell].pending')).toHaveCount(0); + await expect(page.locator('.make')).toBeHidden(); +}); + +test('history slides the board down and closes on a board tap', async ({ page }) => { + await openGame(page); + await page.locator('.burger').click(); + await page.locator('.dropdown button').nth(0).click(); // History + await expect(page.locator('.history')).toBeVisible(); + await expect(page.locator('.boardwrap.slid')).toBeVisible(); + + await page.locator('.boardwrap').click(); // tapping the board closes it + await expect(page.locator('.history')).toBeHidden(); +}); + +test('Draw opens the exchange dialog and confirms a selection', async ({ page }) => { + await openGame(page); + await page.locator('button:has-text("🔄")').click(); // Draw tab + await expect(page.locator('.exch')).toBeVisible(); + + await page.locator('.etile').first().click(); + await expect(page.locator('.etile.sel')).toHaveCount(1); + await page.locator('button.confirm').click(); + await expect(page.locator('.exch')).toBeHidden(); +}); + +test('check-word sanitises input and shows a verdict', async ({ page }) => { + await openGame(page); + await page.locator('.burger').click(); + await page.locator('.dropdown button').nth(2).click(); // Check word + + const input = page.locator('.check input'); + await input.fill('qz9!a'); // digits/punctuation dropped, letters upper-cased + await expect(input).toHaveValue('QZA'); + + await page.locator('.check button').click(); // Check (enabled: length 3) + await expect(page.locator('.ok, .bad')).toBeVisible(); +}); + +test('dropping the game ends it and shows the result', async ({ page }) => { + await openGame(page); + await page.locator('.burger').click(); + await page.locator('.dropdown button').nth(3).click(); // Drop game + await page.locator('button.danger').click(); // confirm in the modal + await expect(page.locator('.status .over')).toBeVisible(); +}); + +test('the board-label mode in Settings changes the on-board labels', async ({ page }) => { + await openGame(page); + // beginner (default) renders split "3× / word" labels. + await expect(page.locator('.bsplit').first()).toBeVisible(); + await expect(page.locator('.b1')).toHaveCount(0); + + // Switch to "classic" in Settings (in-SPA hash nav keeps the guest session). + await page.evaluate(() => (location.hash = '/settings')); + await page.locator('.seg').nth(2).locator('.opt').nth(1).click(); // board labels -> classic + await page.evaluate(() => (location.hash = '/game/g1')); + + // classic renders single "3W"/"2L" labels and no split labels. + await expect(page.locator('.b1').first()).toBeVisible(); + await expect(page.locator('.bsplit')).toHaveCount(0); +}); diff --git a/ui/e2e/smoke.spec.ts b/ui/e2e/smoke.spec.ts index f43e669..1a6a4fa 100644 --- a/ui/e2e/smoke.spec.ts +++ b/ui/e2e/smoke.spec.ts @@ -22,8 +22,9 @@ test('guest reaches a board and previews a placement', async ({ page }) => { await rackTile.click(); await page.locator('[data-cell]:not(.filled)').nth(30).click(); await expect(page.locator('[data-cell].pending')).toHaveCount(1); - await expect(page.locator('.preview')).toContainText(/\d/); + // The score preview appears where the hints count used to be. + await expect(page.locator('.scores')).toContainText(/\d/); - // The contextual MakeMove control appears once a tile is pending. - await expect(page.getByRole('button', { name: /make move/i })).toBeVisible(); + // The contextual MakeMove control (🏁) appears once a tile is pending. + await expect(page.locator('.make')).toBeVisible(); }); diff --git a/ui/e2e/zoom.spec.ts b/ui/e2e/zoom.spec.ts new file mode 100644 index 0000000..866452a --- /dev/null +++ b/ui/e2e/zoom.spec.ts @@ -0,0 +1,26 @@ +import { expect, test } from '@playwright/test'; + +// Item 5: zooming the board must enlarge the labels too (a magnifying-glass zoom). +// cqw is sized against the zoom-scaled board, so the font grows with the cells. +test('zoom enlarges the board labels with the board', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: /guest/i }).click(); + await page.getByRole('button', { name: /Ann/ }).click(); + + const letter = page.locator('[data-cell] .letter').first(); + await expect(letter).toBeVisible(); + const base = await letter.evaluate((el) => parseFloat(getComputedStyle(el).fontSize)); + + // Double-tap an empty cell to zoom in (two synchronous clicks = a double-tap). + await page + .locator('[data-cell]:not(.filled)') + .nth(20) + .evaluate((el: HTMLElement) => { + el.click(); + el.click(); + }); + await page.waitForTimeout(400); // let the width transition settle + + const zoomed = await letter.evaluate((el) => parseFloat(getComputedStyle(el).fontSize)); + expect(zoomed).toBeGreaterThan(base * 1.4); +}); diff --git a/ui/playwright.config.ts b/ui/playwright.config.ts index bac1679..cc28f70 100644 --- a/ui/playwright.config.ts +++ b/ui/playwright.config.ts @@ -18,5 +18,12 @@ export default defineConfig({ reuseExistingServer: !process.env.CI, timeout: 60_000, }, - projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], + // Run the same hermetic specs in Chromium and WebKit (Safari's engine) so the UI is + // exercised in both rendering/JS engines. Note: desktop WebKit on Linux does not + // reproduce iOS Safari's text auto-inflation, so the `text-size-adjust` guard in + // app.css is not regression-covered here — but engine-level CSS/JS differences are. + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + { name: 'webkit', use: { ...devices['Desktop Safari'] } }, + ], }); diff --git a/ui/src/app.css b/ui/src/app.css index 4e4ab26..2bde4aa 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -23,12 +23,12 @@ /* board + tiles (all drawn with CSS primitives) */ --board-bg: #cdd6cf; --cell-bg: #e7ece8; - --cell-line: #b6c0b8; + --cell-line: #7f8d83; --tile-bg: #f4e2b8; --tile-edge: #d8c190; --tile-text: #2a2113; - --tile-pending: #ffe7a3; - --tile-recent: #fff6d8; + --tile-pending: #f2cf73; + --tile-recent: #c8a85c; --prem-tw: #e06a5b; /* triple word */ --prem-dw: #efa6a0; /* double word + centre */ --prem-tl: #4f8fd6; /* triple letter */ @@ -62,12 +62,12 @@ --board-bg: #2a3330; --cell-bg: #222a27; - --cell-line: #38433d; + --cell-line: #56655c; --tile-bg: #d9c79a; --tile-edge: #b6a473; --tile-text: #20190d; - --tile-pending: #f0d98f; - --tile-recent: #4a4636; + --tile-pending: #d8b75e; + --tile-recent: #7a6638; --prem-tw: #b1493d; --prem-dw: #8c5450; --prem-tl: #34608f; @@ -123,6 +123,9 @@ body { line-height: 1.4; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; + /* Stop iOS/Safari from auto-inflating text (e.g. the long marquee message). */ + text-size-adjust: 100%; + -webkit-text-size-adjust: 100%; /* never let the page scroll/zoom out from under the board */ overscroll-behavior: none; touch-action: manipulation; @@ -130,12 +133,23 @@ body { #app { height: 100%; + /* No text selection anywhere by default; inputs opt back in below. */ + user-select: none; + -webkit-user-select: none; +} + +input, +textarea { + user-select: text; + -webkit-user-select: text; } button { font: inherit; color: inherit; cursor: pointer; + user-select: none; + -webkit-user-select: none; } .reduce-motion * { diff --git a/ui/src/components/AdBanner.svelte b/ui/src/components/AdBanner.svelte new file mode 100644 index 0000000..843d2b0 --- /dev/null +++ b/ui/src/components/AdBanner.svelte @@ -0,0 +1,80 @@ + + +
+ {#key current} +
+ {@html linkify(items[current]?.md ?? '')} +
+ {/key} +
+ + diff --git a/ui/src/components/Header.svelte b/ui/src/components/Header.svelte index 692b282..f95ff0c 100644 --- a/ui/src/components/Header.svelte +++ b/ui/src/components/Header.svelte @@ -2,30 +2,45 @@ import type { Snippet } from 'svelte'; import { navigate } from '../lib/router.svelte'; - let { title, back, menu }: { title: string; back?: string; menu?: Snippet } = $props(); + let { title, back, menu, grow = false }: { title: string; back?: string; menu?: Snippet; grow?: boolean } = + $props(); -
- {#if back} - - {:else} - - {/if} -

{title}

-
{#if menu}{@render menu()}{/if}
+ diff --git a/ui/src/components/HoldConfirm.svelte b/ui/src/components/HoldConfirm.svelte new file mode 100644 index 0000000..6db8eb6 --- /dev/null +++ b/ui/src/components/HoldConfirm.svelte @@ -0,0 +1,108 @@ + + +
+ + + {#if open} + + +
+
{@render popover(close)}
+ {/if} +
+ + diff --git a/ui/src/components/Menu.svelte b/ui/src/components/Menu.svelte new file mode 100644 index 0000000..3fb4a08 --- /dev/null +++ b/ui/src/components/Menu.svelte @@ -0,0 +1,83 @@ + + + + + diff --git a/ui/src/components/Screen.svelte b/ui/src/components/Screen.svelte new file mode 100644 index 0000000..12a2b93 --- /dev/null +++ b/ui/src/components/Screen.svelte @@ -0,0 +1,56 @@ + + +
+
+ +
{@render children?.()}
+ {#if tabbar} + + {/if} +
+ + diff --git a/ui/src/components/TabBar.svelte b/ui/src/components/TabBar.svelte new file mode 100644 index 0000000..9865762 --- /dev/null +++ b/ui/src/components/TabBar.svelte @@ -0,0 +1,64 @@ + + +
{@render children?.()}
+ + diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index 7a12ebb..6256e04 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -3,39 +3,64 @@ import type { Premium } from '../lib/premiums'; import { tileValue } from '../lib/premiums'; import type { Variant } from '../lib/model'; + import { bonusLabel, type BoardLabelMode } from '../lib/boardlabels'; + import type { Locale } from '../lib/i18n/catalog'; let { board, premium, pending, - recent, + highlight, + flash, centre, zoomed, variant, + labelMode, + locale, + focus, oncell, ontogglezoom, }: { board: (BoardCell | null)[][]; premium: Premium[][]; pending: Map; - recent: Set; + highlight: Set; + flash: boolean; centre: { row: number; col: number }; zoomed: boolean; variant: Variant; + labelMode: BoardLabelMode; + locale: Locale; + focus: { row: number; col: number } | null; oncell: (row: number, col: number) => void; ontogglezoom: () => void; } = $props(); - const premClass: Record = { - '': '', - TW: 'tw', - DW: 'dw', - TL: 'tl', - DL: 'dl', - }; - const premLabel: Record = { '': '', TW: '3W', DW: '2W', TL: '3L', DL: '2L' }; + const Z = 1.85; + const z = $derived(zoomed ? Z : 1); + const premClass: Record = { '': '', TW: 'tw', DW: 'dw', TL: 'tl', DL: 'dl' }; - // Double-tap toggles zoom. + let viewport = $state(); + + // Genuine layout zoom (the board grows; cqw labels stay constant), so native scroll + // works in every browser. Keep the focus cell centred on every frame of the zoom-in + // (the board widens over ~0.25s) so it magnifies *into* that cell, rather than growing + // from the top-left corner and then jumping to centre once the transition ends. + $effect(() => { + const vp = viewport; + if (!vp || !zoomed || !focus) return; + const f = focus; + const start = performance.now(); + let raf = requestAnimationFrame(function tick(now) { + const cell = vp.scrollWidth / 15; // grows frame by frame as the board widens + vp.scrollLeft = (f.col + 0.5) * cell - vp.clientWidth / 2; + vp.scrollTop = (f.row + 0.5) * cell - vp.clientHeight / 2; + if (now - start < 300) raf = requestAnimationFrame(tick); + }); + return () => cancelAnimationFrame(raf); + }); + + // Double-tap toggles zoom (pinch was dropped — it conflicts with native scroll). let lastTap = 0; function onTap(row: number, col: number) { const now = Date.now(); @@ -48,116 +73,81 @@ oncell(row, col); } - // Minimal pinch: two pointers spreading apart zoom in, pinching together zoom out. - const pts = new Map(); - let startDist = 0; - function dist(): number { - const p = [...pts.values()]; - if (p.length < 2) return 0; - return Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y); - } - function onPointerDown(e: PointerEvent) { - pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); - if (pts.size === 2) startDist = dist(); - } - function onPointerMove(e: PointerEvent) { - if (!pts.has(e.pointerId)) return; - pts.set(e.pointerId, { x: e.clientX, y: e.clientY }); - if (pts.size === 2 && startDist > 0) { - const d = dist(); - if (!zoomed && d > startDist * 1.25) { - ontogglezoom(); - startDist = 0; - } else if (zoomed && d < startDist * 0.8) { - ontogglezoom(); - startDist = 0; - } - } - } - function onPointerUp(e: PointerEvent) { - pts.delete(e.pointerId); - if (pts.size < 2) startDist = 0; - } - - function key(r: number, c: number): string { - return `${r},${c}`; - } + const key = (r: number, c: number) => `${r},${c}`; - -
-
- {#each board as rowCells, r (r)} - {#each rowCells as cell, c (c)} - {@const p = pending.get(key(r, c))} - {@const letter = cell?.letter ?? p?.letter ?? ''} - {@const blank = cell?.blank ?? p?.blank ?? false} - +
+
+
+ {#each board as rowCells, r (r)} + {#each rowCells as cell, c (c)} + {@const p = pending.get(key(r, c))} + {@const letter = cell?.letter ?? p?.letter ?? ''} + {@const blank = cell?.blank ?? p?.blank ?? false} + {@const bl = letter ? null : bonusLabel(labelMode, premium[r][c], locale)} + + {/each} {/each} - {/each} +
diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 95726f3..6701bc8 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -1,23 +1,26 @@ -
+ {#snippet menu()} - - {#if menuOpen} - - -
(menuOpen = false)}>
- - {/if} + {/snippet} -
-{#if view} -
- {#each view.game.seats as s (s.seat)} -
-
{s.accountId === app.session?.userId ? t('common.you') : s.displayName}
-
{s.score}
-
- {/each} -
- -
- (zoomed = !zoomed)} - /> -
- -
- {t('game.bag', { n: view.bagLen })} - {#if gameOver} - {t('game.over')} — {resultText()} - {:else} - {isMyTurn ? t('game.yourTurn') : t('game.waiting', { name: view.game.seats[view.game.toMove]?.displayName ?? '' })} - {/if} - {t('game.hints', { n: view.hintsRemaining })} -
- - {#if !gameOver} -
-
- -
- {#if placement.pending.length > 0} - - {/if} + {#if view} +
+ {#each view.game.seats as s (s.seat)} +
+
{s.accountId === app.session?.userId ? t('common.you') : s.displayName}
+
{s.score}
+
+ {/each}
- +
+ {#if historyOpen} +
+
    + {#each moves as m, i (i)} +
  1. + {view.game.seats[m.player]?.displayName ?? m.player} + {m.action === 'play' ? m.words.join(', ') : m.action} + {m.score} +
  2. + {/each} + {#if moves.length === 0}
  3. {/if} +
+
+ {/if} + + + +
historyOpen && (historyOpen = false)} + > + (zoomed = !zoomed)} + /> +
+
+ +
+ {t('game.bag', { n: view.bagLen })} + {#if gameOver} + {t('game.over')} — {resultText()} + {:else} + {isMyTurn ? t('game.yourTurn') : view.game.seats[view.game.toMove]?.displayName ?? ''} + {/if} + + {#if preview}{preview.legal ? t('game.scores', { n: preview.score }) : t('game.previewIllegal')}{/if} + +
+ + {#if !gameOver} +
+
+ +
+ {#if placement.pending.length > 0} + + {#snippet trigger()}🏁{/snippet} + {#snippet popover(close)} + + + {/snippet} + + {/if} +
+ {/if} + {:else} +

{t('common.loading')}

{/if} -{:else} -

{t('common.loading')}

-{/if} + + {#snippet tabbar()} + {#if view && !gameOver} + + + + {#snippet trigger()}🥺{t('game.skip')}{/snippet} + {#snippet popover(close)}{/snippet} + + + {#snippet trigger()} + 🛟{#if (view?.hintsRemaining ?? 0) > 0}{view?.hintsRemaining}{/if} + {t('game.hint')} + {/snippet} + {#snippet popover(close)}{/snippet} + + + + {/if} + {/snippet} + {#if drag}
@@ -460,8 +524,13 @@ {#if checkOpen} (checkOpen = false)}>
- e.key === 'Enter' && runCheck()} /> - + e.key === 'Enter' && runCheck()} + placeholder={t('game.checkWordPrompt')} + /> +
{#if checkResult}

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

    - {#each moves as m, i (i)} -
  1. - {view.game.seats[m.player]?.displayName ?? m.player} - {m.action === 'play' ? m.words.join(', ') : m.action} - {m.score} -
  2. - {/each} -
-
-{/if} - diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte index e00beae..9a690bb 100644 --- a/ui/src/game/Rack.svelte +++ b/ui/src/game/Rack.svelte @@ -15,45 +15,41 @@ selected: number | null; ondown: (e: PointerEvent, index: number) => void; } = $props(); + + // Used slots are hidden (the rack shifts left, freeing room on the right for the + // MakeMove control); the slot still exists in the model for per-tile recall. + const visible = $derived(slots.filter((s) => !s.used));
- {#each slots as slot (slot.index)} - {#if slot.used} - - {:else} - - {/if} + {#each visible as slot (slot.index)} + {/each}
diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index 74ee12a..40d9c04 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -1,6 +1,6 @@ -
-
- {#if searching} -
-
-

{t('new.searching')}

- -
- {:else} -

{t('new.subtitle')}

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

{t('new.searching')}

+ +
+ {:else} +

{t('new.subtitle')}

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