Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
736 lines
23 KiB
Svelte
736 lines
23 KiB
Svelte
<script lang="ts">
|
||
import About from './About.svelte';
|
||
import TallyBoard from './Tally.svelte';
|
||
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);
|
||
$effect(() => {
|
||
if (location.hash === '#about') aboutOpen = true;
|
||
});
|
||
/** 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 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 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]) => !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 sibling games listed in the about panel, and the family's landing page. */
|
||
let family = $state<FamilyGame[]>([]);
|
||
let hall = $state('');
|
||
$effect(() => {
|
||
void loadFamily('waving-hands').then((f) => {
|
||
family = f.games;
|
||
hall = f.hall;
|
||
});
|
||
});
|
||
/** 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('');
|
||
|
||
// Fetch the reports for every held seat; re-runs when the held seats change.
|
||
$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 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 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;
|
||
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);
|
||
|
||
/** Claim a seat transferred from another device, by its transfer phrase. */
|
||
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}`);
|
||
}
|
||
|
||
/** 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) {
|
||
forgetting = roomId;
|
||
return;
|
||
}
|
||
forgetting = null;
|
||
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.standIn ? `${s.name} (a bot stands in)` : 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}`;
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
|
||
function yourMove(held: { roomId: string; seat: string }): boolean {
|
||
const g = games[held.roomId];
|
||
return !!g?.state && !g.state.over && g.waitingOn.includes(held.seat);
|
||
}
|
||
|
||
/** 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' } },
|
||
{ 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">Wizards duel with their hands, and 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 for each hand, and all are shown together. A Fireball is F-S-S-D-D on one hand, and 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" id="about"><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></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">With a code you can 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">A few turns of a duel. Each row is a turn, each letter one hand's gesture, and a spell is named under the gesture that finished it.</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)} class:asking={forgetting === held.roomId}>
|
||
<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>
|
||
{#if forgetting === held.roomId}
|
||
<span class="forget-ask">This browser may be all that holds your seat. <button type="button" class="quiet" onclick={() => forget(held.roomId)}>Forget it</button> <button type="button" class="quiet" onclick={() => (forgetting = null)}>Keep it</button></span>
|
||
{:else}
|
||
<button type="button" class="ledger-forget" title="forget this duel" onclick={() => forget(held.roomId)}>×</button>
|
||
{/if}
|
||
</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}
|
||
|
||
<!-- 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>
|
||
<p>This version keeps the game as written, with all forty spells, the same simultaneous turns and 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. An offline duel stays in your own browser. Every other duel, against the bot or 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>
|
||
</div>
|
||
{/snippet}
|
||
{#snippet aboutTally()}
|
||
{#if told}
|
||
<TallyBoard title="What the ledgers add up to" headline={told.headline} details={told.details} note={told.note} />
|
||
{:else}
|
||
<p class="muted">Counting…</p>
|
||
{/if}
|
||
{/snippet}
|
||
{#snippet aboutFamily()}
|
||
<div class="about-body">{#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, and anyone who wants to chip in toward the hosting can do so at
|
||
<a href="https://ko-fi.com/kestrelsnest" target="_blank" rel="noreferrer">ko-fi.com/kestrelsnest</a>. Chipping in unlocks nothing, and the games are the same for everyone.
|
||
</p>
|
||
<p class="made">Waving Hands is Richard Bartle's, 1977.</p>
|
||
</div>
|
||
{/snippet}
|
||
{#snippet aboutWord()}
|
||
<div class="about-body">
|
||
<p>If a rule reads wrong, something breaks, or you have a duel 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>
|
||
</div>
|
||
{/snippet}
|
||
<About bind:open={aboutOpen} title="About Waving Hands" tabs={[{ id: 'game', label: 'Origins', body: aboutGame }, { id: 'tally', label: 'Duels so far', body: aboutTally }, { id: 'family', label: 'More games', body: aboutFamily }, { id: 'word', label: 'Send word', body: aboutWord }]} />
|
||
</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';
|
||
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';
|
||
}
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.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 list of held seats, and the reports under it. */
|
||
.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);
|
||
}
|
||
|
||
/* The confirmation wraps onto its own line when the row is narrow. */
|
||
.ledger-row.asking {
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.forget-ask {
|
||
font-size: 0.85em;
|
||
padding: 0 0.2rem 0.3rem;
|
||
}
|
||
|
||
.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-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;
|
||
}
|
||
|
||
.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>
|