fix(ui): one-word games must not highlight phantom cross words; review polish
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m25s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m2s

Board-highlight bug (reported on the contour): formedGeometry walked cross words
unconditionally, so in a single-word (one-word-per-turn) game a staged tile sitting
next to a committed tile lit up a green "cross word" the engine ignores — and which
need not even be a real word (the reported "БО lights up green in a one-word ПОПА
play"). Gate the cross-word walk on the game's multipleWordsPerTurn flag. The score
(8) was already correct — a premium square under the main word.

Also, from review:
- the turn strip reads the staged play's "WORD+WORD = N" while composing a legal move,
  reverting to the turn / result text otherwise;
- the Exchange/Pass dialog shows the bag count ("In the bag: N" / "Bag is empty")
  right-aligned in the title row, via a new optional Modal `titleAside`;
- cosmetics: half the turn strip's bottom padding (the plaques below carry their own
  top pad); a top gap above the landscape rack (it sat flush under the docked history);
  more horizontal padding on tab count badges so a 2-3 digit bag count clears the pill
  ends;
- admin console: the game Summary now shows the single-word / multiple-words rule.

Tests: formed single-word case added; full unit (584) + e2e (chromium + webkit, 113
each) green; backend build + adminconsole templates parse. Docs (FUNCTIONAL +_ru,
UI_DESIGN) updated.
This commit is contained in:
Ilia Denisov
2026-07-10 15:58:05 +02:00
parent 1e1117c28e
commit bf46b9492d
13 changed files with 111 additions and 36 deletions
+21 -5
View File
@@ -490,7 +490,9 @@
// rack (recallOverRack), so the highlight and badge clear the instant a recall begins.
const EMPTY_FORMED = new Set<string>();
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 @@
<!-- Reusable game-screen pieces, arranged differently by the portrait and landscape branches
above so the markup and logic stay single-sourced (see the {#if landscape} split). -->
{#snippet turnStrip()}
<!-- A thin line above the score plaques: whose turn it is during play, or the viewer's
result once the game ends. A hotseat game shows its result as per-seat medals on the
<!-- A thin line above the score plaques: while composing a legal play it reads the formed
word(s) and the move score ("WORD+WORD = N"); otherwise whose turn it is during play, or the
viewer's result once the game ends. A hotseat game shows its result as per-seat medals on the
plaques (2-4 local players have no single "you"), so the strip is hidden when it is over. -->
{#if view && !(gameOver && view.game.hotseat)}
<div class="turnstrip" class:result={gameOver}>
{#if gameOver}
{resultText()}
{:else if preview?.legal && !recallOverRack}
{preview.words.map((w) => w.toUpperCase()).join('+')} = {preview.score}
{:else}
{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}
{/if}
@@ -1764,7 +1769,11 @@
{/if}
{#if exchangeOpen && view}
<Modal title={t('game.exchangeTitle')} onclose={() => (exchangeOpen = false)}>
<Modal
title={t('game.exchangeTitle')}
titleAside={view.bagLen === 0 ? t('game.bagEmpty') : t('game.bagCount', { n: view.bagLen })}
onclose={() => (exchangeOpen = false)}
>
<div class="exch">
{#each view.rack as letter, i (i)}
<button class="etile" class:sel={exchangeSel.includes(i)} disabled={!canExchange} onclick={() => toggleExch(i)}>
@@ -1917,7 +1926,9 @@
play, accented for the viewer's win/lose result once the game ends. */
.turnstrip {
flex: none;
padding: 4px var(--pad);
/* A smaller bottom pad than top: the score plaques below carry their own top padding, so an
even strip read as too tall with the text pushed up. */
padding: 4px var(--pad) 2px;
text-align: center;
font-size: 0.85rem;
font-weight: 600;
@@ -2049,6 +2060,11 @@
pointer-events: none;
opacity: 0.55;
}
/* Landscape: the rack sits directly under the docked history (no board between them), so give it a
small gap above; portrait gets its spacing from the board's own padding. */
.game-land .rack-row {
padding-top: 6px;
}
.rack-wrap {
flex: 1;
min-width: 0;