From fc1261e0783a03ea7fbb96e3a04444c8622dbe40 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 11 Jun 2026 14:13:54 +0200 Subject: [PATCH 1/4] =?UTF-8?q?UI:=20tab-bar=20navigation=20=E2=80=94=20dr?= =?UTF-8?q?op=20the=20hamburger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- PRERELEASE.md | 19 +++ docs/FUNCTIONAL.md | 18 ++- docs/FUNCTIONAL_ru.md | 18 ++- docs/UI_DESIGN.md | 72 +++++++--- ui/e2e/game.spec.ts | 77 ++++++---- ui/e2e/social.spec.ts | 109 ++++++++------ ui/src/App.svelte | 20 ++- ui/src/components/Header.svelte | 22 +-- ui/src/components/HoldConfirm.svelte | 108 -------------- ui/src/components/Menu.svelte | 131 ----------------- ui/src/components/Screen.svelte | 4 +- ui/src/components/TabBar.svelte | 25 +++- ui/src/components/TapConfirm.svelte | 99 +++++++++++++ ui/src/game/ChatScreen.svelte | 13 +- ui/src/game/CheckScreen.svelte | 39 +++-- ui/src/game/CommsHub.svelte | 51 +++++++ ui/src/game/Game.svelte | 204 +++++++++++++++++++-------- ui/src/lib/app.svelte.ts | 28 ++-- ui/src/lib/i18n/en.ts | 5 +- ui/src/lib/i18n/ru.ts | 5 +- ui/src/lib/tapconfirm.test.ts | 66 +++++++++ ui/src/lib/tapconfirm.ts | 79 +++++++++++ ui/src/screens/About.svelte | 43 +++--- ui/src/screens/Friends.svelte | 151 ++++++++++---------- ui/src/screens/Lobby.svelte | 20 +-- ui/src/screens/Profile.svelte | 193 +++++++++++++------------ ui/src/screens/Settings.svelte | 99 +++++++------ ui/src/screens/SettingsHub.svelte | 64 +++++++++ 28 files changed, 1034 insertions(+), 748 deletions(-) delete mode 100644 ui/src/components/HoldConfirm.svelte delete mode 100644 ui/src/components/Menu.svelte create mode 100644 ui/src/components/TapConfirm.svelte create mode 100644 ui/src/game/CommsHub.svelte create mode 100644 ui/src/lib/tapconfirm.test.ts create mode 100644 ui/src/lib/tapconfirm.ts create mode 100644 ui/src/screens/SettingsHub.svelte diff --git a/PRERELEASE.md b/PRERELEASE.md index c05acd4..fc83d01 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -24,6 +24,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l | R5 | Bundle slimming | 6 | **done** | | R6 | Refactor + docs reconciliation + de-staging | 7 | **done** | | R7 | Final stress run + tuning | 9b | **done** | +| UI | Tab-bar navigation redesign (drop the hamburger) | owner ad-hoc | **done** | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | ## Key findings (these reshaped the raw list — read before starting a phase) @@ -413,3 +414,21 @@ Then Stage 18. more cores per node (recorded in the prod-sizing recommendation). - **No schema change → no contour DB wipe.** Bake-back: `loadtest/REPORT-R7.md` (new), `loadtest/README.md`, `docs/TESTING.md`, the telemetry/observability section of `docs/ARCHITECTURE.md`, the repo-layout line in `CLAUDE.md`. + +- **UI — Tab-bar navigation redesign** (owner ad-hoc, not on the raw TODO list): drop the hamburger + `Menu.svelte` everywhere (it fought the Telegram-fullscreen layout, where it had to be re-centred). + - **Locked decisions (interview):** the in-Settings sub-nav is a **bottom TabBar with the active tab + highlighted** (icon-only); **Export GCG** moves to the left slot of the move-history header (free in a + finished game, where 🏁 *leave* does not apply); the lobby **⚙️ badge counts incoming friend requests + only** (invitations keep their own lobby section); unread chat is badged on **the score bar and the 💬**. + - **What shipped:** a ⚙️ **Settings hub** (`screens/SettingsHub.svelte`) over the existing + Settings/Profile/Friends/About bodies and an in-game **comms hub** (`game/CommsHub.svelte`) over + chat + dictionary, both with in-place tabs and a fixed back target; the game's menu items relocate into + the open move history (🏁 leave / 📤 export + 💬 comms header) and the player cards (🤝 add-friend); a + shared **TapConfirm** (`components/TapConfirm.svelte`, `lib/tapconfirm.ts`) — tap → fading ✅ → tap — + replaces the Skip/Hint press-and-hold popovers and drives the add-friend confirm. Fixed the move-history + "jump" bug (the slid board is now inert and the stage can't scroll, so a swipe up genuinely closes it). + `Menu.svelte` + `HoldConfirm.svelte` removed. + - **No schema/wire change → no contour DB wipe.** Bake-back: `docs/UI_DESIGN.md`, `docs/FUNCTIONAL.md` + (+`_ru`). Regression gate: UI `check` + unit (`tapconfirm`) + build + bundle budget + e2e (Chromium & + WebKit), all green. diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index eeb367c..307b4eb 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -68,7 +68,9 @@ account is kept and the guest's games move into it. A merge is blocked only whil two accounts share a game still in progress. ### Lobby & matchmaking -Bottom tab menu: **my games**, **profile**. The **my games** list groups games into three +The lobby lists **my games** and offers a bottom tab bar — new game, statistics, and a +**⚙️ settings** tab opening the settings hub (settings, profile, friends, about). The +**my games** list groups games into three sections — *your turn*, *opponent's turn* and *finished* (empty sections are hidden) — and orders them so the games awaiting your move come first, the longest-waiting on top, while opponent-turn and finished games are most-recent first; it renders as a compact, @@ -128,18 +130,20 @@ digits, valid for twelve hours), or send a **request to someone you have played with** — they accept, ignore it (a request lapses after thirty days and can then be re-sent), or decline (a decline blocks further requests from you until they hand you a code). Cancelling your own pending request withdraws it; unfriending removes the -friendship. In a game, an **add to friends** item for each opponent mirrors the live -relationship: it reads *request sent* (disabled) while a request is pending or was -declined, and *in friends* once accepted — updating in place the moment the opponent -answers, and staying correct across reloads. Block globally — switch off incoming chat +friendship. In a game, each opponent's score card carries an **add-to-friends 🤝** control (while the +move history is open) that mirrors the live relationship: it confirms with a tap on a fading +✅ (the card reads *Add friend?* while confirming), goes **disabled** while a request is +pending or was declined, and **disappears** once you are friends — updating in place the +moment the opponent answers, and staying correct across reloads. Block globally — switch off incoming chat and/or friend requests — and block individual players (a per-user block hides that person's chat and stops requests and game invitations both ways; it also ends any existing friendship). Per-game chat is for quick reactions: messages are short (up to 60 characters) and may not contain links, email addresses or phone numbers, even disguised. Nudge the player whose turn is awaited at most once per hour (the nudge is part of the game chat); the out-of-app push is delivered via the platform. -Chat and the word-check tool open as their **own screens** (with a back to the game), and a -new chat message raises an **unread badge** on the game's menu until the chat is opened. +Chat and the word-check tool share one **comms screen** with **💬 chat** / **🔎 dictionary** +tabs, reached from the 💬 in the move-history header (with a back to the game); a new chat +message raises an **unread badge** on the game's score bar and the 💬 until the chat is opened. ### Profile & settings Edit the display name (letters joined by a single space / "." / "_" separator, with an diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 4fb65aa..599d60f 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -70,7 +70,9 @@ nudge) приходят от бота **этой партии** — по язы запрещено, только пока у аккаунтов есть общая незавершённая игра. ### Лобби и подбор -Нижнее tab-меню: **мои игры**, **профиль**. Список **мои игры** разбит на три секции — +В лобби — список **мои игры** и нижний tab-bar (новая игра, статистика и вкладка +**⚙️ настройки**, открывающая хаб настроек — настройки, профиль, друзья, о программе). +Список **мои игры** разбит на три секции — *твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так, что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу соперника и завершённые — самые свежие сверху; отображается компактным списком с @@ -132,10 +134,11 @@ nudge) приходят от бота **этой партии** — по язы тому, с кем вы играли** — он принимает, игнорирует (заявка истекает через тридцать дней, после чего её можно отправить снова) или отклоняет (отказ блокирует ваши повторные заявки, пока он сам не передаст вам код). Отмена своей висящей заявки -снимает её; удаление расторгает дружбу. В партии пункт меню **в друзья** для каждого -соперника отражает живое отношение: он показывает *заявка отправлена* (неактивный), -пока заявка висит или была отклонена, и *в друзьях* после принятия — обновляясь на месте -в момент ответа соперника и оставаясь верным после перезагрузки. Глобальная блокировка — отключить входящие +снимает её; удаление расторгает дружбу. В партии карточка счёта каждого соперника несёт контрол +**в друзья 🤝** (при открытой истории ходов), отражающий живое отношение: он подтверждается +тапом по затухающей ✅ (карточка показывает *В друзья?* во время подтверждения), становится +**неактивным**, пока заявка висит или была отклонена, и **исчезает** после принятия — +обновляясь на месте в момент ответа соперника и оставаясь верным после перезагрузки. Глобальная блокировка — отключить входящие чат и/или заявки — и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат @@ -143,8 +146,9 @@ nudge) приходят от бота **этой партии** — по язы содержать ссылок, email и телефонов, даже завуалированных. Nudge ожидаемого соперника — не чаще раза в час (nudge — часть игрового чата); внеприложенческий push доставляется через платформу. -Чат и инструмент проверки слова открываются **отдельными экранами** (с кнопкой «назад» в -партию), а новое сообщение в чате рисует **бейдж непрочитанного** на меню партии до открытия чата. +Чат и инструмент проверки слова — один **экран связи** со вкладками **💬 чат** / **🔎 словарь**, +открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию); новое сообщение рисует +**бейдж непрочитанного** на строке счёта партии и на 💬 до открытия чата. ### Профиль и настройки Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» / diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index d2e98ee..adb2e9d 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -23,16 +23,24 @@ Login uses `Screen`. - **Back**: a thin, compact `<` drawn from two rotated CSS borders (`Header.svelte` `.chev`) — lighter than a glyph. -- **Hamburger**: a CSS three-bar (`Menu.svelte`), deliberately larger; opens a dropdown - of items (lobby: Friends/Profile/Settings/About; game: History/Chat/Check word, plus - *Export GCG* on a finished game and *Add to friends* per opponent, then Drop game). A - red count **badge** rides the hamburger (and the lobby *Friends* item) for pending - incoming friend requests + invitations; the same dot style serves any future - notification count. +- **No hamburger**: navigation is entirely bottom tab bars (the former `Menu.svelte` + dropdown is gone — it fought the Telegram-fullscreen layout, where it had to be re-centred). + Destinations beyond the lobby live behind **hub screens** reached from a tab: a ⚙️ + **Settings hub** (`screens/SettingsHub.svelte`, the lobby's ⚙️ tab) and an in-game + **comms hub** (`game/CommsHub.svelte`, the history's 💬). A hub owns one nav bar + one + bottom tab bar whose tabs switch its body **in place** (state, not navigation), so the + back control always returns to the hub's parent (Settings → lobby, comms → game). Settings + hub tabs: ⚙️ Settings / 👤 Profile / 🤝 Friends / ℹ️ About (Friends hidden for guests); + comms hub tabs: 💬 Chat / 🔎 Dictionary (Dictionary only while the game is active). The + 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. A press highlights a rounded **square** behind - the icon (slightly larger than it) until release; spacing keeps adjacent labels from - touching. No text selection on nav / tab-bar / buttons (`user-select: none`). + 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 + 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 + selection on nav / tab-bar / buttons (`user-select: none`). - **Screen transitions** (`App.svelte`): navigation slides directionally — a screen entered from the lobby flies in from the right; returning to the lobby reveals it from the left (back). Transitions are local (so they do not play on first load) and @@ -71,8 +79,9 @@ Login uses `Screen`. hovering a dragged tile never jumps the board. On touch the first tile placement auto-zooms in centred on the target, and **holding a dragged tile over a cell ~1 s** auto-zooms there the first time. The swipe-to-open-history gesture stays dropped (it fought native scroll); - history opens from the menu or a tap on the players plaque (below). A **hint** auto-zooms - centred on the hint's placement, not the top-left. + history opens on a **tap of the score bar** and closes on a tap or an **upward swipe** of + the then-inert board (below). A **hint** auto-zooms centred on the hint's placement, not + the top-left. - **Placing & recall** (`Game.svelte`): a rack tile is placed by tap-then-tap or by dragging it onto a cell; while a dragged tile is carried over the board, the aimed-at empty cell is **highlighted as a drop target** (an accent ring). A pending tile is taken back by a @@ -81,11 +90,21 @@ Login uses `Screen`. recalled tile returns to its original rack slot. - **Players plaque & history** (`Game.svelte`): the seats above the board share the width evenly; the seat whose turn it is is **raised** (a drop shadow on its sides) - while the others read **sunk in** (an inset shadow). A tap anywhere on the plaque toggles + while the others read **sunk in** (an inset shadow). A tap on the plaque toggles the **move history** — a fixed-height slide-down drawer whose bottom border (and its shadow) pins to the board as the board slides down, instead of tracking the table as moves accumulate; its scrollbar gutter is reserved so the centred word column does not - jitter. A move's row lists every word it formed (the main word first). + jitter. A move's row lists every word it formed (the main word first). While the history + is open the **slid board is inert** and the stage cannot scroll, so the whole board reads + as a **tap-or-swipe-up-to-close** surface — closing genuinely clears the open state (it no + longer merely scrolls the board out of view, which used to leave a stale-open state that + made a follow-up plaque tap "jump" the board). The drawer carries its own **header**: a 🏁 + **Drop game** (or 📤 **Export GCG** on a finished game) at the left and the comms 💬 + (badged with unread chat) at the right, icon-only. Each **opponent**'s card also gains a + 🤝 **add-friend** control (non-guests; hidden once a friend, disabled once requested) that + confirms via the fading-✅ tap, swapping the card's score for "Add friend?" while armed + (see Controls); the name and score stay centred — the 🤝 is pinned to the card's edge. + Unread chat is also badged on the score bar itself, so it shows with the history closed. - **Vertical fit & keyboard**: when the game does not fit the viewport, only the board area scrolls vertically (`Screen` `column` mode; the score bar, status, rack and tab bar stay fixed), while zoom keeps its own scroll. The check-word dialog opens in @@ -107,9 +126,12 @@ Login uses `Screen`. ## Controls -- **HoldConfirm** (`components/HoldConfirm.svelte`): the shared press-and-hold control. A - short tap opens a small popover above the button; a ~0.7 s hold runs the primary action - immediately. Used by the **Skip** and **Hint** tabs (each confirmed by an **Ok ✅** popover). +- **TapConfirm** (`components/TapConfirm.svelte`, logic in `lib/tapconfirm.ts`): the shared + tap-to-confirm control. A first tap arms a ~2 s window showing a **fading ✅** (no fade + under reduce-motion, but the window still holds); a tap on the ✅ within it confirms, + otherwise it reverts. Used by the **Skip** and **Hint** tabs (the icon morphs to ✅, no + label — replacing the old press-and-hold popover) and the in-game **add-friend 🤝**. The + **Drop game** action keeps its `Modal` confirmation (a destructive, less-frequent action). - **MakeMove / Reset**: when ≥1 tile is pending the rack collapses its used slots and shifts left, a **borderless ✅ icon button** (styled like a tab, not a filled accent button) beside the rack commits the move — no popover, and disabled while the pending word @@ -140,7 +162,11 @@ IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use ## Social, account & history surfaces -- **Friends** (`screens/Friends.svelte`, from the lobby menu): an "add a friend" block +- **Settings hub** (`screens/SettingsHub.svelte`, the lobby ⚙️ tab): one nav bar + a bottom + tab bar over four bodies — ⚙️ Settings, 👤 Profile, 🤝 Friends, ℹ️ About — switched in + place; back always returns to the lobby. Guests see all but Friends. The lobby ⚙️ badge and + the 🤝 tab badge both show the pending incoming-friend-request count. +- **Friends** (`screens/Friends.svelte`, the Settings hub's 🤝 tab): an "add a friend" block pairing a code **input** with a **Show my code** action that reveals a large 6-digit code + its expiry; then the incoming **requests** (Accept / Decline), the **friends** list (Remove / Block), and a **blocked** list (Unblock). Durable accounts only — a @@ -163,12 +189,12 @@ IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use the icon copies it. Flex text inputs carry `min-width:0` so they shrink instead of overflowing in Safari. - **History / GCG**: the in-game slide-down history gains the running total per move; - *Export GCG* shares or downloads the `.gcg` file and appears only once the game is - finished. -- **Finished game**: the board keeps no last-word highlight and no zoom; the menu drops - *Check word* and *Drop game*; and the footer (rack + tab bar) is **drawn but inert** - (greyed, non-interactive) rather than hidden, so the layout does not jump. Chat - send / nudge are the ⬆️ / 🛎️ icons. + *Export GCG* (the 📤 in the history header) shares or downloads the `.gcg` file and + appears only once the game is finished. +- **Finished game**: the board keeps no last-word highlight and no zoom; the history header + offers *Export GCG* (not *Drop game*) and the comms hub hides the 🔎 *Dictionary* tab; and + the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than + hidden, so the layout does not jump. Chat send / nudge are the ⬆️ / 🛎️ icons. ## Caveat diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index b426192..d5d273e 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -10,9 +10,8 @@ async function openGame(page: Page): Promise { await page.getByRole('button', { name: /guest/i }).click(); await page.getByRole('button', { name: /Ann/ }).click(); // the seeded active game vs Ann await expect(page.locator('[data-cell]').first()).toBeVisible(); - // Wait for the screen-slide transition to settle so only the game pane remains; - // until it does, the leaving lobby pane's header (its menu button) is also in the - // DOM, which would make shared locators like .burger ambiguous. + // Wait for the screen-slide transition to settle so only the game pane remains; until it + // does, the leaving lobby pane is also in the DOM, which would make shared locators ambiguous. await expect(page.locator('.pane')).toHaveCount(1); } @@ -115,17 +114,38 @@ test('shuffle reorders the rack but keeps the same tiles', async ({ page }) => { expect([...after].sort()).toEqual([...before].sort()); }); -test('history slides the board down and closes on a board tap', async ({ page }) => { +test('history slides the board down on a score tap and closes on a board tap', async ({ page }) => { await openGame(page); - await page.locator('.burger').click(); - await page.locator('.dropdown button').nth(0).click(); // History + await page.locator('.scoreboard').click(); // tapping the score bar opens the history await expect(page.locator('.history')).toBeVisible(); await expect(page.locator('.boardwrap.slid')).toBeVisible(); - await page.locator('.boardwrap').click(); // tapping the board closes it + // Tap the (now inert) board's visible top strip below the history to close it. + await page.locator('.boardwrap').click({ position: { x: 30, y: 12 } }); await expect(page.locator('.history')).toBeHidden(); }); +test('history: a swipe-up close does not make a follow-up score tap jump', async ({ page }) => { + await openGame(page); + await page.locator('.scoreboard').click(); + await expect(page.locator('.boardwrap.slid')).toBeVisible(); + + // Swipe up on the inert board to close it. Dispatched on the board so the gesture is + // engine-deterministic — a mouse swipe over a pointer-events:none child is simulated + // inconsistently across engines, whereas a real touch swipe lands the same way this does. + const bw = page.locator('.boardwrap'); + const box = (await bw.boundingBox())!; + const cx = box.x + box.width / 2; + await bw.dispatchEvent('pointerdown', { clientX: cx, clientY: 500, bubbles: true }); + await bw.dispatchEvent('pointermove', { clientX: cx, clientY: 450, bubbles: true }); + await expect(page.locator('.history')).toBeHidden(); + + // A score tap now cleanly reopens the history — the stale-open "jump" no longer happens. + await page.locator('.scoreboard').click(); + await expect(page.locator('.history')).toBeVisible(); + await expect(page.locator('.boardwrap.slid')).toBeVisible(); +}); + test('Draw opens the exchange dialog and confirms a selection', async ({ page }) => { await openGame(page); await page.locator('button:has-text("🔄")').click(); // Draw tab @@ -137,10 +157,22 @@ test('Draw opens the exchange dialog and confirms a selection', async ({ page }) await expect(page.locator('.exch')).toBeHidden(); }); +test('pass confirms with a tap on the fading ✅ instead of a popup', async ({ page }) => { + await openGame(page); + const pass = page.getByRole('button', { name: 'Skip' }); // the 🥺 tab (aria-label) + await expect(pass).toBeEnabled(); + await pass.click(); // arm: 🥺 -> a fading ✅ + await pass.click(); // tap the ✅ to confirm within the window + // The pass hands the turn over, so the control goes inert. + await expect(pass).toBeDisabled(); +}); + test('check-word sanitises input and shows a verdict', async ({ page }) => { await openGame(page); - await page.locator('.burger').click(); - await page.locator('.dropdown button').nth(2).click(); // Check word + 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 const input = page.locator('.check input'); await input.fill('qz9!a'); // digits/punctuation dropped, letters upper-cased @@ -152,8 +184,8 @@ test('check-word sanitises input and shows a verdict', async ({ page }) => { test('dropping the game ends it and shows the result', async ({ page }) => { await openGame(page); - await page.locator('.burger').click(); - await page.getByRole('button', { name: 'Drop game' }).click(); // robust against menu growth + await page.locator('.scoreboard').click(); // open the history + await page.getByRole('button', { name: 'Drop game' }).click(); // 🏁 in the history header await page.locator('button.danger').click(); // confirm in the modal await expect(page.locator('.status .over')).toBeVisible(); }); @@ -181,26 +213,23 @@ test('a placed tile drags from one board cell to another (relocation)', async ({ expect(to).not.toBe(from); }); -test('chat and word-check open as their own screens and back to the game', async ({ page }) => { +test('comms hub: chat and dictionary share a screen, back returns to the game', async ({ page }) => { await openGame(page); - - await page.locator('.burger').click(); - await page.getByRole('button', { name: /^Chat$/ }).click(); + await page.locator('.scoreboard').click(); // open the history + await page.getByRole('button', { name: 'Chat' }).click(); // 💬 in the history header await expect(page).toHaveURL(/\/game\/g1\/chat$/); await expect(page.locator('.pane')).toHaveCount(1); // let the slide transition settle await expect(page.locator('.chat')).toBeVisible(); - // The outgoing game header and the incoming chat header both carry a .back mid-slide; wait - // for the game's to unmount so the click targets a single, settled button. + + // The Dictionary tab switches in place (same screen, no navigation). + await page.getByRole('button', { name: 'Check word' }).click(); + await expect(page.locator('.check input')).toBeVisible(); + + // The header back chevron returns to the game. await expect(page.locator('.back')).toHaveCount(1); - await page.locator('.back').click(); // header back chevron returns to the game + await page.locator('.back').click(); await expect(page).toHaveURL(/\/game\/g1$/); await expect(page.locator('.pane')).toHaveCount(1); - - await page.locator('.burger').click(); - await page.getByRole('button', { name: /Check word/ }).click(); - await expect(page).toHaveURL(/\/game\/g1\/check$/); - await expect(page.locator('.pane')).toHaveCount(1); - await expect(page.locator('.check input')).toBeVisible(); }); test('the board-label mode in Settings changes the on-board labels', async ({ page }) => { diff --git a/ui/e2e/social.spec.ts b/ui/e2e/social.spec.ts index 2fd42c3..785d1f3 100644 --- a/ui/e2e/social.spec.ts +++ b/ui/e2e/social.spec.ts @@ -10,9 +10,18 @@ async function loginLobby(page: Page): Promise { await expect(page.getByText('Your turn')).toBeVisible(); } -async function openFriends(page: Page): Promise { - await page.locator('.burger').first().click(); - await page.getByRole('button', { name: /Friends/ }).click(); +// 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 { + 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(); +} +function openFriends(page: Page): Promise { + return openSettingsTab(page, 'Friends'); +} +function openProfile(page: Page): Promise { + return openSettingsTab(page, 'Profile'); } test('friends: issue a code, accept an incoming request, redeem a code', async ({ page }) => { @@ -50,10 +59,28 @@ test('stats screen shows the metrics', async ({ page }) => { await expect(page.getByText('Best move')).toBeVisible(); }); +test('settings hub: tabs switch in place and back returns to the lobby', async ({ page }) => { + await loginLobby(page); + await page.getByRole('button', { name: /Settings/ }).click(); // lobby ⚙️ tab + await expect(page.locator('.pane')).toHaveCount(1); + await expect(page.locator('.seg').first()).toBeVisible(); // default Settings tab + + // Switching tabs is in place (no navigation): the Friends body appears, still one pane. + await page.getByRole('button', { name: 'Friends', exact: true }).click(); + await expect(page.getByText('Friend requests')).toBeVisible(); + await expect(page.locator('.pane')).toHaveCount(1); + + await page.getByRole('button', { name: 'About', exact: true }).click(); + await expect(page.getByText(/Version/)).toBeVisible(); + + // Back returns to the lobby from any tab. + await page.locator('.back').click(); + await expect(page.getByText('Your turn')).toBeVisible(); +}); + test('profile edit saves a new display name', async ({ page }) => { await loginLobby(page); - await page.locator('.burger').first().click(); - await page.getByRole('button', { name: /Profile/ }).click(); + await openProfile(page); await page.locator('.edit input').first().fill('Kaya Test'); await page.getByRole('button', { name: /^Save$/ }).click(); await expect(page.locator('.name')).toHaveText('Kaya Test'); @@ -61,29 +88,33 @@ test('profile edit saves a new display name', async ({ page }) => { test('GCG export appears only for a finished game', async ({ page }) => { await loginLobby(page); - // The finished game vs Kaya exposes the export; the menu carries the item. + // The finished game vs Kaya exposes export 📤 in the history header. await page.getByRole('button', { name: /Kaya/ }).click(); - await page.locator('.burger').first().click(); - await expect(page.getByRole('button', { name: /Export GCG/ })).toBeVisible(); + await page.locator('.scoreboard').click(); // open the history + await expect(page.getByRole('button', { name: 'Export GCG' })).toBeVisible(); }); test('GCG export is hidden for an active game', async ({ page }) => { await loginLobby(page); await page.getByRole('button', { name: /Ann/ }).click(); - await page.locator('.burger').first().click(); - await expect(page.getByRole('button', { name: /Export GCG/ })).toHaveCount(0); + await page.locator('.scoreboard').click(); // open the history (shows 🏁 leave, not 📤 export) + await expect(page.getByRole('button', { name: 'Export GCG' })).toHaveCount(0); }); -test('finished game draws an inert footer and trims the live-only menu', async ({ page }) => { +test('finished game draws an inert footer and trims live-only controls', async ({ page }) => { await loginLobby(page); await page.getByRole('button', { name: /Kaya/ }).click(); // finished game vs Kaya await expect(page.locator('[data-cell]').first()).toBeVisible(); // The footer (tab bar) is drawn but its controls are disabled in a finished game. await expect(page.locator('.tab').first()).toBeDisabled(); - // The menu drops Check word and Drop game once the game is over. - await page.locator('.burger').first().click(); - await expect(page.getByRole('button', { name: 'Check word' })).toHaveCount(0); + // The history header offers Export GCG, not Drop game, once the game is over. + await page.locator('.scoreboard').click(); + await expect(page.getByRole('button', { name: 'Export GCG' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Drop game' })).toHaveCount(0); + // 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); }); test('lobby: hiding a finished game removes it (kebab → ❌), keeping the others', async ({ page }) => { @@ -114,10 +145,11 @@ test('lobby: the active-row chevron opens the game (not a no-op)', async ({ page await expect(page.locator('[data-cell]').first()).toBeVisible(); }); -test('lobby hamburger shows the pending notification count', async ({ page }) => { +test('lobby ⚙️ tab shows the pending friend-request count', async ({ page }) => { await loginLobby(page); - // One incoming friend request (Rick) + one invitation (Kaya) = 2. - await expect(page.getByTestId('menu-badge')).toHaveText('2'); + // The ⚙️ badge counts incoming friend requests only (Rick = 1); invitations have their + // own lobby section, so they are not summed into it. + await expect(page.getByRole('button', { name: /Settings/ }).locator('.badge')).toHaveText('1'); }); test('play with friends: a game type is required to send an invitation', async ({ page }) => { @@ -138,34 +170,28 @@ test('play with friends: a game type is required to send an invitation', async ( await expect(page.getByText('Your turn')).toBeVisible(); }); -test('game: add-to-friends flips to a disabled "request sent"', async ({ page }) => { +test('game: the add-friend 🤝 confirms with a tap and then reads as sent', async ({ page }) => { await loginLobby(page); await page.getByRole('button', { name: /Ann/ }).click(); // active game vs Ann - await page.locator('.burger').first().click(); - await page.getByRole('button', { name: /Add to friends: Ann/ }).click(); - // Reopening the menu shows the item as a disabled "request sent". - await page.locator('.burger').first().click(); - const sent = page.getByRole('button', { name: 'Request sent' }); - await expect(sent).toBeVisible(); - await expect(sent).toBeDisabled(); + await page.locator('.scoreboard').click(); // open the history -> 🤝 appears on Ann's card + const add = page.getByRole('button', { name: 'Add to friends' }); + await add.click(); // arm: 🤝 -> a fading ✅ + await add.click(); // tap the ✅ to confirm within the window + // The request is sent, so the control is now disabled. + await expect(add).toBeDisabled(); }); -test('game: an opponent who is already a friend shows a disabled "in friends"', async ({ page }) => { +test('game: an opponent who is already a friend shows no add-friend 🤝', async ({ page }) => { await loginLobby(page); - await page.getByRole('button', { name: /Kaya/ }).click(); // the finished game vs Kaya, a seeded friend - await page.locator('.burger').first().click(); - // The in-game friend item is derived from the server's friend list: a friend reads - // a disabled "✓ in friends", not the addable "Add to friends". - const inFriends = page.getByRole('button', { name: /in friends/i }); - await expect(inFriends).toBeVisible(); - await expect(inFriends).toBeDisabled(); - await expect(page.getByRole('button', { name: /Add to friends: Kaya/ })).toHaveCount(0); + await page.getByRole('button', { name: /Kaya/ }).click(); // finished game vs Kaya, a seeded friend + await page.locator('.scoreboard').click(); // open the history + // Kaya is already a friend, so no add-friend control is offered on her card. + await expect(page.getByRole('button', { name: 'Add to friends' })).toHaveCount(0); }); test('profile edit disables Save and flags an invalid display name', async ({ page }) => { await loginLobby(page); - await page.locator('.burger').first().click(); - await page.getByRole('button', { name: /Profile/ }).click(); + await openProfile(page); const name = page.locator('.edit input').first(); const save = page.getByRole('button', { name: /^Save$/ }); @@ -179,8 +205,7 @@ test('profile edit disables Save and flags an invalid display name', async ({ pa test('link account: a taken email opens the irreversible merge confirmation', async ({ page }) => { await loginLobby(page); - await page.locator('.burger').first().click(); - await page.getByRole('button', { name: /Profile/ }).click(); + await openProfile(page); // The linking section is shown to everyone (guests upgrade by linking). await expect(page.getByRole('heading', { name: 'Link an account' })).toBeVisible(); @@ -200,16 +225,16 @@ test('link account: a taken email opens the irreversible merge confirmation', as test('link account: the Telegram web sign-in control is offered in a browser', async ({ page }) => { await loginLobby(page); - await page.locator('.burger').first().click(); - await page.getByRole('button', { name: /Profile/ }).click(); + await openProfile(page); await expect(page.getByRole('button', { name: 'Link Telegram' })).toBeVisible(); }); test('chat: the message field shows on your turn, the nudge replaces it otherwise', async ({ page }) => { await loginLobby(page); await page.getByRole('button', { name: /Ann/ }).click(); // g1: your turn - await page.locator('.burger').first().click(); - await page.getByRole('button', { name: 'Chat' }).click(); + await page.locator('.scoreboard').click(); // open the history + await page.getByRole('button', { name: 'Chat' }).click(); // 💬 -> comms hub + await expect(page.locator('.pane')).toHaveCount(1); // On your turn the message field + Send are shown and the nudge is hidden; // chat and nudge are mutually exclusive by turn. Icon-only controls expose their action // through the aria-label. diff --git a/ui/src/App.svelte b/ui/src/App.svelte index a811c03..9e42fb6 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -9,14 +9,10 @@ import Login from './screens/Login.svelte'; import Lobby from './screens/Lobby.svelte'; import NewGame from './screens/NewGame.svelte'; - import Profile from './screens/Profile.svelte'; - import Settings from './screens/Settings.svelte'; - import About from './screens/About.svelte'; - import Friends from './screens/Friends.svelte'; + import SettingsHub from './screens/SettingsHub.svelte'; import Stats from './screens/Stats.svelte'; import Game from './game/Game.svelte'; - import ChatScreen from './game/ChatScreen.svelte'; - import CheckScreen from './game/CheckScreen.svelte'; + import CommsHub from './game/CommsHub.svelte'; onMount(() => { void bootstrap(); @@ -83,17 +79,17 @@ {:else if router.route.name === 'game'} {:else if router.route.name === 'gameChat'} - + {:else if router.route.name === 'gameCheck'} - + {:else if router.route.name === 'profile'} - + {:else if router.route.name === 'settings'} - + {:else if router.route.name === 'about'} - + {:else if router.route.name === 'friends'} - + {:else if router.route.name === 'stats'} {:else} diff --git a/ui/src/components/Header.svelte b/ui/src/components/Header.svelte index 9cb64bc..ace582b 100644 --- a/ui/src/components/Header.svelte +++ b/ui/src/components/Header.svelte @@ -1,13 +1,11 @@ - -
- - - {#if open} - - -
-
{@render popover(close)}
- {/if} -
- - diff --git a/ui/src/components/Menu.svelte b/ui/src/components/Menu.svelte deleted file mode 100644 index f4a59f5..0000000 --- a/ui/src/components/Menu.svelte +++ /dev/null @@ -1,131 +0,0 @@ - - - - - diff --git a/ui/src/components/Screen.svelte b/ui/src/components/Screen.svelte index d63388b..25af3ce 100644 --- a/ui/src/components/Screen.svelte +++ b/ui/src/components/Screen.svelte @@ -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 @@
-
+
{#if SHOW_AD_BANNER}{/if}
{@render children?.()}
{#if tabbar} diff --git a/ui/src/components/TabBar.svelte b/ui/src/components/TabBar.svelte index 9865762..b6ebbb3 100644 --- a/ui/src/components/TabBar.svelte +++ b/ui/src/components/TabBar.svelte @@ -1,7 +1,7 @@ @@ -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); diff --git a/ui/src/components/TapConfirm.svelte b/ui/src/components/TapConfirm.svelte new file mode 100644 index 0000000..ebaf480 --- /dev/null +++ b/ui/src/components/TapConfirm.svelte @@ -0,0 +1,99 @@ + + + + + diff --git a/ui/src/game/ChatScreen.svelte b/ui/src/game/ChatScreen.svelte index 82cb2be..5f88566 100644 --- a/ui/src/game/ChatScreen.svelte +++ b/ui/src/game/ChatScreen.svelte @@ -1,15 +1,14 @@ - - - + diff --git a/ui/src/game/CheckScreen.svelte b/ui/src/game/CheckScreen.svelte index 410093a..b9889dd 100644 --- a/ui/src/game/CheckScreen.svelte +++ b/ui/src/game/CheckScreen.svelte @@ -1,6 +1,5 @@ - -
-
- e.key === 'Enter' && runCheck()} - placeholder={t('game.checkWordPrompt')} - /> - -
- {#if result} -

- {result.legal - ? t('game.wordLegal', { word: result.word }) - : t('game.wordIllegal', { word: result.word })} -

- - {/if} +
+
+ e.key === 'Enter' && runCheck()} + placeholder={t('game.checkWordPrompt')} + /> +
- + {#if result} +

+ {result.legal + ? t('game.wordLegal', { word: result.word }) + : t('game.wordIllegal', { word: result.word })} +

+ + {/if} +