From 77a690fcf698dae51bb9303a919d2340b6f42de5 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 10 Jul 2026 14:58:32 +0200 Subject: [PATCH 1/5] =?UTF-8?q?feat(ui):=20move=20in-game=20status=20to=20?= =?UTF-8?q?the=20board=20=E2=80=94=20highlight,=20score=20badge,=20full-wi?= =?UTF-8?q?dth=20rack?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the under-board status strip and relocate its signals: - bag count -> a badge on the exchange/pass control + the foot of the move table - whose-turn / win-lose -> a thin strip above the score plaques - the tentative-move caption -> the board itself: staged tiles tint green (legal) or pink (illegal), the board tiles a formed word runs through go a shade darker, and an orange score badge sits on the main word (digit sized like a tile value, clamped on-board) The word geometry (covered cells + badge anchor) is a new pure client-side helper (ui/src/lib/formed.ts), independent of the move evaluator, so it works on the local or network preview path alike; the badge's number still comes from the preview score. Rack: a seven-column grid filling the tray width in both layouts — square, full-width tiles — with the confirm control in the fixed 7th slot. Settings: a touch-only "Zoom the board" toggle (default on, device-local) gates the tile-placement auto-zoom; taking a hint while zoomed in now zooms out so the highlighted hint word is never left off-screen. Docs (FUNCTIONAL +_ru, UI_DESIGN, ARCHITECTURE) and e2e/unit tests updated. --- docs/ARCHITECTURE.md | 6 +- docs/FUNCTIONAL.md | 15 ++- docs/FUNCTIONAL_ru.md | 16 ++- docs/UI_DESIGN.md | 51 +++++---- ui/e2e/game.spec.ts | 4 +- ui/e2e/ingame-ux.spec.ts | 41 +++++++ ui/e2e/quickmatch.spec.ts | 2 +- ui/e2e/smoke.spec.ts | 4 +- ui/e2e/zoom.spec.ts | 26 ++--- ui/src/Landing.svelte | 1 + ui/src/app.css | 16 +++ ui/src/game/Board.svelte | 68 +++++++++++ ui/src/game/Game.svelte | 204 +++++++++++++++++---------------- ui/src/game/Rack.svelte | 66 ++++------- ui/src/lib/app.svelte.ts | 11 ++ ui/src/lib/formed.test.ts | 116 +++++++++++++++++++ ui/src/lib/formed.ts | 164 ++++++++++++++++++++++++++ ui/src/lib/i18n/en.ts | 3 +- ui/src/lib/i18n/ru.ts | 3 +- ui/src/lib/session.ts | 1 + ui/src/screens/Settings.svelte | 15 +++ 21 files changed, 635 insertions(+), 198 deletions(-) create mode 100644 ui/e2e/ingame-ux.spec.ts create mode 100644 ui/src/lib/formed.test.ts create mode 100644 ui/src/lib/formed.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index fcf7749..540f210 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -435,7 +435,11 @@ Key points: through a **session-gated `GET /dict/{variant}/{version}`** edge route (immutable; cached in IndexedDB best-effort) and reused across sessions; any miss, storage eviction or a bad-connection breaker falls back to the network - `evaluate`. The warm-up overlay is `docs/UI_DESIGN.md`. + `evaluate`. The warm-up overlay is `docs/UI_DESIGN.md`. The **on-board rendering** of a staged + play — the legality tint, the cells a formed word covers, and where the score badge anchors — is a + separate **pure geometry helper** (`ui/src/lib/formed.ts`) derived from the board and the staged + tiles, so it works regardless of which eval path produced the preview (the badge's number still + comes from the preview's score): legality and score stay with the eval, geometry with the board. ## 6. Game rules diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 930ba72..29caaeb 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -204,13 +204,16 @@ client, and refuses a guest's friend request, friend code or invitation outright ### Playing a game Place tiles, pass, exchange, or resign. Pass and exchange share one control — -choosing no tiles passes the turn, choosing tiles exchanges them. Tiles are laid +choosing no tiles passes the turn, choosing tiles exchanges them. The tiles left in the +bag are shown as a count on that control and at the foot of the move table. Tiles are laid without choosing a direction — the game infers the play's orientation, so a single tile that extends an existing word (down a column or across a row) is accepted. A play is validated -against the game's dictionary at submit time and scored; an unlimited preview -reports the word(s) a tentative move would form and its score, or that it is not -legal, and the move is offered for submission only once it is confirmed legal. The preview is +against the game's dictionary at submit time and scored; an unlimited preview shows +**on the board itself** whether the tentative move is legal — the staged tiles turn a light +green once they form a word, a shade darker on the board tiles the word runs through, or a +calm pink when they form none — and an **orange badge** on the main word carries the move's +score. The confirm control is offered only once the move is legal. The preview is computed **on-device** for an instant response once the game's dictionary has loaded — a brief warm-up shows on the first open otherwise — and falls back to the server whenever the local dictionary is unavailable. The dictionary check tool is @@ -372,7 +375,9 @@ an email or Telegram and merging accounts are covered under "Accounts, linking & merge". Inside the Telegram Mini App, Telegram's own ⋮ menu also offers a **Settings** entry that opens this screen, and your display preferences (theme, board-label style and reduce-motion — not the interface language, which follows your account) sync across your -Telegram devices. +Telegram devices. On a touch device the settings also offer a **Zoom the board** toggle (on by +default): with it off, dropping a tile no longer auto-magnifies the board toward it. It is a +per-device preference and is hidden on desktop, where the board already fits and never auto-zooms. **Preferences (which variants you can be matched into).** A profile setting picks the game variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index c89f15d..b01c083 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -212,13 +212,16 @@ e-mail) либо ввод фразы. Активные игры форфейтя ### Игровой процесс Выкладывание фишек, пас, обмен или сдача. Пас и обмен — один элемент управления: -без выбранных фишек — пас, с выбранными — обмен. Фишки кладутся без выбора +без выбранных фишек — пас, с выбранными — обмен. Число оставшихся в мешке фишек +показано счётчиком на этом элементе и внизу таблицы ходов. Фишки кладутся без выбора направления — игра сама определяет ориентацию хода, поэтому одна фишка, продолжающая уже лежащее слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при -сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое -образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно -отправить только после подтверждения, что он допустим. Предпросмотр считается **на устройстве** +сдаче и считается; безлимитный предпросмотр показывает **прямо на доске**, допустим ли +предполагаемый ход: выложенные фишки становятся светло-зелёными, когда образуют слово +(и чуть темнее — фишки доски, через которые проходит слово), или спокойно-розовыми, +когда слова нет, — а **оранжевый бейдж** на основном слове несёт очки хода. Кнопка +подтверждения появляется только когда ход допустим. Предпросмотр считается **на устройстве** и отвечает мгновенно, как только словарь партии загружен — иначе при первом открытии показывается короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на @@ -381,7 +384,10 @@ UTC; при создании аккаунта она подставляется Mini App пункт **Settings** в системном меню «⋮» Telegram также открывает этот экран, а ваши настройки отображения (тема, стиль подписей клеток и reduce-motion — кроме языка интерфейса, который следует за аккаунтом) синхронизируются между вашими устройствами в -Telegram. +Telegram. На сенсорном устройстве в настройках также есть переключатель **«Приближать +доску»** (по умолчанию включён): если выключить, при кидании фишки доска больше не +приближается к ней автоматически. Это настройка на устройство, и она скрыта на десктопе, +где доска и так помещается целиком и авто-зума нет. **Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 2bb4fbb..7681013 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -179,16 +179,16 @@ e2e and the screenshots. back near the edges. It **recentres only on a zoom-in** — placing a 2nd+ tile or hovering a dragged tile never jumps the board. On touch the first tile placement auto-zooms in centred on the target, and **holding a dragged tile over a cell ~1 s** auto-zooms there - the first time. A **swipe down on the zoom-out board** opens the history, but only when the + the first time — both gated by the **Zoom the board** setting (on by default; the toggle is + shown only on a touch device). A **swipe down on the zoom-out board** opens the history, but only when the board is scrolled to its top so it never fights the stage's own vertical scroll (the conflict that once retired this gesture) — and it is suppressed on the zoomed board, where the one-finger drag pans (and on desktop / the landscape iframe, where a mouse cannot drag-scroll the viewport natively, a **drag-to-pan** handler moves the zoomed board instead — active only while zoomed, off pending tiles, past a small threshold, swallowing the trailing click). History also opens on a **tap of the score bar** and closes on a tap or - an **upward swipe** of the then-inert board (below). A **hint** auto-zooms centred on the - hint's placement, not - the top-left. + an **upward swipe** of the then-inert board (below). Taking a **hint** while zoomed in **zooms + out** to the whole board, so the hint's word — highlighted (below) — is never left off-screen. - **Placing & recall** (`Game.svelte`): a rack tile is placed by tap-then-tap or by dragging it onto a cell; while a dragged tile is carried over the board, the aimed-at empty cell is **highlighted as a drop target** (an accent ring). A pending tile is taken back by a @@ -224,7 +224,7 @@ e2e and the screenshots. are pinned to the card's edges. Unread chat is also badged on the score bar itself, so it shows with the history closed. - **Vertical fit & keyboard**: when the game does not fit the viewport, only the - board area scrolls vertically (`Screen` `column` mode; the score bar, status, rack and tab + board area scrolls vertically (`Screen` `column` mode; the turn strip, score bar, rack and tab bar stay fixed), while zoom keeps its own scroll. The check-word dialog opens in `Modal` keyboard-overlay mode — the small sheet is top-anchored and the soft keyboard overlays the empty area below, so the layout doesn't resize/jank; other modals stay @@ -235,23 +235,31 @@ e2e and the screenshots. (`min(100cqw, 100cqh)`, height-driven in `Board.svelte`'s `.scaler.land`) inside a `container-type: size` pane, shrinking by width when the column is narrow — the board has the **lowest priority**, so the left panel is never squeezed. The **left panel** stacks, top to - bottom: the score plaques, the **always-open** history (docked and scrolling with its header - sticky — no slide-down drawer, no score-bar toggle), the status line (bag · turn · score - preview), the rack (+ the ✅ make control) and, pinned at the bottom, the controls tab bar. Board + bottom: the **turn/result strip**, the score plaques, the **always-open** history (docked and + scrolling with its header sticky and the **bag count** pinned at its foot — no slide-down drawer, + no score-bar toggle), the rack (with the ✅ confirm control in its fixed 7th slot) and, pinned at + the bottom, the controls tab bar. Board **zoom works as in portrait** (double-tap / pinch / placement auto-zoom), but the viewport is the full pane: zoom-out shows the height-fitted square centred, and zoom-in magnifies the board past the pane and pans within it, occupying the full width up to the left panel (the focus-centred scroll is set directly, without the portrait width-progress tween). Only the history open/close swipes are dropped (it is always open) and the nav bar does not grow (`growNav` off). The portrait layout is byte-for-byte unchanged; both layouts render from the same snippets, so the - behaviour and markup stay single-sourced. The rack tiles size to the (fixed-width) panel rather - than the viewport width so seven tiles never overflow the column. -- **Highlights**: pending tiles use a slightly darker tile background (no outline). The + behaviour and markup stay single-sourced. The rack is a **seven-column grid** filling the tray + width in both layouts, so the tiles are square and as large as the row allows (the confirm ✅ takes + the fixed 7th slot while a play is staged). +- **Highlights**: while composing a play the staged tiles are **tinted by legality** — a light + green once they form a word (a shade darker on the committed board tiles the word runs through), + a calm pink when they form none — and an **orange score badge** sits on a corner of the main + word's tile, its digit sized like a tile's point value (so it scales with the zoom) and clamped + to stay on the board. Off-turn or with no preview the staged tiles keep the plain pending fill. + The colours are theme tokens in `app.css` (`--tile-pending-legal` / `-illegal`, `--tile-formed`, + `--score-badge`); the geometry — which cells a word covers and where the badge anchors — is a + pure client-side helper (`lib/formed.ts`), independent of the move evaluator. The last completed word keeps the normal tile background; instead its letters — not the point values — are drawn in the recent-move colour, in both themes. It is static while it is the opponent's turn (our word), and a 1 s flash (the letter pulses between its normal colour - and the recent-move colour) when it is our turn (their word). While placing, only the - pending tiles are highlighted. + and the recent-move colour) when it is our turn (their word). - **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none` nothing. Default **beginner**. @@ -267,18 +275,21 @@ e2e and the screenshots. otherwise it reverts. Used by the **Hint** tab (the icon morphs to ✅, no label — replacing the old press-and-hold popover) and the in-game **add-friend 🤝** and **block ✖️** card controls. The **Drop game** action keeps its `Modal` confirmation (a destructive, less-frequent action). -- **MakeMove / Reset**: when ≥1 tile is pending the rack collapses its used slots - and shifts left, a **borderless ✅ icon button** (styled like a tab, not a filled accent - button) beside the rack commits the move — no popover, and disabled while the pending word - is known illegal; the 🔀 Shuffle tab is replaced by a **↩️ Reset** tab. -- **Game tab bar**: 🔄 Exchange/Pass (opens a dialog — pick tiles to **Exchange N**, or pick +- **Confirm / Reset**: while ≥1 tile is pending a **borderless ✅ icon button** (styled like a + tab, not a filled accent button) takes the rack's **fixed 7th slot** and commits the move — no + popover, and disabled while the pending word is known illegal; the 🔀 Shuffle tab is replaced by + a **↩️ Reset** tab. +- **Game tab bar**: 🔄 Exchange/Pass (a **bag-count badge** on its icon while the bag is non-empty; + opens a dialog — pick tiles to **Exchange N**, or pick none to **Pass without exchanging**; pass is always available on your turn, exchange only while the bag still holds a full rack, below which the tiles disable and only the pass remains), 🛟 Hint (with a remaining-count badge, disabled at zero); 🔀 Shuffle (no label, no confirm), which **animates** — tiles hop along a low parabola to their new slots (duration scaled by the - distance, the longest ≤ 0.3 s; off under reduce-motion) with a short haptic shake. The - under-board slot shows the **Scores: N** preview. The screen **title** is the variant's + distance, the longest ≤ 0.3 s; off under reduce-motion) with a short haptic shake. Whose turn it + is — or the viewer's result once the game ends — shows in a **thin strip above the score + plaques**; the tentative move's score rides the board badge (above), not an under-board caption. + The screen **title** is the variant's display name (Scrabble / Скрэббл / Erudite / Эрудит), not a constant "Scrabble". ## Advertising banner (`components/AdBanner.svelte` in `components/Header.svelte`, `lib/banner.ts` + `lib/bannerEngine.ts`) diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index ec2f1b3..d0a2fa1 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -261,7 +261,7 @@ test('dropping the game ends it and shows the result', async ({ page }) => { await page.locator('.scoreboard').click(); // open the history await page.getByRole('button', { name: 'Drop game' }).click(); // 🏁 in the history header await page.locator('button.danger').click(); // confirm in the modal - await expect(page.locator('.status .over')).toBeVisible(); + await expect(page.locator('.turnstrip.result')).toBeVisible(); }); test('resigning reveals the full board: closes the history and zooms out', async ({ page }) => { @@ -283,7 +283,7 @@ test('resigning reveals the full board: closes the history and zooms out', async await page.getByRole('button', { name: 'Drop game' }).click(); // 🏁 await page.locator('button.danger').click(); // confirm - await expect(page.locator('.status .over')).toBeVisible(); // the game ended + await expect(page.locator('.turnstrip.result')).toBeVisible(); // the game ended await expect(page.locator('.history')).toHaveCount(0); // history auto-closed (portrait) await expect(page.locator('.viewport.zoomed')).toHaveCount(0); // board zoomed out }); diff --git a/ui/e2e/ingame-ux.spec.ts b/ui/e2e/ingame-ux.spec.ts new file mode 100644 index 0000000..d91c073 --- /dev/null +++ b/ui/e2e/ingame-ux.spec.ts @@ -0,0 +1,41 @@ +import { expect, test } from './fixtures'; + +// The in-game composition UX after the under-board status strip was removed: +// - whose turn (or the final result) shows in a thin strip above the score plaques; +// - the tiles left in the bag ride the exchange control as a badge and repeat at the foot of the +// move table; +// - staging a play tints its tiles on the board and shows the orange move-score badge, and the +// confirm control sits in the rack's fixed 7th slot. +// Mock transport only: the mock has no dictionary (Game.svelte skips the local evaluator under the +// mock mode) and its network evaluator accepts any placement, so a staged tile always reads as legal. + +test('in-game UX: turn strip, bag badge + table footer, staged-play highlight and score badge', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: /guest/i }).click(); + await page.getByRole('button', { name: /🎲/ }).click(); + await page.getByRole('button', { name: 'Random player' }).click(); + await page.locator('.variant').first().click(); + await page.getByRole('button', { name: /Start game/i }).click(); + // Attach the opponent deterministically, then it is the player's turn. + await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent()); + await expect(page.getByText('Robo')).toBeVisible(); + + // The old under-board status line is gone; a thin turn strip sits above the plaques instead. + await expect(page.locator('.turnstrip')).toBeVisible(); + await expect(page.locator('.status')).toHaveCount(0); + + // The bag count rides the exchange control (the first tab) as a badge, and repeats at the foot of + // the move table, out of the scrolling grid. + await expect(page.locator('.tab').first().locator('.badge')).toBeVisible(); + await page.locator('.scoreboard').click(); // open the history drawer + await expect(page.locator('.hbagfoot')).toContainText(/bag/i); + await page.locator('.scoreboard').click(); // close it again + + // Stage a play: the pending tile reads as legal (green), the orange move-score badge shows on the + // board, and the confirm control takes the rack's 7th slot. + await page.locator('.rack .tile').first().click(); + await page.locator('[data-cell]:not(.filled)').nth(112).click(); // centre (row 7, col 7) + await expect(page.locator('[data-cell].pending.legal')).toHaveCount(1); + await expect(page.locator('.scorebadge')).toBeVisible(); + await expect(page.locator('.make')).toBeVisible(); +}); diff --git a/ui/e2e/quickmatch.spec.ts b/ui/e2e/quickmatch.spec.ts index ddf85d2..6699aae 100644 --- a/ui/e2e/quickmatch.spec.ts +++ b/ui/e2e/quickmatch.spec.ts @@ -75,7 +75,7 @@ test('AI game: after it ends, no GCG export and no comms entry', async ({ page } await page.locator('.scoreboard').click(); await page.getByRole('button', { name: 'Drop game' }).click(); await page.locator('button.danger').click(); - await expect(page.locator('.status .over')).toBeVisible(); + await expect(page.locator('.turnstrip.result')).toBeVisible(); // Reopen the history (resigning auto-closed it): the finished AI game offers no GCG export and // no comms entry at all. diff --git a/ui/e2e/smoke.spec.ts b/ui/e2e/smoke.spec.ts index 81900fc..e5b89c6 100644 --- a/ui/e2e/smoke.spec.ts +++ b/ui/e2e/smoke.spec.ts @@ -22,8 +22,8 @@ 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); - // The score preview appears where the hints count used to be. - await expect(page.locator('.scores')).toContainText(/\d/); + // The move-score badge appears on the board for the staged play. + await expect(page.locator('.scorebadge')).toContainText(/\d/); // 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 index 5144acf..d5bf0b6 100644 --- a/ui/e2e/zoom.spec.ts +++ b/ui/e2e/zoom.spec.ts @@ -74,11 +74,11 @@ test.describe('touch placement', () => { }); }); -// A hint taken while the board is ALREADY zoomed in must still scroll to the played word — the -// zoom state does not change, so without an explicit recenter the board stays parked where the -// player was looking (the reported rough edge). The mock hint plays at the centre star (7,7), so -// zooming into the top-left corner first and then hinting must pan the board toward the centre. -test('a hint recentres an already-zoomed board on the played word', async ({ page }) => { +// A hint taken while the board is ALREADY zoomed in now zooms OUT to the whole board, so the played +// word — highlighted while composing — is guaranteed visible rather than parked off-screen under the +// old zoom (the owner changed this from the former recentre-on-the-word behaviour, so the word can +// never be lost off-screen). The mock hint plays at the centre star (7,7). +test('a hint zooms out an already-zoomed board so the played word is visible', async ({ page }) => { await page.goto('/'); await page.getByRole('button', { name: /guest/i }).click(); await page.getByRole('button', { name: /Ann/ }).click(); @@ -91,19 +91,17 @@ test('a hint recentres an already-zoomed board on the played word', async ({ pag el.click(); el.click(); }); - const viewport = page.locator('.viewport.zoomed'); - await expect(viewport).toBeVisible(); - await page.waitForTimeout(400); // let the zoom-in settle near the top-left corner - const before = await viewport.evaluate((el) => ({ left: el.scrollLeft, top: el.scrollTop })); + await expect(page.locator('.viewport.zoomed')).toBeVisible(); // Take a hint (the control confirms on a second tap), which plays at the centre. const hint = page.getByRole('button', { name: 'Hint' }); await hint.click(); await hint.click(); - await page.waitForTimeout(300); // the board pans to the hint word - const after = await viewport.evaluate((el) => ({ left: el.scrollLeft, top: el.scrollTop })); - // The board panned from the top-left toward the centre word: both offsets grew. - expect(after.left).toBeGreaterThan(before.left + 20); - expect(after.top).toBeGreaterThan(before.top + 20); + // The board zoomed out — no zoomed viewport — with the hint's play staged (the confirm control + // shows). The mock hint plays on the centre star, which the seeded game already occupies, so the + // staged tile hides under the committed one; the confirm control is the reliable "a play is + // staged" signal. + await expect(page.locator('.viewport.zoomed')).toHaveCount(0); + await expect(page.locator('.make')).toBeVisible(); }); diff --git a/ui/src/Landing.svelte b/ui/src/Landing.svelte index 44f918a..4fa71a0 100644 --- a/ui/src/Landing.svelte +++ b/ui/src/Landing.svelte @@ -55,6 +55,7 @@ locale: lc, reduceMotion: prefs.reduceMotion ?? false, boardLabels: prefs.boardLabels ?? 'beginner', + zoomBoard: prefs.zoomBoard ?? true, }); prefs = { ...prefs, locale: lc }; } diff --git a/ui/src/app.css b/ui/src/app.css index 2e573a7..aa3e6e3 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -29,6 +29,14 @@ --tile-edge: #d8c190; --tile-text: #2a2113; --tile-pending: #f2cf73; + /* In-composition highlight of the staged play (see game/Board.svelte): a calm reddish-pink + when the tiles form no word, a light green for the player's own tiles once they do, a + deeper green for the board tiles a formed word runs through, and the orange move-score + badge. Tuned per theme; refined on the contour. */ + --tile-pending-illegal: #f2c7bf; + --tile-pending-legal: #c9e7bd; + --tile-formed: #a5cf93; + --score-badge: #e5811d; /* Last-word highlight letter — a lighter burgundy than the dark theme on purpose: against the lighter tile the perceived contrast needs it, so the two are tuned per theme. */ --tile-recent: #9c5849; @@ -84,6 +92,10 @@ --tile-edge: #b6a473; --tile-text: #20190d; --tile-pending: #d8b75e; + --tile-pending-illegal: #d8a99f; + --tile-pending-legal: #a9cf94; + --tile-formed: #83b571; + --score-badge: #e88f31; --tile-recent: #8c4a3c; --prem-tw: #9c3f34; /* 3x word: a touch darker red */ --prem-dw: #a8636b; /* 2x word: softer, pinker */ @@ -115,6 +127,10 @@ --tile-edge: #b6a473; --tile-text: #20190d; --tile-pending: #f0d98f; + --tile-pending-illegal: #d8a99f; + --tile-pending-legal: #a9cf94; + --tile-formed: #83b571; + --score-badge: #e88f31; /* Last-word highlight letter — a warm burgundy whose red hue stays distinct from both the near-black glyph and the warm tile. The light theme uses a lighter burgundy (tuned per theme; perceived contrast depends on the surrounding board). */ diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index 3e94fa3..998c273 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -23,6 +23,9 @@ focus, recenter, dropTarget, + previewLegal, + formed, + scoreBadge, oncell, ontogglezoom, onrecall, @@ -48,6 +51,15 @@ recenter: number; /** The cell a dragged tile is currently aimed at, highlighted as a drop target. */ dropTarget: { row: number; col: number } | null; + /** While composing: true when the staged tiles form a legal play, false when not, null when + * there is no preview (off-turn, nothing staged, or a recall drag) — tints the pending tiles + * green vs a calm pink. */ + previewLegal: boolean | null; + /** "row,col" keys of every committed board cell a formed word runs through, greened a shade + * darker than the player's own staged tiles. */ + formed: Set; + /** The orange move-score badge for the legal play being composed, or null. */ + scoreBadge: { row: number; col: number; corner: 'tr' | 'bl'; score: number } | null; oncell: (row: number, col: number) => void; ontogglezoom: (row: number, col: number) => void; /** Recall the pending tile at (row, col) — fired on a double-tap of a pending cell. */ @@ -60,6 +72,20 @@ const z = $derived(zoomed ? Z : 1); const premClass: Record = { '': '', TW: 'tw', DW: 'dw', TL: 'tl', DL: 'dl' }; + // The score badge sits on a corner of its anchor cell, clamped so the whole pill stays on the + // board. Its position is a percentage of the (15-cell) board; CSS centres the pill on the point. + const BADGE_MARGIN = 2.4; + const badgePos = $derived.by(() => { + const b = scoreBadge; + if (!b) return null; + const cell = 100 / 15; + let x = b.corner === 'tr' ? (b.col + 1) * cell : b.col * cell; + let y = b.corner === 'tr' ? b.row * cell : (b.row + 1) * cell; + x = Math.max(BADGE_MARGIN, Math.min(100 - BADGE_MARGIN, x)); + y = Math.max(BADGE_MARGIN, Math.min(100 - BADGE_MARGIN, y)); + return { x, y }; + }); + let viewport = $state(); // Genuine layout zoom (the board grows; cqw labels stay constant), so native scroll @@ -315,6 +341,9 @@ class="cell {premClass[premium[r][c]]}" class:filled={!!cell} class:pending={!!p && !cell} + class:legal={!!p && !cell && previewLegal === true} + class:illegal={!!p && !cell && previewLegal === false} + class:formed={!!cell && formed.has(key(r, c))} class:hl={!!cell && highlight.has(key(r, c)) && !flash} class:flash={!!cell && flash && highlight.has(key(r, c))} class:dark={premium[r][c] === '' && !cell && !p && (r + c) % 2 === 1} @@ -342,6 +371,9 @@ {/each} {/each} + {#if scoreBadge && badgePos} + {scoreBadge.score} + {/if} @@ -397,6 +429,8 @@ gap: 0; background: var(--board-bg); padding: 0; + /* Anchor for the absolutely-positioned score badge (its percentages resolve against the board). */ + position: relative; } .cell { position: relative; @@ -445,6 +479,19 @@ board) instead of the touch starting a board pan. */ touch-action: none; } + /* While composing, the staged play recolours its tiles: a calm reddish-pink when they form no + word, a light green for the player's own tiles once they do; committed board tiles a formed + word runs through go a shade darker (see lib/formed + Game.svelte). .formed sits after .filled + so it wins on committed cells (equal specificity). */ + .cell.pending.illegal { + background: var(--tile-pending-illegal); + } + .cell.pending.legal { + background: var(--tile-pending-legal); + } + .cell.formed { + background: var(--tile-formed); + } .cell.droptarget { /* The cell a carried tile is aimed at: an accent ring plus a light accent wash, so the target reads clearly while dragging without obscuring the bonus label underneath. */ @@ -545,4 +592,25 @@ font-weight: 700; white-space: nowrap; } + /* The move-score badge for the legal play being composed: an orange pill centred on a corner of + the main word's tile (see lib/formed.badgePlacement + Game.svelte), its digit sized like a + tile's point value so it scales with the zoom. Decorative — no pointer events — and drawn above + the tiles. */ + .scorebadge { + position: absolute; + transform: translate(-50%, -50%); + z-index: 5; + pointer-events: none; + border-radius: 999px; + background: var(--score-badge); + color: #fff; + font-weight: 700; + line-height: 1; + white-space: nowrap; + padding: 2px 5px; /* px fallback for Chrome < 105 (no cqw) */ + padding: 0.4cqw 0.9cqw; + font-size: calc(2.4vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */ + font-size: 2.4cqw; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25); + } diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index bf16d99..324cf30 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -49,6 +49,7 @@ type Placement, } from '../lib/placement'; import { liveDraftTiles, parseDraft, serializeDraft, validRackOrder } from '../lib/draft'; + import { badgePlacement, formedGeometry } from '../lib/formed'; let { id }: { id: string } = $props(); @@ -483,6 +484,21 @@ // move's ✅ confirm and its preview caption are hidden so they don't sit over the opening gap; // they return the moment the tile is dragged back out over the board. const recallOverRack = $derived(draggingPend != null && reorderTo != null); + // The composed play's word geometry (client-side, independent of the move evaluator): the cells a + // formed word runs through — greened on the board — and the main word that anchors the score + // badge. Derived only for a legal preview, and never while dragging a staged tile back over the + // rack (recallOverRack), so the highlight and badge clear the instant a recall begins. + const EMPTY_FORMED = new Set(); + const formedGeom = $derived( + preview?.legal && !recallOverRack ? formedGeometry(board, placement.pending) : null, + ); + const formedCells = $derived(formedGeom?.cells ?? EMPTY_FORMED); + const boardBadge = $derived( + formedGeom && preview?.legal ? { ...badgePlacement(formedGeom.main), score: preview.score } : null, + ); + // The pending-tile tint: null (default colour) with no preview or during a recall drag, otherwise + // the play's legality — green when legal, a calm pink when not. + const previewLegal: boolean | null = $derived(recallOverRack ? null : preview ? preview.legal : null); let dragPointerId = -1; function beginDrag(src: DragSrc, e: PointerEvent) { @@ -610,7 +626,7 @@ ? setTimeout(() => { // Still holding the tile over this cell: magnify into it. Only the first // (zoom-in) hold centres; once zoomed we never move the board on hover. - if (drag && isCoarse() && !landscape && !zoomed) { + if (drag && isCoarse() && !landscape && !zoomed && app.zoomBoard) { focus = c; zoomed = true; haptic('light'); @@ -729,8 +745,8 @@ if (pendingMap.has(`${row},${col}`)) return; focus = { row, col }; // Auto-zoom is portrait-only: landscape fits the whole board, magnifying it there - // only hides the rest of the position. - if (isCoarse() && !landscape && !zoomed) zoomed = true; + // only hides the rest of the position. The "Zoom the board" setting can turn it off. + if (isCoarse() && !landscape && !zoomed && app.zoomBoard) zoomed = true; if (placement.rack[index] === BLANK) { blankPrompt = { rackIndex: index, row, col }; return; @@ -1030,23 +1046,11 @@ // Mark the turn as hinted: the interstitial fires when the player CONFIRMS the move (commit), // not now — showing it here would interrupt placing the preview and revert the board on close. hintUsedThisTurn = true; - // Scroll the (zoomed) board to the hint's placement rather than the top-left: - // focus the centre of the laid tiles' bounding box. - const p = placement.pending; - if (p.length) { - const rows = p.map((tt) => tt.row); - const cols = p.map((tt) => tt.col); - focus = { - row: Math.round((Math.min(...rows) + Math.max(...rows)) / 2), - col: Math.round((Math.min(...cols) + Math.max(...cols)) / 2), - }; - // Ask the board to scroll to the hint word. A zoomed-out board zooms in (and centres) - // on the next line (portrait only); this nonce also recentres a board that is already - // zoomed in, where the unchanged zoom state would otherwise leave it parked where the - // player was looking. - recenter++; - } - if (isCoarse() && !landscape) zoomed = true; + // A hint just landed: if the board was zoomed in, zoom OUT to the whole board so the hint's + // word — highlighted green while composing — is guaranteed visible rather than possibly + // parked off-screen under the old zoom. A full board needs no scroll-to-word, so the former + // focus/recenter step is gone with the zoom-in. + if (zoomed) zoomed = false; view = { ...view, hintsRemaining: h.hintsRemaining }; syncWallet(h.walletBalance); // The hint is the engine's own top-ranked, fully scored legal move: reuse it as the @@ -1486,15 +1490,16 @@ {#if landscape}
+ {@render turnStrip()} {@render scoreboardBlock()}
{@render historyBody()}
- {@render statusBlock()} {@render rackRow()} {@render controlButtons()}
{@render boardBlock()}
{:else} + {@render turnStrip()} {@render scoreboardBlock()}
{#if historyOpen} @@ -1502,7 +1507,6 @@ {/if} {@render boardBlock()}
- {@render statusBlock()} {@render rackRow()} {/if} {:else} @@ -1517,6 +1521,21 @@ +{#snippet turnStrip()} + + {#if view && !(gameOver && view.game.hotseat)} +
+ {#if gameOver} + {resultText()} + {:else} + {view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()} + {/if} +
+ {/if} +{/snippet} + {#snippet scoreboardBlock()} {#if view} {@const badge = badgeKind(app.chatUnread[id] ?? false, app.messageUnread[id] ?? false)} @@ -1603,10 +1622,11 @@ {/each} {/each} + {#if view.game.endReason === 'aborted'} +

{t('game.abortedNote')}

+ {/if} - {#if view.game.endReason === 'aborted'} -

{t('game.abortedNote')}

- {/if} +
{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}
{/if} {/snippet} @@ -1639,6 +1659,9 @@ {focus} {recenter} {dropTarget} + {previewLegal} + formed={formedCells} + scoreBadge={boardBadge} oncell={onCell} ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }} onrecall={onRecall} @@ -1647,25 +1670,6 @@ {/snippet} -{#snippet statusBlock()} - {#if view} -
- {view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })} - {#if gameOver} - - {#if !view.game.hotseat}{resultText()}{/if} - {:else if placement.pending.length === 0} - {view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()} - {/if} - - {#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{/if} - -
- {/if} -{/snippet} - {#snippet rackRow()} @@ -1680,23 +1684,26 @@ slots={rackSlots} {variant} {selected} - {landscape} shuffling={shuffling && !app.reduceMotion} draggingId={reorderDragId} dropIndex={reorderTo} + confirm={!gameOver && placement.pending.length > 0 && !recallOverRack ? confirmBtn : undefined} ondown={onRackDown} /> {/if} - {#if !gameOver && placement.pending.length > 0 && !recallOverRack} - - {/if} {/snippet} +{#snippet confirmBtn()} + + +{/snippet} + {#snippet controlButtons()} {#if view?.game.hotseat} {#snippet turnStrip()} - {#if view && !(gameOver && view.game.hotseat)}
{#if gameOver} {resultText()} + {:else if preview?.legal && !recallOverRack} + {preview.words.map((w) => w.toUpperCase()).join('+')} = {preview.score} {:else} {view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()} {/if} @@ -1764,7 +1769,11 @@ {/if} {#if exchangeOpen && view} - (exchangeOpen = false)}> + (exchangeOpen = false)} + >
{#each view.rack as letter, i (i)} -
- {/if} - {#each values as p (p.productId)} -
- {p.title} - 🪙 {p.chips} - -
- {/each} +
+ + +
- {#if gpBuild} -

{t('wallet.gpStub')}

+ {#if tab === 'buy'} + {#if purchaseBlocked} + +

{t('wallet.iosBlockedPre')}{t('wallet.iosBlockedLink')}{t('wallet.iosBlockedPost')}

+ {/if} + {#if canReward} +
+ {t('wallet.rewardWatch', { n: wallet?.rewardChips ?? 0 })} + +
+ {/if} + {#if gpBuild} +

{t('wallet.gpStub')}

+ {:else} + {#each packs as p (p.productId)} +
+ {p.title} + {formatAmount(p.moneyAmount, p.moneyCurrency)} {currencyLabel(p.moneyCurrency)} + +
+ {/each} + {#if packs.length > 0} +

+ {t('wallet.offer')} +

+ {:else if !loading} +

{t('wallet.empty')}

+ {/if} + {/if} {:else} - {#each packs as p (p.productId)} -
+ {#each values as p (p.productId)} +
{p.title} - {formatAmount(p.moneyAmount, p.moneyCurrency)} {currencyLabel(p.moneyCurrency)} - 🪙 {p.chips} +
{/each} - {#if packs.length > 0} -

- {t('wallet.offer')} -

+ {#if !loading && values.length === 0} +

{t('wallet.empty')}

{/if} {/if} - - {#if !loading && values.length === 0 && (gpBuild || packs.length === 0)} -

{t('wallet.empty')}

- {/if}
-{#if warnProduct} - (warnProduct = null)}> -

{t('wallet.warnBody')}

+{#if spendProduct} + (spendProduct = null)}> +

{t('wallet.spendBody', { n: spendProduct.chips, title: spendProduct.title })}

+ {#if spendWarn}

{t('wallet.warnBody')}

{/if}
- - (spendProduct = null)}>{t('wallet.warnCancel')} +
@@ -281,6 +302,29 @@ gap: 18px; padding: var(--pad); } + /* The compact balance: the context's own chips (🪙 N) then any linked other-platform segments + behind their logo, digits monospaced so they align; a non-spendable (view-only) segment dims. */ + .balance { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 5px; + font-weight: 600; + font-size: 1.05rem; + } + .balance .num { + font-family: monospace; + } + .balance .num.dim { + opacity: 0.5; + } + .balance .plus { + margin: 0 3px; + color: var(--text-muted); + } + .balance .plogo { + display: block; + } section { display: flex; flex-direction: column; @@ -291,6 +335,26 @@ font-size: 0.95rem; color: var(--text-muted); } + /* The buy/spend segmented control, matching the New Game type selector. */ + .seg { + display: flex; + gap: 8px; + } + .opt { + flex: 1; + min-width: 64px; + padding: 10px; + border: 1px solid var(--border); + background: var(--surface); + color: var(--text); + border-radius: var(--radius-sm); + cursor: pointer; + } + .opt.active { + background: var(--accent); + color: var(--accent-text); + border-color: var(--accent); + } .row { display: flex; align-items: center; @@ -304,16 +368,11 @@ flex: 1 1 auto; min-width: 0; } - .value, .price { flex: 0 0 auto; font-weight: 600; white-space: nowrap; } - .muted { - color: var(--text-muted); - font-weight: 400; - } .buy { flex: 0 0 auto; padding: 8px 14px; @@ -330,7 +389,8 @@ /* The dimmed look is scoped: the tapped button while its purchase is in flight (working), or a blocked pack on VK iOS — never every buy button at once when one purchase is busy. */ .buy.blocked, - .buy.working { + .buy.working, + .buy.cantafford { opacity: 0.5; } .ios-note { @@ -362,8 +422,13 @@ .offer a { color: var(--text-muted); } + .spend-body { + margin: 0 0 10px; + } .warn-body { margin: 0 0 14px; + color: var(--text-muted); + font-size: 0.9rem; } .warn-actions { display: flex; -- 2.52.0