Promote development → master (initial production release: pre-release line + Stage 18) #104
@@ -776,7 +776,7 @@ cannot submit; three-way admin filter.
|
|||||||
reusable **HoldConfirm** press-and-hold control (MakeMove 🏁 + game-action confirms);
|
reusable **HoldConfirm** press-and-hold control (MakeMove 🏁 + game-action confirms);
|
||||||
board **zoom reworked** to a width-based zoom in a fixed viewport (real native
|
board **zoom reworked** to a width-based zoom in a fixed viewport (real native
|
||||||
scroll, double-tap; pinch/swipe dropped) with constant `cqw` labels, corner-letter
|
scroll, double-tap; pinch/swipe dropped) with constant `cqw` labels, corner-letter
|
||||||
tiles, contrasting grid lines, last-word dark-tile highlight, and a Settings
|
tiles, last-word dark-tile highlight, and a Settings
|
||||||
**bonus-label style** (beginner/
|
**bonus-label style** (beginner/
|
||||||
classic/none); **hint lays its tiles on the board** (no spend when no move — a new
|
classic/none); **hint lays its tiles on the board** (no spend when no move — a new
|
||||||
`no_hint_available` result code); the history opens as an in-place **slide-down**
|
`no_hint_available` result code); the history opens as an in-place **slide-down**
|
||||||
|
|||||||
@@ -110,7 +110,9 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
|||||||
func (s *Server) consoleDashboard(c *gin.Context) {
|
func (s *Server) consoleDashboard(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
view := adminconsole.DashboardView{Variants: s.variantVersions(), ActiveVersion: s.games.ActiveVersion()}
|
view := adminconsole.DashboardView{Variants: s.variantVersions(), ActiveVersion: s.games.ActiveVersion()}
|
||||||
view.Accounts, _ = s.accounts.CountAccounts(ctx)
|
// The Users card counts people only: robots are pool infrastructure, not registered
|
||||||
|
// users, so an empty filter (Robots: false) excludes them.
|
||||||
|
view.Accounts, _ = s.accounts.CountUsers(ctx, account.UserFilter{})
|
||||||
view.Games, _ = s.games.CountGames(ctx, "")
|
view.Games, _ = s.games.CountGames(ctx, "")
|
||||||
view.ActiveGames, _ = s.games.CountGames(ctx, game.StatusActive)
|
view.ActiveGames, _ = s.games.CountGames(ctx, game.StatusActive)
|
||||||
view.OpenComplaints, _ = s.games.CountComplaints(ctx, game.StatusComplaintOpen)
|
view.OpenComplaints, _ = s.games.CountComplaints(ctx, game.StatusComplaintOpen)
|
||||||
|
|||||||
+4
-2
@@ -93,7 +93,7 @@ invitation — so a player still sees and plays existing games of any language.
|
|||||||
|
|
||||||
**Quick game** lets you choose your opponent — an **AI** (the default) or a **random player**.
|
**Quick game** lets you choose your opponent — an **AI** (the default) or a **random player**.
|
||||||
With **AI** you start at once against a 🤖 that joins and replies immediately: there is no waiting,
|
With **AI** you start at once against a 🤖 that joins and replies immediately: there is no waiting,
|
||||||
chat and nudge are off, add-friend is never shown, and the word-check tool still works; instead of a
|
there is no chat or nudge — only the word-check tool — add-friend is never shown; instead of a
|
||||||
per-move clock you lose only after **7 days without a move** (so you can abandon an AI game freely,
|
per-move clock you lose only after **7 days without a move** (so you can abandon an AI game freely,
|
||||||
and the AI itself never runs out of time). Choosing a **random player** is auto-match
|
and the AI itself never runs out of time). Choosing a **random player** is auto-match
|
||||||
(always 2 players), which drops you **straight into the game and you wait inside it**: if it is your turn you
|
(always 2 players), which drops you **straight into the game and you wait inside it**: if it is your turn you
|
||||||
@@ -211,7 +211,9 @@ is awaited at most once per hour (the
|
|||||||
nudge is part of the game chat); both the in-app toast and the out-of-app push (delivered
|
nudge is part of the game chat); both the in-app toast and the out-of-app push (delivered
|
||||||
via the platform) **name the nudger** ("<opponent>: waiting for your move").
|
via the platform) **name the nudger** ("<opponent>: waiting for your move").
|
||||||
Chat and the word-check tool share one **comms screen** with **💬 chat** / **🔎 dictionary**
|
Chat and the word-check tool share one **comms screen** with **💬 chat** / **🔎 dictionary**
|
||||||
tabs, reached from the 💬 in the move-history header (with a back to the game). An unread chat
|
tabs, reached from the 💬 in the move-history header (with a back to the game). An AI game has no
|
||||||
|
chat, so its comms screen is the word-check alone — and once an AI game is finished (no chat, the
|
||||||
|
dictionary closed) the 💬 entry disappears entirely. An unread chat
|
||||||
entry — a message **or a nudge** from an opponent — raises a small **red dot** next to the game
|
entry — a message **or a nudge** from an opponent — raises a small **red dot** next to the game
|
||||||
in the **lobby** and on the game's **score bar**. Opening the **move history** counts as reading
|
in the **lobby** and on the game's **score bar**. Opening the **move history** counts as reading
|
||||||
the chat, even without entering it: the dot clears and the 💬 icon **fade-blinks twice**. A nudge
|
the chat, even without entering it: the dot clears and the 💬 icon **fade-blinks twice**. A nudge
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ nudge) приходят от бота **этой партии** — по язы
|
|||||||
|
|
||||||
**Быстрая игра** даёт выбрать соперника — **ИИ** (по умолчанию) или **случайного игрока**. С **ИИ**
|
**Быстрая игра** даёт выбрать соперника — **ИИ** (по умолчанию) или **случайного игрока**. С **ИИ**
|
||||||
вы сразу начинаете против 🤖, который присоединяется и отвечает мгновенно: ожидания нет, чат и nudge
|
вы сразу начинаете против 🤖, который присоединяется и отвечает мгновенно: ожидания нет, чат и nudge
|
||||||
выключены, «добавить в друзья» не показывается, а проверка слова работает; вместо лимита на ход вы
|
нет — только проверка слова, — «добавить в друзья» не показывается; вместо лимита на ход вы
|
||||||
проигрываете только после **7 дней без хода** (так что ИИ-партию можно спокойно забросить, а сам ИИ
|
проигрываете только после **7 дней без хода** (так что ИИ-партию можно спокойно забросить, а сам ИИ
|
||||||
никогда не «просрочит» ход). Выбор **случайного игрока** — это авто-подбор (всегда 2 игрока), который
|
никогда не «просрочит» ход). Выбор **случайного игрока** — это авто-подбор (всегда 2 игрока), который
|
||||||
сразу **помещает вас в игру, и вы ждёте соперника прямо
|
сразу **помещает вас в игру, и вы ждёте соперника прямо
|
||||||
@@ -217,7 +217,9 @@ nudge) приходят от бота **этой партии** — по язы
|
|||||||
и внеприложенческий push (доставляется через платформу) **называют отправителя**
|
и внеприложенческий push (доставляется через платформу) **называют отправителя**
|
||||||
(«<соперник>: жду вашего хода»).
|
(«<соперник>: жду вашего хода»).
|
||||||
Чат и инструмент проверки слова — один **экран связи** со вкладками **💬 чат** / **🔎 словарь**,
|
Чат и инструмент проверки слова — один **экран связи** со вкладками **💬 чат** / **🔎 словарь**,
|
||||||
открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию). Непрочитанная запись чата —
|
открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию). В ИИ-партии чата нет, поэтому её
|
||||||
|
экран связи — только проверка слова; а после завершения ИИ-партии (чата нет, словарь закрыт) сам вход
|
||||||
|
💬 исчезает. Непрочитанная запись чата —
|
||||||
сообщение **или nudge** от соперника — зажигает маленькую **красную точку** рядом с партией в
|
сообщение **или nudge** от соперника — зажигает маленькую **красную точку** рядом с партией в
|
||||||
**лобби** и на **строке счёта** партии. Открытие **истории ходов** считается прочтением чата, даже
|
**лобби** и на **строке счёта** партии. Открытие **истории ходов** считается прочтением чата, даже
|
||||||
без захода в него: точка гаснет, а значок 💬 **дважды мигает**. Nudge также гаснет в момент, когда
|
без захода в него: точка гаснет, а значок 💬 **дважды мигает**. Nudge также гаснет в момент, когда
|
||||||
|
|||||||
+20
-14
@@ -32,7 +32,8 @@ except Login uses `Screen`.
|
|||||||
bottom tab bar whose tabs switch its body **in place** (state, not navigation), so the
|
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
|
back control always returns to the hub's parent (Settings → lobby, comms → game). Settings
|
||||||
hub tabs: ⚙️ Settings / 👤 Profile / 🤝 Friends / ℹ️ About (Friends hidden for guests);
|
hub tabs: ⚙️ Settings / 👤 Profile / 🤝 Friends / ℹ️ About (Friends hidden for guests);
|
||||||
comms hub tabs: 💬 Chat / 🔎 Dictionary (Dictionary only while the game is active). The
|
comms hub tabs: 💬 Chat / 🔎 Dictionary (Dictionary only while the game is active; an
|
||||||
|
honest-AI game has no Chat, so it shows the Dictionary alone). The
|
||||||
routes `/settings|/profile|/friends|/about` and `/game/:id/{chat,check}` survive as hub
|
routes `/settings|/profile|/friends|/about` and `/game/:id/{chat,check}` survive as hub
|
||||||
entry points (so a Telegram friend-code deep-link still lands on the Friends tab). The
|
entry points (so a Telegram friend-code deep-link still lands on the Friends tab). The
|
||||||
**Feedback** screen is its own deeper route `/feedback` (back → `/about`, the Info tab), so
|
**Feedback** screen is its own deeper route `/feedback` (back → `/about`, the Info tab), so
|
||||||
@@ -110,8 +111,9 @@ except Login uses `Screen`.
|
|||||||
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
|
||||||
**double-tap** or by **dragging it back onto the rack** (unzoomed board only — when zoomed
|
**double-tap** or by **dragging it back onto the rack** (unzoomed board only — when zoomed
|
||||||
the one-finger gesture scrolls). A single tap no longer recalls (too easy to trigger); a
|
the one-finger gesture scrolls). A single tap no longer recalls (too easy to trigger). A
|
||||||
recalled tile returns to its original rack slot.
|
**double-tap** returns the tile to its original rack slot; **dragging it back** drops it at the
|
||||||
|
rack slot the pointer is over (a gap opens there), the same drag-to-position as a rack reorder.
|
||||||
- **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 on the plaque toggles
|
while the others read **sunk in** (an inset shadow). A tap on the plaque toggles
|
||||||
@@ -172,11 +174,9 @@ except Login uses `Screen`.
|
|||||||
- **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a
|
- **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a
|
||||||
split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none`
|
split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none`
|
||||||
nothing. Default **beginner**.
|
nothing. Default **beginner**.
|
||||||
- **Grid lines** — a Settings toggle, **default off**. Off: a **gapless
|
- **Board surface** — always a **gapless checkerboard** (there is no grid-lines setting):
|
||||||
checkerboard** — plain cells alternate two shades, and tiles get rounded corners with a
|
plain cells alternate two shades, and tiles get rounded corners with a soft right-side
|
||||||
soft right-side shadow so adjacent gapless tiles still read apart, reclaiming ~14px of
|
shadow so adjacent gapless tiles still read apart, reclaiming ~14px of board width.
|
||||||
board width. On: the classic lined grid, where the inter-cell gap shows a contrasting
|
|
||||||
`--cell-line` (darker in light, lighter in dark) to avoid a wavy-line optical illusion.
|
|
||||||
|
|
||||||
## Controls
|
## Controls
|
||||||
|
|
||||||
@@ -242,7 +242,9 @@ game **becomes your turn or finishes** while the lobby is open, that status emoj
|
|||||||
twice** (a two-cycle opacity fade, ~2 s; suppressed under reduce-motion) to draw the eye — the
|
twice** (a two-cycle opacity fade, ~2 s; suppressed under reduce-motion) to draw the eye — the
|
||||||
opponent's-turn change is silent. Each card's blink is keyed by game id, so overlapping
|
opponent's-turn change is silent. Each card's blink is keyed by game id, so overlapping
|
||||||
events animate in isolation, and the newest bottom toast cancels the previous one and replays
|
events animate in isolation, and the newest bottom toast cancels the previous one and replays
|
||||||
its entrance (`components/Toast.svelte`, re-keyed on a per-message sequence).
|
its entrance (`components/Toast.svelte`, re-keyed on a per-message sequence). An **info** toast
|
||||||
|
lives ~2 s, drifting up by about the tab-bar height as it fades (a plain fade, no travel, under
|
||||||
|
reduce-motion); an **error** toast dwells longer (~4 s). Either dismisses instantly on tap.
|
||||||
|
|
||||||
**Simultaneous-game cap.** When the player is at the active-quick-game cap (`games.list`
|
**Simultaneous-game cap.** When the player is at the active-quick-game cap (`games.list`
|
||||||
`at_game_limit`), the lobby's **New Game** tab is **disabled** (greyed via the shared
|
`at_game_limit`), the lobby's **New Game** tab is **disabled** (greyed via the shared
|
||||||
@@ -255,8 +257,11 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
|
|||||||
|
|
||||||
- **Settings hub** (`screens/SettingsHub.svelte`, the lobby ⚙️ tab): one nav bar + a bottom
|
- **Settings hub** (`screens/SettingsHub.svelte`, the lobby ⚙️ tab): one nav bar + a bottom
|
||||||
tab bar over four bodies — ⚙️ Settings, 👤 Profile, 🤝 Friends, ℹ️ About — switched in
|
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
|
place; back always returns to the lobby. The **selected** tab is marked by a filled pill
|
||||||
the 🤝 tab badge both show the pending incoming-friend-request count.
|
hugging its icon **and** label (with an accent underline) — a persistent selection cue that
|
||||||
|
the plain, unhighlighted action tabs (lobby nav, in-game controls) never carry. 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
|
- **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**
|
||||||
@@ -281,8 +286,9 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
|
|||||||
under Start game notes the wait can take a while (the app may be closed meanwhile). With **AI**
|
under Start game notes the wait can take a while (the app may be closed meanwhile). With **AI**
|
||||||
selected there is no wait: the move-clock line instead reads **"Loss after 7 days of inactivity"**
|
selected there is no wait: the move-clock line instead reads **"Loss after 7 days of inactivity"**
|
||||||
and the "searching" hint is hidden; the game starts already seated, the opponent shows as **🤖**
|
and the "searching" hint is hidden; the game starts already seated, the opponent shows as **🤖**
|
||||||
everywhere (score card, lobby row, turn line), the add-friend 🤝 is never drawn, and the chat's
|
everywhere (score card, lobby row, turn line), the add-friend 🤝 is never drawn, and there is
|
||||||
send field and 🛎️ nudge stay **disabled** while the 🔎 dictionary word-check keeps working.
|
**no chat at all**: the comms hub drops the 💬 Chat tab and offers only the 🔎 dictionary
|
||||||
|
word-check (the 🛎️ nudge is likewise gone).
|
||||||
- **Statistics** (`screens/Stats.svelte`, the lobby ✏️ tab): a 2-column grid of stat
|
- **Statistics** (`screens/Stats.svelte`, the lobby ✏️ tab): a 2-column grid of stat
|
||||||
cards (games / wins / draws / losses / moves / hint-share / best game / win-rate) —
|
cards (games / wins / draws / losses / moves / hint-share / best game / win-rate) —
|
||||||
pure numbers, no charts; hint-share is a one-decimal percentage in the active locale's
|
pure numbers, no charts; hint-share is a one-decimal percentage in the active locale's
|
||||||
@@ -315,7 +321,7 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
|
|||||||
never in an honest-AI game (throwaway practice). Confirming a resign reveals the full board:
|
never in an honest-AI game (throwaway practice). Confirming a resign reveals the full board:
|
||||||
it closes the history drawer (portrait) and zooms the board out.
|
it closes the history drawer (portrait) and zooms the board out.
|
||||||
- **Finished game**: the board keeps no last-word highlight and no zoom; the history header
|
- **Finished game**: the board keeps no last-word highlight and no zoom; the history header
|
||||||
offers *Export GCG* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab; and
|
offers *Export GCG* (not *Drop game*; an AI game offers neither) and the comms hub hides the 🔎 *Dictionary* tab — and a **finished AI game has no comms at all** (no chat, dictionary closed), so its 💬 entry is dropped from the header too; and
|
||||||
the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than
|
the footer (rack + tab bar) is **drawn but inert** (greyed, non-interactive) rather than
|
||||||
hidden, so the layout does not jump. Chat send / nudge are the ⬆️ / 🛎️ icons. The input
|
hidden, so the layout does not jump. Chat send / nudge are the ⬆️ / 🛎️ icons. The input
|
||||||
row is turn-driven: on your turn the message field shows until your one allowed message is
|
row is turn-driven: on your turn the message field shows until your one allowed message is
|
||||||
|
|||||||
+29
-8
@@ -123,16 +123,15 @@ test('a pending tile recalls on double-tap, not on a single tap', async ({ page
|
|||||||
await expect(page.locator('[data-cell].pending')).toHaveCount(0);
|
await expect(page.locator('[data-cell].pending')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the board is a gapless checkerboard by default; grid lines toggle in Settings', async ({ page }) => {
|
test('the board is always a gapless checkerboard; no grid-lines setting', async ({ page }) => {
|
||||||
await openGame(page);
|
await openGame(page);
|
||||||
await expect(page.locator('.grid.gridless')).toBeVisible(); // lines off by default
|
|
||||||
|
|
||||||
await page.evaluate(() => (location.hash = '/settings'));
|
|
||||||
await page.locator('.gridlines input').check(); // turn grid lines on
|
|
||||||
await page.evaluate(() => (location.hash = '/game/g1'));
|
|
||||||
|
|
||||||
await expect(page.locator('.grid')).toBeVisible();
|
await expect(page.locator('.grid')).toBeVisible();
|
||||||
await expect(page.locator('.grid.gridless')).toHaveCount(0);
|
// The board has no lined variant any more: its cells sit flush, with no inter-cell gap.
|
||||||
|
await expect(page.locator('.grid')).toHaveCSS('column-gap', '0px');
|
||||||
|
|
||||||
|
// Settings no longer offers a grid-lines toggle.
|
||||||
|
await page.evaluate(() => (location.hash = '/settings'));
|
||||||
|
await expect(page.locator('.gridlines')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shuffle reorders the rack but keeps the same tiles', async ({ page }) => {
|
test('shuffle reorders the rack but keeps the same tiles', async ({ page }) => {
|
||||||
@@ -285,6 +284,28 @@ test('a placed tile drags from one board cell to another (relocation)', async ({
|
|||||||
expect(to).not.toBe(from);
|
expect(to).not.toBe(from);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a placed tile drags back to a chosen rack slot (recall-to-position)', async ({ page }) => {
|
||||||
|
await openGame(page);
|
||||||
|
// Place the first rack tile (slot 0) on the board, remembering its letter.
|
||||||
|
await page.locator('.rack .tile').first().click();
|
||||||
|
const placed = (await page.locator('.rack .tile .letter').first().textContent()) ?? '';
|
||||||
|
await page.locator('[data-cell]:not(.filled)').nth(30).click();
|
||||||
|
const pending = page.locator('[data-cell].pending');
|
||||||
|
await expect(pending).toHaveCount(1);
|
||||||
|
|
||||||
|
// Drag it from the board back onto a later rack slot (the 4th visible tile), not its origin.
|
||||||
|
const fb = await pending.first().boundingBox();
|
||||||
|
const slot = await page.locator('[data-rack] .tile').nth(3).boundingBox();
|
||||||
|
await page.mouse.move(fb!.x + fb!.width / 2, fb!.y + fb!.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(slot!.x + 2, slot!.y + slot!.height / 2, { steps: 10 });
|
||||||
|
await page.mouse.up();
|
||||||
|
|
||||||
|
// Recalled (no pending) and reinserted at the drop slot — slot 3, not its origin slot 0.
|
||||||
|
await expect(pending).toHaveCount(0);
|
||||||
|
await expect(page.locator('[data-rack] .tile .letter').nth(3)).toHaveText(placed);
|
||||||
|
});
|
||||||
|
|
||||||
test('comms hub: chat and dictionary share a screen, back returns 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('.scoreboard').click(); // open the history
|
||||||
|
|||||||
@@ -43,6 +43,29 @@ test('a swipe-down on the zoom-out board opens the history', async ({ page }) =>
|
|||||||
await expect(page.locator('.boardwrap.slid')).toBeVisible();
|
await expect(page.locator('.boardwrap.slid')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a recall drag onto the rack does not break the next board swipe', async ({ page }) => {
|
||||||
|
await openGame(page);
|
||||||
|
// Place a tile, then drag it back onto the rack — the recall's pointerup lands off the
|
||||||
|
// boardwrap, which once left a stale pointer there so the next swipe read as multi-touch.
|
||||||
|
await page.locator('.rack .tile').first().click();
|
||||||
|
await page.locator('[data-cell]:not(.filled)').nth(30).click();
|
||||||
|
const pending = page.locator('[data-cell].pending');
|
||||||
|
await expect(pending).toHaveCount(1);
|
||||||
|
const fb = (await pending.first().boundingBox())!;
|
||||||
|
const slot = (await page.locator('[data-rack] .tile').nth(2).boundingBox())!;
|
||||||
|
await page.mouse.move(fb.x + fb.width / 2, fb.y + fb.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await page.mouse.move(slot.x + 2, slot.y + slot.height / 2, { steps: 10 });
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(pending).toHaveCount(0);
|
||||||
|
|
||||||
|
// The swipe-down still opens the history (the recall pointer was reconciled).
|
||||||
|
await page.locator('.stage').evaluate((el) => (el.scrollTop = 0));
|
||||||
|
const box = (await page.locator('.boardwrap').boundingBox())!;
|
||||||
|
await touchSwipeDown(page, box.y + 20, box.y + 180);
|
||||||
|
await expect(page.locator('.history')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
test('the history is a per-seat grid with move scores but no names or running total', async ({ page }) => {
|
test('the history is a per-seat grid with move scores but no names or running total', async ({ page }) => {
|
||||||
await openGame(page);
|
await openGame(page);
|
||||||
await page.locator('.scoreboard').click(); // open the history (gesture is covered separately)
|
await page.locator('.scoreboard').click(); // open the history (gesture is covered separately)
|
||||||
|
|||||||
+13
-11
@@ -50,18 +50,20 @@ test('AI game: 🤖 opponent, no wait, chat disabled, dictionary still works', a
|
|||||||
await expect(page.locator('.scoreboard').getByText('🤖')).toBeVisible();
|
await expect(page.locator('.scoreboard').getByText('🤖')).toBeVisible();
|
||||||
await expect(page.getByText(/Searching for opponent/)).toHaveCount(0);
|
await expect(page.getByText(/Searching for opponent/)).toHaveCount(0);
|
||||||
|
|
||||||
// Chat is disabled (the message field), while the dictionary word-check stays usable.
|
// An AI game has no chat at all: the comms hub drops the Chat tab and lands on the Dictionary
|
||||||
|
// alone, which stays usable.
|
||||||
await page.locator('.scoreboard').click(); // open the history
|
await page.locator('.scoreboard').click(); // open the history
|
||||||
await page.getByRole('button', { name: 'Chat' }).click(); // 💬 -> comms hub
|
await page.getByRole('button', { name: 'Chat' }).click(); // 💬 (the history-header entry) -> comms hub
|
||||||
await expect(page).toHaveURL(/\/game\/.+\/chat$/);
|
await expect(page).toHaveURL(/\/game\/.+\/chat$/);
|
||||||
await expect(page.locator('.chat input')).toBeDisabled();
|
await expect(page.getByRole('button', { name: 'Chat' })).toHaveCount(0); // no Chat tab
|
||||||
await page.getByRole('button', { name: 'Dictionary' }).click();
|
await expect(page.locator('.chat input')).toHaveCount(0); // the chat body never mounts
|
||||||
await expect(page.locator('.check input')).toBeEnabled();
|
await expect(page.locator('.check input')).toBeEnabled(); // the Dictionary is the only surface
|
||||||
});
|
});
|
||||||
|
|
||||||
// GCG export is pointless for a practice AI game, so the finished AI game hides the 📤 export
|
// GCG export is pointless for a practice AI game, and a finished AI game has no comms at all
|
||||||
// (the comms hub stays). Start an AI game, resign it, reopen the history and check the header.
|
// (no chat, and the dictionary closes with the game), so both the 📤 export and the 💬 comms
|
||||||
test('AI game: no GCG export offered after it ends', async ({ page }) => {
|
// entry are gone. Start an AI game, resign it, reopen the history and check the header.
|
||||||
|
test('AI game: after it ends, no GCG export and no comms entry', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.getByRole('button', { name: /guest/i }).click();
|
await page.getByRole('button', { name: /guest/i }).click();
|
||||||
await page.getByRole('button', { name: /🎲/ }).click();
|
await page.getByRole('button', { name: /🎲/ }).click();
|
||||||
@@ -75,11 +77,11 @@ test('AI game: no GCG export offered after it ends', async ({ page }) => {
|
|||||||
await page.locator('button.danger').click();
|
await page.locator('button.danger').click();
|
||||||
await expect(page.locator('.status .over')).toBeVisible();
|
await expect(page.locator('.status .over')).toBeVisible();
|
||||||
|
|
||||||
// Reopen the history (resigning auto-closed it): the finished AI game offers no GCG export,
|
// Reopen the history (resigning auto-closed it): the finished AI game offers no GCG export and
|
||||||
// while the comms hub stays reachable.
|
// no comms entry at all.
|
||||||
await page.locator('.scoreboard').click();
|
await page.locator('.scoreboard').click();
|
||||||
await expect(page.getByRole('button', { name: 'Export GCG' })).toHaveCount(0);
|
await expect(page.getByRole('button', { name: 'Export GCG' })).toHaveCount(0);
|
||||||
await expect(page.getByRole('button', { name: 'Chat' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Chat' })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// The opponent_joined push is best-effort and never replayed, so a join that lands while the live
|
// The opponent_joined push is best-effort and never replayed, so a join that lands while the live
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { insideTelegram, telegramBackButton } from './lib/telegram';
|
import { insideTelegram, telegramBackButton } from './lib/telegram';
|
||||||
import Toast from './components/Toast.svelte';
|
import Toast from './components/Toast.svelte';
|
||||||
import StaleInviteModal from './components/StaleInviteModal.svelte';
|
import StaleInviteModal from './components/StaleInviteModal.svelte';
|
||||||
|
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
|
||||||
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';
|
||||||
@@ -110,6 +111,7 @@
|
|||||||
|
|
||||||
<Toast />
|
<Toast />
|
||||||
<StaleInviteModal />
|
<StaleInviteModal />
|
||||||
|
<WelcomeRedeemModal />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.splash {
|
.splash {
|
||||||
|
|||||||
@@ -47,7 +47,6 @@
|
|||||||
locale: lc,
|
locale: lc,
|
||||||
reduceMotion: prefs.reduceMotion ?? false,
|
reduceMotion: prefs.reduceMotion ?? false,
|
||||||
boardLabels: prefs.boardLabels ?? 'beginner',
|
boardLabels: prefs.boardLabels ?? 'beginner',
|
||||||
boardLines: prefs.boardLines ?? false,
|
|
||||||
});
|
});
|
||||||
prefs = { ...prefs, locale: lc };
|
prefs = { ...prefs, locale: lc };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,10 +51,25 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
transition: background-color 0.12s;
|
transition: background-color 0.12s;
|
||||||
}
|
}
|
||||||
/* A tab that navigates between peer views (Settings / Comms hubs) stays highlighted
|
/* A tab that navigates between peer views (Settings / Comms hubs) wraps its icon and label
|
||||||
while selected: a filled square with an accent underline. A tap itself leaves no
|
in a .face so the selected highlight hugs both — a filled pill with an accent underline,
|
||||||
highlight — there is no press tint, and the WebKit tap flash is disabled on .tab. */
|
sized to the content with a small padding. A tap itself leaves no highlight (no press
|
||||||
:global(.tab.active .sq) {
|
tint, and the WebKit tap flash is disabled on .tab). Plain action tabs (the lobby nav,
|
||||||
|
the in-game controls) carry no .face and are never .active, so they are unaffected. */
|
||||||
|
:global(.tab .face) {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: background-color 0.12s;
|
||||||
|
}
|
||||||
|
/* Inside a face the icon square needs no padding of its own — the face provides the
|
||||||
|
surround (the bare .sq padding still applies to the unwrapped action tabs). */
|
||||||
|
:global(.tab .face .sq) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
:global(.tab.active .face) {
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
box-shadow: inset 0 -2px 0 var(--accent);
|
box-shadow: inset 0 -2px 0 var(--accent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,28 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade, fly } from 'svelte/transition';
|
import { fade, fly } from 'svelte/transition';
|
||||||
import { app } from '../lib/app.svelte';
|
import { app, dismissToast } from '../lib/app.svelte';
|
||||||
|
|
||||||
const dur = $derived(app.reduceMotion ? 0 : 260);
|
const dur = $derived(app.reduceMotion ? 0 : 260);
|
||||||
|
// An info bubble owns its whole 2s life through a CSS rise-and-fade animation, so it takes
|
||||||
|
// no Svelte enter/leave transition; an error keeps the fly-in / fade-out dwell behaviour.
|
||||||
|
const isInfo = $derived(app.toast?.kind !== 'error');
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if app.toast}
|
{#if app.toast}
|
||||||
<!-- Re-key on the toast's seq so a fresh message tears the old node down (out:fade) and replays
|
<!-- Re-key on the toast's seq so a fresh message tears the old node down and replays the
|
||||||
the entrance (in:fly): the newest toast cancels the previous one and starts its own cycle. -->
|
appear cycle: the newest toast cancels the previous one and starts its own. -->
|
||||||
{#key app.toast.seq}
|
{#key app.toast.seq}
|
||||||
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<div
|
<div
|
||||||
class="toast {app.toast.kind}"
|
class="toast {app.toast.kind}"
|
||||||
|
class:rise={isInfo && !app.reduceMotion}
|
||||||
|
class:rise-reduced={isInfo && app.reduceMotion}
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
in:fly={{ y: 32, duration: dur }}
|
onclick={dismissToast}
|
||||||
out:fade={{ duration: dur }}
|
in:fly={{ y: isInfo ? 0 : 32, duration: isInfo ? 0 : dur }}
|
||||||
|
out:fade={{ duration: isInfo ? 0 : dur }}
|
||||||
>
|
>
|
||||||
{app.toast.text}
|
{app.toast.text}
|
||||||
</div>
|
</div>
|
||||||
@@ -36,9 +44,45 @@
|
|||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.error {
|
.error {
|
||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
/* An info bubble fades in, then drifts up by roughly the tab-bar height while fading out,
|
||||||
|
all within 2s; the X centring is preserved across the rise. */
|
||||||
|
.toast.rise {
|
||||||
|
animation: toast-rise 2s ease-out forwards;
|
||||||
|
}
|
||||||
|
@keyframes toast-rise {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-50%) translateY(8px);
|
||||||
|
}
|
||||||
|
12% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-50%) translateY(0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-50%) translateY(-56px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Reduced motion: the same 2s life, fade only — no upward travel. */
|
||||||
|
.toast.rise-reduced {
|
||||||
|
animation: toast-rise-reduced 2s ease-out forwards;
|
||||||
|
}
|
||||||
|
@keyframes toast-rise-reduced {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
12%,
|
||||||
|
70% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { app, dismissWelcomeRedeem } from '../lib/app.svelte';
|
||||||
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
|
import { botUsername } from '../lib/deeplink';
|
||||||
|
import { telegramOpenLink } from '../lib/telegram';
|
||||||
|
import Modal from './Modal.svelte';
|
||||||
|
|
||||||
|
// Point at the bot the player signed in through (its service language), falling back to the
|
||||||
|
// interface locale, so an ru player is sent to the ru bot and an en player to the en one.
|
||||||
|
const username = $derived(botUsername(app.session?.serviceLanguage || app.locale));
|
||||||
|
// Greet the arriving player by their own display name.
|
||||||
|
const name = $derived(app.profile?.displayName || app.session?.displayName || '');
|
||||||
|
// Interpolate the name, then split the rest around the {bot} token so the bot handle renders
|
||||||
|
// as an inline link (the {name} value is substituted first; {bot} is left for the split).
|
||||||
|
const parts = $derived(t('friends.welcomeRedeem', { name }).split('{bot}'));
|
||||||
|
|
||||||
|
function openBot() {
|
||||||
|
if (username) {
|
||||||
|
const url = `https://t.me/${username}`;
|
||||||
|
// Inside Telegram, openTelegramLink navigates to the bot chat natively; elsewhere fall
|
||||||
|
// back to a new tab.
|
||||||
|
if (!telegramOpenLink(url) && typeof window !== 'undefined') window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
dismissWelcomeRedeem();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if app.welcomeRedeem}
|
||||||
|
<Modal title={t('friends.welcomeRedeemTitle')} onclose={dismissWelcomeRedeem}>
|
||||||
|
<p class="msg">{parts[0]}{#if username}<button type="button" class="bot" onclick={openBot}>@{username}</button>{/if}{parts[1] ?? ''}</p>
|
||||||
|
<button class="ok" onclick={dismissWelcomeRedeem}>{t('common.ok')}</button>
|
||||||
|
</Modal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.msg {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
line-height: 1.5;
|
||||||
|
/* The greeting and the bot sentence are separated by a blank line in the message. */
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
.bot {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.ok {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-text);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+18
-33
@@ -18,7 +18,6 @@
|
|||||||
landscape,
|
landscape,
|
||||||
variant,
|
variant,
|
||||||
labelMode,
|
labelMode,
|
||||||
lines,
|
|
||||||
locale,
|
locale,
|
||||||
focus,
|
focus,
|
||||||
recenter,
|
recenter,
|
||||||
@@ -41,8 +40,6 @@
|
|||||||
landscape: boolean;
|
landscape: boolean;
|
||||||
variant: Variant;
|
variant: Variant;
|
||||||
labelMode: BoardLabelMode;
|
labelMode: BoardLabelMode;
|
||||||
/** Draw 1px grid lines between cells; when false the board is a gapless checkerboard. */
|
|
||||||
lines: boolean;
|
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
focus: { row: number; col: number } | null;
|
focus: { row: number; col: number } | null;
|
||||||
/** A monotonic nonce the parent bumps to request a scroll to `focus` even when the zoom state
|
/** A monotonic nonce the parent bumps to request a scroll to `focus` even when the zoom state
|
||||||
@@ -234,7 +231,7 @@
|
|||||||
|
|
||||||
<div class="viewport" class:zoomed class:land={landscape} bind:this={viewport}>
|
<div class="viewport" class:zoomed class:land={landscape} bind:this={viewport}>
|
||||||
<div class="scaler" class:land={landscape} style="--z: {z};">
|
<div class="scaler" class:land={landscape} style="--z: {z};">
|
||||||
<div class="grid" class:gridless={!lines}>
|
<div class="grid">
|
||||||
{#each board as rowCells, r (r)}
|
{#each board as rowCells, r (r)}
|
||||||
{#each rowCells as cell, c (c)}
|
{#each rowCells as cell, c (c)}
|
||||||
{@const p = pending.get(key(r, c))}
|
{@const p = pending.get(key(r, c))}
|
||||||
@@ -311,18 +308,21 @@
|
|||||||
width 0.25s ease,
|
width 0.25s ease,
|
||||||
height 0.25s ease;
|
height 0.25s ease;
|
||||||
}
|
}
|
||||||
|
/* A gapless checkerboard with no grid lines (the board has no lined variant): plain cells
|
||||||
|
alternate shades and tiles get rounded corners plus a soft right-side shadow so adjacent
|
||||||
|
gapless tiles still read as separate pieces. */
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(15, 1fr);
|
grid-template-columns: repeat(15, 1fr);
|
||||||
gap: 1px;
|
gap: 0;
|
||||||
background: var(--cell-line);
|
background: var(--board-bg);
|
||||||
padding: 1px;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.cell {
|
.cell {
|
||||||
position: relative;
|
position: relative;
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 1px;
|
border-radius: 0;
|
||||||
background: var(--cell-bg);
|
background: var(--cell-bg);
|
||||||
color: var(--prem-text);
|
color: var(--prem-text);
|
||||||
/* No mobile tap flash on a cell tap (parity with the web click; the only intentional
|
/* No mobile tap flash on a cell tap (parity with the web click; the only intentional
|
||||||
@@ -344,11 +344,20 @@
|
|||||||
.cell.dl {
|
.cell.dl {
|
||||||
background: var(--prem-dl);
|
background: var(--prem-dl);
|
||||||
}
|
}
|
||||||
|
.cell.dark {
|
||||||
|
/* A gentle checkerboard tint: enough to read the alternation without competing with the
|
||||||
|
bonus cells, standing in for the grid lines that once separated the cells. Lightened
|
||||||
|
from a stronger mix that looked too contrasty in light theme. */
|
||||||
|
background: color-mix(in srgb, var(--cell-bg) 94%, #000);
|
||||||
|
}
|
||||||
.cell.filled,
|
.cell.filled,
|
||||||
.cell.pending {
|
.cell.pending {
|
||||||
background: var(--tile-bg);
|
background: var(--tile-bg);
|
||||||
color: var(--tile-text);
|
color: var(--tile-text);
|
||||||
box-shadow: inset 0 -2px 0 var(--tile-edge);
|
border-radius: 4px;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 -2px 0 var(--tile-edge),
|
||||||
|
2px 0 3px -1px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
.cell.pending {
|
.cell.pending {
|
||||||
background: var(--tile-pending);
|
background: var(--tile-pending);
|
||||||
@@ -356,30 +365,6 @@
|
|||||||
board) instead of the touch starting a board pan. */
|
board) instead of the touch starting a board pan. */
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
}
|
}
|
||||||
/* Lines-off variant: a gapless checkerboard. The 1px grid gaps (and the cell-line they
|
|
||||||
reveal) collapse, saving ~14px of board width; plain cells alternate shades, and tiles
|
|
||||||
get rounded corners and a soft right-side shadow so adjacent gapless tiles still read
|
|
||||||
as separate pieces. */
|
|
||||||
.grid.gridless {
|
|
||||||
gap: 0;
|
|
||||||
padding: 0;
|
|
||||||
background: var(--board-bg);
|
|
||||||
}
|
|
||||||
.grid.gridless .cell {
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
.grid.gridless .cell.dark {
|
|
||||||
/* A gentle checkerboard tint: enough to read the alternation without competing with the
|
|
||||||
bonus cells. Lightened from a stronger mix that looked too contrasty in light theme. */
|
|
||||||
background: color-mix(in srgb, var(--cell-bg) 94%, #000);
|
|
||||||
}
|
|
||||||
.grid.gridless .cell.filled,
|
|
||||||
.grid.gridless .cell.pending {
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow:
|
|
||||||
inset 0 -2px 0 var(--tile-edge),
|
|
||||||
2px 0 3px -1px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
.cell.droptarget {
|
.cell.droptarget {
|
||||||
/* The cell a carried tile is aimed at: an accent ring plus a light accent wash, so the
|
/* The cell a carried tile is aimed at: an accent ring plus a light accent wash, so the
|
||||||
target reads clearly while dragging without obscuring the bonus label underneath. */
|
target reads clearly while dragging without obscuring the bonus label underneath. */
|
||||||
|
|||||||
@@ -8,19 +8,24 @@
|
|||||||
|
|
||||||
// The in-game comms hub: a single nav bar + bottom tab bar hosting Chat and the word
|
// 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. 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").
|
// Dictionary tab is offered only while the game is active (mirrors the old "check word"); an
|
||||||
|
// honest-AI game has no Chat, so it shows the Dictionary alone.
|
||||||
type CommsTab = 'chat' | 'dictionary';
|
type CommsTab = 'chat' | 'dictionary';
|
||||||
let { id, initialTab = 'chat' }: { id: string; initialTab?: CommsTab } = $props();
|
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
|
// 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.
|
// it is still active without another fetch; an unknown game keeps the Dictionary offered.
|
||||||
const active = $derived(getCachedGame(id)?.view.game.status !== 'finished');
|
const active = $derived(getCachedGame(id)?.view.game.status !== 'finished');
|
||||||
// Seeded once from the entry route's tab and then owned locally; the effect below
|
// An honest-AI game has no chat at all, so its hub is Dictionary-only.
|
||||||
// corrects a Dictionary deep-link into a finished game back to Chat.
|
const vsAi = $derived(getCachedGame(id)?.view.game.vsAi ?? false);
|
||||||
|
// Seeded once from the entry route's tab, then owned locally. The effect keeps the tab valid:
|
||||||
|
// an AI game has only the Dictionary; a finished non-AI game has only Chat (a stale Dictionary
|
||||||
|
// deep-link falls back to Chat).
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
let tab = $state<CommsTab>(initialTab);
|
let tab = $state<CommsTab>(initialTab);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (tab === 'dictionary' && !active) tab = 'chat';
|
if (vsAi) tab = 'dictionary';
|
||||||
|
else if (tab === 'dictionary' && !active) tab = 'chat';
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -38,12 +43,14 @@
|
|||||||
|
|
||||||
{#snippet tabbar()}
|
{#snippet tabbar()}
|
||||||
<TabBar>
|
<TabBar>
|
||||||
<button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')}>
|
{#if !vsAi}
|
||||||
<span class="sq" aria-hidden="true">💬</span><span class="lbl">{t('game.chat')}</span>
|
<button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')}>
|
||||||
</button>
|
<span class="face"><span class="sq" aria-hidden="true">💬</span><span class="lbl">{t('game.chat')}</span></span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
{#if active}
|
{#if active}
|
||||||
<button class="tab" class:active={tab === 'dictionary'} onclick={() => (tab = 'dictionary')}>
|
<button class="tab" class:active={tab === 'dictionary'} onclick={() => (tab = 'dictionary')}>
|
||||||
<span class="sq" aria-hidden="true">🔎</span><span class="lbl">{t('game.dictionary')}</span>
|
<span class="face"><span class="sq" aria-hidden="true">🔎</span><span class="lbl">{t('game.dictionary')}</span></span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</TabBar>
|
</TabBar>
|
||||||
|
|||||||
+57
-13
@@ -31,6 +31,7 @@
|
|||||||
placementFromHint,
|
placementFromHint,
|
||||||
rackView,
|
rackView,
|
||||||
recallAt,
|
recallAt,
|
||||||
|
recallToSlot,
|
||||||
reorderIndices,
|
reorderIndices,
|
||||||
reset,
|
reset,
|
||||||
toSubmit,
|
toSubmit,
|
||||||
@@ -377,6 +378,10 @@
|
|||||||
// While a placed (pending) board tile is dragged to relocate it, draggingPend is its cell —
|
// While a placed (pending) board tile is dragged to relocate it, draggingPend is its cell —
|
||||||
// hidden from the board (the ghost stands in) like a lifted rack tile.
|
// hidden from the board (the ghost stands in) like a lifted rack tile.
|
||||||
let draggingPend = $state<{ row: number; col: number } | null>(null);
|
let draggingPend = $state<{ row: number; col: number } | null>(null);
|
||||||
|
// A board tile dragged over the rack (a recall in progress, the rack showing a drop gap): the
|
||||||
|
// move's ✅ confirm and its preview caption are hidden so they don't sit over the opening gap;
|
||||||
|
// they return the moment the tile is dragged back out over the board.
|
||||||
|
const recallOverRack = $derived(draggingPend != null && reorderTo != null);
|
||||||
|
|
||||||
let dragPointerId = -1;
|
let dragPointerId = -1;
|
||||||
function beginDrag(src: DragSrc, e: PointerEvent) {
|
function beginDrag(src: DragSrc, e: PointerEvent) {
|
||||||
@@ -397,6 +402,7 @@
|
|||||||
window.removeEventListener('pointermove', onWinMove);
|
window.removeEventListener('pointermove', onWinMove);
|
||||||
window.removeEventListener('pointerup', onWinUp);
|
window.removeEventListener('pointerup', onWinUp);
|
||||||
window.removeEventListener('pointerdown', onExtraPointer);
|
window.removeEventListener('pointerdown', onExtraPointer);
|
||||||
|
boardPointers.delete(dragPointerId); // see onWinUp: don't leave a stale boardwrap pointer
|
||||||
clearHover();
|
clearHover();
|
||||||
clearReorder();
|
clearReorder();
|
||||||
downInfo = null;
|
downInfo = null;
|
||||||
@@ -481,7 +487,12 @@
|
|||||||
if (c) {
|
if (c) {
|
||||||
dropTarget = !board[c.row]?.[c.col] && !pendingMap.has(`${c.row},${c.col}`) ? c : null;
|
dropTarget = !board[c.row]?.[c.col] && !pendingMap.has(`${c.row},${c.col}`) ? c : null;
|
||||||
reorderTo = null;
|
reorderTo = null;
|
||||||
} else if (reorderDragId != null && overRack(e.clientY) && placement.pending.length === 0) {
|
} else if (
|
||||||
|
overRack(e.clientY) &&
|
||||||
|
// A rack-source reorder (only with a clean, pending-free rack) or a board tile dragged
|
||||||
|
// back to the rack — both preview the drop slot as a gap the dropped tile will fill.
|
||||||
|
((reorderDragId != null && placement.pending.length === 0) || draggingPend != null)
|
||||||
|
) {
|
||||||
reorderTo = dropSlotAt(e.clientX);
|
reorderTo = dropSlotAt(e.clientX);
|
||||||
dropTarget = null;
|
dropTarget = null;
|
||||||
} else {
|
} else {
|
||||||
@@ -510,6 +521,11 @@
|
|||||||
window.removeEventListener('pointermove', onWinMove);
|
window.removeEventListener('pointermove', onWinMove);
|
||||||
window.removeEventListener('pointerup', onWinUp);
|
window.removeEventListener('pointerup', onWinUp);
|
||||||
window.removeEventListener('pointerdown', onExtraPointer);
|
window.removeEventListener('pointerdown', onExtraPointer);
|
||||||
|
// A board-tile drag also registered this pointer on the boardwrap (the tile sits inside it),
|
||||||
|
// but it can be released off the boardwrap (e.g. a recall dropped on the rack), where the
|
||||||
|
// boardwrap's own pointerup never fires. Reconcile it here so no stale id is left to make the
|
||||||
|
// next swipe read as multi-touch and kill the shuffle / history-pull gesture.
|
||||||
|
boardPointers.delete(e.pointerId);
|
||||||
clearHover();
|
clearHover();
|
||||||
const di = downInfo;
|
const di = downInfo;
|
||||||
downInfo = null;
|
downInfo = null;
|
||||||
@@ -527,8 +543,15 @@
|
|||||||
// Dropped a placed tile on another board cell → relocate it there.
|
// Dropped a placed tile on another board cell → relocate it there.
|
||||||
relocatePending(di.src.row, di.src.col, cell.row, cell.col);
|
relocatePending(di.src.row, di.src.col, cell.row, cell.col);
|
||||||
} else if (di.src.from === 'board' && onRack) {
|
} else if (di.src.from === 'board' && onRack) {
|
||||||
// Dropped a placed tile back onto the rack → recall it to its original slot.
|
// Dropped a placed tile back onto the rack → recall it to the drop slot the player aimed
|
||||||
placement = recallAt(placement, di.src.row, di.src.col);
|
// at (drag-to-position), or to its original slot when the drop is not over a gap.
|
||||||
|
if (to != null) {
|
||||||
|
const res = recallToSlot(placement, di.src.row, di.src.col, to);
|
||||||
|
rackIds = res.order.map((i) => rackIds[i] ?? i);
|
||||||
|
placement = res.placement;
|
||||||
|
} else {
|
||||||
|
placement = recallAt(placement, di.src.row, di.src.col);
|
||||||
|
}
|
||||||
selected = null;
|
selected = null;
|
||||||
recompute();
|
recompute();
|
||||||
scheduleDraftSave();
|
scheduleDraftSave();
|
||||||
@@ -813,6 +836,8 @@
|
|||||||
// - closed: a downward "pull" opens the history, but only on the zoom-out board scrolled
|
// - closed: a downward "pull" opens the history, but only on the zoom-out board scrolled
|
||||||
// to its top — zoomed, the one-finger drag pans the board, and mid-scroll a downward drag
|
// to its top — zoomed, the one-finger drag pans the board, and mid-scroll a downward drag
|
||||||
// is the stage's own vertical scroll (the conflict that once retired this open gesture).
|
// is the stage's own vertical scroll (the conflict that once retired this open gesture).
|
||||||
|
// On that same closed, zoom-out board an upward swipe with no staged tiles shuffles the
|
||||||
|
// rack (a convenience mirror of the shuffle control).
|
||||||
// Both genuinely set `historyOpen` (closing no longer merely scrolls the slid board out of
|
// Both genuinely set `historyOpen` (closing no longer merely scrolls the slid board out of
|
||||||
// view, which left a stale-open state that made a follow-up score-bar tap "jump" the board).
|
// view, which left a stale-open state that made a follow-up score-bar tap "jump" the board).
|
||||||
let stageEl = $state<HTMLDivElement>();
|
let stageEl = $state<HTMLDivElement>();
|
||||||
@@ -869,6 +894,14 @@
|
|||||||
if (-dy > 32) closeHistoryByGesture(); // enough upward travel
|
if (-dy > 32) closeHistoryByGesture(); // enough upward travel
|
||||||
} else if (dy > 40 && dy > Math.abs(dx) * 1.4) {
|
} else if (dy > 40 && dy > Math.abs(dx) * 1.4) {
|
||||||
openHistoryByGesture(); // a clear, vertical-dominant downward pull
|
openHistoryByGesture(); // a clear, vertical-dominant downward pull
|
||||||
|
} else if (-dy > 40 && -dy > Math.abs(dx) * 1.4 && placement.pending.length === 0) {
|
||||||
|
// The same closed-board arm, opposite direction: a clear upward swipe on the zoom-out
|
||||||
|
// board with no staged tiles shuffles the rack (a convenience mirror of the shuffle
|
||||||
|
// control). Disarm and swallow the synthesised click so it cannot also place a tile.
|
||||||
|
shuffle();
|
||||||
|
boardSwipe = null;
|
||||||
|
swallowClick = true;
|
||||||
|
setTimeout(() => (swallowClick = false), 120);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function onBoardWrapUp(e: PointerEvent) {
|
function onBoardWrapUp(e: PointerEvent) {
|
||||||
@@ -1104,9 +1137,13 @@
|
|||||||
<button class="hicon" onclick={() => (resignOpen = true)} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
|
<button class="hicon" onclick={() => (resignOpen = true)} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
|
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
|
||||||
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
|
<!-- A finished AI game has no comms at all (no chat, and the dictionary closes with the
|
||||||
{#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key}
|
game), so the entry is dropped; an active AI game keeps it (it opens the dictionary). -->
|
||||||
</button>
|
{#if !(gameOver && view.game.vsAi)}
|
||||||
|
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
|
||||||
|
{#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="hgridwrap">
|
<div class="hgridwrap">
|
||||||
<div class="hgrid" style="grid-template-columns: repeat({view.game.seats.length}, 1fr)">
|
<div class="hgrid" style="grid-template-columns: repeat({view.game.seats.length}, 1fr)">
|
||||||
@@ -1156,7 +1193,6 @@
|
|||||||
{landscape}
|
{landscape}
|
||||||
{variant}
|
{variant}
|
||||||
labelMode={app.boardLabels}
|
labelMode={app.boardLabels}
|
||||||
lines={app.boardLines}
|
|
||||||
locale={app.locale}
|
locale={app.locale}
|
||||||
{focus}
|
{focus}
|
||||||
{recenter}
|
{recenter}
|
||||||
@@ -1179,7 +1215,7 @@
|
|||||||
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : turnLabel()}</span>
|
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : turnLabel()}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="scores">
|
<span class="scores">
|
||||||
{#if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{:else if !view.game.multipleWordsPerTurn}<span class="oneword" title={t('game.oneWordRule')}>1️⃣</span>{/if}
|
{#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{:else if !view.game.multipleWordsPerTurn}<span class="oneword" title={t('game.oneWordRule')}>1️⃣</span>{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -1201,7 +1237,7 @@
|
|||||||
ondown={onRackDown}
|
ondown={onRackDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{#if !gameOver && placement.pending.length > 0}
|
{#if !gameOver && placement.pending.length > 0 && !recallOverRack}
|
||||||
<button class="make" onclick={commit} disabled={busy || !isMyTurn || !connection.online || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
|
<button class="make" onclick={commit} disabled={busy || !isMyTurn || !connection.online || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -1447,9 +1483,9 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.rack-row {
|
.rack-row {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: none;
|
flex: none;
|
||||||
gap: 8px;
|
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
padding: 0 var(--pad) 6px;
|
padding: 0 var(--pad) 6px;
|
||||||
}
|
}
|
||||||
@@ -1462,14 +1498,22 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
/* A borderless icon button (like the tab bar), not a filled accent button — and disabled
|
/* A borderless icon button (like the tab bar), not a filled accent button — and disabled
|
||||||
while the pending word is known to be illegal. */
|
while the pending word is known to be illegal. It is an overlay, NOT a flex sibling of the
|
||||||
|
rack: the rack keeps the full row width with a fixed tile size (no reflow or tile resize
|
||||||
|
when a tile is staged), and the button is absolutely pinned over the slots a staged tile
|
||||||
|
frees on the right. Its right edge sits at var(--pad) — directly under the right edge of the
|
||||||
|
preview caption above (.status shares that padding). */
|
||||||
.make {
|
.make {
|
||||||
min-width: 56px;
|
position: absolute;
|
||||||
|
right: var(--pad);
|
||||||
|
top: 0;
|
||||||
|
bottom: 6px;
|
||||||
|
width: 56px;
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border: none;
|
border: none;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center end;
|
||||||
font-size: 1.8rem;
|
font-size: 1.8rem;
|
||||||
}
|
}
|
||||||
.make:disabled {
|
.make:disabled {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="rack" class:reordering={draggingId != null} class:landscape data-rack>
|
<div class="rack" class:reordering={draggingId != null || dropIndex != null} class:landscape data-rack>
|
||||||
{#each shown as slot, i (slot.id)}
|
{#each shown as slot, i (slot.id)}
|
||||||
<button
|
<button
|
||||||
class="tile"
|
class="tile"
|
||||||
|
|||||||
+29
-12
@@ -56,8 +56,6 @@ export const app = $state<{
|
|||||||
locale: Locale;
|
locale: Locale;
|
||||||
reduceMotion: boolean;
|
reduceMotion: boolean;
|
||||||
boardLabels: BoardLabelMode;
|
boardLabels: BoardLabelMode;
|
||||||
/** Draw grid lines between board cells; off (default) is a gapless checkerboard. */
|
|
||||||
boardLines: boolean;
|
|
||||||
localeLocked: boolean;
|
localeLocked: boolean;
|
||||||
/** Pending incoming friend requests, for the lobby ⚙️ badge and the Settings Friends tab. */
|
/** Pending incoming friend requests, for the lobby ⚙️ badge and the Settings Friends tab. */
|
||||||
notifications: number;
|
notifications: number;
|
||||||
@@ -72,6 +70,10 @@ export const app = $state<{
|
|||||||
* code is already used/expired, so the visitor lands in the lobby with a gentle pointer to
|
* code is already used/expired, so the visitor lands in the lobby with a gentle pointer to
|
||||||
* the bot instead of a scary error on the Friends screen. */
|
* the bot instead of a scary error on the Friends screen. */
|
||||||
staleInvite: boolean;
|
staleInvite: boolean;
|
||||||
|
/** Whether to show the Telegram "welcome" window: set when a deep-link friend code is
|
||||||
|
* redeemed successfully inside Telegram, greeting the arriving player and pointing them at
|
||||||
|
* the bot for the most convenient way to play. */
|
||||||
|
welcomeRedeem: boolean;
|
||||||
/** Monotonic counter bumped when the app returns to the foreground without the live stream
|
/** Monotonic counter bumped when the app returns to the foreground without the live stream
|
||||||
* having dropped. An open game watches it to refetch once, recovering an in-game event shed
|
* having dropped. An open game watches it to refetch once, recovering an in-game event shed
|
||||||
* from a full hub buffer while suspended (the stream-drop case is covered by streamAlive). */
|
* from a full hub buffer while suspended (the stream-drop case is covered by streamAlive). */
|
||||||
@@ -88,12 +90,12 @@ export const app = $state<{
|
|||||||
locale: 'en',
|
locale: 'en',
|
||||||
reduceMotion: false,
|
reduceMotion: false,
|
||||||
boardLabels: 'beginner',
|
boardLabels: 'beginner',
|
||||||
boardLines: false,
|
|
||||||
localeLocked: false,
|
localeLocked: false,
|
||||||
notifications: 0,
|
notifications: 0,
|
||||||
chatUnread: {},
|
chatUnread: {},
|
||||||
feedbackReplyUnread: false,
|
feedbackReplyUnread: false,
|
||||||
staleInvite: false,
|
staleInvite: false,
|
||||||
|
welcomeRedeem: false,
|
||||||
resync: 0,
|
resync: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -143,7 +145,18 @@ function goForeground(): void {
|
|||||||
export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
|
export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
|
||||||
app.toast = { kind, text, seq: ++toastSeq };
|
app.toast = { kind, text, seq: ++toastSeq };
|
||||||
if (toastTimer) clearTimeout(toastTimer);
|
if (toastTimer) clearTimeout(toastTimer);
|
||||||
toastTimer = setTimeout(() => (app.toast = null), 4000);
|
// An info bubble lives exactly as long as its rise-and-fade animation (2s); an error
|
||||||
|
// dwells longer (4s) so it is not missed.
|
||||||
|
toastTimer = setTimeout(() => (app.toast = null), kind === 'error' ? 4000 : 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** dismissToast hides the current toast at once — a tap on the bubble dismisses it. */
|
||||||
|
export function dismissToast(): void {
|
||||||
|
if (toastTimer) {
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer = null;
|
||||||
|
}
|
||||||
|
app.toast = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** dismissStaleInvite hides the outdated-invite-link notice (the user acknowledged it). */
|
/** dismissStaleInvite hides the outdated-invite-link notice (the user acknowledged it). */
|
||||||
@@ -151,6 +164,11 @@ export function dismissStaleInvite(): void {
|
|||||||
app.staleInvite = false;
|
app.staleInvite = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** dismissWelcomeRedeem hides the Telegram welcome window (the user acknowledged it). */
|
||||||
|
export function dismissWelcomeRedeem(): void {
|
||||||
|
app.welcomeRedeem = false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* seedChatUnread sets a game's unread flag from an authoritative per-viewer REST view (the lobby
|
* seedChatUnread sets a game's unread flag from an authoritative per-viewer REST view (the lobby
|
||||||
* list, a game's state, or a move result). The live-event GameView omits the flag, so the live
|
* list, a game's state, or a move result). The live-event GameView omits the flag, so the live
|
||||||
@@ -476,7 +494,6 @@ export async function bootstrap(): Promise<void> {
|
|||||||
app.theme = prefs.theme ?? 'auto';
|
app.theme = prefs.theme ?? 'auto';
|
||||||
app.reduceMotion = prefs.reduceMotion ?? false;
|
app.reduceMotion = prefs.reduceMotion ?? false;
|
||||||
app.boardLabels = prefs.boardLabels ?? 'beginner';
|
app.boardLabels = prefs.boardLabels ?? 'beginner';
|
||||||
app.boardLines = prefs.boardLines ?? false;
|
|
||||||
applyTheme(app.theme);
|
applyTheme(app.theme);
|
||||||
applyReduceMotion(app.reduceMotion);
|
applyReduceMotion(app.reduceMotion);
|
||||||
if (prefs.locale) {
|
if (prefs.locale) {
|
||||||
@@ -560,7 +577,13 @@ async function routeStartParam(param: string): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const friend = await gateway.friendCodeRedeem(link.code);
|
const friend = await gateway.friendCodeRedeem(link.code);
|
||||||
navigate('/friends');
|
navigate('/friends');
|
||||||
showToast(t('friends.added', { name: friend.displayName }));
|
// Inside Telegram a successful invite-link redeem welcomes the arriving player and
|
||||||
|
// points them at the bot (its native convenience); elsewhere a quiet toast suffices.
|
||||||
|
if (insideTelegram()) {
|
||||||
|
app.welcomeRedeem = true;
|
||||||
|
} else {
|
||||||
|
showToast(t('friends.added', { name: friend.displayName }));
|
||||||
|
}
|
||||||
void refreshNotifications();
|
void refreshNotifications();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code = err instanceof GatewayError ? err.code : '';
|
const code = err instanceof GatewayError ? err.code : '';
|
||||||
@@ -633,7 +656,6 @@ function persistPrefs(): void {
|
|||||||
locale: app.locale,
|
locale: app.locale,
|
||||||
reduceMotion: app.reduceMotion,
|
reduceMotion: app.reduceMotion,
|
||||||
boardLabels: app.boardLabels,
|
boardLabels: app.boardLabels,
|
||||||
boardLines: app.boardLines,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -686,11 +708,6 @@ export function setBoardLabels(mode: BoardLabelMode): void {
|
|||||||
persistPrefs();
|
persistPrefs();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setBoardLines(on: boolean): void {
|
|
||||||
app.boardLines = on;
|
|
||||||
persistPrefs();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Background/foreground lifecycle: silence the reconnect banner during a suspend and
|
// Background/foreground lifecycle: silence the reconnect banner during a suspend and
|
||||||
// reconnect quietly on return (and refresh the lobby badge for any push missed while
|
// reconnect quietly on return (and refresh the lobby badge for any push missed while
|
||||||
// hidden, §10). Several signals cover the platforms: the page Visibility API, the
|
// hidden, §10). Several signals cover the platforms: the page Visibility API, the
|
||||||
|
|||||||
@@ -163,7 +163,6 @@ export const en = {
|
|||||||
'settings.labelsBeginner': 'Beginner',
|
'settings.labelsBeginner': 'Beginner',
|
||||||
'settings.labelsClassic': 'Classic',
|
'settings.labelsClassic': 'Classic',
|
||||||
'settings.labelsNone': 'None',
|
'settings.labelsNone': 'None',
|
||||||
'settings.boardLines': 'Grid lines',
|
|
||||||
'settings.reduceMotion': 'Reduce motion',
|
'settings.reduceMotion': 'Reduce motion',
|
||||||
|
|
||||||
'about.title': 'About',
|
'about.title': 'About',
|
||||||
@@ -241,6 +240,8 @@ export const en = {
|
|||||||
'friends.selfInvite': "Hopefully you've been friends with yourself for a while ☺️",
|
'friends.selfInvite': "Hopefully you've been friends with yourself for a while ☺️",
|
||||||
'friends.staleInviteTitle': 'Link expired',
|
'friends.staleInviteTitle': 'Link expired',
|
||||||
'friends.staleInvite': 'You opened the game from an outdated link. Open the bot {bot} to play and get notifications.',
|
'friends.staleInvite': 'You opened the game from an outdated link. Open the bot {bot} to play and get notifications.',
|
||||||
|
'friends.welcomeRedeemTitle': 'Welcome!',
|
||||||
|
'friends.welcomeRedeem': 'Welcome, {name}!\n\nUse {bot} to join games the most convenient way.',
|
||||||
'friends.added': 'Added {name}.',
|
'friends.added': 'Added {name}.',
|
||||||
'friends.blockedList': 'Blocked players',
|
'friends.blockedList': 'Blocked players',
|
||||||
'friends.unblock': 'Unblock',
|
'friends.unblock': 'Unblock',
|
||||||
|
|||||||
@@ -164,7 +164,6 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'settings.labelsBeginner': 'Новичок',
|
'settings.labelsBeginner': 'Новичок',
|
||||||
'settings.labelsClassic': 'Классика',
|
'settings.labelsClassic': 'Классика',
|
||||||
'settings.labelsNone': 'Без текста',
|
'settings.labelsNone': 'Без текста',
|
||||||
'settings.boardLines': 'Линии сетки',
|
|
||||||
'settings.reduceMotion': 'Меньше анимаций',
|
'settings.reduceMotion': 'Меньше анимаций',
|
||||||
|
|
||||||
'about.title': 'О программе',
|
'about.title': 'О программе',
|
||||||
@@ -242,6 +241,8 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'friends.selfInvite': 'Надеюсь, что с собой Вы уже давно дружите ☺️',
|
'friends.selfInvite': 'Надеюсь, что с собой Вы уже давно дружите ☺️',
|
||||||
'friends.staleInviteTitle': 'Ссылка устарела',
|
'friends.staleInviteTitle': 'Ссылка устарела',
|
||||||
'friends.staleInvite': 'Вы открыли игру по устаревшей ссылке. Откройте бота {bot}, чтобы играть и получать уведомления.',
|
'friends.staleInvite': 'Вы открыли игру по устаревшей ссылке. Откройте бота {bot}, чтобы играть и получать уведомления.',
|
||||||
|
'friends.welcomeRedeemTitle': 'Добро пожаловать!',
|
||||||
|
'friends.welcomeRedeem': 'Добро пожаловать, {name}!\n\nВоспользуйтесь {bot}, чтобы заходить в игру наиболее удобным способом.',
|
||||||
'friends.added': 'Добавлен(а) {name}.',
|
'friends.added': 'Добавлен(а) {name}.',
|
||||||
'friends.blockedList': 'Заблокированные',
|
'friends.blockedList': 'Заблокированные',
|
||||||
'friends.unblock': 'Разблокировать',
|
'friends.unblock': 'Разблокировать',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
rackView,
|
rackView,
|
||||||
recallAt,
|
recallAt,
|
||||||
recallIndex,
|
recallIndex,
|
||||||
|
recallToSlot,
|
||||||
reorderIndices,
|
reorderIndices,
|
||||||
reset,
|
reset,
|
||||||
toSubmit,
|
toSubmit,
|
||||||
@@ -112,3 +113,35 @@ describe('reorderIndices', () => {
|
|||||||
expect(reorderIndices(3, 0, 99)).toEqual([1, 2, 0]); // slot clamped to the end
|
expect(reorderIndices(3, 0, 99)).toEqual([1, 2, 0]); // slot clamped to the end
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('recallToSlot', () => {
|
||||||
|
it('reinserts the recalled tile at the chosen visible slot, reordering the rack', () => {
|
||||||
|
// Lift Q (slot 1) onto the board, then recall it to visible slot 4.
|
||||||
|
const p = place(newPlacement(rack), 1, 7, 7);
|
||||||
|
const { placement, order } = recallToSlot(p, 7, 7, 4);
|
||||||
|
expect(placement.pending).toHaveLength(0);
|
||||||
|
expect(placement.rack).toEqual(['A', BLANK, 'N', 'I', 'Q', 'W', 'E']);
|
||||||
|
expect(order).toEqual([0, 2, 3, 4, 1, 5, 6]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts only still-visible slots and remaps the remaining pending tiles', () => {
|
||||||
|
// A (slot 0) and N (slot 3) are on the board; recall A to visible slot 2 — the placed N is
|
||||||
|
// skipped, and N's rackIndex follows the rack permutation.
|
||||||
|
let p = place(newPlacement(rack), 0, 7, 7);
|
||||||
|
p = place(p, 3, 7, 8);
|
||||||
|
const { placement, order } = recallToSlot(p, 7, 7, 2);
|
||||||
|
expect(placement.rack).toEqual(['Q', BLANK, 'N', 'A', 'I', 'W', 'E']);
|
||||||
|
expect(order).toEqual([1, 2, 3, 0, 4, 5, 6]);
|
||||||
|
expect(placement.pending).toHaveLength(1);
|
||||||
|
expect(placement.pending[0].rackIndex).toBe(2);
|
||||||
|
expect(placement.rack[placement.pending[0].rackIndex]).toBe('N');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps the slot to the end and is an identity no-op when no tile sits at the cell', () => {
|
||||||
|
const p = place(newPlacement(rack), 1, 7, 7);
|
||||||
|
expect(recallToSlot(p, 7, 7, 99).placement.rack).toEqual(['A', BLANK, 'N', 'I', 'W', 'E', 'Q']);
|
||||||
|
const none = recallToSlot(p, 0, 0, 0);
|
||||||
|
expect(none.placement).toBe(p);
|
||||||
|
expect(none.order).toEqual([0, 1, 2, 3, 4, 5, 6]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -102,6 +102,51 @@ export function recallIndex(p: Placement, rackIndex: number): Placement {
|
|||||||
return { ...p, pending: p.pending.filter((t) => t.rackIndex !== rackIndex) };
|
return { ...p, pending: p.pending.filter((t) => t.rackIndex !== rackIndex) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* recallToSlot recalls the pending tile at (row, col) and reinserts its rack letter at the
|
||||||
|
* given visible slot, reordering the rack the way a drag-to-reorder does (the drop position the
|
||||||
|
* player chose) instead of snapping the tile back to its original slot. It returns the new
|
||||||
|
* placement together with the index permutation applied to the rack, so the caller can permute
|
||||||
|
* its parallel stable ids in lockstep. toSlot counts the slots that stay visible after the
|
||||||
|
* recall (those still on the board are skipped); it is clamped to a valid position. The result
|
||||||
|
* is the unchanged placement and an identity permutation when no pending tile sits at (row, col).
|
||||||
|
*/
|
||||||
|
export function recallToSlot(
|
||||||
|
p: Placement,
|
||||||
|
row: number,
|
||||||
|
col: number,
|
||||||
|
toSlot: number,
|
||||||
|
): { placement: Placement; order: number[] } {
|
||||||
|
const identity = p.rack.map((_, i) => i);
|
||||||
|
const tile = p.pending.find((t) => t.row === row && t.col === col);
|
||||||
|
if (!tile) return { placement: p, order: identity };
|
||||||
|
const ri = tile.rackIndex;
|
||||||
|
const rest = p.pending.filter((t) => t !== tile);
|
||||||
|
const usedByRest = new Set(rest.map((t) => t.rackIndex));
|
||||||
|
// Reinsert ri just before the toSlot-th slot that stays visible after the recall.
|
||||||
|
const others = identity.filter((i) => i !== ri);
|
||||||
|
let visible = 0;
|
||||||
|
let insertPos = others.length;
|
||||||
|
for (let pos = 0; pos < others.length; pos++) {
|
||||||
|
if (usedByRest.has(others[pos])) continue;
|
||||||
|
if (visible === toSlot) {
|
||||||
|
insertPos = pos;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
visible++;
|
||||||
|
}
|
||||||
|
const order = [...others];
|
||||||
|
order.splice(insertPos, 0, ri);
|
||||||
|
const newIndex = new Map(order.map((oldIdx, idx) => [oldIdx, idx]));
|
||||||
|
return {
|
||||||
|
placement: {
|
||||||
|
rack: order.map((i) => p.rack[i]),
|
||||||
|
pending: rest.map((t) => ({ ...t, rackIndex: newIndex.get(t.rackIndex)! })),
|
||||||
|
},
|
||||||
|
order,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function reset(p: Placement): Placement {
|
export function reset(p: Placement): Placement {
|
||||||
return { ...p, pending: [] };
|
return { ...p, pending: [] };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,8 +124,6 @@ export interface Prefs {
|
|||||||
locale: Locale;
|
locale: Locale;
|
||||||
reduceMotion: boolean;
|
reduceMotion: boolean;
|
||||||
boardLabels: BoardLabelMode;
|
boardLabels: BoardLabelMode;
|
||||||
/** Draw the 1px grid lines between cells; off (default) shows a gapless checkerboard. */
|
|
||||||
boardLines: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadPrefs(): Promise<Partial<Prefs>> {
|
export async function loadPrefs(): Promise<Partial<Prefs>> {
|
||||||
|
|||||||
@@ -380,8 +380,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: none;
|
border: none;
|
||||||
background: var(--danger);
|
background: var(--bg-elev);
|
||||||
color: #fff;
|
color: var(--text);
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
}
|
}
|
||||||
.row {
|
.row {
|
||||||
@@ -404,7 +404,7 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 10px 6px;
|
padding: 5px 6px;
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
@@ -471,7 +471,7 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.emoji {
|
.emoji {
|
||||||
font-size: 1.8rem;
|
font-size: 1.35rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import {
|
import {
|
||||||
app,
|
app,
|
||||||
setBoardLabels,
|
setBoardLabels,
|
||||||
setBoardLines,
|
|
||||||
setLocalePref,
|
setLocalePref,
|
||||||
setReduceMotion,
|
setReduceMotion,
|
||||||
setTheme,
|
setTheme,
|
||||||
@@ -62,14 +61,6 @@
|
|||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<label class="row gridlines">
|
|
||||||
<span>{t('settings.boardLines')}</span>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={app.boardLines}
|
|
||||||
onchange={(e) => setBoardLines(e.currentTarget.checked)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@@ -124,7 +115,4 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
.gridlines {
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -46,18 +46,18 @@
|
|||||||
{#snippet tabbar()}
|
{#snippet tabbar()}
|
||||||
<TabBar>
|
<TabBar>
|
||||||
<button class="tab" class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>
|
<button class="tab" class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>
|
||||||
<span class="sq" aria-hidden="true">⚙️</span><span class="lbl">{t('settings.title')}</span>
|
<span class="face"><span class="sq" aria-hidden="true">⚙️</span><span class="lbl">{t('settings.title')}</span></span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tab" class:active={tab === 'profile'} onclick={() => (tab = 'profile')}>
|
<button class="tab" class:active={tab === 'profile'} onclick={() => (tab = 'profile')}>
|
||||||
<span class="sq" aria-hidden="true">👤</span><span class="lbl">{t('profile.title')}</span>
|
<span class="face"><span class="sq" aria-hidden="true">👤</span><span class="lbl">{t('profile.title')}</span></span>
|
||||||
</button>
|
</button>
|
||||||
{#if !guest}
|
{#if !guest}
|
||||||
<button class="tab" class:active={tab === 'friends'} onclick={() => (tab = 'friends')}>
|
<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>
|
<span class="face"><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></span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
<button class="tab" class:active={tab === 'about'} onclick={() => (tab = 'about')}>
|
<button class="tab" class:active={tab === 'about'} onclick={() => (tab = 'about')}>
|
||||||
<span class="sq" aria-hidden="true">ℹ️{#if app.feedbackReplyUnread}<span class="badge">1</span>{/if}</span><span class="lbl">{t('about.tab')}</span>
|
<span class="face"><span class="sq" aria-hidden="true">ℹ️{#if app.feedbackReplyUnread}<span class="badge">1</span>{/if}</span><span class="lbl">{t('about.tab')}</span></span>
|
||||||
</button>
|
</button>
|
||||||
</TabBar>
|
</TabBar>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
Reference in New Issue
Block a user