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