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);
|
||||
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
|
||||
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/
|
||||
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**
|
||||
|
||||
@@ -110,7 +110,9 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
||||
func (s *Server) consoleDashboard(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
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.ActiveGames, _ = s.games.CountGames(ctx, game.StatusActive)
|
||||
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**.
|
||||
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,
|
||||
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
|
||||
@@ -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
|
||||
via the platform) **name the nudger** ("<opponent>: waiting for your move").
|
||||
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
|
||||
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
|
||||
|
||||
@@ -96,7 +96,7 @@ nudge) приходят от бота **этой партии** — по язы
|
||||
|
||||
**Быстрая игра** даёт выбрать соперника — **ИИ** (по умолчанию) или **случайного игрока**. С **ИИ**
|
||||
вы сразу начинаете против 🤖, который присоединяется и отвечает мгновенно: ожидания нет, чат и nudge
|
||||
выключены, «добавить в друзья» не показывается, а проверка слова работает; вместо лимита на ход вы
|
||||
нет — только проверка слова, — «добавить в друзья» не показывается; вместо лимита на ход вы
|
||||
проигрываете только после **7 дней без хода** (так что ИИ-партию можно спокойно забросить, а сам ИИ
|
||||
никогда не «просрочит» ход). Выбор **случайного игрока** — это авто-подбор (всегда 2 игрока), который
|
||||
сразу **помещает вас в игру, и вы ждёте соперника прямо
|
||||
@@ -217,7 +217,9 @@ nudge) приходят от бота **этой партии** — по язы
|
||||
и внеприложенческий push (доставляется через платформу) **называют отправителя**
|
||||
(«<соперник>: жду вашего хода»).
|
||||
Чат и инструмент проверки слова — один **экран связи** со вкладками **💬 чат** / **🔎 словарь**,
|
||||
открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию). Непрочитанная запись чата —
|
||||
открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию). В ИИ-партии чата нет, поэтому её
|
||||
экран связи — только проверка слова; а после завершения ИИ-партии (чата нет, словарь закрыт) сам вход
|
||||
💬 исчезает. Непрочитанная запись чата —
|
||||
сообщение **или 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
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
the one-finger gesture scrolls). A single tap no longer recalls (too easy to trigger); a
|
||||
recalled tile returns to its original rack slot.
|
||||
the one-finger gesture scrolls). A single tap no longer recalls (too easy to trigger). A
|
||||
**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
|
||||
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
|
||||
@@ -172,11 +174,9 @@ except Login uses `Screen`.
|
||||
- **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a
|
||||
split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none`
|
||||
nothing. Default **beginner**.
|
||||
- **Grid lines** — a Settings toggle, **default off**. Off: a **gapless
|
||||
checkerboard** — plain cells alternate two shades, and tiles get rounded corners with a
|
||||
soft right-side shadow so adjacent gapless tiles still read apart, reclaiming ~14px of
|
||||
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.
|
||||
- **Board surface** — always a **gapless checkerboard** (there is no grid-lines setting):
|
||||
plain cells alternate two shades, and tiles get rounded corners with a soft right-side
|
||||
shadow so adjacent gapless tiles still read apart, reclaiming ~14px of board width.
|
||||
|
||||
## 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
|
||||
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
|
||||
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`
|
||||
`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
|
||||
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.
|
||||
place; back always returns to the lobby. The **selected** tab is marked by a filled pill
|
||||
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
|
||||
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**
|
||||
@@ -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**
|
||||
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 **🤖**
|
||||
everywhere (score card, lobby row, turn line), the add-friend 🤝 is never drawn, and the chat's
|
||||
send field and 🛎️ nudge stay **disabled** while the 🔎 dictionary word-check keeps working.
|
||||
everywhere (score card, lobby row, turn line), the add-friend 🤝 is never drawn, and there is
|
||||
**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
|
||||
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
|
||||
@@ -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:
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
+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);
|
||||
});
|
||||
|
||||
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 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.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 }) => {
|
||||
@@ -285,6 +284,28 @@ test('a placed tile drags from one board cell to another (relocation)', async ({
|
||||
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 }) => {
|
||||
await openGame(page);
|
||||
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();
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
await openGame(page);
|
||||
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.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.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.locator('.chat input')).toBeDisabled();
|
||||
await page.getByRole('button', { name: 'Dictionary' }).click();
|
||||
await expect(page.locator('.check input')).toBeEnabled();
|
||||
await expect(page.getByRole('button', { name: 'Chat' })).toHaveCount(0); // no Chat tab
|
||||
await expect(page.locator('.chat input')).toHaveCount(0); // the chat body never mounts
|
||||
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
|
||||
// (the comms hub stays). Start an AI game, resign it, reopen the history and check the header.
|
||||
test('AI game: no GCG export offered after it ends', async ({ page }) => {
|
||||
// GCG export is pointless for a practice AI game, and a finished AI game has no comms at all
|
||||
// (no chat, and the dictionary closes with the game), so both the 📤 export and the 💬 comms
|
||||
// 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.getByRole('button', { name: /guest/i }).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 expect(page.locator('.status .over')).toBeVisible();
|
||||
|
||||
// Reopen the history (resigning auto-closed it): the finished AI game offers no GCG export,
|
||||
// while the comms hub stays reachable.
|
||||
// Reopen the history (resigning auto-closed it): the finished AI game offers no GCG export and
|
||||
// no comms entry at all.
|
||||
await page.locator('.scoreboard').click();
|
||||
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
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { insideTelegram, telegramBackButton } from './lib/telegram';
|
||||
import Toast from './components/Toast.svelte';
|
||||
import StaleInviteModal from './components/StaleInviteModal.svelte';
|
||||
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
|
||||
import Login from './screens/Login.svelte';
|
||||
import Lobby from './screens/Lobby.svelte';
|
||||
import NewGame from './screens/NewGame.svelte';
|
||||
@@ -110,6 +111,7 @@
|
||||
|
||||
<Toast />
|
||||
<StaleInviteModal />
|
||||
<WelcomeRedeemModal />
|
||||
|
||||
<style>
|
||||
.splash {
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
locale: lc,
|
||||
reduceMotion: prefs.reduceMotion ?? false,
|
||||
boardLabels: prefs.boardLabels ?? 'beginner',
|
||||
boardLines: prefs.boardLines ?? false,
|
||||
});
|
||||
prefs = { ...prefs, locale: lc };
|
||||
}
|
||||
|
||||
@@ -51,10 +51,25 @@
|
||||
line-height: 1;
|
||||
transition: background-color 0.12s;
|
||||
}
|
||||
/* A tab that navigates between peer views (Settings / Comms hubs) stays highlighted
|
||||
while selected: a filled square with an accent underline. A tap itself leaves no
|
||||
highlight — there is no press tint, and the WebKit tap flash is disabled on .tab. */
|
||||
:global(.tab.active .sq) {
|
||||
/* A tab that navigates between peer views (Settings / Comms hubs) wraps its icon and label
|
||||
in a .face so the selected highlight hugs both — a filled pill with an accent underline,
|
||||
sized to the content with a small padding. A tap itself leaves no highlight (no press
|
||||
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);
|
||||
box-shadow: inset 0 -2px 0 var(--accent);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
<script lang="ts">
|
||||
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);
|
||||
// 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>
|
||||
|
||||
{#if app.toast}
|
||||
<!-- Re-key on the toast's seq so a fresh message tears the old node down (out:fade) and replays
|
||||
the entrance (in:fly): the newest toast cancels the previous one and starts its own cycle. -->
|
||||
<!-- Re-key on the toast's seq so a fresh message tears the old node down and replays the
|
||||
appear cycle: the newest toast cancels the previous one and starts its own. -->
|
||||
{#key app.toast.seq}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div
|
||||
class="toast {app.toast.kind}"
|
||||
class:rise={isInfo && !app.reduceMotion}
|
||||
class:rise-reduced={isInfo && app.reduceMotion}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
in:fly={{ y: 32, duration: dur }}
|
||||
out:fade={{ duration: dur }}
|
||||
onclick={dismissToast}
|
||||
in:fly={{ y: isInfo ? 0 : 32, duration: isInfo ? 0 : dur }}
|
||||
out:fade={{ duration: isInfo ? 0 : dur }}
|
||||
>
|
||||
{app.toast.text}
|
||||
</div>
|
||||
@@ -36,9 +44,45 @@
|
||||
box-shadow: var(--shadow);
|
||||
z-index: 50;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.error {
|
||||
border-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>
|
||||
|
||||
@@ -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,
|
||||
variant,
|
||||
labelMode,
|
||||
lines,
|
||||
locale,
|
||||
focus,
|
||||
recenter,
|
||||
@@ -41,8 +40,6 @@
|
||||
landscape: boolean;
|
||||
variant: Variant;
|
||||
labelMode: BoardLabelMode;
|
||||
/** Draw 1px grid lines between cells; when false the board is a gapless checkerboard. */
|
||||
lines: boolean;
|
||||
locale: Locale;
|
||||
focus: { row: number; col: number } | null;
|
||||
/** 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="scaler" class:land={landscape} style="--z: {z};">
|
||||
<div class="grid" class:gridless={!lines}>
|
||||
<div class="grid">
|
||||
{#each board as rowCells, r (r)}
|
||||
{#each rowCells as cell, c (c)}
|
||||
{@const p = pending.get(key(r, c))}
|
||||
@@ -311,18 +308,21 @@
|
||||
width 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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(15, 1fr);
|
||||
gap: 1px;
|
||||
background: var(--cell-line);
|
||||
padding: 1px;
|
||||
gap: 0;
|
||||
background: var(--board-bg);
|
||||
padding: 0;
|
||||
}
|
||||
.cell {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
border: none;
|
||||
border-radius: 1px;
|
||||
border-radius: 0;
|
||||
background: var(--cell-bg);
|
||||
color: var(--prem-text);
|
||||
/* No mobile tap flash on a cell tap (parity with the web click; the only intentional
|
||||
@@ -344,11 +344,20 @@
|
||||
.cell.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.pending {
|
||||
background: var(--tile-bg);
|
||||
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 {
|
||||
background: var(--tile-pending);
|
||||
@@ -356,30 +365,6 @@
|
||||
board) instead of the touch starting a board pan. */
|
||||
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 {
|
||||
/* 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. */
|
||||
|
||||
@@ -8,19 +8,24 @@
|
||||
|
||||
// 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").
|
||||
// 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';
|
||||
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.
|
||||
// An honest-AI game has no chat at all, so its hub is Dictionary-only.
|
||||
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
|
||||
let tab = $state<CommsTab>(initialTab);
|
||||
$effect(() => {
|
||||
if (tab === 'dictionary' && !active) tab = 'chat';
|
||||
if (vsAi) tab = 'dictionary';
|
||||
else if (tab === 'dictionary' && !active) tab = 'chat';
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -38,12 +43,14 @@
|
||||
|
||||
{#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 !vsAi}
|
||||
<button class="tab" class:active={tab === 'chat'} onclick={() => (tab = 'chat')}>
|
||||
<span class="face"><span class="sq" aria-hidden="true">💬</span><span class="lbl">{t('game.chat')}</span></span>
|
||||
</button>
|
||||
{/if}
|
||||
{#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>
|
||||
<span class="face"><span class="sq" aria-hidden="true">🔎</span><span class="lbl">{t('game.dictionary')}</span></span>
|
||||
</button>
|
||||
{/if}
|
||||
</TabBar>
|
||||
|
||||
+57
-13
@@ -31,6 +31,7 @@
|
||||
placementFromHint,
|
||||
rackView,
|
||||
recallAt,
|
||||
recallToSlot,
|
||||
reorderIndices,
|
||||
reset,
|
||||
toSubmit,
|
||||
@@ -377,6 +378,10 @@
|
||||
// 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.
|
||||
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;
|
||||
function beginDrag(src: DragSrc, e: PointerEvent) {
|
||||
@@ -397,6 +402,7 @@
|
||||
window.removeEventListener('pointermove', onWinMove);
|
||||
window.removeEventListener('pointerup', onWinUp);
|
||||
window.removeEventListener('pointerdown', onExtraPointer);
|
||||
boardPointers.delete(dragPointerId); // see onWinUp: don't leave a stale boardwrap pointer
|
||||
clearHover();
|
||||
clearReorder();
|
||||
downInfo = null;
|
||||
@@ -481,7 +487,12 @@
|
||||
if (c) {
|
||||
dropTarget = !board[c.row]?.[c.col] && !pendingMap.has(`${c.row},${c.col}`) ? c : 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);
|
||||
dropTarget = null;
|
||||
} else {
|
||||
@@ -510,6 +521,11 @@
|
||||
window.removeEventListener('pointermove', onWinMove);
|
||||
window.removeEventListener('pointerup', onWinUp);
|
||||
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();
|
||||
const di = downInfo;
|
||||
downInfo = null;
|
||||
@@ -527,8 +543,15 @@
|
||||
// Dropped a placed tile on another board cell → relocate it there.
|
||||
relocatePending(di.src.row, di.src.col, cell.row, cell.col);
|
||||
} else if (di.src.from === 'board' && onRack) {
|
||||
// Dropped a placed tile back onto the rack → recall it to its original slot.
|
||||
placement = recallAt(placement, di.src.row, di.src.col);
|
||||
// Dropped a placed tile back onto the rack → recall it to the drop slot the player aimed
|
||||
// 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;
|
||||
recompute();
|
||||
scheduleDraftSave();
|
||||
@@ -813,6 +836,8 @@
|
||||
// - 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
|
||||
// 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
|
||||
// view, which left a stale-open state that made a follow-up score-bar tap "jump" the board).
|
||||
let stageEl = $state<HTMLDivElement>();
|
||||
@@ -869,6 +894,14 @@
|
||||
if (-dy > 32) closeHistoryByGesture(); // enough upward travel
|
||||
} else if (dy > 40 && dy > Math.abs(dx) * 1.4) {
|
||||
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) {
|
||||
@@ -1104,9 +1137,13 @@
|
||||
<button class="hicon" onclick={() => (resignOpen = true)} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
|
||||
{/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')}>
|
||||
{#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key}
|
||||
</button>
|
||||
<!-- A finished AI game has no comms at all (no chat, and the dictionary closes with the
|
||||
game), so the entry is dropped; an active AI game keeps it (it opens the dictionary). -->
|
||||
{#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 class="hgridwrap">
|
||||
<div class="hgrid" style="grid-template-columns: repeat({view.game.seats.length}, 1fr)">
|
||||
@@ -1156,7 +1193,6 @@
|
||||
{landscape}
|
||||
{variant}
|
||||
labelMode={app.boardLabels}
|
||||
lines={app.boardLines}
|
||||
locale={app.locale}
|
||||
{focus}
|
||||
{recenter}
|
||||
@@ -1179,7 +1215,7 @@
|
||||
<span class="turn-ind">{isMyTurn ? t('game.yourTurn') : turnLabel()}</span>
|
||||
{/if}
|
||||
<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>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1201,7 +1237,7 @@
|
||||
ondown={onRackDown}
|
||||
/>
|
||||
</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>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1447,9 +1483,9 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rack-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: 8px;
|
||||
align-items: stretch;
|
||||
padding: 0 var(--pad) 6px;
|
||||
}
|
||||
@@ -1462,14 +1498,22 @@
|
||||
min-width: 0;
|
||||
}
|
||||
/* 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 {
|
||||
min-width: 56px;
|
||||
position: absolute;
|
||||
right: var(--pad);
|
||||
top: 0;
|
||||
bottom: 6px;
|
||||
width: 56px;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
border: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
place-items: center end;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
.make:disabled {
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
}
|
||||
</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)}
|
||||
<button
|
||||
class="tile"
|
||||
|
||||
+29
-12
@@ -56,8 +56,6 @@ export const app = $state<{
|
||||
locale: Locale;
|
||||
reduceMotion: boolean;
|
||||
boardLabels: BoardLabelMode;
|
||||
/** Draw grid lines between board cells; off (default) is a gapless checkerboard. */
|
||||
boardLines: boolean;
|
||||
localeLocked: boolean;
|
||||
/** Pending incoming friend requests, for the lobby ⚙️ badge and the Settings Friends tab. */
|
||||
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
|
||||
* the bot instead of a scary error on the Friends screen. */
|
||||
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
|
||||
* 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). */
|
||||
@@ -88,12 +90,12 @@ export const app = $state<{
|
||||
locale: 'en',
|
||||
reduceMotion: false,
|
||||
boardLabels: 'beginner',
|
||||
boardLines: false,
|
||||
localeLocked: false,
|
||||
notifications: 0,
|
||||
chatUnread: {},
|
||||
feedbackReplyUnread: false,
|
||||
staleInvite: false,
|
||||
welcomeRedeem: false,
|
||||
resync: 0,
|
||||
});
|
||||
|
||||
@@ -143,7 +145,18 @@ function goForeground(): void {
|
||||
export function showToast(text: string, kind: Toast['kind'] = 'info'): void {
|
||||
app.toast = { kind, text, seq: ++toastSeq };
|
||||
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). */
|
||||
@@ -151,6 +164,11 @@ export function dismissStaleInvite(): void {
|
||||
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
|
||||
* 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.reduceMotion = prefs.reduceMotion ?? false;
|
||||
app.boardLabels = prefs.boardLabels ?? 'beginner';
|
||||
app.boardLines = prefs.boardLines ?? false;
|
||||
applyTheme(app.theme);
|
||||
applyReduceMotion(app.reduceMotion);
|
||||
if (prefs.locale) {
|
||||
@@ -560,7 +577,13 @@ async function routeStartParam(param: string): Promise<void> {
|
||||
try {
|
||||
const friend = await gateway.friendCodeRedeem(link.code);
|
||||
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();
|
||||
} catch (err) {
|
||||
const code = err instanceof GatewayError ? err.code : '';
|
||||
@@ -633,7 +656,6 @@ function persistPrefs(): void {
|
||||
locale: app.locale,
|
||||
reduceMotion: app.reduceMotion,
|
||||
boardLabels: app.boardLabels,
|
||||
boardLines: app.boardLines,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -686,11 +708,6 @@ export function setBoardLabels(mode: BoardLabelMode): void {
|
||||
persistPrefs();
|
||||
}
|
||||
|
||||
export function setBoardLines(on: boolean): void {
|
||||
app.boardLines = on;
|
||||
persistPrefs();
|
||||
}
|
||||
|
||||
// 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
|
||||
// hidden, §10). Several signals cover the platforms: the page Visibility API, the
|
||||
|
||||
@@ -163,7 +163,6 @@ export const en = {
|
||||
'settings.labelsBeginner': 'Beginner',
|
||||
'settings.labelsClassic': 'Classic',
|
||||
'settings.labelsNone': 'None',
|
||||
'settings.boardLines': 'Grid lines',
|
||||
'settings.reduceMotion': 'Reduce motion',
|
||||
|
||||
'about.title': 'About',
|
||||
@@ -241,6 +240,8 @@ export const en = {
|
||||
'friends.selfInvite': "Hopefully you've been friends with yourself for a while ☺️",
|
||||
'friends.staleInviteTitle': 'Link expired',
|
||||
'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.blockedList': 'Blocked players',
|
||||
'friends.unblock': 'Unblock',
|
||||
|
||||
@@ -164,7 +164,6 @@ export const ru: Record<MessageKey, string> = {
|
||||
'settings.labelsBeginner': 'Новичок',
|
||||
'settings.labelsClassic': 'Классика',
|
||||
'settings.labelsNone': 'Без текста',
|
||||
'settings.boardLines': 'Линии сетки',
|
||||
'settings.reduceMotion': 'Меньше анимаций',
|
||||
|
||||
'about.title': 'О программе',
|
||||
@@ -242,6 +241,8 @@ export const ru: Record<MessageKey, string> = {
|
||||
'friends.selfInvite': 'Надеюсь, что с собой Вы уже давно дружите ☺️',
|
||||
'friends.staleInviteTitle': 'Ссылка устарела',
|
||||
'friends.staleInvite': 'Вы открыли игру по устаревшей ссылке. Откройте бота {bot}, чтобы играть и получать уведомления.',
|
||||
'friends.welcomeRedeemTitle': 'Добро пожаловать!',
|
||||
'friends.welcomeRedeem': 'Добро пожаловать, {name}!\n\nВоспользуйтесь {bot}, чтобы заходить в игру наиболее удобным способом.',
|
||||
'friends.added': 'Добавлен(а) {name}.',
|
||||
'friends.blockedList': 'Заблокированные',
|
||||
'friends.unblock': 'Разблокировать',
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
rackView,
|
||||
recallAt,
|
||||
recallIndex,
|
||||
recallToSlot,
|
||||
reorderIndices,
|
||||
reset,
|
||||
toSubmit,
|
||||
@@ -112,3 +113,35 @@ describe('reorderIndices', () => {
|
||||
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) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
return { ...p, pending: [] };
|
||||
}
|
||||
|
||||
@@ -124,8 +124,6 @@ export interface Prefs {
|
||||
locale: Locale;
|
||||
reduceMotion: boolean;
|
||||
boardLabels: BoardLabelMode;
|
||||
/** Draw the 1px grid lines between cells; off (default) shows a gapless checkerboard. */
|
||||
boardLines: boolean;
|
||||
}
|
||||
|
||||
export async function loadPrefs(): Promise<Partial<Prefs>> {
|
||||
|
||||
@@ -380,8 +380,8 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
background: var(--bg-elev);
|
||||
color: var(--text);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.row {
|
||||
@@ -404,7 +404,7 @@
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
padding: 10px 6px;
|
||||
padding: 5px 6px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text);
|
||||
@@ -471,7 +471,7 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.emoji {
|
||||
font-size: 1.8rem;
|
||||
font-size: 1.35rem;
|
||||
line-height: 1;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import {
|
||||
app,
|
||||
setBoardLabels,
|
||||
setBoardLines,
|
||||
setLocalePref,
|
||||
setReduceMotion,
|
||||
setTheme,
|
||||
@@ -62,14 +61,6 @@
|
||||
</button>
|
||||
{/each}
|
||||
</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>
|
||||
@@ -124,7 +115,4 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.gridlines {
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -46,18 +46,18 @@
|
||||
{#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>
|
||||
<span class="face"><span class="sq" aria-hidden="true">⚙️</span><span class="lbl">{t('settings.title')}</span></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>
|
||||
<span class="face"><span class="sq" aria-hidden="true">👤</span><span class="lbl">{t('profile.title')}</span></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>
|
||||
<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>
|
||||
{/if}
|
||||
<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>
|
||||
</TabBar>
|
||||
{/snippet}
|
||||
|
||||
Reference in New Issue
Block a user