Preferences: a panel of what the browser remembers about its player, with the kit's motion and confirm-move settings, a game's own declarations, and the host's last table options as defaults

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG
This commit is contained in:
Eric Wagoner
2026-09-23 18:54:22 -04:00
co-authored by Claude Fable 5.1
parent de430c3794
commit 3825dfb44c
6 changed files with 196 additions and 2 deletions
+7
View File
@@ -64,6 +64,13 @@ README.
where the keeper roosts. It lives in the about panel; the colophons, the where the keeper roosts. It lives in the about panel; the colophons, the
rules page and the guide each carry a one-line mail link. rules page and the guide each carry a one-line mail link.
- **Preferences** are what a browser remembers about its player: a panel
opened from *preferences* in the hall and the masthead, kept in
localStorage and nowhere else. The kit's own settings are motion (full or
reduced, and the system's reduced-motion request counts too) and
confirming a move before it is sent; a game declares more in
`GameSpec.preferences` and the panel renders them. The hall also
remembers the host's last table options as the next table's defaults.
- **The tally** sits in the about panel: what the ledgers add up to - **The tally** sits in the about panel: what the ledgers add up to
(tables, games begun and finished, names seated, turns, time at the (tables, games begun and finished, names seated, turns, time at the
table, games with a bot, talk), counted on demand from the files and table, games with a bot, talk), counted on demand from the files and
+21 -2
View File
@@ -4,6 +4,8 @@
// 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 { otherGames, type FamilyGame } from '$lib/family'; import { otherGames, type FamilyGame } from '$lib/family';
import Preferences from './Preferences.svelte';
import { prefs, setPref } from '$lib/prefs.svelte';
import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat, type Report, type Tally } from '$lib/net/client'; import { allSeats, doubtSeat, forgetSeat, rememberSeat, ServerError, trustSeat, type Report, type Tally } from '$lib/net/client';
import { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte'; import { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
import { unreadTalk } from '$lib/net/talk'; import { unreadTalk } from '$lib/net/talk';
@@ -14,7 +16,14 @@
let name = $state(playerName()); let name = $state(playerName());
let code = $state(''); let code = $state('');
/** The host's choices for the next table, from the game's declared options. */ /** The host's choices for the next table, from the game's declared options. */
let options = $state<Record<string, string>>(Object.fromEntries((game.options ?? []).map((o) => [o.key, o.default]))); // The host's last choices come back as the defaults; a regular does not re-pick them each visit.
let options = $state<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]))
);
$effect(() => {
for (const o of game.options ?? []) if (prefs[`table.${o.key}`] !== options[o.key]) setPref(`table.${o.key}`, options[o.key]);
});
let prefsOpen = $state(false);
let busy = $state(false); let busy = $state(false);
let error = $state(''); let error = $state('');
let seats = $state(allSeats()); let seats = $state(allSeats());
@@ -157,7 +166,8 @@
<h1>__NAME__</h1> <h1>__NAME__</h1>
<p class="pitch">One line that says what the game is.</p> <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="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> <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> · <button type="button" class="link-like" onclick={() => (prefsOpen = true)}>preferences</button></p>
<Preferences bind:open={prefsOpen} />
</div> </div>
<div class="lid-play"> <div class="lid-play">
@@ -625,4 +635,13 @@
.tally dd { .tally dd {
margin: 0; margin: 0;
} }
.link-like {
background: none;
border: 0;
padding: 0;
font: inherit;
color: inherit;
text-decoration: underline;
cursor: pointer;
}
</style> </style>
@@ -0,0 +1,97 @@
<script lang="ts">
// The preferences panel: every declared setting, kit and game, as a
// control. Opened from the masthead; closes with its button or Escape.
import { PREFERENCES, prefs, setPref } from '$lib/prefs.svelte';
let { open = $bindable(false) }: { open?: boolean } = $props();
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') open = false;
}
</script>
<svelte:window onkeydown={onKey} />
{#if open}
<div class="scrim" role="presentation" onclick={() => (open = false)}></div>
<div class="panel" role="dialog" aria-modal="true" aria-labelledby="prefs-title">
<h2 id="prefs-title">Preferences</h2>
<p class="muted small">Kept in this browser, and nowhere else.</p>
{#each PREFERENCES as p (p.key)}
<div class="pref">
{#if p.kind === 'toggle'}
<label class="row">
<input type="checkbox" checked={prefs[p.key] === true} onchange={(e) => setPref(p.key, e.currentTarget.checked)} />
<span>{p.label}</span>
</label>
{:else}
<label class="row">
<span>{p.label}</span>
<select value={prefs[p.key]} onchange={(e) => setPref(p.key, e.currentTarget.value)}>
{#each p.choices ?? [] as c (c.value)}<option value={c.value}>{c.label}</option>{/each}
</select>
</label>
{/if}
{#if p.note}<p class="muted small note">{p.note}</p>{/if}
</div>
{/each}
<button type="button" class="quiet" onclick={() => (open = false)}>Done</button>
</div>
{/if}
<style>
.scrim {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 20;
}
.panel {
position: fixed;
z-index: 21;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: min(92vw, 26rem);
max-height: 90vh;
overflow: auto;
background: var(--slate);
border: 1px solid var(--rule-strong);
border-radius: 8px;
padding: 1.2rem 1.4rem 1.4rem;
}
h2 {
font-size: 1.3rem;
margin: 0 0 0.2rem;
}
.pref {
padding: 0.55rem 0;
border-top: 1px solid var(--rule);
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.8rem;
}
.row input[type='checkbox'] {
order: 2;
}
.note {
margin-top: 0.3rem;
}
.small {
font-size: 0.85rem;
}
.quiet {
margin-top: 0.8rem;
}
</style>
+2
View File
@@ -39,6 +39,8 @@ export interface GameSpec<State, Input> {
seatIds: SeatId[]; seatIds: SeatId[];
/** What the host may choose when opening a table; empty for a game with one way to play. */ /** What the host may choose when opening a table; empty for a game with one way to play. */
options?: TableOption[]; options?: TableOption[];
/** The game's own settings for the preferences panel, kept in the player's browser (see $lib/prefs.svelte). */
preferences?: { key: string; label: string; kind: 'toggle' | 'choice'; choices?: { value: string; label: string }[]; default: boolean | string; note?: string }[];
minSeats: number; minSeats: number;
/** Names the server draws for bots, in preference order. */ /** Names the server draws for bots, in preference order. */
botNames: string[]; botNames: string[];
+65
View File
@@ -0,0 +1,65 @@
// Preferences: what this browser remembers about how its player likes to
// play. The kit's own settings are motion and whether a move is confirmed
// before it is sent; a game declares more in GameSpec.preferences, and the
// panel renders every declaration. Nothing here leaves the browser.
import { game } from '$lib/game';
export interface Preference {
key: string;
label: string;
/** A toggle holds a boolean; a choice holds one of its choices' values. */
kind: 'toggle' | 'choice';
choices?: { value: string; label: string }[];
default: boolean | string;
note?: string;
}
/** The settings every game in the kit shares. */
export const KIT_PREFERENCES: Preference[] = [
{ key: 'motion', label: 'Motion', kind: 'choice', choices: [{ value: 'full', label: 'pieces move and fade' }, { value: 'reduced', label: 'changes appear at once' }], default: 'full' },
{ key: 'confirmMoves', label: 'Confirm a move before it is sent', kind: 'toggle', default: false }
];
export const PREFERENCES: Preference[] = [...KIT_PREFERENCES, ...(game.preferences ?? [])];
const KEY = '__SLUG__:prefs';
function defaults(): Record<string, boolean | string> {
return Object.fromEntries(PREFERENCES.map((p) => [p.key, p.default]));
}
function load(): Record<string, boolean | string> {
const base = defaults();
try {
const saved = JSON.parse(localStorage.getItem(KEY) ?? '{}') as Record<string, unknown>;
for (const p of PREFERENCES) {
const v = saved[p.key];
if (p.kind === 'toggle' && typeof v === 'boolean') base[p.key] = v;
if (p.kind === 'choice' && typeof v === 'string' && p.choices?.some((c) => c.value === v)) base[p.key] = v;
}
// The hall's last table options ride along under table.<key>, undeclared.
for (const [k, v] of Object.entries(saved)) if (k.startsWith('table.') && typeof v === 'string') base[k] = v;
} catch {
// Without storage the defaults apply for this visit.
}
return base;
}
/** The live preferences; read them where they matter, set them through setPref. */
export const prefs = $state<Record<string, boolean | string>>(typeof localStorage === 'undefined' ? defaults() : load());
export function setPref(key: string, value: boolean | string): void {
prefs[key] = value;
try {
localStorage.setItem(KEY, JSON.stringify(prefs));
} catch {
// The change still applies for this visit.
}
}
/** The reduced-motion preference, or the system's, whichever asks for calm. */
export function reducedMotion(): boolean {
if (prefs.motion === 'reduced') return true;
return typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
}
@@ -4,6 +4,7 @@
import Board from '$lib/components/Board.svelte'; import Board from '$lib/components/Board.svelte';
import Lobby from '$lib/components/Lobby.svelte'; import Lobby from '$lib/components/Lobby.svelte';
import ReportSlip from '$lib/components/ReportSlip.svelte'; import ReportSlip from '$lib/components/ReportSlip.svelte';
import Preferences from '$lib/components/Preferences.svelte';
import { seatFor } from '$lib/net/client'; import { seatFor } from '$lib/net/client';
import { NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte'; import { NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte';
@@ -16,6 +17,7 @@
let error = $state(''); let error = $state('');
let copied = $state(false); let copied = $state(false);
let reporting = $state(false); let reporting = $state(false);
let prefsOpen = $state(false);
/** A phrase minted to carry this seat to another device, while it lasts. */ /** A phrase minted to carry this seat to another device, while it lasts. */
let phrase = $state<{ phrase: string; expiresAt: number } | null>(null); let phrase = $state<{ phrase: string; expiresAt: number } | null>(null);
let rematching = $state(false); let rematching = $state(false);
@@ -120,6 +122,7 @@
<button type="button" class="quiet" title="A phrase that brings this seat to another device" onclick={transfer}>Transfer seat</button> <button type="button" class="quiet" title="A phrase that brings this seat to another device" onclick={transfer}>Transfer seat</button>
{/if} {/if}
{/if} {/if}
<button type="button" class="quiet" onclick={() => (prefsOpen = true)}>Preferences</button>
<a class="quiet" href="/guide">How to play</a> <a class="quiet" href="/guide">How to play</a>
<a class="quiet" href={room ? `/rules?room=${roomId}` : '/rules'}>Rules</a> <a class="quiet" href={room ? `/rules?room=${roomId}` : '/rules'}>Rules</a>
{#if room} {#if room}
@@ -129,6 +132,7 @@
</nav> </nav>
</header> </header>
<Preferences bind:open={prefsOpen} />
{#if room} {#if room}
<ReportSlip {room} bind:open={reporting} /> <ReportSlip {room} bind:open={reporting} />
{/if} {/if}