feat(ui): move in-game status to the board — highlight, score badge, full-width rack
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Failing after 13s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped

Remove the under-board status strip and relocate its signals:
- bag count -> a badge on the exchange/pass control + the foot of the move table
- whose-turn / win-lose -> a thin strip above the score plaques
- the tentative-move caption -> the board itself: staged tiles tint green (legal) or
  pink (illegal), the board tiles a formed word runs through go a shade darker, and an
  orange score badge sits on the main word (digit sized like a tile value, clamped on-board)

The word geometry (covered cells + badge anchor) is a new pure client-side helper
(ui/src/lib/formed.ts), independent of the move evaluator, so it works on the local or
network preview path alike; the badge's number still comes from the preview score.

Rack: a seven-column grid filling the tray width in both layouts — square, full-width
tiles — with the confirm control in the fixed 7th slot.

Settings: a touch-only "Zoom the board" toggle (default on, device-local) gates the
tile-placement auto-zoom; taking a hint while zoomed in now zooms out so the highlighted
hint word is never left off-screen.

Docs (FUNCTIONAL +_ru, UI_DESIGN, ARCHITECTURE) and e2e/unit tests updated.
This commit is contained in:
Ilia Denisov
2026-07-10 14:58:32 +02:00
parent 2683103fc1
commit 77a690fcf6
21 changed files with 635 additions and 198 deletions
+11
View File
@@ -118,6 +118,9 @@ export const app = $state<{
locale: Locale;
reduceMotion: boolean;
boardLabels: BoardLabelMode;
/** Auto-zoom the board toward a tile when it is placed (touch/mobile only; the wide desktop and
* landscape layouts already fit the whole board and never auto-zoom). On by default. */
zoomBoard: boolean;
/** Completion flags for the two first-run coachmark series (components/Coachmark.svelte). */
onboarding: OnboardingState;
/** Whether a first-run coachmark overlay is currently on screen. While set, the scrolling promo
@@ -172,6 +175,7 @@ export const app = $state<{
locale: 'en',
reduceMotion: false,
boardLabels: 'beginner',
zoomBoard: true,
onboarding: { lobbyDone: false, gameDone: false },
coachActive: false,
notifications: 0,
@@ -813,6 +817,7 @@ export async function bootstrap(): Promise<void> {
app.theme = prefs.theme ?? 'auto';
app.reduceMotion = prefs.reduceMotion ?? false;
app.boardLabels = prefs.boardLabels ?? 'beginner';
app.zoomBoard = prefs.zoomBoard ?? true;
app.onboarding = await loadOnboarding();
applyTheme(app.theme);
applyReduceMotion(app.reduceMotion);
@@ -1232,6 +1237,7 @@ function persistPrefs(): void {
locale: app.locale,
reduceMotion: app.reduceMotion,
boardLabels: app.boardLabels,
zoomBoard: app.zoomBoard,
});
// Mirror the device-independent display prefs to Telegram CloudStorage so they follow the user
// across devices (no-op outside Telegram / on a client predating it). Locale is excluded — it
@@ -1321,6 +1327,11 @@ export function setBoardLabels(mode: BoardLabelMode): void {
persistPrefs();
}
export function setZoomBoard(on: boolean): void {
app.zoomBoard = on;
persistPrefs();
}
// Background/foreground lifecycle: silence the reconnect banner during a suspend and
// reconnect quietly on return (and refresh the lobby badge for any push missed while
// hidden, §10). Several signals cover the platforms: the page Visibility API, the
+116
View File
@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest';
import { formedGeometry, badgePlacement } from './formed';
import type { Board } from './board';
const SIZE = 15;
// board builds a 15×15 empty board with the given committed tiles placed.
function board(tiles: Array<[number, number, string]> = []): Board {
const b: Board = [];
for (let r = 0; r < SIZE; r++) {
const row: (Board[number][number])[] = [];
for (let c = 0; c < SIZE; c++) row.push(null);
b.push(row);
}
for (const [r, c, letter] of tiles) b[r][c] = { letter, blank: false };
return b;
}
const cells = (g: ReturnType<typeof formedGeometry>) => [...(g?.cells ?? new Set())].sort();
describe('formedGeometry', () => {
it('returns null with nothing staged', () => {
expect(formedGeometry(board(), [])).toBeNull();
});
it('finds a fresh horizontal word with no cross words', () => {
const g = formedGeometry(board(), [
{ row: 7, col: 7 },
{ row: 7, col: 8 },
]);
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
expect(cells(g)).toEqual(['7,7', '7,8']);
});
it('finds a fresh vertical word', () => {
const g = formedGeometry(board(), [
{ row: 7, col: 7 },
{ row: 8, col: 7 },
]);
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'V', len: 2 });
expect(cells(g)).toEqual(['7,7', '8,7']);
});
it('extends a committed tile into the main word (committed cell in the set)', () => {
// A committed A at (7,7); a lone tile to its right forms the two-letter main word.
const g = formedGeometry(board([[7, 7, 'A']]), [{ row: 7, col: 8 }]);
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
expect(cells(g)).toEqual(['7,7', '7,8']);
});
it('collects a cross word through a staged tile, committed cells included', () => {
// Committed X above and Y below (7,8) leave a one-cell gap; a horizontal play through it also
// completes the vertical X-_-Y cross word.
const g = formedGeometry(board([[6, 8, 'X'], [8, 8, 'Y']]), [
{ row: 7, col: 7 },
{ row: 7, col: 8 },
]);
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
expect(cells(g)).toEqual(['6,8', '7,7', '7,8', '8,8']);
});
it('takes the longer axis as the main word for a lone connecting tile', () => {
// (7,8) joins a length-3 horizontal (cols 6-8) and a length-4 vertical (rows 5-8): the vertical
// is longer, so it is the main word and the horizontal becomes a cross word.
const g = formedGeometry(
board([
[7, 6, 'A'],
[7, 7, 'B'],
[5, 8, 'C'],
[6, 8, 'D'],
[8, 8, 'E'],
]),
[{ row: 7, col: 8 }],
);
expect(g?.main).toEqual({ row: 5, col: 8, dir: 'V', len: 4 });
expect(cells(g)).toEqual(['5,8', '6,8', '7,6', '7,7', '7,8', '8,8']);
});
});
describe('badgePlacement', () => {
it('anchors a mid-board horizontal word at the last letter, top-right', () => {
expect(badgePlacement({ row: 7, col: 5, dir: 'H', len: 3 })).toEqual({ row: 7, col: 7, corner: 'tr' });
});
it('anchors a right-edge horizontal word at the first letter, bottom-left', () => {
expect(badgePlacement({ row: 7, col: 12, dir: 'H', len: 3 })).toEqual({ row: 7, col: 12, corner: 'bl' });
});
it('anchors a top-edge horizontal word at the first letter, bottom-left', () => {
expect(badgePlacement({ row: 0, col: 5, dir: 'H', len: 3 })).toEqual({ row: 0, col: 5, corner: 'bl' });
});
it('anchors a left-edge horizontal word at the last letter, top-right', () => {
expect(badgePlacement({ row: 7, col: 0, dir: 'H', len: 3 })).toEqual({ row: 7, col: 2, corner: 'tr' });
});
it('anchors a bottom-edge horizontal word at the last letter, top-right', () => {
expect(badgePlacement({ row: 14, col: 5, dir: 'H', len: 3 })).toEqual({ row: 14, col: 7, corner: 'tr' });
});
it('anchors a mid-board vertical word at the first letter, top-right', () => {
expect(badgePlacement({ row: 5, col: 7, dir: 'V', len: 3 })).toEqual({ row: 5, col: 7, corner: 'tr' });
});
it('anchors a top-edge vertical word at the last letter, bottom-left', () => {
expect(badgePlacement({ row: 0, col: 7, dir: 'V', len: 3 })).toEqual({ row: 2, col: 7, corner: 'bl' });
});
it('anchors a right-edge vertical word at the last letter, bottom-left', () => {
expect(badgePlacement({ row: 5, col: 14, dir: 'V', len: 3 })).toEqual({ row: 7, col: 14, corner: 'bl' });
});
it('anchors a full-width horizontal word at the last letter, top-right (clamped by the board)', () => {
expect(badgePlacement({ row: 7, col: 0, dir: 'H', len: 15 })).toEqual({ row: 7, col: 14, corner: 'tr' });
});
});
+164
View File
@@ -0,0 +1,164 @@
// Client-side geometry of the play being composed: which cells the formed word(s) cover, and
// where to anchor the move-score badge. This is deliberately independent of the move evaluator
// (lib/dict/eval.ts) — the network `evaluate` returns legality/score/word strings but no cell
// coordinates, and the board highlight plus the score badge must work even while the local
// dictionary is still warming up. Legality and the score come from the preview; the geometry is
// derived here purely from the board and the staged tiles. Both functions are pure (testable in
// lib/formed.test.ts); the board stays the source of truth for legality.
import type { Board } from './board';
import { BOARD_SIZE } from './premiums';
/** Play axis: horizontal or vertical. */
export type Dir = 'H' | 'V';
/** MainWord locates the play's main word: its start cell, axis and length in cells. */
export interface MainWord {
row: number;
col: number;
dir: Dir;
len: number;
}
/** FormedGeometry is the composed play's shape: the main word plus the set of every cell (as a
* "row,col" key) that the main word and any cross words cover — committed tiles included. */
export interface FormedGeometry {
main: MainWord;
cells: Set<string>;
}
/** A minimal cell reference; PendingTile satisfies it structurally. */
interface Cell {
row: number;
col: number;
}
const key = (r: number, c: number): string => `${r},${c}`;
/**
* filledPredicate returns a test for whether a cell holds a tile once the staged play is on the
* board: either a committed tile sits there or a pending tile is being placed there.
*/
function filledPredicate(board: Board, pending: readonly Cell[]): (r: number, c: number) => boolean {
const pend = new Set(pending.map((p) => key(p.row, p.col)));
return (r, c) =>
r >= 0 &&
r < BOARD_SIZE &&
c >= 0 &&
c < BOARD_SIZE &&
(board[r]?.[c] != null || pend.has(key(r, c)));
}
/**
* span walks the maximal contiguous filled run through cell (row, col) along dir and returns the
* run's start index and length on the moving axis (columns for H, rows for V).
*/
function span(
filled: (r: number, c: number) => boolean,
dir: Dir,
row: number,
col: number,
): { start: number; len: number } {
if (dir === 'H') {
let s = col;
while (filled(row, s - 1)) s--;
let e = col;
while (filled(row, e + 1)) e++;
return { start: s, len: e - s + 1 };
}
let s = row;
while (filled(s - 1, col)) s--;
let e = row;
while (filled(e + 1, col)) e++;
return { start: s, len: e - s + 1 };
}
/**
* formedGeometry derives the composed play's word geometry from the board and the staged tiles.
* It returns the main word (the maximal run along the play axis through the staged tiles) and the
* set of every cell the main word and each cross word (a perpendicular run of two or more cells
* through a staged tile) cover. It returns null when nothing is staged, or when the staged tiles
* are not on a single line — a shape only an illegal play produces, which the caller never asks
* about (it is invoked only for a legal preview). The play axis is fixed by the staged tiles when
* two or more are colinear; a lone tile takes whichever axis forms the longer word.
*/
export function formedGeometry(board: Board, pending: readonly Cell[]): FormedGeometry | null {
if (pending.length === 0) return null;
const filled = filledPredicate(board, pending);
const first = pending[0];
let dir: Dir;
if (pending.length === 1) {
const h = span(filled, 'H', first.row, first.col);
const v = span(filled, 'V', first.row, first.col);
dir = h.len >= v.len ? 'H' : 'V';
} else if (pending.every((p) => p.row === first.row)) {
dir = 'H';
} else if (pending.every((p) => p.col === first.col)) {
dir = 'V';
} else {
return null;
}
const cells = new Set<string>();
// Main word: the maximal run along the play axis through the staged tiles (contiguous via any
// committed tiles between them), collected from any one staged tile.
const ms = span(filled, dir, first.row, first.col);
let main: MainWord;
if (dir === 'H') {
main = { row: first.row, col: ms.start, dir, len: ms.len };
for (let c = ms.start; c < ms.start + ms.len; c++) cells.add(key(first.row, c));
} else {
main = { row: ms.start, col: first.col, dir, len: ms.len };
for (let r = ms.start; r < ms.start + ms.len; r++) cells.add(key(r, first.col));
}
// Cross words: the perpendicular run through each staged tile, kept only when it is a real word
// (two or more cells).
const cross: Dir = dir === 'H' ? 'V' : 'H';
for (const p of pending) {
const cs = span(filled, cross, p.row, p.col);
if (cs.len < 2) continue;
if (cross === 'H') {
for (let c = cs.start; c < cs.start + cs.len; c++) cells.add(key(p.row, c));
} else {
for (let r = cs.start; r < cs.start + cs.len; r++) cells.add(key(r, p.col));
}
}
return { main, cells };
}
/** Where the move-score badge is anchored: a main-word cell and the tile corner it sits on. */
export interface BadgeAnchor {
row: number;
col: number;
corner: 'tr' | 'bl';
}
/**
* badgePlacement chooses the main-word tile and corner for the move-score badge, so the badge
* points into the board and away from the nearest edge (the board clamps the final pill fully
* on-board). The rules: a word touching the right or top edge anchors at a tile's bottom-left
* corner (horizontal → its first letter, vertical → its last letter); a word touching the left or
* bottom edge — and any mid-board word — anchors at the top-right corner (horizontal → its last
* letter, vertical → its first letter). A full-width horizontal word (it touches both side edges)
* anchors at the last letter's top-right, which the board then clamps leftward.
*/
export function badgePlacement(main: MainWord): BadgeAnchor {
const last = BOARD_SIZE - 1;
const h = main.dir === 'H';
const minRow = main.row;
const maxRow = h ? main.row : main.row + main.len - 1;
const minCol = main.col;
const maxCol = h ? main.col + main.len - 1 : main.col;
if (h && main.len === BOARD_SIZE) {
return { row: main.row, col: maxCol, corner: 'tr' };
}
if (maxCol === last || minRow === 0) {
return h ? { row: main.row, col: minCol, corner: 'bl' } : { row: maxRow, col: main.col, corner: 'bl' };
}
return h ? { row: main.row, col: maxCol, corner: 'tr' } : { row: minRow, col: main.col, corner: 'tr' };
}
+1 -2
View File
@@ -104,8 +104,6 @@ export const en = {
'game.checkWord': 'Check word',
'game.dictionary': 'Dictionary',
'game.dropGame': 'Drop game',
'game.previewWords': '{words}: {n}',
'game.previewIllegal': 'Not a legal move',
'game.oneWordRule': 'One word per turn',
'game.chooseBlank': 'Choose a letter for the blank',
'game.exchangeTitle': 'Exchange or pass',
@@ -215,6 +213,7 @@ export const en = {
'settings.labelsClassic': 'Classic',
'settings.labelsNone': 'None',
'settings.reduceMotion': 'Reduce motion',
'settings.zoomBoard': 'Zoom the board',
'settings.offlineMode': 'Play mode',
'settings.online': 'Online',
'settings.offline': 'Offline',
+1 -2
View File
@@ -104,8 +104,6 @@ export const ru: Record<MessageKey, string> = {
'game.checkWord': 'Проверить слово',
'game.dictionary': 'Словарь',
'game.dropGame': 'Покинуть игру',
'game.previewWords': '{words}: {n}',
'game.previewIllegal': 'Недопустимый ход',
'game.oneWordRule': 'Одно слово за ход',
'game.chooseBlank': 'Выберите букву для бланка',
'game.exchangeTitle': 'Обмен или пас',
@@ -215,6 +213,7 @@ export const ru: Record<MessageKey, string> = {
'settings.labelsClassic': 'Классика',
'settings.labelsNone': 'Без текста',
'settings.reduceMotion': 'Меньше анимаций',
'settings.zoomBoard': 'Приближать доску',
'settings.offlineMode': 'Режим игры',
'settings.online': 'Онлайн',
'settings.offline': 'Оффлайн',
+1
View File
@@ -143,6 +143,7 @@ export interface Prefs {
locale: Locale;
reduceMotion: boolean;
boardLabels: BoardLabelMode;
zoomBoard: boolean;
}
export async function loadPrefs(): Promise<Partial<Prefs>> {