The game kit: the shared infrastructure of Wiz-War and Waving Hands as a template

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141G6xqLeNRYEtviLWSB5Up
This commit is contained in:
Eric Wagoner
2026-09-23 11:00:05 -04:00
co-authored by Claude Fable 5.1
commit 1cd24e3ddd
59 changed files with 7420 additions and 0 deletions
+202
View File
@@ -0,0 +1,202 @@
<script lang="ts">
// The demo game's board. A real game replaces this file entirely; the
// masthead, lobby, talk, gallery and reports around it stay.
import TableTalk from './TableTalk.svelte';
import { HIGHEST, TARGET } from '$lib/game';
import type { Room } from '$lib/net/room.svelte';
let { room }: { room: Room } = $props();
const g = $derived(room.state!);
let pick = $state(0);
async function play() {
if (!pick) return;
if (await room.submit({ pick })) pick = 0;
}
</script>
<main class="board">
<section class="play">
<table class="rounds">
<thead>
<tr>
<th class="turn"><span class="visually-hidden">Round</span></th>
{#each g.seats as id (id)}
<th class:you={id === room.me}>{g.players[id].name}{#if id === room.me}&nbsp;(you){/if}</th>
{/each}
</tr>
</thead>
<tbody>
{#each g.rounds as round, i (i)}
<tr>
<td class="turn">{i + 1}</td>
{#each g.seats as id (id)}
<td class:scored={round.scorer === id}>{round.picks[id]}</td>
{/each}
</tr>
{/each}
{#if !g.over}
<tr class="draft">
<td class="turn">{g.rounds.length + 1}</td>
{#each g.seats as id (id)}
<td class="muted">{id === room.me && pick ? pick : '?'}</td>
{/each}
</tr>
{/if}
</tbody>
</table>
{#if g.over}
<div class="verdict">
<h2>{g.over.winner === room.me ? 'You win.' : g.over.winner ? `${g.players[g.over.winner].name} wins.` : 'A draw.'}</h2>
<p>{g.over.reason}</p>
<a class="commit" href="/">Back to the hall</a>
</div>
{:else if room.spectating}
<section class="move">
<h2>You watch from the Peanut Gallery</h2>
<p class="muted">{room.awaitingText ? `${room.awaitingText} ${room.awaiting.length === 1 ? 'is' : 'are'} still choosing.` : 'The round is being written.'}</p>
</section>
{:else}
<section class="move">
<h2>Round {g.rounds.length + 1}: name a number</h2>
<p class="muted">The highest number named by exactly one player scores. First to {TARGET}.</p>
<div class="picks">
{#each Array.from({ length: HIGHEST }, (_, i) => i + 1) as n (n)}
<button type="button" class="pick" class:chosen={pick === n} disabled={room.moved} onclick={() => (pick = n)}>{n}</button>
{/each}
</div>
<button type="button" class="commit" disabled={!pick || room.moved || room.sending} onclick={play}>
{room.moved ? `Waiting on ${room.awaitingText}` : room.sending ? 'Sending' : 'Commit'}
</button>
{#if room.error}<p class="warning">{room.error}</p>{/if}
</section>
{/if}
</section>
<aside class="rail">
<section class="standings">
<h2>Standings</h2>
<ul>
{#each g.seats as id (id)}
<li><span>{g.players[id].name}</span><span>{g.players[id].points} / {TARGET}</span></li>
{/each}
</ul>
</section>
<TableTalk {room} />
</aside>
</main>
<style>
.board {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
gap: 0 clamp(1.5rem, 4vw, 3.5rem);
padding: 0 var(--gutter);
max-width: 1280px;
margin: 0 auto;
}
@media (max-width: 860px) {
.board {
grid-template-columns: minmax(0, 1fr);
}
}
.rounds {
width: 100%;
border-collapse: collapse;
font-size: 1.3rem;
text-align: center;
}
.rounds th {
font-weight: 500;
font-style: italic;
font-size: 1rem;
padding: 0.3rem 0;
border-bottom: 1px solid var(--rule-strong);
}
.rounds th.you {
color: var(--frost);
}
.rounds td {
padding: 0.35rem 0;
border-bottom: 1px solid var(--rule);
}
.rounds .turn {
width: 2.5rem;
font-size: 0.8rem;
color: var(--bone-faint);
}
.rounds td.scored {
color: var(--ember);
font-weight: 600;
}
.rounds tr.draft td {
background: var(--slate);
}
.move,
.verdict {
margin-top: 1.2rem;
}
.move h2,
.verdict h2 {
font-size: 1.3rem;
margin-bottom: 0.4rem;
}
.picks {
display: flex;
gap: 0.5rem;
margin: 0.8rem 0;
}
.pick {
width: 3rem;
height: 3rem;
font-size: 1.3rem;
background: var(--slate);
border: 1px solid var(--rule-strong);
border-radius: 6px;
color: var(--bone);
}
.pick.chosen {
border-color: var(--ember);
color: var(--ember);
}
.verdict .commit {
display: inline-block;
margin-top: 0.6rem;
text-decoration: none;
}
.standings h2 {
font-size: 1.1rem;
font-style: italic;
margin: 0.75rem 0 0.4rem;
}
.standings ul {
list-style: none;
margin: 0;
padding: 0;
}
.standings li {
display: flex;
justify-content: space-between;
padding: 0.3rem 0;
border-bottom: 1px solid var(--rule);
}
</style>
+500
View File
@@ -0,0 +1,500 @@
<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 { allSeats, forgetSeat, type Report } from '$lib/net/client';
import { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
import { unreadTalk } from '$lib/net/talk';
import type { RoomView } from '$lib/net/view';
import type { State } from '$lib/game';
import { game } from '$lib/game';
let name = $state(playerName());
let code = $state('');
let busy = $state(false);
let error = $state('');
let seats = $state(allSeats());
let games = $state<Record<string, RoomView<State> | null>>({});
let aboutOpen = $state(false);
/** 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.
$effect(() => {
for (const held of seats) {
if (held.roomId in games) continue;
games[held.roomId] = null;
api.view(held.roomId, held.token)
.then((v) => (games[held.roomId] = v))
.catch(() => (games[held.roomId] = null));
}
});
// 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 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}`);
}
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.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">
<div class="lid-head">
<h1>__NAME__</h1>
<p class="pitch">One line that says what the game is.</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" onclick={() => (aboutOpen = true)}>about this game — a labor of love</a></p>
</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>
<button type="button" class="commit primary" disabled={!ready || busy} onclick={() => open(() => Room.createWithBot(name.trim()))}>Play now against the bot</button>
<div class="ways">
<button type="button" class="quiet" disabled={!ready || busy} onclick={() => open(() => Room.create(name.trim(), game.seatIds.length))}>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>
{#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>
{#if seats.length}
<div class="ledger">
<div class="ledger-head">your games</div>
{#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 game" 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>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, and each player is shown only what the rules let them see.</p>
<h3>Send word</h3>
<p>A rule read wrong, a bug, a game 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>
<p class="made">__NAME__ is its designer's. 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;
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;
}
.small {
font-size: 0.85rem;
}
.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-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);
}
.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);
}
.ledger-forget {
background: none;
border: 0;
color: var(--bone-faint);
font-size: 1.1rem;
padding: 0 0.4rem;
}
.ledger-forget:hover {
color: var(--blood);
}
.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 */
.about {
grid-area: about;
border-top: 1px solid var(--rule-strong);
padding-top: 0.5rem;
}
.about summary {
cursor: pointer;
font-style: italic;
color: var(--bone-dim);
}
.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 a {
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);
}
</style>
+131
View File
@@ -0,0 +1,131 @@
<script lang="ts">
import TableTalk from './TableTalk.svelte';
import type { Room } from '$lib/net/room.svelte';
let { room, link }: { room: Room; link: string } = $props();
const v = $derived(room.view);
const hostName = $derived(v.seats.find((s) => s.id === v.host)?.name ?? 'the host');
const seatFree = $derived(v.seats.length < v.size);
let copied = $state(false);
async function copyLink() {
try {
await navigator.clipboard.writeText(link);
copied = true;
setTimeout(() => (copied = false), 1600);
} catch {
// The link is on screen to copy by hand.
}
}
</script>
<section class="lobby">
{#if room.spectating}
<h2>At the table</h2>
{#if seatFree}
<p>{hostName} is gathering players. Sit down above, or watch from the gallery until the game begins.</p>
{:else}
<p>The table is full. You watch from the Peanut Gallery; the game begins when {hostName} says.</p>
{/if}
{:else}
<p class="eyebrow">room {v.roomId}</p>
<h2>The table is laid</h2>
<p>Send this link, or the code. Whoever opens it takes a seat. Anyone who arrives once the game has begun watches from the Peanut Gallery.</p>
<p class="link-row"><code>{link}</code><button type="button" class="quiet" onclick={copyLink}>{copied ? 'Copied' : 'Copy link'}</button></p>
{/if}
<ul class="roster">
{#each v.seats as s (s.id)}
<li>
<span class="seat-name">{s.name}</span>
<span class="muted">{s.id === v.host ? 'opened the table' : s.bot ? 'the bot' : 'seated'}{s.id === v.me ? ', you' : ''}</span>
</li>
{/each}
<li class="empty muted">{v.size - v.seats.length} of {v.size} seats empty{#if v.audience > 0}; {v.audience} in the gallery{/if}</li>
</ul>
{#if !room.spectating}
<div class="ways">
{#if seatFree}
<button type="button" class="quiet" onclick={() => room.addBot()}>Seat a bot</button>
{/if}
{#if room.isHost}
<button type="button" class="commit small" disabled={v.seats.length < 2} onclick={() => room.begin()}>Begin</button>
<span class="muted">{v.seats.length < 2 ? 'once a second player is seated' : `with ${v.seats.length} players`}</span>
{:else}
<span class="muted">{hostName} will begin when the table is ready.</span>
{/if}
</div>
{/if}
{#if room.error}<p class="warning">{room.error}</p>{/if}
<div class="lobby-talk"><TableTalk {room} /></div>
</section>
<style>
.lobby {
max-width: 1280px;
margin: 0 auto;
padding: 1rem var(--gutter) 2rem;
}
.lobby h2 {
font-size: 1.4rem;
margin-bottom: 0.4rem;
}
.lobby p + p {
margin-top: 0.6rem;
}
.roster {
list-style: none;
margin: 0.9rem 0 0.6rem;
padding: 0;
max-width: 28em;
}
.roster li {
display: flex;
justify-content: space-between;
gap: 1rem;
padding: 0.3rem 0;
border-bottom: 1px solid var(--rule);
}
.roster li.empty {
border-bottom: 0;
justify-content: flex-start;
font-style: italic;
}
.seat-name {
font-weight: 500;
}
.ways {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.9rem;
margin-top: 0.4rem;
}
.link-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 0.75rem;
}
code {
font-family: var(--font);
background: var(--slate);
padding: 0.3rem 0.6rem;
border-radius: 4px;
word-break: break-all;
}
.lobby-talk {
max-width: 28em;
margin-top: 1rem;
}
</style>
@@ -0,0 +1,165 @@
<script lang="ts">
import { api, type Room } from '$lib/net/room.svelte';
let { room, open = $bindable(false) }: { room: Room; open?: boolean } = $props();
let happened = $state('');
let expected = $state('');
let image = $state<File | null>(null);
let preview = $state<string | null>(null);
let busy = $state(false);
let sent = $state(false);
let error = $state('');
function pickImage(e: Event) {
const file = (e.currentTarget as HTMLInputElement).files?.[0] ?? null;
if (preview) URL.revokeObjectURL(preview);
if (file && file.size > 2_500_000) {
error = 'That picture is over 2.5 MB; a smaller one, please.';
image = null;
preview = null;
return;
}
error = '';
image = file;
preview = file ? URL.createObjectURL(file) : null;
}
function close() {
open = false;
if (preview) URL.revokeObjectURL(preview);
happened = '';
expected = '';
image = null;
preview = null;
sent = false;
error = '';
}
async function send(e: SubmitEvent) {
e.preventDefault();
const r = room;
if (!happened.trim() || busy) return;
busy = true;
error = '';
try {
const { id } = await api.report(r.roomId, r.token, happened.trim(), expected.trim());
if (image) await api.reportImage(id, image).catch(() => (error = 'The report went; the picture did not.'));
sent = true;
setTimeout(close, 2200);
} catch (err) {
error = err instanceof Error ? err.message : 'The report did not reach the keeper.';
} finally {
busy = false;
}
}
</script>
{#if open}
<div class="scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && close()}>
<form class="slip" aria-labelledby="report-heading" onsubmit={send}>
<h2 id="report-heading">Something amiss?</h2>
{#if sent}
<p class="thanks">Recorded beside the room's ledger, with the round. Thank you; the keeper will study the moment.{#if error} {error}{/if}</p>
{:else}
<label class="field">
<span>What happened?</span>
<textarea bind:value={happened} rows="3" maxlength="2000" placeholder="e.g. my move went in but nothing changed"></textarea>
</label>
<label class="field">
<span>What did you expect?</span>
<textarea bind:value={expected} rows="2" maxlength="2000" placeholder="e.g. a point for me"></textarea>
</label>
<label class="field picture">
<span>A screenshot, if you have one</span>
<input type="file" accept="image/png,image/jpeg,image/webp" onchange={pickImage} />
{#if preview}<img class="preview" src={preview} alt="The screenshot you chose" />{/if}
</label>
<p class="muted small">The room code and the round ride along; the keeper can replay the game to this moment.</p>
{#if error}<p class="warning">{error}</p>{/if}
<div class="actions">
<button type="submit" class="commit small" disabled={!happened.trim() || busy}>{busy ? 'Sending' : 'Send it'}</button>
<button type="button" class="link" onclick={close}>never mind</button>
</div>
{/if}
</form>
</div>
{/if}
<style>
.scrim {
position: fixed;
inset: 0;
z-index: 20;
background: rgba(0, 0, 0, 0.55);
display: grid;
place-items: center;
padding: 16px;
}
.slip {
width: min(100%, 30rem);
max-height: 100%;
overflow-y: auto;
background: var(--slate);
border: 1px solid var(--rule-strong);
border-radius: 6px;
padding: 1rem 1.2rem 1.2rem;
}
h2 {
font-size: 1.3rem;
margin-bottom: 0.6rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-top: 0.7rem;
}
.picture input {
font-size: 0.9rem;
}
.preview {
max-height: 8rem;
max-width: 100%;
border: 1px solid var(--rule);
border-radius: 3px;
margin-top: 0.3rem;
}
.small {
font-size: 0.85rem;
margin-top: 0.6rem;
}
.warning {
color: var(--blood);
margin-top: 0.4rem;
}
.thanks {
color: var(--bone);
}
.actions {
display: flex;
align-items: center;
gap: 1rem;
margin-top: 0.9rem;
}
.link {
background: none;
border: 0;
color: var(--bone-dim);
text-decoration: underline dotted;
padding: 0;
font: inherit;
}
</style>
@@ -0,0 +1,109 @@
<script lang="ts">
import { tick } from 'svelte';
import type { Room } from '$lib/net/room.svelte';
let { room }: { room: Room } = $props();
const lines = $derived(room.view.chat);
let draft = $state('');
let sending = $state(false);
let list = $state<HTMLElement | null>(null);
// New talk scrolls into view, as at any table.
$effect(() => {
void lines.length;
void tick().then(() => {
if (list) list.scrollTop = list.scrollHeight;
});
});
async function say(e: SubmitEvent) {
e.preventDefault();
const text = draft.trim();
if (!text || sending) return;
sending = true;
if (await room.say(text)) draft = '';
sending = false;
}
</script>
<section class="talk" aria-label="Table talk">
<h2>Table talk{#if room.view.audience > 0}<span class="audience">{room.view.audience} in the gallery</span>{/if}</h2>
<ul bind:this={list}>
{#if lines.length === 0}
<li class="muted">Nobody has said a word.</li>
{/if}
{#each lines as line, i (line.at + ':' + i)}
<li class:you={line.id === room.me}><em>{line.id === room.me ? 'You' : room.nameOf(line.id)}</em> {line.text}</li>
{/each}
</ul>
{#if room.spectating}
<p class="muted small">The gallery listens; only seated players speak.</p>
{:else}
<form onsubmit={say}>
<input type="text" bind:value={draft} maxlength="300" placeholder="Say something to the table" aria-label="Say something to the table" autocomplete="off" />
<button type="submit" class="quiet" disabled={!draft.trim() || sending}>Say</button>
</form>
{/if}
</section>
<style>
.talk {
padding: 0.75rem 0;
border-top: 1px solid var(--rule);
}
h2 {
font-size: 1.1rem;
font-style: italic;
margin-bottom: 0.4rem;
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.75rem;
}
.audience {
font-size: 0.8rem;
font-style: normal;
color: var(--bone-faint);
}
ul {
list-style: none;
margin: 0;
padding: 0;
max-height: 12rem;
overflow-y: auto;
font-size: 0.9rem;
line-height: 1.4;
}
li + li {
margin-top: 0.2rem;
}
li em {
color: var(--bone-dim);
}
li.you em {
color: var(--frost);
}
form {
display: flex;
gap: 0.4rem;
margin-top: 0.5rem;
}
input {
flex: 1;
min-width: 0;
}
.small {
font-size: 0.85rem;
margin-top: 0.4rem;
}
</style>