From ab1ad998aa28ca29405e1862ff2248d6311c205a Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 19 Jun 2026 08:56:29 +0200 Subject: [PATCH 1/4] feat(ui): batch of UI polish tweaks - admin dashboard "Users" count excludes robots (CountUsers, humans only) - lobby finished-game delete reveal: background matches header/tab-bar (--bg-elev) - mobile: swipe up on the zoom-out board (no staged tiles) shuffles the rack - lobby: status icons -25%, game-row top/bottom padding halved - toast: info tier drifts up ~a tab-bar height while fading over 2s and dismisses on tap; error tier unchanged - game: confirm-move button stays pinned at the right edge; rack tiles shrink to fit so a full rack never overflows onto it - telegram: a successful invite-link redeem shows a welcome window pointing at the bot - settings: remove the grid-lines toggle; the board is always a gapless checkerboard - settings/comms hubs: the selected tab highlight wraps icon + label as a pill Docs: UI_DESIGN (toast, board surface, selected tab), PLAN; e2e updated for the removed grid-lines toggle. --- PLAN.md | 2 +- .../internal/server/handlers_admin_console.go | 4 +- docs/UI_DESIGN.md | 19 +++--- ui/e2e/game.spec.ts | 15 +++-- ui/src/App.svelte | 2 + ui/src/Landing.svelte | 1 - ui/src/components/TabBar.svelte | 23 ++++++-- ui/src/components/Toast.svelte | 54 +++++++++++++++-- ui/src/components/WelcomeRedeemModal.svelte | 58 +++++++++++++++++++ ui/src/game/Board.svelte | 51 ++++++---------- ui/src/game/CommsHub.svelte | 4 +- ui/src/game/Game.svelte | 11 +++- ui/src/game/Rack.svelte | 7 ++- ui/src/lib/app.svelte.ts | 41 +++++++++---- ui/src/lib/i18n/en.ts | 3 +- ui/src/lib/i18n/ru.ts | 3 +- ui/src/lib/session.ts | 2 - ui/src/screens/Lobby.svelte | 8 +-- ui/src/screens/Settings.svelte | 12 ---- ui/src/screens/SettingsHub.svelte | 8 +-- 20 files changed, 227 insertions(+), 101 deletions(-) create mode 100644 ui/src/components/WelcomeRedeemModal.svelte 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/UI_DESIGN.md b/docs/UI_DESIGN.md index e8369d7..1443cc6 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -172,11 +172,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 +240,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 +255,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** diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index 3f3a4de..310be23 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 }) => { 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..6ee7f9e 100644 --- a/ui/src/game/CommsHub.svelte +++ b/ui/src/game/CommsHub.svelte @@ -39,11 +39,11 @@ {#snippet tabbar()} {#if active} {/if} diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 5358a99..f22d153 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -813,6 +813,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 +871,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) { @@ -1156,7 +1166,6 @@ {landscape} {variant} labelMode={app.boardLabels} - lines={app.boardLines} locale={app.locale} {focus} {recenter} diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte index 9f2154c..e820771 100644 --- a/ui/src/game/Rack.svelte +++ b/ui/src/game/Rack.svelte @@ -83,7 +83,12 @@ } .tile { position: relative; - flex: 0 0 auto; + /* Sit at the natural tile width, but shrink to fit when the row is crowded so a full + rack never overflows its wrapper onto the MakeMove control to its right (the confirm + button stays pinned at the right screen edge). flex-grow stays 0 so a sparse rack does + not stretch its tiles. */ + flex: 0 1 auto; + min-width: 0; width: min(12.5vw, 46px); aspect-ratio: 1; background: var(--tile-bg); diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index c1e529f..20e8418 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -56,8 +56,6 @@ export const app = $state<{ locale: Locale; reduceMotion: boolean; boardLabels: BoardLabelMode; - /** Draw grid lines between board cells; off (default) is a gapless checkerboard. */ - boardLines: boolean; localeLocked: boolean; /** Pending incoming friend requests, for the lobby ⚙️ badge and the Settings Friends tab. */ notifications: number; @@ -72,6 +70,10 @@ export const app = $state<{ * code is already used/expired, so the visitor lands in the lobby with a gentle pointer to * the bot instead of a scary error on the Friends screen. */ staleInvite: boolean; + /** Whether to show the Telegram "welcome" window: set when a deep-link friend code is + * redeemed successfully inside Telegram, greeting the arriving player and pointing them at + * the bot for the most convenient way to play. */ + welcomeRedeem: boolean; /** Monotonic counter bumped when the app returns to the foreground without the live stream * having dropped. An open game watches it to refetch once, recovering an in-game event shed * from a full hub buffer while suspended (the stream-drop case is covered by streamAlive). */ @@ -88,12 +90,12 @@ export const app = $state<{ locale: 'en', reduceMotion: false, boardLabels: 'beginner', - boardLines: false, localeLocked: false, notifications: 0, chatUnread: {}, feedbackReplyUnread: false, staleInvite: false, + welcomeRedeem: false, resync: 0, }); @@ -143,7 +145,18 @@ function goForeground(): void { export function showToast(text: string, kind: Toast['kind'] = 'info'): void { app.toast = { kind, text, seq: ++toastSeq }; if (toastTimer) clearTimeout(toastTimer); - toastTimer = setTimeout(() => (app.toast = null), 4000); + // An info bubble lives exactly as long as its rise-and-fade animation (2s); an error + // dwells longer (4s) so it is not missed. + toastTimer = setTimeout(() => (app.toast = null), kind === 'error' ? 4000 : 2000); +} + +/** dismissToast hides the current toast at once — a tap on the bubble dismisses it. */ +export function dismissToast(): void { + if (toastTimer) { + clearTimeout(toastTimer); + toastTimer = null; + } + app.toast = null; } /** dismissStaleInvite hides the outdated-invite-link notice (the user acknowledged it). */ @@ -151,6 +164,11 @@ export function dismissStaleInvite(): void { app.staleInvite = false; } +/** dismissWelcomeRedeem hides the Telegram welcome window (the user acknowledged it). */ +export function dismissWelcomeRedeem(): void { + app.welcomeRedeem = false; +} + /** * seedChatUnread sets a game's unread flag from an authoritative per-viewer REST view (the lobby * list, a game's state, or a move result). The live-event GameView omits the flag, so the live @@ -476,7 +494,6 @@ export async function bootstrap(): Promise { app.theme = prefs.theme ?? 'auto'; app.reduceMotion = prefs.reduceMotion ?? false; app.boardLabels = prefs.boardLabels ?? 'beginner'; - app.boardLines = prefs.boardLines ?? false; applyTheme(app.theme); applyReduceMotion(app.reduceMotion); if (prefs.locale) { @@ -560,7 +577,13 @@ async function routeStartParam(param: string): Promise { try { const friend = await gateway.friendCodeRedeem(link.code); navigate('/friends'); - showToast(t('friends.added', { name: friend.displayName })); + // Inside Telegram a successful invite-link redeem welcomes the arriving player and + // points them at the bot (its native convenience); elsewhere a quiet toast suffices. + if (insideTelegram()) { + app.welcomeRedeem = true; + } else { + showToast(t('friends.added', { name: friend.displayName })); + } void refreshNotifications(); } catch (err) { const code = err instanceof GatewayError ? err.code : ''; @@ -633,7 +656,6 @@ function persistPrefs(): void { locale: app.locale, reduceMotion: app.reduceMotion, boardLabels: app.boardLabels, - boardLines: app.boardLines, }); } @@ -686,11 +708,6 @@ export function setBoardLabels(mode: BoardLabelMode): void { persistPrefs(); } -export function setBoardLines(on: boolean): void { - app.boardLines = on; - persistPrefs(); -} - // Background/foreground lifecycle: silence the reconnect banner during a suspend and // reconnect quietly on return (and refresh the lobby badge for any push missed while // hidden, §10). Several signals cover the platforms: the page Visibility API, the diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 27da867..0b45a14 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -163,7 +163,6 @@ export const en = { 'settings.labelsBeginner': 'Beginner', 'settings.labelsClassic': 'Classic', 'settings.labelsNone': 'None', - 'settings.boardLines': 'Grid lines', 'settings.reduceMotion': 'Reduce motion', 'about.title': 'About', @@ -241,6 +240,8 @@ export const en = { 'friends.selfInvite': "Hopefully you've been friends with yourself for a while ☺️", 'friends.staleInviteTitle': 'Link expired', 'friends.staleInvite': 'You opened the game from an outdated link. Open the bot {bot} to play and get notifications.', + 'friends.welcomeRedeemTitle': 'Welcome!', + 'friends.welcomeRedeem': 'Welcome, {name}!\n\nUse {bot} to join games the most convenient way.', 'friends.added': 'Added {name}.', 'friends.blockedList': 'Blocked players', 'friends.unblock': 'Unblock', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index 2482f88..661bf7b 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -164,7 +164,6 @@ export const ru: Record = { 'settings.labelsBeginner': 'Новичок', 'settings.labelsClassic': 'Классика', 'settings.labelsNone': 'Без текста', - 'settings.boardLines': 'Линии сетки', 'settings.reduceMotion': 'Меньше анимаций', 'about.title': 'О программе', @@ -242,6 +241,8 @@ export const ru: Record = { 'friends.selfInvite': 'Надеюсь, что с собой Вы уже давно дружите ☺️', 'friends.staleInviteTitle': 'Ссылка устарела', 'friends.staleInvite': 'Вы открыли игру по устаревшей ссылке. Откройте бота {bot}, чтобы играть и получать уведомления.', + 'friends.welcomeRedeemTitle': 'Добро пожаловать!', + 'friends.welcomeRedeem': 'Добро пожаловать, {name}!\n\nВоспользуйтесь {bot}, чтобы заходить в игру наиболее удобным способом.', 'friends.added': 'Добавлен(а) {name}.', 'friends.blockedList': 'Заблокированные', 'friends.unblock': 'Разблокировать', diff --git a/ui/src/lib/session.ts b/ui/src/lib/session.ts index 5c5a8dd..f4f703d 100644 --- a/ui/src/lib/session.ts +++ b/ui/src/lib/session.ts @@ -124,8 +124,6 @@ export interface Prefs { locale: Locale; reduceMotion: boolean; boardLabels: BoardLabelMode; - /** Draw the 1px grid lines between cells; off (default) shows a gapless checkerboard. */ - boardLines: boolean; } export async function loadPrefs(): Promise> { diff --git a/ui/src/screens/Lobby.svelte b/ui/src/screens/Lobby.svelte index 9b5f985..7ee2060 100644 --- a/ui/src/screens/Lobby.svelte +++ b/ui/src/screens/Lobby.svelte @@ -380,8 +380,8 @@ align-items: center; justify-content: center; border: none; - background: var(--danger); - color: #fff; + background: var(--bg-elev); + color: var(--text); font-size: 1.1rem; } .row { @@ -404,7 +404,7 @@ gap: 12px; min-width: 0; text-align: left; - padding: 10px 6px; + padding: 5px 6px; border: none; background: none; color: var(--text); @@ -471,7 +471,7 @@ color: var(--text-muted); } .emoji { - font-size: 1.8rem; + font-size: 1.35rem; line-height: 1; flex: 0 0 auto; } diff --git a/ui/src/screens/Settings.svelte b/ui/src/screens/Settings.svelte index 520739c..6fd33e3 100644 --- a/ui/src/screens/Settings.svelte +++ b/ui/src/screens/Settings.svelte @@ -2,7 +2,6 @@ import { app, setBoardLabels, - setBoardLines, setLocalePref, setReduceMotion, setTheme, @@ -62,14 +61,6 @@ {/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} From b5688d4848656201a187ff631dc011ae277b5eb8 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 19 Jun 2026 09:17:39 +0200 Subject: [PATCH 2/4] fix(ui): right-align the confirm-move glyph under the preview caption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ✅ box already ended at var(--pad) from the screen edge, but place-items: center centred the glyph inside the 56px box, so it sat ~14px left of the green preview caption (.scores, text-align:right at the same var(--pad)). Right-align the glyph (place-items: center end) so its right edge lands under the caption's. --- ui/src/game/Game.svelte | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index f22d153..f25cc9b 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -1471,14 +1471,16 @@ 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. The glyph is right-aligned within its box so + its right edge lands under the right edge of the preview caption above it (both sit at + var(--pad) from the screen edge — .status and .rack-row share that padding). */ .make { min-width: 56px; background: none; color: var(--text); border: none; display: grid; - place-items: center; + place-items: center end; font-size: 1.8rem; } .make:disabled { From 4adb608ad18d8b3c8d8b5bcf2248ed0a44b64201 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 19 Jun 2026 09:54:30 +0200 Subject: [PATCH 3/4] feat(ui): vs-AI comms trim, overlay confirm button, recall-to-slot drag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vs-AI: the comms hub drops the Chat tab (Dictionary only); a finished AI game has no comms at all, so its 💬 history-header entry is hidden too. - Confirm-move ✅: redone as an absolute overlay pinned to the right edge (its right edge sits under the preview caption), so the rack keeps a fixed tile size — this reverts the tile-shrink that resized the letters at 6 tiles. - Recall: dragging a placed tile back to the rack now drops it at the slot the pointer is over (drag-to-position, a gap opens), instead of snapping to its original slot; double-tap still recalls to the origin. New pure recallToSlot. Tests: placement recallToSlot units; e2e recall-to-position; vs-AI comms e2e updated. Docs: UI_DESIGN + FUNCTIONAL (+ru). --- docs/FUNCTIONAL.md | 6 +++-- docs/FUNCTIONAL_ru.md | 6 +++-- docs/UI_DESIGN.md | 15 +++++++----- ui/e2e/game.spec.ts | 22 ++++++++++++++++++ ui/e2e/quickmatch.spec.ts | 24 ++++++++++--------- ui/src/game/CommsHub.svelte | 21 +++++++++++------ ui/src/game/Game.svelte | 45 +++++++++++++++++++++++++++--------- ui/src/game/Rack.svelte | 7 +----- ui/src/lib/placement.test.ts | 33 ++++++++++++++++++++++++++ ui/src/lib/placement.ts | 45 ++++++++++++++++++++++++++++++++++++ 10 files changed, 179 insertions(+), 45 deletions(-) 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 1443cc6..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 @@ -284,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 @@ -318,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 310be23..e49c028 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -284,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/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/game/CommsHub.svelte b/ui/src/game/CommsHub.svelte index 6ee7f9e..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,9 +43,11 @@ {#snippet tabbar()} - + {#if !vsAi} + + {/if} {#if active} {/if} {#if !view.game.multipleWordsPerTurn}{t('game.oneWordRule')}{/if} - + + {#if !(gameOver && view.game.vsAi)} + + {/if}
@@ -1456,9 +1473,9 @@ white-space: nowrap; } .rack-row { + position: relative; display: flex; flex: none; - gap: 8px; align-items: stretch; padding: 0 var(--pad) 6px; } @@ -1471,11 +1488,17 @@ 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. The glyph is right-aligned within its box so - its right edge lands under the right edge of the preview caption above it (both sit at - var(--pad) from the screen edge — .status and .rack-row share that padding). */ + 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; diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte index e820771..9f2154c 100644 --- a/ui/src/game/Rack.svelte +++ b/ui/src/game/Rack.svelte @@ -83,12 +83,7 @@ } .tile { position: relative; - /* Sit at the natural tile width, but shrink to fit when the row is crowded so a full - rack never overflows its wrapper onto the MakeMove control to its right (the confirm - button stays pinned at the right screen edge). flex-grow stays 0 so a sparse rack does - not stretch its tiles. */ - flex: 0 1 auto; - min-width: 0; + flex: 0 0 auto; width: min(12.5vw, 46px); aspect-ratio: 1; background: var(--tile-bg); diff --git a/ui/src/lib/placement.test.ts b/ui/src/lib/placement.test.ts index 0f388d9..241b9e6 100644 --- a/ui/src/lib/placement.test.ts +++ b/ui/src/lib/placement.test.ts @@ -9,6 +9,7 @@ import { rackView, recallAt, recallIndex, + recallToSlot, reorderIndices, reset, toSubmit, @@ -112,3 +113,35 @@ describe('reorderIndices', () => { expect(reorderIndices(3, 0, 99)).toEqual([1, 2, 0]); // slot clamped to the end }); }); + +describe('recallToSlot', () => { + it('reinserts the recalled tile at the chosen visible slot, reordering the rack', () => { + // Lift Q (slot 1) onto the board, then recall it to visible slot 4. + const p = place(newPlacement(rack), 1, 7, 7); + const { placement, order } = recallToSlot(p, 7, 7, 4); + expect(placement.pending).toHaveLength(0); + expect(placement.rack).toEqual(['A', BLANK, 'N', 'I', 'Q', 'W', 'E']); + expect(order).toEqual([0, 2, 3, 4, 1, 5, 6]); + }); + + it('counts only still-visible slots and remaps the remaining pending tiles', () => { + // A (slot 0) and N (slot 3) are on the board; recall A to visible slot 2 — the placed N is + // skipped, and N's rackIndex follows the rack permutation. + let p = place(newPlacement(rack), 0, 7, 7); + p = place(p, 3, 7, 8); + const { placement, order } = recallToSlot(p, 7, 7, 2); + expect(placement.rack).toEqual(['Q', BLANK, 'N', 'A', 'I', 'W', 'E']); + expect(order).toEqual([1, 2, 3, 0, 4, 5, 6]); + expect(placement.pending).toHaveLength(1); + expect(placement.pending[0].rackIndex).toBe(2); + expect(placement.rack[placement.pending[0].rackIndex]).toBe('N'); + }); + + it('clamps the slot to the end and is an identity no-op when no tile sits at the cell', () => { + const p = place(newPlacement(rack), 1, 7, 7); + expect(recallToSlot(p, 7, 7, 99).placement.rack).toEqual(['A', BLANK, 'N', 'I', 'W', 'E', 'Q']); + const none = recallToSlot(p, 0, 0, 0); + expect(none.placement).toBe(p); + expect(none.order).toEqual([0, 1, 2, 3, 4, 5, 6]); + }); +}); diff --git a/ui/src/lib/placement.ts b/ui/src/lib/placement.ts index 28727e8..8c551d6 100644 --- a/ui/src/lib/placement.ts +++ b/ui/src/lib/placement.ts @@ -102,6 +102,51 @@ export function recallIndex(p: Placement, rackIndex: number): Placement { return { ...p, pending: p.pending.filter((t) => t.rackIndex !== rackIndex) }; } +/** + * recallToSlot recalls the pending tile at (row, col) and reinserts its rack letter at the + * given visible slot, reordering the rack the way a drag-to-reorder does (the drop position the + * player chose) instead of snapping the tile back to its original slot. It returns the new + * placement together with the index permutation applied to the rack, so the caller can permute + * its parallel stable ids in lockstep. toSlot counts the slots that stay visible after the + * recall (those still on the board are skipped); it is clamped to a valid position. The result + * is the unchanged placement and an identity permutation when no pending tile sits at (row, col). + */ +export function recallToSlot( + p: Placement, + row: number, + col: number, + toSlot: number, +): { placement: Placement; order: number[] } { + const identity = p.rack.map((_, i) => i); + const tile = p.pending.find((t) => t.row === row && t.col === col); + if (!tile) return { placement: p, order: identity }; + const ri = tile.rackIndex; + const rest = p.pending.filter((t) => t !== tile); + const usedByRest = new Set(rest.map((t) => t.rackIndex)); + // Reinsert ri just before the toSlot-th slot that stays visible after the recall. + const others = identity.filter((i) => i !== ri); + let visible = 0; + let insertPos = others.length; + for (let pos = 0; pos < others.length; pos++) { + if (usedByRest.has(others[pos])) continue; + if (visible === toSlot) { + insertPos = pos; + break; + } + visible++; + } + const order = [...others]; + order.splice(insertPos, 0, ri); + const newIndex = new Map(order.map((oldIdx, idx) => [oldIdx, idx])); + return { + placement: { + rack: order.map((i) => p.rack[i]), + pending: rest.map((t) => ({ ...t, rackIndex: newIndex.get(t.rackIndex)! })), + }, + order, + }; +} + export function reset(p: Placement): Placement { return { ...p, pending: [] }; } From b156d3403c1624011a6ae51575a51a46e77e87b5 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 19 Jun 2026 10:12:48 +0200 Subject: [PATCH 4/4] =?UTF-8?q?fix(ui):=20polish=20board=E2=86=92rack=20re?= =?UTF-8?q?call=20drag=20and=20its=20gesture=20interplay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The rack now animates the opening drop gap for a board-tile recall drag too (the reorder transition was gated on a rack-source drag only). - While a board tile is dragged over the rack (a recall, gap shown), the ✅ confirm and its preview caption are hidden so they don't sit over the gap; they return if the tile is dragged back out over the board. - A recall drop lands off the boardwrap, so its pointerup never reached the boardwrap handler and left a stale id in the active-pointer set — the next board swipe then read as multi-touch and the shuffle / history pull went dead. Reconcile the pointer when a drag ends or cancels. Regression e2e added. --- ui/e2e/history.spec.ts | 23 +++++++++++++++++++++++ ui/src/game/Game.svelte | 14 ++++++++++++-- ui/src/game/Rack.svelte | 2 +- 3 files changed, 36 insertions(+), 3 deletions(-) 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/src/game/Game.svelte b/ui/src/game/Game.svelte index 2875ae9..75e3558 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -378,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) { @@ -398,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; @@ -516,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; @@ -1205,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} @@ -1227,7 +1237,7 @@ ondown={onRackDown} />
- {#if !gameOver && placement.pending.length > 0} + {#if !gameOver && placement.pending.length > 0 && !recallOverRack} {/if}
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)}