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
+53 -24
View File
@@ -10,9 +10,8 @@ async function openGame(page: Page): Promise<void> {
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /Ann/ }).click(); // the seeded active game vs Ann
await expect(page.locator('[data-cell]').first()).toBeVisible();
// Wait for the screen-slide transition to settle so only the game pane remains;
// until it does, the leaving lobby pane's header (its menu button) is also in the
// DOM, which would make shared locators like .burger ambiguous.
// Wait for the screen-slide transition to settle so only the game pane remains; until it
// does, the leaving lobby pane is also in the DOM, which would make shared locators ambiguous.
await expect(page.locator('.pane')).toHaveCount(1);
}
@@ -115,17 +114,38 @@ test('shuffle reorders the rack but keeps the same tiles', async ({ page }) => {
expect([...after].sort()).toEqual([...before].sort());
});
test('history slides the board down and closes on a board tap', async ({ page }) => {
test('history slides the board down on a score tap and closes on a board tap', async ({ page }) => {
await openGame(page);
await page.locator('.burger').click();
await page.locator('.dropdown button').nth(0).click(); // History
await page.locator('.scoreboard').click(); // tapping the score bar opens the history
await expect(page.locator('.history')).toBeVisible();
await expect(page.locator('.boardwrap.slid')).toBeVisible();
await page.locator('.boardwrap').click(); // tapping the board closes it
// Tap the (now inert) board's visible top strip below the history to close it.
await page.locator('.boardwrap').click({ position: { x: 30, y: 12 } });
await expect(page.locator('.history')).toBeHidden();
});
test('history: a swipe-up close does not make a follow-up score tap jump', async ({ page }) => {
await openGame(page);
await page.locator('.scoreboard').click();
await expect(page.locator('.boardwrap.slid')).toBeVisible();
// Swipe up on the inert board to close it. Dispatched on the board so the gesture is
// engine-deterministic — a mouse swipe over a pointer-events:none child is simulated
// inconsistently across engines, whereas a real touch swipe lands the same way this does.
const bw = page.locator('.boardwrap');
const box = (await bw.boundingBox())!;
const cx = box.x + box.width / 2;
await bw.dispatchEvent('pointerdown', { clientX: cx, clientY: 500, bubbles: true });
await bw.dispatchEvent('pointermove', { clientX: cx, clientY: 450, bubbles: true });
await expect(page.locator('.history')).toBeHidden();
// A score tap now cleanly reopens the history — the stale-open "jump" no longer happens.
await page.locator('.scoreboard').click();
await expect(page.locator('.history')).toBeVisible();
await expect(page.locator('.boardwrap.slid')).toBeVisible();
});
test('Draw opens the exchange dialog and confirms a selection', async ({ page }) => {
await openGame(page);
await page.locator('button:has-text("🔄")').click(); // Draw tab
@@ -137,10 +157,22 @@ test('Draw opens the exchange dialog and confirms a selection', async ({ page })
await expect(page.locator('.exch')).toBeHidden();
});
test('pass confirms with a tap on the fading ✅ instead of a popup', async ({ page }) => {
await openGame(page);
const pass = page.getByRole('button', { name: 'Skip' }); // the 🥺 tab (aria-label)
await expect(pass).toBeEnabled();
await pass.click(); // arm: 🥺 -> a fading ✅
await pass.click(); // tap the ✅ to confirm within the window
// The pass hands the turn over, so the control goes inert.
await expect(pass).toBeDisabled();
});
test('check-word sanitises input and shows a verdict', async ({ page }) => {
await openGame(page);
await page.locator('.burger').click();
await page.locator('.dropdown button').nth(2).click(); // Check word
await page.locator('.scoreboard').click(); // open the history
await page.getByRole('button', { name: 'Chat' }).click(); // 💬 -> comms hub
await expect(page.locator('.pane')).toHaveCount(1);
await page.getByRole('button', { name: 'Check word' }).click(); // 🔎 -> dictionary tab
const input = page.locator('.check input');
await input.fill('qz9!a'); // digits/punctuation dropped, letters upper-cased
@@ -152,8 +184,8 @@ test('check-word sanitises input and shows a verdict', async ({ page }) => {
test('dropping the game ends it and shows the result', async ({ page }) => {
await openGame(page);
await page.locator('.burger').click();
await page.getByRole('button', { name: 'Drop game' }).click(); // robust against menu growth
await page.locator('.scoreboard').click(); // open the history
await page.getByRole('button', { name: 'Drop game' }).click(); // 🏁 in the history header
await page.locator('button.danger').click(); // confirm in the modal
await expect(page.locator('.status .over')).toBeVisible();
});
@@ -181,26 +213,23 @@ test('a placed tile drags from one board cell to another (relocation)', async ({
expect(to).not.toBe(from);
});
test('chat and word-check open as their own screens and back to the game', async ({ page }) => {
test('comms hub: chat and dictionary share a screen, back returns to the game', async ({ page }) => {
await openGame(page);
await page.locator('.burger').click();
await page.getByRole('button', { name: /^Chat$/ }).click();
await page.locator('.scoreboard').click(); // open the history
await page.getByRole('button', { name: 'Chat' }).click(); // 💬 in the history header
await expect(page).toHaveURL(/\/game\/g1\/chat$/);
await expect(page.locator('.pane')).toHaveCount(1); // let the slide transition settle
await expect(page.locator('.chat')).toBeVisible();
// The outgoing game header and the incoming chat header both carry a .back mid-slide; wait
// for the game's to unmount so the click targets a single, settled button.
// The Dictionary tab switches in place (same screen, no navigation).
await page.getByRole('button', { name: 'Check word' }).click();
await expect(page.locator('.check input')).toBeVisible();
// The header back chevron returns to the game.
await expect(page.locator('.back')).toHaveCount(1);
await page.locator('.back').click(); // header back chevron returns to the game
await page.locator('.back').click();
await expect(page).toHaveURL(/\/game\/g1$/);
await expect(page.locator('.pane')).toHaveCount(1);
await page.locator('.burger').click();
await page.getByRole('button', { name: /Check word/ }).click();
await expect(page).toHaveURL(/\/game\/g1\/check$/);
await expect(page.locator('.pane')).toHaveCount(1);
await expect(page.locator('.check input')).toBeVisible();
});
test('the board-label mode in Settings changes the on-board labels', async ({ page }) => {
+67 -42
View File
@@ -10,9 +10,18 @@ async function loginLobby(page: Page): Promise<void> {
await expect(page.getByText('Your turn')).toBeVisible();
}
async function openFriends(page: Page): Promise<void> {
await page.locator('.burger').first().click();
await page.getByRole('button', { name: /Friends/ }).click();
// The Settings hub (the lobby ⚙️ tab) hosts Settings / Profile / Friends / About as in-place
// tabs; the back control always returns to the lobby. Tabs are icon-only with an aria-label.
async function openSettingsTab(page: Page, tab: 'Profile' | 'Friends' | 'About'): Promise<void> {
await page.getByRole('button', { name: /Settings/ }).click(); // lobby ⚙️ tab
await expect(page.locator('.pane')).toHaveCount(1); // let the slide settle
await page.getByRole('button', { name: tab, exact: true }).click();
}
function openFriends(page: Page): Promise<void> {
return openSettingsTab(page, 'Friends');
}
function openProfile(page: Page): Promise<void> {
return openSettingsTab(page, 'Profile');
}
test('friends: issue a code, accept an incoming request, redeem a code', async ({ page }) => {
@@ -50,10 +59,28 @@ test('stats screen shows the metrics', async ({ page }) => {
await expect(page.getByText('Best move')).toBeVisible();
});
test('settings hub: tabs switch in place and back returns to the lobby', async ({ page }) => {
await loginLobby(page);
await page.getByRole('button', { name: /Settings/ }).click(); // lobby ⚙️ tab
await expect(page.locator('.pane')).toHaveCount(1);
await expect(page.locator('.seg').first()).toBeVisible(); // default Settings tab
// Switching tabs is in place (no navigation): the Friends body appears, still one pane.
await page.getByRole('button', { name: 'Friends', exact: true }).click();
await expect(page.getByText('Friend requests')).toBeVisible();
await expect(page.locator('.pane')).toHaveCount(1);
await page.getByRole('button', { name: 'About', exact: true }).click();
await expect(page.getByText(/Version/)).toBeVisible();
// Back returns to the lobby from any tab.
await page.locator('.back').click();
await expect(page.getByText('Your turn')).toBeVisible();
});
test('profile edit saves a new display name', async ({ page }) => {
await loginLobby(page);
await page.locator('.burger').first().click();
await page.getByRole('button', { name: /Profile/ }).click();
await openProfile(page);
await page.locator('.edit input').first().fill('Kaya Test');
await page.getByRole('button', { name: /^Save$/ }).click();
await expect(page.locator('.name')).toHaveText('Kaya Test');
@@ -61,29 +88,33 @@ test('profile edit saves a new display name', async ({ page }) => {
test('GCG export appears only for a finished game', async ({ page }) => {
await loginLobby(page);
// The finished game vs Kaya exposes the export; the menu carries the item.
// The finished game vs Kaya exposes export 📤 in the history header.
await page.getByRole('button', { name: /Kaya/ }).click();
await page.locator('.burger').first().click();
await expect(page.getByRole('button', { name: /Export GCG/ })).toBeVisible();
await page.locator('.scoreboard').click(); // open the history
await expect(page.getByRole('button', { name: 'Export GCG' })).toBeVisible();
});
test('GCG export is hidden for an active game', async ({ page }) => {
await loginLobby(page);
await page.getByRole('button', { name: /Ann/ }).click();
await page.locator('.burger').first().click();
await expect(page.getByRole('button', { name: /Export GCG/ })).toHaveCount(0);
await page.locator('.scoreboard').click(); // open the history (shows 🏁 leave, not 📤 export)
await expect(page.getByRole('button', { name: 'Export GCG' })).toHaveCount(0);
});
test('finished game draws an inert footer and trims the live-only menu', async ({ page }) => {
test('finished game draws an inert footer and trims live-only controls', async ({ page }) => {
await loginLobby(page);
await page.getByRole('button', { name: /Kaya/ }).click(); // finished game vs Kaya
await expect(page.locator('[data-cell]').first()).toBeVisible();
// The footer (tab bar) is drawn but its controls are disabled in a finished game.
await expect(page.locator('.tab').first()).toBeDisabled();
// The menu drops Check word and Drop game once the game is over.
await page.locator('.burger').first().click();
await expect(page.getByRole('button', { name: 'Check word' })).toHaveCount(0);
// The history header offers Export GCG, not Drop game, once the game is over.
await page.locator('.scoreboard').click();
await expect(page.getByRole('button', { name: 'Export GCG' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Drop game' })).toHaveCount(0);
// The comms hub offers Chat only — the Dictionary tab is hidden for a finished game.
await page.getByRole('button', { name: 'Chat' }).click(); // 💬
await expect(page.locator('.pane')).toHaveCount(1);
await expect(page.getByRole('button', { name: 'Check word' })).toHaveCount(0);
});
test('lobby: hiding a finished game removes it (kebab → ❌), keeping the others', async ({ page }) => {
@@ -114,10 +145,11 @@ test('lobby: the active-row chevron opens the game (not a no-op)', async ({ page
await expect(page.locator('[data-cell]').first()).toBeVisible();
});
test('lobby hamburger shows the pending notification count', async ({ page }) => {
test('lobby ⚙️ tab shows the pending friend-request count', async ({ page }) => {
await loginLobby(page);
// One incoming friend request (Rick) + one invitation (Kaya) = 2.
await expect(page.getByTestId('menu-badge')).toHaveText('2');
// The ⚙️ badge counts incoming friend requests only (Rick = 1); invitations have their
// own lobby section, so they are not summed into it.
await expect(page.getByRole('button', { name: /Settings/ }).locator('.badge')).toHaveText('1');
});
test('play with friends: a game type is required to send an invitation', async ({ page }) => {
@@ -138,34 +170,28 @@ test('play with friends: a game type is required to send an invitation', async (
await expect(page.getByText('Your turn')).toBeVisible();
});
test('game: add-to-friends flips to a disabled "request sent"', async ({ page }) => {
test('game: the add-friend 🤝 confirms with a tap and then reads as sent', async ({ page }) => {
await loginLobby(page);
await page.getByRole('button', { name: /Ann/ }).click(); // active game vs Ann
await page.locator('.burger').first().click();
await page.getByRole('button', { name: /Add to friends: Ann/ }).click();
// Reopening the menu shows the item as a disabled "request sent".
await page.locator('.burger').first().click();
const sent = page.getByRole('button', { name: 'Request sent' });
await expect(sent).toBeVisible();
await expect(sent).toBeDisabled();
await page.locator('.scoreboard').click(); // open the history -> 🤝 appears on Ann's card
const add = page.getByRole('button', { name: 'Add to friends' });
await add.click(); // arm: 🤝 -> a fading ✅
await add.click(); // tap the ✅ to confirm within the window
// The request is sent, so the control is now disabled.
await expect(add).toBeDisabled();
});
test('game: an opponent who is already a friend shows a disabled "in friends"', async ({ page }) => {
test('game: an opponent who is already a friend shows no add-friend 🤝', async ({ page }) => {
await loginLobby(page);
await page.getByRole('button', { name: /Kaya/ }).click(); // the finished game vs Kaya, a seeded friend
await page.locator('.burger').first().click();
// The in-game friend item is derived from the server's friend list: a friend reads
// a disabled "✓ in friends", not the addable "Add to friends".
const inFriends = page.getByRole('button', { name: /in friends/i });
await expect(inFriends).toBeVisible();
await expect(inFriends).toBeDisabled();
await expect(page.getByRole('button', { name: /Add to friends: Kaya/ })).toHaveCount(0);
await page.getByRole('button', { name: /Kaya/ }).click(); // finished game vs Kaya, a seeded friend
await page.locator('.scoreboard').click(); // open the history
// Kaya is already a friend, so no add-friend control is offered on her card.
await expect(page.getByRole('button', { name: 'Add to friends' })).toHaveCount(0);
});
test('profile edit disables Save and flags an invalid display name', async ({ page }) => {
await loginLobby(page);
await page.locator('.burger').first().click();
await page.getByRole('button', { name: /Profile/ }).click();
await openProfile(page);
const name = page.locator('.edit input').first();
const save = page.getByRole('button', { name: /^Save$/ });
@@ -179,8 +205,7 @@ test('profile edit disables Save and flags an invalid display name', async ({ pa
test('link account: a taken email opens the irreversible merge confirmation', async ({ page }) => {
await loginLobby(page);
await page.locator('.burger').first().click();
await page.getByRole('button', { name: /Profile/ }).click();
await openProfile(page);
// The linking section is shown to everyone (guests upgrade by linking).
await expect(page.getByRole('heading', { name: 'Link an account' })).toBeVisible();
@@ -200,16 +225,16 @@ test('link account: a taken email opens the irreversible merge confirmation', as
test('link account: the Telegram web sign-in control is offered in a browser', async ({ page }) => {
await loginLobby(page);
await page.locator('.burger').first().click();
await page.getByRole('button', { name: /Profile/ }).click();
await openProfile(page);
await expect(page.getByRole('button', { name: 'Link Telegram' })).toBeVisible();
});
test('chat: the message field shows on your turn, the nudge replaces it otherwise', async ({ page }) => {
await loginLobby(page);
await page.getByRole('button', { name: /Ann/ }).click(); // g1: your turn
await page.locator('.burger').first().click();
await page.getByRole('button', { name: 'Chat' }).click();
await page.locator('.scoreboard').click(); // open the history
await page.getByRole('button', { name: 'Chat' }).click(); // 💬 -> comms hub
await expect(page.locator('.pane')).toHaveCount(1);
// On your turn the message field + Send are shown and the nudge is hidden;
// chat and nudge are mutually exclusive by turn. Icon-only controls expose their action
// through the aria-label.
+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>