diff --git a/internal/server/webserver/chess-client-web/app.js b/internal/server/webserver/chess-client-web/app.js
index 81485bc..de165ff 100644
--- a/internal/server/webserver/chess-client-web/app.js
+++ b/internal/server/webserver/chess-client-web/app.js
@@ -2202,6 +2202,14 @@ function defaultFlip(history) {
return white?.type === 2 && black?.type !== 2;
}
+// The exit button's text, its short form for a phone's row, and its
+// accessible name for the row that shows the icon alone.
+function setReplayExitLabel(text, short) {
+ $('replay-exit-label').textContent = text;
+ $('replay-exit-short').textContent = short;
+ $('replay-exit').setAttribute('aria-label', text);
+}
+
function exitReplay({ push = true } = {}) {
if (!replay.active) return;
stopAutoplay();
@@ -2221,7 +2229,8 @@ function exitReplay({ push = true } = {}) {
}
function renderReplayLoading() {
- $('replay-result').textContent = '';
+ $('replay-score').textContent = '';
+ $('replay-term').textContent = '';
$('replay-ply').textContent = '';
$('replay-players').textContent = 'Loading game…';
$('replay-meta').textContent = replay.gameId || '';
@@ -2229,7 +2238,7 @@ function renderReplayLoading() {
grid.innerHTML = '';
$('replay-moves-empty').textContent = 'Loading…';
setReplayNavEnabled(false);
- $('replay-exit').textContent = live.gameId ? 'Back to live game' : 'Close replay';
+ setReplayExitLabel(live.gameId ? 'Back to live game' : 'Close replay', live.gameId ? 'Live' : 'Close');
}
function renderReplayError(message) {
@@ -2250,9 +2259,11 @@ function setReplayNavEnabled(enabled) {
function renderReplayHead() {
const h = replay.history;
const finished = !!h.result;
- $('replay-result').textContent = finished
- ? `${h.pgnResult} · ${terminationLabel(h.termination) || describeResult(h.result, h.termination)}`
- : 'Ongoing';
+ // Score and termination apart: a large-font phone shows the score alone.
+ $('replay-score').textContent = finished ? h.pgnResult : 'Ongoing';
+ $('replay-term').textContent = finished
+ ? ` · ${terminationLabel(h.termination) || describeResult(h.result, h.termination)}`
+ : '';
$('replay-players').textContent = `${sideLabel(h.players?.white)} vs ${sideLabel(h.players?.black)}`;
$('replay-players').title = `White: ${sideLabel(h.players?.white)} · Black: ${sideLabel(h.players?.black)}`;
const plies = h.moves.length;
@@ -2264,7 +2275,7 @@ function renderReplayHead() {
: `resignation at ply ${h.concession.ply}`);
}
$('replay-meta').textContent = meta.join(' · ');
- $('replay-exit').textContent = live.gameId ? 'Back to live game' : 'Close replay';
+ setReplayExitLabel(live.gameId ? 'Back to live game' : 'Close replay', live.gameId ? 'Live' : 'Close');
setReplayNavEnabled(true);
}
diff --git a/internal/server/webserver/chess-client-web/index.html b/internal/server/webserver/chess-client-web/index.html
index 8b8344d..b42482a 100644
--- a/internal/server/webserver/chess-client-web/index.html
+++ b/internal/server/webserver/chess-client-web/index.html
@@ -5,6 +5,9 @@
Chess
+
+
@@ -98,7 +101,7 @@
Replay
-
+
@@ -155,8 +158,12 @@
Link
+
+
-
diff --git a/internal/server/webserver/chess-client-web/style.css b/internal/server/webserver/chess-client-web/style.css
index b859b05..86d8295 100644
--- a/internal/server/webserver/chess-client-web/style.css
+++ b/internal/server/webserver/chess-client-web/style.css
@@ -45,17 +45,16 @@
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
- /* Side-by-side sizes: the board edge includes its coordinate margin. It
- grows with the window but never past what is left under the top bar
- (the host's 30rem frame leaves 416px), so the embed keeps a 360px
- board while a full window gets up to 560px. */
+ /* Side-by-side sizes: the board edge includes its coordinate margin. The
+ panel takes a quarter of the width between 260px and 420px; the board
+ takes everything else the window leaves beside it and under the top
+ bar, so the margins give way first, the board second, and the panel
+ is never pushed out of view. 3.5rem and 2rem are the app's padding and
+ gaps (8px each side, a gap of up to 2rem beside the board) plus 8px of
+ slack; the host's embed sizes its frame to this formula. */
--topbar-height: 2.5rem;
- --panel-width: clamp(260px, 22vw, 380px);
- /* The board takes what the window leaves beside the panel and under the
- top bar, so the panel is never pushed out of view and the row never
- scrolls: 5rem covers the gaps and the frame's padding. The host's
- 30rem desktop frame leaves 416px. */
- --board-edge: clamp(280px, min(100vw - var(--panel-width) - 5rem, 100vh - var(--topbar-height) - 2.5rem), 600px);
+ --panel-width: clamp(260px, 24vw, 420px);
+ --board-edge: clamp(280px, min(100vw - var(--panel-width) - 3.5rem, 100vh - var(--topbar-height) - 2rem), 840px);
--board-pad: clamp(14px, 1.04vw, 20px);
}
@@ -549,6 +548,11 @@ body[data-mode="replay"] .pane-live { display: none; }
/* --- Replay pane ----------------------------------------------------- */
+/* The export row wraps the exit button onto a row of its own. */
+.pane-replay .toolbar { flex-wrap: wrap; row-gap: 0.6rem; }
+.pane-replay .btn-exit { flex: 1 0 100%; }
+.pane-replay .btn-exit .label-short { display: none; }
+
.replay-head {
flex: none;
display: flex;
@@ -1090,13 +1094,15 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
@media (max-width: 699px) {
:root {
/* Reserve under the board, in rem so a larger system font grows it:
- top bar 2.5 + gap 0.4 + turn strip 2 + controls 2.25 + move list 6
- + toolbar 2.25 + gaps and padding 2.35 = 17.75rem. Small viewport
- units, so the browser's bars never cover the panel. The replay
+ top bar 2.5 + gap 0.4 + turn strip 2 + controls 2.25 + move list 4
+ + toolbar 2.25 + gaps and padding 2.35 = 15.75rem. Small viewport
+ units, so the browser's bars never cover the panel. At a 1.5x
+ system font (rem 24px) on a 412x734 viewport this still leaves a
+ 356px board; the move list scrolls inside its 4rem. The replay
pane is taller (head, navigation, export, exit), so a replay
reserves more and the board gives way on a short screen rather
than the controls dropping below the fold. */
- --board-edge: max(240px, min(100vw, calc(100svh - 17.75rem)));
+ --board-edge: max(240px, min(100vw, calc(100svh - 15.75rem)));
--board-pad: 14px;
--panel-width: 100%;
--panel-height: auto;
@@ -1104,7 +1110,9 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
}
body { overflow-y: auto; overflow-x: hidden; }
- body[data-mode="replay"] { --board-edge: max(240px, min(100vw, calc(100svh - 23.25rem))); }
+ /* A replay's panel: head 2.3 + list 4 + navigation 2.2 + export row 2.25
+ + gaps and padding 2.85, under the top bar 2.9 = 16.5rem. */
+ body[data-mode="replay"] { --board-edge: max(240px, min(100vw, calc(100svh - 16.5rem))); }
/* The app is exactly the viewport, so the panel and its move list shrink
to fit; only when their minimums do not fit does the page scroll. */
@@ -1167,6 +1175,8 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
padding: 0.5rem 0.6rem 0.6rem;
height: auto;
min-height: 0;
+ container-type: inline-size;
+ container-name: pane;
}
.pane > * { min-width: 0; }
@@ -1180,14 +1190,29 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
}
.pane-live .controls .btn { padding: 0.5rem 0.2rem; font-size: 0.85rem; min-height: 2.25rem; height: auto; }
- .moves { flex: 1 1 auto; min-height: 6rem; }
+ .moves { flex: 1 1 auto; min-height: 4rem; }
.move-grid { grid-template-columns: 2.2rem 1fr 1fr; font-size: 0.9rem; }
.toolbar .btn-tool { padding: 0.45rem 0.3rem; font-size: 0.82rem; min-width: 0; }
- /* Replay: head, list, navigation, export and exit as rows. */
- .pane-replay .replay-head { padding: 0.35rem 0.6rem; }
+ /* Replay: a one-line head (result, players, ply; no badge, no meta),
+ the list, the navigation row, then export and exit on one row. */
+ .pane-replay .replay-head {
+ flex-direction: row;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.35rem 0.6rem;
+ min-height: 2.3rem;
+ }
+ .pane-replay .replay-title-row { display: contents; }
+ .pane-replay .badge { display: none; }
+ .pane-replay .replay-result { flex: none; white-space: nowrap; }
+ .pane-replay .replay-players { order: 1; flex: 1 1 auto; }
+ .pane-replay .replay-ply { order: 2; margin-left: auto; }
.pane-replay .replay-meta { display: none; }
+ .pane-replay .btn-exit { flex: 1.3 1 0; min-width: 0; padding: 0.45rem 0.3rem; font-size: 0.82rem; }
+ .pane-replay .btn-exit #replay-exit-label { display: none; }
+ .pane-replay .btn-exit .label-short { display: inline; }
.pane-replay .replay-nav {
display: grid;
grid-template-columns: repeat(5, 1fr) 1.4fr 1fr;
@@ -1196,7 +1221,6 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
.pane-replay .replay-nav .speed { margin: 0; }
.pane-replay .replay-nav .speed select { width: 100%; }
.pane-replay .btn-nav { width: 100%; height: 2.2rem; }
- .pane-replay #replay-exit { padding: 0.5rem; font-size: 0.85rem; }
.games-modal { padding: 0.75rem; }
.game-row { gap: 0.5rem; padding: 0.4rem; }
@@ -1205,10 +1229,15 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
.modal-overlay { padding: 0.5rem; }
}
-/* Very narrow phones: icon-only export buttons. */
-@media (max-width: 380px) {
- .pane-replay .toolbar .btn-tool span { display: none; }
- .pane-replay .toolbar .btn-tool .icon { width: 1.2rem; height: 1.2rem; }
+/* Narrow phones, and phones whose system font is large: the pane is a
+ container on phones and rem in a container query is the root font size,
+ so 24rem is 384px at the default size and 600px at a 1.5x font. Then the
+ export buttons show icons alone and the replay head keeps the score and
+ the players but not the termination. */
+@container pane (max-width: 24rem) {
+ .pane-replay .toolbar .btn-tool span, .pane-replay .btn-exit span, .pane-replay .btn-exit .label-short { display: none; }
+ .pane-replay .toolbar .btn-tool .icon, .pane-replay .btn-exit .icon { width: 1.2rem; height: 1.2rem; }
+ .pane-replay .replay-term { display: none; }
.pane-live .controls .btn { font-size: 0.8rem; }
}
diff --git a/test/e2e/replay.spec.js b/test/e2e/replay.spec.js
index 234510c..2755d2a 100644
--- a/test/e2e/replay.spec.js
+++ b/test/e2e/replay.spec.js
@@ -167,7 +167,7 @@ test.describe('web replay', () => {
await expect(page.locator('#replay-ply')).toHaveText('2 / 4');
await expect(page.locator('#replay-result')).toContainText('0-1');
await expect(page.locator('#replay-result')).toContainText('Checkmate');
- await expect(page.locator('#replay-exit')).toHaveText('Close replay');
+ await expect(page.locator('#replay-exit-label')).toHaveText('Close replay');
await expect(page.locator('#replay-moves .move').nth(3)).toHaveText('Qh4#');
await page.keyboard.press('End');
await expect(page.locator('[data-square="e1"]')).toHaveClass(/mated-king/);
@@ -267,6 +267,16 @@ test.describe('web replay', () => {
await expect(page.locator('#replay-exit')).toBeInViewport();
const box = await page.locator('#replay-moves-box').evaluate(el => ({ scrolls: el.scrollHeight > el.clientHeight, atEnd: el.scrollTop > 0 }));
expect(box).toEqual({ scrolls: true, atEnd: true });
+ // A 1.5x system font (Android font scaling raises the root size): the
+ // board keeps a usable size, the export row shows icons alone and
+ // nothing overflows its button.
+ await page.evaluate(() => { document.documentElement.style.fontSize = '25px'; });
+ expect(await fits()).toEqual({ w: true, h: true });
+ await expect(page.locator('#replay-exit')).toBeInViewport();
+ expect((await page.locator('.board-wrapper').boundingBox()).width).toBeGreaterThanOrEqual(280);
+ await expect(page.locator('#replay-exit-label')).toBeHidden();
+ const clipped = await page.locator('.pane-replay .toolbar > *').evaluateAll(els => els.filter(el => el.scrollWidth > el.clientWidth + 1).length);
+ expect(clipped).toBe(0);
await context.close();
});