v0.16.0 import PGN, delete games, two-layout web client
This commit is contained in:
1 parent
e1fa5242e3
commit
664cf9654e
34 files changed
+2007
-150
No files matched your search
@@ -124,7 +124,7 @@ function setModalMessage(elementId, message, type = 'error') {
|
||||
const el = $(elementId);
|
||||
if (!el) return;
|
||||
el.textContent = message;
|
||||
el.classList.remove('error', 'success');
|
||||
el.classList.remove('error', 'success', 'hint');
|
||||
el.classList.add('show', type);
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ function clearModalMessage(elementId) {
|
||||
const el = $(elementId);
|
||||
if (!el) return;
|
||||
el.textContent = '';
|
||||
el.classList.remove('show', 'error', 'success');
|
||||
el.classList.remove('show', 'error', 'success', 'hint');
|
||||
}
|
||||
|
||||
function anyModalOpen() {
|
||||
@@ -377,7 +377,15 @@ function setStorageStatus(status) {
|
||||
|
||||
function setMailStatus(status) {
|
||||
ui.mail = status;
|
||||
$('forgot-row').classList.toggle('hidden', status !== 'ok');
|
||||
}
|
||||
|
||||
// A neutral hint fills a modal's message slot until a result replaces it.
|
||||
function setModalHint(elementId, text) {
|
||||
const el = $(elementId);
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
el.classList.remove('error', 'success');
|
||||
el.classList.add('show', 'hint');
|
||||
}
|
||||
|
||||
function startHealthCheck() {
|
||||
@@ -467,7 +475,7 @@ function showAccountModal() {
|
||||
$('account-username').textContent = auth.username || '';
|
||||
$('delete-account').open = false;
|
||||
$('delete-password').value = '';
|
||||
clearModalMessage('account-modal-message');
|
||||
setModalHint('account-modal-message', 'Signing out keeps your games; deleting the account removes your name from them.');
|
||||
$('account-overlay').classList.add('show');
|
||||
document.removeEventListener('keydown', handleAccountKeydown);
|
||||
document.addEventListener('keydown', handleAccountKeydown);
|
||||
@@ -624,7 +632,11 @@ function switchAuthTab(tab) {
|
||||
$('login-form').classList.toggle('hidden', tab !== 'login');
|
||||
$('register-form').classList.toggle('hidden', tab !== 'register');
|
||||
$('recover-form').classList.toggle('hidden', tab !== 'recover');
|
||||
clearModalMessage('auth-modal-message');
|
||||
setModalHint('auth-modal-message', {
|
||||
login: 'Your games are kept under your account.',
|
||||
register: 'A username and a password; the email is optional and lets you reset a forgotten password.',
|
||||
recover: ui.mail === 'ok' ? 'The link in the email works for an hour.' : 'This server may not send email yet; the administrator can give you a reset link.',
|
||||
}[tab]);
|
||||
$({ login: 'login-identifier', register: 'register-username', recover: 'recover-identifier' }[tab]).focus();
|
||||
}
|
||||
|
||||
@@ -1070,6 +1082,7 @@ function setNewGameModalBusy(busy) {
|
||||
|
||||
function showNewGameModal() {
|
||||
if (replay.active) return;
|
||||
setModalHint('new-game-modal-message', 'Level 0 is a beginner, 20 is full strength; the search time is how long it thinks per move.');
|
||||
$('modal-overlay').classList.add('show');
|
||||
document.addEventListener('keydown', handleModalKeydown);
|
||||
$('start-game-btn').focus();
|
||||
@@ -1744,6 +1757,7 @@ function renderGamesSections() {
|
||||
$('games-filters').classList.toggle('hidden', !signedIn);
|
||||
$('account-games-heading').textContent = 'Your games';
|
||||
$('games-list').classList.toggle('hidden', !signedIn);
|
||||
$('games-import-btn').classList.toggle('hidden', !signedIn);
|
||||
$('games-more').classList.toggle('hidden', !signedIn || !games.cursor);
|
||||
if (!signedIn) $('games-status').textContent = '';
|
||||
renderLocalGames();
|
||||
@@ -1855,6 +1869,7 @@ function renderLocalGames() {
|
||||
// affordance. The whole row is a button.
|
||||
function renderGameRow(summary) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'game-item';
|
||||
const row = document.createElement('button');
|
||||
row.type = 'button';
|
||||
row.className = 'game-row';
|
||||
@@ -1886,7 +1901,16 @@ function renderGameRow(summary) {
|
||||
: `${terminationLabel(summary.termination) || describeResult(summary.result, summary.termination)} · ${moves} move${moves === 1 ? '' : 's'}`));
|
||||
const when = document.createElement('div');
|
||||
when.className = 'game-when';
|
||||
when.textContent = formatDate(summary.startTimeUtc) + (summary.gameId === live.gameId ? ' · this game' : '');
|
||||
const notes = [formatDate(summary.startTimeUtc)];
|
||||
if (summary.origin === 'imported') {
|
||||
const origin = document.createElement('span');
|
||||
origin.className = 'game-origin';
|
||||
origin.textContent = summary.event ? `imported · ${summary.event}` : 'imported';
|
||||
when.append(document.createTextNode(notes[0] + ' · '), origin);
|
||||
} else {
|
||||
if (summary.gameId === live.gameId) notes.push('this game');
|
||||
when.textContent = notes.join(' · ');
|
||||
}
|
||||
info.append(players, outcome, when);
|
||||
|
||||
const go = document.createElement('span');
|
||||
@@ -1898,9 +1922,203 @@ function renderGameRow(summary) {
|
||||
row.setAttribute('aria-label', `Replay ${players.textContent}, ${outcome.textContent}, ${when.textContent}`);
|
||||
row.addEventListener('click', () => enterReplay(summary.gameId, summary.moveCount || 0));
|
||||
li.appendChild(row);
|
||||
|
||||
// Delete, with an in-row confirmation.
|
||||
const del = document.createElement('button');
|
||||
del.type = 'button';
|
||||
del.className = 'btn game-delete';
|
||||
del.title = summary.local ? 'Forget this game on this device' : 'Delete this game';
|
||||
del.setAttribute('aria-label', `${del.title}: ${players.textContent}`);
|
||||
del.innerHTML = '<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
del.addEventListener('click', () => confirmDeleteRow(li, del, summary));
|
||||
li.appendChild(del);
|
||||
return li;
|
||||
}
|
||||
|
||||
// Replaces the row's delete button with Yes/No for one decision.
|
||||
function confirmDeleteRow(li, del, summary) {
|
||||
const confirm = document.createElement('div');
|
||||
confirm.className = 'game-confirm';
|
||||
confirm.setAttribute('role', 'group');
|
||||
confirm.setAttribute('aria-label', 'Confirm deletion');
|
||||
const yes = document.createElement('button');
|
||||
yes.type = 'button';
|
||||
yes.className = 'btn btn-danger';
|
||||
yes.textContent = summary.local ? 'Forget' : 'Delete';
|
||||
const no = document.createElement('button');
|
||||
no.type = 'button';
|
||||
no.className = 'btn btn-secondary';
|
||||
no.textContent = 'Keep';
|
||||
const restore = () => { confirm.replaceWith(del); del.focus(); };
|
||||
no.addEventListener('click', restore);
|
||||
yes.addEventListener('click', async () => {
|
||||
yes.disabled = true;
|
||||
no.disabled = true;
|
||||
const ok = await deleteGame(summary);
|
||||
if (!ok) restore();
|
||||
});
|
||||
confirm.append(yes, no);
|
||||
del.replaceWith(confirm);
|
||||
yes.focus();
|
||||
}
|
||||
|
||||
// Deletes a game from the account list, or forgets a device entry. Returns
|
||||
// whether the row is gone.
|
||||
async function deleteGame(summary) {
|
||||
if (summary.local) {
|
||||
saveRecentGames(loadRecentGames().filter(g => g.gameId !== summary.gameId));
|
||||
renderLocalGames();
|
||||
toast('Game forgotten on this device', 'success');
|
||||
return true;
|
||||
}
|
||||
let response;
|
||||
try {
|
||||
response = await apiJSON('DELETE', `/api/users/me/games/${summary.gameId}`);
|
||||
} catch (error) {
|
||||
handleApiError('delete game', error);
|
||||
return false;
|
||||
}
|
||||
if (response.status === 401) {
|
||||
clearAuthState();
|
||||
return false;
|
||||
}
|
||||
if (!response.ok) {
|
||||
handleApiError('delete game', null, response);
|
||||
return false;
|
||||
}
|
||||
let outcome = 'game';
|
||||
try {
|
||||
outcome = (await response.json()).removed || 'game';
|
||||
} catch { /* the row is gone either way */ }
|
||||
games.items = games.items.filter(g => g.gameId !== summary.gameId);
|
||||
renderGamesList();
|
||||
toast(outcome === 'claim' ? 'Removed from your games; the other player keeps it' : 'Game deleted', 'success', 2500);
|
||||
if (replay.gameId === summary.gameId && outcome === 'game') exitReplay({ push: false });
|
||||
if (live.gameId === summary.gameId && outcome === 'game') dropLiveGame();
|
||||
ui.historyCache.delete(summary.gameId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// The live game was deleted from storage: the board returns to no game.
|
||||
function dropLiveGame() {
|
||||
stopPolling();
|
||||
live.gameId = null;
|
||||
live.fen = null;
|
||||
live.state = null;
|
||||
live.moves = [];
|
||||
live.san = [];
|
||||
live.lastMove = null;
|
||||
live.isLocked = false;
|
||||
live.selectedSquare = null;
|
||||
if (!replay.active) renderPosition(START_FEN);
|
||||
renderLiveMoves();
|
||||
updateTurnStrip();
|
||||
updateGameControls();
|
||||
}
|
||||
|
||||
// --- import: a PGN pasted or picked from a file, stored under the account
|
||||
|
||||
const importState = { busy: false };
|
||||
|
||||
function openImportModal() {
|
||||
if (!auth.token) {
|
||||
showAuthModal();
|
||||
return;
|
||||
}
|
||||
importState.busy = false;
|
||||
$('import-pgn').value = '';
|
||||
$('import-file').value = '';
|
||||
setModalHint('import-modal-message', 'One game: tags in brackets, then the moves. Comments and variations are dropped; every move is checked.');
|
||||
$('import-overlay').classList.add('show');
|
||||
document.removeEventListener('keydown', handleImportKeydown);
|
||||
document.addEventListener('keydown', handleImportKeydown);
|
||||
$('import-pgn').focus();
|
||||
}
|
||||
|
||||
function closeImportModal() {
|
||||
if (importState.busy) return;
|
||||
$('import-overlay').classList.remove('show');
|
||||
document.removeEventListener('keydown', handleImportKeydown);
|
||||
}
|
||||
|
||||
function handleImportKeydown(e) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeImportModal();
|
||||
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
submitImport();
|
||||
}
|
||||
}
|
||||
|
||||
function setImportBusy(busy) {
|
||||
importState.busy = busy;
|
||||
for (const id of ['import-pgn', 'import-file', 'import-submit-btn', 'import-cancel-btn']) $(id).disabled = busy;
|
||||
}
|
||||
|
||||
// A picked file fills the text box; the size limit is the server's.
|
||||
function readImportFile() {
|
||||
const file = $('import-file').files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > 65536) {
|
||||
setModalMessage('import-modal-message', 'That file is larger than 64 KiB; one game is a few kilobytes', 'error');
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
$('import-pgn').value = String(reader.result || '');
|
||||
setModalHint('import-modal-message', `${file.name} loaded; press Import.`);
|
||||
};
|
||||
reader.onerror = () => setModalMessage('import-modal-message', 'Could not read the file', 'error');
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
async function submitImport() {
|
||||
if (importState.busy) return;
|
||||
const pgn = $('import-pgn').value.trim();
|
||||
if (!pgn) {
|
||||
setModalMessage('import-modal-message', 'Paste a PGN or pick a file', 'error');
|
||||
$('import-pgn').focus();
|
||||
return;
|
||||
}
|
||||
setImportBusy(true);
|
||||
clearModalMessage('import-modal-message');
|
||||
let response;
|
||||
try {
|
||||
response = await apiJSON('POST', '/api/games/import', { pgn });
|
||||
} catch (error) {
|
||||
setImportBusy(false);
|
||||
setModalMessage('import-modal-message', handleApiError('import', error).statusMessage, 'error');
|
||||
return;
|
||||
}
|
||||
setImportBusy(false);
|
||||
if (response.status === 401) {
|
||||
clearAuthState();
|
||||
setModalMessage('import-modal-message', 'Sign in again to import', 'error');
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
const err = await parseErrorResponse(response);
|
||||
setModalMessage('import-modal-message', response.status === 400
|
||||
? `Not a valid game: ${err.details || err.error}`
|
||||
: response.status === 429 ? 'Too many imports; wait a minute' : (err.error || `Server error (${response.status})`), 'error');
|
||||
$('import-pgn').focus();
|
||||
return;
|
||||
}
|
||||
let result;
|
||||
try {
|
||||
result = await response.json();
|
||||
} catch {
|
||||
setModalMessage('import-modal-message', 'Unexpected response from server', 'error');
|
||||
return;
|
||||
}
|
||||
closeImportModal();
|
||||
toast(`Imported, ${Math.ceil((result.moveCount || 0) / 2)} moves`, 'success', 2500);
|
||||
games.cursor = '';
|
||||
await loadGames(true, true);
|
||||
enterReplay(result.gameId, 0);
|
||||
}
|
||||
|
||||
// A side's name in the list, "You" for the signed-in user's claim.
|
||||
function sideLabel(player) {
|
||||
if (player && player.type !== 2 && auth.userId && player.claimedBy === auth.userId) return 'You';
|
||||
@@ -2039,6 +2257,7 @@ function renderReplayHead() {
|
||||
$('replay-players').title = `White: ${sideLabel(h.players?.white)} · Black: ${sideLabel(h.players?.black)}`;
|
||||
const plies = h.moves.length;
|
||||
const meta = [formatDate(h.startTimeUtc), `${plies} ${plies === 1 ? 'ply' : 'plies'}`];
|
||||
if (h.origin === 'imported') meta.unshift(h.event || 'imported');
|
||||
if (h.concession && h.concession.termination) {
|
||||
meta.push(h.concession.termination === 'agreement'
|
||||
? `draw agreed at ply ${h.concession.ply}`
|
||||
@@ -2352,6 +2571,19 @@ function bindEvents() {
|
||||
$('promotion-overlay').addEventListener('click', (e) => {
|
||||
if (e.target === e.currentTarget) closePromotion(null);
|
||||
});
|
||||
// A click on a backdrop closes the dialog, unless a request is in flight.
|
||||
const backdrop = (id, close) => $(id).addEventListener('click', (e) => { if (e.target === e.currentTarget) close(); });
|
||||
backdrop('auth-modal-overlay', () => { if (!auth.busy) hideAuthModal(); });
|
||||
backdrop('account-overlay', hideAccountModal);
|
||||
backdrop('modal-overlay', () => { if (!live.newGameBusy) hideNewGameModal(); });
|
||||
backdrop('resign-overlay', hideResignModal);
|
||||
backdrop('reset-overlay', closeResetModal);
|
||||
backdrop('import-overlay', closeImportModal);
|
||||
|
||||
$('games-import-btn').addEventListener('click', openImportModal);
|
||||
$('import-submit-btn').addEventListener('click', submitImport);
|
||||
$('import-cancel-btn').addEventListener('click', closeImportModal);
|
||||
$('import-file').addEventListener('change', readImportFile);
|
||||
|
||||
$('games-btn').addEventListener('click', () => { if (games.open) closeGames(); else openGames(); });
|
||||
$('games-close').addEventListener('click', () => closeGames());
|
||||
|
||||
@@ -168,6 +168,10 @@
|
||||
<div class="modal games-modal" role="dialog" aria-modal="true" aria-labelledby="games-title">
|
||||
<div class="modal-head">
|
||||
<h2 id="games-title">My games</h2>
|
||||
<button id="games-import-btn" class="btn btn-ghost btn-sm hidden" type="button" title="Store a game from PGN">
|
||||
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5M5 19h14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span>Import PGN</span>
|
||||
</button>
|
||||
<button id="games-close" class="btn btn-icon" type="button" aria-label="Close">
|
||||
<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>
|
||||
</button>
|
||||
@@ -225,7 +229,7 @@
|
||||
<button id="login-submit-btn" class="btn btn-primary" type="button">Sign in</button>
|
||||
<button id="auth-cancel-btn" class="btn btn-secondary" type="button">Cancel</button>
|
||||
</div>
|
||||
<p class="auth-aside hidden" id="forgot-row"><button id="forgot-btn" class="link-btn" type="button">Forgot password?</button></p>
|
||||
<p class="auth-aside" id="forgot-row"><button id="forgot-btn" class="link-btn" type="button">Forgot password?</button></p>
|
||||
</div>
|
||||
|
||||
<div id="recover-form" class="auth-form hidden">
|
||||
@@ -261,6 +265,31 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Modal: a PGN game stored under the account -->
|
||||
<div id="import-overlay" class="modal-overlay">
|
||||
<div class="modal import-modal" role="dialog" aria-modal="true" aria-labelledby="import-title">
|
||||
<h2 id="import-title">Import a game</h2>
|
||||
<div id="import-modal-message" class="modal-message" role="status" aria-live="polite"></div>
|
||||
<div class="form-group">
|
||||
<label for="import-pgn">PGN (one game: tags, then the moves)</label>
|
||||
<textarea id="import-pgn" class="fen-input pgn-input" rows="8" spellcheck="false"
|
||||
placeholder='[White "Spassky, Boris"]
|
||||
[Black "Fischer, Robert"]
|
||||
[Result "1-0"]
|
||||
|
||||
1. d4 Nf6 2. c4 e6 3. Nf3 d5 1-0'></textarea>
|
||||
</div>
|
||||
<div class="form-group form-row">
|
||||
<label for="import-file" class="file-label">Or pick a .pgn file</label>
|
||||
<input type="file" id="import-file" accept=".pgn,text/plain,application/x-chess-pgn">
|
||||
</div>
|
||||
<div class="modal-buttons">
|
||||
<button id="import-submit-btn" class="btn btn-primary" type="button">Import</button>
|
||||
<button id="import-cancel-btn" class="btn btn-secondary" type="button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reset Modal: opened by the link in the recovery email (?reset=<token>) -->
|
||||
<div id="reset-overlay" class="modal-overlay">
|
||||
<div class="modal auth-modal" role="dialog" aria-modal="true" aria-labelledby="reset-title">
|
||||
|
||||
@@ -50,9 +50,13 @@
|
||||
(the host's 30rem frame leaves 416px), so the embed keeps a 360px
|
||||
board while a full window gets up to 560px. */
|
||||
--topbar-height: 2.5rem;
|
||||
--board-edge: clamp(360px, min(32vw, calc(100vh - 64px)), 560px);
|
||||
--board-pad: clamp(16px, 1.04vw, 20px);
|
||||
--panel-width: clamp(280px, 20vw, 380px);
|
||||
--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);
|
||||
--board-pad: clamp(14px, 1.04vw, 20px);
|
||||
}
|
||||
|
||||
html, body {
|
||||
@@ -404,8 +408,18 @@ body[data-mode="replay"] .square, .square.inert { cursor: default; }
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
/* A narrow panel shows the export buttons as icons. */
|
||||
@container (max-width: 300px) {
|
||||
.toolbar .btn-tool span { display: none; }
|
||||
.toolbar .btn-tool .icon { width: 1.2rem; height: 1.2rem; }
|
||||
.controls .btn { font-size: 0.78rem; padding: 0.5rem 0.2rem; }
|
||||
}
|
||||
|
||||
.toolbar .btn-tool { min-width: 0; overflow: hidden; }
|
||||
|
||||
.pane {
|
||||
height: 100%;
|
||||
padding: 0.75rem;
|
||||
@@ -566,7 +580,7 @@ body[data-mode="replay"] .pane-live { display: none; }
|
||||
}
|
||||
|
||||
.replay-result { color: var(--fg); font-weight: 600; }
|
||||
.replay-ply { margin-left: auto; color: var(--muted); font-family: var(--mono); font-size: 0.78rem; }
|
||||
.replay-ply { margin-left: auto; color: var(--muted); font-family: var(--mono); font-size: 0.78rem; white-space: nowrap; }
|
||||
|
||||
.replay-players {
|
||||
color: var(--fg);
|
||||
@@ -822,6 +836,7 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
|
||||
}
|
||||
|
||||
.modal-message.show { visibility: visible; }
|
||||
.modal-message.hint { color: var(--muted); }
|
||||
.modal-message.error { background: rgba(247, 118, 142, 0.12); color: var(--red); border-color: rgba(247, 118, 142, 0.4); }
|
||||
.modal-message.success { background: rgba(158, 206, 106, 0.12); color: var(--green); border-color: rgba(158, 206, 106, 0.4); }
|
||||
|
||||
@@ -843,6 +858,11 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
|
||||
|
||||
.confirm-modal { max-width: 320px; }
|
||||
|
||||
.import-modal { max-width: 460px; }
|
||||
.pgn-input { min-height: 9rem; }
|
||||
.file-label { margin: 0; }
|
||||
#import-file { max-width: 55%; font-size: 0.8rem; color: var(--muted); }
|
||||
|
||||
.promotion-modal { max-width: 320px; }
|
||||
|
||||
.promotion-choices {
|
||||
@@ -1020,6 +1040,38 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* A row's delete control sits beside the row; a confirmation replaces it. */
|
||||
.game-item {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.game-item .game-row { flex: 1; min-width: 0; }
|
||||
|
||||
.game-delete {
|
||||
flex: none;
|
||||
width: 2.2rem;
|
||||
padding: 0;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.game-delete:hover:not(:disabled) { color: var(--red); border-color: var(--red); transform: none; }
|
||||
|
||||
.game-confirm {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 0.3rem;
|
||||
padding: 0 0.2rem;
|
||||
}
|
||||
|
||||
.game-confirm .btn { padding: 0.3rem 0.5rem; font-size: 0.78rem; }
|
||||
.game-origin { color: var(--periwinkle); }
|
||||
|
||||
.games-status {
|
||||
margin: 0.4rem 0;
|
||||
font-size: 0.85rem;
|
||||
@@ -1033,104 +1085,18 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
|
||||
#games-more { margin-top: 0.4rem; }
|
||||
#games-more.hidden { display: none; }
|
||||
|
||||
/* --- Stacked layout (board over panel) -------------------------------- */
|
||||
|
||||
@media (min-width: 641px) and (max-width: 978px) {
|
||||
:root {
|
||||
--panel-width: clamp(360px, 45vw, 440px);
|
||||
--panel-height: 230px;
|
||||
/* Board edge = the smaller of the width-derived size and the height
|
||||
that is left: topbar 40 + gaps 16 + panel 230 + app padding 16 +
|
||||
slack 40 = 342. A short frame shrinks the board before anything
|
||||
scrolls. */
|
||||
--board-edge: max(280px, min(clamp(360px, 45vw, 440px), calc(100vh - 342px)));
|
||||
--board-pad: clamp(16px, 2.04vw, 20px);
|
||||
}
|
||||
|
||||
body { overflow-y: auto; overflow-x: hidden; scrollbar-gutter: stable; }
|
||||
|
||||
.app { height: auto; min-height: 100%; }
|
||||
|
||||
.workspace {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.panel {
|
||||
width: var(--panel-width);
|
||||
height: var(--panel-height);
|
||||
}
|
||||
|
||||
.pane {
|
||||
display: grid;
|
||||
grid-template-columns: clamp(150px, 18.4vw, 180px) minmax(0, 1fr);
|
||||
grid-template-rows: min-content 1fr;
|
||||
gap: 0.5rem 0.75rem;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
|
||||
.pane > * { min-width: 0; }
|
||||
.toolbar .btn-tool { min-width: 0; overflow: hidden; }
|
||||
/* Four export buttons in the narrow right column: icons only. */
|
||||
.pane-replay .toolbar .btn-tool span { display: none; }
|
||||
.pane-replay .toolbar .btn-tool .icon { width: 1.2rem; height: 1.2rem; }
|
||||
|
||||
body[data-mode="replay"] .pane-live, body[data-mode="live"] .pane-replay { display: none; }
|
||||
|
||||
.pane-live .turn-strip { grid-column: 1; grid-row: 1; }
|
||||
.pane-live .controls {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
align-self: stretch;
|
||||
}
|
||||
.pane-live .controls .btn { padding: 0.3rem 0.2rem; font-size: 0.78rem; min-height: 2rem; }
|
||||
.pane-live .moves { grid-column: 2; grid-row: 1 / 3; }
|
||||
.pane-live .toolbar { grid-column: 2; grid-row: 3; }
|
||||
.pane-live { grid-template-rows: min-content 1fr min-content; }
|
||||
.pane-live .controls { grid-row: 2 / 4; }
|
||||
|
||||
/* Replay: head, navigation and the exit button down the left; the move
|
||||
list with the export row on the right. */
|
||||
.pane-replay { grid-template-rows: min-content min-content 1fr min-content; }
|
||||
.pane-replay .replay-head { grid-column: 1; grid-row: 1; padding: 0.3rem 0.5rem; }
|
||||
.pane-replay .replay-nav {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.pane-replay .replay-nav .speed { grid-column: 1 / 5; margin: 0; }
|
||||
.pane-replay .replay-nav .speed select { width: 100%; }
|
||||
.pane-replay #replay-exit { grid-column: 1; grid-row: 3 / 5; align-self: end; padding: 0.35rem; font-size: 0.78rem; }
|
||||
.pane-replay .moves { grid-column: 2; grid-row: 1 / 4; }
|
||||
.pane-replay .toolbar { grid-column: 2; grid-row: 4; }
|
||||
.pane-replay .btn-nav { width: 100%; height: 1.65rem; }
|
||||
.pane-replay .btn-nav .icon { width: 1rem; height: 1rem; }
|
||||
.pane-replay .speed select { height: 1.65rem; font-size: 0.72rem; padding: 0 0.2rem; }
|
||||
.pane-replay .replay-meta { display: none; }
|
||||
.pane-replay .replay-title-row { flex-wrap: wrap; gap: 0.2rem 0.4rem; }
|
||||
.pane-replay .replay-players { font-size: 0.76rem; }
|
||||
.pane-replay .replay-result { font-size: 0.76rem; }
|
||||
|
||||
.move-grid { grid-template-columns: 2rem 1fr 1fr; font-size: 0.84rem; }
|
||||
.btn-tool { font-size: 0.74rem; padding: 0.3rem 0.2rem; }
|
||||
}
|
||||
|
||||
/* --- Phone layout: full-width rows, edge to edge ------------------------ */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@media (max-width: 699px) {
|
||||
:root {
|
||||
/* Reserve under the board: top bar 40 + gap 6 + turn strip 32 +
|
||||
controls 36 + move list 96 + toolbar 36 + gaps and padding 26 =
|
||||
276. Small viewport units, so the browser's bars never cover the
|
||||
panel. 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 - 284px)));
|
||||
/* 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
|
||||
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-pad: 14px;
|
||||
--panel-width: 100%;
|
||||
--panel-height: auto;
|
||||
@@ -1138,7 +1104,7 @@ 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 - 372px))); }
|
||||
body[data-mode="replay"] { --board-edge: max(240px, min(100vw, calc(100svh - 23.25rem))); }
|
||||
|
||||
/* 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. */
|
||||
@@ -1174,6 +1140,7 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
|
||||
.coordinates.ranks { left: -14px; }
|
||||
.coordinates.files { top: -15px; }
|
||||
|
||||
/* The panel never clips: what the viewport cannot hold scrolls the page. */
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -1185,8 +1152,13 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
overflow: visible;
|
||||
container-type: normal;
|
||||
}
|
||||
|
||||
/* min-height 0: a flex item's automatic minimum is its content height,
|
||||
which would let a long move list push the controls below the fold
|
||||
instead of scrolling inside its box. */
|
||||
.pane, .pane-live, .pane-replay {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
@@ -1194,6 +1166,7 @@ input[type="range"]:focus-visible { outline: 2px solid var(--periwinkle); outlin
|
||||
gap: 0.4rem;
|
||||
padding: 0.5rem 0.6rem 0.6rem;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.pane > * { min-width: 0; }
|
||||
@@ -1207,7 +1180,7 @@ 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: 96px; }
|
||||
.moves { flex: 1 1 auto; min-height: 6rem; }
|
||||
.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; }
|
||||
|
||||
Reference in new issue
Block a user