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
+2 -2
View File
@@ -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
});
+41
View File
@@ -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();
});
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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();
+12 -14
View File
@@ -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();
});