From 3825dfb44c20fb03674c4f8ac9468bb46afcc7e6 Mon Sep 17 00:00:00 2001 From: Eric Wagoner Date: Wed, 23 Sep 2026 18:54:22 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Jm2auWk6RP71CjaAb4FMoG --- CONVENTIONS.md | 7 ++ template/src/lib/components/Hall.svelte | 23 ++++- .../src/lib/components/Preferences.svelte | 97 +++++++++++++++++++ template/src/lib/game/spec.ts | 2 + template/src/lib/prefs.svelte.ts | 65 +++++++++++++ template/src/routes/join/[code]/+page.svelte | 4 + 6 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 template/src/lib/components/Preferences.svelte create mode 100644 template/src/lib/prefs.svelte.ts diff --git a/CONVENTIONS.md b/CONVENTIONS.md index 050f709..c531fe3 100644 --- a/CONVENTIONS.md +++ b/CONVENTIONS.md @@ -64,6 +64,13 @@ README. 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. +- **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 (tables, games begun and finished, names seated, turns, time at the table, games with a bot, talk), counted on demand from the files and diff --git a/template/src/lib/components/Hall.svelte b/template/src/lib/components/Hall.svelte index 5a3264a..7cc082e 100644 --- a/template/src/lib/components/Hall.svelte +++ b/template/src/lib/components/Hall.svelte @@ -4,6 +4,8 @@ // about panel with the rules, the guide and a way to reach the keeper. import { goto } from '$app/navigation'; 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 { api, NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte'; import { unreadTalk } from '$lib/net/talk'; @@ -14,7 +16,14 @@ let name = $state(playerName()); let code = $state(''); /** The host's choices for the next table, from the game's declared options. */ - let options = $state>(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>( + 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 error = $state(''); let seats = $state(allSeats()); @@ -157,7 +166,8 @@

__NAME__

One line that says what the game is.

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.

- + +
@@ -625,4 +635,13 @@ .tally dd { margin: 0; } + .link-like { + background: none; + border: 0; + padding: 0; + font: inherit; + color: inherit; + text-decoration: underline; + cursor: pointer; + } diff --git a/template/src/lib/components/Preferences.svelte b/template/src/lib/components/Preferences.svelte new file mode 100644 index 0000000..952d0ae --- /dev/null +++ b/template/src/lib/components/Preferences.svelte @@ -0,0 +1,97 @@ + + + + +{#if open} + + +{/if} + + diff --git a/template/src/lib/game/spec.ts b/template/src/lib/game/spec.ts index dd5c4fe..a7636b6 100644 --- a/template/src/lib/game/spec.ts +++ b/template/src/lib/game/spec.ts @@ -39,6 +39,8 @@ export interface GameSpec { seatIds: SeatId[]; /** What the host may choose when opening a table; empty for a game with one way to play. */ 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; /** Names the server draws for bots, in preference order. */ botNames: string[]; diff --git a/template/src/lib/prefs.svelte.ts b/template/src/lib/prefs.svelte.ts new file mode 100644 index 0000000..e8b50b5 --- /dev/null +++ b/template/src/lib/prefs.svelte.ts @@ -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 { + return Object.fromEntries(PREFERENCES.map((p) => [p.key, p.default])); +} + +function load(): Record { + const base = defaults(); + try { + const saved = JSON.parse(localStorage.getItem(KEY) ?? '{}') as Record; + 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., 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>(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; +} diff --git a/template/src/routes/join/[code]/+page.svelte b/template/src/routes/join/[code]/+page.svelte index c304c9e..892cad9 100644 --- a/template/src/routes/join/[code]/+page.svelte +++ b/template/src/routes/join/[code]/+page.svelte @@ -4,6 +4,7 @@ import Board from '$lib/components/Board.svelte'; import Lobby from '$lib/components/Lobby.svelte'; import ReportSlip from '$lib/components/ReportSlip.svelte'; + import Preferences from '$lib/components/Preferences.svelte'; import { seatFor } from '$lib/net/client'; import { NAME_MAX, playerName, rememberName, Room } from '$lib/net/room.svelte'; @@ -16,6 +17,7 @@ let error = $state(''); let copied = $state(false); let reporting = $state(false); + let prefsOpen = $state(false); /** A phrase minted to carry this seat to another device, while it lasts. */ let phrase = $state<{ phrase: string; expiresAt: number } | null>(null); let rematching = $state(false); @@ -120,6 +122,7 @@ {/if} {/if} + How to play Rules {#if room} @@ -129,6 +132,7 @@ + {#if room} {/if}