UI: taller tg-fullscreen header + labelled hub tabs
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 38s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 56s

- tg-fullscreen: +20px header height — without the (removed) hamburger the
  title bar lost its bulk and sat flush on Telegram's native nav band.
- Settings/Comms hub tabs gain text labels under the icons (Settings /
  Profile / Friends / Info and Chat / Dictionary); the icon is aria-hidden
  so the label names the button. New i18n keys about.tab, game.dictionary.
This commit is contained in:
Ilia Denisov
2026-06-11 15:12:40 +02:00
parent fc1261e078
commit ad91bc728b
8 changed files with 29 additions and 24 deletions
+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}