v0.16.1 web client update: inline favicon, board fills the window, large-font phones
test / test (push) Canceled after 0s
test / e2e (push) Canceled after 0s

This commit is contained in:
lixen committed 2026-10-19 22:45:12 -04:00
1 parent 664cf9654e
commit 0461020c46
4 files changed
+89 -32

No files matched your search

@@ -2202,6 +2202,14 @@ function defaultFlip(history) {
return white?.type === 2 && black?.type !== 2; 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 } = {}) { function exitReplay({ push = true } = {}) {
if (!replay.active) return; if (!replay.active) return;
stopAutoplay(); stopAutoplay();
@@ -2221,7 +2229,8 @@ function exitReplay({ push = true } = {}) {
} }
function renderReplayLoading() { function renderReplayLoading() {
$('replay-result').textContent = ''; $('replay-score').textContent = '';
$('replay-term').textContent = '';
$('replay-ply').textContent = ''; $('replay-ply').textContent = '';
$('replay-players').textContent = 'Loading game…'; $('replay-players').textContent = 'Loading game…';
$('replay-meta').textContent = replay.gameId || ''; $('replay-meta').textContent = replay.gameId || '';
@@ -2229,7 +2238,7 @@ function renderReplayLoading() {
grid.innerHTML = ''; grid.innerHTML = '';
$('replay-moves-empty').textContent = 'Loading…'; $('replay-moves-empty').textContent = 'Loading…';
setReplayNavEnabled(false); 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) { function renderReplayError(message) {
@@ -2250,9 +2259,11 @@ function setReplayNavEnabled(enabled) {
function renderReplayHead() { function renderReplayHead() {
const h = replay.history; const h = replay.history;
const finished = !!h.result; const finished = !!h.result;
$('replay-result').textContent = finished // Score and termination apart: a large-font phone shows the score alone.
? `${h.pgnResult} · ${terminationLabel(h.termination) || describeResult(h.result, h.termination)}` $('replay-score').textContent = finished ? h.pgnResult : 'Ongoing';
: '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').textContent = `${sideLabel(h.players?.white)} vs ${sideLabel(h.players?.black)}`;
$('replay-players').title = `White: ${sideLabel(h.players?.white)} · Black: ${sideLabel(h.players?.black)}`; $('replay-players').title = `White: ${sideLabel(h.players?.white)} · Black: ${sideLabel(h.players?.black)}`;
const plies = h.moves.length; const plies = h.moves.length;
@@ -2264,7 +2275,7 @@ function renderReplayHead() {
: `resignation at ply ${h.concession.ply}`); : `resignation at ply ${h.concession.ply}`);
} }
$('replay-meta').textContent = meta.join(' · '); $('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); setReplayNavEnabled(true);
} }
@@ -5,6 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="dark"> <meta name="color-scheme" content="dark">
<title>Chess</title> <title>Chess</title>
<!-- The brand mark as the tab icon, inline so no request leaves the page
(the host answers /favicon.ico with its 404 page) and CSP img-src data: covers it. -->
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='41 34 430 430'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='20%' y1='0%' x2='80%' y2='100%'%3E%3Cstop offset='0%' stop-color='%23C8CDD6'/%3E%3Cstop offset='45%' stop-color='%238B929E'/%3E%3Cstop offset='100%' stop-color='%234F545D'/%3E%3C/linearGradient%3E%3C/defs%3E%3Cg fill='url(%23g)' stroke='%2314161A' stroke-width='4' stroke-linejoin='round'%3E%3Cpath d='M136 450Q256 465 376 450L346 410Q256 420 166 410Z'/%3E%3Cpath d='M166 410Q256 420 346 410L316 370Q256 380 196 370Z'/%3E%3Cpath d='M196 370Q256 380 316 370C300 290 286 230 282 205H230C226 230 212 290 196 370Z'/%3E%3C/g%3E%3Cpath d='M330 90Q265 96 230 120Q265 144 335 150Z' fill='%23111318'/%3E%3Cg stroke='%23F0F2F5' stroke-width='6' stroke-linecap='round'%3E%3Cpath d='M263.17 85V155M299.33 85V155'/%3E%3C/g%3E%3Cg fill='url(%23g)' stroke='%2314161A' stroke-width='4' stroke-linejoin='round'%3E%3Cpath d='M226 200C150 180 150 70 256 40C290 50 315 65 330 90Q265 96 230 120Q265 144 335 150C335 170 315 190 286 200Z'/%3E%3Crect x='216' y='195' width='80' height='16' rx='8'/%3E%3C/g%3E%3C/svg%3E">
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
</head> </head>
<body data-mode="live"> <body data-mode="live">
@@ -98,7 +101,7 @@
<div class="replay-head"> <div class="replay-head">
<div class="replay-title-row"> <div class="replay-title-row">
<span class="badge" id="replay-badge">Replay</span> <span class="badge" id="replay-badge">Replay</span>
<span class="replay-result" id="replay-result"></span> <span class="replay-result" id="replay-result"><span id="replay-score"></span><span class="replay-term" id="replay-term"></span></span>
<span class="replay-ply" id="replay-ply" aria-hidden="true"></span> <span class="replay-ply" id="replay-ply" aria-hidden="true"></span>
</div> </div>
<div class="replay-players" id="replay-players"></div> <div class="replay-players" id="replay-players"></div>
@@ -155,8 +158,12 @@
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1.5 1.5M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1.5-1.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg> <svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1.5 1.5M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1.5-1.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span>Link</span> <span>Link</span>
</button> </button>
<!-- A row of its own beside the board; on a phone it shares this row. -->
<button id="replay-exit" class="btn btn-secondary btn-exit" type="button" aria-label="Close replay">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span id="replay-exit-label">Close replay</span><span class="label-short" id="replay-exit-short" aria-hidden="true">Close</span>
</button>
</div> </div>
<button id="replay-exit" class="btn btn-secondary btn-block" type="button">Back to live game</button>
<div id="ply-announcer" class="visually-hidden" aria-live="polite" aria-atomic="true"></div> <div id="ply-announcer" class="visually-hidden" aria-live="polite" aria-atomic="true"></div>
</div> </div>
</aside> </aside>
@@ -45,17 +45,16 @@
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
/* Side-by-side sizes: the board edge includes its coordinate margin. It /* Side-by-side sizes: the board edge includes its coordinate margin. The
grows with the window but never past what is left under the top bar panel takes a quarter of the width between 260px and 420px; the board
(the host's 30rem frame leaves 416px), so the embed keeps a 360px takes everything else the window leaves beside it and under the top
board while a full window gets up to 560px. */ 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; --topbar-height: 2.5rem;
--panel-width: clamp(260px, 22vw, 380px); --panel-width: clamp(260px, 24vw, 420px);
/* The board takes what the window leaves beside the panel and under the --board-edge: clamp(280px, min(100vw - var(--panel-width) - 3.5rem, 100vh - var(--topbar-height) - 2rem), 840px);
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);
--board-pad: clamp(14px, 1.04vw, 20px); --board-pad: clamp(14px, 1.04vw, 20px);
} }
@@ -549,6 +548,11 @@ body[data-mode="replay"] .pane-live { display: none; }
/* --- Replay pane ----------------------------------------------------- */ /* --- 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 { .replay-head {
flex: none; flex: none;
display: flex; display: flex;
@@ -1090,13 +1094,15 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
@media (max-width: 699px) { @media (max-width: 699px) {
:root { :root {
/* Reserve under the board, in rem so a larger system font grows it: /* 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 top bar 2.5 + gap 0.4 + turn strip 2 + controls 2.25 + move list 4
+ toolbar 2.25 + gaps and padding 2.35 = 17.75rem. Small viewport + toolbar 2.25 + gaps and padding 2.35 = 15.75rem. Small viewport
units, so the browser's bars never cover the panel. The replay 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 pane is taller (head, navigation, export, exit), so a replay
reserves more and the board gives way on a short screen rather reserves more and the board gives way on a short screen rather
than the controls dropping below the fold. */ 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; --board-pad: 14px;
--panel-width: 100%; --panel-width: 100%;
--panel-height: auto; --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 { 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 /* 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. */ 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; padding: 0.5rem 0.6rem 0.6rem;
height: auto; height: auto;
min-height: 0; min-height: 0;
container-type: inline-size;
container-name: pane;
} }
.pane > * { min-width: 0; } .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; } .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; } .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; } .toolbar .btn-tool { padding: 0.45rem 0.3rem; font-size: 0.82rem; min-width: 0; }
/* Replay: head, list, navigation, export and exit as rows. */ /* Replay: a one-line head (result, players, ply; no badge, no meta),
.pane-replay .replay-head { padding: 0.35rem 0.6rem; } 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 .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 { .pane-replay .replay-nav {
display: grid; display: grid;
grid-template-columns: repeat(5, 1fr) 1.4fr 1fr; 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 { margin: 0; }
.pane-replay .replay-nav .speed select { width: 100%; } .pane-replay .replay-nav .speed select { width: 100%; }
.pane-replay .btn-nav { width: 100%; height: 2.2rem; } .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; } .games-modal { padding: 0.75rem; }
.game-row { gap: 0.5rem; padding: 0.4rem; } .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; } .modal-overlay { padding: 0.5rem; }
} }
/* Very narrow phones: icon-only export buttons. */ /* Narrow phones, and phones whose system font is large: the pane is a
@media (max-width: 380px) { container on phones and rem in a container query is the root font size,
.pane-replay .toolbar .btn-tool span { display: none; } so 24rem is 384px at the default size and 600px at a 1.5x font. Then the
.pane-replay .toolbar .btn-tool .icon { width: 1.2rem; height: 1.2rem; } 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; } .pane-live .controls .btn { font-size: 0.8rem; }
} }
+11 -1
View File
@@ -167,7 +167,7 @@ test.describe('web replay', () => {
await expect(page.locator('#replay-ply')).toHaveText('2 / 4'); 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('0-1');
await expect(page.locator('#replay-result')).toContainText('Checkmate'); 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 expect(page.locator('#replay-moves .move').nth(3)).toHaveText('Qh4#');
await page.keyboard.press('End'); await page.keyboard.press('End');
await expect(page.locator('[data-square="e1"]')).toHaveClass(/mated-king/); 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(); 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 })); 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 }); 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(); await context.close();
}); });