Merge pull request 'UI: tab-bar navigation — drop the hamburger' (#39) from feature/ui-tabbar-nav into development
CI / changes (push) Successful in 1s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 40s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 59s

This commit was merged in pull request #39.
This commit is contained in:
2026-06-11 13:46:40 +00:00
29 changed files with 1078 additions and 753 deletions
+19
View File
@@ -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** | | R5 | Bundle slimming | 6 | **done** |
| R6 | Refactor + docs reconciliation + de-staging | 7 | **done** | | R6 | Refactor + docs reconciliation + de-staging | 7 | **done** |
| R7 | Final stress run + tuning | 9b | **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) | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
## Key findings (these reshaped the raw list — read before starting a phase) ## 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). 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`, - **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`. `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.
+11 -7
View File
@@ -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. two accounts share a game still in progress.
### Lobby & matchmaking ### 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 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 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, 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 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 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 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 friendship. In a game, each opponent's score card carries an **add-to-friends 🤝** control (while the
relationship: it reads *request sent* (disabled) while a request is pending or was move history is open) that mirrors the live relationship: it confirms with a tap on a fading
declined, and *in friends* once accepted — updating in place the moment the opponent ✅ (the card reads *Add friend?* while confirming), goes **disabled** while a request is
answers, and staying correct across reloads. Block globally — switch off incoming chat 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 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 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 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, (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 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. 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 Chat and the word-check tool share one **comms screen** with **💬 chat** / **🔎 dictionary**
new chat message raises an **unread badge** on the game's menu until the chat is opened. 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 ### Profile & settings
Edit the display name (letters joined by a single space / "." / "_" separator, with an Edit the display name (letters joined by a single space / "." / "_" separator, with an
+11 -7
View File
@@ -70,7 +70,9 @@ nudge) приходят от бота **этой партии** — по язы
запрещено, только пока у аккаунтов есть общая незавершённая игра. запрещено, только пока у аккаунтов есть общая незавершённая игра.
### Лобби и подбор ### Лобби и подбор
Нижнее tab-меню: **мои игры**, **профиль**. Список **мои игры** разбит на три секции — В лобби — список **мои игры** и нижний tab-bar (новая игра, статистика и вкладка
**⚙️ настройки**, открывающая хаб настроек — настройки, профиль, друзья, о программе).
Список **мои игры** разбит на три секции —
*твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так, *твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так,
что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу
соперника и завершённые — самые свежие сверху; отображается компактным списком с соперника и завершённые — самые свежие сверху; отображается компактным списком с
@@ -132,10 +134,11 @@ nudge) приходят от бота **этой партии** — по язы
тому, с кем вы играли** — он принимает, игнорирует (заявка истекает через тридцать тому, с кем вы играли** — он принимает, игнорирует (заявка истекает через тридцать
дней, после чего её можно отправить снова) или отклоняет (отказ блокирует ваши дней, после чего её можно отправить снова) или отклоняет (отказ блокирует ваши
повторные заявки, пока он сам не передаст вам код). Отмена своей висящей заявки повторные заявки, пока он сам не передаст вам код). Отмена своей висящей заявки
снимает её; удаление расторгает дружбу. В партии пункт меню **в друзья** для каждого снимает её; удаление расторгает дружбу. В партии карточка счёта каждого соперника несёт контрол
соперника отражает живое отношение: он показывает *заявка отправлена* (неактивный), **в друзья 🤝** (при открытой истории ходов), отражающий живое отношение: он подтверждается
пока заявка висит или была отклонена, и *в друзьях* после принятия — обновляясь на месте тапом по затухающей ✅ (карточка показывает *В друзья?* во время подтверждения), становится
в момент ответа соперника и оставаясь верным после перезагрузки. Глобальная блокировка — отключить входящие **неактивным**, пока заявка висит или была отклонена, и **исчезает** после принятия —
обновляясь на месте в момент ответа соперника и оставаясь верным после перезагрузки. Глобальная блокировка — отключить входящие
чат и/или заявки — чат и/или заявки —
и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки
и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат
@@ -143,8 +146,9 @@ nudge) приходят от бота **этой партии** — по язы
содержать ссылок, email и телефонов, даже завуалированных. Nudge ожидаемого содержать ссылок, email и телефонов, даже завуалированных. Nudge ожидаемого
соперника — не чаще раза в час (nudge — часть игрового чата); внеприложенческий соперника — не чаще раза в час (nudge — часть игрового чата); внеприложенческий
push доставляется через платформу. push доставляется через платформу.
Чат и инструмент проверки слова открываются **отдельными экранами** (с кнопкой «назад» в Чат и инструмент проверки слова — один **экран связи** со вкладками **💬 чат** / **🔎 словарь**,
партию), а новое сообщение в чате рисует **бейдж непрочитанного** на меню партии до открытия чата. открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию); новое сообщение рисует
**бейдж непрочитанного** на строке счёта партии и на 💬 до открытия чата.
### Профиль и настройки ### Профиль и настройки
Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» / Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
+50 -23
View File
@@ -23,16 +23,25 @@ Login uses `Screen`.
- **Back**: a thin, compact `<` drawn from two rotated CSS borders (`Header.svelte` - **Back**: a thin, compact `<` drawn from two rotated CSS borders (`Header.svelte`
`.chev`) — lighter than a glyph. `.chev`) — lighter than a glyph.
- **Hamburger**: a CSS three-bar (`Menu.svelte`), deliberately larger; opens a dropdown - **No hamburger**: navigation is entirely bottom tab bars (the former `Menu.svelte`
of items (lobby: Friends/Profile/Settings/About; game: History/Chat/Check word, plus dropdown is gone — it fought the Telegram-fullscreen layout, where it had to be re-centred).
*Export GCG* on a finished game and *Add to friends* per opponent, then Drop game). A Destinations beyond the lobby live behind **hub screens** reached from a tab: a ⚙️
red count **badge** rides the hamburger (and the lobby *Friends* item) for pending **Settings hub** (`screens/SettingsHub.svelte`, the lobby's ⚙️ tab) and an in-game
incoming friend requests + invitations; the same dot style serves any future **comms hub** (`game/CommsHub.svelte`, the history's 💬). A hub owns one nav bar + one
notification count. 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 - **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 emoji icon over a tiny truncated label (the icon is `aria-hidden`, so the label names the
the icon (slightly larger than it) until release; spacing keeps adjacent labels from button). A press highlights a rounded **square** behind the icon; a hub's **selected** tab
touching. No text selection on nav / tab-bar / buttons (`user-select: none`). 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 transitions** (`App.svelte`): navigation slides directionally — a
screen entered from the lobby flies in from the right; returning to the lobby reveals it 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 from the left (back). Transitions are local (so they do not play on first load) and
@@ -71,8 +80,9 @@ Login uses `Screen`.
hovering a dragged tile never jumps the board. On touch the first tile placement auto-zooms 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 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); 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 history opens on a **tap of the score bar** and closes on a tap or an **upward swipe** of
centred on the hint's placement, not the top-left. 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 - **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 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 cell is **highlighted as a drop target** (an accent ring). A pending tile is taken back by a
@@ -81,11 +91,21 @@ Login uses `Screen`.
recalled tile returns to its original rack slot. recalled tile returns to its original rack slot.
- **Players plaque & history** (`Game.svelte`): the seats above the board share - **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) 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 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 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 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 - **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 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 bar stay fixed), while zoom keeps its own scroll. The check-word dialog opens in
@@ -107,9 +127,12 @@ Login uses `Screen`.
## Controls ## Controls
- **HoldConfirm** (`components/HoldConfirm.svelte`): the shared press-and-hold control. A - **TapConfirm** (`components/TapConfirm.svelte`, logic in `lib/tapconfirm.ts`): the shared
short tap opens a small popover above the button; a ~0.7 s hold runs the primary action tap-to-confirm control. A first tap arms a ~2 s window showing a **fading ✅** (no fade
immediately. Used by the **Skip** and **Hint** tabs (each confirmed by an **Ok ✅** popover). 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 - **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 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 button) beside the rack commits the move — no popover, and disabled while the pending word
@@ -140,7 +163,11 @@ IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use
## Social, account & history surfaces ## 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 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** code + its expiry; then the incoming **requests** (Accept / Decline), the **friends**
list (Remove / Block), and a **blocked** list (Unblock). Durable accounts only — a list (Remove / Block), and a **blocked** list (Unblock). Durable accounts only — a
@@ -163,12 +190,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 the icon copies it. Flex text inputs carry `min-width:0` so they shrink instead of
overflowing in Safari. overflowing in Safari.
- **History / GCG**: the in-game slide-down history gains the running total per move; - **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 *Export GCG* (the 📤 in the history header) shares or downloads the `.gcg` file and
finished. appears only once the game is finished.
- **Finished game**: the board keeps no last-word highlight and no zoom; the menu drops - **Finished game**: the board keeps no last-word highlight and no zoom; the history header
*Check word* and *Drop game*; and the footer (rack + tab bar) is **drawn but inert** offers *Export GCG* (not *Drop game*) and the comms hub hides the 🔎 *Dictionary* tab; and
(greyed, non-interactive) rather than hidden, so the layout does not jump. Chat the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than
send / nudge are the ⬆️ / 🛎️ icons. hidden, so the layout does not jump. Chat send / nudge are the ⬆️ / 🛎️ icons.
## Caveat ## Caveat
+53 -24
View File
@@ -10,9 +10,8 @@ async function openGame(page: Page): Promise<void> {
await page.getByRole('button', { name: /guest/i }).click(); await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /Ann/ }).click(); // the seeded active game vs Ann await page.getByRole('button', { name: /Ann/ }).click(); // the seeded active game vs Ann
await expect(page.locator('[data-cell]').first()).toBeVisible(); await expect(page.locator('[data-cell]').first()).toBeVisible();
// Wait for the screen-slide transition to settle so only the game pane remains; // Wait for the screen-slide transition to settle so only the game pane remains; until it
// until it does, the leaving lobby pane's header (its menu button) is also in the // does, the leaving lobby pane is also in the DOM, which would make shared locators ambiguous.
// DOM, which would make shared locators like .burger ambiguous.
await expect(page.locator('.pane')).toHaveCount(1); 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()); 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 openGame(page);
await page.locator('.burger').click(); await page.locator('.scoreboard').click(); // tapping the score bar opens the history
await page.locator('.dropdown button').nth(0).click(); // History
await expect(page.locator('.history')).toBeVisible(); await expect(page.locator('.history')).toBeVisible();
await expect(page.locator('.boardwrap.slid')).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(); 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 }) => { test('Draw opens the exchange dialog and confirms a selection', async ({ page }) => {
await openGame(page); await openGame(page);
await page.locator('button:has-text("🔄")').click(); // Draw tab 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(); 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 }) => { test('check-word sanitises input and shows a verdict', async ({ page }) => {
await openGame(page); await openGame(page);
await page.locator('.burger').click(); await page.locator('.scoreboard').click(); // open the history
await page.locator('.dropdown button').nth(2).click(); // Check word await page.getByRole('button', { name: 'Chat' }).click(); // 💬 -> comms hub
await expect(page.locator('.pane')).toHaveCount(1);
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
@@ -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 }) => { test('dropping the game ends it and shows the result', async ({ page }) => {
await openGame(page); await openGame(page);
await page.locator('.burger').click(); await page.locator('.scoreboard').click(); // open the history
await page.getByRole('button', { name: 'Drop game' }).click(); // robust against menu growth await page.getByRole('button', { name: 'Drop game' }).click(); // 🏁 in the history header
await page.locator('button.danger').click(); // confirm in the modal await page.locator('button.danger').click(); // confirm in the modal
await expect(page.locator('.status .over')).toBeVisible(); 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); 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 openGame(page);
await page.locator('.scoreboard').click(); // open the history
await page.locator('.burger').click(); await page.getByRole('button', { name: 'Chat' }).click(); // 💬 in the history header
await page.getByRole('button', { name: /^Chat$/ }).click();
await expect(page).toHaveURL(/\/game\/g1\/chat$/); await expect(page).toHaveURL(/\/game\/g1\/chat$/);
await expect(page.locator('.pane')).toHaveCount(1); // let the slide transition settle await expect(page.locator('.pane')).toHaveCount(1); // let the slide transition settle
await expect(page.locator('.chat')).toBeVisible(); 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: 'Dictionary' }).click();
await expect(page.locator('.check input')).toBeVisible();
// The header back chevron returns to the game.
await expect(page.locator('.back')).toHaveCount(1); 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).toHaveURL(/\/game\/g1$/);
await expect(page.locator('.pane')).toHaveCount(1); 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 }) => { test('the board-label mode in Settings changes the on-board labels', async ({ page }) => {
+67 -42
View File
@@ -10,9 +10,18 @@ async function loginLobby(page: Page): Promise<void> {
await expect(page.getByText('Your turn')).toBeVisible(); await expect(page.getByText('Your turn')).toBeVisible();
} }
async function openFriends(page: Page): Promise<void> { // The Settings hub (the lobby ⚙️ tab) hosts Settings / Profile / Friends / About as in-place
await page.locator('.burger').first().click(); // tabs; the back control always returns to the lobby. Tabs are icon-only with an aria-label.
await page.getByRole('button', { name: /Friends/ }).click(); 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();
}
function openFriends(page: Page): Promise<void> {
return openSettingsTab(page, 'Friends');
}
function openProfile(page: Page): Promise<void> {
return openSettingsTab(page, 'Profile');
} }
test('friends: issue a code, accept an incoming request, redeem a code', async ({ page }) => { 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(); 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: 'Info', 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 }) => { test('profile edit saves a new display name', async ({ page }) => {
await loginLobby(page); await loginLobby(page);
await page.locator('.burger').first().click(); await openProfile(page);
await page.getByRole('button', { name: /Profile/ }).click();
await page.locator('.edit input').first().fill('Kaya Test'); await page.locator('.edit input').first().fill('Kaya Test');
await page.getByRole('button', { name: /^Save$/ }).click(); await page.getByRole('button', { name: /^Save$/ }).click();
await expect(page.locator('.name')).toHaveText('Kaya Test'); 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 }) => { test('GCG export appears only for a finished game', async ({ page }) => {
await loginLobby(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.getByRole('button', { name: /Kaya/ }).click();
await page.locator('.burger').first().click(); await page.locator('.scoreboard').click(); // open the history
await expect(page.getByRole('button', { name: /Export GCG/ })).toBeVisible(); await expect(page.getByRole('button', { name: 'Export GCG' })).toBeVisible();
}); });
test('GCG export is hidden for an active game', async ({ page }) => { test('GCG export is hidden for an active game', async ({ page }) => {
await loginLobby(page); await loginLobby(page);
await page.getByRole('button', { name: /Ann/ }).click(); await page.getByRole('button', { name: /Ann/ }).click();
await page.locator('.burger').first().click(); await page.locator('.scoreboard').click(); // open the history (shows 🏁 leave, not 📤 export)
await expect(page.getByRole('button', { name: /Export GCG/ })).toHaveCount(0); 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 loginLobby(page);
await page.getByRole('button', { name: /Kaya/ }).click(); // finished game vs Kaya await page.getByRole('button', { name: /Kaya/ }).click(); // finished game vs Kaya
await expect(page.locator('[data-cell]').first()).toBeVisible(); await expect(page.locator('[data-cell]').first()).toBeVisible();
// The footer (tab bar) is drawn but its controls are disabled in a finished game. // The footer (tab bar) is drawn but its controls are disabled in a finished game.
await expect(page.locator('.tab').first()).toBeDisabled(); await expect(page.locator('.tab').first()).toBeDisabled();
// The menu drops Check word and Drop game once the game is over. // The history header offers Export GCG, not Drop game, once the game is over.
await page.locator('.burger').first().click(); await page.locator('.scoreboard').click();
await expect(page.getByRole('button', { name: 'Check word' })).toHaveCount(0); await expect(page.getByRole('button', { name: 'Export GCG' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Drop game' })).toHaveCount(0); 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: '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 }) => {
@@ -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(); 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); await loginLobby(page);
// One incoming friend request (Rick) + one invitation (Kaya) = 2. // The ⚙️ badge counts incoming friend requests only (Rick = 1); invitations have their
await expect(page.getByTestId('menu-badge')).toHaveText('2'); // 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 }) => { 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(); 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 loginLobby(page);
await page.getByRole('button', { name: /Ann/ }).click(); // active game vs Ann await page.getByRole('button', { name: /Ann/ }).click(); // active game vs Ann
await page.locator('.burger').first().click(); await page.locator('.scoreboard').click(); // open the history -> 🤝 appears on Ann's card
await page.getByRole('button', { name: /Add to friends: Ann/ }).click(); const add = page.getByRole('button', { name: 'Add to friends' });
// Reopening the menu shows the item as a disabled "request sent". await add.click(); // arm: 🤝 -> a fading ✅
await page.locator('.burger').first().click(); await add.click(); // tap the ✅ to confirm within the window
const sent = page.getByRole('button', { name: 'Request sent' }); // The request is sent, so the control is now disabled.
await expect(sent).toBeVisible(); await expect(add).toBeDisabled();
await expect(sent).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 loginLobby(page);
await page.getByRole('button', { name: /Kaya/ }).click(); // the finished game vs Kaya, a seeded friend await page.getByRole('button', { name: /Kaya/ }).click(); // finished game vs Kaya, a seeded friend
await page.locator('.burger').first().click(); await page.locator('.scoreboard').click(); // open the history
// The in-game friend item is derived from the server's friend list: a friend reads // Kaya is already a friend, so no add-friend control is offered on her card.
// a disabled "✓ in friends", not the addable "Add to friends". await expect(page.getByRole('button', { name: 'Add to friends' })).toHaveCount(0);
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);
}); });
test('profile edit disables Save and flags an invalid display name', async ({ page }) => { test('profile edit disables Save and flags an invalid display name', async ({ page }) => {
await loginLobby(page); await loginLobby(page);
await page.locator('.burger').first().click(); await openProfile(page);
await page.getByRole('button', { name: /Profile/ }).click();
const name = page.locator('.edit input').first(); const name = page.locator('.edit input').first();
const save = page.getByRole('button', { name: /^Save$/ }); 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 }) => { test('link account: a taken email opens the irreversible merge confirmation', async ({ page }) => {
await loginLobby(page); await loginLobby(page);
await page.locator('.burger').first().click(); await openProfile(page);
await page.getByRole('button', { name: /Profile/ }).click();
// The linking section is shown to everyone (guests upgrade by linking). // The linking section is shown to everyone (guests upgrade by linking).
await expect(page.getByRole('heading', { name: 'Link an account' })).toBeVisible(); 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 }) => { test('link account: the Telegram web sign-in control is offered in a browser', async ({ page }) => {
await loginLobby(page); await loginLobby(page);
await page.locator('.burger').first().click(); await openProfile(page);
await page.getByRole('button', { name: /Profile/ }).click();
await expect(page.getByRole('button', { name: 'Link Telegram' })).toBeVisible(); 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 }) => { test('chat: the message field shows on your turn, the nudge replaces it otherwise', async ({ page }) => {
await loginLobby(page); await loginLobby(page);
await page.getByRole('button', { name: /Ann/ }).click(); // g1: your turn await page.getByRole('button', { name: /Ann/ }).click(); // g1: your turn
await page.locator('.burger').first().click(); await page.locator('.scoreboard').click(); // open the history
await page.getByRole('button', { name: 'Chat' }).click(); 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; // 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 // chat and nudge are mutually exclusive by turn. Icon-only controls expose their action
// through the aria-label. // through the aria-label.
+31
View File
@@ -35,6 +35,37 @@ test('Telegram launch auto-authenticates into the lobby and applies the theme',
.toBe('#101418'); .toBe('#101418');
}); });
test('tg-fullscreen header keeps a constant native-nav gap as the font scales', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await expect(page.getByText('Your turn')).toBeVisible(); // the lobby header is present + settled
// Emulate Telegram fullscreen: the class + safe-area vars our header positions against.
await page.evaluate(() => {
const h = document.documentElement;
h.classList.add('tg-fullscreen');
h.style.setProperty('--tg-safe-top', '47px');
h.style.setProperty('--tg-content-top', '50px');
});
const probe = () =>
page.evaluate(() => {
const bar = document.querySelector('.bar')!.getBoundingClientRect();
const h1 = document.querySelector('.bar h1')!.getBoundingClientRect();
return { h1Top: Math.round(h1.top), overflows: h1.bottom > bar.bottom + 0.5 };
});
const normal = await probe();
// Scale the root font up (an OS / Telegram "larger text" setting scales rem-based text).
await page.evaluate(() => (document.documentElement.style.fontSize = '28px'));
const large = await probe();
// The gap to Telegram's native controls is a fixed px, so the title's top does not move…
expect(large.h1Top).toBe(normal.h1Top);
// …the title grows downward inside the bar (which grows with it), never overflowing it.
expect(normal.overflows).toBe(false);
expect(large.overflows).toBe(false);
});
test('outside Telegram, the /telegram/ entry redirects to the site root', async ({ page }) => { test('outside Telegram, the /telegram/ entry redirects to the site root', async ({ page }) => {
await page.goto('/telegram/'); await page.goto('/telegram/');
+8 -12
View File
@@ -9,14 +9,10 @@
import Login from './screens/Login.svelte'; import Login from './screens/Login.svelte';
import Lobby from './screens/Lobby.svelte'; import Lobby from './screens/Lobby.svelte';
import NewGame from './screens/NewGame.svelte'; import NewGame from './screens/NewGame.svelte';
import Profile from './screens/Profile.svelte'; import SettingsHub from './screens/SettingsHub.svelte';
import Settings from './screens/Settings.svelte';
import About from './screens/About.svelte';
import Friends from './screens/Friends.svelte';
import Stats from './screens/Stats.svelte'; import Stats from './screens/Stats.svelte';
import Game from './game/Game.svelte'; import Game from './game/Game.svelte';
import ChatScreen from './game/ChatScreen.svelte'; import CommsHub from './game/CommsHub.svelte';
import CheckScreen from './game/CheckScreen.svelte';
onMount(() => { onMount(() => {
void bootstrap(); void bootstrap();
@@ -83,17 +79,17 @@
{:else if router.route.name === 'game'} {:else if router.route.name === 'game'}
<Game id={router.route.params.id} /> <Game id={router.route.params.id} />
{:else if router.route.name === 'gameChat'} {:else if router.route.name === 'gameChat'}
<ChatScreen id={router.route.params.id} /> <CommsHub id={router.route.params.id} initialTab="chat" />
{:else if router.route.name === 'gameCheck'} {:else if router.route.name === 'gameCheck'}
<CheckScreen id={router.route.params.id} /> <CommsHub id={router.route.params.id} initialTab="dictionary" />
{:else if router.route.name === 'profile'} {:else if router.route.name === 'profile'}
<Profile /> <SettingsHub initialTab="profile" />
{:else if router.route.name === 'settings'} {:else if router.route.name === 'settings'}
<Settings /> <SettingsHub initialTab="settings" />
{:else if router.route.name === 'about'} {:else if router.route.name === 'about'}
<About /> <SettingsHub initialTab="about" />
{:else if router.route.name === 'friends'} {:else if router.route.name === 'friends'}
<Friends /> <SettingsHub initialTab="friends" />
{:else if router.route.name === 'stats'} {:else if router.route.name === 'stats'}
<Stats /> <Stats />
{:else} {:else}
+14 -21
View File
@@ -1,13 +1,11 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte';
import { navigate } from '../lib/router.svelte'; import { navigate } from '../lib/router.svelte';
import { insideTelegram } from '../lib/telegram'; import { insideTelegram } from '../lib/telegram';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import Spinner from './Spinner.svelte'; import Spinner from './Spinner.svelte';
let { title, back, menu, grow = false }: { title: string; back?: string; menu?: Snippet; grow?: boolean } = let { title, back, grow = false }: { title: string; back?: string; grow?: boolean } = $props();
$props();
// Inside Telegram the native header back button (App.svelte) is the back control, so // 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. // the app's own chevron is hidden to avoid two back affordances.
@@ -28,7 +26,8 @@
{:else} {:else}
<h1 class="connecting"><Spinner /> <span>{t('connection.connecting')}</span></h1> <h1 class="connecting"><Spinner /> <span>{t('connection.connecting')}</span></h1>
{/if} {/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> </div>
</header> </header>
@@ -75,18 +74,13 @@
font-weight: 500; font-weight: 500;
} }
.icon, .icon,
.spacer, .spacer {
.end {
min-width: 40px; min-width: 40px;
height: 36px; height: 36px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.end {
width: auto;
justify-content: flex-end;
}
.back { .back {
background: none; background: none;
border: none; border: none;
@@ -108,19 +102,21 @@
} }
/* Telegram fullscreen: TG's native nav occupies the band between the device notch /* 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 (--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 is unchanged) and centres the title within it, BELOW the notch — lining it up vertically
it, BELOW the notch — lining them up vertically with Telegram's own back/menu controls, with Telegram's own back/menu controls, which sit in the band's corners. */
which sit in the band's corners. */
:global(html.tg-fullscreen) .bar { :global(html.tg-fullscreen) .bar {
/* The bar is sized by its content + padding — in Telegram the title is the only child (the
back chevron is hidden), so min-height (the nav-band height) doesn't bind. Without the
(removed) hamburger that content shrank and the bar sat flush under Telegram's native nav
band. So **padding-top** is the lever: it drops the title clear of the band — the notch
plus a **10px** gap (was 6). A fixed px (not rem/em) gap so the clearance from Telegram's
native controls stays constant if the user scales up the font (the title then grows
downward and the bar with it). (Owner-tunable: the 10px.) */
min-height: var(--tg-content-top); min-height: var(--tg-content-top);
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 padding-top: calc(var(--tg-safe-top) + 16px);
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; padding-bottom: 6px;
} }
:global(html.tg-fullscreen) .spacer { :global(html.tg-fullscreen) .spacer {
@@ -129,7 +125,4 @@
:global(html.tg-fullscreen) h1 { :global(html.tg-fullscreen) h1 {
flex: 0 1 auto; flex: 0 1 auto;
} }
:global(html.tg-fullscreen) .end {
min-width: 0;
}
</style> </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 { let {
title, title,
back, back,
menu,
tabbar, tabbar,
children, children,
scroll = true, scroll = true,
@@ -19,7 +18,6 @@
}: { }: {
title: string; title: string;
back?: string; back?: string;
menu?: Snippet;
tabbar?: Snippet; tabbar?: Snippet;
children?: Snippet; children?: Snippet;
scroll?: boolean; scroll?: boolean;
@@ -58,7 +56,7 @@
</script> </script>
<div class="screen"> <div class="screen">
<Header {title} {back} {menu} grow={growNav} /> <Header {title} {back} grow={growNav} />
{#if SHOW_AD_BANNER}<AdBanner />{/if} {#if SHOW_AD_BANNER}<AdBanner />{/if}
<main class="content" class:scroll class:fill={!growNav} class:column>{@render children?.()}</main> <main class="content" class:scroll class:fill={!growNav} class:column>{@render children?.()}</main>
{#if tabbar} {#if tabbar}
+24 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
// The bottom tab bar: square borderless buttons, evenly distributed, mobile-OS feel. // 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(); let { children }: { children?: Snippet } = $props();
</script> </script>
@@ -53,6 +53,29 @@
:global(.tab:active:not(:disabled) .sq) { :global(.tab:active:not(:disabled) .sq) {
background: var(--surface-2); 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) { :global(.tab .lbl) {
font-size: 0.62rem; font-size: 0.62rem;
color: var(--text-muted); 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>
+4 -7
View File
@@ -1,15 +1,14 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import Chat from './Chat.svelte'; import Chat from './Chat.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { app, handleError, clearChatUnread } from '../lib/app.svelte'; import { app, handleError, clearChatUnread } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte';
import type { ChatMessage, StateView } from '../lib/model'; import type { ChatMessage, StateView } from '../lib/model';
// The chat is its own screen, so the soft keyboard simply resizes the viewport with // The Chat tab body, hosted by CommsHub (which supplies the nav bar + tab bar). The
// the input pinned to the bottom — no modal relayout jank. It loads the game state (for the // hub lays it out as a non-scrolling column, so the soft keyboard simply resizes the
// turn-based chat/nudge toggle) and the message list, and clears the unread badge while open. // viewport with the input pinned to the bottom. It loads the game state (for the
// turn-based chat/nudge toggle) and the message list, and clears the unread while open.
let { id }: { id: string } = $props(); let { id }: { id: string } = $props();
let view = $state<StateView | null>(null); let view = $state<StateView | null>(null);
@@ -88,6 +87,4 @@
} }
</script> </script>
<Screen title={t('game.chat')} back={`/game/${id}`} scroll={false} column>
<Chat {messages} {myId} {busy} myTurn={isMyTurn} {nudgeOnCooldown} onsend={sendChat} onnudge={nudge} /> <Chat {messages} {myId} {busy} myTurn={isMyTurn} {nudgeOnCooldown} onsend={sendChat} onnudge={nudge} />
</Screen>
-3
View File
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { handleError, showToast } from '../lib/app.svelte'; import { handleError, showToast } from '../lib/app.svelte';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
@@ -60,7 +59,6 @@
} }
</script> </script>
<Screen title={t('game.checkWord')} back={`/game/${id}`}>
<div class="wrap"> <div class="wrap">
<div class="check"> <div class="check">
<input <input
@@ -80,7 +78,6 @@
<button class="complain" onclick={complain}>{t('game.complain')}</button> <button class="complain" onclick={complain}>{t('game.complain')}</button>
{/if} {/if}
</div> </div>
</Screen>
<style> <style>
.wrap { .wrap {
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts">
import Screen from '../components/Screen.svelte';
import TabBar from '../components/TabBar.svelte';
import ChatScreen from './ChatScreen.svelte';
import CheckScreen from './CheckScreen.svelte';
import { t } from '../lib/i18n/index.svelte';
import { getCachedGame } from '../lib/gamecache';
// The in-game comms hub: a single nav bar + bottom tab bar hosting Chat and the word
// Dictionary. Tabs switch in place, so the back control always returns to the game. The
// Dictionary tab is offered only while the game is active (mirrors the old "check word").
type CommsTab = 'chat' | 'dictionary';
let { id, initialTab = 'chat' }: { id: string; initialTab?: CommsTab } = $props();
// The game is rendered (and cached) before its comms open, so the cache tells us whether
// it is still active without another fetch; an unknown game keeps the Dictionary offered.
const active = $derived(getCachedGame(id)?.view.game.status !== 'finished');
// Seeded once from the entry route's tab and then owned locally; the effect below
// corrects a Dictionary deep-link into a finished game back to Chat.
// svelte-ignore state_referenced_locally
let tab = $state<CommsTab>(initialTab);
$effect(() => {
if (tab === 'dictionary' && !active) tab = 'chat';
});
</script>
<Screen
title={t(tab === 'chat' ? 'game.chat' : 'game.checkWord')}
back={`/game/${id}`}
scroll={tab === 'dictionary'}
column={tab === 'chat'}
>
{#if tab === 'chat'}
<ChatScreen {id} />
{:else}
<CheckScreen {id} />
{/if}
{#snippet tabbar()}
<TabBar>
<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')}>
<span class="sq" aria-hidden="true">🔎</span><span class="lbl">{t('game.dictionary')}</span>
</button>
{/if}
</TabBar>
{/snippet}
</Screen>
+141 -59
View File
@@ -1,9 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import Screen from '../components/Screen.svelte'; import Screen from '../components/Screen.svelte';
import Menu from '../components/Menu.svelte';
import TabBar from '../components/TabBar.svelte'; import TabBar from '../components/TabBar.svelte';
import HoldConfirm from '../components/HoldConfirm.svelte'; import TapConfirm from '../components/TapConfirm.svelte';
import Modal from '../components/Modal.svelte'; import Modal from '../components/Modal.svelte';
import Board from './Board.svelte'; import Board from './Board.svelte';
import Rack from './Rack.svelte'; import Rack from './Rack.svelte';
@@ -612,13 +611,46 @@
} }
} }
// Friend state for the in-game "add to friends" item, derived from the server so it is // --- move history: open by tapping the score bar, close by tapping or swiping up the board ---
// correct across reloads and live-updates when a request is answered: // While the history is open the board is inert (CSS pointer-events), so the whole slid board
// `friends` are the caller's accepted friends; `requested` are the addressees already // reads as a "tap or swipe up to close" surface and the stage cannot scroll instead of close.
// requested (pending or declined — both block a re-send and read as "request sent"). // The tap closes on click; the swipe closes as soon as enough upward travel is seen, so it
// never depends on where a fast swipe's pointerup lands (which differs across engines).
// Closing genuinely clears `historyOpen` (rather than only scrolling the slid board out of
// view, which left a stale-open state that made a follow-up score-bar tap "jump" the board).
let histSwipeY: number | null = null;
function toggleHistory() {
historyOpen = !historyOpen;
}
function closeHistoryByGesture() {
if (!historyOpen) return;
historyOpen = false;
histSwipeY = null;
// Swallow the click some browsers synthesise from a board tap, so it does not place a tile.
swallowClick = true;
setTimeout(() => (swallowClick = false), 120);
}
function onBoardWrapDown(e: PointerEvent) {
histSwipeY = historyOpen ? e.clientY : null;
}
function onBoardWrapMove(e: PointerEvent) {
if (histSwipeY !== null && histSwipeY - e.clientY > 32) closeHistoryByGesture();
}
// A closed history clears every per-seat add-friend confirmation.
$effect(() => {
if (!historyOpen) addConfirm = {};
});
// Friend state for the in-game "add friend" affordance (the 🤝 in each opponent's score
// card while the history is open), derived from the server so it is correct across reloads
// and live-updates when a request is answered: `friends` are the caller's accepted friends;
// `requested` are the addressees already requested (pending or declined — both block a
// re-send and disable the 🤝).
let friends = $state(new Set<string>()); let friends = $state(new Set<string>());
let requested = $state(new Set<string>()); let requested = $state(new Set<string>());
const noop = () => {}; // Per-seat "confirming" flag for the 🤝 → ✅ tap-to-confirm (TapConfirm writes it); while
// set, that seat's card shows "Add friend?" in place of the score. Reset when history closes.
let addConfirm = $state<Record<number, boolean>>({});
// loadFriends refreshes the friend/outgoing sets for a non-guest; guests have no social // loadFriends refreshes the friend/outgoing sets for a non-guest; guests have no social
// surfaces, so the sets stay empty. Best-effort — a failure leaves the previous sets. // surfaces, so the sets stay empty. Best-effort — a failure leaves the previous sets.
@@ -643,50 +675,52 @@
} }
} }
const opponents = $derived( // canAddFriend reports whether a seat shows the 🤝: a non-guest viewing an opponent who is
view ? view.game.seats.filter((s) => s.accountId !== app.session?.userId) : [], // not yet a friend (an already-requested opponent still shows it, but disabled).
); function canAddFriend(accountId: string): boolean {
return !app.profile?.isGuest && accountId !== app.session?.userId && !friends.has(accountId);
// In a finished game the menu drops Check word and Drop game, gains Export GCG, and }
// an "add to friends" item flips to a disabled "request sent" once tapped.
const menuItems = $derived([
{ label: t('game.history'), onclick: () => (historyOpen = true) },
{ label: t('game.chat'), onclick: () => navigate(`/game/${id}/chat`), badge: app.chatUnread[id] ?? 0 },
...(gameOver ? [] : [{ label: t('game.checkWord'), onclick: () => navigate(`/game/${id}/check`) }]),
...(view?.game.status === 'finished' ? [{ label: t('game.exportGcg'), onclick: exportGcg }] : []),
...(!app.profile?.isGuest
? opponents.map((s) =>
friends.has(s.accountId)
? { label: t('game.alreadyFriends'), onclick: noop, disabled: true }
: requested.has(s.accountId)
? { label: t('game.requestSent'), onclick: noop, disabled: true }
: { label: `${t('friends.addFromGame')}: ${s.displayName}`, onclick: () => addFriend(s.accountId) },
)
: []),
...(gameOver ? [] : [{ label: t('game.dropGame'), onclick: () => (resignOpen = true) }]),
]);
</script> </script>
<Screen title={t(variantNameKey(variant))} back="/" growNav column scroll={false}> <Screen title={t(variantNameKey(variant))} back="/" growNav column scroll={false}>
{#snippet menu()}
<Menu items={menuItems} badge={app.chatUnread[id] ?? 0} />
{/snippet}
{#if view} {#if view}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="scoreboard" onclick={() => (historyOpen = !historyOpen)}> <div class="scoreboard" onclick={toggleHistory}>
{#if (app.chatUnread[id] ?? 0) > 0}<span class="cbadge sbadge">{app.chatUnread[id]}</span>{/if}
{#each view.game.seats as s (s.seat)} {#each view.game.seats as s (s.seat)}
<div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}> <div class="seat" class:turn={view.game.toMove === s.seat && !gameOver} class:win={s.isWinner}>
<div class="nm">{s.accountId === app.session?.userId ? t('common.you') : s.displayName}</div> <div class="nm">{s.accountId === app.session?.userId ? t('common.you') : s.displayName}</div>
<div class="sc">{s.score}</div> <div class="sc">{addConfirm[s.seat] ? t('game.addFriendShort') : s.score}</div>
{#if historyOpen && canAddFriend(s.accountId)}
<span class="addfriend">
<TapConfirm
label={t('friends.addFromGame')}
disabled={requested.has(s.accountId)}
onConfirming={(v) => (addConfirm[s.seat] = v)}
onconfirm={() => addFriend(s.accountId)}
>
<span class="fico">🤝</span>
</TapConfirm>
</span>
{/if}
</div> </div>
{/each} {/each}
</div> </div>
<div class="stage"> <div class="stage" class:histopen={historyOpen}>
{#if historyOpen} {#if historyOpen}
<div class="history"> <div class="history">
<div class="hhead">
{#if gameOver}
<button class="hicon" onclick={exportGcg} aria-label={t('game.exportGcg')}>📤</button>
{:else}
<button class="hicon" onclick={() => (resignOpen = true)} aria-label={t('game.dropGame')}>🏁</button>
{/if}
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
💬{#if (app.chatUnread[id] ?? 0) > 0}<span class="cbadge">{app.chatUnread[id]}</span>{/if}
</button>
</div>
<ol> <ol>
{#each moves as m, i (i)} {#each moves as m, i (i)}
<li> <li>
@@ -705,7 +739,10 @@
<div <div
class="boardwrap" class="boardwrap"
class:slid={historyOpen} class:slid={historyOpen}
onclick={() => historyOpen && (historyOpen = false)} onpointerdown={onBoardWrapDown}
onpointermove={onBoardWrapMove}
onpointerup={() => (histSwipeY = null)}
onclick={closeHistoryByGesture}
> >
<Board <Board
{board} {board}
@@ -769,17 +806,18 @@
<button class="tab" disabled={busy || !isMyTurn || !connection.online || bagEmpty} onclick={openExchange}> <button class="tab" disabled={busy || !isMyTurn || !connection.online || bagEmpty} onclick={openExchange}>
<span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span> <span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>
</button> </button>
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn || !connection.online} onhold={doPass}> <TapConfirm triggerClass="tab" label={t('game.skip')} disabled={busy || !isMyTurn || !connection.online} onconfirm={doPass}>
{#snippet trigger()}<span class="sq">🥺</span><span class="lbl">{t('game.skip')}</span>{/snippet} <span class="sq">🥺</span><span class="lbl">{t('game.skip')}</span>
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doPass(); }}>{t('game.confirm')} ✅</button>{/snippet} </TapConfirm>
</HoldConfirm> <TapConfirm
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn || !connection.online || (view?.hintsRemaining ?? 0) <= 0} onhold={doHint}> triggerClass="tab"
{#snippet trigger()} label={t('game.hint')}
disabled={busy || !isMyTurn || !connection.online || (view?.hintsRemaining ?? 0) <= 0}
onconfirm={doHint}
>
<span class="sq">🛟{#if (view?.hintsRemaining ?? 0) > 0}<span class="badge">{view?.hintsRemaining}</span>{/if}</span> <span class="sq">🛟{#if (view?.hintsRemaining ?? 0) > 0}<span class="badge">{view?.hintsRemaining}</span>{/if}</span>
<span class="lbl">{t('game.hint')}</span> <span class="lbl">{t('game.hint')}</span>
{/snippet} </TapConfirm>
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doHint(); }}>{t('game.confirm')} ✅</button>{/snippet}
</HoldConfirm>
{#if placement.pending.length > 0} {#if placement.pending.length > 0}
<button class="tab" disabled={busy} onclick={resetPlacement}> <button class="tab" disabled={busy} onclick={resetPlacement}>
<span class="sq">↩️</span><span class="lbl">{t('game.reset')}</span> <span class="sq">↩️</span><span class="lbl">{t('game.reset')}</span>
@@ -836,6 +874,7 @@
<style> <style>
.scoreboard { .scoreboard {
position: relative;
display: flex; display: flex;
flex: none; flex: none;
gap: 6px; gap: 6px;
@@ -844,6 +883,7 @@
cursor: pointer; cursor: pointer;
} }
.seat { .seat {
position: relative;
flex: 1; flex: 1;
text-align: center; text-align: center;
padding: 5px 4px; padding: 5px 4px;
@@ -891,6 +931,11 @@
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
} }
/* While the history is open the stage must not scroll — a swipe up on the board closes
the panel instead of scrolling the slid board out from under it. */
.stage.histopen {
overflow: hidden;
}
.history { .history {
position: absolute; position: absolute;
inset: 0 0 auto 0; inset: 0 0 auto 0;
@@ -947,6 +992,10 @@
.boardwrap.slid { .boardwrap.slid {
transform: translateY(62%); transform: translateY(62%);
} }
/* The slid board is inert: the whole surface reads as "tap or swipe up to close". */
.boardwrap.slid :global(.viewport) {
pointer-events: none;
}
.status { .status {
display: flex; display: flex;
flex: none; flex: none;
@@ -998,22 +1047,47 @@
.make:disabled { .make:disabled {
opacity: 0.4; opacity: 0.4;
} }
.pop { /* The move-history header: leave (active) / export (finished) on the left, comms on the
padding: 9px 14px; right, icon-only. Sticky so it stays atop the scrolling move list. */
border: none; .hhead {
background: none; position: sticky;
color: var(--text); top: 0;
border-radius: var(--radius-sm); z-index: 1;
font-weight: 500; display: flex;
text-align: left; align-items: center;
} justify-content: space-between;
.pop:hover { padding: 4px 8px;
background: var(--surface-2); background: var(--surface-2);
border-bottom: 1px solid var(--border);
} }
.badge { .hicon {
position: relative;
background: none;
border: none;
color: var(--text);
font-size: 1.3rem;
line-height: 1;
padding: 4px 8px;
border-radius: var(--radius-sm);
}
.hicon:active {
background: var(--bg-elev);
}
/* The 🤝 add-friend control: pinned to the seat's right edge so the centred name and
score never shift; the TapConfirm inside swaps it for a fading ✅ on tap. */
.addfriend {
position: absolute;
right: 2px;
top: 50%;
transform: translateY(-50%);
font-size: 1.15rem;
}
.fico {
line-height: 1;
}
/* The unread-chat count: on the score bar's corner and on the history's 💬 icon. */
.cbadge {
position: absolute; position: absolute;
top: -3px;
right: -3px;
font-size: 0.68rem; font-size: 0.68rem;
font-weight: 700; font-weight: 700;
background: var(--accent); background: var(--accent);
@@ -1024,6 +1098,14 @@
line-height: 1.4; line-height: 1.4;
text-align: center; text-align: center;
} }
.sbadge {
top: 2px;
right: 4px;
}
.hicon .cbadge {
top: -1px;
right: -1px;
}
.loading { .loading {
text-align: center; text-align: center;
color: var(--text-muted); color: var(--text-muted);
+14 -14
View File
@@ -49,9 +49,9 @@ export const app = $state<{
/** Draw grid lines between board cells; off (default) is a gapless checkerboard. */ /** Draw grid lines between board cells; off (default) is a gapless checkerboard. */
boardLines: boolean; boardLines: boolean;
localeLocked: boolean; localeLocked: boolean;
/** Pending incoming friend requests + invitations, for the lobby badge. */ /** Pending incoming friend requests, for the lobby ⚙️ badge and the Settings Friends tab. */
notifications: number; notifications: number;
/** Unread chat-message count per game id, for the in-game menu/hamburger badge. */ /** Unread chat-message count per game id, for the in-game score-bar and 💬 badges. */
chatUnread: Record<string, number>; chatUnread: Record<string, number>;
}>({ }>({
ready: false, ready: false,
@@ -139,9 +139,12 @@ function openStream(): void {
reportOnline(); // a delivered event proves the gateway is reachable reportOnline(); // a delivered event proves the gateway is reachable
app.lastEvent = e; app.lastEvent = e;
if (e.kind === 'chat_message' && e.message.senderId !== app.session?.userId) { if (e.kind === 'chat_message' && e.message.senderId !== app.session?.userId) {
// While the player is on that game's chat screen, neither toast nor bump the unread. // While the player is in that game's comms hub (chat or dictionary tab), neither
const onChat = router.route.name === 'gameChat' && router.route.params.id === e.message.gameId; // toast nor bump the unread — the chat is a tap away and reloads on open.
if (!onChat) { const inComms =
(router.route.name === 'gameChat' || router.route.name === 'gameCheck') &&
router.route.params.id === e.message.gameId;
if (!inComms) {
if (e.message.kind !== 'nudge') { if (e.message.kind !== 'nudge') {
const gid = e.message.gameId; const gid = e.message.gameId;
app.chatUnread = { ...app.chatUnread, [gid]: (app.chatUnread[gid] ?? 0) + 1 }; app.chatUnread = { ...app.chatUnread, [gid]: (app.chatUnread[gid] ?? 0) + 1 };
@@ -186,9 +189,10 @@ function scheduleReconnect(): void {
} }
/** /**
* refreshNotifications recomputes the lobby badge count (incoming friend requests * refreshNotifications recomputes the badge count (incoming friend requests).
* plus open invitations). Authoritative poll, complementing the live 'notify' push. * Authoritative poll, complementing the live 'notify' push. Game invitations have
* Guests have no social surfaces, so it is a no-op for them. * their own lobby section, so they are not counted here. Guests have no social
* surfaces, so it is a no-op for them.
*/ */
export async function refreshNotifications(): Promise<void> { export async function refreshNotifications(): Promise<void> {
if (!app.session || app.profile?.isGuest) { if (!app.session || app.profile?.isGuest) {
@@ -196,11 +200,7 @@ export async function refreshNotifications(): Promise<void> {
return; return;
} }
try { try {
const [incoming, invitations] = await Promise.all([ app.notifications = (await gateway.friendsIncoming()).length;
gateway.friendsIncoming(),
gateway.invitationsList(),
]);
app.notifications = incoming.length + invitations.length;
} catch { } catch {
// Best-effort; leave the previous count on a transient failure. // Best-effort; leave the previous count on a transient failure.
} }
@@ -260,7 +260,7 @@ function syncTelegramChrome(): void {
/** /**
* syncTelegramSafeArea mirrors Telegram's content-safe-area top inset (the height its native * syncTelegramSafeArea mirrors Telegram's content-safe-area top inset (the height its native
* nav overlays the viewport in fullscreen) into the --tg-content-top CSS var and toggles a * nav overlays the viewport in fullscreen) into the --tg-content-top CSS var and toggles a
* `tg-fullscreen` class, so the header can drop below the nav and lift the menu into its * `tg-fullscreen` class, so the header can drop below the nav and centre the title in its
* band. Called on launch and on Telegram's safe-area / fullscreen change events. * band. Called on launch and on Telegram's safe-area / fullscreen change events.
*/ */
function syncTelegramSafeArea(): void { function syncTelegramSafeArea(): void {
+3 -4
View File
@@ -63,9 +63,9 @@ export const en = {
'game.skip': 'Skip', 'game.skip': 'Skip',
'game.shuffle': 'Shuffle', 'game.shuffle': 'Shuffle',
'game.hint': 'Hint', 'game.hint': 'Hint',
'game.history': 'History',
'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',
@@ -83,7 +83,6 @@ export const en = {
'game.wordIllegal': '“{word}” is not valid', 'game.wordIllegal': '“{word}” is not valid',
'game.complain': 'Disagree', 'game.complain': 'Disagree',
'game.complaintSent': 'Thanks, sent for review.', 'game.complaintSent': 'Thanks, sent for review.',
'game.confirm': 'Ok',
'game.check': 'Check', 'game.check': 'Check',
'game.checkWait': 'Please wait a moment.', 'game.checkWait': 'Please wait a moment.',
'game.noHintOptions': 'No options with your letters.', 'game.noHintOptions': 'No options with your letters.',
@@ -151,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}',
@@ -242,8 +242,7 @@ export const en = {
'game.exportGcg': 'Export GCG', 'game.exportGcg': 'Export GCG',
'game.gcgActiveOnly': 'Available once the game is finished.', 'game.gcgActiveOnly': 'Available once the game is finished.',
'game.requestSent': 'Request sent', 'game.addFriendShort': 'Add friend?',
'game.alreadyFriends': '✓ In friends',
'time.minutes': '{n} min', 'time.minutes': '{n} min',
'time.hours': '{n} h', 'time.hours': '{n} h',
+3 -4
View File
@@ -64,9 +64,9 @@ export const ru: Record<MessageKey, string> = {
'game.skip': 'Пас', 'game.skip': 'Пас',
'game.shuffle': 'Перемешать', 'game.shuffle': 'Перемешать',
'game.hint': 'Подсказка', 'game.hint': 'Подсказка',
'game.history': 'История',
'game.chat': 'Чат', 'game.chat': 'Чат',
'game.checkWord': 'Проверить слово', 'game.checkWord': 'Проверить слово',
'game.dictionary': 'Словарь',
'game.dropGame': 'Покинуть игру', 'game.dropGame': 'Покинуть игру',
'game.preview': 'Очков: {n}', 'game.preview': 'Очков: {n}',
'game.previewIllegal': 'Недопустимый ход', 'game.previewIllegal': 'Недопустимый ход',
@@ -84,7 +84,6 @@ export const ru: Record<MessageKey, string> = {
'game.wordIllegal': '«{word}» недопустимо', 'game.wordIllegal': '«{word}» недопустимо',
'game.complain': 'Не согласен', 'game.complain': 'Не согласен',
'game.complaintSent': 'Спасибо, отправлено на проверку.', 'game.complaintSent': 'Спасибо, отправлено на проверку.',
'game.confirm': 'Да',
'game.check': 'Проверить', 'game.check': 'Проверить',
'game.checkWait': 'Секунду, пожалуйста.', 'game.checkWait': 'Секунду, пожалуйста.',
'game.noHintOptions': 'Нет вариантов с вашим набором.', 'game.noHintOptions': 'Нет вариантов с вашим набором.',
@@ -152,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}',
@@ -243,8 +243,7 @@ export const ru: Record<MessageKey, string> = {
'game.exportGcg': 'Экспорт GCG', 'game.exportGcg': 'Экспорт GCG',
'game.gcgActiveOnly': 'Доступно после завершения игры.', 'game.gcgActiveOnly': 'Доступно после завершения игры.',
'game.requestSent': 'Запрос отправлен', 'game.addFriendShort': 'В друзья?',
'game.alreadyFriends': '✓ В друзьях',
'time.minutes': '{n} мин', 'time.minutes': '{n} мин',
'time.hours': '{n} ч', 'time.hours': '{n} ч',
+66
View File
@@ -0,0 +1,66 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createTapConfirm } from './tapconfirm';
describe('createTapConfirm', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('arms a window and reverts after the duration', () => {
const changes: boolean[] = [];
const c = createTapConfirm({ durationMs: 2000, onConfirm: () => {}, onChange: (x) => changes.push(x) });
c.arm();
expect(c.confirming).toBe(true);
expect(changes).toEqual([true]);
vi.advanceTimersByTime(1999);
expect(c.confirming).toBe(true);
vi.advanceTimersByTime(1);
expect(c.confirming).toBe(false);
expect(changes).toEqual([true, false]);
});
it('confirms within the window exactly once and stops the revert timer', () => {
const onConfirm = vi.fn();
const c = createTapConfirm({ durationMs: 2000, onConfirm });
c.arm();
c.confirm();
expect(onConfirm).toHaveBeenCalledTimes(1);
expect(c.confirming).toBe(false);
vi.advanceTimersByTime(5000); // the revert timer must not fire after a confirm
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it('ignores confirm when the window is not open', () => {
const onConfirm = vi.fn();
const c = createTapConfirm({ durationMs: 2000, onConfirm });
c.confirm();
expect(onConfirm).not.toHaveBeenCalled();
expect(c.confirming).toBe(false);
});
it('treats arm as idempotent while already confirming', () => {
const changes: boolean[] = [];
const c = createTapConfirm({ durationMs: 2000, onConfirm: () => {}, onChange: (x) => changes.push(x) });
c.arm();
c.arm();
expect(changes).toEqual([true]);
});
it('cancel closes the window without confirming', () => {
const onConfirm = vi.fn();
const c = createTapConfirm({ durationMs: 2000, onConfirm });
c.arm();
c.cancel();
expect(c.confirming).toBe(false);
vi.advanceTimersByTime(5000);
expect(onConfirm).not.toHaveBeenCalled();
});
it('dispose clears a pending timer without a revert callback', () => {
const changes: boolean[] = [];
const c = createTapConfirm({ durationMs: 2000, onConfirm: () => {}, onChange: (x) => changes.push(x) });
c.arm();
c.dispose();
vi.advanceTimersByTime(5000);
expect(changes).toEqual([true]);
});
});
+79
View File
@@ -0,0 +1,79 @@
/**
* tapconfirm holds the small state machine behind the "tap to confirm" controls: the
* first tap arms a confirmation window of durationMs (during which the view shows a
* fading ✅), a second tap within it confirms, and otherwise the window reverts. It is
* framework agnostic — a view observes onChange and renders accordingly — so the timing
* logic is unit-testable without a DOM. The pending timer is the only side effect.
*/
export interface TapConfirmOptions {
/** Length of the confirmation window in milliseconds. */
durationMs: number;
/** Invoked once when a confirmation lands inside the window. */
onConfirm: () => void;
/** Invoked whenever the confirming flag flips, so a view can react. */
onChange?: (confirming: boolean) => void;
}
/** TapConfirmController drives a single "tap to confirm" control. */
export interface TapConfirmController {
/** Whether the confirmation window is currently open. */
readonly confirming: boolean;
/** Arm the confirmation window; a no-op while it is already open. */
arm(): void;
/** Confirm within the window: fires onConfirm once and closes the window. A no-op
* while the window is closed. */
confirm(): void;
/** Close the window without confirming (e.g. the control was disabled). */
cancel(): void;
/** Clear any pending timer; the controller must not be reused afterwards. */
dispose(): void;
}
/**
* createTapConfirm builds a TapConfirmController whose confirmation window lasts
* durationMs. onConfirm fires once per confirmed window; onChange (when given)
* reports every flip of the confirming flag.
*/
export function createTapConfirm(opts: TapConfirmOptions): TapConfirmController {
let confirming = false;
let timer: ReturnType<typeof setTimeout> | null = null;
function clear(): void {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
}
function set(next: boolean): void {
if (confirming === next) return;
confirming = next;
opts.onChange?.(next);
}
return {
get confirming() {
return confirming;
},
arm() {
if (confirming) return;
set(true);
timer = setTimeout(() => {
timer = null;
set(false);
}, opts.durationMs);
},
confirm() {
if (!confirming) return;
clear();
set(false);
opts.onConfirm();
},
cancel() {
if (!confirming) return;
clear();
set(false);
},
dispose() {
clear();
},
};
}
-3
View File
@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import Screen from '../components/Screen.svelte';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import { app } from '../lib/app.svelte'; import { app } from '../lib/app.svelte';
import { aboutContent } from '../lib/aboutContent'; import { aboutContent } from '../lib/aboutContent';
@@ -10,7 +9,6 @@
const c = $derived(aboutContent(app.locale, AUTO_MATCH_HOURS)); const c = $derived(aboutContent(app.locale, AUTO_MATCH_HOURS));
</script> </script>
<Screen title={t('about.title')} back="/">
<div class="page"> <div class="page">
<h1>{c.title}</h1> <h1>{c.title}</h1>
<p> <p>
@@ -34,7 +32,6 @@
<p class="muted">{t('about.version', { v: version })}</p> <p class="muted">{t('about.version', { v: version })}</p>
</div> </div>
</Screen>
<style> <style>
.page { .page {
-3
View File
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte';
import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte'; import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
@@ -81,7 +80,6 @@
} }
</script> </script>
<Screen title={t('friends.title')} back="/">
<div class="page"> <div class="page">
{#if app.profile?.isGuest} {#if app.profile?.isGuest}
<p class="muted">{t('profile.guestLocked')}</p> <p class="muted">{t('profile.guestLocked')}</p>
@@ -162,7 +160,6 @@
{/if} {/if}
{/if} {/if}
</div> </div>
</Screen>
<style> <style>
.page { .page {
+7 -13
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Screen from '../components/Screen.svelte'; import Screen from '../components/Screen.svelte';
import Menu from '../components/Menu.svelte';
import TabBar from '../components/TabBar.svelte'; import TabBar from '../components/TabBar.svelte';
import { app, handleError, showToast } from '../lib/app.svelte'; import { app, handleError, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
@@ -24,7 +23,9 @@
games = (await gateway.gamesList()).games; games = (await gateway.gamesList()).games;
if (!guest) { if (!guest) {
[invitations, incoming] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]); [invitations, incoming] = await Promise.all([gateway.invitationsList(), gateway.friendsIncoming()]);
app.notifications = invitations.length + incoming.length; // The ⚙️ badge counts only what lives behind it (incoming friend requests);
// invitations surface in their own lobby section above.
app.notifications = incoming.length;
} }
setLobby({ games, invitations, incoming }); setLobby({ games, invitations, incoming });
} catch (e) { } catch (e) {
@@ -116,13 +117,6 @@
} }
} }
const menuItems = $derived([
...(guest ? [] : [{ label: t('lobby.friends'), onclick: () => navigate('/friends'), badge: incoming.length }]),
{ label: t('lobby.profile'), onclick: () => navigate('/profile') },
{ label: t('lobby.settings'), onclick: () => navigate('/settings') },
{ label: t('lobby.about'), onclick: () => navigate('/about') },
]);
async function acceptInvite(inv: Invitation) { async function acceptInvite(inv: Invitation) {
try { try {
const r = await gateway.invitationAccept(inv.id); const r = await gateway.invitationAccept(inv.id);
@@ -151,10 +145,6 @@
</script> </script>
<Screen title={app.profile?.displayName ?? t('app.title')}> <Screen title={app.profile?.displayName ?? t('app.title')}>
{#snippet menu()}
<Menu items={menuItems} badge={app.notifications} />
{/snippet}
<div class="lobby"> <div class="lobby">
{#if invitations.length} {#if invitations.length}
<section> <section>
@@ -238,6 +228,10 @@
<button class="tab" onclick={() => showToast(t('lobby.soon'))}> <button class="tab" onclick={() => showToast(t('lobby.soon'))}>
<span class="sq">🏆</span><span class="lbl">{t('lobby.tournaments')}</span> <span class="sq">🏆</span><span class="lbl">{t('lobby.tournaments')}</span>
</button> </button>
<button class="tab" onclick={() => navigate('/settings')}>
<span class="sq">⚙️{#if app.notifications > 0}<span class="badge">{app.notifications}</span>{/if}</span>
<span class="lbl">{t('lobby.settings')}</span>
</button>
</TabBar> </TabBar>
{/snippet} {/snippet}
</Screen> </Screen>
-3
View File
@@ -1,7 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Modal from '../components/Modal.svelte'; import Modal from '../components/Modal.svelte';
import Screen from '../components/Screen.svelte';
import { app, applyLinkResult, handleError, logout, showToast } from '../lib/app.svelte'; import { app, applyLinkResult, handleError, logout, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
@@ -160,7 +159,6 @@
} }
</script> </script>
<Screen title={t('profile.title')} back="/">
<div class="page"> <div class="page">
{#if app.profile} {#if app.profile}
{@const p = app.profile} {@const p = app.profile}
@@ -262,7 +260,6 @@
</div> </div>
</Modal> </Modal>
{/if} {/if}
</Screen>
<style> <style>
.page { .page {
-3
View File
@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import Screen from '../components/Screen.svelte';
import { import {
app, app,
setBoardLabels, setBoardLabels,
@@ -28,7 +27,6 @@
}; };
</script> </script>
<Screen title={t('settings.title')} back="/">
<div class="page"> <div class="page">
{#if !insideTelegram()} {#if !insideTelegram()}
<section> <section>
@@ -85,7 +83,6 @@
</label> </label>
</section> </section>
</div> </div>
</Screen>
<style> <style>
.page { .page {
+64
View File
@@ -0,0 +1,64 @@
<script lang="ts">
import Screen from '../components/Screen.svelte';
import TabBar from '../components/TabBar.svelte';
import Settings from './Settings.svelte';
import Profile from './Profile.svelte';
import Friends from './Friends.svelte';
import About from './About.svelte';
import { app } from '../lib/app.svelte';
import { t, type MessageKey } from '../lib/i18n/index.svelte';
// The Settings hub: a single nav bar + bottom tab bar hosting the Settings / Profile /
// Friends / About bodies. Tabs switch in place (no navigation), so the back control
// always returns to the lobby. Guests have no social surface, so the Friends tab hides.
type SettingsTab = 'settings' | 'profile' | 'friends' | 'about';
let { initialTab = 'settings' }: { initialTab?: SettingsTab } = $props();
const guest = $derived(app.profile?.isGuest ?? true);
// The active tab is seeded once from the entry route's tab and then owned locally;
// the hub is keyed by route in App.svelte, so initialTab is constant for its lifetime.
// svelte-ignore state_referenced_locally
let tab = $state<SettingsTab>(initialTab);
// A guest who deep-links to the Friends tab falls back to Settings.
$effect(() => {
if (guest && tab === 'friends') tab = 'settings';
});
const titleKey: Record<SettingsTab, MessageKey> = {
settings: 'settings.title',
profile: 'profile.title',
friends: 'friends.title',
about: 'about.title',
};
</script>
<Screen title={t(titleKey[tab])} back="/">
{#if tab === 'settings'}
<Settings />
{:else if tab === 'profile'}
<Profile />
{:else if tab === 'friends'}
<Friends />
{:else}
<About />
{/if}
{#snippet tabbar()}
<TabBar>
<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')}>
<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')}>
<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')}>
<span class="sq" aria-hidden="true"></span><span class="lbl">{t('about.tab')}</span>
</button>
</TabBar>
{/snippet}
</Screen>