UI: tab-bar navigation — drop the hamburger #39
@@ -24,6 +24,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
|
||||
| R5 | Bundle slimming | 6 | **done** |
|
||||
| R6 | Refactor + docs reconciliation + de-staging | 7 | **done** |
|
||||
| R7 | Final stress run + tuning | 9b | **done** |
|
||||
| UI | Tab-bar navigation redesign (drop the hamburger) | owner ad-hoc | **done** |
|
||||
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
|
||||
|
||||
## Key findings (these reshaped the raw list — read before starting a phase)
|
||||
@@ -413,3 +414,21 @@ Then Stage 18.
|
||||
more cores per node (recorded in the prod-sizing recommendation).
|
||||
- **No schema change → no contour DB wipe.** Bake-back: `loadtest/REPORT-R7.md` (new), `loadtest/README.md`,
|
||||
`docs/TESTING.md`, the telemetry/observability section of `docs/ARCHITECTURE.md`, the repo-layout line in `CLAUDE.md`.
|
||||
|
||||
- **UI — Tab-bar navigation redesign** (owner ad-hoc, not on the raw TODO list): drop the hamburger
|
||||
`Menu.svelte` everywhere (it fought the Telegram-fullscreen layout, where it had to be re-centred).
|
||||
- **Locked decisions (interview):** the in-Settings sub-nav is a **bottom TabBar with the active tab
|
||||
highlighted** (icon-only); **Export GCG** moves to the left slot of the move-history header (free in a
|
||||
finished game, where 🏁 *leave* does not apply); the lobby **⚙️ badge counts incoming friend requests
|
||||
only** (invitations keep their own lobby section); unread chat is badged on **the score bar and the 💬**.
|
||||
- **What shipped:** a ⚙️ **Settings hub** (`screens/SettingsHub.svelte`) over the existing
|
||||
Settings/Profile/Friends/About bodies and an in-game **comms hub** (`game/CommsHub.svelte`) over
|
||||
chat + dictionary, both with in-place tabs and a fixed back target; the game's menu items relocate into
|
||||
the open move history (🏁 leave / 📤 export + 💬 comms header) and the player cards (🤝 add-friend); a
|
||||
shared **TapConfirm** (`components/TapConfirm.svelte`, `lib/tapconfirm.ts`) — tap → fading ✅ → tap —
|
||||
replaces the Skip/Hint press-and-hold popovers and drives the add-friend confirm. Fixed the move-history
|
||||
"jump" bug (the slid board is now inert and the stage can't scroll, so a swipe up genuinely closes it).
|
||||
`Menu.svelte` + `HoldConfirm.svelte` removed.
|
||||
- **No schema/wire change → no contour DB wipe.** Bake-back: `docs/UI_DESIGN.md`, `docs/FUNCTIONAL.md`
|
||||
(+`_ru`). Regression gate: UI `check` + unit (`tapconfirm`) + build + bundle budget + e2e (Chromium &
|
||||
WebKit), all green.
|
||||
|
||||
+11
-7
@@ -68,7 +68,9 @@ account is kept and the guest's games move into it. A merge is blocked only whil
|
||||
two accounts share a game still in progress.
|
||||
|
||||
### Lobby & matchmaking
|
||||
Bottom tab menu: **my games**, **profile**. The **my games** list groups games into three
|
||||
The lobby lists **my games** and offers a bottom tab bar — new game, statistics, and a
|
||||
**⚙️ settings** tab opening the settings hub (settings, profile, friends, about). The
|
||||
**my games** list groups games into three
|
||||
sections — *your turn*, *opponent's turn* and *finished* (empty sections are hidden) — and
|
||||
orders them so the games awaiting your move come first, the longest-waiting on top, while
|
||||
opponent-turn and finished games are most-recent first; it renders as a compact,
|
||||
@@ -128,18 +130,20 @@ digits, valid for twelve hours), or send a **request to someone you have played
|
||||
with** — they accept, ignore it (a request lapses after thirty days and can then be
|
||||
re-sent), or decline (a decline blocks further requests from you until they hand you
|
||||
a code). Cancelling your own pending request withdraws it; unfriending removes the
|
||||
friendship. In a game, an **add to friends** item for each opponent mirrors the live
|
||||
relationship: it reads *request sent* (disabled) while a request is pending or was
|
||||
declined, and *in friends* once accepted — updating in place the moment the opponent
|
||||
answers, and staying correct across reloads. Block globally — switch off incoming chat
|
||||
friendship. In a game, each opponent's score card carries an **add-to-friends 🤝** control (while the
|
||||
move history is open) that mirrors the live relationship: it confirms with a tap on a fading
|
||||
✅ (the card reads *Add friend?* while confirming), goes **disabled** while a request is
|
||||
pending or was declined, and **disappears** once you are friends — updating in place the
|
||||
moment the opponent answers, and staying correct across reloads. Block globally — switch off incoming chat
|
||||
and/or friend requests — and block individual players (a per-user block hides that
|
||||
person's chat and stops requests and game invitations both ways; it also ends any
|
||||
existing friendship). Per-game chat is for quick reactions: messages are short
|
||||
(up to 60 characters) and may not contain links, email addresses or phone numbers,
|
||||
even disguised. Nudge the player whose turn is awaited at most once per hour (the
|
||||
nudge is part of the game chat); the out-of-app push is delivered via the platform.
|
||||
Chat and the word-check tool open as their **own screens** (with a back to the game), and a
|
||||
new chat message raises an **unread badge** on the game's menu until the chat is opened.
|
||||
Chat and the word-check tool share one **comms screen** with **💬 chat** / **🔎 dictionary**
|
||||
tabs, reached from the 💬 in the move-history header (with a back to the game); a new chat
|
||||
message raises an **unread badge** on the game's score bar and the 💬 until the chat is opened.
|
||||
|
||||
### Profile & settings
|
||||
Edit the display name (letters joined by a single space / "." / "_" separator, with an
|
||||
|
||||
+11
-7
@@ -70,7 +70,9 @@ nudge) приходят от бота **этой партии** — по язы
|
||||
запрещено, только пока у аккаунтов есть общая незавершённая игра.
|
||||
|
||||
### Лобби и подбор
|
||||
Нижнее tab-меню: **мои игры**, **профиль**. Список **мои игры** разбит на три секции —
|
||||
В лобби — список **мои игры** и нижний tab-bar (новая игра, статистика и вкладка
|
||||
**⚙️ настройки**, открывающая хаб настроек — настройки, профиль, друзья, о программе).
|
||||
Список **мои игры** разбит на три секции —
|
||||
*твой ход*, *ход соперника* и *завершённые* (пустые секции скрыты) — и упорядочен так,
|
||||
что игры, ждущие твоего хода, идут первыми, дольше всего ждущие сверху, а игры на ходу
|
||||
соперника и завершённые — самые свежие сверху; отображается компактным списком с
|
||||
@@ -132,10 +134,11 @@ nudge) приходят от бота **этой партии** — по язы
|
||||
тому, с кем вы играли** — он принимает, игнорирует (заявка истекает через тридцать
|
||||
дней, после чего её можно отправить снова) или отклоняет (отказ блокирует ваши
|
||||
повторные заявки, пока он сам не передаст вам код). Отмена своей висящей заявки
|
||||
снимает её; удаление расторгает дружбу. В партии пункт меню **в друзья** для каждого
|
||||
соперника отражает живое отношение: он показывает *заявка отправлена* (неактивный),
|
||||
пока заявка висит или была отклонена, и *в друзьях* после принятия — обновляясь на месте
|
||||
в момент ответа соперника и оставаясь верным после перезагрузки. Глобальная блокировка — отключить входящие
|
||||
снимает её; удаление расторгает дружбу. В партии карточка счёта каждого соперника несёт контрол
|
||||
**в друзья 🤝** (при открытой истории ходов), отражающий живое отношение: он подтверждается
|
||||
тапом по затухающей ✅ (карточка показывает *В друзья?* во время подтверждения), становится
|
||||
**неактивным**, пока заявка висит или была отклонена, и **исчезает** после принятия —
|
||||
обновляясь на месте в момент ответа соперника и оставаясь верным после перезагрузки. Глобальная блокировка — отключить входящие
|
||||
чат и/или заявки —
|
||||
и блокировка конкретного игрока (пер-юзер блок скрывает его чат и запрещает заявки
|
||||
и приглашения в игру в обе стороны, а также расторгает уже имеющуюся дружбу). Чат
|
||||
@@ -143,8 +146,9 @@ nudge) приходят от бота **этой партии** — по язы
|
||||
содержать ссылок, email и телефонов, даже завуалированных. Nudge ожидаемого
|
||||
соперника — не чаще раза в час (nudge — часть игрового чата); внеприложенческий
|
||||
push доставляется через платформу.
|
||||
Чат и инструмент проверки слова открываются **отдельными экранами** (с кнопкой «назад» в
|
||||
партию), а новое сообщение в чате рисует **бейдж непрочитанного** на меню партии до открытия чата.
|
||||
Чат и инструмент проверки слова — один **экран связи** со вкладками **💬 чат** / **🔎 словарь**,
|
||||
открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию); новое сообщение рисует
|
||||
**бейдж непрочитанного** на строке счёта партии и на 💬 до открытия чата.
|
||||
|
||||
### Профиль и настройки
|
||||
Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
|
||||
|
||||
+50
-23
@@ -23,16 +23,25 @@ Login uses `Screen`.
|
||||
|
||||
- **Back**: a thin, compact `<` drawn from two rotated CSS borders (`Header.svelte`
|
||||
`.chev`) — lighter than a glyph.
|
||||
- **Hamburger**: a CSS three-bar (`Menu.svelte`), deliberately larger; opens a dropdown
|
||||
of items (lobby: Friends/Profile/Settings/About; game: History/Chat/Check word, plus
|
||||
*Export GCG* on a finished game and *Add to friends* per opponent, then Drop game). A
|
||||
red count **badge** rides the hamburger (and the lobby *Friends* item) for pending
|
||||
incoming friend requests + invitations; the same dot style serves any future
|
||||
notification count.
|
||||
- **No hamburger**: navigation is entirely bottom tab bars (the former `Menu.svelte`
|
||||
dropdown is gone — it fought the Telegram-fullscreen layout, where it had to be re-centred).
|
||||
Destinations beyond the lobby live behind **hub screens** reached from a tab: a ⚙️
|
||||
**Settings hub** (`screens/SettingsHub.svelte`, the lobby's ⚙️ tab) and an in-game
|
||||
**comms hub** (`game/CommsHub.svelte`, the history's 💬). A hub owns one nav bar + one
|
||||
bottom tab bar whose tabs switch its body **in place** (state, not navigation), so the
|
||||
back control always returns to the hub's parent (Settings → lobby, comms → game). Settings
|
||||
hub tabs: ⚙️ Settings / 👤 Profile / 🤝 Friends / ℹ️ About (Friends hidden for guests);
|
||||
comms hub tabs: 💬 Chat / 🔎 Dictionary (Dictionary only while the game is active). The
|
||||
routes `/settings|/profile|/friends|/about` and `/game/:id/{chat,check}` survive as hub
|
||||
entry points (so a Telegram friend-code deep-link still lands on the Friends tab).
|
||||
- **Tab bar** (`TabBar.svelte`): square, borderless, evenly distributed buttons — a large
|
||||
emoji icon over a tiny truncated label. A press highlights a rounded **square** behind
|
||||
the icon (slightly larger than it) until release; spacing keeps adjacent labels from
|
||||
touching. No text selection on nav / tab-bar / buttons (`user-select: none`).
|
||||
emoji icon over a tiny truncated label (the icon is `aria-hidden`, so the label names the
|
||||
button). A press highlights a rounded **square** behind the icon; a hub's **selected** tab
|
||||
stays highlighted (a filled
|
||||
square with an accent underline). A red count **badge** rides the icon's corner — on the
|
||||
lobby ⚙️ tab and the hub's 🤝 Friends tab for pending incoming friend requests (invitations
|
||||
keep their own lobby section), and on the Hint tab for the remaining count. No text
|
||||
selection on nav / tab-bar / buttons (`user-select: none`).
|
||||
- **Screen transitions** (`App.svelte`): navigation slides directionally — a
|
||||
screen entered from the lobby flies in from the right; returning to the lobby reveals it
|
||||
from the left (back). Transitions are local (so they do not play on first load) and
|
||||
@@ -71,8 +80,9 @@ Login uses `Screen`.
|
||||
hovering a dragged tile never jumps the board. On touch the first tile placement auto-zooms
|
||||
in centred on the target, and **holding a dragged tile over a cell ~1 s** auto-zooms there
|
||||
the first time. The swipe-to-open-history gesture stays dropped (it fought native scroll);
|
||||
history opens from the menu or a tap on the players plaque (below). A **hint** auto-zooms
|
||||
centred on the hint's placement, not the top-left.
|
||||
history opens on a **tap of the score bar** and closes on a tap or an **upward swipe** of
|
||||
the then-inert board (below). A **hint** auto-zooms centred on the hint's placement, not
|
||||
the top-left.
|
||||
- **Placing & recall** (`Game.svelte`): a rack tile is placed by tap-then-tap or by
|
||||
dragging it onto a cell; while a dragged tile is carried over the board, the aimed-at empty
|
||||
cell is **highlighted as a drop target** (an accent ring). A pending tile is taken back by a
|
||||
@@ -81,11 +91,21 @@ Login uses `Screen`.
|
||||
recalled tile returns to its original rack slot.
|
||||
- **Players plaque & history** (`Game.svelte`): the seats above the board share
|
||||
the width evenly; the seat whose turn it is is **raised** (a drop shadow on its sides)
|
||||
while the others read **sunk in** (an inset shadow). A tap anywhere on the plaque toggles
|
||||
while the others read **sunk in** (an inset shadow). A tap on the plaque toggles
|
||||
the **move history** — a fixed-height slide-down drawer whose bottom border (and its
|
||||
shadow) pins to the board as the board slides down, instead of tracking the table as
|
||||
moves accumulate; its scrollbar gutter is reserved so the centred word column does not
|
||||
jitter. A move's row lists every word it formed (the main word first).
|
||||
jitter. A move's row lists every word it formed (the main word first). While the history
|
||||
is open the **slid board is inert** and the stage cannot scroll, so the whole board reads
|
||||
as a **tap-or-swipe-up-to-close** surface — closing genuinely clears the open state (it no
|
||||
longer merely scrolls the board out of view, which used to leave a stale-open state that
|
||||
made a follow-up plaque tap "jump" the board). The drawer carries its own **header**: a 🏁
|
||||
**Drop game** (or 📤 **Export GCG** on a finished game) at the left and the comms 💬
|
||||
(badged with unread chat) at the right, icon-only. Each **opponent**'s card also gains a
|
||||
🤝 **add-friend** control (non-guests; hidden once a friend, disabled once requested) that
|
||||
confirms via the fading-✅ tap, swapping the card's score for "Add friend?" while armed
|
||||
(see Controls); the name and score stay centred — the 🤝 is pinned to the card's edge.
|
||||
Unread chat is also badged on the score bar itself, so it shows with the history closed.
|
||||
- **Vertical fit & keyboard**: when the game does not fit the viewport, only the
|
||||
board area scrolls vertically (`Screen` `column` mode; the score bar, status, rack and tab
|
||||
bar stay fixed), while zoom keeps its own scroll. The check-word dialog opens in
|
||||
@@ -107,9 +127,12 @@ Login uses `Screen`.
|
||||
|
||||
## Controls
|
||||
|
||||
- **HoldConfirm** (`components/HoldConfirm.svelte`): the shared press-and-hold control. A
|
||||
short tap opens a small popover above the button; a ~0.7 s hold runs the primary action
|
||||
immediately. Used by the **Skip** and **Hint** tabs (each confirmed by an **Ok ✅** popover).
|
||||
- **TapConfirm** (`components/TapConfirm.svelte`, logic in `lib/tapconfirm.ts`): the shared
|
||||
tap-to-confirm control. A first tap arms a ~2 s window showing a **fading ✅** (no fade
|
||||
under reduce-motion, but the window still holds); a tap on the ✅ within it confirms,
|
||||
otherwise it reverts. Used by the **Skip** and **Hint** tabs (the icon morphs to ✅, no
|
||||
label — replacing the old press-and-hold popover) and the in-game **add-friend 🤝**. The
|
||||
**Drop game** action keeps its `Modal` confirmation (a destructive, less-frequent action).
|
||||
- **MakeMove / Reset**: when ≥1 tile is pending the rack collapses its used slots
|
||||
and shifts left, a **borderless ✅ icon button** (styled like a tab, not a filled accent
|
||||
button) beside the rack commits the move — no popover, and disabled while the pending word
|
||||
@@ -140,7 +163,11 @@ IV 🏅; active games show Your move 🟢 / Opponent's move ⏳; invitations use
|
||||
|
||||
## Social, account & history surfaces
|
||||
|
||||
- **Friends** (`screens/Friends.svelte`, from the lobby menu): an "add a friend" block
|
||||
- **Settings hub** (`screens/SettingsHub.svelte`, the lobby ⚙️ tab): one nav bar + a bottom
|
||||
tab bar over four bodies — ⚙️ Settings, 👤 Profile, 🤝 Friends, ℹ️ About — switched in
|
||||
place; back always returns to the lobby. Guests see all but Friends. The lobby ⚙️ badge and
|
||||
the 🤝 tab badge both show the pending incoming-friend-request count.
|
||||
- **Friends** (`screens/Friends.svelte`, the Settings hub's 🤝 tab): an "add a friend" block
|
||||
pairing a code **input** with a **Show my code** action that reveals a large 6-digit
|
||||
code + its expiry; then the incoming **requests** (Accept / Decline), the **friends**
|
||||
list (Remove / Block), and a **blocked** list (Unblock). Durable accounts only — a
|
||||
@@ -163,12 +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
|
||||
overflowing in Safari.
|
||||
- **History / GCG**: the in-game slide-down history gains the running total per move;
|
||||
*Export GCG* shares or downloads the `.gcg` file and appears only once the game is
|
||||
finished.
|
||||
- **Finished game**: the board keeps no last-word highlight and no zoom; the menu drops
|
||||
*Check word* and *Drop game*; and the footer (rack + tab bar) is **drawn but inert**
|
||||
(greyed, non-interactive) rather than hidden, so the layout does not jump. Chat
|
||||
send / nudge are the ⬆️ / 🛎️ icons.
|
||||
*Export GCG* (the 📤 in the history header) shares or downloads the `.gcg` file and
|
||||
appears only once the game is finished.
|
||||
- **Finished game**: the board keeps no last-word highlight and no zoom; the history header
|
||||
offers *Export GCG* (not *Drop game*) and the comms hub hides the 🔎 *Dictionary* tab; and
|
||||
the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than
|
||||
hidden, so the layout does not jump. Chat send / nudge are the ⬆️ / 🛎️ icons.
|
||||
|
||||
## Caveat
|
||||
|
||||
|
||||
+53
-24
@@ -10,9 +10,8 @@ async function openGame(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: /guest/i }).click();
|
||||
await page.getByRole('button', { name: /Ann/ }).click(); // the seeded active game vs Ann
|
||||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||
// Wait for the screen-slide transition to settle so only the game pane remains;
|
||||
// until it does, the leaving lobby pane's header (its menu button) is also in the
|
||||
// DOM, which would make shared locators like .burger ambiguous.
|
||||
// Wait for the screen-slide transition to settle so only the game pane remains; until it
|
||||
// does, the leaving lobby pane is also in the DOM, which would make shared locators ambiguous.
|
||||
await expect(page.locator('.pane')).toHaveCount(1);
|
||||
}
|
||||
|
||||
@@ -115,17 +114,38 @@ test('shuffle reorders the rack but keeps the same tiles', async ({ page }) => {
|
||||
expect([...after].sort()).toEqual([...before].sort());
|
||||
});
|
||||
|
||||
test('history slides the board down and closes on a board tap', async ({ page }) => {
|
||||
test('history slides the board down on a score tap and closes on a board tap', async ({ page }) => {
|
||||
await openGame(page);
|
||||
await page.locator('.burger').click();
|
||||
await page.locator('.dropdown button').nth(0).click(); // History
|
||||
await page.locator('.scoreboard').click(); // tapping the score bar opens the history
|
||||
await expect(page.locator('.history')).toBeVisible();
|
||||
await expect(page.locator('.boardwrap.slid')).toBeVisible();
|
||||
|
||||
await page.locator('.boardwrap').click(); // tapping the board closes it
|
||||
// Tap the (now inert) board's visible top strip below the history to close it.
|
||||
await page.locator('.boardwrap').click({ position: { x: 30, y: 12 } });
|
||||
await expect(page.locator('.history')).toBeHidden();
|
||||
});
|
||||
|
||||
test('history: a swipe-up close does not make a follow-up score tap jump', async ({ page }) => {
|
||||
await openGame(page);
|
||||
await page.locator('.scoreboard').click();
|
||||
await expect(page.locator('.boardwrap.slid')).toBeVisible();
|
||||
|
||||
// Swipe up on the inert board to close it. Dispatched on the board so the gesture is
|
||||
// engine-deterministic — a mouse swipe over a pointer-events:none child is simulated
|
||||
// inconsistently across engines, whereas a real touch swipe lands the same way this does.
|
||||
const bw = page.locator('.boardwrap');
|
||||
const box = (await bw.boundingBox())!;
|
||||
const cx = box.x + box.width / 2;
|
||||
await bw.dispatchEvent('pointerdown', { clientX: cx, clientY: 500, bubbles: true });
|
||||
await bw.dispatchEvent('pointermove', { clientX: cx, clientY: 450, bubbles: true });
|
||||
await expect(page.locator('.history')).toBeHidden();
|
||||
|
||||
// A score tap now cleanly reopens the history — the stale-open "jump" no longer happens.
|
||||
await page.locator('.scoreboard').click();
|
||||
await expect(page.locator('.history')).toBeVisible();
|
||||
await expect(page.locator('.boardwrap.slid')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Draw opens the exchange dialog and confirms a selection', async ({ page }) => {
|
||||
await openGame(page);
|
||||
await page.locator('button:has-text("🔄")').click(); // Draw tab
|
||||
@@ -137,10 +157,22 @@ test('Draw opens the exchange dialog and confirms a selection', async ({ page })
|
||||
await expect(page.locator('.exch')).toBeHidden();
|
||||
});
|
||||
|
||||
test('pass confirms with a tap on the fading ✅ instead of a popup', async ({ page }) => {
|
||||
await openGame(page);
|
||||
const pass = page.getByRole('button', { name: 'Skip' }); // the 🥺 tab (aria-label)
|
||||
await expect(pass).toBeEnabled();
|
||||
await pass.click(); // arm: 🥺 -> a fading ✅
|
||||
await pass.click(); // tap the ✅ to confirm within the window
|
||||
// The pass hands the turn over, so the control goes inert.
|
||||
await expect(pass).toBeDisabled();
|
||||
});
|
||||
|
||||
test('check-word sanitises input and shows a verdict', async ({ page }) => {
|
||||
await openGame(page);
|
||||
await page.locator('.burger').click();
|
||||
await page.locator('.dropdown button').nth(2).click(); // Check word
|
||||
await page.locator('.scoreboard').click(); // open the history
|
||||
await page.getByRole('button', { name: 'Chat' }).click(); // 💬 -> comms hub
|
||||
await expect(page.locator('.pane')).toHaveCount(1);
|
||||
await page.getByRole('button', { name: 'Dictionary' }).click(); // 🔎 -> dictionary tab
|
||||
|
||||
const input = page.locator('.check input');
|
||||
await input.fill('qz9!a'); // digits/punctuation dropped, letters upper-cased
|
||||
@@ -152,8 +184,8 @@ test('check-word sanitises input and shows a verdict', async ({ page }) => {
|
||||
|
||||
test('dropping the game ends it and shows the result', async ({ page }) => {
|
||||
await openGame(page);
|
||||
await page.locator('.burger').click();
|
||||
await page.getByRole('button', { name: 'Drop game' }).click(); // robust against menu growth
|
||||
await page.locator('.scoreboard').click(); // open the history
|
||||
await page.getByRole('button', { name: 'Drop game' }).click(); // 🏁 in the history header
|
||||
await page.locator('button.danger').click(); // confirm in the modal
|
||||
await expect(page.locator('.status .over')).toBeVisible();
|
||||
});
|
||||
@@ -181,26 +213,23 @@ test('a placed tile drags from one board cell to another (relocation)', async ({
|
||||
expect(to).not.toBe(from);
|
||||
});
|
||||
|
||||
test('chat and word-check open as their own screens and back to the game', async ({ page }) => {
|
||||
test('comms hub: chat and dictionary share a screen, back returns to the game', async ({ page }) => {
|
||||
await openGame(page);
|
||||
|
||||
await page.locator('.burger').click();
|
||||
await page.getByRole('button', { name: /^Chat$/ }).click();
|
||||
await page.locator('.scoreboard').click(); // open the history
|
||||
await page.getByRole('button', { name: 'Chat' }).click(); // 💬 in the history header
|
||||
await expect(page).toHaveURL(/\/game\/g1\/chat$/);
|
||||
await expect(page.locator('.pane')).toHaveCount(1); // let the slide transition settle
|
||||
await expect(page.locator('.chat')).toBeVisible();
|
||||
// The outgoing game header and the incoming chat header both carry a .back mid-slide; wait
|
||||
// for the game's to unmount so the click targets a single, settled button.
|
||||
|
||||
// The Dictionary tab switches in place (same screen, no navigation).
|
||||
await page.getByRole('button', { name: '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 page.locator('.back').click(); // header back chevron returns to the game
|
||||
await page.locator('.back').click();
|
||||
await expect(page).toHaveURL(/\/game\/g1$/);
|
||||
await expect(page.locator('.pane')).toHaveCount(1);
|
||||
|
||||
await page.locator('.burger').click();
|
||||
await page.getByRole('button', { name: /Check word/ }).click();
|
||||
await expect(page).toHaveURL(/\/game\/g1\/check$/);
|
||||
await expect(page.locator('.pane')).toHaveCount(1);
|
||||
await expect(page.locator('.check input')).toBeVisible();
|
||||
});
|
||||
|
||||
test('the board-label mode in Settings changes the on-board labels', async ({ page }) => {
|
||||
|
||||
+67
-42
@@ -10,9 +10,18 @@ async function loginLobby(page: Page): Promise<void> {
|
||||
await expect(page.getByText('Your turn')).toBeVisible();
|
||||
}
|
||||
|
||||
async function openFriends(page: Page): Promise<void> {
|
||||
await page.locator('.burger').first().click();
|
||||
await page.getByRole('button', { name: /Friends/ }).click();
|
||||
// The Settings hub (the lobby ⚙️ tab) hosts Settings / Profile / Friends / About as in-place
|
||||
// tabs; the back control always returns to the lobby. Tabs are icon-only with an aria-label.
|
||||
async function openSettingsTab(page: Page, tab: 'Profile' | 'Friends' | '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 }) => {
|
||||
@@ -50,10 +59,28 @@ test('stats screen shows the metrics', async ({ page }) => {
|
||||
await expect(page.getByText('Best move')).toBeVisible();
|
||||
});
|
||||
|
||||
test('settings hub: tabs switch in place and back returns to the lobby', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
await page.getByRole('button', { name: /Settings/ }).click(); // lobby ⚙️ tab
|
||||
await expect(page.locator('.pane')).toHaveCount(1);
|
||||
await expect(page.locator('.seg').first()).toBeVisible(); // default Settings tab
|
||||
|
||||
// Switching tabs is in place (no navigation): the Friends body appears, still one pane.
|
||||
await page.getByRole('button', { name: 'Friends', exact: true }).click();
|
||||
await expect(page.getByText('Friend requests')).toBeVisible();
|
||||
await expect(page.locator('.pane')).toHaveCount(1);
|
||||
|
||||
await page.getByRole('button', { name: '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 }) => {
|
||||
await loginLobby(page);
|
||||
await page.locator('.burger').first().click();
|
||||
await page.getByRole('button', { name: /Profile/ }).click();
|
||||
await openProfile(page);
|
||||
await page.locator('.edit input').first().fill('Kaya Test');
|
||||
await page.getByRole('button', { name: /^Save$/ }).click();
|
||||
await expect(page.locator('.name')).toHaveText('Kaya Test');
|
||||
@@ -61,29 +88,33 @@ test('profile edit saves a new display name', async ({ page }) => {
|
||||
|
||||
test('GCG export appears only for a finished game', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
// The finished game vs Kaya exposes the export; the menu carries the item.
|
||||
// The finished game vs Kaya exposes export 📤 in the history header.
|
||||
await page.getByRole('button', { name: /Kaya/ }).click();
|
||||
await page.locator('.burger').first().click();
|
||||
await expect(page.getByRole('button', { name: /Export GCG/ })).toBeVisible();
|
||||
await page.locator('.scoreboard').click(); // open the history
|
||||
await expect(page.getByRole('button', { name: 'Export GCG' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('GCG export is hidden for an active game', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
await page.getByRole('button', { name: /Ann/ }).click();
|
||||
await page.locator('.burger').first().click();
|
||||
await expect(page.getByRole('button', { name: /Export GCG/ })).toHaveCount(0);
|
||||
await page.locator('.scoreboard').click(); // open the history (shows 🏁 leave, not 📤 export)
|
||||
await expect(page.getByRole('button', { name: 'Export GCG' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('finished game draws an inert footer and trims the live-only menu', async ({ page }) => {
|
||||
test('finished game draws an inert footer and trims live-only controls', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
await page.getByRole('button', { name: /Kaya/ }).click(); // finished game vs Kaya
|
||||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||
// The footer (tab bar) is drawn but its controls are disabled in a finished game.
|
||||
await expect(page.locator('.tab').first()).toBeDisabled();
|
||||
// The menu drops Check word and Drop game once the game is over.
|
||||
await page.locator('.burger').first().click();
|
||||
await expect(page.getByRole('button', { name: 'Check word' })).toHaveCount(0);
|
||||
// The history header offers Export GCG, not Drop game, once the game is over.
|
||||
await page.locator('.scoreboard').click();
|
||||
await expect(page.getByRole('button', { name: 'Export GCG' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Drop game' })).toHaveCount(0);
|
||||
// The comms hub offers Chat only — the Dictionary tab is hidden for a finished game.
|
||||
await page.getByRole('button', { name: 'Chat' }).click(); // 💬
|
||||
await expect(page.locator('.pane')).toHaveCount(1);
|
||||
await expect(page.getByRole('button', { name: 'Dictionary' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('lobby: hiding a finished game removes it (kebab → ❌), keeping the others', async ({ page }) => {
|
||||
@@ -114,10 +145,11 @@ test('lobby: the active-row chevron opens the game (not a no-op)', async ({ page
|
||||
await expect(page.locator('[data-cell]').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('lobby hamburger shows the pending notification count', async ({ page }) => {
|
||||
test('lobby ⚙️ tab shows the pending friend-request count', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
// One incoming friend request (Rick) + one invitation (Kaya) = 2.
|
||||
await expect(page.getByTestId('menu-badge')).toHaveText('2');
|
||||
// The ⚙️ badge counts incoming friend requests only (Rick = 1); invitations have their
|
||||
// own lobby section, so they are not summed into it.
|
||||
await expect(page.getByRole('button', { name: /Settings/ }).locator('.badge')).toHaveText('1');
|
||||
});
|
||||
|
||||
test('play with friends: a game type is required to send an invitation', async ({ page }) => {
|
||||
@@ -138,34 +170,28 @@ test('play with friends: a game type is required to send an invitation', async (
|
||||
await expect(page.getByText('Your turn')).toBeVisible();
|
||||
});
|
||||
|
||||
test('game: add-to-friends flips to a disabled "request sent"', async ({ page }) => {
|
||||
test('game: the add-friend 🤝 confirms with a tap and then reads as sent', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
await page.getByRole('button', { name: /Ann/ }).click(); // active game vs Ann
|
||||
await page.locator('.burger').first().click();
|
||||
await page.getByRole('button', { name: /Add to friends: Ann/ }).click();
|
||||
// Reopening the menu shows the item as a disabled "request sent".
|
||||
await page.locator('.burger').first().click();
|
||||
const sent = page.getByRole('button', { name: 'Request sent' });
|
||||
await expect(sent).toBeVisible();
|
||||
await expect(sent).toBeDisabled();
|
||||
await page.locator('.scoreboard').click(); // open the history -> 🤝 appears on Ann's card
|
||||
const add = page.getByRole('button', { name: 'Add to friends' });
|
||||
await add.click(); // arm: 🤝 -> a fading ✅
|
||||
await add.click(); // tap the ✅ to confirm within the window
|
||||
// The request is sent, so the control is now disabled.
|
||||
await expect(add).toBeDisabled();
|
||||
});
|
||||
|
||||
test('game: an opponent who is already a friend shows a disabled "in friends"', async ({ page }) => {
|
||||
test('game: an opponent who is already a friend shows no add-friend 🤝', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
await page.getByRole('button', { name: /Kaya/ }).click(); // the finished game vs Kaya, a seeded friend
|
||||
await page.locator('.burger').first().click();
|
||||
// The in-game friend item is derived from the server's friend list: a friend reads
|
||||
// a disabled "✓ in friends", not the addable "Add to friends".
|
||||
const inFriends = page.getByRole('button', { name: /in friends/i });
|
||||
await expect(inFriends).toBeVisible();
|
||||
await expect(inFriends).toBeDisabled();
|
||||
await expect(page.getByRole('button', { name: /Add to friends: Kaya/ })).toHaveCount(0);
|
||||
await page.getByRole('button', { name: /Kaya/ }).click(); // finished game vs Kaya, a seeded friend
|
||||
await page.locator('.scoreboard').click(); // open the history
|
||||
// Kaya is already a friend, so no add-friend control is offered on her card.
|
||||
await expect(page.getByRole('button', { name: 'Add to friends' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('profile edit disables Save and flags an invalid display name', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
await page.locator('.burger').first().click();
|
||||
await page.getByRole('button', { name: /Profile/ }).click();
|
||||
await openProfile(page);
|
||||
|
||||
const name = page.locator('.edit input').first();
|
||||
const save = page.getByRole('button', { name: /^Save$/ });
|
||||
@@ -179,8 +205,7 @@ test('profile edit disables Save and flags an invalid display name', async ({ pa
|
||||
|
||||
test('link account: a taken email opens the irreversible merge confirmation', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
await page.locator('.burger').first().click();
|
||||
await page.getByRole('button', { name: /Profile/ }).click();
|
||||
await openProfile(page);
|
||||
|
||||
// The linking section is shown to everyone (guests upgrade by linking).
|
||||
await expect(page.getByRole('heading', { name: 'Link an account' })).toBeVisible();
|
||||
@@ -200,16 +225,16 @@ test('link account: a taken email opens the irreversible merge confirmation', as
|
||||
|
||||
test('link account: the Telegram web sign-in control is offered in a browser', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
await page.locator('.burger').first().click();
|
||||
await page.getByRole('button', { name: /Profile/ }).click();
|
||||
await openProfile(page);
|
||||
await expect(page.getByRole('button', { name: 'Link Telegram' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('chat: the message field shows on your turn, the nudge replaces it otherwise', async ({ page }) => {
|
||||
await loginLobby(page);
|
||||
await page.getByRole('button', { name: /Ann/ }).click(); // g1: your turn
|
||||
await page.locator('.burger').first().click();
|
||||
await page.getByRole('button', { name: 'Chat' }).click();
|
||||
await page.locator('.scoreboard').click(); // open the history
|
||||
await page.getByRole('button', { name: 'Chat' }).click(); // 💬 -> comms hub
|
||||
await expect(page.locator('.pane')).toHaveCount(1);
|
||||
// On your turn the message field + Send are shown and the nudge is hidden;
|
||||
// chat and nudge are mutually exclusive by turn. Icon-only controls expose their action
|
||||
// through the aria-label.
|
||||
|
||||
@@ -35,6 +35,37 @@ test('Telegram launch auto-authenticates into the lobby and applies the theme',
|
||||
.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 }) => {
|
||||
await page.goto('/telegram/');
|
||||
|
||||
|
||||
+8
-12
@@ -9,14 +9,10 @@
|
||||
import Login from './screens/Login.svelte';
|
||||
import Lobby from './screens/Lobby.svelte';
|
||||
import NewGame from './screens/NewGame.svelte';
|
||||
import Profile from './screens/Profile.svelte';
|
||||
import Settings from './screens/Settings.svelte';
|
||||
import About from './screens/About.svelte';
|
||||
import Friends from './screens/Friends.svelte';
|
||||
import SettingsHub from './screens/SettingsHub.svelte';
|
||||
import Stats from './screens/Stats.svelte';
|
||||
import Game from './game/Game.svelte';
|
||||
import ChatScreen from './game/ChatScreen.svelte';
|
||||
import CheckScreen from './game/CheckScreen.svelte';
|
||||
import CommsHub from './game/CommsHub.svelte';
|
||||
|
||||
onMount(() => {
|
||||
void bootstrap();
|
||||
@@ -83,17 +79,17 @@
|
||||
{:else if router.route.name === 'game'}
|
||||
<Game id={router.route.params.id} />
|
||||
{: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'}
|
||||
<CheckScreen id={router.route.params.id} />
|
||||
<CommsHub id={router.route.params.id} initialTab="dictionary" />
|
||||
{:else if router.route.name === 'profile'}
|
||||
<Profile />
|
||||
<SettingsHub initialTab="profile" />
|
||||
{:else if router.route.name === 'settings'}
|
||||
<Settings />
|
||||
<SettingsHub initialTab="settings" />
|
||||
{:else if router.route.name === 'about'}
|
||||
<About />
|
||||
<SettingsHub initialTab="about" />
|
||||
{:else if router.route.name === 'friends'}
|
||||
<Friends />
|
||||
<SettingsHub initialTab="friends" />
|
||||
{:else if router.route.name === 'stats'}
|
||||
<Stats />
|
||||
{:else}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { navigate } from '../lib/router.svelte';
|
||||
import { insideTelegram } from '../lib/telegram';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import Spinner from './Spinner.svelte';
|
||||
|
||||
let { title, back, menu, grow = false }: { title: string; back?: string; menu?: Snippet; grow?: boolean } =
|
||||
$props();
|
||||
let { title, back, grow = false }: { title: string; back?: string; grow?: boolean } = $props();
|
||||
|
||||
// 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.
|
||||
@@ -28,7 +26,8 @@
|
||||
{:else}
|
||||
<h1 class="connecting"><Spinner /> <span>{t('connection.connecting')}</span></h1>
|
||||
{/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>
|
||||
</header>
|
||||
|
||||
@@ -75,18 +74,13 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
.icon,
|
||||
.spacer,
|
||||
.end {
|
||||
.spacer {
|
||||
min-width: 40px;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.end {
|
||||
width: auto;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.back {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -108,19 +102,21 @@
|
||||
}
|
||||
/* 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
|
||||
is unchanged) and centres the title + menu as a pair (hamburger right of the title) within
|
||||
it, BELOW the notch — lining them up vertically with Telegram's own back/menu controls,
|
||||
which sit in the band's corners. */
|
||||
is unchanged) and centres the title within it, BELOW the notch — lining it up vertically
|
||||
with Telegram's own back/menu controls, which sit in the band's corners. */
|
||||
:global(html.tg-fullscreen) .bar {
|
||||
/* 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);
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* +12px of vertical breathing room (6 above / 6 below the centred content, on top of the
|
||||
notch) so Telegram's native controls aren't flush against our header. Applied as
|
||||
padding because the bar is sized by its content here, not by min-height (owner review
|
||||
tweaks). */
|
||||
padding-top: calc(var(--tg-safe-top) + 6px);
|
||||
padding-top: calc(var(--tg-safe-top) + 16px);
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
:global(html.tg-fullscreen) .spacer {
|
||||
@@ -129,7 +125,4 @@
|
||||
:global(html.tg-fullscreen) h1 {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
:global(html.tg-fullscreen) .end {
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -10,7 +10,6 @@
|
||||
let {
|
||||
title,
|
||||
back,
|
||||
menu,
|
||||
tabbar,
|
||||
children,
|
||||
scroll = true,
|
||||
@@ -19,7 +18,6 @@
|
||||
}: {
|
||||
title: string;
|
||||
back?: string;
|
||||
menu?: Snippet;
|
||||
tabbar?: Snippet;
|
||||
children?: Snippet;
|
||||
scroll?: boolean;
|
||||
@@ -58,7 +56,7 @@
|
||||
</script>
|
||||
|
||||
<div class="screen">
|
||||
<Header {title} {back} {menu} grow={growNav} />
|
||||
<Header {title} {back} grow={growNav} />
|
||||
{#if SHOW_AD_BANNER}<AdBanner />{/if}
|
||||
<main class="content" class:scroll class:fill={!growNav} class:column>{@render children?.()}</main>
|
||||
{#if tabbar}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
// 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();
|
||||
</script>
|
||||
|
||||
@@ -53,6 +53,29 @@
|
||||
:global(.tab:active:not(:disabled) .sq) {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
/* A tab that navigates between peer views (Settings / Comms hubs) stays highlighted
|
||||
while selected: a filled square with an accent underline, distinct from the
|
||||
momentary press tint above. */
|
||||
:global(.tab.active .sq) {
|
||||
background: var(--surface-2);
|
||||
box-shadow: inset 0 -2px 0 var(--accent);
|
||||
}
|
||||
/* A small count badge on the icon square's corner (lobby ⚙️, the Friends tab, the
|
||||
hint count) — one shared style so every tab badge reads the same. */
|
||||
:global(.tab .badge) {
|
||||
position: absolute;
|
||||
top: -3px;
|
||||
right: -3px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
border-radius: 999px;
|
||||
min-width: 15px;
|
||||
padding: 0 3px;
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
}
|
||||
:global(.tab .lbl) {
|
||||
font-size: 0.62rem;
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -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>
|
||||
@@ -1,15 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Screen from '../components/Screen.svelte';
|
||||
import Chat from './Chat.svelte';
|
||||
import { gateway } from '../lib/gateway';
|
||||
import { app, handleError, clearChatUnread } from '../lib/app.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import type { ChatMessage, StateView } from '../lib/model';
|
||||
|
||||
// The chat is its own screen, so the soft keyboard simply resizes the viewport with
|
||||
// the input pinned to the bottom — no modal relayout jank. It loads the game state (for the
|
||||
// turn-based chat/nudge toggle) and the message list, and clears the unread badge while open.
|
||||
// The Chat tab body, hosted by CommsHub (which supplies the nav bar + tab bar). The
|
||||
// hub lays it out as a non-scrolling column, so the soft keyboard simply resizes the
|
||||
// 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 view = $state<StateView | null>(null);
|
||||
@@ -88,6 +87,4 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Screen title={t('game.chat')} back={`/game/${id}`} scroll={false} column>
|
||||
<Chat {messages} {myId} {busy} myTurn={isMyTurn} {nudgeOnCooldown} onsend={sendChat} onnudge={nudge} />
|
||||
</Screen>
|
||||
<Chat {messages} {myId} {busy} myTurn={isMyTurn} {nudgeOnCooldown} onsend={sendChat} onnudge={nudge} />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Screen from '../components/Screen.svelte';
|
||||
import { gateway } from '../lib/gateway';
|
||||
import { handleError, showToast } from '../lib/app.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
@@ -60,8 +59,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Screen title={t('game.checkWord')} back={`/game/${id}`}>
|
||||
<div class="wrap">
|
||||
<div class="wrap">
|
||||
<div class="check">
|
||||
<input
|
||||
value={word}
|
||||
@@ -79,8 +77,7 @@
|
||||
</p>
|
||||
<button class="complain" onclick={complain}>{t('game.complain')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
</Screen>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.wrap {
|
||||
|
||||
@@ -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
@@ -1,9 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import Screen from '../components/Screen.svelte';
|
||||
import Menu from '../components/Menu.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 Board from './Board.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
|
||||
// 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 read as "request sent").
|
||||
// --- move history: open by tapping the score bar, close by tapping or swiping up the board ---
|
||||
// While the history is open the board is inert (CSS pointer-events), so the whole slid board
|
||||
// reads as a "tap or swipe up to close" surface and the stage cannot scroll instead of close.
|
||||
// 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 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
|
||||
// surfaces, so the sets stay empty. Best-effort — a failure leaves the previous sets.
|
||||
@@ -643,50 +675,52 @@
|
||||
}
|
||||
}
|
||||
|
||||
const opponents = $derived(
|
||||
view ? view.game.seats.filter((s) => s.accountId !== app.session?.userId) : [],
|
||||
);
|
||||
|
||||
// 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) }]),
|
||||
]);
|
||||
// canAddFriend reports whether a seat shows the 🤝: a non-guest viewing an opponent who is
|
||||
// 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);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Screen title={t(variantNameKey(variant))} back="/" growNav column scroll={false}>
|
||||
{#snippet menu()}
|
||||
<Menu items={menuItems} badge={app.chatUnread[id] ?? 0} />
|
||||
{/snippet}
|
||||
|
||||
{#if view}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- 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)}
|
||||
<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="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>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="stage">
|
||||
<div class="stage" class:histopen={historyOpen}>
|
||||
{#if historyOpen}
|
||||
<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>
|
||||
{#each moves as m, i (i)}
|
||||
<li>
|
||||
@@ -705,7 +739,10 @@
|
||||
<div
|
||||
class="boardwrap"
|
||||
class:slid={historyOpen}
|
||||
onclick={() => historyOpen && (historyOpen = false)}
|
||||
onpointerdown={onBoardWrapDown}
|
||||
onpointermove={onBoardWrapMove}
|
||||
onpointerup={() => (histSwipeY = null)}
|
||||
onclick={closeHistoryByGesture}
|
||||
>
|
||||
<Board
|
||||
{board}
|
||||
@@ -769,17 +806,18 @@
|
||||
<button class="tab" disabled={busy || !isMyTurn || !connection.online || bagEmpty} onclick={openExchange}>
|
||||
<span class="sq">🔄</span><span class="lbl">{t('game.draw')}</span>
|
||||
</button>
|
||||
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn || !connection.online} onhold={doPass}>
|
||||
{#snippet trigger()}<span class="sq">🥺</span><span class="lbl">{t('game.skip')}</span>{/snippet}
|
||||
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doPass(); }}>{t('game.confirm')} ✅</button>{/snippet}
|
||||
</HoldConfirm>
|
||||
<HoldConfirm triggerClass="tab" disabled={busy || !isMyTurn || !connection.online || (view?.hintsRemaining ?? 0) <= 0} onhold={doHint}>
|
||||
{#snippet trigger()}
|
||||
<TapConfirm triggerClass="tab" label={t('game.skip')} disabled={busy || !isMyTurn || !connection.online} onconfirm={doPass}>
|
||||
<span class="sq">🥺</span><span class="lbl">{t('game.skip')}</span>
|
||||
</TapConfirm>
|
||||
<TapConfirm
|
||||
triggerClass="tab"
|
||||
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="lbl">{t('game.hint')}</span>
|
||||
{/snippet}
|
||||
{#snippet popover(close)}<button class="pop go" onclick={() => { close(); doHint(); }}>{t('game.confirm')} ✅</button>{/snippet}
|
||||
</HoldConfirm>
|
||||
</TapConfirm>
|
||||
{#if placement.pending.length > 0}
|
||||
<button class="tab" disabled={busy} onclick={resetPlacement}>
|
||||
<span class="sq">↩️</span><span class="lbl">{t('game.reset')}</span>
|
||||
@@ -836,6 +874,7 @@
|
||||
|
||||
<style>
|
||||
.scoreboard {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: 6px;
|
||||
@@ -844,6 +883,7 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
.seat {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 5px 4px;
|
||||
@@ -891,6 +931,11 @@
|
||||
overflow-y: auto;
|
||||
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 {
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
@@ -947,6 +992,10 @@
|
||||
.boardwrap.slid {
|
||||
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 {
|
||||
display: flex;
|
||||
flex: none;
|
||||
@@ -998,22 +1047,47 @@
|
||||
.make:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.pop {
|
||||
padding: 9px 14px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
}
|
||||
.pop:hover {
|
||||
/* The move-history header: leave (active) / export (finished) on the left, comms on the
|
||||
right, icon-only. Sticky so it stays atop the scrolling move list. */
|
||||
.hhead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 8px;
|
||||
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;
|
||||
top: -3px;
|
||||
right: -3px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
background: var(--accent);
|
||||
@@ -1024,6 +1098,14 @@
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
}
|
||||
.sbadge {
|
||||
top: 2px;
|
||||
right: 4px;
|
||||
}
|
||||
.hicon .cbadge {
|
||||
top: -1px;
|
||||
right: -1px;
|
||||
}
|
||||
.loading {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
|
||||
+14
-14
@@ -49,9 +49,9 @@ export const app = $state<{
|
||||
/** Draw grid lines between board cells; off (default) is a gapless checkerboard. */
|
||||
boardLines: 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;
|
||||
/** 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>;
|
||||
}>({
|
||||
ready: false,
|
||||
@@ -139,9 +139,12 @@ function openStream(): void {
|
||||
reportOnline(); // a delivered event proves the gateway is reachable
|
||||
app.lastEvent = e;
|
||||
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.
|
||||
const onChat = router.route.name === 'gameChat' && router.route.params.id === e.message.gameId;
|
||||
if (!onChat) {
|
||||
// While the player is in that game's comms hub (chat or dictionary tab), neither
|
||||
// toast nor bump the unread — the chat is a tap away and reloads on open.
|
||||
const inComms =
|
||||
(router.route.name === 'gameChat' || router.route.name === 'gameCheck') &&
|
||||
router.route.params.id === e.message.gameId;
|
||||
if (!inComms) {
|
||||
if (e.message.kind !== 'nudge') {
|
||||
const gid = e.message.gameId;
|
||||
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
|
||||
* plus open invitations). Authoritative poll, complementing the live 'notify' push.
|
||||
* Guests have no social surfaces, so it is a no-op for them.
|
||||
* refreshNotifications recomputes the badge count (incoming friend requests).
|
||||
* Authoritative poll, complementing the live 'notify' push. Game invitations have
|
||||
* 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> {
|
||||
if (!app.session || app.profile?.isGuest) {
|
||||
@@ -196,11 +200,7 @@ export async function refreshNotifications(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [incoming, invitations] = await Promise.all([
|
||||
gateway.friendsIncoming(),
|
||||
gateway.invitationsList(),
|
||||
]);
|
||||
app.notifications = incoming.length + invitations.length;
|
||||
app.notifications = (await gateway.friendsIncoming()).length;
|
||||
} catch {
|
||||
// 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
|
||||
* 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.
|
||||
*/
|
||||
function syncTelegramSafeArea(): void {
|
||||
|
||||
@@ -63,9 +63,9 @@ export const en = {
|
||||
'game.skip': 'Skip',
|
||||
'game.shuffle': 'Shuffle',
|
||||
'game.hint': 'Hint',
|
||||
'game.history': 'History',
|
||||
'game.chat': 'Chat',
|
||||
'game.checkWord': 'Check word',
|
||||
'game.dictionary': 'Dictionary',
|
||||
'game.dropGame': 'Drop game',
|
||||
'game.preview': 'Scores {n}',
|
||||
'game.previewIllegal': 'Not a legal move',
|
||||
@@ -83,7 +83,6 @@ export const en = {
|
||||
'game.wordIllegal': '“{word}” is not valid',
|
||||
'game.complain': 'Disagree',
|
||||
'game.complaintSent': 'Thanks, sent for review.',
|
||||
'game.confirm': 'Ok',
|
||||
'game.check': 'Check',
|
||||
'game.checkWait': 'Please wait a moment.',
|
||||
'game.noHintOptions': 'No options with your letters.',
|
||||
@@ -151,6 +150,7 @@ export const en = {
|
||||
'settings.reduceMotion': 'Reduce motion',
|
||||
|
||||
'about.title': 'About',
|
||||
'about.tab': 'Info',
|
||||
'about.description': 'A multiplatform Scrabble game.',
|
||||
'about.version': 'Version {v}',
|
||||
|
||||
@@ -242,8 +242,7 @@ export const en = {
|
||||
|
||||
'game.exportGcg': 'Export GCG',
|
||||
'game.gcgActiveOnly': 'Available once the game is finished.',
|
||||
'game.requestSent': 'Request sent',
|
||||
'game.alreadyFriends': '✓ In friends',
|
||||
'game.addFriendShort': 'Add friend?',
|
||||
|
||||
'time.minutes': '{n} min',
|
||||
'time.hours': '{n} h',
|
||||
|
||||
@@ -64,9 +64,9 @@ export const ru: Record<MessageKey, string> = {
|
||||
'game.skip': 'Пас',
|
||||
'game.shuffle': 'Перемешать',
|
||||
'game.hint': 'Подсказка',
|
||||
'game.history': 'История',
|
||||
'game.chat': 'Чат',
|
||||
'game.checkWord': 'Проверить слово',
|
||||
'game.dictionary': 'Словарь',
|
||||
'game.dropGame': 'Покинуть игру',
|
||||
'game.preview': 'Очков: {n}',
|
||||
'game.previewIllegal': 'Недопустимый ход',
|
||||
@@ -84,7 +84,6 @@ export const ru: Record<MessageKey, string> = {
|
||||
'game.wordIllegal': '«{word}» недопустимо',
|
||||
'game.complain': 'Не согласен',
|
||||
'game.complaintSent': 'Спасибо, отправлено на проверку.',
|
||||
'game.confirm': 'Да',
|
||||
'game.check': 'Проверить',
|
||||
'game.checkWait': 'Секунду, пожалуйста.',
|
||||
'game.noHintOptions': 'Нет вариантов с вашим набором.',
|
||||
@@ -152,6 +151,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'settings.reduceMotion': 'Меньше анимаций',
|
||||
|
||||
'about.title': 'О программе',
|
||||
'about.tab': 'Инфо',
|
||||
'about.description': 'Мультиплатформенная игра в скрабл.',
|
||||
'about.version': 'Версия {v}',
|
||||
|
||||
@@ -243,8 +243,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
|
||||
'game.exportGcg': 'Экспорт GCG',
|
||||
'game.gcgActiveOnly': 'Доступно после завершения игры.',
|
||||
'game.requestSent': 'Запрос отправлен',
|
||||
'game.alreadyFriends': '✓ В друзьях',
|
||||
'game.addFriendShort': 'В друзья?',
|
||||
|
||||
'time.minutes': '{n} мин',
|
||||
'time.hours': '{n} ч',
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import Screen from '../components/Screen.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { app } from '../lib/app.svelte';
|
||||
import { aboutContent } from '../lib/aboutContent';
|
||||
@@ -10,8 +9,7 @@
|
||||
const c = $derived(aboutContent(app.locale, AUTO_MATCH_HOURS));
|
||||
</script>
|
||||
|
||||
<Screen title={t('about.title')} back="/">
|
||||
<div class="page">
|
||||
<div class="page">
|
||||
<h1>{c.title}</h1>
|
||||
<p>
|
||||
{c.rulesPrefix}<a href={c.rulesUrl} target="_blank" rel="noopener noreferrer">{c.rulesLink}</a>.
|
||||
@@ -33,8 +31,7 @@
|
||||
</section>
|
||||
|
||||
<p class="muted">{t('about.version', { v: version })}</p>
|
||||
</div>
|
||||
</Screen>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Screen from '../components/Screen.svelte';
|
||||
import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { gateway } from '../lib/gateway';
|
||||
@@ -81,8 +80,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Screen title={t('friends.title')} back="/">
|
||||
<div class="page">
|
||||
<div class="page">
|
||||
{#if app.profile?.isGuest}
|
||||
<p class="muted">{t('profile.guestLocked')}</p>
|
||||
{:else}
|
||||
@@ -161,8 +159,7 @@
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</Screen>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Screen from '../components/Screen.svelte';
|
||||
import Menu from '../components/Menu.svelte';
|
||||
import TabBar from '../components/TabBar.svelte';
|
||||
import { app, handleError, showToast } from '../lib/app.svelte';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
@@ -24,7 +23,9 @@
|
||||
games = (await gateway.gamesList()).games;
|
||||
if (!guest) {
|
||||
[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 });
|
||||
} 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) {
|
||||
try {
|
||||
const r = await gateway.invitationAccept(inv.id);
|
||||
@@ -151,10 +145,6 @@
|
||||
</script>
|
||||
|
||||
<Screen title={app.profile?.displayName ?? t('app.title')}>
|
||||
{#snippet menu()}
|
||||
<Menu items={menuItems} badge={app.notifications} />
|
||||
{/snippet}
|
||||
|
||||
<div class="lobby">
|
||||
{#if invitations.length}
|
||||
<section>
|
||||
@@ -238,6 +228,10 @@
|
||||
<button class="tab" onclick={() => showToast(t('lobby.soon'))}>
|
||||
<span class="sq">🏆</span><span class="lbl">{t('lobby.tournaments')}</span>
|
||||
</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>
|
||||
{/snippet}
|
||||
</Screen>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import Modal from '../components/Modal.svelte';
|
||||
import Screen from '../components/Screen.svelte';
|
||||
import { app, applyLinkResult, handleError, logout, showToast } from '../lib/app.svelte';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { gateway } from '../lib/gateway';
|
||||
@@ -160,8 +159,7 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<Screen title={t('profile.title')} back="/">
|
||||
<div class="page">
|
||||
<div class="page">
|
||||
{#if app.profile}
|
||||
{@const p = app.profile}
|
||||
<div class="name">{p.displayName}</div>
|
||||
@@ -250,9 +248,9 @@
|
||||
once its entry point is decided; logout() also still runs on an invalid session. -->
|
||||
<button class="logout" hidden onclick={() => logout()}>{t('login.title')} / logout</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if pendingMerge}
|
||||
{#if pendingMerge}
|
||||
<Modal title={t('profile.mergeTitle')} onclose={() => (pendingMerge = null)}>
|
||||
<p>{t('profile.mergeBody', { name: pendingMerge.name, games: pendingMerge.games, friends: pendingMerge.friends })}</p>
|
||||
<p class="warn">{t('profile.mergeIrreversible')}</p>
|
||||
@@ -261,8 +259,7 @@
|
||||
<button class="btn" onclick={confirmMerge} disabled={!connection.online}>{t('profile.mergeConfirm')}</button>
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
</Screen>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.page {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import Screen from '../components/Screen.svelte';
|
||||
import {
|
||||
app,
|
||||
setBoardLabels,
|
||||
@@ -28,8 +27,7 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<Screen title={t('settings.title')} back="/">
|
||||
<div class="page">
|
||||
<div class="page">
|
||||
{#if !insideTelegram()}
|
||||
<section>
|
||||
<h3>{t('settings.theme')}</h3>
|
||||
@@ -84,8 +82,7 @@
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
</div>
|
||||
</Screen>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user