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
+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);