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/deploy/README.md b/deploy/README.md
index a10ece9..60e03af 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -255,8 +255,10 @@ shipping or redeploying this stack does **not** start archiving — the artifact
armed, which is why an un-armed prod deploy can never pile WAL onto the disk. The base-backup
timer is provisioned by the Ansible `main` role behind `pitr_enabled` (also default off). Two
Grafana alerts watch health: `WAL archiving failing` (`pg_stat_archiver_failed_count` rising)
-and `WAL archiving stalled` (`pg_stat_archiver_last_archive_age` over 30 min) — both
-absent/NaN-safe, so they stay quiet until archiving is armed.
+and `WAL archiving stalled` (last archive over 30 min old **while `pg_wal_size_bytes` is growing**
+— the pg_wal-growth guard keeps an idle database, which archives nothing because it writes nothing,
+from false-triggering during quiet hours) — both absent/NaN-safe, so they stay quiet until
+archiving is armed.
**Assessment (owner-reviewed; the gate before the first real money).** Measured on prod
`pg_stat_wal`: WAL is generated at **~0.77 MB/day** and the database is **~9.6 MB**. At
diff --git a/deploy/grafana/provisioning/alerting/rules.yaml b/deploy/grafana/provisioning/alerting/rules.yaml
index c56aae6..9657c23 100644
--- a/deploy/grafana/provisioning/alerting/rules.yaml
+++ b/deploy/grafana/provisioning/alerting/rules.yaml
@@ -245,8 +245,14 @@ groups:
conditions:
- evaluator: { type: gt, params: [0] }
labels: { severity: critical }
- annotations: { summary: 'pgBackRest archive_command is failing — PITR is degrading and pg_wal can fill the disk. Run `docker exec scrabble-postgres pgbackrest --stanza=scrabble check`.' }
+ annotations: { summary: 'pgBackRest archive_command is failing — PITR is degrading and pg_wal can fill the disk. Run `docker exec scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble check`.' }
+ # Fires only when the last archive is >30 min old AND pg_wal is actually growing (WAL is being
+ # produced but not archived). A genuinely idle database archives nothing — Postgres does not
+ # force-switch an empty segment on archive_timeout — so `last_archive_age` alone false-triggers
+ # during quiet hours; the pg_wal-growth guard removes that. `and on()` bridges the differing
+ # label sets (last_archive_age has a server label, the pg_wal gauge does not); delta() (not
+ # increase) suits the pg_wal_size_bytes gauge. Real archive failures are caught by pg_archive_failing.
- uid: pg_archive_stalled
title: WAL archiving stalled
condition: C
@@ -255,11 +261,11 @@ groups:
execErrState: OK
data:
- refId: A
- relativeTimeRange: { from: 300, to: 0 }
+ relativeTimeRange: { from: 2100, to: 0 }
datasourceUid: prometheus
model:
refId: A
- expr: pg_stat_archiver_last_archive_age
+ expr: (pg_stat_archiver_last_archive_age > 1800) and on() (delta(pg_wal_size_bytes[35m]) > 16777216)
instant: true
- refId: C
datasourceUid: __expr__
@@ -270,4 +276,4 @@ groups:
conditions:
- evaluator: { type: gt, params: [1800] }
labels: { severity: critical }
- annotations: { summary: 'No WAL segment archived for over 30 minutes (archive_timeout is 5m) — archiving is stalled; the PITR recovery point is frozen and pg_wal may grow.' }
+ annotations: { summary: 'No WAL archived for over 30 minutes while pg_wal keeps growing — archiving is genuinely stalled; the PITR recovery point is frozen and pg_wal will fill the disk. Run `docker exec scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble check`.' }
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..d93dc5e 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
@@ -528,21 +534,29 @@ shown defensively (text escaped, attachments downloaded rather than rendered).
### Wallet
Durable players have a **Wallet** — a tab in the Settings hub, between Friends and About; guests have
-none. It shows their **chips** (the in-game currency, split by where they were funded — VK, Telegram
-or the web), their **active benefits** (ads off until a date or forever, and the available hint
-count), and a **store**. What is visible and spendable depends on **where the app runs**, by the
-store-compliance rules in [`PAYMENTS.md`](PAYMENTS.md): inside a store only that store's chips are
-usable; on the open web all attached chips are, drawn web → VK → Telegram; on VK-iOS the balance is
-shown but frozen for spending.
+none. A compact **balance** leads the screen: the running context's own **chips** (the in-game
+currency) first, then any linked other-platform chips behind that platform's logo — VK, Telegram or
+the web, split by where they were funded. What is visible and spendable depends on **where the app
+runs**, by the store-compliance rules in [`PAYMENTS.md`](PAYMENTS.md): inside a store only that
+store's chips are usable; on the open web all attached chips are, drawn web → VK → Telegram; on
+VK-iOS the balance is shown but frozen for spending.
+
+Below the balance an **Active** line lists what the player currently holds — the remaining hint count
+and, if bought, the ad-free end date (or "forever"); it is hidden when nothing is active. The
+**store** then splits, by a two-way toggle, into **Buy chips** and **Spend chips**:
+
+- **Buy chips** — the **chip packs** bought with real money, priced in the running context's currency
+ (VK Votes, Telegram Stars, or roubles on the web), plus, at the top, a **watch-an-ad-for-chips**
+ option where it is offered. Paying opens the provider's payment page (accepting the public offer).
+- **Spend chips** — the **values** bought with chips (extra hints, days without ads) at a fixed chip
+ price. Their action reads **Exchange**, and a value the balance cannot cover is disabled. Tapping
+ Exchange asks the player to **confirm** (the spend is instant, with no provider window to stand in
+ for a confirmation); that same dialog also carries the store-compliance **warning** when a web
+ spend would draw VK/Telegram chips — the benefit then works only on the web and in the app.
-The **store** lists **values** bought with chips (extra hints, days without ads) at a fixed chip
-price shown in every context, and **chip packs** bought with real money, priced in the running
-context's currency (VK Votes, Telegram Stars, or roubles on the web). Buying a value applies its
-benefit at once. Before a web purchase that would draw VK/Telegram chips, the app **warns** that the
-benefit will then work only on the web and in the app — a store rule — and asks the player to confirm.
On the **Google Play** build the money purchases are hidden behind a note pointing to the RuStore
build (Google's in-app-currency rule); spending already-earned chips still works. The wallet keeps
-**no purchase history** — only the current balances, benefits and store.
+**no purchase history** — only the current balances, active benefits and store.
### Advertising banner
diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md
index c89f15d..7e27cf5 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, показанные **сначала Эрудит**, — в
@@ -541,21 +548,30 @@ high-rate флага. С карточки пользователя операт
### Кошелёк
У постоянных (не гостевых) игроков есть **Кошелёк** — вкладка в разделе настроек, между «Друзьями» и
-«О программе»; у гостей его нет. Он показывает **фишки** (внутриигровую валюту, разделённую по тому,
-где она была пополнена — VK, Telegram или веб), **активные блага** (реклама выключена до даты или
-навсегда, и число доступных подсказок) и **магазин**. Что видно и что можно потратить, зависит от
-того, **где запущено приложение**, по правилам соответствия магазинам из [`PAYMENTS.md`](PAYMENTS.md):
-внутри магазина доступны только его фишки; в открытом вебе — все привязанные, списываются в порядке
-веб → VK → Telegram; на VK-iOS баланс показан, но трата заморожена.
+«О программе»; у гостей его нет. Экран открывает компактный **баланс**: сначала собственные **фишки**
+(внутриигровая валюта) текущего контекста, затем привязанные фишки других платформ за их логотипом —
+VK, Telegram или веб, разделённые по тому, где они были пополнены. Что видно и что можно потратить,
+зависит от того, **где запущено приложение**, по правилам соответствия магазинам из
+[`PAYMENTS.md`](PAYMENTS.md): внутри магазина доступны только его фишки; в открытом вебе — все
+привязанные, списываются в порядке веб → VK → Telegram; на VK-iOS баланс показан, но трата заморожена.
-**Магазин** перечисляет **ценности**, покупаемые за фишки (дополнительные подсказки, дни без рекламы),
-по фиксированной цене в фишках, одинаковой во всех контекстах, и **наборы фишек**, покупаемые за
-настоящие деньги, в валюте текущего контекста (голоса VK, звёзды Telegram или рубли в вебе). Покупка
-ценности сразу применяет её благо. Перед покупкой в вебе, которая списала бы фишки VK/Telegram,
-приложение **предупреждает**, что благо будет работать только в вебе и в приложении — это правило
-магазинов — и просит подтверждения. В сборке для **Google Play** покупки за деньги скрыты за подсказкой
-установить сборку из RuStore (правило Google о внутриигровой валюте); трата уже заработанных фишек
-по-прежнему работает. Кошелёк **не хранит историю покупок** — только текущие балансы, блага и магазин.
+Под балансом строка **«Активные»** перечисляет, что у игрока есть сейчас — остаток подсказок и, если
+куплено, дату окончания «без рекламы» (или «навсегда»); она скрыта, когда активного ничего нет. Далее
+**магазин** двухпозиционным переключателем делится на **«Купить фишки»** и **«Потратить фишки»**:
+
+- **Купить фишки** — **наборы фишек** за настоящие деньги, в валюте текущего контекста (голоса VK,
+ звёзды Telegram или рубли в вебе), а сверху — вариант **«ролик за фишки»**, где он предлагается.
+ Оплата открывает страницу провайдера (с принятием публичной оферты).
+- **Потратить фишки** — **ценности** за фишки (дополнительные подсказки, дни без рекламы) по
+ фиксированной цене в фишках. Их действие называется **«Обмен»**, а ценность, на которую не хватает
+ баланса, недоступна. Тап по «Обмену» просит **подтверждения** (трата мгновенна, окна провайдера,
+ которое сошло бы за подтверждение, нет); это же окно несёт и **предупреждение** соответствия
+ магазинам, когда трата в вебе списала бы фишки VK/Telegram — благо тогда работает только в вебе и в
+ приложении.
+
+В сборке для **Google Play** покупки за деньги скрыты за подсказкой установить сборку из RuStore
+(правило Google о внутриигровой валюте); трата уже заработанных фишек по-прежнему работает. Кошелёк
+**не хранит историю покупок** — только текущие балансы, активные блага и магазин.
### Рекламный баннер
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/wallet.spec.ts b/ui/e2e/wallet.spec.ts
index 4298fd6..1651bc0 100644
--- a/ui/e2e/wallet.spec.ts
+++ b/ui/e2e/wallet.spec.ts
@@ -1,9 +1,10 @@
import { expect, test, type Page } from './fixtures';
// The Wallet section against the mock transport (no backend). The mock seeds a web/native (direct)
-// account holding a direct + vk chip segment and a small catalog (two chip-priced values and one
-// rouble-priced chip pack — see lib/mock/client.ts), so the storefront, the store-compliance
-// warning and the Google Play stub are all exercisable without a backend.
+// account holding a direct (120) + vk (400) chip segment and a small catalog (two chip-priced values
+// and one rouble-priced chip pack — see lib/mock/client.ts), so the compact balance, the split
+// storefront, the exchange-confirm dialog (with its store-compliance warning) and the Google Play
+// stub are all exercisable without a backend.
async function loginLobby(page: Page): Promise {
await page.goto('/');
@@ -22,24 +23,33 @@ async function openWallet(page: Page): Promise {
await expect(page.getByTestId('wallet')).toBeVisible();
}
-test('wallet: balances, benefits and the storefront render for a durable account', async ({ page }) => {
+test('wallet: balance, active benefits and the split storefront render for a durable account', async ({ page }) => {
await loginLobby(page);
await openWallet(page);
- // Balances: the seeded direct ("Web") and vk segments.
- await expect(page.getByRole('heading', { name: 'Balance' })).toBeVisible();
- await expect(page.getByText('Web', { exact: true })).toBeVisible();
- await expect(page.getByText('VK', { exact: true })).toBeVisible();
+ // The compact balance leads with the context's own (direct/"Web") chips, then the linked vk
+ // segment behind its logo.
+ const balance = page.getByTestId('balance');
+ await expect(balance).toContainText('120');
+ await expect(balance).toContainText('400');
+ await expect(balance.locator('.plogo')).toHaveCount(1); // the one linked (vk) platform
- // Benefits + the storefront: two values priced in chips, one pack priced in roubles.
- await expect(page.getByRole('heading', { name: 'Benefits' })).toBeVisible();
+ // The active benefits (the seeded 5 hints) sit above the store.
+ await expect(page.getByRole('heading', { name: 'Active' })).toBeVisible();
+ await expect(page.getByTestId('active')).toContainText('Hints: 5');
await expect(page.getByRole('heading', { name: 'Store' })).toBeVisible();
- await expect(page.getByTestId('product')).toHaveCount(3);
- const pack = page.locator('[data-kind="pack"]');
- await expect(pack).toContainText('₽');
- // The pack purchase is wired to money intake: an enabled Buy action, with the public-offer link.
- await expect(pack.getByTestId('buy-pack')).toBeEnabled();
+
+ // Default tab — Buy chips: one rouble-priced pack + the public offer, and no values.
+ await expect(page.locator('[data-kind="pack"]')).toHaveCount(1);
+ await expect(page.locator('[data-kind="pack"]')).toContainText('₽');
+ await expect(page.locator('[data-kind="pack"]').getByTestId('buy-pack')).toBeEnabled();
await expect(page.getByTestId('offer')).toContainText('Public offer');
+ await expect(page.locator('[data-kind="value"]')).toHaveCount(0);
+
+ // Spend chips tab: the two chip-priced values, exchanged (not bought) with the "Exchange" button.
+ await page.getByTestId('tab-spend').click();
+ await expect(page.locator('[data-kind="value"]')).toHaveCount(2);
+ await expect(page.locator('[data-kind="value"]').first().getByTestId('buy')).toHaveText('Exchange');
});
test('wallet: buying a chip pack opens the provider payment page', async ({ page }) => {
@@ -87,32 +97,39 @@ test('wallet: the Google Play build hides the money purchases behind the RuStore
await expect(page.getByText('Your turn')).toBeVisible();
await openWallet(page);
- // The chip pack is gone; the stub points at the RuStore build. Spending earned chips still works.
+ // Buy chips: the chip pack is gone; the stub points at the RuStore build.
await expect(page.getByTestId('gp-stub')).toBeVisible();
await expect(page.locator('[data-kind="pack"]')).toHaveCount(0);
+ // Spending earned chips still works — the values live in the Spend tab.
+ await page.getByTestId('tab-spend').click();
await expect(page.locator('[data-kind="value"]').first().getByTestId('buy')).toBeVisible();
});
-test('wallet: a web spend that would draw store chips warns first, then completes', async ({ page }) => {
+test('wallet: a web spend that would draw store chips confirms with a warning, then completes', async ({ page }) => {
await loginLobby(page);
await openWallet(page);
+ await page.getByTestId('tab-spend').click();
- // The 300-chip value drains direct (120) then reaches into vk (180) → a store segment → warn.
+ // The 300-chip value drains direct (120) then reaches into vk (180) → a store segment → the confirm
+ // dialog carries the cross-platform warning.
await page.locator('[data-pid="val-noads-30"]').getByTestId('buy').click();
await expect(page.getByTestId('warn')).toBeVisible();
- await page.getByTestId('warn-confirm').click();
- await expect(page.getByTestId('warn')).toBeHidden();
- // The spend applied: the no-ads benefit now shows an end date.
+ await page.getByTestId('spend-confirm').click();
+ // The spend applied: the no-ads benefit now shows an end date in the Active section.
await expect(page.getByTestId('wallet')).toContainText('No ads until');
});
-test('wallet: a spend the direct segment covers alone does not warn', async ({ page }) => {
+test('wallet: a spend the direct segment covers alone confirms without a warning', async ({ page }) => {
await loginLobby(page);
await openWallet(page);
+ await page.getByTestId('tab-spend').click();
- // The 100-chip value is covered by the direct segment (120) alone → no store draw, no warning.
+ // The 100-chip value is covered by the direct segment (120) alone → the confirm dialog shows, but
+ // with no cross-platform warning.
await page.locator('[data-pid="val-hints-50"]').getByTestId('buy').click();
+ await expect(page.getByTestId('spend-body')).toBeVisible();
await expect(page.getByTestId('warn')).toBeHidden();
+ await page.getByTestId('spend-confirm').click();
// The hints benefit rose from 5 to 55.
- await expect(page.getByTestId('wallet')).toContainText('Hints 55');
+ await expect(page.getByTestId('wallet')).toContainText('Hints: 55');
});
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..8be029a 100644
--- a/ui/scripts/bundle-size.mjs
+++ b/ui/scripts/bundle-size.mjs
@@ -32,9 +32,13 @@ 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, 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, then to 127 for the wallet storefront
+// redesign — its buy/spend toggle, compact balance and exchange-confirm dialog 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 };
+const BUDGET = { app: 127, 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}