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:
+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