The about panel is a dialog of tabs, as Wiz-War's is, not a wall of text

"About this game — a labor of love" opens a dialog with the game, the
tally, more games and a way to send word each on a tab of its own
(About.svelte, shared through the kit), instead of one long fold at the
foot of the hall. Arrow keys walk the tabs; Escape or a click outside
closes it; a link to #about opens it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-27 20:40:41 -04:00
co-authored by Claude Opus 5.5
parent 7f19ec3ea9
commit 2d2a82034e
2 changed files with 175 additions and 22 deletions
+148
View File
@@ -0,0 +1,148 @@
<script lang="ts" module>
import type { Snippet } from 'svelte';
/** One section of the about panel: a tab and what it shows. */
export interface AboutTab {
id: string;
label: string;
body: Snippet;
}
</script>
<script lang="ts">
// The about panel: the game's story, its keeping and its company, a tab
// at a time rather than one long page. Opened from the hall's "about this
// game" link (and #about); closes with its button, a click outside it, or
// Escape. The tab last read is the one it reopens on.
import { tick } from 'svelte';
let { open = $bindable(false), tabs, title }: { open?: boolean; tabs: AboutTab[]; title: string } = $props();
let current = $state('');
const shown = $derived(tabs.find((t) => t.id === current) ?? tabs[0]);
let tabButtons: HTMLButtonElement[] = $state([]);
function onKey(e: KeyboardEvent) {
if (open && e.key === 'Escape') open = false;
}
/** Arrow keys walk the tabs, as a tab list should; Home and End go to the ends. */
async function onTabKey(e: KeyboardEvent, i: number) {
const to = e.key === 'ArrowRight' ? i + 1 : e.key === 'ArrowLeft' ? i - 1 : e.key === 'Home' ? 0 : e.key === 'End' ? tabs.length - 1 : null;
if (to === null) return;
e.preventDefault();
const next = (to + tabs.length) % tabs.length;
current = tabs[next].id;
await tick();
tabButtons[next]?.focus();
}
</script>
<svelte:window onkeydown={onKey} />
{#if open}
<div class="about-scrim" role="presentation" onclick={(ev) => ev.target === ev.currentTarget && (open = false)}>
<div class="panel" role="dialog" aria-modal="true" aria-labelledby="about-title">
<header>
<h2 id="about-title">{title}</h2>
<button type="button" class="close" aria-label="Close" onclick={() => (open = false)}>×</button>
</header>
<div class="tabs" role="tablist" aria-label="About this game">
{#each tabs as t, i (t.id)}
<button
type="button"
role="tab"
id="about-tab-{t.id}"
aria-controls="about-panel"
aria-selected={shown.id === t.id}
tabindex={shown.id === t.id ? 0 : -1}
class:on={shown.id === t.id}
bind:this={tabButtons[i]}
onclick={() => (current = t.id)}
onkeydown={(e) => onTabKey(e, i)}>{t.label}</button
>
{/each}
</div>
<div class="body" id="about-panel" role="tabpanel" aria-labelledby="about-tab-{shown.id}" tabindex="0">
{@render shown.body()}
</div>
</div>
</div>
{/if}
<style>
/* Its own backdrop: not every game has another dialog to share one with. */
.about-scrim {
position: fixed;
inset: 0;
z-index: 20;
background: rgba(0, 0, 0, 0.55);
display: grid;
place-items: center;
padding: 16px;
}
.panel {
width: min(100%, 44rem);
max-height: 100%;
display: flex;
flex-direction: column;
background: var(--slate);
border: 1px solid var(--rule-strong);
border-radius: 6px;
padding: 1rem 1.4rem 1.2rem;
}
header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
}
h2 {
font-size: 1.3rem;
margin: 0 0 0.6rem;
}
.close {
background: none;
border: none;
color: inherit;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
padding: 0.2rem 0.4rem;
}
.tabs {
display: flex;
flex-wrap: wrap;
gap: 0.2rem 0.4rem;
border-bottom: 1px solid var(--rule-strong);
margin-bottom: 0.8rem;
}
.tabs button {
background: none;
border: none;
border-bottom: 3px solid transparent;
color: inherit;
opacity: 0.75;
font: inherit;
padding: 0.35rem 0.5rem 0.4rem;
cursor: pointer;
}
.tabs button.on {
opacity: 1;
border-bottom-color: currentColor;
}
.body {
overflow: auto;
min-height: 12rem;
}
.body :global(p + p) {
margin-top: 0.7rem;
}
.body :global(h3) {
font-size: 1.05rem;
margin: 1rem 0 0.4rem;
}
.body :global(h3:first-child) {
margin-top: 0;
}
</style>
+27 -22
View File
@@ -4,6 +4,7 @@
// about panel with the rules, the guide and a way to reach the keeper. // about panel with the rules, the guide and a way to reach the keeper.
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { loadFamily, type FamilyGame } from '$lib/family'; import { loadFamily, type FamilyGame } from '$lib/family';
import About from './About.svelte';
import Preferences from './Preferences.svelte'; import Preferences from './Preferences.svelte';
import { prefs, setPref } from '$lib/prefs.svelte'; import { prefs, setPref } from '$lib/prefs.svelte';
import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat } from '$lib/net/client'; import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat } from '$lib/net/client';
@@ -27,6 +28,9 @@
let seats = $state(allSeats()); let seats = $state(allSeats());
let games = $state<Record<string, RoomView<State> | null>>({}); let games = $state<Record<string, RoomView<State> | null>>({});
let aboutOpen = $state(false); let aboutOpen = $state(false);
$effect(() => {
if (location.hash === '#about') aboutOpen = true;
});
/** The ledgers' tally, fetched when the about panel opens. */ /** The ledgers' tally, fetched when the about panel opens. */
let tally = $state<Tally | null>(null); let tally = $state<Tally | null>(null);
$effect(() => { $effect(() => {
@@ -261,16 +265,18 @@
</div> </div>
{/if} {/if}
<details class="about" id="about" bind:open={aboutOpen}> <!-- The about panel, a tab at a time: its sections are written here, in the game's own words. -->
<summary>about this game</summary> {#snippet aboutGame()}
<div class="about-body"> <div class="about-body">
<p>Hnefatafl is the tafl game of the Vikings, carried by them from Scandinavia to Britain, Ireland, Iceland and beyond, and played until chess arrived in the eleventh and twelfth centuries. No rules from that age survive; the game was reconstructed in modern times from a Sámi cousin, Tablut, whose rules the botanist Carl Linnaeus jotted down on his journey through Lapland in 1732.</p> <p>Hnefatafl is the tafl game of the Vikings, carried by them from Scandinavia to Britain, Ireland, Iceland and beyond, and played until chess arrived in the eleventh and twelfth centuries. No rules from that age survive; the game was reconstructed in modern times from a Sámi cousin, Tablut, whose rules the botanist Carl Linnaeus jotted down on his journey through Lapland in 1732.</p>
<p>This table plays Copenhagen, the modern tournament ruleset, on the eleven-by-eleven board, and Tablut on the nine-by-nine one, with the older board showing its age. The rules it follows are on the <a href="/rules">rules page</a>. The game is in the public domain; the board and pieces here are drawn, not borrowed.</p> <p>This table plays Copenhagen, the modern tournament ruleset, on the eleven-by-eleven board, and Tablut on the nine-by-nine one, with the older board showing its age. The rules it follows are on the <a href="/rules">rules page</a>. The game is in the public domain; the board and pieces here are drawn, not borrowed.</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> <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> </div>
<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> {/snippet}
{#snippet aboutTally()}
<div class="about-body">
{#if tally} {#if tally}
<h3>The tally</h3>
<dl class="tally"> <dl class="tally">
<dt>{tally.gamesFinished}</dt><dd>{tally.gamesFinished === 1 ? 'game' : 'games'} played to a finish, of {tally.gamesBegun} begun at {tally.tablesOpened} {tally.tablesOpened === 1 ? 'table' : 'tables'}</dd> <dt>{tally.gamesFinished}</dt><dd>{tally.gamesFinished === 1 ? 'game' : 'games'} played 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.namesSeated}</dt><dd>{tally.namesSeated === 1 ? 'name has' : 'names have'} taken a seat</dd>
@@ -285,8 +291,13 @@
{#if tally.firstGameAt}The first game began {new Date(tally.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}.{/if} {#if tally.firstGameAt}The first game began {new Date(tally.firstGameAt).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}.{/if}
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. 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.
</p> </p>
{:else}
<p class="muted">Counting…</p>
{/if} {/if}
{#if family.length} </div>
{/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> <h3>More games in {#if hall}<a href={hall}>Kestrel's Hall</a>{:else}Kestrel's Hall{/if}</h3>
<ul class="family"> <ul class="family">
{#each family as g (g.slug)} {#each family as g (g.slug)}
@@ -300,7 +311,13 @@
</p> </p>
<p class="made">Hnefatafl belongs to everyone; it is a thousand years old. This page was made by Eric and a very enthusiastic AI, 2026.</p> <p class="made">Hnefatafl belongs to everyone; it is a thousand years old. This page was made by Eric and a very enthusiastic AI, 2026.</p>
</div> </div>
</details> {/snippet}
{#snippet aboutWord()}
<div class="about-body">
<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>
</div>
{/snippet}
<About bind:open={aboutOpen} title="About Hnefatafl" tabs={[{ id: 'game', label: 'The game', body: aboutGame }, { id: 'tally', label: 'The tally', body: aboutTally }, { id: 'family', label: 'More games', body: aboutFamily }, { id: 'word', label: 'Send word', body: aboutWord }]} />
</section> </section>
<style> <style>
@@ -313,14 +330,14 @@
padding: clamp(1.4rem, 3vw, 2.2rem) clamp(1.2rem, 3vw, 2.6rem) 1.8rem; padding: clamp(1.4rem, 3vw, 2.2rem) clamp(1.2rem, 3vw, 2.6rem) 1.8rem;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
grid-template-areas: 'head' 'play' 'look' 'ledger' 'about'; grid-template-areas: 'head' 'play' 'look' 'ledger';
gap: 1.2rem 2.2rem; gap: 1.2rem 2.2rem;
} }
@media (min-width: 901px) { @media (min-width: 901px) {
.lid { .lid {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas: 'head head' 'play look' 'ledger ledger' 'about about'; grid-template-areas: 'head head' 'play look' 'ledger ledger';
} }
} }
@@ -539,19 +556,7 @@
min-width: 0; min-width: 0;
} }
/* about */ /* about: the panel's own words; About.svelte is the dialog around them */
.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 { .about-body {
max-width: 44em; max-width: 44em;
color: var(--bone-dim); color: var(--bone-dim);