diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 8c9bd52..c4ec658 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -202,6 +202,11 @@ e2e and the screenshots. the **move history** — a fixed-height slide-down drawer whose bottom border (and its shadow) pins to the board as the board slides down, instead of tracking the table as moves accumulate; its scrollbar gutter is reserved so the centred cells do not jitter. The + table is shown **scrolled to its newest row**: the grid fills top to bottom, oldest first, so an + untouched scroller would open on the opening moves — the least interesting end. It is pinned once + per showing (in landscape, once the moves reach the already-docked table), never on each arriving + move, so a player reading back through a long game is not yanked to the bottom by an opponent's + move. The history is a **ruled matrix** — one column per seat, aligned under the score plaques, each seat's moves filling its column top to bottom: no player names (the plaque heads the column) and no running total, just the word(s) and the move score, `WORD (12)`, centred. A non-play @@ -247,7 +252,11 @@ e2e and the screenshots. portrait layout is byte-for-byte unchanged; both layouts render from the same snippets, so 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). + the fixed 7th slot while a play is staged). The landscape tray shares the narrow left panel, so its + tiles — and with them the `cqw`-sized glyphs — come out smaller than the full-width portrait tray: + the **letter** and Erudit's **blank star** are scaled up a quarter there (a `(orientation: + landscape)` query in `Rack.svelte`) to stay comfortably legible, while the point value keeps its + size — it reads fine small, and growing it would crowd the letter on a tile that size. - **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), diff --git a/ui/e2e/history.spec.ts b/ui/e2e/history.spec.ts index 662e75e..f2b3c17 100644 --- a/ui/e2e/history.spec.ts +++ b/ui/e2e/history.spec.ts @@ -121,6 +121,32 @@ test('a two-finger pinch does not open the history (pinch-zoom vs swipe-down)', await expect(page.locator('.history')).toHaveCount(0); }); +/** tallHistoryRows stretches the move-table cells so the seeded four-move game overflows the + * table's scroller — the condition under which "opens at the newest move" is observable at all + * (with no overflow the assertion would hold vacuously). Injected at document start, since the + * table is pinned as soon as it is shown. */ +async function tallHistoryRows(page: Page): Promise { + await page.addInitScript(() => { + document.addEventListener('DOMContentLoaded', () => { + const style = document.createElement('style'); + style.textContent = '.hcell { min-height: 200px !important; }'; + document.head.append(style); + }); + }); +} + +test('the history opens scrolled to the newest move', async ({ page }) => { + await tallHistoryRows(page); + await openGame(page); + await page.locator('.scoreboard').click(); // open the history + + const m = await page + .locator('.hgridwrap') + .evaluate((el) => ({ top: el.scrollTop, scroll: el.scrollHeight, client: el.clientHeight })); + expect(m.scroll).toBeGreaterThan(m.client); // the table really overflows — no vacuous pass + expect(m.top).toBeGreaterThanOrEqual(m.scroll - m.client - 1); // pinned to the last row +}); + test('a swipe-down on the zoomed-in board does not open the history (native scroll wins)', async ({ page }) => { await openGame(page); // Double-tap an empty cell to zoom in (two synchronous clicks = a double-tap). diff --git a/ui/e2e/landscape.spec.ts b/ui/e2e/landscape.spec.ts index 709c6ed..194f20e 100644 --- a/ui/e2e/landscape.spec.ts +++ b/ui/e2e/landscape.spec.ts @@ -26,6 +26,27 @@ test('landscape lays the game in two columns with the history docked open', asyn await expect(page.locator('.leftpane .tabbar')).toBeVisible(); }); +test('the docked history shows the newest move', async ({ page }) => { + // Stretch the cells so the seeded four-move game overflows the docked table's scroller — the + // condition under which "shows the newest move" is observable at all. Injected at document + // start: in landscape the table is docked open from the mount, and it is pinned to its last row + // as soon as the moves arrive. The portrait (drawer) counterpart lives in history.spec.ts. + await page.addInitScript(() => { + document.addEventListener('DOMContentLoaded', () => { + const style = document.createElement('style'); + style.textContent = '.hcell { min-height: 200px !important; }'; + document.head.append(style); + }); + }); + await openGame(page); + + const m = await page + .locator('.hgridwrap') + .evaluate((el) => ({ top: el.scrollTop, scroll: el.scrollHeight, client: el.clientHeight })); + expect(m.scroll).toBeGreaterThan(m.client); // the table really overflows — no vacuous pass + expect(m.top).toBeGreaterThanOrEqual(m.scroll - m.client - 1); // pinned to the last row +}); + test('landscape zooms the board on a double-tap and the zoomed board overflows the pane', async ({ page }) => { await openGame(page); // Double-tap an empty cell zooms in, same as portrait. The board is height-driven here, so the diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 7e238f3..9c95ce3 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -1358,6 +1358,29 @@ } }); + // The move table's scroller (the grid between the sticky header and the pinned bag footer). + let hgridEl = $state(); + // Whether the table has already been pinned to its newest row for the current showing. + // A plain flag (not $state): the effect below writes it, and tracking it would re-run the + // effect from its own write. + let historyPinned = false; + // The history opens showing the LATEST moves: the grid fills top to bottom (oldest first), so + // an untouched scroller would open on the opening moves — the least interesting end. It is + // pinned once per showing, not on every arriving move, so a player who has scrolled back + // through a long game is not yanked to the bottom by an opponent's move. In landscape the + // table is docked open from the start, so the moves usually arrive after it mounts — hence + // the wait for a non-empty journal rather than a plain "on open". + $effect(() => { + const el = hgridEl; + if (!historyShown || !el) { + historyPinned = false; + return; + } + if (historyPinned || moves.length === 0) return; + historyPinned = true; + el.scrollTop = el.scrollHeight; + }); + // Friend state for the in-game "add friend" affordance (the 🤝 in each opponent's score // card while the history is open), derived from the server so it is correct across reloads // and live-updates when a request is answered: `friends` are the caller's accepted friends; @@ -1657,7 +1680,7 @@ {/if} -
+
{#each historyGrid(moves, view.game.seats.length, thinkingSeat) as row} {#each row as cell (cell.player)} diff --git a/ui/src/game/Rack.svelte b/ui/src/game/Rack.svelte index 28d6c13..8210f4a 100644 --- a/ui/src/game/Rack.svelte +++ b/ui/src/game/Rack.svelte @@ -159,4 +159,19 @@ font-size: 7.6vmin; /* cqw fallback for Chrome < 105 */ font-size: 7.6cqw; } + /* Landscape docks the rack in the narrow left panel (Game.svelte's .leftpane), so its tiles — + and with them the cqw-sized glyphs — come out markedly smaller than the full-width portrait + tray. Scale the tile face up by a quarter there so the letters stay comfortably legible; the + point value keeps its size (it reads fine small, and growing it would crowd the letter on a + tile this size). The media query matches the one that selects the landscape layout. */ + @media (orientation: landscape) { + .letter { + font-size: 7.5vmin; /* cqw fallback for Chrome < 105 */ + font-size: 7.5cqw; + } + .star { + font-size: 9.5vmin; /* cqw fallback for Chrome < 105 */ + font-size: 9.5cqw; + } + }