diff --git a/PLAN.md b/PLAN.md index a4d5855..92030a0 100644 --- a/PLAN.md +++ b/PLAN.md @@ -776,7 +776,7 @@ cannot submit; three-way admin filter. 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 + tiles, 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** diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 3dc929b..f8f63df 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -110,7 +110,9 @@ func (s *Server) registerConsole(router *gin.Engine) { func (s *Server) consoleDashboard(c *gin.Context) { ctx := c.Request.Context() view := adminconsole.DashboardView{Variants: s.variantVersions(), ActiveVersion: s.games.ActiveVersion()} - view.Accounts, _ = s.accounts.CountAccounts(ctx) + // The Users card counts people only: robots are pool infrastructure, not registered + // users, so an empty filter (Robots: false) excludes them. + view.Accounts, _ = s.accounts.CountUsers(ctx, account.UserFilter{}) view.Games, _ = s.games.CountGames(ctx, "") view.ActiveGames, _ = s.games.CountGames(ctx, game.StatusActive) view.OpenComplaints, _ = s.games.CountComplaints(ctx, game.StatusComplaintOpen) diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index f541828..0a5130c 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -93,7 +93,7 @@ invitation — so a player still sees and plays existing games of any language. **Quick game** lets you choose your opponent — an **AI** (the default) or a **random player**. With **AI** you start at once against a 🤖 that joins and replies immediately: there is no waiting, -chat and nudge are off, add-friend is never shown, and the word-check tool still works; instead of a +there is no chat or nudge — only the word-check tool — add-friend is never shown; instead of a per-move clock you lose only after **7 days without a move** (so you can abandon an AI game freely, and the AI itself never runs out of time). Choosing a **random player** is auto-match (always 2 players), which drops you **straight into the game and you wait inside it**: if it is your turn you @@ -211,7 +211,9 @@ is awaited at most once per hour (the nudge is part of the game chat); both the in-app toast and the out-of-app push (delivered via the platform) **name the nudger** (": waiting for your move"). Chat and the word-check tool share one **comms screen** with **💬 chat** / **🔎 dictionary** -tabs, reached from the 💬 in the move-history header (with a back to the game). An unread chat +tabs, reached from the 💬 in the move-history header (with a back to the game). An AI game has no +chat, so its comms screen is the word-check alone — and once an AI game is finished (no chat, the +dictionary closed) the 💬 entry disappears entirely. An unread chat entry — a message **or a nudge** from an opponent — raises a small **red dot** next to the game in the **lobby** and on the game's **score bar**. Opening the **move history** counts as reading the chat, even without entering it: the dot clears and the 💬 icon **fade-blinks twice**. A nudge diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 6e633ad..cc7ae87 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -96,7 +96,7 @@ nudge) приходят от бота **этой партии** — по язы **Быстрая игра** даёт выбрать соперника — **ИИ** (по умолчанию) или **случайного игрока**. С **ИИ** вы сразу начинаете против 🤖, который присоединяется и отвечает мгновенно: ожидания нет, чат и nudge -выключены, «добавить в друзья» не показывается, а проверка слова работает; вместо лимита на ход вы +нет — только проверка слова, — «добавить в друзья» не показывается; вместо лимита на ход вы проигрываете только после **7 дней без хода** (так что ИИ-партию можно спокойно забросить, а сам ИИ никогда не «просрочит» ход). Выбор **случайного игрока** — это авто-подбор (всегда 2 игрока), который сразу **помещает вас в игру, и вы ждёте соперника прямо @@ -217,7 +217,9 @@ nudge) приходят от бота **этой партии** — по язы и внеприложенческий push (доставляется через платформу) **называют отправителя** («<соперник>: жду вашего хода»). Чат и инструмент проверки слова — один **экран связи** со вкладками **💬 чат** / **🔎 словарь**, -открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию). Непрочитанная запись чата — +открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию). В ИИ-партии чата нет, поэтому её +экран связи — только проверка слова; а после завершения ИИ-партии (чата нет, словарь закрыт) сам вход +💬 исчезает. Непрочитанная запись чата — сообщение **или nudge** от соперника — зажигает маленькую **красную точку** рядом с партией в **лобби** и на **строке счёта** партии. Открытие **истории ходов** считается прочтением чата, даже без захода в него: точка гаснет, а значок 💬 **дважды мигает**. Nudge также гаснет в момент, когда diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index e8369d7..30ad8e7 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -32,7 +32,8 @@ except Login uses `Screen`. bottom tab bar whose tabs switch its body **in place** (state, not navigation), so the back control always returns to the hub's parent (Settings → lobby, comms → game). Settings hub tabs: ⚙️ Settings / 👤 Profile / 🤝 Friends / ℹ️ About (Friends hidden for guests); - comms hub tabs: 💬 Chat / 🔎 Dictionary (Dictionary only while the game is active). The + comms hub tabs: 💬 Chat / 🔎 Dictionary (Dictionary only while the game is active; an + honest-AI game has no Chat, so it shows the Dictionary alone). The routes `/settings|/profile|/friends|/about` and `/game/:id/{chat,check}` survive as hub entry points (so a Telegram friend-code deep-link still lands on the Friends tab). The **Feedback** screen is its own deeper route `/feedback` (back → `/about`, the Info tab), so @@ -110,8 +111,9 @@ except Login uses `Screen`. 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 **double-tap** or by **dragging it back onto the rack** (unzoomed board only — when zoomed - the one-finger gesture scrolls). A single tap no longer recalls (too easy to trigger); a - recalled tile returns to its original rack slot. + the one-finger gesture scrolls). A single tap no longer recalls (too easy to trigger). A + **double-tap** returns the tile to its original rack slot; **dragging it back** drops it at the + rack slot the pointer is over (a gap opens there), the same drag-to-position as a rack reorder. - **Players plaque & history** (`Game.svelte`): the seats above the board share the width evenly; the seat whose turn it is is **raised** (a drop shadow on its sides) while the others read **sunk in** (an inset shadow). A tap on the plaque toggles @@ -172,11 +174,9 @@ except Login uses `Screen`. - **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** — a Settings toggle, **default off**. Off: a **gapless - checkerboard** — plain cells alternate two shades, and tiles get rounded corners with a - soft right-side shadow so adjacent gapless tiles still read apart, reclaiming ~14px of - board width. On: the classic lined grid, where the inter-cell gap shows a contrasting - `--cell-line` (darker in light, lighter in dark) to avoid a wavy-line optical illusion. +- **Board surface** — always a **gapless checkerboard** (there is no grid-lines setting): + plain cells alternate two shades, and tiles get rounded corners with a soft right-side + shadow so adjacent gapless tiles still read apart, reclaiming ~14px of board width. ## Controls @@ -242,7 +242,9 @@ game **becomes your turn or finishes** while the lobby is open, that status emoj twice** (a two-cycle opacity fade, ~2 s; suppressed under reduce-motion) to draw the eye — the opponent's-turn change is silent. Each card's blink is keyed by game id, so overlapping events animate in isolation, and the newest bottom toast cancels the previous one and replays -its entrance (`components/Toast.svelte`, re-keyed on a per-message sequence). +its entrance (`components/Toast.svelte`, re-keyed on a per-message sequence). An **info** toast +lives ~2 s, drifting up by about the tab-bar height as it fades (a plain fade, no travel, under +reduce-motion); an **error** toast dwells longer (~4 s). Either dismisses instantly on tap. **Simultaneous-game cap.** When the player is at the active-quick-game cap (`games.list` `at_game_limit`), the lobby's **New Game** tab is **disabled** (greyed via the shared @@ -255,8 +257,11 @@ enabled on the first, uncached load) and flip in place when an event refreshes t - **Settings hub** (`screens/SettingsHub.svelte`, the lobby ⚙️ tab): one nav bar + a bottom tab bar over four bodies — ⚙️ Settings, 👤 Profile, 🤝 Friends, ℹ️ About — switched in - place; back always returns to the lobby. Guests see all but Friends. The lobby ⚙️ badge and - the 🤝 tab badge both show the pending incoming-friend-request count. + place; back always returns to the lobby. The **selected** tab is marked by a filled pill + hugging its icon **and** label (with an accent underline) — a persistent selection cue that + the plain, unhighlighted action tabs (lobby nav, in-game controls) never carry. Guests see + all but Friends. The lobby ⚙️ badge and the 🤝 tab badge both show the pending + incoming-friend-request count. - **Friends** (`screens/Friends.svelte`, the Settings hub's 🤝 tab): an "add a friend" block pairing a code **input** with a **Show my code** action that reveals a large 6-digit code + its expiry; then the incoming **requests** (Accept / Decline), the **friends** @@ -281,8 +286,9 @@ enabled on the first, uncached load) and flip in place when an event refreshes t under Start game notes the wait can take a while (the app may be closed meanwhile). With **AI** selected there is no wait: the move-clock line instead reads **"Loss after 7 days of inactivity"** and the "searching" hint is hidden; the game starts already seated, the opponent shows as **🤖** - everywhere (score card, lobby row, turn line), the add-friend 🤝 is never drawn, and the chat's - send field and 🛎️ nudge stay **disabled** while the 🔎 dictionary word-check keeps working. + everywhere (score card, lobby row, turn line), the add-friend 🤝 is never drawn, and there is + **no chat at all**: the comms hub drops the 💬 Chat tab and offers only the 🔎 dictionary + word-check (the 🛎️ nudge is likewise gone). - **Statistics** (`screens/Stats.svelte`, the lobby ✏️ tab): a 2-column grid of stat cards (games / wins / draws / losses / moves / hint-share / best game / win-rate) — pure numbers, no charts; hint-share is a one-decimal percentage in the active locale's @@ -315,7 +321,7 @@ enabled on the first, uncached load) and flip in place when an event refreshes t never in an honest-AI game (throwaway practice). Confirming a resign reveals the full board: it closes the history drawer (portrait) and zooms the board out. - **Finished game**: the board keeps no last-word highlight and no zoom; the history header - offers *Export GCG* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab; and + offers *Export GCG* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab — and a **finished AI game has no comms at all** (no chat, dictionary closed), so its 💬 entry is dropped from the header too; and the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than hidden, so the layout does not jump. Chat send / nudge are the ⬆️ / 🛎️ icons. The input row is turn-driven: on your turn the message field shows until your one allowed message is diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index 3f3a4de..e49c028 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -123,16 +123,15 @@ test('a pending tile recalls on double-tap, not on a single tap', async ({ page await expect(page.locator('[data-cell].pending')).toHaveCount(0); }); -test('the board is a gapless checkerboard by default; grid lines toggle in Settings', async ({ page }) => { +test('the board is always a gapless checkerboard; no grid-lines setting', async ({ page }) => { await openGame(page); - await expect(page.locator('.grid.gridless')).toBeVisible(); // lines off by default - - await page.evaluate(() => (location.hash = '/settings')); - await page.locator('.gridlines input').check(); // turn grid lines on - await page.evaluate(() => (location.hash = '/game/g1')); - await expect(page.locator('.grid')).toBeVisible(); - await expect(page.locator('.grid.gridless')).toHaveCount(0); + // The board has no lined variant any more: its cells sit flush, with no inter-cell gap. + await expect(page.locator('.grid')).toHaveCSS('column-gap', '0px'); + + // Settings no longer offers a grid-lines toggle. + await page.evaluate(() => (location.hash = '/settings')); + await expect(page.locator('.gridlines')).toHaveCount(0); }); test('shuffle reorders the rack but keeps the same tiles', async ({ page }) => { @@ -285,6 +284,28 @@ test('a placed tile drags from one board cell to another (relocation)', async ({ expect(to).not.toBe(from); }); +test('a placed tile drags back to a chosen rack slot (recall-to-position)', async ({ page }) => { + await openGame(page); + // Place the first rack tile (slot 0) on the board, remembering its letter. + await page.locator('.rack .tile').first().click(); + const placed = (await page.locator('.rack .tile .letter').first().textContent()) ?? ''; + await page.locator('[data-cell]:not(.filled)').nth(30).click(); + const pending = page.locator('[data-cell].pending'); + await expect(pending).toHaveCount(1); + + // Drag it from the board back onto a later rack slot (the 4th visible tile), not its origin. + const fb = await pending.first().boundingBox(); + const slot = await page.locator('[data-rack] .tile').nth(3).boundingBox(); + await page.mouse.move(fb!.x + fb!.width / 2, fb!.y + fb!.height / 2); + await page.mouse.down(); + await page.mouse.move(slot!.x + 2, slot!.y + slot!.height / 2, { steps: 10 }); + await page.mouse.up(); + + // Recalled (no pending) and reinserted at the drop slot — slot 3, not its origin slot 0. + await expect(pending).toHaveCount(0); + await expect(page.locator('[data-rack] .tile .letter').nth(3)).toHaveText(placed); +}); + test('comms hub: chat and dictionary share a screen, back returns to the game', async ({ page }) => { await openGame(page); await page.locator('.scoreboard').click(); // open the history diff --git a/ui/e2e/history.spec.ts b/ui/e2e/history.spec.ts index 53b19dc..662e75e 100644 --- a/ui/e2e/history.spec.ts +++ b/ui/e2e/history.spec.ts @@ -43,6 +43,29 @@ test('a swipe-down on the zoom-out board opens the history', async ({ page }) => await expect(page.locator('.boardwrap.slid')).toBeVisible(); }); +test('a recall drag onto the rack does not break the next board swipe', async ({ page }) => { + await openGame(page); + // Place a tile, then drag it back onto the rack — the recall's pointerup lands off the + // boardwrap, which once left a stale pointer there so the next swipe read as multi-touch. + await page.locator('.rack .tile').first().click(); + await page.locator('[data-cell]:not(.filled)').nth(30).click(); + const pending = page.locator('[data-cell].pending'); + await expect(pending).toHaveCount(1); + const fb = (await pending.first().boundingBox())!; + const slot = (await page.locator('[data-rack] .tile').nth(2).boundingBox())!; + await page.mouse.move(fb.x + fb.width / 2, fb.y + fb.height / 2); + await page.mouse.down(); + await page.mouse.move(slot.x + 2, slot.y + slot.height / 2, { steps: 10 }); + await page.mouse.up(); + await expect(pending).toHaveCount(0); + + // The swipe-down still opens the history (the recall pointer was reconciled). + await page.locator('.stage').evaluate((el) => (el.scrollTop = 0)); + const box = (await page.locator('.boardwrap').boundingBox())!; + await touchSwipeDown(page, box.y + 20, box.y + 180); + await expect(page.locator('.history')).toBeVisible(); +}); + test('the history is a per-seat grid with move scores but no names or running total', async ({ page }) => { await openGame(page); await page.locator('.scoreboard').click(); // open the history (gesture is covered separately) diff --git a/ui/e2e/quickmatch.spec.ts b/ui/e2e/quickmatch.spec.ts index 59d9a3a..3561745 100644 --- a/ui/e2e/quickmatch.spec.ts +++ b/ui/e2e/quickmatch.spec.ts @@ -50,18 +50,20 @@ test('AI game: 🤖 opponent, no wait, chat disabled, dictionary still works', a await expect(page.locator('.scoreboard').getByText('🤖')).toBeVisible(); await expect(page.getByText(/Searching for opponent/)).toHaveCount(0); - // Chat is disabled (the message field), while the dictionary word-check stays usable. + // An AI game has no chat at all: the comms hub drops the Chat tab and lands on the Dictionary + // alone, which stays usable. await page.locator('.scoreboard').click(); // open the history - await page.getByRole('button', { name: 'Chat' }).click(); // 💬 -> comms hub + await page.getByRole('button', { name: 'Chat' }).click(); // 💬 (the history-header entry) -> comms hub await expect(page).toHaveURL(/\/game\/.+\/chat$/); - await expect(page.locator('.chat input')).toBeDisabled(); - await page.getByRole('button', { name: 'Dictionary' }).click(); - await expect(page.locator('.check input')).toBeEnabled(); + await expect(page.getByRole('button', { name: 'Chat' })).toHaveCount(0); // no Chat tab + await expect(page.locator('.chat input')).toHaveCount(0); // the chat body never mounts + await expect(page.locator('.check input')).toBeEnabled(); // the Dictionary is the only surface }); -// GCG export is pointless for a practice AI game, so the finished AI game hides the 📤 export -// (the comms hub stays). Start an AI game, resign it, reopen the history and check the header. -test('AI game: no GCG export offered after it ends', async ({ page }) => { +// GCG export is pointless for a practice AI game, and a finished AI game has no comms at all +// (no chat, and the dictionary closes with the game), so both the 📤 export and the 💬 comms +// entry are gone. Start an AI game, resign it, reopen the history and check the header. +test('AI game: after it ends, no GCG export and no comms entry', async ({ page }) => { await page.goto('/'); await page.getByRole('button', { name: /guest/i }).click(); await page.getByRole('button', { name: /🎲/ }).click(); @@ -75,11 +77,11 @@ test('AI game: no GCG export offered after it ends', async ({ page }) => { await page.locator('button.danger').click(); await expect(page.locator('.status .over')).toBeVisible(); - // Reopen the history (resigning auto-closed it): the finished AI game offers no GCG export, - // while the comms hub stays reachable. + // Reopen the history (resigning auto-closed it): the finished AI game offers no GCG export and + // no comms entry at all. await page.locator('.scoreboard').click(); await expect(page.getByRole('button', { name: 'Export GCG' })).toHaveCount(0); - await expect(page.getByRole('button', { name: 'Chat' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Chat' })).toHaveCount(0); }); // The opponent_joined push is best-effort and never replayed, so a join that lands while the live diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 8f3bba3..2de9e13 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -7,6 +7,7 @@ import { insideTelegram, telegramBackButton } from './lib/telegram'; import Toast from './components/Toast.svelte'; import StaleInviteModal from './components/StaleInviteModal.svelte'; + import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte'; import Login from './screens/Login.svelte'; import Lobby from './screens/Lobby.svelte'; import NewGame from './screens/NewGame.svelte'; @@ -110,6 +111,7 @@ + diff --git a/ui/src/components/WelcomeRedeemModal.svelte b/ui/src/components/WelcomeRedeemModal.svelte new file mode 100644 index 0000000..acdf4a5 --- /dev/null +++ b/ui/src/components/WelcomeRedeemModal.svelte @@ -0,0 +1,58 @@ + + +{#if app.welcomeRedeem} + +

{parts[0]}{#if username}{/if}{parts[1] ?? ''}

+ +
+{/if} + + diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte index febab72..11256b9 100644 --- a/ui/src/game/Board.svelte +++ b/ui/src/game/Board.svelte @@ -18,7 +18,6 @@ landscape, variant, labelMode, - lines, locale, focus, recenter, @@ -41,8 +40,6 @@ landscape: boolean; variant: Variant; labelMode: BoardLabelMode; - /** Draw 1px grid lines between cells; when false the board is a gapless checkerboard. */ - lines: boolean; locale: Locale; focus: { row: number; col: number } | null; /** A monotonic nonce the parent bumps to request a scroll to `focus` even when the zoom state @@ -234,7 +231,7 @@
-
+
{#each board as rowCells, r (r)} {#each rowCells as cell, c (c)} {@const p = pending.get(key(r, c))} @@ -311,18 +308,21 @@ width 0.25s ease, height 0.25s ease; } + /* A gapless checkerboard with no grid lines (the board has no lined variant): plain cells + alternate shades and tiles get rounded corners plus a soft right-side shadow so adjacent + gapless tiles still read as separate pieces. */ .grid { display: grid; grid-template-columns: repeat(15, 1fr); - gap: 1px; - background: var(--cell-line); - padding: 1px; + gap: 0; + background: var(--board-bg); + padding: 0; } .cell { position: relative; aspect-ratio: 1; border: none; - border-radius: 1px; + border-radius: 0; background: var(--cell-bg); color: var(--prem-text); /* No mobile tap flash on a cell tap (parity with the web click; the only intentional @@ -344,11 +344,20 @@ .cell.dl { background: var(--prem-dl); } + .cell.dark { + /* A gentle checkerboard tint: enough to read the alternation without competing with the + bonus cells, standing in for the grid lines that once separated the cells. Lightened + from a stronger mix that looked too contrasty in light theme. */ + background: color-mix(in srgb, var(--cell-bg) 94%, #000); + } .cell.filled, .cell.pending { background: var(--tile-bg); color: var(--tile-text); - box-shadow: inset 0 -2px 0 var(--tile-edge); + border-radius: 4px; + box-shadow: + inset 0 -2px 0 var(--tile-edge), + 2px 0 3px -1px rgba(0, 0, 0, 0.4); } .cell.pending { background: var(--tile-pending); @@ -356,30 +365,6 @@ board) instead of the touch starting a board pan. */ touch-action: none; } - /* Lines-off variant: a gapless checkerboard. The 1px grid gaps (and the cell-line they - reveal) collapse, saving ~14px of board width; plain cells alternate shades, and tiles - get rounded corners and a soft right-side shadow so adjacent gapless tiles still read - as separate pieces. */ - .grid.gridless { - gap: 0; - padding: 0; - background: var(--board-bg); - } - .grid.gridless .cell { - border-radius: 0; - } - .grid.gridless .cell.dark { - /* A gentle checkerboard tint: enough to read the alternation without competing with the - bonus cells. Lightened from a stronger mix that looked too contrasty in light theme. */ - background: color-mix(in srgb, var(--cell-bg) 94%, #000); - } - .grid.gridless .cell.filled, - .grid.gridless .cell.pending { - border-radius: 4px; - box-shadow: - inset 0 -2px 0 var(--tile-edge), - 2px 0 3px -1px rgba(0, 0, 0, 0.4); - } .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. */ diff --git a/ui/src/game/CommsHub.svelte b/ui/src/game/CommsHub.svelte index 0c0fecc..0cac7fe 100644 --- a/ui/src/game/CommsHub.svelte +++ b/ui/src/game/CommsHub.svelte @@ -8,19 +8,24 @@ // The in-game comms hub: a single nav bar + bottom tab bar hosting Chat and the word // Dictionary. Tabs switch in place, so the back control always returns to the game. The - // Dictionary tab is offered only while the game is active (mirrors the old "check word"). + // Dictionary tab is offered only while the game is active (mirrors the old "check word"); an + // honest-AI game has no Chat, so it shows the Dictionary alone. type CommsTab = 'chat' | 'dictionary'; let { id, initialTab = 'chat' }: { id: string; initialTab?: CommsTab } = $props(); // The game is rendered (and cached) before its comms open, so the cache tells us whether // it is still active without another fetch; an unknown game keeps the Dictionary offered. const active = $derived(getCachedGame(id)?.view.game.status !== 'finished'); - // Seeded once from the entry route's tab and then owned locally; the effect below - // corrects a Dictionary deep-link into a finished game back to Chat. + // An honest-AI game has no chat at all, so its hub is Dictionary-only. + const vsAi = $derived(getCachedGame(id)?.view.game.vsAi ?? false); + // Seeded once from the entry route's tab, then owned locally. The effect keeps the tab valid: + // an AI game has only the Dictionary; a finished non-AI game has only Chat (a stale Dictionary + // deep-link falls back to Chat). // svelte-ignore state_referenced_locally let tab = $state(initialTab); $effect(() => { - if (tab === 'dictionary' && !active) tab = 'chat'; + if (vsAi) tab = 'dictionary'; + else if (tab === 'dictionary' && !active) tab = 'chat'; }); @@ -38,12 +43,14 @@ {#snippet tabbar()} - + {#if !vsAi} + + {/if} {#if active} {/if} diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 5358a99..75e3558 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -31,6 +31,7 @@ placementFromHint, rackView, recallAt, + recallToSlot, reorderIndices, reset, toSubmit, @@ -377,6 +378,10 @@ // While a placed (pending) board tile is dragged to relocate it, draggingPend is its cell — // hidden from the board (the ghost stands in) like a lifted rack tile. let draggingPend = $state<{ row: number; col: number } | null>(null); + // A board tile dragged over the rack (a recall in progress, the rack showing a drop gap): the + // 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); let dragPointerId = -1; function beginDrag(src: DragSrc, e: PointerEvent) { @@ -397,6 +402,7 @@ window.removeEventListener('pointermove', onWinMove); window.removeEventListener('pointerup', onWinUp); window.removeEventListener('pointerdown', onExtraPointer); + boardPointers.delete(dragPointerId); // see onWinUp: don't leave a stale boardwrap pointer clearHover(); clearReorder(); downInfo = null; @@ -481,7 +487,12 @@ if (c) { dropTarget = !board[c.row]?.[c.col] && !pendingMap.has(`${c.row},${c.col}`) ? c : null; reorderTo = null; - } else if (reorderDragId != null && overRack(e.clientY) && placement.pending.length === 0) { + } else if ( + overRack(e.clientY) && + // A rack-source reorder (only with a clean, pending-free rack) or a board tile dragged + // back to the rack — both preview the drop slot as a gap the dropped tile will fill. + ((reorderDragId != null && placement.pending.length === 0) || draggingPend != null) + ) { reorderTo = dropSlotAt(e.clientX); dropTarget = null; } else { @@ -510,6 +521,11 @@ window.removeEventListener('pointermove', onWinMove); window.removeEventListener('pointerup', onWinUp); window.removeEventListener('pointerdown', onExtraPointer); + // A board-tile drag also registered this pointer on the boardwrap (the tile sits inside it), + // but it can be released off the boardwrap (e.g. a recall dropped on the rack), where the + // boardwrap's own pointerup never fires. Reconcile it here so no stale id is left to make the + // next swipe read as multi-touch and kill the shuffle / history-pull gesture. + boardPointers.delete(e.pointerId); clearHover(); const di = downInfo; downInfo = null; @@ -527,8 +543,15 @@ // Dropped a placed tile on another board cell → relocate it there. relocatePending(di.src.row, di.src.col, cell.row, cell.col); } else if (di.src.from === 'board' && onRack) { - // Dropped a placed tile back onto the rack → recall it to its original slot. - placement = recallAt(placement, di.src.row, di.src.col); + // Dropped a placed tile back onto the rack → recall it to the drop slot the player aimed + // at (drag-to-position), or to its original slot when the drop is not over a gap. + if (to != null) { + const res = recallToSlot(placement, di.src.row, di.src.col, to); + rackIds = res.order.map((i) => rackIds[i] ?? i); + placement = res.placement; + } else { + placement = recallAt(placement, di.src.row, di.src.col); + } selected = null; recompute(); scheduleDraftSave(); @@ -813,6 +836,8 @@ // - closed: a downward "pull" opens the history, but only on the zoom-out board scrolled // to its top — zoomed, the one-finger drag pans the board, and mid-scroll a downward drag // is the stage's own vertical scroll (the conflict that once retired this open gesture). + // On that same closed, zoom-out board an upward swipe with no staged tiles shuffles the + // rack (a convenience mirror of the shuffle control). // Both genuinely set `historyOpen` (closing no longer merely scrolls the slid board out of // view, which left a stale-open state that made a follow-up score-bar tap "jump" the board). let stageEl = $state(); @@ -869,6 +894,14 @@ if (-dy > 32) closeHistoryByGesture(); // enough upward travel } else if (dy > 40 && dy > Math.abs(dx) * 1.4) { openHistoryByGesture(); // a clear, vertical-dominant downward pull + } else if (-dy > 40 && -dy > Math.abs(dx) * 1.4 && placement.pending.length === 0) { + // The same closed-board arm, opposite direction: a clear upward swipe on the zoom-out + // board with no staged tiles shuffles the rack (a convenience mirror of the shuffle + // control). Disarm and swallow the synthesised click so it cannot also place a tile. + shuffle(); + boardSwipe = null; + swallowClick = true; + setTimeout(() => (swallowClick = false), 120); } } function onBoardWrapUp(e: PointerEvent) { @@ -1104,9 +1137,13 @@ {/if} {#if !view.game.multipleWordsPerTurn}{t('game.oneWordRule')}{/if} - + + {#if !(gameOver && view.game.vsAi)} + + {/if}
@@ -1156,7 +1193,6 @@ {landscape} {variant} labelMode={app.boardLabels} - lines={app.boardLines} locale={app.locale} {focus} {recenter} @@ -1179,7 +1215,7 @@ {isMyTurn ? t('game.yourTurn') : turnLabel()} {/if} - {#if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{:else if !view.game.multipleWordsPerTurn}1️⃣{/if} + {#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{:else if !view.game.multipleWordsPerTurn}1️⃣{/if}
{/if} @@ -1201,7 +1237,7 @@ ondown={onRackDown} />
- {#if !gameOver && placement.pending.length > 0} + {#if !gameOver && placement.pending.length > 0 && !recallOverRack} {/if}
@@ -1447,9 +1483,9 @@ white-space: nowrap; } .rack-row { + position: relative; display: flex; flex: none; - gap: 8px; align-items: stretch; padding: 0 var(--pad) 6px; } @@ -1462,14 +1498,22 @@ min-width: 0; } /* A borderless icon button (like the tab bar), not a filled accent button — and disabled - while the pending word is known to be illegal. */ + while the pending word is known to be illegal. It is an overlay, NOT a flex sibling of the + rack: the rack keeps the full row width with a fixed tile size (no reflow or tile resize + when a tile is staged), and the button is absolutely pinned over the slots a staged tile + frees on the right. Its right edge sits at var(--pad) — directly under the right edge of the + preview caption above (.status shares that padding). */ .make { - min-width: 56px; + position: absolute; + right: var(--pad); + top: 0; + bottom: 6px; + width: 56px; background: none; color: var(--text); border: none; display: grid; - place-items: center; + place-items: center end; font-size: 1.8rem; } .make:disabled { diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte index 9f2154c..efe7cee 100644 --- a/ui/src/game/Rack.svelte +++ b/ui/src/game/Rack.svelte @@ -56,7 +56,7 @@ } -
+
{#each shown as slot, i (slot.id)} {/each}
-
@@ -124,7 +115,4 @@ align-items: center; justify-content: space-between; } - .gridlines { - margin-top: 12px; - } diff --git a/ui/src/screens/SettingsHub.svelte b/ui/src/screens/SettingsHub.svelte index bdc1d9c..b570fb2 100644 --- a/ui/src/screens/SettingsHub.svelte +++ b/ui/src/screens/SettingsHub.svelte @@ -46,18 +46,18 @@ {#snippet tabbar()} {#if !guest} {/if} {/snippet}