Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
731 lines
21 KiB
Svelte
731 lines
21 KiB
Svelte
<script lang="ts">
|
||
import { goto } from '$app/navigation';
|
||
import { loadFamily, type FamilyGame } from '$lib/family';
|
||
import { allSeats, api, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat, type Report, type Tally } from '$lib/game/client';
|
||
import { Duel, duel, NAME_MAX, playerName, rememberName } from '$lib/game/duel.svelte';
|
||
import { unreadTalk } from '$lib/game/talk';
|
||
import type { RoomView } from '$lib/game/view';
|
||
|
||
let name = $state(playerName() || duel.you.name);
|
||
let code = $state('');
|
||
let busy = $state(false);
|
||
let error = $state('');
|
||
let seats = $state(allSeats());
|
||
let games = $state<Record<string, RoomView | null>>({});
|
||
let aboutOpen = $state(false);
|
||
/** The ledgers' tally, fetched when 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 other games of Kestrel's Hall, for the about panel. */
|
||
let family = $state<FamilyGame[]>([]);
|
||
let hall = $state('');
|
||
$effect(() => {
|
||
void loadFamily('waving-hands').then((f) => {
|
||
family = f.games;
|
||
hall = f.hall;
|
||
});
|
||
});
|
||
/** Reports this browser's seats have filed, with the keeper'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.
|
||
$effect(() => {
|
||
const held = seats;
|
||
if (!held.length) return;
|
||
api.myReports(held.map((h) => ({ roomId: h.roomId, token: h.token })))
|
||
.then((r) => (reports = r.reports.sort((a, b) => (a.at < b.at ? 1 : -1))))
|
||
.catch(() => (reports = []));
|
||
});
|
||
|
||
async function answer(r: Report) {
|
||
const text = (answers[r.id] ?? '').trim();
|
||
const held = seats.find((h) => h.roomId === r.roomId);
|
||
if (!text || !held) return;
|
||
answerError = '';
|
||
try {
|
||
await api.answerReport(r.id, r.roomId, held.token, text);
|
||
r.thread.push({ at: new Date().toISOString(), text, from: 'player' });
|
||
answers[r.id] = '';
|
||
} catch (e) {
|
||
answerError = e instanceof Error ? e.message : 'The answer did not reach the keeper.';
|
||
}
|
||
}
|
||
|
||
/** The single-player duel saved in this browser, if one is under way. */
|
||
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.
|
||
$effect(() => {
|
||
for (const held of seats) {
|
||
if (held.roomId in games) continue;
|
||
games[held.roomId] = null;
|
||
api.view(held.roomId, held.token)
|
||
.then((v) => {
|
||
trustSeat(held.roomId);
|
||
games[held.roomId] = v;
|
||
})
|
||
.catch((e: unknown) => {
|
||
games[held.roomId] = null;
|
||
if (e instanceof ServerError && (e.status === 403 || e.status === 404) && doubtSeat(held.roomId)) seats = allSeats();
|
||
});
|
||
}
|
||
});
|
||
|
||
let phrase = $state('');
|
||
let claiming = $state(false);
|
||
|
||
/** Bring a seat from another device by the phrase it minted. */
|
||
async function claim(e: SubmitEvent) {
|
||
e.preventDefault();
|
||
claiming = true;
|
||
error = '';
|
||
try {
|
||
const got = await api.claim(phrase.trim());
|
||
rememberSeat(got.roomId, { seat: got.seat, token: got.token });
|
||
await goto(`/join/${got.roomId}`);
|
||
} catch (err) {
|
||
error = err instanceof Error ? err.message : 'The phrase opened no seat.';
|
||
} finally {
|
||
claiming = false;
|
||
}
|
||
}
|
||
|
||
async function playBot() {
|
||
busy = true;
|
||
error = '';
|
||
try {
|
||
rememberName(name);
|
||
const remote = await Duel.createBotRoom(name.trim());
|
||
await goto(`/join/${remote.roomId}`);
|
||
} catch (e) {
|
||
error = e instanceof Error ? e.message : 'The duel could not be opened.';
|
||
} finally {
|
||
busy = false;
|
||
}
|
||
}
|
||
|
||
async function playOffline() {
|
||
rememberName(name);
|
||
await goto('/play');
|
||
}
|
||
|
||
async function create() {
|
||
busy = true;
|
||
error = '';
|
||
try {
|
||
rememberName(name);
|
||
const remote = await Duel.createRoom(name.trim());
|
||
await goto(`/join/${remote.roomId}`);
|
||
} catch (e) {
|
||
error = e instanceof Error ? e.message : 'The duel could not be opened.';
|
||
} finally {
|
||
busy = false;
|
||
}
|
||
}
|
||
|
||
function join(e: SubmitEvent) {
|
||
e.preventDefault();
|
||
rememberName(name);
|
||
const c = code.trim().toUpperCase();
|
||
if (c) void goto(`/join/${c}`);
|
||
}
|
||
|
||
function forget(roomId: string) {
|
||
forgetSeat(roomId);
|
||
seats = allSeats();
|
||
}
|
||
|
||
function status(held: { roomId: string; seat: string }): string {
|
||
const g = games[held.roomId];
|
||
if (g === undefined) return 'looking';
|
||
if (g === null) return 'unreachable';
|
||
const others = g.seats.filter((s) => s.id !== held.seat).map((s) => (s.bot ? `${s.name} (the bot)` : s.name));
|
||
if (!g.state) return `waiting to start${g.seats.length ? ` · ${g.seats.map((s) => s.name).join(', ')}` : ''}`;
|
||
if (g.state.over) {
|
||
if (g.state.over.winner === held.seat) return 'you won';
|
||
if (g.state.over.winner === null) return 'a draw';
|
||
return `${g.state.wizards[g.state.over.winner].name} won`;
|
||
}
|
||
const turn = `turn ${g.state.turn + 1}`;
|
||
if (g.waitingOn.includes(held.seat)) return `your move · ${turn} · against ${others.join(', ')}`;
|
||
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. */
|
||
function unread(held: { roomId: string }): number {
|
||
const g = games[held.roomId];
|
||
return g ? unreadTalk(held.roomId, g.chat.length) : 0;
|
||
}
|
||
|
||
function yourMove(held: { roomId: string; seat: string }): boolean {
|
||
const g = games[held.roomId];
|
||
return !!g?.state && !g.state.over && g.waitingOn.includes(held.seat);
|
||
}
|
||
|
||
/* A few turns of a duel, as the ledger writes them. */
|
||
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' } },
|
||
{ turn: 3, a: ['S', 'F'], b: ['F', 'D'], notes: { a0: 'Counter-spell' } },
|
||
{ turn: 4, a: ['F', 'S'], b: ['F', 'D'], notes: { b0: 'Paralysis', b1: 'Lightning bolt' } }
|
||
];
|
||
</script>
|
||
|
||
<section class="lid">
|
||
<div class="lid-head">
|
||
<h1>Waving Hands</h1>
|
||
<p class="pitch">Duelling wizards. Two hands each. The right run of gestures is a spell.</p>
|
||
<p class="tag">Richard Bartle's 1977 pencil-and-paper duel, free in your browser. Each turn every wizard chooses a gesture per hand and all reveal together; a Fireball is F-S-S-D-D on one hand, a Shield is a single palm. Play the bot, or open a table for up to a dozen wizards and play by turns.</p>
|
||
<p class="links"><a href="/guide">how to play</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about" onclick={() => (aboutOpen = true)}>about this game — a labor of love</a></p>
|
||
</div>
|
||
|
||
<div class="lid-play">
|
||
<label class="name">
|
||
<span>Your wizard's name</span>
|
||
<input type="text" bind:value={name} maxlength={NAME_MAX} placeholder="e.g. Morwenna" autocomplete="nickname" />
|
||
</label>
|
||
<button type="button" class="reveal primary" disabled={!ready || busy} onclick={playBot}>Play now against the bot</button>
|
||
<p class="muted small">
|
||
{#if local}
|
||
<button type="button" class="link" onclick={playOffline}>Continue your offline duel against {local.foe.name} (turn {local.turnNumber})</button>
|
||
{:else}
|
||
<button type="button" class="link" onclick={playOffline}>Or play offline, in this browser only</button>
|
||
{/if}
|
||
</p>
|
||
<div class="ways">
|
||
<button type="button" class="quiet" disabled={!ready || busy} onclick={create}>Open a table</button>
|
||
<form class="joinform" onsubmit={join}>
|
||
<span class="or">or join one</span>
|
||
<input type="text" class="code" bind:value={code} maxlength="4" placeholder="CODE" autocapitalize="characters" autocomplete="off" aria-label="room code" />
|
||
<button type="submit" class="quiet" disabled={!ready || code.trim().length < 4}>Join</button>
|
||
</form>
|
||
</div>
|
||
<p class="muted small">A code opens the door either way: take a seat if one is free, or watch from the Peanut Gallery.</p>
|
||
<form class="joinform claimform" onsubmit={claim}>
|
||
<span class="or">or bring a seat from another device</span>
|
||
<input type="text" bind:value={phrase} placeholder="the four-word phrase" autocomplete="off" aria-label="transfer phrase" />
|
||
<button type="submit" class="quiet" disabled={claiming || phrase.trim().split(/[\s-]+/).length < 4}>Claim</button>
|
||
</form>
|
||
{#if error}<p class="warning">{error}</p>{/if}
|
||
</div>
|
||
|
||
<div class="lid-look" aria-hidden="true">
|
||
<table class="sample">
|
||
<thead>
|
||
<tr><th></th><th colspan="2">Corwin</th><th colspan="2">Ysolde</th></tr>
|
||
</thead>
|
||
<tbody>
|
||
{#each sample as row (row.turn)}
|
||
<tr>
|
||
<td class="t">{row.turn}</td>
|
||
{#each ['a0', 'a1', 'b0', 'b1'] as cell, i (cell)}
|
||
{@const g = i < 2 ? row.a[i] : row.b[i - 2]}
|
||
{@const note = (row.notes as Record<string, string | undefined>)[cell]}
|
||
<td class:cast={!!note}><span class="letter">{g}</span>{#if note}<span class="note">{note}</span>{/if}</td>
|
||
{/each}
|
||
</tr>
|
||
{/each}
|
||
</tbody>
|
||
</table>
|
||
<p class="caption">The ledger: every gesture both wizards have made, and the spells they finished.</p>
|
||
</div>
|
||
|
||
{#if seats.length || local}
|
||
<div class="ledger">
|
||
<div class="ledger-head">your duels</div>
|
||
{#if local}
|
||
<div class="ledger-row your-move">
|
||
<a class="ledger-resume" href="/play">
|
||
<span class="ledger-code">here</span>
|
||
<span class="ledger-info">offline · your move · turn {local.turnNumber} · against {local.foe.name}</span>
|
||
</a>
|
||
</div>
|
||
{/if}
|
||
{#each seats as held (held.roomId)}
|
||
<div class="ledger-row" class:your-move={yourMove(held)}>
|
||
<a class="ledger-resume" href={`/join/${held.roomId}`}>
|
||
<span class="ledger-code">{held.roomId}</span>
|
||
<span class="ledger-info">{status(held)}{#if unread(held) > 0} · <span class="talk-new">{unread(held)} new {unread(held) === 1 ? 'line' : 'lines'} of talk</span>{/if}</span>
|
||
</a>
|
||
<button type="button" class="ledger-forget" title="forget this duel" onclick={() => forget(held.roomId)}>×</button>
|
||
</div>
|
||
{/each}
|
||
{#if reports.length}
|
||
<div class="ledger-head reports-head">your reports to the keeper</div>
|
||
{#each reports as r (r.id)}
|
||
{@const last = r.thread[r.thread.length - 1]}
|
||
<div class="report-row">
|
||
<span class="ledger-code">{r.roomId}</span>
|
||
<div class="report-body">
|
||
<p class="report-text">“{r.happened}”{#if r.image}<span class="muted" title="with your screenshot"> (with a picture)</span>{/if}</p>
|
||
{#each r.thread as line, j (j)}
|
||
{#if line.from === 'player'}
|
||
<p class="report-reply yours">you: {line.text}</p>
|
||
{:else}
|
||
<p class="report-reply"><span class="report-status">{line.status}</span> {line.text}</p>
|
||
{/if}
|
||
{/each}
|
||
{#if !last}
|
||
<p class="report-reply pending">the keeper is studying the moment</p>
|
||
{:else if last.from === 'player'}
|
||
<p class="report-reply pending">your word is with the keeper</p>
|
||
{/if}
|
||
{#if r.thread.some((l) => l.from === 'desk')}
|
||
<form class="report-answer" onsubmit={(e) => { e.preventDefault(); void answer(r); }}>
|
||
<input type="text" bind:value={answers[r.id]} maxlength="2000" placeholder="answer the keeper" aria-label="answer the keeper" />
|
||
<button type="submit" class="quiet" disabled={!(answers[r.id] ?? '').trim()}>Send</button>
|
||
</form>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
{/each}
|
||
{#if answerError}<p class="warning">{answerError}</p>{/if}
|
||
{/if}
|
||
</div>
|
||
{/if}
|
||
|
||
<details class="about" id="about" bind:open={aboutOpen}>
|
||
<summary>about this game</summary>
|
||
<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>
|
||
<p>This version keeps the game as written: all forty spells, the same simultaneous resolution, the same ledger of letters. The rules text it follows is the fanzine transcription, kept in full on the <a href="/rules">rules page</a>. The bot reads your gestures as you would read its; the hand silhouettes on the buttons are adapted from Plotkin's bitmaps, with his notice retained.</p>
|
||
<p>It is free and keeps no accounts. A duel against the bot lives in your own browser. A duel between people lives on a small server as an append-only ledger of moves, so it can be replayed from its first gesture, and each wizard is shown only what the rules let them see.</p>
|
||
<h3>Send word</h3>
|
||
<p>A rule read wrong, a bug, a duel you would like to tell of: write to <a href="mailto:eric@ericwagoner.com">eric@ericwagoner.com</a>, <a href="https://bsky.app/profile/kestrelsnest.social" target="_blank" rel="noreferrer">@kestrelsnest.social</a> on Bluesky, or <a href="https://toots.kestrelsnest.social/@eric" target="_blank" rel="noreferrer">@eric@toots.kestrelsnest.social</a> on Mastodon. The keeper of this hall roosts at <a href="https://kestrelsnest.social" target="_blank" rel="noreferrer">kestrelsnest.social</a>.</p>
|
||
{#if tally}
|
||
<h3>The tally</h3>
|
||
<dl class="tally">
|
||
<dt>{tally.gamesFinished}</dt><dd>{tally.gamesFinished === 1 ? 'duel' : 'duels'} fought to a finish, of {tally.gamesBegun} begun at {tally.tablesOpened} {tally.tablesOpened === 1 ? 'table' : 'tables'}</dd>
|
||
<dt>{tally.namesSeated}</dt><dd>{tally.namesSeated === 1 ? 'name has' : 'names have'} taken a seat</dd>
|
||
<dt>{tally.turnsPlayed}</dt><dd>turns of gestures written; {tally.longestGameTurns} in the longest duel</dd>
|
||
<dt>{hours(tally.minutesAtTable)}</dt><dd>at the table, by the clock between reveals</dd>
|
||
<dt>{tally.gamesWithBot}</dt><dd>{tally.gamesWithBot === 1 ? 'duel' : 'duels'} with the bot seated; {tally.talkLines} {tally.talkLines === 1 ? 'line' : 'lines'} of table talk</dd>
|
||
{#each Object.entries(tally.byGame) as [label, n] (label)}
|
||
<dt>{n}</dt><dd>{label}</dd>
|
||
{/each}
|
||
</dl>
|
||
<p class="muted small">
|
||
{#if tally.firstGameAt}The first duel began {new Date(tally.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}.{/if}
|
||
Names are counted, not people: one wizard 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.
|
||
</p>
|
||
{/if}
|
||
{#if family.length}
|
||
<h3>More games in {#if hall}<a href={hall}>Kestrel's Hall</a>{:else}Kestrel's Hall{/if}</h3>
|
||
<ul class="family">
|
||
{#each family as g (g.slug)}
|
||
<li><a href={g.url}>{g.name}</a>: {g.pitch} <span class="muted">{g.players} players; {g.origin}.</span></li>
|
||
{/each}
|
||
</ul>
|
||
{/if}
|
||
<p class="hosting">
|
||
The games of Kestrel's Hall are free and will stay free. They run on small servers that cost a few dollars a month; anyone who wants to chip in toward the hosting can at
|
||
<a href="https://ko-fi.com/kestrelsnest" target="_blank" rel="noreferrer">ko-fi.com/kestrelsnest</a>. Nothing is unlocked by it and nothing is withheld without it.
|
||
</p>
|
||
<p class="made">Waving Hands is Richard Bartle's, 1977. This page was made by Eric and a very enthusiastic AI, 2026.</p>
|
||
</div>
|
||
</details>
|
||
</section>
|
||
|
||
<style>
|
||
.lid {
|
||
width: 100%;
|
||
max-width: 54rem;
|
||
background: var(--slate);
|
||
border: 1px solid var(--rule-strong);
|
||
border-radius: 8px;
|
||
padding: clamp(1.4rem, 3vw, 2.2rem) clamp(1.2rem, 3vw, 2.6rem) 1.8rem;
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr);
|
||
grid-template-areas: 'head' 'play' 'look' 'ledger' 'about';
|
||
gap: 1.2rem 2.2rem;
|
||
}
|
||
|
||
@media (min-width: 901px) {
|
||
.lid {
|
||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||
grid-template-areas: 'head head' 'play look' 'ledger ledger' 'about about';
|
||
}
|
||
}
|
||
|
||
.lid-head {
|
||
grid-area: head;
|
||
text-align: center;
|
||
}
|
||
|
||
h1 {
|
||
font-size: clamp(2.6rem, 6vw, 3.6rem);
|
||
font-weight: 300;
|
||
font-variation-settings: 'opsz' 144;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
|
||
.pitch {
|
||
font-style: italic;
|
||
font-size: 1.2rem;
|
||
margin-top: 0.3rem;
|
||
}
|
||
|
||
.tag {
|
||
color: var(--bone-dim);
|
||
max-width: 40em;
|
||
margin: 0.6rem auto 0.4rem;
|
||
font-size: 0.95rem;
|
||
}
|
||
|
||
.links {
|
||
font-size: 0.95rem;
|
||
color: var(--bone-faint);
|
||
}
|
||
|
||
.links a {
|
||
color: var(--frost);
|
||
}
|
||
|
||
.about-link {
|
||
font-style: italic;
|
||
text-decoration: underline dotted;
|
||
}
|
||
|
||
.lid-play {
|
||
grid-area: play;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.8rem;
|
||
}
|
||
|
||
.name {
|
||
display: block;
|
||
}
|
||
|
||
.name span {
|
||
display: block;
|
||
font-size: 0.8rem;
|
||
letter-spacing: 0.12em;
|
||
color: var(--bone-dim);
|
||
margin-bottom: 0.3rem;
|
||
}
|
||
|
||
.name input {
|
||
width: 100%;
|
||
}
|
||
|
||
.reveal.primary {
|
||
width: 100%;
|
||
text-align: center;
|
||
}
|
||
|
||
.small {
|
||
font-size: 0.85rem;
|
||
margin-top: -0.4rem;
|
||
}
|
||
|
||
.ways {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 0.6rem 1rem;
|
||
}
|
||
|
||
.joinform {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.claimform {
|
||
flex-wrap: wrap;
|
||
margin-top: -0.2rem;
|
||
}
|
||
|
||
.claimform input {
|
||
width: 15em;
|
||
}
|
||
|
||
.or {
|
||
color: var(--bone-dim);
|
||
font-style: italic;
|
||
font-size: 0.95rem;
|
||
}
|
||
|
||
.code {
|
||
width: 6.5em;
|
||
text-align: center;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.15em;
|
||
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);
|
||
}
|
||
|
||
.lid-look {
|
||
grid-area: look;
|
||
}
|
||
|
||
.sample {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
background: var(--slate-deep);
|
||
border: 1px solid var(--rule-strong);
|
||
border-radius: 6px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.sample th {
|
||
font-weight: 500;
|
||
font-style: italic;
|
||
padding: 0.4rem 0 0.2rem;
|
||
border-bottom: 1px solid var(--rule-strong);
|
||
}
|
||
|
||
.sample td {
|
||
text-align: center;
|
||
padding: 0.3rem 0.2rem;
|
||
border-bottom: 1px solid var(--rule);
|
||
line-height: 1.1;
|
||
}
|
||
|
||
.sample td.t {
|
||
width: 2rem;
|
||
color: var(--bone-faint);
|
||
font-size: 0.8rem;
|
||
}
|
||
|
||
.sample td:nth-child(3) {
|
||
border-right: 1px solid var(--rule-strong);
|
||
}
|
||
|
||
.sample .letter {
|
||
display: block;
|
||
font-size: 1.6rem;
|
||
}
|
||
|
||
.sample td.cast .letter {
|
||
color: var(--ember);
|
||
}
|
||
|
||
.sample .note {
|
||
display: block;
|
||
font-size: 0.65rem;
|
||
font-style: italic;
|
||
color: var(--ember);
|
||
}
|
||
|
||
.caption {
|
||
font-size: 0.85rem;
|
||
color: var(--bone-dim);
|
||
margin-top: 0.5rem;
|
||
text-align: center;
|
||
}
|
||
|
||
/* the games ledger */
|
||
.ledger {
|
||
grid-area: ledger;
|
||
border-top: 1px solid var(--rule-strong);
|
||
padding-top: 0.5rem;
|
||
}
|
||
|
||
.ledger-head {
|
||
font-style: italic;
|
||
color: var(--bone-dim);
|
||
margin-bottom: 0.3rem;
|
||
}
|
||
|
||
.ledger-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.4rem;
|
||
border-radius: 4px;
|
||
padding: 0.15rem 0.3rem;
|
||
}
|
||
|
||
.ledger-row.your-move {
|
||
background: rgba(240, 165, 58, 0.12);
|
||
}
|
||
|
||
.ledger-resume {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 0.7rem;
|
||
padding: 0.3rem 0.2rem;
|
||
color: var(--bone);
|
||
text-decoration: none;
|
||
font-size: 0.95rem;
|
||
}
|
||
|
||
.ledger-resume:hover .ledger-code {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.talk-new {
|
||
color: var(--frost);
|
||
}
|
||
|
||
.reports-head {
|
||
margin-top: 0.9rem;
|
||
}
|
||
|
||
.report-row {
|
||
display: flex;
|
||
gap: 0.75rem;
|
||
align-items: flex-start;
|
||
padding: 0.45rem 0;
|
||
border-top: 1px solid var(--rule);
|
||
}
|
||
|
||
.report-body {
|
||
min-width: 0;
|
||
flex: 1;
|
||
}
|
||
|
||
.report-text {
|
||
font-size: 0.95rem;
|
||
}
|
||
|
||
.report-reply {
|
||
font-size: 0.85rem;
|
||
color: var(--bone-dim);
|
||
margin-top: 0.2rem;
|
||
}
|
||
|
||
.report-reply.yours {
|
||
font-style: italic;
|
||
}
|
||
|
||
.report-reply.pending {
|
||
font-style: italic;
|
||
color: var(--bone-faint);
|
||
}
|
||
|
||
.report-status {
|
||
color: var(--ember);
|
||
font-style: italic;
|
||
}
|
||
|
||
.report-answer {
|
||
display: flex;
|
||
gap: 0.4rem;
|
||
margin-top: 0.4rem;
|
||
}
|
||
|
||
.report-answer input {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.ledger-code {
|
||
font-weight: 600;
|
||
letter-spacing: 0.12em;
|
||
min-width: 3.5em;
|
||
}
|
||
|
||
.ledger-info {
|
||
color: var(--bone-dim);
|
||
}
|
||
|
||
.ledger-row.your-move .ledger-info {
|
||
color: var(--ember);
|
||
}
|
||
|
||
.ledger-forget {
|
||
background: none;
|
||
border: 0;
|
||
color: var(--bone-faint);
|
||
font-size: 1.05rem;
|
||
padding: 0 0.3rem;
|
||
}
|
||
|
||
.ledger-forget:hover {
|
||
color: var(--blood);
|
||
}
|
||
|
||
.about {
|
||
grid-area: about;
|
||
border-top: 1px solid var(--rule);
|
||
padding-top: 0.6rem;
|
||
}
|
||
|
||
.about summary {
|
||
cursor: pointer;
|
||
font-style: italic;
|
||
color: var(--frost);
|
||
}
|
||
|
||
.about-body h3 {
|
||
font-size: 1rem;
|
||
font-style: italic;
|
||
margin: 0.9rem 0 0.2rem;
|
||
color: var(--bone);
|
||
}
|
||
|
||
.about-body .made {
|
||
font-size: 0.85rem;
|
||
color: var(--bone-faint);
|
||
}
|
||
|
||
.about-body {
|
||
max-width: 44em;
|
||
color: var(--bone-dim);
|
||
padding: 0.6rem 0 0.2rem;
|
||
}
|
||
|
||
.about-body p + p {
|
||
margin-top: 0.6rem;
|
||
}
|
||
|
||
.tally {
|
||
display: grid;
|
||
grid-template-columns: max-content 1fr;
|
||
gap: 0.25rem 0.9rem;
|
||
margin: 0.4rem 0 0.6rem;
|
||
}
|
||
|
||
.tally dt {
|
||
text-align: right;
|
||
font-weight: 500;
|
||
color: var(--bone);
|
||
}
|
||
|
||
.tally dd {
|
||
margin: 0;
|
||
}
|
||
|
||
.about-body em {
|
||
color: var(--bone);
|
||
}
|
||
|
||
.about-body a {
|
||
color: var(--frost);
|
||
}
|
||
.family {
|
||
margin: 0.4rem 0 0.8rem 1.2rem;
|
||
padding: 0;
|
||
}
|
||
|
||
.family li + li {
|
||
margin-top: 0.3rem;
|
||
}
|
||
</style>
|