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
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:
@@ -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**
|
||||
|
||||
@@ -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)
|
||||
|
||||
+11
-8
@@ -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**
|
||||
|
||||
+7
-8
@@ -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 }) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
locale: lc,
|
||||
reduceMotion: prefs.reduceMotion ?? false,
|
||||
boardLabels: prefs.boardLabels ?? 'beginner',
|
||||
boardLines: prefs.boardLines ?? false,
|
||||
});
|
||||
prefs = { ...prefs, locale: lc };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Разблокировать',
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user