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 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). 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 - **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 emoji icon over a tiny truncated label (the icon is `aria-hidden`, so the label names the
rounded **square** behind the icon; a hub's **selected** tab stays highlighted (a filled 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 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 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 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.locator('.scoreboard').click(); // open the history
await page.getByRole('button', { name: 'Chat' }).click(); // 💬 -> comms hub await page.getByRole('button', { name: 'Chat' }).click(); // 💬 -> comms hub
await expect(page.locator('.pane')).toHaveCount(1); 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'); const input = page.locator('.check input');
await input.fill('qz9!a'); // digits/punctuation dropped, letters upper-cased 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(); await expect(page.locator('.chat')).toBeVisible();
// The Dictionary tab switches in place (same screen, no navigation). // 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(); await expect(page.locator('.check input')).toBeVisible();
// The header back chevron returns to the game. // 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 // 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. // 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 page.getByRole('button', { name: /Settings/ }).click(); // lobby ⚙️ tab
await expect(page.locator('.pane')).toHaveCount(1); // let the slide settle await expect(page.locator('.pane')).toHaveCount(1); // let the slide settle
await page.getByRole('button', { name: tab, exact: true }).click(); 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.getByText('Friend requests')).toBeVisible();
await expect(page.locator('.pane')).toHaveCount(1); 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(); await expect(page.getByText(/Version/)).toBeVisible();
// Back returns to the lobby from any tab. // 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. // The comms hub offers Chat only — the Dictionary tab is hidden for a finished game.
await page.getByRole('button', { name: 'Chat' }).click(); // 💬 await page.getByRole('button', { name: 'Chat' }).click(); // 💬
await expect(page.locator('.pane')).toHaveCount(1); 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 }) => { 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 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. */ with Telegram's own back/menu controls, which sit in the band's corners. */
:global(html.tg-fullscreen) .bar { :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; box-sizing: border-box;
align-items: center; align-items: center;
justify-content: 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-top: calc(var(--tg-safe-top) + 6px);
padding-bottom: 6px; padding-bottom: 6px;
} }
+4 -4
View File
@@ -38,12 +38,12 @@
{#snippet tabbar()} {#snippet tabbar()}
<TabBar> <TabBar>
<button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')} aria-label={t('game.chat')}> <button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')}>
<span class="sq">💬</span> <span class="sq" aria-hidden="true">💬</span><span class="lbl">{t('game.chat')}</span>
</button> </button>
{#if active} {#if active}
<button class="tab" class:active={tab === 'dictionary'} onclick={() => (tab = 'dictionary')} aria-label={t('game.checkWord')}> <button class="tab" class:active={tab === 'dictionary'} onclick={() => (tab = 'dictionary')}>
<span class="sq">🔎</span> <span class="sq" aria-hidden="true">🔎</span><span class="lbl">{t('game.dictionary')}</span>
</button> </button>
{/if} {/if}
</TabBar> </TabBar>
+2
View File
@@ -65,6 +65,7 @@ export const en = {
'game.hint': 'Hint', 'game.hint': 'Hint',
'game.chat': 'Chat', 'game.chat': 'Chat',
'game.checkWord': 'Check word', 'game.checkWord': 'Check word',
'game.dictionary': 'Dictionary',
'game.dropGame': 'Drop game', 'game.dropGame': 'Drop game',
'game.preview': 'Scores {n}', 'game.preview': 'Scores {n}',
'game.previewIllegal': 'Not a legal move', 'game.previewIllegal': 'Not a legal move',
@@ -149,6 +150,7 @@ export const en = {
'settings.reduceMotion': 'Reduce motion', 'settings.reduceMotion': 'Reduce motion',
'about.title': 'About', 'about.title': 'About',
'about.tab': 'Info',
'about.description': 'A multiplatform Scrabble game.', 'about.description': 'A multiplatform Scrabble game.',
'about.version': 'Version {v}', 'about.version': 'Version {v}',
+2
View File
@@ -66,6 +66,7 @@ export const ru: Record<MessageKey, string> = {
'game.hint': 'Подсказка', 'game.hint': 'Подсказка',
'game.chat': 'Чат', 'game.chat': 'Чат',
'game.checkWord': 'Проверить слово', 'game.checkWord': 'Проверить слово',
'game.dictionary': 'Словарь',
'game.dropGame': 'Покинуть игру', 'game.dropGame': 'Покинуть игру',
'game.preview': 'Очков: {n}', 'game.preview': 'Очков: {n}',
'game.previewIllegal': 'Недопустимый ход', 'game.previewIllegal': 'Недопустимый ход',
@@ -150,6 +151,7 @@ export const ru: Record<MessageKey, string> = {
'settings.reduceMotion': 'Меньше анимаций', 'settings.reduceMotion': 'Меньше анимаций',
'about.title': 'О программе', 'about.title': 'О программе',
'about.tab': 'Инфо',
'about.description': 'Мультиплатформенная игра в скрабл.', 'about.description': 'Мультиплатформенная игра в скрабл.',
'about.version': 'Версия {v}', 'about.version': 'Версия {v}',
+8 -8
View File
@@ -45,19 +45,19 @@
{#snippet tabbar()} {#snippet tabbar()}
<TabBar> <TabBar>
<button class="tab" class:active={tab === 'settings'} onclick={() => (tab = 'settings')} aria-label={t('settings.title')}> <button class="tab" class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>
<span class="sq">⚙️</span> <span class="sq" aria-hidden="true">⚙️</span><span class="lbl">{t('settings.title')}</span>
</button> </button>
<button class="tab" class:active={tab === 'profile'} onclick={() => (tab = 'profile')} aria-label={t('profile.title')}> <button class="tab" class:active={tab === 'profile'} onclick={() => (tab = 'profile')}>
<span class="sq">👤</span> <span class="sq" aria-hidden="true">👤</span><span class="lbl">{t('profile.title')}</span>
</button> </button>
{#if !guest} {#if !guest}
<button class="tab" class:active={tab === 'friends'} onclick={() => (tab = 'friends')} aria-label={t('friends.title')}> <button class="tab" class:active={tab === 'friends'} onclick={() => (tab = 'friends')}>
<span class="sq">🤝{#if app.notifications > 0}<span class="badge">{app.notifications}</span>{/if}</span> <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> </button>
{/if} {/if}
<button class="tab" class:active={tab === 'about'} onclick={() => (tab = 'about')} aria-label={t('about.title')}> <button class="tab" class:active={tab === 'about'} onclick={() => (tab = 'about')}>
<span class="sq"></span> <span class="sq" aria-hidden="true"></span><span class="lbl">{t('about.tab')}</span>
</button> </button>
</TabBar> </TabBar>
{/snippet} {/snippet}