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
+2
View File
@@ -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 @@
<Toast />
<StaleInviteModal />
<WelcomeRedeemModal />
<style>
.splash {
-1
View File
@@ -47,7 +47,6 @@
locale: lc,
reduceMotion: prefs.reduceMotion ?? false,
boardLabels: prefs.boardLabels ?? 'beginner',
boardLines: prefs.boardLines ?? false,
});
prefs = { ...prefs, locale: lc };
}
+19 -4
View File
@@ -51,10 +51,25 @@
line-height: 1;
transition: background-color 0.12s;
}
/* A tab that navigates between peer views (Settings / Comms hubs) stays highlighted
while selected: a filled square with an accent underline. A tap itself leaves no
highlight — there is no press tint, and the WebKit tap flash is disabled on .tab. */
:global(.tab.active .sq) {
/* A tab that navigates between peer views (Settings / Comms hubs) wraps its icon and label
in a .face so the selected highlight hugs both — a filled pill with an accent underline,
sized to the content with a small padding. A tap itself leaves no highlight (no press
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);
box-shadow: inset 0 -2px 0 var(--accent);
}
+49 -5
View File
@@ -1,20 +1,28 @@
<script lang="ts">
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);
// 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>
{#if app.toast}
<!-- Re-key on the toast's seq so a fresh message tears the old node down (out:fade) and replays
the entrance (in:fly): the newest toast cancels the previous one and starts its own cycle. -->
<!-- Re-key on the toast's seq so a fresh message tears the old node down and replays the
appear cycle: the newest toast cancels the previous one and starts its own. -->
{#key app.toast.seq}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="toast {app.toast.kind}"
class:rise={isInfo && !app.reduceMotion}
class:rise-reduced={isInfo && app.reduceMotion}
role="status"
aria-live="polite"
in:fly={{ y: 32, duration: dur }}
out:fade={{ duration: dur }}
onclick={dismissToast}
in:fly={{ y: isInfo ? 0 : 32, duration: isInfo ? 0 : dur }}
out:fade={{ duration: isInfo ? 0 : dur }}
>
{app.toast.text}
</div>
@@ -36,9 +44,45 @@
box-shadow: var(--shadow);
z-index: 50;
text-align: center;
cursor: pointer;
}
.error {
border-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>
@@ -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,
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 @@
<div class="viewport" class:zoomed class:land={landscape} bind:this={viewport}>
<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 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. */
+2 -2
View File
@@ -39,11 +39,11 @@
{#snippet tabbar()}
<TabBar>
<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>
{#if active}
<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>
{/if}
</TabBar>
+10 -1
View File
@@ -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<HTMLDivElement>();
@@ -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}
+6 -1
View File
@@ -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);
+29 -12
View File
@@ -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<void> {
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<void> {
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
+2 -1
View File
@@ -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',
+2 -1
View File
@@ -164,7 +164,6 @@ export const ru: Record<MessageKey, string> = {
'settings.labelsBeginner': 'Новичок',
'settings.labelsClassic': 'Классика',
'settings.labelsNone': 'Без текста',
'settings.boardLines': 'Линии сетки',
'settings.reduceMotion': 'Меньше анимаций',
'about.title': 'О программе',
@@ -242,6 +241,8 @@ export const ru: Record<MessageKey, string> = {
'friends.selfInvite': 'Надеюсь, что с собой Вы уже давно дружите ☺️',
'friends.staleInviteTitle': 'Ссылка устарела',
'friends.staleInvite': 'Вы открыли игру по устаревшей ссылке. Откройте бота {bot}, чтобы играть и получать уведомления.',
'friends.welcomeRedeemTitle': 'Добро пожаловать!',
'friends.welcomeRedeem': 'Добро пожаловать, {name}!\n\nВоспользуйтесь {bot}, чтобы заходить в игру наиболее удобным способом.',
'friends.added': 'Добавлен(а) {name}.',
'friends.blockedList': 'Заблокированные',
'friends.unblock': 'Разблокировать',
-2
View File
@@ -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<Partial<Prefs>> {
+4 -4
View File
@@ -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;
}
-12
View File
@@ -2,7 +2,6 @@
import {
app,
setBoardLabels,
setBoardLines,
setLocalePref,
setReduceMotion,
setTheme,
@@ -62,14 +61,6 @@
</button>
{/each}
</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>
@@ -124,7 +115,4 @@
align-items: center;
justify-content: space-between;
}
.gridlines {
margin-top: 12px;
}
</style>
+4 -4
View File
@@ -46,18 +46,18 @@
{#snippet tabbar()}
<TabBar>
<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 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>
{#if !guest}
<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>
{/if}
<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>
</TabBar>
{/snippet}