The masthead, the scrim, the quiet button and the colophon are styled once in app.css instead of pasted into each page and slip with drifting paddings; the style blocks of the play page and the board are indented like the rest of the files; and comments in script and CSS say what the code does or guards, in plain words, where they had been written in the game's own voice

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric WagonerandClaude Fable 5.1 committed 2026-10-29 11:10:19 -04:00
1 parent 22979afd35
commit 42144c1cc9
19 files changed
+364 -481

No files matched your search

+18 -31
View File
@@ -18,37 +18,37 @@
$effect(() => {
if (location.hash === '#about') aboutOpen = true;
});
/** The ledgers' tally, fetched when the about panel opens. */
/** Site-wide statistics, fetched the first time the about panel opens. */
let tally = $state<Tally | null>(null);
$effect(() => {
if (aboutOpen && !tally) api.tally().then((t) => (tally = t)).catch(() => (tally = null));
});
const hours = (minutes: number) => (minutes < 90 ? `${minutes} minutes` : `${Math.round(minutes / 60)} hours`);
/** The tally as the about panel tells it: this game's own story on cards, the rest in detail. */
/** The tally split for the about panel: headline figures for the cards, every other count as a detail line. */
const told = $derived.by(() => {
const t = tally;
if (!t) return null;
const headline = [
{ n: t.gamesFinished, label: t.gamesFinished === 1 ? 'duel fought to a finish' : 'duels fought to a finish' },
{ n: t.byGame['duels won with every rival slain'] ?? 0, label: 'won with every rival slain' },
{ n: t.byGame["duels won by a rival's surrender"] ?? 0, label: "won by a rival's surrender" },
{ n: hours(t.minutesAtTable), label: 'at the table' }
];
const told = new Set<string>(['duels won with every rival slain', "duels won by a rival's surrender"]);
const onCards = new Set<string>(['duels won with every rival slain', "duels won by a rival's surrender"]);
const details = [
{ n: t.gamesBegun, label: `duels begun, at ${t.tablesOpened} ${t.tablesOpened === 1 ? 'table' : 'tables'}` },
{ n: t.namesSeated, label: t.namesSeated === 1 ? 'name has taken a seat' : 'names have taken a seat' },
{ n: t.turnsPlayed, label: `turns of gestures written; ${t.longestGameTurns} in the longest duel` },
{ n: t.gamesWithBot, label: `duels with the bot seated` },
...(t.talkLines ? [{ n: t.talkLines, label: t.talkLines === 1 ? 'line of table talk' : 'lines of table talk' }] : []),
...Object.entries(t.byGame).filter(([label]) => !told.has(label)).map(([label, n]) => ({ n, label }))
...Object.entries(t.byGame).filter(([label]) => !onCards.has(label)).map(([label, n]) => ({ n, label }))
];
const first = t.firstGameAt ? `The first duel began ${new Date(t.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}. ` : '';
return { headline, details, note: `${first}The count is of names, so one player under two names is two here. Table time counts only the minutes between reveals, so a duel left open overnight adds nothing for the night.` };
});
/** The other games of Kestrel's Hall, for the about panel. */
/** The sibling games listed in the about panel, and the family's landing page. */
let family = $state<FamilyGame[]>([]);
let hall = $state('');
$effect(() => {
@@ -57,12 +57,12 @@
hall = f.hall;
});
});
/** Reports this browser's seats have filed, with the keeper's replies. */
/** Bug reports filed from this browser's seats, with the maintainer's replies. */
let reports = $state<Report[]>([]);
let answers = $state<Record<string, string>>({});
let answerError = $state('');
// The desk is asked once per visit for every seat this browser holds.
// Fetch the reports for every held seat; re-runs when the held seats change.
$effect(() => {
const held = seats;
if (!held.length) return;
@@ -85,13 +85,12 @@
}
}
/** The single-player duel saved in this browser, if one is under way. */
/** The offline game saved in this browser, if one is unfinished. */
const local = $derived(duel.inProgress ? duel : null);
const ready = $derived(name.trim().length > 0);
// Each held seat is looked up once, so the ledger can say whose move it
// is. A seat the server refuses outright is doubted; a second refusal in
// a row forgets it. Silence (a restart, a stale answer) forgets nothing.
// Each held seat is fetched once so the list can say whose move it is. A 403 or 404 marks
// the seat doubtful, and a second refusal in a row drops it; a network failure changes nothing.
$effect(() => {
for (const held of seats) {
if (held.roomId in games) continue;
@@ -111,7 +110,7 @@
let phrase = $state('');
let claiming = $state(false);
/** Bring a seat from another device by the phrase it minted. */
/** Claim a seat transferred from another device, by its transfer phrase. */
async function claim(e: SubmitEvent) {
e.preventDefault();
claiming = true;
@@ -167,7 +166,7 @@
if (c) void goto(`/join/${c}`);
}
/** A duel in play asks before it is forgotten: this browser may be the only thing that holds the seat. */
/** Forgetting an unfinished game asks first: this browser's token may be the only copy of the seat. */
let forgetting = $state<string | null>(null);
function forget(roomId: string) {
if (!games[roomId]?.state?.over && forgetting !== roomId) {
@@ -195,7 +194,7 @@
return `waiting on ${g.waitingOn.map((id) => g.state!.wizards[id].name).join(', ')} · ${turn}`;
}
/** Lines of table talk said since this browser last looked in. */
/** Chat lines posted since this browser last opened the room. */
function unread(held: { roomId: string }): number {
const g = games[held.roomId];
return g ? unreadTalk(held.roomId, g.chat.length) : 0;
@@ -206,7 +205,7 @@
return !!g?.state && !g.state.over && g.waitingOn.includes(held.seat);
}
/* A few turns of a duel, as the ledger writes them. */
/** Fixed sample rows for the illustration table. */
const sample = [
{ turn: 1, a: ['W', 'S'], b: ['P', 'W'], notes: { b0: 'Shield' } },
{ turn: 2, a: ['W', 'D'], b: ['P', 'D'], notes: { a1: 'Missile', b0: 'Shield' } },
@@ -332,7 +331,7 @@
</div>
{/if}
<!-- The about panel, a tab at a time: its sections are written here, in the game's own words. -->
<!-- The about panel's tabs. Their copy is this game's own, in its in-world voice by design. -->
{#snippet aboutGame()}
<div class="about-body">
<p>Waving Hands is Richard Bartle's game from 1977, played with pencil, paper and two hands. It was later known as Spellbinder and Spellcaster, and Andrew Plotkin's <em>Spellcast</em> brought it to university X terminals in 1993, which is where the maker of this page first met it.</p>
@@ -500,18 +499,6 @@
font-weight: 600;
}
.quiet {
background: none;
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 0.45rem 0.9rem;
color: var(--bone);
}
.quiet:hover:not(:disabled) {
border-color: var(--bone);
}
.warning {
color: var(--blood);
}
@@ -576,7 +563,7 @@
text-align: center;
}
/* the games ledger */
/* The list of held seats, and the reports under it. */
.ledger {
grid-area: ledger;
border-top: 1px solid var(--rule-strong);
@@ -686,7 +673,7 @@
color: var(--ember);
}
/* The question takes a line of its own under the duel on a narrow screen. */
/* The confirmation wraps onto its own line when the row is narrow. */
.ledger-row.asking {
flex-wrap: wrap;
}