UI: tab-bar navigation — drop the hamburger
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 39s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 59s

Replace Menu.svelte (hamburger) everywhere with tab-bar navigation:
- Settings hub (SettingsHub) from the lobby ⚙️ tab: Settings/Profile/
  Friends/About as in-place tabs, back → lobby; the lobby ⚙️ badge counts
  incoming friend requests (invitations keep their own lobby section).
- Comms hub (CommsHub) from the move-history 💬: Chat/Dictionary tabs,
  back → game; Dictionary only while the game is active.
- Game menu items relocate into the open history: 🏁 leave / 📤 export in
  the header, 🤝 add-friend per opponent card, 💬 comms; unread chat is
  badged on the score bar + the 💬.
- TapConfirm (tap → fading  → tap) replaces the Skip/Hint press-and-hold
  popovers and drives the add-friend confirm.
- Fix the move-history "jump": the slid board is inert and the stage can't
  scroll, so a swipe up genuinely closes the history.

Remove Menu.svelte + HoldConfirm.svelte. Docs: UI_DESIGN, FUNCTIONAL(+ru),
PRERELEASE. UI check/unit/build/bundle/e2e (Chromium+WebKit) all green.
This commit is contained in:
Ilia Denisov
2026-06-11 14:13:54 +02:00
parent f8b6b7f2e3
commit fc1261e078
28 changed files with 1034 additions and 748 deletions
+8 -12
View File
@@ -9,14 +9,10 @@
import Login from './screens/Login.svelte';
import Lobby from './screens/Lobby.svelte';
import NewGame from './screens/NewGame.svelte';
import Profile from './screens/Profile.svelte';
import Settings from './screens/Settings.svelte';
import About from './screens/About.svelte';
import Friends from './screens/Friends.svelte';
import SettingsHub from './screens/SettingsHub.svelte';
import Stats from './screens/Stats.svelte';
import Game from './game/Game.svelte';
import ChatScreen from './game/ChatScreen.svelte';
import CheckScreen from './game/CheckScreen.svelte';
import CommsHub from './game/CommsHub.svelte';
onMount(() => {
void bootstrap();
@@ -83,17 +79,17 @@
{:else if router.route.name === 'game'}
<Game id={router.route.params.id} />
{:else if router.route.name === 'gameChat'}
<ChatScreen id={router.route.params.id} />
<CommsHub id={router.route.params.id} initialTab="chat" />
{:else if router.route.name === 'gameCheck'}
<CheckScreen id={router.route.params.id} />
<CommsHub id={router.route.params.id} initialTab="dictionary" />
{:else if router.route.name === 'profile'}
<Profile />
<SettingsHub initialTab="profile" />
{:else if router.route.name === 'settings'}
<Settings />
<SettingsHub initialTab="settings" />
{:else if router.route.name === 'about'}
<About />
<SettingsHub initialTab="about" />
{:else if router.route.name === 'friends'}
<Friends />
<SettingsHub initialTab="friends" />
{:else if router.route.name === 'stats'}
<Stats />
{:else}
+6 -16
View File
@@ -1,13 +1,11 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { navigate } from '../lib/router.svelte';
import { insideTelegram } from '../lib/telegram';
import { connection } from '../lib/connection.svelte';
import { t } from '../lib/i18n/index.svelte';
import Spinner from './Spinner.svelte';
let { title, back, menu, grow = false }: { title: string; back?: string; menu?: Snippet; grow?: boolean } =
$props();
let { title, back, grow = false }: { title: string; back?: string; grow?: boolean } = $props();
// Inside Telegram the native header back button (App.svelte) is the back control, so
// the app's own chevron is hidden to avoid two back affordances.
@@ -28,7 +26,8 @@
{:else}
<h1 class="connecting"><Spinner /> <span>{t('connection.connecting')}</span></h1>
{/if}
<div class="end">{#if menu}{@render menu()}{/if}</div>
<!-- A right-hand spacer balances the back button so the title stays centred. -->
<span class="spacer"></span>
</div>
</header>
@@ -75,18 +74,13 @@
font-weight: 500;
}
.icon,
.spacer,
.end {
.spacer {
min-width: 40px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.end {
width: auto;
justify-content: flex-end;
}
.back {
background: none;
border: none;
@@ -108,9 +102,8 @@
}
/* Telegram fullscreen: TG's native nav occupies the band between the device notch
(--tg-safe-top) and --tg-content-top. Our header spans that full band (so the layout below
is unchanged) and centres the title + menu as a pair (hamburger right of the title) within
it, BELOW the notch — lining them up vertically with Telegram's own back/menu controls,
which sit in the band's corners. */
is unchanged) and centres the title within it, BELOW the notch — lining it up vertically
with Telegram's own back/menu controls, which sit in the band's corners. */
:global(html.tg-fullscreen) .bar {
min-height: var(--tg-content-top);
box-sizing: border-box;
@@ -129,7 +122,4 @@
:global(html.tg-fullscreen) h1 {
flex: 0 1 auto;
}
:global(html.tg-fullscreen) .end {
min-width: 0;
}
</style>
-108
View File
@@ -1,108 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
// A press-and-hold control: a short tap opens a popover (the consumer renders its
// buttons), a ~holdMs hold runs `onhold` immediately. Reused by MakeMove and the
// game tab-bar confirmations. The popover snippet receives a `close` callback.
let {
onhold,
holdMs = 700,
disabled = false,
triggerClass = '',
trigger,
popover,
}: {
onhold: () => void;
holdMs?: number;
disabled?: boolean;
triggerClass?: string;
trigger: Snippet;
popover: Snippet<[() => void]>;
} = $props();
let open = $state(false);
let timer: ReturnType<typeof setTimeout> | null = null;
let held = false;
function clear() {
if (timer) {
clearTimeout(timer);
timer = null;
}
}
function down() {
if (disabled) return;
held = false;
clear();
timer = setTimeout(() => {
held = true;
open = false;
onhold();
}, holdMs);
}
function up() {
clear();
if (!held && !disabled) open = true;
}
function leave() {
clear();
}
const close = () => (open = false);
</script>
<div class="hc">
<button
class="trigger {triggerClass}"
{disabled}
onpointerdown={down}
onpointerup={up}
onpointerleave={leave}
onpointercancel={leave}
>
{@render trigger()}
</button>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="backdrop" onclick={close}></div>
<div class="popover">{@render popover(close)}</div>
{/if}
</div>
<style>
.hc {
position: relative;
display: flex;
}
.trigger {
width: 100%;
background: none;
border: none;
padding: 0;
color: inherit;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
.backdrop {
position: fixed;
inset: 0;
z-index: 18;
}
.popover {
position: absolute;
bottom: calc(100% + 6px);
right: 0;
z-index: 19;
display: flex;
flex-direction: column;
gap: 2px;
white-space: nowrap;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
padding: 4px;
min-width: 132px;
}
</style>
-131
View File
@@ -1,131 +0,0 @@
<script lang="ts">
// The header hamburger + dropdown, shared by the lobby and game screens. An item
// may carry a numeric badge; the hamburger shows the total via the `badge` prop so
// a pending count is visible while the menu is closed.
interface MenuItem {
label: string;
onclick: () => void;
badge?: number;
disabled?: boolean;
}
let { items, badge = 0 }: { items: MenuItem[]; badge?: number } = $props();
let open = $state(false);
function pick(fn: () => void) {
open = false;
fn();
}
</script>
<div class="menu">
<button class="burger" onclick={() => (open = !open)} aria-label="Menu">
<span></span><span></span><span></span>
{#if badge > 0}<span class="dot" data-testid="menu-badge">{badge}</span>{/if}
</button>
{#if open}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="backdrop" onclick={() => (open = false)}></div>
<div class="dropdown">
{#each items as it (it.label)}
<button onclick={() => pick(it.onclick)} disabled={it.disabled}>
<span>{it.label}</span>
{#if it.badge}<span class="idot">{it.badge}</span>{/if}
</button>
{/each}
</div>
{/if}
</div>
<style>
.menu {
position: relative;
display: inline-flex;
}
.burger {
position: relative;
background: none;
border: none;
width: 44px;
height: 38px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 5px;
padding: 0 10px;
user-select: none;
-webkit-user-select: none;
}
.dot {
position: absolute;
top: -2px;
right: 0;
min-width: 18px;
height: 18px;
padding: 0 4px;
border-radius: 999px;
background: var(--danger, #c0392b);
color: #fff;
font-size: 0.72rem;
line-height: 18px;
text-align: center;
font-weight: 700;
}
.idot {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: var(--danger, #c0392b);
color: #fff;
font-size: 0.72rem;
line-height: 18px;
text-align: center;
font-weight: 700;
}
.burger span:not(.dot) {
display: block;
height: 3px;
background: var(--text);
border-radius: 2px;
}
.backdrop {
position: fixed;
inset: 0;
z-index: 8;
}
.dropdown {
position: absolute;
right: 0;
top: calc(100% + 6px);
z-index: 9;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
min-width: 170px;
overflow: hidden;
}
.dropdown button {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 16px;
text-align: left;
background: none;
border: none;
color: var(--text);
user-select: none;
-webkit-user-select: none;
}
.dropdown button:hover:not(:disabled) {
background: var(--surface-2);
}
.dropdown button:disabled {
color: var(--text-muted);
opacity: 0.6;
}
</style>
+1 -3
View File
@@ -10,7 +10,6 @@
let {
title,
back,
menu,
tabbar,
children,
scroll = true,
@@ -19,7 +18,6 @@
}: {
title: string;
back?: string;
menu?: Snippet;
tabbar?: Snippet;
children?: Snippet;
scroll?: boolean;
@@ -58,7 +56,7 @@
</script>
<div class="screen">
<Header {title} {back} {menu} grow={growNav} />
<Header {title} {back} grow={growNav} />
{#if SHOW_AD_BANNER}<AdBanner />{/if}
<main class="content" class:scroll class:fill={!growNav} class:column>{@render children?.()}</main>
{#if tabbar}
+24 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import type { Snippet } from 'svelte';
// The bottom tab bar: square borderless buttons, evenly distributed, mobile-OS feel.
// Direct children (plain `.tab` buttons or HoldConfirm wrappers) share the width.
// Direct children (plain `.tab` buttons or TapConfirm wrappers) share the width.
let { children }: { children?: Snippet } = $props();
</script>
@@ -53,6 +53,29 @@
:global(.tab:active:not(:disabled) .sq) {
background: var(--surface-2);
}
/* A tab that navigates between peer views (Settings / Comms hubs) stays highlighted
while selected: a filled square with an accent underline, distinct from the
momentary press tint above. */
:global(.tab.active .sq) {
background: var(--surface-2);
box-shadow: inset 0 -2px 0 var(--accent);
}
/* A small count badge on the icon square's corner (lobby ⚙️, the Friends tab, the
hint count) — one shared style so every tab badge reads the same. */
:global(.tab .badge) {
position: absolute;
top: -3px;
right: -3px;
font-size: 0.68rem;
font-weight: 700;
background: var(--accent);
color: var(--accent-text);
border-radius: 999px;
min-width: 15px;
padding: 0 3px;
line-height: 1.4;
text-align: center;
}
:global(.tab .lbl) {
font-size: 0.62rem;
color: var(--text-muted);
+99
View File
@@ -0,0 +1,99 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { onDestroy } from 'svelte';
import { app } from '../lib/app.svelte';
import { createTapConfirm } from '../lib/tapconfirm';
// A two-tap confirmation control: the first tap on the trigger arms a ~durationMs
// window during which a ✅ is shown (fading out, unless reduce-motion); a tap on the
// ✅ within the window confirms. Replaces the press-and-hold and pop-up confirms.
// onConfirming reports the window opening/closing so a parent can swap adjacent content
// (e.g. a score for an "Add friend?" label). The click never bubbles, so the control can
// sit inside another clickable surface (the score bar) without triggering it.
let {
onconfirm,
durationMs = 2000,
disabled = false,
triggerClass = '',
label,
onConfirming,
children,
}: {
onconfirm: () => void;
durationMs?: number;
disabled?: boolean;
triggerClass?: string;
/** Accessible label for the control (applied in both states). */
label?: string;
/** Notified whenever the confirming flag flips, so a parent can react. */
onConfirming?: (confirming: boolean) => void;
children: Snippet;
} = $props();
let confirming = $state(false);
const ctl = createTapConfirm({
get durationMs() {
return durationMs;
},
onConfirm: () => onconfirm(),
onChange: (c) => {
confirming = c;
onConfirming?.(c);
},
});
onDestroy(() => ctl.dispose());
// A control disabled mid-window (e.g. it became the opponent's turn) closes it.
$effect(() => {
if (disabled && confirming) ctl.cancel();
});
function onclick(e: MouseEvent) {
e.stopPropagation();
if (confirming) ctl.confirm();
else if (!disabled) ctl.arm();
}
</script>
<button class="tapconfirm {triggerClass}" class:confirming {disabled} aria-label={label} {onclick}>
{#if confirming}
<span class="sq ok" class:fade={!app.reduceMotion} style="--tc-ms: {durationMs}ms"></span>
{:else}
{@render children()}
{/if}
</button>
<style>
.tapconfirm {
background: none;
border: none;
padding: 0;
color: inherit;
font: inherit;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
user-select: none;
-webkit-user-select: none;
}
.tapconfirm:disabled {
opacity: 0.4;
}
/* Outside the tab bar (where :global(.tab .sq) supplies the size) the confirm icon
needs a size of its own: inherit the trigger's, so ✅ matches the idle icon. */
.tapconfirm:not(.tab) .sq {
display: inline-grid;
place-items: center;
font-size: 1em;
}
.ok.fade {
animation: tc-fade var(--tc-ms) linear forwards;
}
@keyframes tc-fade {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
</style>
+5 -8
View File
@@ -1,15 +1,14 @@
<script lang="ts">
import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import Chat from './Chat.svelte';
import { gateway } from '../lib/gateway';
import { app, handleError, clearChatUnread } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte';
import type { ChatMessage, StateView } from '../lib/model';
// The chat is its own screen, so the soft keyboard simply resizes the viewport with
// the input pinned to the bottom — no modal relayout jank. It loads the game state (for the
// turn-based chat/nudge toggle) and the message list, and clears the unread badge while open.
// The Chat tab body, hosted by CommsHub (which supplies the nav bar + tab bar). The
// hub lays it out as a non-scrolling column, so the soft keyboard simply resizes the
// viewport with the input pinned to the bottom. It loads the game state (for the
// turn-based chat/nudge toggle) and the message list, and clears the unread while open.
let { id }: { id: string } = $props();
let view = $state<StateView | null>(null);
@@ -88,6 +87,4 @@
}
</script>
<Screen title={t('game.chat')} back={`/game/${id}`} scroll={false} column>
<Chat {messages} {myId} {busy} myTurn={isMyTurn} {nudgeOnCooldown} onsend={sendChat} onnudge={nudge} />
</Screen>
<Chat {messages} {myId} {busy} myTurn={isMyTurn} {nudgeOnCooldown} onsend={sendChat} onnudge={nudge} />
+18 -21
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import { gateway } from '../lib/gateway';
import { handleError, showToast } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte';
@@ -60,27 +59,25 @@
}
</script>
<Screen title={t('game.checkWord')} back={`/game/${id}`}>
<div class="wrap">
<div class="check">
<input
value={word}
oninput={onInput}
onkeydown={(e) => e.key === 'Enter' && runCheck()}
placeholder={t('game.checkWordPrompt')}
/>
<button onclick={runCheck} disabled={!canCheck()}>{t('game.check')}</button>
</div>
{#if result}
<p class="verdict" class:ok={result.legal} class:bad={!result.legal}>
{result.legal
? t('game.wordLegal', { word: result.word })
: t('game.wordIllegal', { word: result.word })}
</p>
<button class="complain" onclick={complain}>{t('game.complain')}</button>
{/if}
<div class="wrap">
<div class="check">
<input
value={word}
oninput={onInput}
onkeydown={(e) => e.key === 'Enter' && runCheck()}
placeholder={t('game.checkWordPrompt')}
/>
<button onclick={runCheck} disabled={!canCheck()}>{t('game.check')}</button>
</div>
</Screen>
{#if result}
<p class="verdict" class:ok={result.legal} class:bad={!result.legal}>
{result.legal
? t('game.wordLegal', { word: result.word })
: t('game.wordIllegal', { word: result.word })}
</p>
<button class="complain" onclick={complain}>{t('game.complain')}</button>
{/if}
</div>
<style>
.wrap {
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts">
import Screen from '../components/Screen.svelte';
import TabBar from '../components/TabBar.svelte';
import ChatScreen from './ChatScreen.svelte';
import CheckScreen from './CheckScreen.svelte';
import { t } from '../lib/i18n/index.svelte';
import { getCachedGame } from '../lib/gamecache';
// The in-game comms hub: a single nav bar + bottom tab bar hosting Chat and the word
// Dictionary. Tabs switch in place, so the back control always returns to the game. The
// Dictionary tab is offered only while the game is active (mirrors the old "check word").
type CommsTab = 'chat' | 'dictionary';
let { id, initialTab = 'chat' }: { id: string; initialTab?: CommsTab } = $props();
// The game is rendered (and cached) before its comms open, so the cache tells us whether
// it is still active without another fetch; an unknown game keeps the Dictionary offered.
const active = $derived(getCachedGame(id)?.view.game.status !== 'finished');
// Seeded once from the entry route's tab and then owned locally; the effect below
// corrects a Dictionary deep-link into a finished game back to Chat.
// svelte-ignore state_referenced_locally
let tab = $state<CommsTab>(initialTab);
$effect(() => {
if (tab === 'dictionary' && !active) tab = 'chat';
});
</script>
<Screen
title={t(tab === 'chat' ? 'game.chat' : 'game.checkWord')}
back={`/game/${id}`}
scroll={tab === 'dictionary'}
column={tab === 'chat'}
>
{#if tab === 'chat'}
<ChatScreen {id} />
{:else}
<CheckScreen {id} />
{/if}
{#snippet tabbar()}
<TabBar>
<button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')} aria-label={t('game.chat')}>
<span class="sq">💬</span>
</button>
{#if active}
<button class="tab" class:active={tab === 'dictionary'} onclick={() => (tab = 'dictionary')} aria-label={t('game.checkWord')}>
<span class="sq">🔎</span>
</button>
{/if}
</TabBar>
{/snippet}
</Screen>
+143 -61
View File
@@ -1,9 +1,8 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import Menu from '../components/Menu.svelte';
import TabBar from '../components/TabBar.svelte';
import HoldConfirm from '../components/HoldConfirm.svelte';
import TapConfirm from '../components/TapConfirm.svelte';
import Modal from '../components/Modal.svelte';
import Board from './Board.svelte';
import Rack from './Rack.svelte';
@@ -612,13 +611,46 @@
}
}
// Friend state for the in-game "add to friends" item, derived from the server so it is
// correct across reloads and live-updates when a request is answered:
// `friends` are the caller's accepted friends; `requested` are the addressees already
// requested (pending or declined — both block a re-send and read as "request sent").
// --- move history: open by tapping the score bar, close by tapping or swiping up the board ---
// While the history is open the board is inert (CSS pointer-events), so the whole slid board
// reads as a "tap or swipe up to close" surface and the stage cannot scroll instead of close.
// The tap closes on click; the swipe closes as soon as enough upward travel is seen, so it
// never depends on where a fast swipe's pointerup lands (which differs across engines).
// Closing genuinely clears `historyOpen` (rather than only scrolling the slid board out of
// view, which left a stale-open state that made a follow-up score-bar tap "jump" the board).
let histSwipeY: number | null = null;
function toggleHistory() {
historyOpen = !historyOpen;
}
function closeHistoryByGesture() {
if (!historyOpen) return;
historyOpen = false;
histSwipeY = null;
// Swallow the click some browsers synthesise from a board tap, so it does not place a tile.
swallowClick = true;
setTimeout(() => (swallowClick = false), 120);
}
function onBoardWrapDown(e: PointerEvent) {
histSwipeY = historyOpen ? e.clientY : null;
}
function onBoardWrapMove(e: PointerEvent) {
if (histSwipeY !== null && histSwipeY - e.clientY > 32) closeHistoryByGesture();
}
// A closed history clears every per-seat add-friend confirmation.
$effect(() => {
if (!historyOpen) addConfirm = {};
});
// Friend state for the in-game "add friend" affordance (the 🤝 in each opponent's score
// card while the history is open), derived from the server so it is correct across reloads
// and live-updates when a request is answered: `friends` are the caller's accepted friends;
// `requested` are the addressees already requested (pending or declined — both block a
// re-send and disable the 🤝).
let friends = $state(new Set<string>());
let requested = $state(new Set<string>());
const noop = () => {};
// Per-seat "confirming" flag for the 🤝 → ✅ tap-to-confirm (TapConfirm writes it); while
// set, that seat's card shows "Add friend?" in place of the score. Reset when history closes.
let addConfirm = $state<Record<number, boolean>>({});
// loadFriends refreshes the friend/outgoing sets for a non-guest; guests have no social
// surfaces, so the sets stay empty. Best-effort — a failure leaves the previous sets.
@@ -643,50 +675,52 @@
}
}
const opponents = $derived(
view ? view.game.seats.filter((s) => s.accountId !== app.session?.userId) : [],
);
// In a finished game the menu drops Check word and Drop game, gains Export GCG, and
// an "add to friends" item flips to a disabled "request sent" once tapped.
const menuItems = $derived([
{ label: t('game.history'), onclick: () => (historyOpen = true) },
{ label: t('game.chat'), onclick: () => navigate(`/game/${id}/chat`), badge: app.chatUnread[id] ?? 0 },
...(gameOver ? [] : [{ label: t('game.checkWord'), onclick: () => navigate(`/game/${id}/check`) }]),
...(view?.game.status === 'finished' ? [{ label: t('game.exportGcg'), onclick: exportGcg }] : []),
...(!app.profile?.isGuest
? opponents.map((s) =>
friends.has(s.accountId)
? { label: t('game.alreadyFriends'), onclick: noop, disabled: true }
: requested.has(s.accountId)
? { label: t('game.requestSent'), onclick: noop, disabled: true }
: { label: `${t('friends.addFromGame')}: ${s.displayName}`, onclick: () => addFriend(s.accountId) },
)
: []),
...(gameOver ? [] : [{ label: t('game.dropGame'), onclick: () => (resignOpen = true) }]),
]);
// canAddFriend reports whether a seat shows the 🤝: a non-guest viewing an opponent who is
// not yet a friend (an already-requested opponent still shows it, but disabled).
function canAddFriend(accountId: string): boolean {
return !app.profile?.isGuest && accountId !== app.session?.userId && !friends.has(accountId);
}
</script>
<Screen title={t(variantNameKey(variant))} back="/" growNav column scroll={false}>
{#snippet menu()}
<Menu items={menuItems} badge={app.chatUnread[id] ?? 0} />
{/snippet}
{#if view}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="scoreboard" onclick={() => (historyOpen = !historyOpen)}>
<div class="scoreboard" onclick={toggleHistory}>
{#if (app.chatUnread[id] ?? 0) > 0}<span class="cbadge sbadge">{app.chatUnread[id]}</span>{/if}
{#each view.game.seats as s (s.seat)}
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
<div class="nm">{s.accountId === app.session?.userId ? t('common.you') : s.displayName}</div>
<div class="sc">{s.score}</div>
<div class="sc">{addConfirm[s.seat] ? t('game.addFriendShort') : s.score}</div>
{#if historyOpen && canAddFriend(s.accountId)}
<span class="addfriend">
<TapConfirm
label={t('friends.addFromGame')}
disabled={requested.has(s.accountId)}
onConfirming={(v) => (addConfirm[s.seat] = v)}
onconfirm={() => addFriend(s.accountId)}
>
<span class="fico">🤝</span>
</TapConfirm>
</span>
{/if}
</div>
{/each}
</div>
<div class="stage">
<div class="stage" class:histopen={historyOpen}>
{#if historyOpen}
<div class="history">
<div class="hhead">
{#if gameOver}
<button class="hicon" onclick={exportGcg} aria-label={t('game.exportGcg')}>📤</button>
{:else}
<button class="hicon" onclick={() => (resignOpen = true)} aria-label={t('game.dropGame')}>🏁</button>
{/if}
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
💬{#if (app.chatUnread[id] ?? 0) > 0}<span class="cbadge">{app.chatUnread[id]}</span>{/if}
</button>
</div>
<ol>
{#each moves as m, i (i)}
<li>
@@ -705,7 +739,10 @@
<div
class="boardwrap"
class:slid={historyOpen}
onclick={() => historyOpen && (historyOpen = false)}
onpointerdown={onBoardWrapDown}
onpointermove={onBoardWrapMove}
onpointerup={() => (histSwipeY = null)}
onclick={closeHistoryByGesture}
>
<Board
{board}
@@ -769,17 +806,18 @@
<button class="tab" disabled={busy || !isMyTurn || !connection.online || bagEmpty} onclick={openExchange}>
<span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>
</button>
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn || !connection.online} onhold={doPass}>
{#snippet trigger()}<span class="sq">🥺</span><span class="lbl">{t('game.skip')}</span>{/snippet}
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doPass(); }}>{t('game.confirm')} ✅</button>{/snippet}
</HoldConfirm>
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn || !connection.online || (view?.hintsRemaining ?? 0) <= 0} onhold={doHint}>
{#snippet trigger()}
<span class="sq">🛟{#if (view?.hintsRemaining ?? 0) > 0}<span class="badge">{view?.hintsRemaining}</span>{/if}</span>
<span class="lbl">{t('game.hint')}</span>
{/snippet}
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doHint(); }}>{t('game.confirm')} ✅</button>{/snippet}
</HoldConfirm>
<TapConfirm triggerClass="tab" label={t('game.skip')} disabled={busy || !isMyTurn || !connection.online} onconfirm={doPass}>
<span class="sq">🥺</span><span class="lbl">{t('game.skip')}</span>
</TapConfirm>
<TapConfirm
triggerClass="tab"
label={t('game.hint')}
disabled={busy || !isMyTurn || !connection.online || (view?.hintsRemaining ?? 0) <= 0}
onconfirm={doHint}
>
<span class="sq">🛟{#if (view?.hintsRemaining ?? 0) > 0}<span class="badge">{view?.hintsRemaining}</span>{/if}</span>
<span class="lbl">{t('game.hint')}</span>
</TapConfirm>
{#if placement.pending.length > 0}
<button class="tab" disabled={busy} onclick={resetPlacement}>
<span class="sq">↩️</span><span class="lbl">{t('game.reset')}</span>
@@ -836,6 +874,7 @@
<style>
.scoreboard {
position: relative;
display: flex;
flex: none;
gap: 6px;
@@ -844,6 +883,7 @@
cursor: pointer;
}
.seat {
position: relative;
flex: 1;
text-align: center;
padding: 5px 4px;
@@ -891,6 +931,11 @@
overflow-y: auto;
overflow-x: hidden;
}
/* While the history is open the stage must not scroll — a swipe up on the board closes
the panel instead of scrolling the slid board out from under it. */
.stage.histopen {
overflow: hidden;
}
.history {
position: absolute;
inset: 0 0 auto 0;
@@ -947,6 +992,10 @@
.boardwrap.slid {
transform: translateY(62%);
}
/* The slid board is inert: the whole surface reads as "tap or swipe up to close". */
.boardwrap.slid :global(.viewport) {
pointer-events: none;
}
.status {
display: flex;
flex: none;
@@ -998,22 +1047,47 @@
.make:disabled {
opacity: 0.4;
}
.pop {
padding: 9px 14px;
border: none;
background: none;
color: var(--text);
border-radius: var(--radius-sm);
font-weight: 500;
text-align: left;
}
.pop:hover {
/* The move-history header: leave (active) / export (finished) on the left, comms on the
right, icon-only. Sticky so it stays atop the scrolling move list. */
.hhead {
position: sticky;
top: 0;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
background: var(--surface-2);
border-bottom: 1px solid var(--border);
}
.badge {
.hicon {
position: relative;
background: none;
border: none;
color: var(--text);
font-size: 1.3rem;
line-height: 1;
padding: 4px 8px;
border-radius: var(--radius-sm);
}
.hicon:active {
background: var(--bg-elev);
}
/* The 🤝 add-friend control: pinned to the seat's right edge so the centred name and
score never shift; the TapConfirm inside swaps it for a fading ✅ on tap. */
.addfriend {
position: absolute;
right: 2px;
top: 50%;
transform: translateY(-50%);
font-size: 1.15rem;
}
.fico {
line-height: 1;
}
/* The unread-chat count: on the score bar's corner and on the history's 💬 icon. */
.cbadge {
position: absolute;
top: -3px;
right: -3px;
font-size: 0.68rem;
font-weight: 700;
background: var(--accent);
@@ -1024,6 +1098,14 @@
line-height: 1.4;
text-align: center;
}
.sbadge {
top: 2px;
right: 4px;
}
.hicon .cbadge {
top: -1px;
right: -1px;
}
.loading {
text-align: center;
color: var(--text-muted);
+14 -14
View File
@@ -49,9 +49,9 @@ export const app = $state<{
/** Draw grid lines between board cells; off (default) is a gapless checkerboard. */
boardLines: boolean;
localeLocked: boolean;
/** Pending incoming friend requests + invitations, for the lobby badge. */
/** Pending incoming friend requests, for the lobby ⚙️ badge and the Settings Friends tab. */
notifications: number;
/** Unread chat-message count per game id, for the in-game menu/hamburger badge. */
/** Unread chat-message count per game id, for the in-game score-bar and 💬 badges. */
chatUnread: Record<string, number>;
}>({
ready: false,
@@ -139,9 +139,12 @@ function openStream(): void {
reportOnline(); // a delivered event proves the gateway is reachable
app.lastEvent = e;
if (e.kind === 'chat_message' && e.message.senderId !== app.session?.userId) {
// While the player is on that game's chat screen, neither toast nor bump the unread.
const onChat = router.route.name === 'gameChat' && router.route.params.id === e.message.gameId;
if (!onChat) {
// While the player is in that game's comms hub (chat or dictionary tab), neither
// toast nor bump the unread — the chat is a tap away and reloads on open.
const inComms =
(router.route.name === 'gameChat' || router.route.name === 'gameCheck') &&
router.route.params.id === e.message.gameId;
if (!inComms) {
if (e.message.kind !== 'nudge') {
const gid = e.message.gameId;
app.chatUnread = { ...app.chatUnread, [gid]: (app.chatUnread[gid] ?? 0) + 1 };
@@ -186,9 +189,10 @@ function scheduleReconnect(): void {
}
/**
* refreshNotifications recomputes the lobby badge count (incoming friend requests
* plus open invitations). Authoritative poll, complementing the live 'notify' push.
* Guests have no social surfaces, so it is a no-op for them.
* refreshNotifications recomputes the badge count (incoming friend requests).
* Authoritative poll, complementing the live 'notify' push. Game invitations have
* their own lobby section, so they are not counted here. Guests have no social
* surfaces, so it is a no-op for them.
*/
export async function refreshNotifications(): Promise<void> {
if (!app.session || app.profile?.isGuest) {
@@ -196,11 +200,7 @@ export async function refreshNotifications(): Promise<void> {
return;
}
try {
const [incoming, invitations] = await Promise.all([
gateway.friendsIncoming(),
gateway.invitationsList(),
]);
app.notifications = incoming.length + invitations.length;
app.notifications = (await gateway.friendsIncoming()).length;
} catch {
// Best-effort; leave the previous count on a transient failure.
}
@@ -260,7 +260,7 @@ function syncTelegramChrome(): void {
/**
* syncTelegramSafeArea mirrors Telegram's content-safe-area top inset (the height its native
* nav overlays the viewport in fullscreen) into the --tg-content-top CSS var and toggles a
* `tg-fullscreen` class, so the header can drop below the nav and lift the menu into its
* `tg-fullscreen` class, so the header can drop below the nav and centre the title in its
* band. Called on launch and on Telegram's safe-area / fullscreen change events.
*/
function syncTelegramSafeArea(): void {
+1 -4
View File
@@ -63,7 +63,6 @@ export const en = {
'game.skip': 'Skip',
'game.shuffle': 'Shuffle',
'game.hint': 'Hint',
'game.history': 'History',
'game.chat': 'Chat',
'game.checkWord': 'Check word',
'game.dropGame': 'Drop game',
@@ -83,7 +82,6 @@ export const en = {
'game.wordIllegal': '“{word}” is not valid',
'game.complain': 'Disagree',
'game.complaintSent': 'Thanks, sent for review.',
'game.confirm': 'Ok',
'game.check': 'Check',
'game.checkWait': 'Please wait a moment.',
'game.noHintOptions': 'No options with your letters.',
@@ -242,8 +240,7 @@ export const en = {
'game.exportGcg': 'Export GCG',
'game.gcgActiveOnly': 'Available once the game is finished.',
'game.requestSent': 'Request sent',
'game.alreadyFriends': '✓ In friends',
'game.addFriendShort': 'Add friend?',
'time.minutes': '{n} min',
'time.hours': '{n} h',
+1 -4
View File
@@ -64,7 +64,6 @@ export const ru: Record<MessageKey, string> = {
'game.skip': 'Пас',
'game.shuffle': 'Перемешать',
'game.hint': 'Подсказка',
'game.history': 'История',
'game.chat': 'Чат',
'game.checkWord': 'Проверить слово',
'game.dropGame': 'Покинуть игру',
@@ -84,7 +83,6 @@ export const ru: Record<MessageKey, string> = {
'game.wordIllegal': '«{word}» недопустимо',
'game.complain': 'Не согласен',
'game.complaintSent': 'Спасибо, отправлено на проверку.',
'game.confirm': 'Да',
'game.check': 'Проверить',
'game.checkWait': 'Секунду, пожалуйста.',
'game.noHintOptions': 'Нет вариантов с вашим набором.',
@@ -243,8 +241,7 @@ export const ru: Record<MessageKey, string> = {
'game.exportGcg': 'Экспорт GCG',
'game.gcgActiveOnly': 'Доступно после завершения игры.',
'game.requestSent': 'Запрос отправлен',
'game.alreadyFriends': '✓ В друзьях',
'game.addFriendShort': 'В друзья?',
'time.minutes': '{n} мин',
'time.hours': '{n} ч',
+66
View File
@@ -0,0 +1,66 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createTapConfirm } from './tapconfirm';
describe('createTapConfirm', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('arms a window and reverts after the duration', () => {
const changes: boolean[] = [];
const c = createTapConfirm({ durationMs: 2000, onConfirm: () => {}, onChange: (x) => changes.push(x) });
c.arm();
expect(c.confirming).toBe(true);
expect(changes).toEqual([true]);
vi.advanceTimersByTime(1999);
expect(c.confirming).toBe(true);
vi.advanceTimersByTime(1);
expect(c.confirming).toBe(false);
expect(changes).toEqual([true, false]);
});
it('confirms within the window exactly once and stops the revert timer', () => {
const onConfirm = vi.fn();
const c = createTapConfirm({ durationMs: 2000, onConfirm });
c.arm();
c.confirm();
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(c.confirming).toBe(false);
vi.advanceTimersByTime(5000); // the revert timer must not fire after a confirm
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it('ignores confirm when the window is not open', () => {
const onConfirm = vi.fn();
const c = createTapConfirm({ durationMs: 2000, onConfirm });
c.confirm();
expect(onConfirm).not.toHaveBeenCalled();
expect(c.confirming).toBe(false);
});
it('treats arm as idempotent while already confirming', () => {
const changes: boolean[] = [];
const c = createTapConfirm({ durationMs: 2000, onConfirm: () => {}, onChange: (x) => changes.push(x) });
c.arm();
c.arm();
expect(changes).toEqual([true]);
});
it('cancel closes the window without confirming', () => {
const onConfirm = vi.fn();
const c = createTapConfirm({ durationMs: 2000, onConfirm });
c.arm();
c.cancel();
expect(c.confirming).toBe(false);
vi.advanceTimersByTime(5000);
expect(onConfirm).not.toHaveBeenCalled();
});
it('dispose clears a pending timer without a revert callback', () => {
const changes: boolean[] = [];
const c = createTapConfirm({ durationMs: 2000, onConfirm: () => {}, onChange: (x) => changes.push(x) });
c.arm();
c.dispose();
vi.advanceTimersByTime(5000);
expect(changes).toEqual([true]);
});
});
+79
View File
@@ -0,0 +1,79 @@
/**
* tapconfirm holds the small state machine behind the "tap to confirm" controls: the
* first tap arms a confirmation window of durationMs (during which the view shows a
* fading ✅), a second tap within it confirms, and otherwise the window reverts. It is
* framework agnostic — a view observes onChange and renders accordingly — so the timing
* logic is unit-testable without a DOM. The pending timer is the only side effect.
*/
export interface TapConfirmOptions {
/** Length of the confirmation window in milliseconds. */
durationMs: number;
/** Invoked once when a confirmation lands inside the window. */
onConfirm: () => void;
/** Invoked whenever the confirming flag flips, so a view can react. */
onChange?: (confirming: boolean) => void;
}
/** TapConfirmController drives a single "tap to confirm" control. */
export interface TapConfirmController {
/** Whether the confirmation window is currently open. */
readonly confirming: boolean;
/** Arm the confirmation window; a no-op while it is already open. */
arm(): void;
/** Confirm within the window: fires onConfirm once and closes the window. A no-op
* while the window is closed. */
confirm(): void;
/** Close the window without confirming (e.g. the control was disabled). */
cancel(): void;
/** Clear any pending timer; the controller must not be reused afterwards. */
dispose(): void;
}
/**
* createTapConfirm builds a TapConfirmController whose confirmation window lasts
* durationMs. onConfirm fires once per confirmed window; onChange (when given)
* reports every flip of the confirming flag.
*/
export function createTapConfirm(opts: TapConfirmOptions): TapConfirmController {
let confirming = false;
let timer: ReturnType<typeof setTimeout> | null = null;
function clear(): void {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
}
function set(next: boolean): void {
if (confirming === next) return;
confirming = next;
opts.onChange?.(next);
}
return {
get confirming() {
return confirming;
},
arm() {
if (confirming) return;
set(true);
timer = setTimeout(() => {
timer = null;
set(false);
}, opts.durationMs);
},
confirm() {
if (!confirming) return;
clear();
set(false);
opts.onConfirm();
},
cancel() {
if (!confirming) return;
clear();
set(false);
},
dispose() {
clear();
},
};
}
+20 -23
View File
@@ -1,5 +1,4 @@
<script lang="ts">
import Screen from '../components/Screen.svelte';
import { t } from '../lib/i18n/index.svelte';
import { app } from '../lib/app.svelte';
import { aboutContent } from '../lib/aboutContent';
@@ -10,31 +9,29 @@
const c = $derived(aboutContent(app.locale, AUTO_MATCH_HOURS));
</script>
<Screen title={t('about.title')} back="/">
<div class="page">
<h1>{c.title}</h1>
<p>
{c.rulesPrefix}<a href={c.rulesUrl} target="_blank" rel="noopener noreferrer">{c.rulesLink}</a>.
</p>
<div class="page">
<h1>{c.title}</h1>
<p>
{c.rulesPrefix}<a href={c.rulesUrl} target="_blank" rel="noopener noreferrer">{c.rulesLink}</a>.
</p>
<section>
<h2>{c.randomTitle}</h2>
<p class="respect">❗️{c.randomRespect}</p>
<ul>
{#each c.random as item (item)}<li>{item}</li>{/each}
</ul>
</section>
<section>
<h2>{c.randomTitle}</h2>
<p class="respect">❗️{c.randomRespect}</p>
<ul>
{#each c.random as item (item)}<li>{item}</li>{/each}
</ul>
</section>
<section>
<h2>{c.friendsTitle}</h2>
<ul>
{#each c.friends as item (item)}<li>{item}</li>{/each}
</ul>
</section>
<section>
<h2>{c.friendsTitle}</h2>
<ul>
{#each c.friends as item (item)}<li>{item}</li>{/each}
</ul>
</section>
<p class="muted">{t('about.version', { v: version })}</p>
</div>
</Screen>
<p class="muted">{t('about.version', { v: version })}</p>
</div>
<style>
.page {
+74 -77
View File
@@ -1,6 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway';
@@ -81,88 +80,86 @@
}
</script>
<Screen title={t('friends.title')} back="/">
<div class="page">
{#if app.profile?.isGuest}
<p class="muted">{t('profile.guestLocked')}</p>
{:else}
<section>
<h3>{t('friends.add')}</h3>
<div class="addrow">
<input
class="codein"
bind:value={redeemInput}
placeholder={t('friends.codePlaceholder')}
inputmode="numeric"
maxlength="6"
/>
<button class="btn" onclick={redeem} disabled={!connection.online}>{t('friends.redeem')}</button>
</div>
{#if code}
{@const tg = shareLink(friendCodeParam(code.code))}
<div class="code" data-testid="friend-code">
<div class="coderow">
<button class="codeval" onclick={copyCode}>{code.code}</button>
<button class="copy" onclick={copyCode} aria-label={t('friends.copy')}>📋</button>
</div>
<span class="codehint">
{t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })}
</span>
{#if tg}
<a class="link tgshare" href={tg} target="_blank" rel="noopener">{t('friends.shareTelegram')}</a>
{/if}
<div class="page">
{#if app.profile?.isGuest}
<p class="muted">{t('profile.guestLocked')}</p>
{:else}
<section>
<h3>{t('friends.add')}</h3>
<div class="addrow">
<input
class="codein"
bind:value={redeemInput}
placeholder={t('friends.codePlaceholder')}
inputmode="numeric"
maxlength="6"
/>
<button class="btn" onclick={redeem} disabled={!connection.online}>{t('friends.redeem')}</button>
</div>
{#if code}
{@const tg = shareLink(friendCodeParam(code.code))}
<div class="code" data-testid="friend-code">
<div class="coderow">
<button class="codeval" onclick={copyCode}>{code.code}</button>
<button class="copy" onclick={copyCode} aria-label={t('friends.copy')}>📋</button>
</div>
{:else}
<button class="link" onclick={getCode} disabled={!connection.online}>{t('friends.getCode')}</button>
{/if}
</section>
{#if incoming.length}
<section>
<h3>{t('friends.incoming')}</h3>
{#each incoming as r (r.accountId)}
<div class="item">
<span class="who">{r.displayName}</span>
<span class="acts">
<button class="btn" onclick={() => respond(r.accountId, true)} disabled={!connection.online}>{t('friends.accept')}</button>
<button class="ghost" onclick={() => respond(r.accountId, false)} disabled={!connection.online}>{t('friends.decline')}</button>
</span>
</div>
{/each}
</section>
<span class="codehint">
{t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })}
</span>
{#if tg}
<a class="link tgshare" href={tg} target="_blank" rel="noopener">{t('friends.shareTelegram')}</a>
{/if}
</div>
{:else}
<button class="link" onclick={getCode} disabled={!connection.online}>{t('friends.getCode')}</button>
{/if}
</section>
{#if incoming.length}
<section>
<h3>{t('friends.yours')}</h3>
{#if friends.length}
{#each friends as f (f.accountId)}
<div class="item">
<span class="who">{f.displayName}</span>
<span class="acts">
<button class="ghost" onclick={() => remove(f.accountId)} disabled={!connection.online}>{t('friends.unfriend')}</button>
<button class="ghost danger" onclick={() => blockUser(f.accountId)} disabled={!connection.online}>{t('friends.block')}</button>
</span>
</div>
{/each}
{:else}
<p class="muted">{t('friends.none')}</p>
{/if}
<h3>{t('friends.incoming')}</h3>
{#each incoming as r (r.accountId)}
<div class="item">
<span class="who">{r.displayName}</span>
<span class="acts">
<button class="btn" onclick={() => respond(r.accountId, true)} disabled={!connection.online}>{t('friends.accept')}</button>
<button class="ghost" onclick={() => respond(r.accountId, false)} disabled={!connection.online}>{t('friends.decline')}</button>
</span>
</div>
{/each}
</section>
{#if blocked.length}
<section>
<h3>{t('friends.blockedList')}</h3>
{#each blocked as b (b.accountId)}
<div class="item">
<span class="who">{b.displayName}</span>
<button class="ghost" onclick={() => unblock(b.accountId)} disabled={!connection.online}>{t('friends.unblock')}</button>
</div>
{/each}
</section>
{/if}
{/if}
</div>
</Screen>
<section>
<h3>{t('friends.yours')}</h3>
{#if friends.length}
{#each friends as f (f.accountId)}
<div class="item">
<span class="who">{f.displayName}</span>
<span class="acts">
<button class="ghost" onclick={() => remove(f.accountId)} disabled={!connection.online}>{t('friends.unfriend')}</button>
<button class="ghost danger" onclick={() => blockUser(f.accountId)} disabled={!connection.online}>{t('friends.block')}</button>
</span>
</div>
{/each}
{:else}
<p class="muted">{t('friends.none')}</p>
{/if}
</section>
{#if blocked.length}
<section>
<h3>{t('friends.blockedList')}</h3>
{#each blocked as b (b.accountId)}
<div class="item">
<span class="who">{b.displayName}</span>
<button class="ghost" onclick={() => unblock(b.accountId)} disabled={!connection.online}>{t('friends.unblock')}</button>
</div>
{/each}
</section>
{/if}
{/if}
</div>
<style>
.page {
+7 -13
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import Menu from '../components/Menu.svelte';
import TabBar from '../components/TabBar.svelte';
import { app, handleError, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
@@ -24,7 +23,9 @@
games = (await gateway.gamesList()).games;
if (!guest) {
[invitations, incoming] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]);
app.notifications = invitations.length + incoming.length;
// The ⚙️ badge counts only what lives behind it (incoming friend requests);
// invitations surface in their own lobby section above.
app.notifications = incoming.length;
}
setLobby({ games, invitations, incoming });
} catch (e) {
@@ -116,13 +117,6 @@
}
}
const menuItems = $derived([
...(guest ? [] : [{ label: t('lobby.friends'), onclick: () => navigate('/friends'), badge: incoming.length }]),
{ label: t('lobby.profile'), onclick: () => navigate('/profile') },
{ label: t('lobby.settings'), onclick: () => navigate('/settings') },
{ label: t('lobby.about'), onclick: () => navigate('/about') },
]);
async function acceptInvite(inv: Invitation) {
try {
const r = await gateway.invitationAccept(inv.id);
@@ -151,10 +145,6 @@
</script>
<Screen title={app.profile?.displayName ?? t('app.title')}>
{#snippet menu()}
<Menu items={menuItems} badge={app.notifications} />
{/snippet}
<div class="lobby">
{#if invitations.length}
<section>
@@ -238,6 +228,10 @@
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>
<span class="sq">🏆</span><span class="lbl">{t('lobby.tournaments')}</span>
</button>
<button class="tab" onclick={() => navigate('/settings')}>
<span class="sq">⚙️{#if app.notifications > 0}<span class="badge">{app.notifications}</span>{/if}</span>
<span class="lbl">{t('lobby.settings')}</span>
</button>
</TabBar>
{/snippet}
</Screen>
+95 -98
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import Modal from '../components/Modal.svelte';
import Screen from '../components/Screen.svelte';
import { app, applyLinkResult, handleError, logout, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway';
@@ -160,109 +159,107 @@
}
</script>
<Screen title={t('profile.title')} back="/">
<div class="page">
{#if app.profile}
{@const p = app.profile}
<div class="name">{p.displayName}</div>
{#if p.isGuest}<span class="badge">{t('profile.guest')}</span>{/if}
<div class="page">
{#if app.profile}
{@const p = app.profile}
<div class="name">{p.displayName}</div>
{#if p.isGuest}<span class="badge">{t('profile.guest')}</span>{/if}
{#if p.isGuest}
<p class="muted">{t('profile.guestLocked')}</p>
{:else}
<form class="edit" onsubmit={(e) => { e.preventDefault(); void save(); }}>
<label>
<span>{t('profile.displayName')}</span>
<input class:invalid={!nameOk} bind:value={dn} maxlength="40" />
</label>
<label>
<span>{t('profile.timezone')}</span>
<select bind:value={tz}>
{#each timezoneOffsets as o (o)}<option value={o}>{o}</option>{/each}
</select>
</label>
<fieldset class="away" class:invalid={!awayOk}>
<legend>{t('profile.awayWindow')}</legend>
<div class="times">
<span class="tlabel">{t('profile.from')}</span>
<select bind:value={startH}>{#each awayHours as h (h)}<option>{h}</option>{/each}</select>
<span class="colon">:</span>
<select bind:value={startM}>{#each awayMinutes as m (m)}<option>{m}</option>{/each}</select>
</div>
<div class="times">
<span class="tlabel">{t('profile.to')}</span>
<select bind:value={endH}>{#each awayHours as h (h)}<option>{h}</option>{/each}</select>
<span class="colon">:</span>
<select bind:value={endM}>{#each awayMinutes as m (m)}<option>{m}</option>{/each}</select>
</div>
<p class="muted">{t('profile.awayHint')}</p>
</fieldset>
<label class="check">
<input type="checkbox" bind:checked={blockChat} />
<span>{t('profile.blockChat')}</span>
</label>
<label class="check">
<input type="checkbox" bind:checked={blockFriendRequests} />
<span>{t('profile.blockFriendRequests')}</span>
</label>
<label class="check">
<input type="checkbox" bind:checked={notificationsInAppOnly} />
<span>{t('profile.notificationsInAppOnly')}</span>
</label>
<div class="formacts">
<button type="submit" class="btn" disabled={!formValid || !connection.online}>{t('common.save')}</button>
{#if p.isGuest}
<p class="muted">{t('profile.guestLocked')}</p>
{:else}
<form class="edit" onsubmit={(e) => { e.preventDefault(); void save(); }}>
<label>
<span>{t('profile.displayName')}</span>
<input class:invalid={!nameOk} bind:value={dn} maxlength="40" />
</label>
<label>
<span>{t('profile.timezone')}</span>
<select bind:value={tz}>
{#each timezoneOffsets as o (o)}<option value={o}>{o}</option>{/each}
</select>
</label>
<fieldset class="away" class:invalid={!awayOk}>
<legend>{t('profile.awayWindow')}</legend>
<div class="times">
<span class="tlabel">{t('profile.from')}</span>
<select bind:value={startH}>{#each awayHours as h (h)}<option>{h}</option>{/each}</select>
<span class="colon">:</span>
<select bind:value={startM}>{#each awayMinutes as m (m)}<option>{m}</option>{/each}</select>
</div>
</form>
{/if}
<!-- Linking & merge. Shown to everyone, including guests, who
upgrade by binding their first identity. -->
<section class="emailbox">
<h3>{t('profile.linkAccount')}</h3>
{#if !emailSent}
<div class="addrow">
<input
class:invalid={emailInput.length > 0 && !emailOk}
bind:value={emailInput}
placeholder={t('login.emailPlaceholder')}
type="email"
/>
<button class="ghost" onclick={requestEmail} disabled={!emailOk || !connection.online}>{t('login.sendCode')}</button>
<div class="times">
<span class="tlabel">{t('profile.to')}</span>
<select bind:value={endH}>{#each awayHours as h (h)}<option>{h}</option>{/each}</select>
<span class="colon">:</span>
<select bind:value={endM}>{#each awayMinutes as m (m)}<option>{m}</option>{/each}</select>
</div>
{:else}
<div class="addrow">
<input
class="codein"
bind:value={codeInput}
placeholder={t('profile.emailCode')}
inputmode="numeric"
maxlength="6"
/>
<button class="btn" onclick={confirmEmail} disabled={!connection.online}>{t('common.ok')}</button>
</div>
{/if}
{#if telegramLinkable}
<button class="ghost tg" onclick={linkTelegram} disabled={!connection.online}>{t('profile.linkTelegram')}</button>
{/if}
</section>
<!-- Logout is hidden for now but kept wired — drop `hidden` to re-enable
once its entry point is decided; logout() also still runs on an invalid session. -->
<button class="logout" hidden onclick={() => logout()}>{t('login.title')} / logout</button>
<p class="muted">{t('profile.awayHint')}</p>
</fieldset>
<label class="check">
<input type="checkbox" bind:checked={blockChat} />
<span>{t('profile.blockChat')}</span>
</label>
<label class="check">
<input type="checkbox" bind:checked={blockFriendRequests} />
<span>{t('profile.blockFriendRequests')}</span>
</label>
<label class="check">
<input type="checkbox" bind:checked={notificationsInAppOnly} />
<span>{t('profile.notificationsInAppOnly')}</span>
</label>
<div class="formacts">
<button type="submit" class="btn" disabled={!formValid || !connection.online}>{t('common.save')}</button>
</div>
</form>
{/if}
</div>
{#if pendingMerge}
<Modal title={t('profile.mergeTitle')} onclose={() => (pendingMerge = null)}>
<p>{t('profile.mergeBody', { name: pendingMerge.name, games: pendingMerge.games, friends: pendingMerge.friends })}</p>
<p class="warn">{t('profile.mergeIrreversible')}</p>
<div class="addrow end">
<button class="ghost" onclick={() => (pendingMerge = null)}>{t('common.cancel')}</button>
<button class="btn" onclick={confirmMerge} disabled={!connection.online}>{t('profile.mergeConfirm')}</button>
</div>
</Modal>
<!-- Linking & merge. Shown to everyone, including guests, who
upgrade by binding their first identity. -->
<section class="emailbox">
<h3>{t('profile.linkAccount')}</h3>
{#if !emailSent}
<div class="addrow">
<input
class:invalid={emailInput.length > 0 && !emailOk}
bind:value={emailInput}
placeholder={t('login.emailPlaceholder')}
type="email"
/>
<button class="ghost" onclick={requestEmail} disabled={!emailOk || !connection.online}>{t('login.sendCode')}</button>
</div>
{:else}
<div class="addrow">
<input
class="codein"
bind:value={codeInput}
placeholder={t('profile.emailCode')}
inputmode="numeric"
maxlength="6"
/>
<button class="btn" onclick={confirmEmail} disabled={!connection.online}>{t('common.ok')}</button>
</div>
{/if}
{#if telegramLinkable}
<button class="ghost tg" onclick={linkTelegram} disabled={!connection.online}>{t('profile.linkTelegram')}</button>
{/if}
</section>
<!-- Logout is hidden for now but kept wired — drop `hidden` to re-enable
once its entry point is decided; logout() also still runs on an invalid session. -->
<button class="logout" hidden onclick={() => logout()}>{t('login.title')} / logout</button>
{/if}
</Screen>
</div>
{#if pendingMerge}
<Modal title={t('profile.mergeTitle')} onclose={() => (pendingMerge = null)}>
<p>{t('profile.mergeBody', { name: pendingMerge.name, games: pendingMerge.games, friends: pendingMerge.friends })}</p>
<p class="warn">{t('profile.mergeIrreversible')}</p>
<div class="addrow end">
<button class="ghost" onclick={() => (pendingMerge = null)}>{t('common.cancel')}</button>
<button class="btn" onclick={confirmMerge} disabled={!connection.online}>{t('profile.mergeConfirm')}</button>
</div>
</Modal>
{/if}
<style>
.page {
+48 -51
View File
@@ -1,5 +1,4 @@
<script lang="ts">
import Screen from '../components/Screen.svelte';
import {
app,
setBoardLabels,
@@ -28,64 +27,62 @@
};
</script>
<Screen title={t('settings.title')} back="/">
<div class="page">
{#if !insideTelegram()}
<section>
<h3>{t('settings.theme')}</h3>
<div class="seg">
{#each themes as th (th)}
<button class="opt" class:active={app.theme === th} onclick={() => setTheme(th)}>
{t(themeLabel[th])}
</button>
{/each}
</div>
</section>
{/if}
<div class="page">
{#if !insideTelegram()}
<section>
<h3>{t('settings.language')}</h3>
<h3>{t('settings.theme')}</h3>
<div class="seg">
{#each locales as lc (lc)}
<button class="opt" class:active={app.locale === lc} onclick={() => setLocalePref(lc)}>
{t(lc === 'en' ? 'lang.en' : 'lang.ru')}
{#each themes as th (th)}
<button class="opt" class:active={app.theme === th} onclick={() => setTheme(th)}>
{t(themeLabel[th])}
</button>
{/each}
</div>
</section>
{/if}
<section>
<h3>{t('settings.boardStyle')}</h3>
<div class="sub">{t('settings.boardLabels')}</div>
<div class="seg">
{#each labelModes as lm (lm)}
<button class="opt" class:active={app.boardLabels === lm} onclick={() => setBoardLabels(lm)}>
{t(labelModeKey[lm])}
</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>
<h3>{t('settings.language')}</h3>
<div class="seg">
{#each locales as lc (lc)}
<button class="opt" class:active={app.locale === lc} onclick={() => setLocalePref(lc)}>
{t(lc === 'en' ? 'lang.en' : 'lang.ru')}
</button>
{/each}
</div>
</section>
<section>
<label class="row">
<span>{t('settings.reduceMotion')}</span>
<input
type="checkbox"
checked={app.reduceMotion}
onchange={(e) => setReduceMotion(e.currentTarget.checked)}
/>
</label>
</section>
</div>
</Screen>
<section>
<h3>{t('settings.boardStyle')}</h3>
<div class="sub">{t('settings.boardLabels')}</div>
<div class="seg">
{#each labelModes as lm (lm)}
<button class="opt" class:active={app.boardLabels === lm} onclick={() => setBoardLabels(lm)}>
{t(labelModeKey[lm])}
</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>
<label class="row">
<span>{t('settings.reduceMotion')}</span>
<input
type="checkbox"
checked={app.reduceMotion}
onchange={(e) => setReduceMotion(e.currentTarget.checked)}
/>
</label>
</section>
</div>
<style>
.page {
+64
View File
@@ -0,0 +1,64 @@
<script lang="ts">
import Screen from '../components/Screen.svelte';
import TabBar from '../components/TabBar.svelte';
import Settings from './Settings.svelte';
import Profile from './Profile.svelte';
import Friends from './Friends.svelte';
import About from './About.svelte';
import { app } from '../lib/app.svelte';
import { t, type MessageKey } from '../lib/i18n/index.svelte';
// The Settings hub: a single nav bar + bottom tab bar hosting the Settings / Profile /
// Friends / About bodies. Tabs switch in place (no navigation), so the back control
// always returns to the lobby. Guests have no social surface, so the Friends tab hides.
type SettingsTab = 'settings' | 'profile' | 'friends' | 'about';
let { initialTab = 'settings' }: { initialTab?: SettingsTab } = $props();
const guest = $derived(app.profile?.isGuest ?? true);
// The active tab is seeded once from the entry route's tab and then owned locally;
// the hub is keyed by route in App.svelte, so initialTab is constant for its lifetime.
// svelte-ignore state_referenced_locally
let tab = $state<SettingsTab>(initialTab);
// A guest who deep-links to the Friends tab falls back to Settings.
$effect(() => {
if (guest && tab === 'friends') tab = 'settings';
});
const titleKey: Record<SettingsTab, MessageKey> = {
settings: 'settings.title',
profile: 'profile.title',
friends: 'friends.title',
about: 'about.title',
};
</script>
<Screen title={t(titleKey[tab])} back="/">
{#if tab === 'settings'}
<Settings />
{:else if tab === 'profile'}
<Profile />
{:else if tab === 'friends'}
<Friends />
{:else}
<About />
{/if}
{#snippet tabbar()}
<TabBar>
<button class="tab" class:active={tab === 'settings'} onclick={() => (tab = 'settings')} aria-label={t('settings.title')}>
<span class="sq">⚙️</span>
</button>
<button class="tab" class:active={tab === 'profile'} onclick={() => (tab = 'profile')} aria-label={t('profile.title')}>
<span class="sq">👤</span>
</button>
{#if !guest}
<button class="tab" class:active={tab === 'friends'} onclick={() => (tab = 'friends')} aria-label={t('friends.title')}>
<span class="sq">🤝{#if app.notifications > 0}<span class="badge">{app.notifications}</span>{/if}</span>
</button>
{/if}
<button class="tab" class:active={tab === 'about'} onclick={() => (tab = 'about')} aria-label={t('about.title')}>
<span class="sq"></span>
</button>
</TabBar>
{/snippet}
</Screen>