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
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:
@@ -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} />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user