feat(ui): open the move history at the newest move; scale the landscape rack glyphs
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m18s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m6s

The move table fills top to bottom, oldest first, so it used to open on the
opening moves — the least interesting end of a long game. Pin it to its last
row when it is shown (the portrait drawer and the landscape dock alike), once
per showing rather than on every arriving move, so a player reading back
through the journal is not yanked to the bottom by an opponent's move.

The landscape tray shares the narrow left panel, so its tiles — and with them
the cqw-sized glyphs — come out markedly smaller than the full-width portrait
tray. Scale the letter and Erudit's blank star up a quarter there; the point
value keeps its size (it reads fine small, and growing it would crowd the
letter on a tile that size).
This commit is contained in:
Ilia Denisov
2026-07-27 21:11:34 +02:00
parent f383442430
commit 92700d0646
5 changed files with 96 additions and 2 deletions
+10 -1
View File
@@ -202,6 +202,11 @@ e2e and the screenshots.
the **move history** — a fixed-height slide-down drawer whose bottom border (and its 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 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 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 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) 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 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 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 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 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 - **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), in a **single-word game** only the main word lights up, since the engine ignores cross words),
+26
View File
@@ -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); 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<void> {
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 }) => { test('a swipe-down on the zoomed-in board does not open the history (native scroll wins)', async ({ page }) => {
await openGame(page); await openGame(page);
// Double-tap an empty cell to zoom in (two synchronous clicks = a double-tap). // Double-tap an empty cell to zoom in (two synchronous clicks = a double-tap).
+21
View File
@@ -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(); 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 }) => { test('landscape zooms the board on a double-tap and the zoomed board overflows the pane', async ({ page }) => {
await openGame(page); await openGame(page);
// Double-tap an empty cell zooms in, same as portrait. The board is height-driven here, so the // Double-tap an empty cell zooms in, same as portrait. The board is height-driven here, so the
+24 -1
View File
@@ -1358,6 +1358,29 @@
} }
}); });
// The move table's scroller (the grid between the sticky header and the pinned bag footer).
let hgridEl = $state<HTMLDivElement>();
// 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 // 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 // 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; // and live-updates when a request is answered: `friends` are the caller's accepted friends;
@@ -1657,7 +1680,7 @@
</button> </button>
{/if} {/if}
</div> </div>
<div class="hgridwrap"> <div class="hgridwrap" bind:this={hgridEl}>
<div class="hgrid" style="grid-template-columns: repeat({view.game.seats.length}, 1fr)"> <div class="hgrid" style="grid-template-columns: repeat({view.game.seats.length}, 1fr)">
{#each historyGrid(moves, view.game.seats.length, thinkingSeat) as row} {#each historyGrid(moves, view.game.seats.length, thinkingSeat) as row}
{#each row as cell (cell.player)} {#each row as cell (cell.player)}
+15
View File
@@ -159,4 +159,19 @@
font-size: 7.6vmin; /* cqw fallback for Chrome < 105 */ font-size: 7.6vmin; /* cqw fallback for Chrome < 105 */
font-size: 7.6cqw; 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;
}
}
</style> </style>