UI: tab-bar navigation — drop the hamburger #39

Merged
developer merged 4 commits from feature/ui-tabbar-nav into development 2026-06-11 13:46:41 +00:00
8 changed files with 29 additions and 24 deletions
Showing only changes of commit ad91bc728b - Show all commits
+3 -2
View File
@@ -35,8 +35,9 @@ Login uses `Screen`.
routes `/settings|/profile|/friends|/about` and `/game/:id/{chat,check}` survive as hub
entry points (so a Telegram friend-code deep-link still lands on the Friends tab).
- **Tab bar** (`TabBar.svelte`): square, borderless, evenly distributed buttons — a large
emoji icon over a tiny truncated label (hub tabs are **icon-only**). A press highlights a
rounded **square** behind the icon; a hub's **selected** tab stays highlighted (a filled
emoji icon over a tiny truncated label (the icon is `aria-hidden`, so the label names the
button). A press highlights a rounded **square** behind the icon; a hub's **selected** tab
stays highlighted (a filled
square with an accent underline). A red count **badge** rides the icon's corner — on the
lobby ⚙️ tab and the hub's 🤝 Friends tab for pending incoming friend requests (invitations
keep their own lobby section), and on the Hint tab for the remaining count. No text
+2 -2
View File
@@ -172,7 +172,7 @@ test('check-word sanitises input and shows a verdict', async ({ page }) => {
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
await page.getByRole('button', { name: 'Dictionary' }).click(); // 🔎 -> dictionary tab
const input = page.locator('.check input');
await input.fill('qz9!a'); // digits/punctuation dropped, letters upper-cased
@@ -222,7 +222,7 @@ test('comms hub: chat and dictionary share a screen, back returns to the game',
await expect(page.locator('.chat')).toBeVisible();
// The Dictionary tab switches in place (same screen, no navigation).
await page.getByRole('button', { name: 'Check word' }).click();
await page.getByRole('button', { name: 'Dictionary' }).click();
await expect(page.locator('.check input')).toBeVisible();
// The header back chevron returns to the game.
+3 -3
View File
@@ -12,7 +12,7 @@ async function loginLobby(page: Page): Promise<void> {
// 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> {
async function openSettingsTab(page: Page, tab: 'Profile' | 'Friends' | 'Info'): 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();
@@ -70,7 +70,7 @@ test('settings hub: tabs switch in place and back returns to the lobby', async (
await expect(page.getByText('Friend requests')).toBeVisible();
await expect(page.locator('.pane')).toHaveCount(1);
await page.getByRole('button', { name: 'About', exact: true }).click();
await page.getByRole('button', { name: 'Info', exact: true }).click();
await expect(page.getByText(/Version/)).toBeVisible();
// Back returns to the lobby from any tab.
@@ -114,7 +114,7 @@ test('finished game draws an inert footer and trims live-only controls', async (
// 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);
await expect(page.getByRole('button', { name: 'Dictionary' })).toHaveCount(0);
});
test('lobby: hiding a finished game removes it (kebab → ❌), keeping the others', async ({ page }) => {
+5 -5
View File
@@ -105,14 +105,14 @@
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);
/* The bar spans the Telegram nav band and then **+20px** past --tg-content-top, so the
native controls aren't flush against our content. Without the (removed) hamburger the
title alone left the bar sitting right on the band; the extra height restores the gap.
padding-top clears the notch; the title centres in the band. (Owner-tunable height.) */
min-height: calc(var(--tg-content-top) + 20px);
box-sizing: border-box;
align-items: center;
justify-content: center;
/* +12px of vertical breathing room (6 above / 6 below the centred content, on top of the
notch) so Telegram's native controls aren't flush against our header. Applied as
padding because the bar is sized by its content here, not by min-height (owner review
tweaks). */
padding-top: calc(var(--tg-safe-top) + 6px);
padding-bottom: 6px;
}
+4 -4
View File
@@ -38,12 +38,12 @@
{#snippet tabbar()}
<TabBar>
<button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')} aria-label={t('game.chat')}>
<span class="sq">💬</span>
<button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')}>
<span class="sq" aria-hidden="true">💬</span><span class="lbl">{t('game.chat')}</span>
</button>
{#if active}
<button class="tab" class:active={tab === 'dictionary'} onclick={() => (tab = 'dictionary')} aria-label={t('game.checkWord')}>
<span class="sq">🔎</span>
<button class="tab" class:active={tab === 'dictionary'} onclick={() => (tab = 'dictionary')}>
<span class="sq" aria-hidden="true">🔎</span><span class="lbl">{t('game.dictionary')}</span>
</button>
{/if}
</TabBar>
+2
View File
@@ -65,6 +65,7 @@ export const en = {
'game.hint': 'Hint',
'game.chat': 'Chat',
'game.checkWord': 'Check word',
'game.dictionary': 'Dictionary',
'game.dropGame': 'Drop game',
'game.preview': 'Scores {n}',
'game.previewIllegal': 'Not a legal move',
@@ -149,6 +150,7 @@ export const en = {
'settings.reduceMotion': 'Reduce motion',
'about.title': 'About',
'about.tab': 'Info',
'about.description': 'A multiplatform Scrabble game.',
'about.version': 'Version {v}',
+2
View File
@@ -66,6 +66,7 @@ export const ru: Record<MessageKey, string> = {
'game.hint': 'Подсказка',
'game.chat': 'Чат',
'game.checkWord': 'Проверить слово',
'game.dictionary': 'Словарь',
'game.dropGame': 'Покинуть игру',
'game.preview': 'Очков: {n}',
'game.previewIllegal': 'Недопустимый ход',
@@ -150,6 +151,7 @@ export const ru: Record<MessageKey, string> = {
'settings.reduceMotion': 'Меньше анимаций',
'about.title': 'О программе',
'about.tab': 'Инфо',
'about.description': 'Мультиплатформенная игра в скрабл.',
'about.version': 'Версия {v}',
+8 -8
View File
@@ -45,19 +45,19 @@
{#snippet tabbar()}
<TabBar>
<button class="tab" class:active={tab === 'settings'} onclick={() => (tab = 'settings')} aria-label={t('settings.title')}>
<span class="sq">⚙️</span>
<button class="tab" class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>
<span class="sq" aria-hidden="true">⚙️</span><span class="lbl">{t('settings.title')}</span>
</button>
<button class="tab" class:active={tab === 'profile'} onclick={() => (tab = 'profile')} aria-label={t('profile.title')}>
<span class="sq">👤</span>
<button class="tab" class:active={tab === 'profile'} onclick={() => (tab = 'profile')}>
<span class="sq" aria-hidden="true">👤</span><span class="lbl">{t('profile.title')}</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 class="tab" class:active={tab === 'friends'} onclick={() => (tab = 'friends')}>
<span class="sq" aria-hidden="true">🤝{#if app.notifications > 0}<span class="badge">{app.notifications}</span>{/if}</span><span class="lbl">{t('friends.title')}</span>
</button>
{/if}
<button class="tab" class:active={tab === 'about'} onclick={() => (tab = 'about')} aria-label={t('about.title')}>
<span class="sq"></span>
<button class="tab" class:active={tab === 'about'} onclick={() => (tab = 'about')}>
<span class="sq" aria-hidden="true"></span><span class="lbl">{t('about.tab')}</span>
</button>
</TabBar>
{/snippet}