Merge pull request 'feat(ui): UI polish batch (toast, gestures, lobby, settings, admin count)' (#84) from feature/ui-polish-batch into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 52s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 56s

This commit was merged in pull request #84.
This commit is contained in:
2026-06-19 08:17:50 +00:00
26 changed files with 435 additions and 140 deletions
+1 -1
View File
@@ -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
View File
@@ -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
+4 -2
View File
@@ -96,7 +96,7 @@ nudge) приходят от бота **этой партии** — по язы
**Быстрая игра** даёт выбрать соперника — **ИИ** (по умолчанию) или **случайного игрока**. С **ИИ**
вы сразу начинаете против 🤖, который присоединяется и отвечает мгновенно: ожидания нет, чат и nudge
выключены, «добавить в друзья» не показывается, а проверка слова работает; вместо лимита на ход вы
нет — только проверка слова, — «добавить в друзья» не показывается; вместо лимита на ход вы
проигрываете только после **7 дней без хода** (так что ИИ-партию можно спокойно забросить, а сам ИИ
никогда не «просрочит» ход). Выбор **случайного игрока** — это авто-подбор (всегда 2 игрока), который
сразу **помещает вас в игру, и вы ждёте соперника прямо
@@ -217,7 +217,9 @@ nudge) приходят от бота **этой партии** — по язы
и внеприложенческий push (доставляется через платформу) **называют отправителя**
(«<соперник>: жду вашего хода»).
Чат и инструмент проверки слова — один **экран связи** со вкладками **💬 чат** / **🔎 словарь**,
открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию). Непрочитанная запись чата —
открываемый по 💬 в шапке истории ходов (с кнопкой «назад» в партию). В ИИ-партии чата нет, поэтому её
экран связи — только проверка слова; а после завершения ИИ-партии (чата нет, словарь закрыт) сам вход
💬 исчезает. Непрочитанная запись чата —
сообщение **или nudge** от соперника — зажигает маленькую **красную точку** рядом с партией в
**лобби** и на **строке счёта** партии. Открытие **истории ходов** считается прочтением чата, даже
без захода в него: точка гаснет, а значок 💬 **дважды мигает**. Nudge также гаснет в момент, когда
+20 -14
View File
@@ -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
View File
@@ -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
+23
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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 {
-1
View File
@@ -47,7 +47,6 @@
locale: lc,
reduceMotion: prefs.reduceMotion ?? false,
boardLabels: prefs.boardLabels ?? 'beginner',
boardLines: prefs.boardLines ?? false,
});
prefs = { ...prefs, locale: lc };
}
+19 -4
View File
@@ -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);
}
+49 -5
View File
@@ -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
View File
@@ -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. */
+15 -8
View File
@@ -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
View File
@@ -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 {
+1 -1
View File
@@ -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
View File
@@ -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
+2 -1
View File
@@ -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',
+2 -1
View File
@@ -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': 'Разблокировать',
+33
View File
@@ -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]);
});
});
+45
View File
@@ -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: [] };
}
-2
View File
@@ -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>> {
+4 -4
View File
@@ -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;
}
-12
View File
@@ -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>
+4 -4
View File
@@ -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}