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}