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