feat(ui): batch of UI polish tweaks
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 52s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s

- 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.
This commit is contained in:
Ilia Denisov
2026-06-19 08:56:29 +02:00
parent 1c87313a78
commit ab1ad998aa
20 changed files with 227 additions and 101 deletions
+1 -1
View File
@@ -776,7 +776,7 @@ cannot submit; three-way admin filter.
reusable **HoldConfirm** press-and-hold control (MakeMove 🏁 + game-action confirms); reusable **HoldConfirm** press-and-hold control (MakeMove 🏁 + game-action confirms);
board **zoom reworked** to a width-based zoom in a fixed viewport (real native 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 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/ **bonus-label style** (beginner/
classic/none); **hint lays its tiles on the board** (no spend when no move — a new 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** `no_hint_available` result code); the history opens as an in-place **slide-down**
@@ -110,7 +110,9 @@ func (s *Server) registerConsole(router *gin.Engine) {
func (s *Server) consoleDashboard(c *gin.Context) { func (s *Server) consoleDashboard(c *gin.Context) {
ctx := c.Request.Context() ctx := c.Request.Context()
view := adminconsole.DashboardView{Variants: s.variantVersions(), ActiveVersion: s.games.ActiveVersion()} 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.Games, _ = s.games.CountGames(ctx, "")
view.ActiveGames, _ = s.games.CountGames(ctx, game.StatusActive) view.ActiveGames, _ = s.games.CountGames(ctx, game.StatusActive)
view.OpenComplaints, _ = s.games.CountComplaints(ctx, game.StatusComplaintOpen) view.OpenComplaints, _ = s.games.CountComplaints(ctx, game.StatusComplaintOpen)
+11 -8
View File
@@ -172,11 +172,9 @@ except Login uses `Screen`.
- **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a - **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a
split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none` split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none`
nothing. Default **beginner**. nothing. Default **beginner**.
- **Grid lines** — a Settings toggle, **default off**. Off: a **gapless - **Board surface** — always a **gapless checkerboard** (there is no grid-lines setting):
checkerboard** — plain cells alternate two shades, and tiles get rounded corners with a plain cells alternate two shades, and tiles get rounded corners with a soft right-side
soft right-side shadow so adjacent gapless tiles still read apart, reclaiming ~14px of shadow so adjacent gapless tiles still read apart, reclaiming ~14px of board width.
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.
## Controls ## 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 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 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 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` **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 `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 - **Settings hub** (`screens/SettingsHub.svelte`, the lobby ⚙️ tab): one nav bar + a bottom
tab bar over four bodies — ⚙️ Settings, 👤 Profile, 🤝 Friends, ️ About — switched in 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 place; back always returns to the lobby. The **selected** tab is marked by a filled pill
the 🤝 tab badge both show the pending incoming-friend-request count. 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 - **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 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** code + its expiry; then the incoming **requests** (Accept / Decline), the **friends**
+7 -8
View File
@@ -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); 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 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')).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 }) => { test('shuffle reorders the rack but keeps the same tiles', async ({ page }) => {
+2
View File
@@ -7,6 +7,7 @@
import { insideTelegram, telegramBackButton } from './lib/telegram'; import { insideTelegram, telegramBackButton } from './lib/telegram';
import Toast from './components/Toast.svelte'; import Toast from './components/Toast.svelte';
import StaleInviteModal from './components/StaleInviteModal.svelte'; import StaleInviteModal from './components/StaleInviteModal.svelte';
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
import Login from './screens/Login.svelte'; import Login from './screens/Login.svelte';
import Lobby from './screens/Lobby.svelte'; import Lobby from './screens/Lobby.svelte';
import NewGame from './screens/NewGame.svelte'; import NewGame from './screens/NewGame.svelte';
@@ -110,6 +111,7 @@
<Toast /> <Toast />
<StaleInviteModal /> <StaleInviteModal />
<WelcomeRedeemModal />
<style> <style>
.splash { .splash {
-1
View File
@@ -47,7 +47,6 @@
locale: lc, locale: lc,
reduceMotion: prefs.reduceMotion ?? false, reduceMotion: prefs.reduceMotion ?? false,
boardLabels: prefs.boardLabels ?? 'beginner', boardLabels: prefs.boardLabels ?? 'beginner',
boardLines: prefs.boardLines ?? false,
}); });
prefs = { ...prefs, locale: lc }; prefs = { ...prefs, locale: lc };
} }
+19 -4
View File
@@ -51,10 +51,25 @@
line-height: 1; line-height: 1;
transition: background-color 0.12s; transition: background-color 0.12s;
} }
/* A tab that navigates between peer views (Settings / Comms hubs) stays highlighted /* A tab that navigates between peer views (Settings / Comms hubs) wraps its icon and label
while selected: a filled square with an accent underline. A tap itself leaves no in a .face so the selected highlight hugs both — a filled pill with an accent underline,
highlight — there is no press tint, and the WebKit tap flash is disabled on .tab. */ sized to the content with a small padding. A tap itself leaves no highlight (no press
:global(.tab.active .sq) { tint, and the WebKit tap flash is disabled on .tab). Plain action tabs (the lobby nav,
the in-game controls) carry no .face and are never .active, so they are unaffected. */
:global(.tab .face) {
display: inline-flex;
flex-direction: column;
align-items: center;
padding: 3px 10px;
border-radius: 12px;
transition: background-color 0.12s;
}
/* Inside a face the icon square needs no padding of its own — the face provides the
surround (the bare .sq padding still applies to the unwrapped action tabs). */
:global(.tab .face .sq) {
padding: 0;
}
:global(.tab.active .face) {
background: var(--surface-2); background: var(--surface-2);
box-shadow: inset 0 -2px 0 var(--accent); box-shadow: inset 0 -2px 0 var(--accent);
} }
+49 -5
View File
@@ -1,20 +1,28 @@
<script lang="ts"> <script lang="ts">
import { fade, fly } from 'svelte/transition'; import { fade, fly } from 'svelte/transition';
import { app } from '../lib/app.svelte'; import { app, dismissToast } from '../lib/app.svelte';
const dur = $derived(app.reduceMotion ? 0 : 260); const dur = $derived(app.reduceMotion ? 0 : 260);
// An info bubble owns its whole 2s life through a CSS rise-and-fade animation, so it takes
// no Svelte enter/leave transition; an error keeps the fly-in / fade-out dwell behaviour.
const isInfo = $derived(app.toast?.kind !== 'error');
</script> </script>
{#if app.toast} {#if app.toast}
<!-- Re-key on the toast's seq so a fresh message tears the old node down (out:fade) and replays <!-- Re-key on the toast's seq so a fresh message tears the old node down and replays the
the entrance (in:fly): the newest toast cancels the previous one and starts its own cycle. --> appear cycle: the newest toast cancels the previous one and starts its own. -->
{#key app.toast.seq} {#key app.toast.seq}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div <div
class="toast {app.toast.kind}" class="toast {app.toast.kind}"
class:rise={isInfo && !app.reduceMotion}
class:rise-reduced={isInfo && app.reduceMotion}
role="status" role="status"
aria-live="polite" aria-live="polite"
in:fly={{ y: 32, duration: dur }} onclick={dismissToast}
out:fade={{ duration: dur }} in:fly={{ y: isInfo ? 0 : 32, duration: isInfo ? 0 : dur }}
out:fade={{ duration: isInfo ? 0 : dur }}
> >
{app.toast.text} {app.toast.text}
</div> </div>
@@ -36,9 +44,45 @@
box-shadow: var(--shadow); box-shadow: var(--shadow);
z-index: 50; z-index: 50;
text-align: center; text-align: center;
cursor: pointer;
} }
.error { .error {
border-color: var(--danger); border-color: var(--danger);
color: var(--danger); color: var(--danger);
} }
/* An info bubble fades in, then drifts up by roughly the tab-bar height while fading out,
all within 2s; the X centring is preserved across the rise. */
.toast.rise {
animation: toast-rise 2s ease-out forwards;
}
@keyframes toast-rise {
0% {
opacity: 0;
transform: translateX(-50%) translateY(8px);
}
12% {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
100% {
opacity: 0;
transform: translateX(-50%) translateY(-56px);
}
}
/* Reduced motion: the same 2s life, fade only — no upward travel. */
.toast.rise-reduced {
animation: toast-rise-reduced 2s ease-out forwards;
}
@keyframes toast-rise-reduced {
0% {
opacity: 0;
}
12%,
70% {
opacity: 1;
}
100% {
opacity: 0;
}
}
</style> </style>
@@ -0,0 +1,58 @@
<script lang="ts">
import { app, dismissWelcomeRedeem } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte';
import { botUsername } from '../lib/deeplink';
import { telegramOpenLink } from '../lib/telegram';
import Modal from './Modal.svelte';
// Point at the bot the player signed in through (its service language), falling back to the
// interface locale, so an ru player is sent to the ru bot and an en player to the en one.
const username = $derived(botUsername(app.session?.serviceLanguage || app.locale));
// Greet the arriving player by their own display name.
const name = $derived(app.profile?.displayName || app.session?.displayName || '');
// Interpolate the name, then split the rest around the {bot} token so the bot handle renders
// as an inline link (the {name} value is substituted first; {bot} is left for the split).
const parts = $derived(t('friends.welcomeRedeem', { name }).split('{bot}'));
function openBot() {
if (username) {
const url = `https://t.me/${username}`;
// Inside Telegram, openTelegramLink navigates to the bot chat natively; elsewhere fall
// back to a new tab.
if (!telegramOpenLink(url) && typeof window !== 'undefined') window.open(url, '_blank');
}
dismissWelcomeRedeem();
}
</script>
{#if app.welcomeRedeem}
<Modal title={t('friends.welcomeRedeemTitle')} onclose={dismissWelcomeRedeem}>
<p class="msg">{parts[0]}{#if username}<button type="button" class="bot" onclick={openBot}>@{username}</button>{/if}{parts[1] ?? ''}</p>
<button class="ok" onclick={dismissWelcomeRedeem}>{t('common.ok')}</button>
</Modal>
{/if}
<style>
.msg {
margin: 0 0 16px;
line-height: 1.5;
/* The greeting and the bot sentence are separated by a blank line in the message. */
white-space: pre-line;
}
.bot {
background: none;
border: none;
padding: 0;
font: inherit;
color: var(--accent);
cursor: pointer;
}
.ok {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius-sm);
}
</style>
+18 -33
View File
@@ -18,7 +18,6 @@
landscape, landscape,
variant, variant,
labelMode, labelMode,
lines,
locale, locale,
focus, focus,
recenter, recenter,
@@ -41,8 +40,6 @@
landscape: boolean; landscape: boolean;
variant: Variant; variant: Variant;
labelMode: BoardLabelMode; labelMode: BoardLabelMode;
/** Draw 1px grid lines between cells; when false the board is a gapless checkerboard. */
lines: boolean;
locale: Locale; locale: Locale;
focus: { row: number; col: number } | null; focus: { row: number; col: number } | null;
/** A monotonic nonce the parent bumps to request a scroll to `focus` even when the zoom state /** A monotonic nonce the parent bumps to request a scroll to `focus` even when the zoom state
@@ -234,7 +231,7 @@
<div class="viewport" class:zoomed class:land={landscape} bind:this={viewport}> <div class="viewport" class:zoomed class:land={landscape} bind:this={viewport}>
<div class="scaler" class:land={landscape} style="--z: {z};"> <div class="scaler" class:land={landscape} style="--z: {z};">
<div class="grid" class:gridless={!lines}> <div class="grid">
{#each board as rowCells, r (r)} {#each board as rowCells, r (r)}
{#each rowCells as cell, c (c)} {#each rowCells as cell, c (c)}
{@const p = pending.get(key(r, c))} {@const p = pending.get(key(r, c))}
@@ -311,18 +308,21 @@
width 0.25s ease, width 0.25s ease,
height 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 { .grid {
display: grid; display: grid;
grid-template-columns: repeat(15, 1fr); grid-template-columns: repeat(15, 1fr);
gap: 1px; gap: 0;
background: var(--cell-line); background: var(--board-bg);
padding: 1px; padding: 0;
} }
.cell { .cell {
position: relative; position: relative;
aspect-ratio: 1; aspect-ratio: 1;
border: none; border: none;
border-radius: 1px; border-radius: 0;
background: var(--cell-bg); background: var(--cell-bg);
color: var(--prem-text); color: var(--prem-text);
/* No mobile tap flash on a cell tap (parity with the web click; the only intentional /* No mobile tap flash on a cell tap (parity with the web click; the only intentional
@@ -344,11 +344,20 @@
.cell.dl { .cell.dl {
background: var(--prem-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.filled,
.cell.pending { .cell.pending {
background: var(--tile-bg); background: var(--tile-bg);
color: var(--tile-text); 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 { .cell.pending {
background: var(--tile-pending); background: var(--tile-pending);
@@ -356,30 +365,6 @@
board) instead of the touch starting a board pan. */ board) instead of the touch starting a board pan. */
touch-action: none; 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 { .cell.droptarget {
/* The cell a carried tile is aimed at: an accent ring plus a light accent wash, so the /* 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. */ target reads clearly while dragging without obscuring the bonus label underneath. */
+2 -2
View File
@@ -39,11 +39,11 @@
{#snippet tabbar()} {#snippet tabbar()}
<TabBar> <TabBar>
<button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')}> <button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')}>
<span class="sq" aria-hidden="true">💬</span><span class="lbl">{t('game.chat')}</span> <span class="face"><span class="sq" aria-hidden="true">💬</span><span class="lbl">{t('game.chat')}</span></span>
</button> </button>
{#if active} {#if active}
<button class="tab" class:active={tab === 'dictionary'} onclick={() => (tab = 'dictionary')}> <button class="tab" class:active={tab === 'dictionary'} onclick={() => (tab = 'dictionary')}>
<span class="sq" aria-hidden="true">🔎</span><span class="lbl">{t('game.dictionary')}</span> <span class="face"><span class="sq" aria-hidden="true">🔎</span><span class="lbl">{t('game.dictionary')}</span></span>
</button> </button>
{/if} {/if}
</TabBar> </TabBar>
+10 -1
View File
@@ -813,6 +813,8 @@
// - closed: a downward "pull" opens the history, but only on the zoom-out board scrolled // - 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 // 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). // 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 // 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). // view, which left a stale-open state that made a follow-up score-bar tap "jump" the board).
let stageEl = $state<HTMLDivElement>(); let stageEl = $state<HTMLDivElement>();
@@ -869,6 +871,14 @@
if (-dy > 32) closeHistoryByGesture(); // enough upward travel if (-dy > 32) closeHistoryByGesture(); // enough upward travel
} else if (dy > 40 && dy > Math.abs(dx) * 1.4) { } else if (dy > 40 && dy > Math.abs(dx) * 1.4) {
openHistoryByGesture(); // a clear, vertical-dominant downward pull 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) { function onBoardWrapUp(e: PointerEvent) {
@@ -1156,7 +1166,6 @@
{landscape} {landscape}
{variant} {variant}
labelMode={app.boardLabels} labelMode={app.boardLabels}
lines={app.boardLines}
locale={app.locale} locale={app.locale}
{focus} {focus}
{recenter} {recenter}
+6 -1
View File
@@ -83,7 +83,12 @@
} }
.tile { .tile {
position: relative; 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); width: min(12.5vw, 46px);
aspect-ratio: 1; aspect-ratio: 1;
background: var(--tile-bg); background: var(--tile-bg);
+28 -11
View File
@@ -56,8 +56,6 @@ export const app = $state<{
locale: Locale; locale: Locale;
reduceMotion: boolean; reduceMotion: boolean;
boardLabels: BoardLabelMode; boardLabels: BoardLabelMode;
/** Draw grid lines between board cells; off (default) is a gapless checkerboard. */
boardLines: boolean;
localeLocked: boolean; localeLocked: boolean;
/** Pending incoming friend requests, for the lobby ⚙️ badge and the Settings Friends tab. */ /** Pending incoming friend requests, for the lobby ⚙️ badge and the Settings Friends tab. */
notifications: number; 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 * 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. */ * the bot instead of a scary error on the Friends screen. */
staleInvite: boolean; 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 /** 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 * 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). */ * 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', locale: 'en',
reduceMotion: false, reduceMotion: false,
boardLabels: 'beginner', boardLabels: 'beginner',
boardLines: false,
localeLocked: false, localeLocked: false,
notifications: 0, notifications: 0,
chatUnread: {}, chatUnread: {},
feedbackReplyUnread: false, feedbackReplyUnread: false,
staleInvite: false, staleInvite: false,
welcomeRedeem: false,
resync: 0, resync: 0,
}); });
@@ -143,7 +145,18 @@ function goForeground(): void {
export function showToast(text: string, kind: Toast['kind'] = 'info'): void { export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
app.toast = { kind, text, seq: ++toastSeq }; app.toast = { kind, text, seq: ++toastSeq };
if (toastTimer) clearTimeout(toastTimer); 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). */ /** dismissStaleInvite hides the outdated-invite-link notice (the user acknowledged it). */
@@ -151,6 +164,11 @@ export function dismissStaleInvite(): void {
app.staleInvite = false; 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 * 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 * 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<void> {
app.theme = prefs.theme ?? 'auto'; app.theme = prefs.theme ?? 'auto';
app.reduceMotion = prefs.reduceMotion ?? false; app.reduceMotion = prefs.reduceMotion ?? false;
app.boardLabels = prefs.boardLabels ?? 'beginner'; app.boardLabels = prefs.boardLabels ?? 'beginner';
app.boardLines = prefs.boardLines ?? false;
applyTheme(app.theme); applyTheme(app.theme);
applyReduceMotion(app.reduceMotion); applyReduceMotion(app.reduceMotion);
if (prefs.locale) { if (prefs.locale) {
@@ -560,7 +577,13 @@ async function routeStartParam(param: string): Promise<void> {
try { try {
const friend = await gateway.friendCodeRedeem(link.code); const friend = await gateway.friendCodeRedeem(link.code);
navigate('/friends'); navigate('/friends');
// 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 })); showToast(t('friends.added', { name: friend.displayName }));
}
void refreshNotifications(); void refreshNotifications();
} catch (err) { } catch (err) {
const code = err instanceof GatewayError ? err.code : ''; const code = err instanceof GatewayError ? err.code : '';
@@ -633,7 +656,6 @@ function persistPrefs(): void {
locale: app.locale, locale: app.locale,
reduceMotion: app.reduceMotion, reduceMotion: app.reduceMotion,
boardLabels: app.boardLabels, boardLabels: app.boardLabels,
boardLines: app.boardLines,
}); });
} }
@@ -686,11 +708,6 @@ export function setBoardLabels(mode: BoardLabelMode): void {
persistPrefs(); persistPrefs();
} }
export function setBoardLines(on: boolean): void {
app.boardLines = on;
persistPrefs();
}
// Background/foreground lifecycle: silence the reconnect banner during a suspend and // 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 // 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 // hidden, §10). Several signals cover the platforms: the page Visibility API, the
+2 -1
View File
@@ -163,7 +163,6 @@ export const en = {
'settings.labelsBeginner': 'Beginner', 'settings.labelsBeginner': 'Beginner',
'settings.labelsClassic': 'Classic', 'settings.labelsClassic': 'Classic',
'settings.labelsNone': 'None', 'settings.labelsNone': 'None',
'settings.boardLines': 'Grid lines',
'settings.reduceMotion': 'Reduce motion', 'settings.reduceMotion': 'Reduce motion',
'about.title': 'About', 'about.title': 'About',
@@ -241,6 +240,8 @@ export const en = {
'friends.selfInvite': "Hopefully you've been friends with yourself for a while ☺️", 'friends.selfInvite': "Hopefully you've been friends with yourself for a while ☺️",
'friends.staleInviteTitle': 'Link expired', 'friends.staleInviteTitle': 'Link expired',
'friends.staleInvite': 'You opened the game from an outdated link. Open the bot {bot} to play and get notifications.', '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.added': 'Added {name}.',
'friends.blockedList': 'Blocked players', 'friends.blockedList': 'Blocked players',
'friends.unblock': 'Unblock', 'friends.unblock': 'Unblock',
+2 -1
View File
@@ -164,7 +164,6 @@ export const ru: Record<MessageKey, string> = {
'settings.labelsBeginner': 'Новичок', 'settings.labelsBeginner': 'Новичок',
'settings.labelsClassic': 'Классика', 'settings.labelsClassic': 'Классика',
'settings.labelsNone': 'Без текста', 'settings.labelsNone': 'Без текста',
'settings.boardLines': 'Линии сетки',
'settings.reduceMotion': 'Меньше анимаций', 'settings.reduceMotion': 'Меньше анимаций',
'about.title': 'О программе', 'about.title': 'О программе',
@@ -242,6 +241,8 @@ export const ru: Record<MessageKey, string> = {
'friends.selfInvite': 'Надеюсь, что с собой Вы уже давно дружите ☺️', 'friends.selfInvite': 'Надеюсь, что с собой Вы уже давно дружите ☺️',
'friends.staleInviteTitle': 'Ссылка устарела', 'friends.staleInviteTitle': 'Ссылка устарела',
'friends.staleInvite': 'Вы открыли игру по устаревшей ссылке. Откройте бота {bot}, чтобы играть и получать уведомления.', 'friends.staleInvite': 'Вы открыли игру по устаревшей ссылке. Откройте бота {bot}, чтобы играть и получать уведомления.',
'friends.welcomeRedeemTitle': 'Добро пожаловать!',
'friends.welcomeRedeem': 'Добро пожаловать, {name}!\n\nВоспользуйтесь {bot}, чтобы заходить в игру наиболее удобным способом.',
'friends.added': 'Добавлен(а) {name}.', 'friends.added': 'Добавлен(а) {name}.',
'friends.blockedList': 'Заблокированные', 'friends.blockedList': 'Заблокированные',
'friends.unblock': 'Разблокировать', 'friends.unblock': 'Разблокировать',
-2
View File
@@ -124,8 +124,6 @@ export interface Prefs {
locale: Locale; locale: Locale;
reduceMotion: boolean; reduceMotion: boolean;
boardLabels: BoardLabelMode; boardLabels: BoardLabelMode;
/** Draw the 1px grid lines between cells; off (default) shows a gapless checkerboard. */
boardLines: boolean;
} }
export async function loadPrefs(): Promise<Partial<Prefs>> { export async function loadPrefs(): Promise<Partial<Prefs>> {
+4 -4
View File
@@ -380,8 +380,8 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border: none; border: none;
background: var(--danger); background: var(--bg-elev);
color: #fff; color: var(--text);
font-size: 1.1rem; font-size: 1.1rem;
} }
.row { .row {
@@ -404,7 +404,7 @@
gap: 12px; gap: 12px;
min-width: 0; min-width: 0;
text-align: left; text-align: left;
padding: 10px 6px; padding: 5px 6px;
border: none; border: none;
background: none; background: none;
color: var(--text); color: var(--text);
@@ -471,7 +471,7 @@
color: var(--text-muted); color: var(--text-muted);
} }
.emoji { .emoji {
font-size: 1.8rem; font-size: 1.35rem;
line-height: 1; line-height: 1;
flex: 0 0 auto; flex: 0 0 auto;
} }
-12
View File
@@ -2,7 +2,6 @@
import { import {
app, app,
setBoardLabels, setBoardLabels,
setBoardLines,
setLocalePref, setLocalePref,
setReduceMotion, setReduceMotion,
setTheme, setTheme,
@@ -62,14 +61,6 @@
</button> </button>
{/each} {/each}
</div> </div>
<label class="row gridlines">
<span>{t('settings.boardLines')}</span>
<input
type="checkbox"
checked={app.boardLines}
onchange={(e) => setBoardLines(e.currentTarget.checked)}
/>
</label>
</section> </section>
<section> <section>
@@ -124,7 +115,4 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
} }
.gridlines {
margin-top: 12px;
}
</style> </style>
+4 -4
View File
@@ -46,18 +46,18 @@
{#snippet tabbar()} {#snippet tabbar()}
<TabBar> <TabBar>
<button class="tab" class:active={tab === 'settings'} onclick={() => (tab = 'settings')}> <button class="tab" class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>
<span class="sq" aria-hidden="true">⚙️</span><span class="lbl">{t('settings.title')}</span> <span class="face"><span class="sq" aria-hidden="true">⚙️</span><span class="lbl">{t('settings.title')}</span></span>
</button> </button>
<button class="tab" class:active={tab === 'profile'} onclick={() => (tab = 'profile')}> <button class="tab" class:active={tab === 'profile'} onclick={() => (tab = 'profile')}>
<span class="sq" aria-hidden="true">👤</span><span class="lbl">{t('profile.title')}</span> <span class="face"><span class="sq" aria-hidden="true">👤</span><span class="lbl">{t('profile.title')}</span></span>
</button> </button>
{#if !guest} {#if !guest}
<button class="tab" class:active={tab === 'friends'} onclick={() => (tab = 'friends')}> <button class="tab" class:active={tab === 'friends'} onclick={() => (tab = 'friends')}>
<span class="sq" aria-hidden="true">🤝{#if app.notifications > 0}<span class="badge">{app.notifications}</span>{/if}</span><span class="lbl">{t('friends.title')}</span> <span class="face"><span class="sq" aria-hidden="true">🤝{#if app.notifications > 0}<span class="badge">{app.notifications}</span>{/if}</span><span class="lbl">{t('friends.title')}</span></span>
</button> </button>
{/if} {/if}
<button class="tab" class:active={tab === 'about'} onclick={() => (tab = 'about')}> <button class="tab" class:active={tab === 'about'} onclick={() => (tab = 'about')}>
<span class="sq" aria-hidden="true">{#if app.feedbackReplyUnread}<span class="badge">1</span>{/if}</span><span class="lbl">{t('about.tab')}</span> <span class="face"><span class="sq" aria-hidden="true">{#if app.feedbackReplyUnread}<span class="badge">1</span>{/if}</span><span class="lbl">{t('about.tab')}</span></span>
</button> </button>
</TabBar> </TabBar>
{/snippet} {/snippet}