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}