Status {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}
AI game {{if .VsAI}}🤖 yes{{else}}no{{end}}
+
Word rule {{if .MultipleWordsPerTurn}}multiple words per turn{{else}}single word per turn{{end}}
Players {{.Players}}
To move seat {{.ToMove}}
Moves {{.MoveCount}}
diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go
index 5f354b0..cdc8676 100644
--- a/backend/internal/adminconsole/views.go
+++ b/backend/internal/adminconsole/views.go
@@ -348,8 +348,12 @@ type GameDetailView struct {
UpdatedAt string
FinishedAt string
// VsAI marks an honest-AI game (shown as a 🤖 flag in the summary).
- VsAI bool
- Seats []SeatRow
+ VsAI bool
+ // MultipleWordsPerTurn is the game's cross-word rule: true = standard Scrabble (every cross-word
+ // is validated and scored), false = the single-word rule (only the main word along the play
+ // direction counts). Shown in the summary so an operator can tell the rule at a glance.
+ MultipleWordsPerTurn bool
+ Seats []SeatRow
// HasRobot is true when any seat is a robot, gating the robot-target caption;
// RobotTargetPct is the configured global play-to-win rate, in percent.
HasRobot bool
diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go
index bc39dcf..cfb72f9 100644
--- a/backend/internal/server/handlers_admin_console.go
+++ b/backend/internal/server/handlers_admin_console.go
@@ -566,6 +566,7 @@ func (s *Server) consoleGameDetail(c *gin.Context) {
Status: g.Status, Players: g.Players, ToMove: g.ToMove, EndReason: g.EndReason,
MoveCount: g.MoveCount, CreatedAt: fmtTime(g.CreatedAt), UpdatedAt: fmtTime(g.UpdatedAt),
FinishedAt: fmtTimePtr(g.FinishedAt), VsAI: g.VsAI,
+ MultipleWordsPerTurn: g.MultipleWordsPerTurn,
}
// Resolve seats and detect robot seats; capture the human opponent's timezone, which
// anchors the robot's sleep window for the next-move ETA.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index fcf7749..540f210 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -435,7 +435,11 @@ Key points:
through a **session-gated `GET /dict/{variant}/{version}`** edge route
(immutable; cached in IndexedDB best-effort) and reused across sessions; any
miss, storage eviction or a bad-connection breaker falls back to the network
- `evaluate`. The warm-up overlay is `docs/UI_DESIGN.md`.
+ `evaluate`. The warm-up overlay is `docs/UI_DESIGN.md`. The **on-board rendering** of a staged
+ play — the legality tint, the cells a formed word covers, and where the score badge anchors — is a
+ separate **pure geometry helper** (`ui/src/lib/formed.ts`) derived from the board and the staged
+ tiles, so it works regardless of which eval path produced the preview (the badge's number still
+ comes from the preview's score): legality and score stay with the eval, geometry with the board.
## 6. Game rules
diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md
index 930ba72..e8b7f0b 100644
--- a/docs/FUNCTIONAL.md
+++ b/docs/FUNCTIONAL.md
@@ -204,13 +204,17 @@ client, and refuses a guest's friend request, friend code or invitation outright
### Playing a game
Place tiles, pass, exchange, or resign. Pass and exchange share one control —
-choosing no tiles passes the turn, choosing tiles exchanges them. Tiles are laid
+choosing no tiles passes the turn, choosing tiles exchanges them. The tiles left in the
+bag are shown as a count on that control and at the foot of the move table. Tiles are laid
without choosing a direction — the game infers the play's orientation, so a single
tile that extends
an existing word (down a column or across a row) is accepted. A play is validated
-against the game's dictionary at submit time and scored; an unlimited preview
-reports the word(s) a tentative move would form and its score, or that it is not
-legal, and the move is offered for submission only once it is confirmed legal. The preview is
+against the game's dictionary at submit time and scored; an unlimited preview shows
+**on the board itself** whether the tentative move is legal — the staged tiles turn a light
+green once they form a word, a shade darker on the board tiles the word runs through (in a
+one-word-per-turn game only the main word), or a calm pink when they form none — and an
+**orange badge** on the main word carries the move's
+score. The confirm control is offered only once the move is legal. The preview is
computed **on-device** for an instant response once the game's dictionary has loaded — a
brief warm-up shows on the first open otherwise — and falls back to the server whenever the
local dictionary is unavailable. The dictionary check tool is
@@ -372,7 +376,9 @@ an email or Telegram and merging accounts are covered under "Accounts, linking &
merge". Inside the Telegram Mini App, Telegram's own ⋮ menu also offers a **Settings**
entry that opens this screen, and your display preferences (theme, board-label style and
reduce-motion — not the interface language, which follows your account) sync across your
-Telegram devices.
+Telegram devices. On a touch device the settings also offer a **Zoom the board** toggle (on by
+default): with it off, dropping a tile no longer auto-magnifies the board toward it. It is a
+per-device preference and is hidden on desktop, where the board already fits and never auto-zooms.
**Preferences (which variants you can be matched into).** A profile setting picks the game
variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow
diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md
index c89f15d..40efc4c 100644
--- a/docs/FUNCTIONAL_ru.md
+++ b/docs/FUNCTIONAL_ru.md
@@ -212,13 +212,17 @@ e-mail) либо ввод фразы. Активные игры форфейтя
### Игровой процесс
Выкладывание фишек, пас, обмен или сдача. Пас и обмен — один элемент управления:
-без выбранных фишек — пас, с выбранными — обмен. Фишки кладутся без выбора
+без выбранных фишек — пас, с выбранными — обмен. Число оставшихся в мешке фишек
+показано счётчиком на этом элементе и внизу таблицы ходов. Фишки кладутся без выбора
направления — игра сама определяет ориентацию хода, поэтому одна фишка, продолжающая
уже лежащее
слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при
-сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое
-образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно
-отправить только после подтверждения, что он допустим. Предпросмотр считается **на устройстве**
+сдаче и считается; безлимитный предпросмотр показывает **прямо на доске**, допустим ли
+предполагаемый ход: выложенные фишки становятся светло-зелёными, когда образуют слово
+(и чуть темнее — фишки доски, через которые проходит слово; в партии «одно слово за ход»
+подсвечивается только основное слово), или спокойно-розовыми, когда слова нет, — а
+**оранжевый бейдж** на основном слове несёт очки хода. Кнопка
+подтверждения появляется только когда ход допустим. Предпросмотр считается **на устройстве**
и отвечает мгновенно, как только словарь партии загружен — иначе при первом открытии показывается
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
@@ -381,7 +385,10 @@ UTC; при создании аккаунта она подставляется
Mini App пункт **Settings** в системном меню «⋮» Telegram также открывает этот экран, а
ваши настройки отображения (тема, стиль подписей клеток и reduce-motion — кроме языка
интерфейса, который следует за аккаунтом) синхронизируются между вашими устройствами в
-Telegram.
+Telegram. На сенсорном устройстве в настройках также есть переключатель **«Приближать
+доску»** (по умолчанию включён): если выключить, при кидании фишки доска больше не
+приближается к ней автоматически. Это настройка на устройство, и она скрыта на десктопе,
+где доска и так помещается целиком и авто-зума нет.
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md
index 2bb4fbb..0d2c510 100644
--- a/docs/UI_DESIGN.md
+++ b/docs/UI_DESIGN.md
@@ -179,16 +179,16 @@ e2e and the screenshots.
back near the edges. It **recentres only on a zoom-in** — placing a 2nd+ tile or
hovering a dragged tile never jumps the board. On touch the first tile placement auto-zooms
in centred on the target, and **holding a dragged tile over a cell ~1 s** auto-zooms there
- the first time. A **swipe down on the zoom-out board** opens the history, but only when the
+ the first time — both gated by the **Zoom the board** setting (on by default; the toggle is
+ shown only on a touch device). A **swipe down on the zoom-out board** opens the history, but only when the
board is scrolled to its top so it never fights the stage's own vertical scroll (the conflict
that once retired this gesture) — and it is suppressed on the zoomed board, where the
one-finger drag pans (and on desktop / the landscape iframe, where a mouse cannot drag-scroll the
viewport natively, a **drag-to-pan** handler moves the zoomed board instead — active only while
zoomed, off pending tiles, past a small threshold, swallowing the trailing click). History also
opens on a **tap of the score bar** and closes on a tap or
- an **upward swipe** of the then-inert board (below). A **hint** auto-zooms centred on the
- hint's placement, not
- the top-left.
+ an **upward swipe** of the then-inert board (below). Taking a **hint** while zoomed in **zooms
+ out** to the whole board, so the hint's word — highlighted (below) — is never left off-screen.
- **Placing & recall** (`Game.svelte`): a rack tile is placed by tap-then-tap or by
dragging it onto a cell; while a dragged tile is carried over the board, the aimed-at empty
cell is **highlighted as a drop target** (an accent ring). A pending tile is taken back by a
@@ -224,7 +224,7 @@ e2e and the screenshots.
are pinned to the card's edges.
Unread chat is also badged on the score bar itself, so it shows with the history closed.
- **Vertical fit & keyboard**: when the game does not fit the viewport, only the
- board area scrolls vertically (`Screen` `column` mode; the score bar, status, rack and tab
+ board area scrolls vertically (`Screen` `column` mode; the turn strip, score bar, rack and tab
bar stay fixed), while zoom keeps its own scroll. The check-word dialog opens in
`Modal` keyboard-overlay mode — the small sheet is top-anchored and the soft keyboard
overlays the empty area below, so the layout doesn't resize/jank; other modals stay
@@ -235,23 +235,32 @@ e2e and the screenshots.
(`min(100cqw, 100cqh)`, height-driven in `Board.svelte`'s `.scaler.land`) inside a
`container-type: size` pane, shrinking by width when the column is narrow — the board has the
**lowest priority**, so the left panel is never squeezed. The **left panel** stacks, top to
- bottom: the score plaques, the **always-open** history (docked and scrolling with its header
- sticky — no slide-down drawer, no score-bar toggle), the status line (bag · turn · score
- preview), the rack (+ the ✅ make control) and, pinned at the bottom, the controls tab bar. Board
+ bottom: the **turn/result strip**, the score plaques, the **always-open** history (docked and
+ scrolling with its header sticky and the **bag count** pinned at its foot — no slide-down drawer,
+ no score-bar toggle), the rack (with the ✅ confirm control in its fixed 7th slot) and, pinned at
+ the bottom, the controls tab bar. Board
**zoom works as in portrait** (double-tap / pinch / placement auto-zoom), but the viewport is the
full pane: zoom-out shows the height-fitted square centred, and zoom-in magnifies the board past
the pane and pans within it, occupying the full width up to the left panel (the focus-centred
scroll is set directly, without the portrait width-progress tween). Only the history open/close
swipes are dropped (it is always open) and the nav bar does not grow (`growNav` off). The
portrait layout is byte-for-byte unchanged; both layouts render from the same snippets, so the
- behaviour and markup stay single-sourced. The rack tiles size to the (fixed-width) panel rather
- than the viewport width so seven tiles never overflow the column.
-- **Highlights**: pending tiles use a slightly darker tile background (no outline). The
+ behaviour and markup stay single-sourced. The rack is a **seven-column grid** filling the tray
+ width in both layouts, so the tiles are square and as large as the row allows (the confirm ✅ takes
+ the fixed 7th slot while a play is staged).
+- **Highlights**: while composing a play the staged tiles are **tinted by legality** — a light
+ green once they form a word (a shade darker on the committed board tiles the word runs through —
+ in a **single-word game** only the main word lights up, since the engine ignores cross words),
+ a calm pink when they form none — and an **orange score badge** sits on a corner of the main
+ word's tile, its digit sized like a tile's point value (so it scales with the zoom) and clamped
+ to stay on the board. Off-turn or with no preview the staged tiles keep the plain pending fill.
+ The colours are theme tokens in `app.css` (`--tile-pending-legal` / `-illegal`, `--tile-formed`,
+ `--score-badge`); the geometry — which cells a word covers and where the badge anchors — is a
+ pure client-side helper (`lib/formed.ts`), independent of the move evaluator. The
last completed word keeps the normal tile background; instead its letters — not the point
values — are drawn in the recent-move colour, in both themes. It is static while it is the
opponent's turn (our word), and a 1 s flash (the letter pulses between its normal colour
- and the recent-move colour) when it is our turn (their word). While placing, only the
- pending tiles are highlighted.
+ and the recent-move colour) when it is our turn (their word).
- **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a
split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none`
nothing. Default **beginner**.
@@ -267,18 +276,22 @@ e2e and the screenshots.
otherwise it reverts. Used by the **Hint** tab (the icon morphs to ✅, no label — replacing
the old press-and-hold popover) and the in-game **add-friend 🤝** and **block ✖️** card
controls. The **Drop game** action keeps its `Modal` confirmation (a destructive, less-frequent action).
-- **MakeMove / Reset**: when ≥1 tile is pending the rack collapses its used slots
- and shifts left, a **borderless ✅ icon button** (styled like a tab, not a filled accent
- button) beside the rack commits the move — no popover, and disabled while the pending word
- is known illegal; the 🔀 Shuffle tab is replaced by a **↩️ Reset** tab.
-- **Game tab bar**: 🔄 Exchange/Pass (opens a dialog — pick tiles to **Exchange N**, or pick
+- **Confirm / Reset**: while ≥1 tile is pending a **borderless ✅ icon button** (styled like a
+ tab, not a filled accent button) takes the rack's **fixed 7th slot** and commits the move — no
+ popover, and disabled while the pending word is known illegal; the 🔀 Shuffle tab is replaced by
+ a **↩️ Reset** tab.
+- **Game tab bar**: 🔄 Exchange/Pass (a **bag-count badge** on its icon while the bag is non-empty;
+ opens a dialog — pick tiles to **Exchange N**, or pick
none to **Pass without exchanging**; pass is always available on your turn, exchange only
while the bag still holds a full rack, below which the tiles disable and only the pass
remains), 🛟 Hint (with a remaining-count badge, disabled at zero); 🔀 Shuffle (no label,
no confirm), which
**animates** — tiles hop along a low parabola to their new slots (duration scaled by the
- distance, the longest ≤ 0.3 s; off under reduce-motion) with a short haptic shake. The
- under-board slot shows the **Scores: N** preview. The screen **title** is the variant's
+ distance, the longest ≤ 0.3 s; off under reduce-motion) with a short haptic shake. A **thin strip
+ above the score plaques** reads, while composing a legal play, the formed word(s) and the move
+ score ("WORD+WORD = N"); otherwise whose turn it is, or the viewer's result once the game ends. The
+ move score also rides the board badge (above), never an under-board caption.
+ The screen **title** is the variant's
display name (Scrabble / Скрэббл / Erudite / Эрудит), not a constant "Scrabble".
## Advertising banner (`components/AdBanner.svelte` in `components/Header.svelte`, `lib/banner.ts` + `lib/bannerEngine.ts`)
diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts
index ec2f1b3..d0a2fa1 100644
--- a/ui/e2e/game.spec.ts
+++ b/ui/e2e/game.spec.ts
@@ -261,7 +261,7 @@ test('dropping the game ends it and shows the result', async ({ page }) => {
await page.locator('.scoreboard').click(); // open the history
await page.getByRole('button', { name: 'Drop game' }).click(); // 🏁 in the history header
await page.locator('button.danger').click(); // confirm in the modal
- await expect(page.locator('.status .over')).toBeVisible();
+ await expect(page.locator('.turnstrip.result')).toBeVisible();
});
test('resigning reveals the full board: closes the history and zooms out', async ({ page }) => {
@@ -283,7 +283,7 @@ test('resigning reveals the full board: closes the history and zooms out', async
await page.getByRole('button', { name: 'Drop game' }).click(); // 🏁
await page.locator('button.danger').click(); // confirm
- await expect(page.locator('.status .over')).toBeVisible(); // the game ended
+ await expect(page.locator('.turnstrip.result')).toBeVisible(); // the game ended
await expect(page.locator('.history')).toHaveCount(0); // history auto-closed (portrait)
await expect(page.locator('.viewport.zoomed')).toHaveCount(0); // board zoomed out
});
diff --git a/ui/e2e/ingame-ux.spec.ts b/ui/e2e/ingame-ux.spec.ts
new file mode 100644
index 0000000..d91c073
--- /dev/null
+++ b/ui/e2e/ingame-ux.spec.ts
@@ -0,0 +1,41 @@
+import { expect, test } from './fixtures';
+
+// The in-game composition UX after the under-board status strip was removed:
+// - whose turn (or the final result) shows in a thin strip above the score plaques;
+// - the tiles left in the bag ride the exchange control as a badge and repeat at the foot of the
+// move table;
+// - staging a play tints its tiles on the board and shows the orange move-score badge, and the
+// confirm control sits in the rack's fixed 7th slot.
+// Mock transport only: the mock has no dictionary (Game.svelte skips the local evaluator under the
+// mock mode) and its network evaluator accepts any placement, so a staged tile always reads as legal.
+
+test('in-game UX: turn strip, bag badge + table footer, staged-play highlight and score badge', async ({ page }) => {
+ await page.goto('/');
+ await page.getByRole('button', { name: /guest/i }).click();
+ await page.getByRole('button', { name: /🎲/ }).click();
+ await page.getByRole('button', { name: 'Random player' }).click();
+ await page.locator('.variant').first().click();
+ await page.getByRole('button', { name: /Start game/i }).click();
+ // Attach the opponent deterministically, then it is the player's turn.
+ await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent());
+ await expect(page.getByText('Robo')).toBeVisible();
+
+ // The old under-board status line is gone; a thin turn strip sits above the plaques instead.
+ await expect(page.locator('.turnstrip')).toBeVisible();
+ await expect(page.locator('.status')).toHaveCount(0);
+
+ // The bag count rides the exchange control (the first tab) as a badge, and repeats at the foot of
+ // the move table, out of the scrolling grid.
+ await expect(page.locator('.tab').first().locator('.badge')).toBeVisible();
+ await page.locator('.scoreboard').click(); // open the history drawer
+ await expect(page.locator('.hbagfoot')).toContainText(/bag/i);
+ await page.locator('.scoreboard').click(); // close it again
+
+ // Stage a play: the pending tile reads as legal (green), the orange move-score badge shows on the
+ // board, and the confirm control takes the rack's 7th slot.
+ await page.locator('.rack .tile').first().click();
+ await page.locator('[data-cell]:not(.filled)').nth(112).click(); // centre (row 7, col 7)
+ await expect(page.locator('[data-cell].pending.legal')).toHaveCount(1);
+ await expect(page.locator('.scorebadge')).toBeVisible();
+ await expect(page.locator('.make')).toBeVisible();
+});
diff --git a/ui/e2e/quickmatch.spec.ts b/ui/e2e/quickmatch.spec.ts
index ddf85d2..6699aae 100644
--- a/ui/e2e/quickmatch.spec.ts
+++ b/ui/e2e/quickmatch.spec.ts
@@ -75,7 +75,7 @@ test('AI game: after it ends, no GCG export and no comms entry', async ({ page }
await page.locator('.scoreboard').click();
await page.getByRole('button', { name: 'Drop game' }).click();
await page.locator('button.danger').click();
- await expect(page.locator('.status .over')).toBeVisible();
+ await expect(page.locator('.turnstrip.result')).toBeVisible();
// Reopen the history (resigning auto-closed it): the finished AI game offers no GCG export and
// no comms entry at all.
diff --git a/ui/e2e/smoke.spec.ts b/ui/e2e/smoke.spec.ts
index 81900fc..e5b89c6 100644
--- a/ui/e2e/smoke.spec.ts
+++ b/ui/e2e/smoke.spec.ts
@@ -22,8 +22,8 @@ test('guest reaches a board and previews a placement', async ({ page }) => {
await rackTile.click();
await page.locator('[data-cell]:not(.filled)').nth(30).click();
await expect(page.locator('[data-cell].pending')).toHaveCount(1);
- // The score preview appears where the hints count used to be.
- await expect(page.locator('.scores')).toContainText(/\d/);
+ // The move-score badge appears on the board for the staged play.
+ await expect(page.locator('.scorebadge')).toContainText(/\d/);
// The contextual MakeMove control (✅) appears once a tile is pending.
await expect(page.locator('.make')).toBeVisible();
diff --git a/ui/e2e/zoom.spec.ts b/ui/e2e/zoom.spec.ts
index 5144acf..d5bf0b6 100644
--- a/ui/e2e/zoom.spec.ts
+++ b/ui/e2e/zoom.spec.ts
@@ -74,11 +74,11 @@ test.describe('touch placement', () => {
});
});
-// A hint taken while the board is ALREADY zoomed in must still scroll to the played word — the
-// zoom state does not change, so without an explicit recenter the board stays parked where the
-// player was looking (the reported rough edge). The mock hint plays at the centre star (7,7), so
-// zooming into the top-left corner first and then hinting must pan the board toward the centre.
-test('a hint recentres an already-zoomed board on the played word', async ({ page }) => {
+// A hint taken while the board is ALREADY zoomed in now zooms OUT to the whole board, so the played
+// word — highlighted while composing — is guaranteed visible rather than parked off-screen under the
+// old zoom (the owner changed this from the former recentre-on-the-word behaviour, so the word can
+// never be lost off-screen). The mock hint plays at the centre star (7,7).
+test('a hint zooms out an already-zoomed board so the played word is visible', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /Ann/ }).click();
@@ -91,19 +91,17 @@ test('a hint recentres an already-zoomed board on the played word', async ({ pag
el.click();
el.click();
});
- const viewport = page.locator('.viewport.zoomed');
- await expect(viewport).toBeVisible();
- await page.waitForTimeout(400); // let the zoom-in settle near the top-left corner
- const before = await viewport.evaluate((el) => ({ left: el.scrollLeft, top: el.scrollTop }));
+ await expect(page.locator('.viewport.zoomed')).toBeVisible();
// Take a hint (the control confirms on a second tap), which plays at the centre.
const hint = page.getByRole('button', { name: 'Hint' });
await hint.click();
await hint.click();
- await page.waitForTimeout(300); // the board pans to the hint word
- const after = await viewport.evaluate((el) => ({ left: el.scrollLeft, top: el.scrollTop }));
- // The board panned from the top-left toward the centre word: both offsets grew.
- expect(after.left).toBeGreaterThan(before.left + 20);
- expect(after.top).toBeGreaterThan(before.top + 20);
+ // The board zoomed out — no zoomed viewport — with the hint's play staged (the confirm control
+ // shows). The mock hint plays on the centre star, which the seeded game already occupies, so the
+ // staged tile hides under the committed one; the confirm control is the reliable "a play is
+ // staged" signal.
+ await expect(page.locator('.viewport.zoomed')).toHaveCount(0);
+ await expect(page.locator('.make')).toBeVisible();
});
diff --git a/ui/scripts/bundle-size.mjs b/ui/scripts/bundle-size.mjs
index f10beb8..5f33ae8 100644
--- a/ui/scripts/bundle-size.mjs
+++ b/ui/scripts/bundle-size.mjs
@@ -32,9 +32,12 @@ const DIST = 'dist';
// storefront logic and the catalog codec load with the always-mounted settings hub (its i18n lands
// in the shared chunk) — and to 125 for the payment intake rails: the wallet order flow, the
// Telegram Stars openInvoice / VK order-box launch and the per-button in-flight state ride the same
-// always-loaded Wallet screen. The heavy parts — the dict loader, the move generator and the preload
-// orchestration — still stay in lazy chunks. Scoped CSS lands in the CSS chunk, not this JS budget.
-const BUDGET = { app: 125, shared: 31, landing: 5 };
+// always-loaded Wallet screen, then to 126 for the in-game board-feedback UX — the staged-play
+// highlight geometry (lib/formed.ts), the on-board score badge and the bag / turn-strip / board-zoom
+// wiring live in the always-loaded Game / Board / Rack screens. The heavy parts — the dict loader,
+// the move generator and the preload orchestration — still stay in lazy chunks. Scoped CSS lands in
+// the CSS chunk, not this JS budget.
+const BUDGET = { app: 126, shared: 31, landing: 5 };
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
diff --git a/ui/src/Landing.svelte b/ui/src/Landing.svelte
index 44f918a..4fa71a0 100644
--- a/ui/src/Landing.svelte
+++ b/ui/src/Landing.svelte
@@ -55,6 +55,7 @@
locale: lc,
reduceMotion: prefs.reduceMotion ?? false,
boardLabels: prefs.boardLabels ?? 'beginner',
+ zoomBoard: prefs.zoomBoard ?? true,
});
prefs = { ...prefs, locale: lc };
}
diff --git a/ui/src/app.css b/ui/src/app.css
index 2e573a7..aa3e6e3 100644
--- a/ui/src/app.css
+++ b/ui/src/app.css
@@ -29,6 +29,14 @@
--tile-edge: #d8c190;
--tile-text: #2a2113;
--tile-pending: #f2cf73;
+ /* In-composition highlight of the staged play (see game/Board.svelte): a calm reddish-pink
+ when the tiles form no word, a light green for the player's own tiles once they do, a
+ deeper green for the board tiles a formed word runs through, and the orange move-score
+ badge. Tuned per theme; refined on the contour. */
+ --tile-pending-illegal: #f2c7bf;
+ --tile-pending-legal: #c9e7bd;
+ --tile-formed: #a5cf93;
+ --score-badge: #e5811d;
/* Last-word highlight letter — a lighter burgundy than the dark theme on purpose: against
the lighter tile the perceived contrast needs it, so the two are tuned per theme. */
--tile-recent: #9c5849;
@@ -84,6 +92,10 @@
--tile-edge: #b6a473;
--tile-text: #20190d;
--tile-pending: #d8b75e;
+ --tile-pending-illegal: #d8a99f;
+ --tile-pending-legal: #a9cf94;
+ --tile-formed: #83b571;
+ --score-badge: #e88f31;
--tile-recent: #8c4a3c;
--prem-tw: #9c3f34; /* 3x word: a touch darker red */
--prem-dw: #a8636b; /* 2x word: softer, pinker */
@@ -115,6 +127,10 @@
--tile-edge: #b6a473;
--tile-text: #20190d;
--tile-pending: #f0d98f;
+ --tile-pending-illegal: #d8a99f;
+ --tile-pending-legal: #a9cf94;
+ --tile-formed: #83b571;
+ --score-badge: #e88f31;
/* Last-word highlight letter — a warm burgundy whose red hue stays distinct from both the
near-black glyph and the warm tile. The light theme uses a lighter burgundy (tuned per
theme; perceived contrast depends on the surrounding board). */
diff --git a/ui/src/components/Modal.svelte b/ui/src/components/Modal.svelte
index c0adfea..a3200c6 100644
--- a/ui/src/components/Modal.svelte
+++ b/ui/src/components/Modal.svelte
@@ -3,12 +3,16 @@
let {
title = '',
+ titleAside = '',
onclose,
overlayKeyboard = false,
bottomSheet = false,
children,
}: {
title?: string;
+ /** Optional secondary text pinned to the right of the title row, in a lighter (non-bold)
+ * muted style — e.g. the bag count beside the "Exchange or pass" title. */
+ titleAside?: string;
onclose?: () => void;
overlayKeyboard?: boolean;
bottomSheet?: boolean;
@@ -65,7 +69,12 @@
style:--kb={bottomSheet ? `${kb}px` : null}
onclick={(e) => e.stopPropagation()}
>
- {#if title}
{title}
{/if}
+ {#if title}
+
+
{title}
+ {#if titleAside}{titleAside}{/if}
+
+ {/if}
{@render children?.()}
@@ -121,8 +130,21 @@
max-height: 86dvh;
overflow: auto;
}
+ .titlerow {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 8px;
+ }
h2 {
margin: 0 0 10px;
font-size: 1.05rem;
}
+ /* Secondary title-row text (e.g. the bag count): lighter and muted so the title still leads. */
+ .aside {
+ font-weight: 400;
+ font-size: 0.9rem;
+ color: var(--text-muted);
+ white-space: nowrap;
+ }
diff --git a/ui/src/components/TabBar.svelte b/ui/src/components/TabBar.svelte
index 8f6d512..e518098 100644
--- a/ui/src/components/TabBar.svelte
+++ b/ui/src/components/TabBar.svelte
@@ -85,7 +85,9 @@
color: var(--accent-text);
border-radius: 999px;
min-width: 15px;
- padding: 0 3px;
+ /* Enough horizontal room that a two/three-digit count (e.g. the bag count) does not touch the
+ pill's rounded ends. */
+ padding: 0 5px;
line-height: 1.4;
text-align: center;
}
diff --git a/ui/src/game/Board.svelte b/ui/src/game/Board.svelte
index 3e94fa3..998c273 100644
--- a/ui/src/game/Board.svelte
+++ b/ui/src/game/Board.svelte
@@ -23,6 +23,9 @@
focus,
recenter,
dropTarget,
+ previewLegal,
+ formed,
+ scoreBadge,
oncell,
ontogglezoom,
onrecall,
@@ -48,6 +51,15 @@
recenter: number;
/** The cell a dragged tile is currently aimed at, highlighted as a drop target. */
dropTarget: { row: number; col: number } | null;
+ /** While composing: true when the staged tiles form a legal play, false when not, null when
+ * there is no preview (off-turn, nothing staged, or a recall drag) — tints the pending tiles
+ * green vs a calm pink. */
+ previewLegal: boolean | null;
+ /** "row,col" keys of every committed board cell a formed word runs through, greened a shade
+ * darker than the player's own staged tiles. */
+ formed: Set;
+ /** The orange move-score badge for the legal play being composed, or null. */
+ scoreBadge: { row: number; col: number; corner: 'tr' | 'bl'; score: number } | null;
oncell: (row: number, col: number) => void;
ontogglezoom: (row: number, col: number) => void;
/** Recall the pending tile at (row, col) — fired on a double-tap of a pending cell. */
@@ -60,6 +72,20 @@
const z = $derived(zoomed ? Z : 1);
const premClass: Record = { '': '', TW: 'tw', DW: 'dw', TL: 'tl', DL: 'dl' };
+ // The score badge sits on a corner of its anchor cell, clamped so the whole pill stays on the
+ // board. Its position is a percentage of the (15-cell) board; CSS centres the pill on the point.
+ const BADGE_MARGIN = 2.4;
+ const badgePos = $derived.by(() => {
+ const b = scoreBadge;
+ if (!b) return null;
+ const cell = 100 / 15;
+ let x = b.corner === 'tr' ? (b.col + 1) * cell : b.col * cell;
+ let y = b.corner === 'tr' ? b.row * cell : (b.row + 1) * cell;
+ x = Math.max(BADGE_MARGIN, Math.min(100 - BADGE_MARGIN, x));
+ y = Math.max(BADGE_MARGIN, Math.min(100 - BADGE_MARGIN, y));
+ return { x, y };
+ });
+
let viewport = $state();
// Genuine layout zoom (the board grows; cqw labels stay constant), so native scroll
@@ -315,6 +341,9 @@
class="cell {premClass[premium[r][c]]}"
class:filled={!!cell}
class:pending={!!p && !cell}
+ class:legal={!!p && !cell && previewLegal === true}
+ class:illegal={!!p && !cell && previewLegal === false}
+ class:formed={!!cell && formed.has(key(r, c))}
class:hl={!!cell && highlight.has(key(r, c)) && !flash}
class:flash={!!cell && flash && highlight.has(key(r, c))}
class:dark={premium[r][c] === '' && !cell && !p && (r + c) % 2 === 1}
@@ -342,6 +371,9 @@
{/each}
{/each}
+ {#if scoreBadge && badgePos}
+ {scoreBadge.score}
+ {/if}
@@ -397,6 +429,8 @@
gap: 0;
background: var(--board-bg);
padding: 0;
+ /* Anchor for the absolutely-positioned score badge (its percentages resolve against the board). */
+ position: relative;
}
.cell {
position: relative;
@@ -445,6 +479,19 @@
board) instead of the touch starting a board pan. */
touch-action: none;
}
+ /* While composing, the staged play recolours its tiles: a calm reddish-pink when they form no
+ word, a light green for the player's own tiles once they do; committed board tiles a formed
+ word runs through go a shade darker (see lib/formed + Game.svelte). .formed sits after .filled
+ so it wins on committed cells (equal specificity). */
+ .cell.pending.illegal {
+ background: var(--tile-pending-illegal);
+ }
+ .cell.pending.legal {
+ background: var(--tile-pending-legal);
+ }
+ .cell.formed {
+ background: var(--tile-formed);
+ }
.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. */
@@ -545,4 +592,25 @@
font-weight: 700;
white-space: nowrap;
}
+ /* The move-score badge for the legal play being composed: an orange pill centred on a corner of
+ the main word's tile (see lib/formed.badgePlacement + Game.svelte), its digit sized like a
+ tile's point value so it scales with the zoom. Decorative — no pointer events — and drawn above
+ the tiles. */
+ .scorebadge {
+ position: absolute;
+ transform: translate(-50%, -50%);
+ z-index: 5;
+ pointer-events: none;
+ border-radius: 999px;
+ background: var(--score-badge);
+ color: #fff;
+ font-weight: 700;
+ line-height: 1;
+ white-space: nowrap;
+ padding: 2px 5px; /* px fallback for Chrome < 105 (no cqw) */
+ padding: 0.4cqw 0.9cqw;
+ font-size: calc(2.4vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
+ font-size: 2.4cqw;
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
+ }
diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte
index bf16d99..f2ab090 100644
--- a/ui/src/game/Game.svelte
+++ b/ui/src/game/Game.svelte
@@ -49,6 +49,7 @@
type Placement,
} from '../lib/placement';
import { liveDraftTiles, parseDraft, serializeDraft, validRackOrder } from '../lib/draft';
+ import { badgePlacement, formedGeometry } from '../lib/formed';
let { id }: { id: string } = $props();
@@ -483,6 +484,23 @@
// 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);
+ // The composed play's word geometry (client-side, independent of the move evaluator): the cells a
+ // formed word runs through — greened on the board — and the main word that anchors the score
+ // badge. Derived only for a legal preview, and never while dragging a staged tile back over the
+ // rack (recallOverRack), so the highlight and badge clear the instant a recall begins.
+ const EMPTY_FORMED = new Set();
+ const formedGeom = $derived(
+ preview?.legal && !recallOverRack
+ ? formedGeometry(board, placement.pending, !!view?.game.multipleWordsPerTurn)
+ : null,
+ );
+ const formedCells = $derived(formedGeom?.cells ?? EMPTY_FORMED);
+ const boardBadge = $derived(
+ formedGeom && preview?.legal ? { ...badgePlacement(formedGeom.main), score: preview.score } : null,
+ );
+ // The pending-tile tint: null (default colour) with no preview or during a recall drag, otherwise
+ // the play's legality — green when legal, a calm pink when not.
+ const previewLegal: boolean | null = $derived(recallOverRack ? null : preview ? preview.legal : null);
let dragPointerId = -1;
function beginDrag(src: DragSrc, e: PointerEvent) {
@@ -610,7 +628,7 @@
? setTimeout(() => {
// Still holding the tile over this cell: magnify into it. Only the first
// (zoom-in) hold centres; once zoomed we never move the board on hover.
- if (drag && isCoarse() && !landscape && !zoomed) {
+ if (drag && isCoarse() && !landscape && !zoomed && app.zoomBoard) {
focus = c;
zoomed = true;
haptic('light');
@@ -729,8 +747,8 @@
if (pendingMap.has(`${row},${col}`)) return;
focus = { row, col };
// Auto-zoom is portrait-only: landscape fits the whole board, magnifying it there
- // only hides the rest of the position.
- if (isCoarse() && !landscape && !zoomed) zoomed = true;
+ // only hides the rest of the position. The "Zoom the board" setting can turn it off.
+ if (isCoarse() && !landscape && !zoomed && app.zoomBoard) zoomed = true;
if (placement.rack[index] === BLANK) {
blankPrompt = { rackIndex: index, row, col };
return;
@@ -1030,23 +1048,11 @@
// Mark the turn as hinted: the interstitial fires when the player CONFIRMS the move (commit),
// not now — showing it here would interrupt placing the preview and revert the board on close.
hintUsedThisTurn = true;
- // Scroll the (zoomed) board to the hint's placement rather than the top-left:
- // focus the centre of the laid tiles' bounding box.
- const p = placement.pending;
- if (p.length) {
- const rows = p.map((tt) => tt.row);
- const cols = p.map((tt) => tt.col);
- focus = {
- row: Math.round((Math.min(...rows) + Math.max(...rows)) / 2),
- col: Math.round((Math.min(...cols) + Math.max(...cols)) / 2),
- };
- // Ask the board to scroll to the hint word. A zoomed-out board zooms in (and centres)
- // on the next line (portrait only); this nonce also recentres a board that is already
- // zoomed in, where the unchanged zoom state would otherwise leave it parked where the
- // player was looking.
- recenter++;
- }
- if (isCoarse() && !landscape) zoomed = true;
+ // A hint just landed: if the board was zoomed in, zoom OUT to the whole board so the hint's
+ // word — highlighted green while composing — is guaranteed visible rather than possibly
+ // parked off-screen under the old zoom. A full board needs no scroll-to-word, so the former
+ // focus/recenter step is gone with the zoom-in.
+ if (zoomed) zoomed = false;
view = { ...view, hintsRemaining: h.hintsRemaining };
syncWallet(h.walletBalance);
// The hint is the engine's own top-ranked, fully scored legal move: reuse it as the
@@ -1486,15 +1492,16 @@
{#if landscape}