Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
669 lines
21 KiB
Svelte
669 lines
21 KiB
Svelte
<script lang="ts">
|
||
// The hall: the landing page every game in the kit shares. Name, play the
|
||
// bot, open a table, join by code, the ledger of your tables, and the
|
||
// about panel with the rules, the guide and a way to reach the keeper.
|
||
import { goto } from '$app/navigation';
|
||
import { loadFamily, type FamilyGame } from '$lib/family';
|
||
import TabbedDialog from './TabbedDialog.svelte';
|
||
import TallyBoard from './Tally.svelte';
|
||
import Preferences from './Preferences.svelte';
|
||
import { prefs, setPref } from '$lib/prefs.svelte';
|
||
import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat } from '$lib/net/client';
|
||
import { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
|
||
import { unreadTalk } from '$lib/net/talk';
|
||
import type { Report, RoomView, Tally } from '$lib/net/view';
|
||
import type { State } from '$lib/game';
|
||
import { game } from '$lib/game';
|
||
import { tableSize } from '$lib/game/spec';
|
||
|
||
let name = $state(playerName());
|
||
let code = $state('');
|
||
/** The host's choices for the next table: the preferences' table defaults, which the selectors below also set. */
|
||
const options = $derived<Record<string, string>>(
|
||
Object.fromEntries((game.options ?? []).map((o) => [o.key, o.choices.some((c) => c.value === prefs[`table.${o.key}`]) ? String(prefs[`table.${o.key}`]) : o.default]))
|
||
);
|
||
let prefsOpen = $state(false);
|
||
let busy = $state(false);
|
||
let error = $state('');
|
||
let seats = $state(allSeats());
|
||
let games = $state<Record<string, RoomView<State> | 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 tally as the about panel tells it: this game's own story on cards, the rest in detail. */
|
||
const told = $derived.by(() => {
|
||
const t = tally;
|
||
if (!t) return null;
|
||
const headline = [
|
||
{ n: t.gamesFinished, label: t.gamesFinished === 1 ? 'game played to a finish' : 'games played to a finish' },
|
||
{ n: t.namesSeated, label: t.namesSeated === 1 ? 'name at the table' : 'names at the table' },
|
||
{ n: t.turnsPlayed, label: 'turns recorded' },
|
||
{ n: hours(t.minutesAtTable), label: 'at the table' }
|
||
];
|
||
const details = [
|
||
{ n: t.gamesBegun, label: `games 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 recorded; ${t.longestGameTurns} in the longest game` },
|
||
{ n: t.gamesWithBot, label: t.gamesWithBot === 1 ? 'game with a bot seated' : 'games with a bot seated' },
|
||
...(t.talkLines ? [{ n: t.talkLines, label: t.talkLines === 1 ? 'line of table talk' : 'lines of table talk' }] : []),
|
||
...Object.entries(t.byGame).map(([label, n]) => ({ n, label }))
|
||
];
|
||
const first = t.firstGameAt ? `The first game began ${new Date(t.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}. ` : '';
|
||
return { headline, details, note: `${first}Names are counted, not people: one player under two names is two here. Table time counts only the minutes between moves, so a game left open overnight adds nothing for the night.` };
|
||
});
|
||
/** The other games of Kestrel's Hall, for the about panel. */
|
||
let family = $state<FamilyGame[]>([]);
|
||
let hall = $state('');
|
||
$effect(() => {
|
||
void loadFamily('__SLUG__').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('');
|
||
|
||
const ready = $derived(name.trim().length > 0);
|
||
|
||
// Each held seat is looked up once, so the ledger can say whose move it
|
||
// is; until the answer comes, its game is absent and reads "looking". A
|
||
// seat the server refuses outright is doubted; a second refusal in a row
|
||
// forgets it. Silence (a restart, a stale answer) forgets nothing.
|
||
const asked = new Set<string>();
|
||
$effect(() => {
|
||
for (const held of seats) {
|
||
if (asked.has(held.roomId)) continue;
|
||
asked.add(held.roomId);
|
||
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;
|
||
}
|
||
}
|
||
|
||
// The desk is asked for every seat this browser holds, again whenever that set changes.
|
||
$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 open(make: () => Promise<Room>) {
|
||
busy = true;
|
||
error = '';
|
||
try {
|
||
rememberName(name);
|
||
const room = await make();
|
||
await goto(`/join/${room.roomId}`);
|
||
} catch (e) {
|
||
error = e instanceof Error ? e.message : 'The table 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}`);
|
||
}
|
||
|
||
/** A game in play asks before it is forgotten: this browser may be the only thing that holds the seat. */
|
||
let forgetting = $state<string | null>(null);
|
||
function forget(roomId: string) {
|
||
if (!games[roomId]?.over && forgetting !== roomId) {
|
||
forgetting = roomId;
|
||
return;
|
||
}
|
||
forgetting = null;
|
||
forgetSeat(roomId);
|
||
seats = allSeats();
|
||
}
|
||
|
||
/** A game counts as finished once its table says so; until then, even while loading, it is in play. */
|
||
const finished = $derived(seats.filter((h) => !!games[h.roomId]?.over));
|
||
const active = $derived(seats.filter((h) => !games[h.roomId]?.over));
|
||
|
||
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.over) {
|
||
if (g.over.winner === held.seat) return 'you won';
|
||
if (g.over.winner === null) return 'a draw';
|
||
return `${game.nameOf(g.state, g.over.winner)} won`;
|
||
}
|
||
const round = `round ${g.turn}`;
|
||
if (g.waitingOn.includes(held.seat)) return `your move · ${round} · against ${others.join(', ')}`;
|
||
return `waiting on ${g.waitingOn.map((id) => game.nameOf(g.state!, id)).join(', ')} · ${round}`;
|
||
}
|
||
|
||
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.over && g.waitingOn.includes(held.seat);
|
||
}
|
||
|
||
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.';
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<section class="lid" class:returning={active.length > 0}>
|
||
<div class="lid-head">
|
||
<h1>__NAME__</h1>
|
||
<p class="pitch">One sentence that says what the game is, in its own world.</p>
|
||
<p class="tag">Two or three sentences for someone who has never heard of it: what you do on a turn, what makes it fun, and that it is free to play here against the bot or with friends.</p>
|
||
<p class="links"><a href="/guide">how to play</a> · <a href="/rules">the rules</a> · <a class="about-link" href="#about">about this game</a> · <button type="button" class="link" onclick={() => (prefsOpen = true)}>preferences</button></p>
|
||
<Preferences bind:open={prefsOpen} />
|
||
</div>
|
||
|
||
<div class="lid-play">
|
||
<label class="name">
|
||
<span>Your name</span>
|
||
<input type="text" bind:value={name} maxlength={NAME_MAX} placeholder="e.g. Morwenna" autocomplete="nickname" />
|
||
</label>
|
||
{#if game.options?.length}
|
||
<div class="options">
|
||
{#each game.options as o (o.key)}
|
||
{@const chosen = o.choices.find((c) => c.value === options[o.key])}
|
||
<div>
|
||
<label class="option">
|
||
<span>{o.label}</span>
|
||
<select value={options[o.key]} onchange={(e) => setPref(`table.${o.key}`, e.currentTarget.value)}>
|
||
{#each o.choices as c (c.value)}<option value={c.value}>{c.label}</option>{/each}
|
||
</select>
|
||
</label>
|
||
{#if chosen?.note}<p class="option-note muted small">{chosen.note}</p>{/if}
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
<button type="button" class="commit primary" disabled={!ready || busy} onclick={() => open(() => Room.createWithBot(name.trim(), options))}>Play now against the bot</button>
|
||
<div class="ways">
|
||
<button type="button" class="quiet" disabled={!ready || busy} onclick={() => open(() => Room.create(name.trim(), tableSize(game, options), options))}>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" 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">
|
||
<!-- A still of the game at its most characteristic: a few rounds of the ledger, a board mid-play. -->
|
||
<p class="caption">A picture of the game goes here.</p>
|
||
</div>
|
||
|
||
{#snippet gameRow(held: (typeof seats)[number])}
|
||
<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>
|
||
{#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="dismiss" title="forget this game" onclick={() => forget(held.roomId)}>×</button>
|
||
{/if}
|
||
</div>
|
||
{/snippet}
|
||
|
||
<!-- The games still in play, first thing for someone coming back; finished ones wait further down. -->
|
||
{#if active.length}
|
||
<div class="ledger">
|
||
<div class="ledger-head">your games</div>
|
||
{#each active as held (held.roomId)}{@render gameRow(held)}{/each}
|
||
</div>
|
||
{/if}
|
||
|
||
{#if finished.length || reports.length}
|
||
<div class="ledger past">
|
||
{#if finished.length}
|
||
<div class="ledger-head">finished games</div>
|
||
{#each finished as held (held.roomId)}{@render gameRow(held)}{/each}
|
||
{/if}
|
||
{#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, a tab at a time: its sections are written here, in the game's own words. -->
|
||
{#snippet aboutGame()}
|
||
<div class="about-body">
|
||
<p>Where the game comes from, who made the original and when, and how the keeper of this page first met it.</p>
|
||
<p>What this version keeps and what it changes. Where the rules text it follows lives (the <a href="/rules">rules page</a>), and any borrowed art or text with its notice.</p>
|
||
<p>It is free and keeps no accounts. A game between people lives on a small server as an append-only ledger of moves, so it can be replayed from its first move. Each player is shown only what the rules let them see (drop this sentence if nothing in the game is hidden).</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">__NAME__ is its designer's.</p>
|
||
</div>
|
||
{/snippet}
|
||
{#snippet aboutWord()}
|
||
<div class="about-body">
|
||
<p>If a rule reads wrong, something breaks, or you have a game 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}
|
||
<TabbedDialog
|
||
address="about"
|
||
bind:open={aboutOpen}
|
||
title="About __NAME__"
|
||
label="About this game"
|
||
tabs={[
|
||
{ id: 'game', label: 'Origins', body: aboutGame },
|
||
{ id: 'tally', label: 'Games 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' 'past';
|
||
gap: 1.2rem 2.2rem;
|
||
}
|
||
/* Someone coming back sees their games first. */
|
||
.lid.returning {
|
||
grid-template-areas: 'head' 'ledger' 'play' 'look' 'past';
|
||
}
|
||
|
||
@media (min-width: 901px) {
|
||
.lid {
|
||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||
grid-template-areas: 'head head' 'play look' 'ledger ledger' 'past past';
|
||
}
|
||
.lid.returning {
|
||
grid-template-areas: 'head head' 'ledger ledger' 'play look' 'past past';
|
||
}
|
||
}
|
||
|
||
.lid-head {
|
||
grid-area: head;
|
||
text-align: center;
|
||
}
|
||
|
||
h1 {
|
||
font-size: clamp(2.6rem, 6vw, 3.6rem);
|
||
font-weight: 300;
|
||
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%;
|
||
}
|
||
|
||
.commit.primary {
|
||
width: 100%;
|
||
text-align: center;
|
||
}
|
||
|
||
.ways {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 0.6rem 1rem;
|
||
}
|
||
|
||
.joinform {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.lid-look {
|
||
grid-area: look;
|
||
display: grid;
|
||
place-items: center;
|
||
min-height: 8rem;
|
||
background: var(--slate-deep);
|
||
border: 1px solid var(--rule-strong);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.caption {
|
||
font-size: 0.85rem;
|
||
color: var(--bone-dim);
|
||
text-align: center;
|
||
}
|
||
|
||
/* the games ledger */
|
||
.ledger {
|
||
grid-area: ledger;
|
||
border-top: 1px solid var(--rule-strong);
|
||
padding-top: 0.5rem;
|
||
}
|
||
|
||
.ledger.past {
|
||
grid-area: past;
|
||
}
|
||
|
||
.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.08);
|
||
}
|
||
|
||
.forget-ask {
|
||
font-size: 0.85em;
|
||
flex: none;
|
||
}
|
||
|
||
.ledger-row.your-move .ledger-info {
|
||
color: var(--ember);
|
||
}
|
||
|
||
.ledger-resume {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 0.9rem;
|
||
min-width: 0;
|
||
color: inherit;
|
||
text-decoration: none;
|
||
padding: 0.25rem 0;
|
||
}
|
||
|
||
.ledger-resume:hover .ledger-code {
|
||
color: var(--frost);
|
||
}
|
||
|
||
.ledger-code {
|
||
font-weight: 600;
|
||
letter-spacing: 0.12em;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.ledger-info {
|
||
color: var(--bone-dim);
|
||
font-size: 0.95rem;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
/* about: the panel's own words; TabbedDialog.svelte is the dialog around them */
|
||
.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 .made {
|
||
font-size: 0.85rem;
|
||
color: var(--bone-faint);
|
||
}
|
||
|
||
.family {
|
||
margin: 0.4rem 0 0.8rem 1.2rem;
|
||
padding: 0;
|
||
}
|
||
|
||
.family li + li {
|
||
margin-top: 0.3rem;
|
||
}
|
||
|
||
.options {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 0.5rem 1.2rem;
|
||
margin: 0.2rem 0 0.6rem;
|
||
}
|
||
|
||
.option {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
font-size: 0.9rem;
|
||
}
|
||
|
||
.option-note {
|
||
margin: 0.2rem 0 0;
|
||
max-width: 22em;
|
||
}
|
||
</style>
|