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/FUNCTIONAL.md b/docs/FUNCTIONAL.md
index 29caaeb..e8b7f0b 100644
--- a/docs/FUNCTIONAL.md
+++ b/docs/FUNCTIONAL.md
@@ -211,8 +211,9 @@ 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 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, or a
-calm pink when they form none — and an **orange badge** on the main word carries the move's
+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
diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md
index b01c083..40efc4c 100644
--- a/docs/FUNCTIONAL_ru.md
+++ b/docs/FUNCTIONAL_ru.md
@@ -219,8 +219,9 @@ e-mail) либо ввод фразы. Активные игры форфейтя
слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при
сдаче и считается; безлимитный предпросмотр показывает **прямо на доске**, допустим ли
предполагаемый ход: выложенные фишки становятся светло-зелёными, когда образуют слово
-(и чуть темнее — фишки доски, через которые проходит слово), или спокойно-розовыми,
-когда слова нет, — а **оранжевый бейдж** на основном слове несёт очки хода. Кнопка
+(и чуть темнее — фишки доски, через которые проходит слово; в партии «одно слово за ход»
+подсвечивается только основное слово), или спокойно-розовыми, когда слова нет, — а
+**оранжевый бейдж** на основном слове несёт очки хода. Кнопка
подтверждения появляется только когда ход допустим. Предпросмотр считается **на устройстве**
и отвечает мгновенно, как только словарь партии загружен — иначе при первом открытии показывается
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md
index 7681013..0d2c510 100644
--- a/docs/UI_DESIGN.md
+++ b/docs/UI_DESIGN.md
@@ -249,7 +249,8 @@ e2e and the screenshots.
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),
+ 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.
@@ -286,9 +287,10 @@ e2e and the screenshots.
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. Whose turn it
- is — or the viewer's result once the game ends — shows in a **thin strip above the score
- plaques**; the tentative move's score rides the board badge (above), not an under-board caption.
+ 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".
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/Game.svelte b/ui/src/game/Game.svelte
index 324cf30..f2ab090 100644
--- a/ui/src/game/Game.svelte
+++ b/ui/src/game/Game.svelte
@@ -490,7 +490,9 @@
// 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) : null,
+ preview?.legal && !recallOverRack
+ ? formedGeometry(board, placement.pending, !!view?.game.multipleWordsPerTurn)
+ : null,
);
const formedCells = $derived(formedGeom?.cells ?? EMPTY_FORMED);
const boardBadge = $derived(
@@ -1522,13 +1524,16 @@
{#snippet turnStrip()}
-
{#if view && !(gameOver && view.game.hotseat)}