The table on a phone: the masthead keeps the title, who is playing and the room code, and folds the rest behind a Table button; the piece panel opens as a summary and unfolds the lore on request; the told game brings the move's board into view and keeps Back and Next at the foot of the screen with the move's name; a game against a drake can be started over at any time and played again when it ends

The masthead on a 390-pixel screen was 300 pixels tall, with seven buttons before the status line; it is 132 now. The piece panel was 30 percent of the screen with the lore scrolling inside; the summary is a third less, and the lore is a tap away. The told game's words were sticky on a phone as on a wide screen, and pinned themselves over the boards; they scroll with the page now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CCLa2PaEeCj8njQZSWGqHx
This commit is contained in:
Eric WagonerandClaude Fable 5.1 committed 2026-10-27 20:57:14 -04:00
1 parent 7dd113b1ff
commit 36508f29ca
4 files changed
+159 -13

No files matched your search

+24 -4
View File
@@ -12,7 +12,8 @@
legalMovesFrom, movesFrom, squareName, sideOf, kindOf, kingSquare, inCheck, levelOf, frozen, NAMES, SIDE_NAMES,
type Move, type Side
} from '$lib/game';
import type { Room } from '$lib/net/room.svelte';
import { Room } from '$lib/net/room.svelte';
import { goto } from '$app/navigation';
import { prefs, reducedMotion } from '$lib/prefs.svelte';
let { room }: { room: Room } = $props();
@@ -199,6 +200,23 @@
return g.over.winner === room.me ? 'You win.' : `${g.players[g.over.winner].name} wins.`;
});
/** A game against a drake can be started over at any time: a fresh table, the same side and setup. */
const drake = $derived(!room.spectating && room.view.seats.some((s) => s.bot && !s.standIn));
let starting = $state(false);
let fault = $state('');
async function again() {
starting = true;
fault = '';
try {
const next = await Room.createWithBot(room.nameOf(room.me!), room.view.options);
await goto(`/join/${next.roomId}`);
} catch (e) {
fault = e instanceof Error ? e.message : 'A new table could not be opened.';
} finally {
starting = false;
}
}
const pieceCount = (side: Side) => g.cells.filter((p) => p !== '' && sideOf(p) === side).length;
</script>
@@ -227,7 +245,8 @@
{/if}
{#if room.error}<p class="warning">{room.error}</p>{/if}
<p class="muted small glass-way"><button type="button" class="link plain" disabled={fetching} aria-busy={fetching} onclick={() => seeGlass(!onGlass)}>{fetching ? 'Fetching the glass board…' : onGlass ? 'Back to the flat boards' : 'See the glass board'}</button></p>
<p class="muted small glass-way"><button type="button" class="link plain" disabled={fetching} aria-busy={fetching} onclick={() => seeGlass(!onGlass)}>{fetching ? 'Fetching the glass board…' : onGlass ? 'Back to the flat boards' : 'See the glass board'}</button>{#if drake && !g.over}&nbsp;· <button type="button" class="link plain" disabled={starting} onclick={again}>Start over against a fresh drake</button>{/if}</p>
{#if fault}<p class="warning">{fault}</p>{/if}
{#if onGlass && Glass}
<Glass {g} {mySide} {selected} {targets} {reach} {shownSquares} {shownCaptured} {checkSquare} {calm} {tap} />
{:else}
@@ -239,7 +258,7 @@
{#if g.over}
<div class="verdict">
<a class="commit" href="/">Back to the hall</a>
{#if drake}<button type="button" class="commit" disabled={starting} onclick={again}>Play the drake again</button> <a class="quiet" href="/">Back to the hall</a>{:else}<a class="commit" href="/">Back to the hall</a>{/if}
</div>
{:else if yourMove}
<p class="muted small how">Tap a piece, then a square on any board. Tap any piece, yours or not, to read what it is and what it can do. With a keyboard, the arrows move about a board, Page Up and Page Down change boards, Enter picks up and sets down, and Escape cancels.</p>
@@ -366,7 +385,8 @@
margin: 0.8rem auto 0;
}
.verdict .commit {
.verdict .commit,
.verdict .quiet {
display: inline-block;
}
+49 -7
View File
@@ -50,6 +50,13 @@
return lines;
});
const letter = $derived(LETTERS[kind]);
/** On a phone the panel opens as a summary, and the lore unfolds on request. */
let more = $state(false);
$effect(() => {
void at;
more = false;
});
</script>
<aside class="lore" aria-label="About this piece">
@@ -62,13 +69,16 @@
<button type="button" class="dismiss" title="Close" aria-label="Close" onclick={close}>×</button>
</header>
<p class="now">{situation.join(' ')}</p>
<p>{lore.is}</p>
<h3>How it moves</h3>
<p>{lore.moves}</p>
<h3>Why it is here</h3>
<p>{lore.why}</p>
<h3>How to play it</h3>
<p>{lore.play}</p>
<button type="button" class="link plain unfold" aria-expanded={more} onclick={() => (more = !more)}>{more ? 'Less' : 'How it moves, why it is here, how to play it'}</button>
<div class="rest" class:open={more}>
<p>{lore.is}</p>
<h3>How it moves</h3>
<p>{lore.moves}</p>
<h3>Why it is here</h3>
<p>{lore.why}</p>
<h3>How to play it</h3>
<p>{lore.play}</p>
</div>
</aside>
<style>
@@ -107,6 +117,38 @@
font-weight: 500;
}
.unfold {
display: none;
}
@media (max-width: 860px) {
.lore {
padding-top: 0.6rem;
}
header {
margin-bottom: 0.3rem;
}
.now {
margin-bottom: 0.4rem;
}
.unfold {
display: inline;
font-size: 0.85rem;
}
.rest {
display: none;
}
.rest.open {
display: block;
margin-top: 0.5rem;
}
}
.now {
color: var(--bone);
border-left: 2px solid var(--ember);
+38
View File
@@ -18,6 +18,7 @@
let error = $state('');
let copied = $state(false);
let reporting = $state(false);
let tableOpen = $state(false);
let prefsOpen = $state(false);
/** A phrase minted to carry this seat to another device, while it lasts. */
let phrase = $state<{ phrase: string; expiresAt: number } | null>(null);
@@ -190,6 +191,10 @@
{#if room.view.audience > 0}
<span class="audience" title="the Peanut Gallery">{room.view.audience} in the gallery{room.view.galleryTalk ? ', talking' : ''}</span>
{/if}
{/if}
<button type="button" class="quiet more" aria-expanded={tableOpen} onclick={() => (tableOpen = !tableOpen)}>Table {tableOpen ? '▴' : '▾'}</button>
<div class="rest">
{#if room}
{#if !watching}
<button type="button" class="quiet" title="A phrase that brings this seat to another device" onclick={transfer}>Transfer seat</button>
{/if}
@@ -204,6 +209,7 @@
<button type="button" class="quiet" title="Something behaved unexpectedly? Tell the keeper." onclick={() => (reporting = true)}>Report</button>
{/if}
<a class="quiet" href="/">The hall</a>
</div>
</nav>
</header>
@@ -311,6 +317,32 @@
flex-shrink: 0;
}
/* On a phone the masthead keeps the title, who is playing and the room code; the rest waits behind one button. */
.more {
display: none;
}
.rest {
display: contents;
}
@media (max-width: 860px) {
.more {
display: inline-block;
}
.rest {
display: none;
width: 100%;
flex-wrap: wrap;
gap: 0.5rem;
}
.more[aria-expanded='true'] + .rest {
display: flex;
}
}
.quiet.room b {
letter-spacing: 0.12em;
font-weight: 600;
@@ -389,6 +421,12 @@
@media (max-width: 860px) {
.masthead {
flex-direction: column;
gap: 0.6rem;
padding: 0.6rem var(--gutter) 0.4rem;
}
.standfirst {
margin-top: 0.15rem;
}
}
</style>
+48 -2
View File
@@ -5,7 +5,7 @@
import PiecePanel from '$lib/components/PiecePanel.svelte';
import { CALLOUTS, TOLD_MOVES, toldStates } from '$lib/game/told';
import { narrate } from '$lib/game/chronicle';
import { notation, kingSquare, inCheck, movesFrom, legalMovesFrom, sideOf } from '$lib/game';
import { notation, kingSquare, inCheck, movesFrom, legalMovesFrom, sideOf, levelOf } from '$lib/game';
import { prefs, reducedMotion } from '$lib/prefs.svelte';
/** The glass board, loaded the first time it is asked for. */
@@ -49,11 +49,22 @@
function go(n: number) {
ply = Math.max(0, Math.min(TOLD_MOVES.length, n));
inspected = null;
showMove();
}
function tap(i: number) {
inspected = g.cells[i] !== '' && inspected !== i ? i : null;
}
/** Left and right step the game, unless the keyboard is on a square, where the arrows walk the board. */
/** On a phone the boards stack below the words, so the board the move was played on is brought into view. */
function showMove() {
const m = ply > 0 ? last.moves[ply - 1] : null;
if (!m || typeof matchMedia === 'undefined' || !matchMedia('(max-width: 900px)').matches) return;
requestAnimationFrame(() => {
const target = onGlass ? document.querySelector('.boards canvas') : document.querySelector(`.boards g.world[data-level="${levelOf(m.to)}"]`);
target?.scrollIntoView({ block: 'center', behavior: calm ? 'auto' : 'smooth' });
});
}
function key(e: KeyboardEvent) {
if ((e.target as HTMLElement | null)?.tagName === 'rect') return;
if (e.key === 'ArrowRight') go(ply + 1);
@@ -97,6 +108,7 @@
<button type="button" class="quiet" disabled={ply === 0} onclick={() => go(ply - 1)}>Back</button>
<button type="button" class="commit small" disabled={ply === TOLD_MOVES.length} onclick={() => go(ply + 1)}>Next move</button>
{#if ply === TOLD_MOVES.length}<a class="quiet" href="/">Play a drake</a>{/if}
<span class="where">{ply === 0 ? 'Before the first move' : `Move ${ply} of ${TOLD_MOVES.length}`}{#if move}&nbsp;· {notation(move)}{/if}</span>
</div>
<p class="muted small marks">
Jump to a callout:
@@ -123,13 +135,47 @@
align-items: start;
}
.where {
display: none;
}
@media (max-width: 900px) {
.stage {
grid-template-columns: minmax(0, 1fr);
}
.words {
/* The words come first and scroll with the page; the stickiness that keeps them beside the boards on a wide screen would pin them over the boards here. */
.stage .words {
order: -1;
position: static;
max-height: none;
overflow: visible;
}
/* Back and Next stay at the foot of the screen with the move's name, so the boards can be looked at without losing the thread. */
.ways {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 4;
margin: 0;
padding: 0.55rem var(--gutter);
background: var(--slate-deep);
border-top: 1px solid var(--rule);
}
.where {
display: block;
flex-basis: 100%;
order: -1;
font-size: 0.85rem;
color: var(--bone-dim);
font-variant-numeric: tabular-nums;
}
main {
padding-bottom: 4.5rem;
}
}