# Viewer API

For TypeScript types, validation, and reusable integration helpers, see the [protocol library](./protocol-library.md).

BGS loads your viewer in an iframe and calls `window[name].launch(selector)` to get
its event emitter. [`registerViewer`](#registerviewer) creates this entry point for
you. You supply the UI and handlers for the events below.

For bundle hosting, see [Adding a game](./adding-a-game.md).

Use plain data for event payloads. The iframe bridge uses `postMessage`; functions,
DOM nodes, and reactive proxies cannot be cloned. Although structured cloning can
copy `Map` and `Set`, BGS payloads should use JSON-compatible objects and arrays.
For a Svelte 5 `$state` value, send `$state.snapshot(value)`.

[[toc]]

## Viewer registration

### registerViewer

```ts
import { registerViewer } from "@boardgamers/protocol/viewer";

registerViewer<State, Move>(name, mount, {
	stateSchema,
	moveSchema,
	onInvalid: ({ event, message }) => console.warn(event, message),
});
```

The optional third argument validates game-specific payloads. `name` must be an
unused JavaScript global identifier, such as `tictactoe`; configure that name in BGS.
The library registers the global itself. If your bundler requires an IIFE name
(Vite's `build.lib.name`), use a different one, such as `tictactoeBundle`.

`mount({ target, move, openPlayer, ... })` runs on each launch. It receives the
selected DOM container and the commands below. Return `onState` and whichever
optional callbacks your UI needs. No emitter is exposed to the mount callback.

Create components and chat controllers inside `mount` so each launch gets fresh
instances. Mounting is synchronous; `onState` and `onLog` may await rendering.
Failures in `onState`, `onUpdate`, or `onLog` call `onError` (or log the error);
later callbacks can still run.

The return value of `registerViewer` is only needed for local tests or explicit cleanup:

| Method                       | Behavior                                                                                                    |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `launch(selector)`           | Replace the previous UI and return the transport emitter. BGS calls this; a local host can too.             |
| `diagnostics(capabilities?)` | Report the current instance's handler coverage. Before launch or after disposal, no handlers are installed. |
| `destroy()`                  | Dispose the instance; safe to repeat. The registration remains available for another launch.                |

The library sends `ready` after the first successful state render. By default it
requests full state on `state:updated` and `gamelog`; `onUpdate` and `onLog`
respectively replace those defaults. On disposal it removes listeners, cancels timers,
destroys chat, calls your `destroy` hook, and empties the mount container.
See the [short example](./protocol-library.md#launch-and-lifecycle) or
[complete tic-tac-toe viewer](./tictactoe.md#viewer).

### Commands

Destructure these from the mount callback's argument. They are also methods on the
object returned by `createViewer`.

| Command                                  | Purpose                                                        |
| ---------------------------------------- | -------------------------------------------------------------- |
| `move(payload)`                          | Submit your game's typed move, such as `move({ x: 1, y: 2 })`. |
| `openPlayer(index)`                      | Open the profile for a zero-based player seat.                 |
| `updatePreference(name, value)`          | Save a preference; values are strings, booleans, or `null`.    |
| `fetchState()`                           | Request the full state. Updates trigger this by default.       |
| `fetchLog({ start, end? })`              | Request a log range, inclusive of both endpoints.              |
| `addLog(lines)` / `replaceLog(lines)`    | Append or replace string entries in BGS's journal.             |
| `setReplayInfo({ start, current, end })` | Update BGS's replay controls.                                  |

Commands return `false` for invalid payloads or after disposal. `true` means the
payload passed local validation, **not** that BGS accepted the action. The engine
still validates moves. Use `ChatController` for chat sends and read reports;
readiness is handled internally.

### Callbacks

Return these from `mount`, or pass them as options to `createViewer`:

| Callback                                                  | Purpose                                                                    |
| --------------------------------------------------------- | -------------------------------------------------------------------------- |
| `onState(state)`                                          | **Required.** Render the game state; may return a promise.                 |
| `onUpdate()`                                              | Request an update, replacing the default `fetchState()`.                   |
| `onPlayer({ index })`                                     | Store the viewer's seat; `undefined` means spectator.                      |
| `onPreferences(preferences)`                              | Apply game preferences, including sound when declared.                     |
| `onAvatars(urls)`                                         | Apply player avatars in seat order.                                        |
| `onTheme({ dark })`                                       | Apply theme changes.                                                       |
| `onLog({ start, end?, data })`                            | Apply a log slice; may return a promise. Replaces the default state fetch. |
| `onReplayStart()` / `onReplayTo(index)` / `onReplayEnd()` | Implement replay, and call `setReplayInfo` as the position changes.        |
| `onError(error)`                                          | Handle a failed state, update, or log callback.                            |

Only `onState` is required to construct the helper. Interactive games should also
handle `onPlayer`; the core coverage check flags missing player handling. Omit
unused callbacks instead of adding empty ones.
Return a `chat` controller to connect chat, and `destroy()` to unmount your framework.

### createViewer and ViewerEmitter

`createViewer({ onState, onPlayer?, chat?, ... })` supplies the same commands,
callbacks, validation, state refreshes, and readiness handling without registering
a global or managing DOM. It returns the commands plus `diagnostics`, `destroy`,
and an `emitter` for connecting BGS or a local test host.

Use `new ViewerEmitter<State, Move>(validation?)` only when you need to own the
transport and lifecycle yourself. Incremental logs work with the helpers above. It provides typed,
validated events without automatic handlers. `on` and `once` return cleanup
functions. `destroy` permanently disconnects it; later events return `false`.
`attachChat(emitter, chat)` installs chat handlers and returns cleanup that also
destroys the controller.

### Incremental logs

BGS sends an initial state, update notifications, and log slices (including the
result of your own move). Return `onUpdate` and `onLog` to apply slices instead of
fetching the full state after each change. The same callbacks work with
`registerViewer` and `createViewer`.

This example expects `state.log` and `logSlice` to contain arrays of strings:

```ts
import { registerViewer } from "@boardgamers/protocol/viewer";

type LogState = { log: string[] };

registerViewer<LogState, never>("logDemo", ({ target, fetchLog }) => {
	let entries: string[] = [];
	const render = () => {
		target.textContent = entries.join("\n");
	};

	return {
		onState(state) {
			entries = [...state.log];
			render();
		},
		onUpdate() {
			fetchLog({ start: entries.length });
		},
		onLog({ start, data }) {
			if (!Array.isArray(data) || !data.every((entry) => typeof entry === "string")) {
				throw new Error("Invalid log slice");
			}
			if (start > entries.length) {
				fetchLog({ start: entries.length });
				return;
			}
			entries.splice(start, data.length, ...data);
			render();
		},
	};
});
```

`onState`, `onUpdate`, and `onLog` run in arrival order, awaiting each callback.
Issue requests from `onUpdate` and handle their replies in `onLog`; do not wait
inside one callback for a later queued callback to run.

The viewer owns the cursor because `logSlice` can return any structure. Merge by
`start` to handle overlapping slices, and request missing entries when there is a
gap. If you also need fresh private state or other data absent from the log, call
`fetchState()` explicitly. Without `onUpdate` or `onLog`, that event keeps its
default full-state refresh.

### Event validation

The emitter checks event payloads before delivery. Unknown event names return
`false`; invalid known events call `onInvalid` (or `console.warn`) and return
`false`. The library validates game-specific state and moves only when you supply
`stateSchema` and `moveSchema`. Each validator implements `parse(value: unknown)`.

Custom adapters can use `validateEvent(name, payload)` or `downlinkSchemas` and
`uplinkSchemas` from `@boardgamers/protocol`. `validateEvent` returns parsed data or
throws. These describe emitter payloads, not the bridge's raw window messages.
The game engine remains responsible for authorization-sensitive game rules.

### Handler coverage

Use `registration.diagnostics()` after launch, or `viewer.diagnostics()` with
`createViewer`. For a custom emitter, use `inspectViewer(emitter, capabilities?)`.

| Group       | Checked handlers                                                                                               |
| ----------- | -------------------------------------------------------------------------------------------------------------- |
| Core        | `state`, `state:updated`, `player`                                                                             |
| Chat        | `chat:messages`, `chat:appended`, `chat:updated`, `chat:deleted`, `chat:state`, `chat:disabled`, `chat:result` |
| Replay      | `replay:start`, `replay:to`, `replay:end`                                                                      |
| Recommended | `preferences`, `avatars`                                                                                       |

Theme handling appears separately under `optional`; a single-theme viewer need
not implement it.

Each group has `completed`, `total`, `percent`, and `missing` fields. `compatible`
means all checked required groups are complete. A connected chat controller enables
the chat checks; any replay callback enables all three replay checks. Pass an
explicit array, such as `["chat", "replay"]`, to choose the capabilities to check.
`inspectViewer` checks only core unless capabilities are supplied.

Only installed handlers count, including those implemented by the library. An
omitted callback does not install a placeholder listener. Coverage measures wiring,
not UI behavior or rule correctness; optional features need no empty handlers.

## Downlink

This section describes the underlying BGS events for custom adapters. With
`registerViewer` or `createViewer`, use the [callbacks](#callbacks) above; chat
events are connected through `ChatController`. These events flow from BGS to the viewer:

```ts
emitter.on("state", (state) => {
	// Render the new state
});
```

### state

```ts
emitter.on("state", (state: GameData) => {
	// ...
});
```

Receive game data filtered by [stripSecret](./engine-api.md#stripsecret) for this player or spectator.

Replace the previous viewer state with this snapshot.

### state:updated

```ts
emitter.on("state:updated", () => {
	// ...
});
```

Notification that new state is available.

You can request the full state by emitting [fetchState](#fetchstate) or request the new log elements by emitting [fetchLog](#fetchlog).

### gamelog

```ts
emitter.on("gamelog", (logData: { start: number; end?: number; data: unknown }) => {
	//...
});
```

Receive log data.

`data` is the return value of the backend's [logSlice](./engine-api.md#logslice).

### preferences

```ts
emitter.on("preferences", (preferences: Record<string, unknown>) => {
	//...
});
```

Get the user's specific UI preferences for this game.

For example, a game may expose a preference for flat building graphics.

#### devMode

On top of the game's own preferences, the platform injects a `devMode: true` key when the user has developer
settings enabled on their device. When developer settings are off, the key is entirely absent — check
`preferences.devMode === true`.

### player

```ts
emitter.on("player", (playerInfo: { index?: number }) => {
	// ...
});
```

Receive the connected player's zero-based seat index. For spectators, `index` is
`undefined`; start with no selected seat until this event arrives.

### Sound (in-game audio)

If your game has a `sound` entry in its preferences, the site reserves that key: `preferences.sound` in the
[preferences](#preferences) message is the user's **global, site-wide** in-game sound setting, re-sent live
whenever it changes. Your viewer must honor it: mute when it is `false`. Games without a `sound` preference get
no sound control on the site.

A viewer-side sound toggle emits `update:preference {name: "sound", value}` as usual; the site routes it to the
global setting (broadcast back to all the user's games). There is no per-game override.

### avatars

```ts
emitter.on("avatars", (avatars: string[]) => {
	// ...
});
```

Receive the avatars of each player, in order.

### replay:start

```ts
emitter.on("replay:start", () => {
	// ...
});
```

Start replay mode. Only for compatible viewers.

When entering replay mode, you should emit the [replay:info](#replay-info) event with the necessary info.

### replay:to

```ts
emitter.on("replay:to", (logIndex: number) => {
	// ...
});
```

Replay up to that point in the log.

### replay:end

```ts
emitter.on("replay:end", () => {
	//...
});
```

Leave replay mode

### theme

```ts
emitter.on("theme", ({ dark }: { dark: boolean }) => {
	// ...
});
```

Receive the site's current color theme. It is sent when the game is ready and every time the theme changes
afterwards, including when the OS theme flips while the user's setting is "system".

The theme also arrives as a raw `postMessage` (`{ type: "theme", dark: boolean }`) before any viewer script runs —
that's how the wrapper page toggles its own `dark` class without waiting for `launch`. The emitter event is the
preferred API; only fall back to a `window` `message` listener if you must apply the theme before `launch` returns.

Theme support is optional. If your viewer switches themes, apply changes live
without reloading the iframe.

See [Dark mode](#dark-mode).

### chat:messages

```ts
emitter.on("chat:messages", (messages: ViewerChatMessage[]) => {
	// ...
});
```

**Only sent to viewers that declare the chat capability** (`viewer.chat: true` in the game metadata — see
[Chat](#chat)). Hands the viewer the **full recent chat history** as a replace: render this list wholesale. It is
sent on mount and re-sent on every iframe remount (e.g. when the user toggles the alternate UI), so always treat it
as the complete current history, not an append.

`ViewerChatMessage` contains a plain-text fallback and structured rich segments:

```ts
type ViewerChatMessage = {
	_id?: string; // message id, for reconciling chat:updated / chat:deleted
	author?: string; // author display name; absent on system messages
	authorId?: string; // author's stable user id — match players/colors on THIS, never on the name
	playerIndex?: number; // author's seat index, set when the author is a player in this game
	createdAt?: string; // ISO date — when the message was posted
	text: string; // plain-text fallback
	segments: (
		| { kind: "text"; text: string }
		| { kind: "link"; text: string; url: string }
		| { kind: "mention"; name: string; id: string }
	)[];
	type: "text" | "system";
	editedAt?: string; // ISO date, set when the message was edited
};
```

Use `playerIndex` to apply board seat colors and `authorId` for stable identity.
System messages may omit both. The library also accepts messages without `segments`;
render `text` as the fallback.

### chat:appended

```ts
emitter.on("chat:appended", (messages: ViewerChatMessage[]) => {
	// ...
});
```

New messages to append to the list. The sender's own message arrives back through this event too (the platform
rebroadcasts every accepted post), so don't add an optimistic echo on send.

### chat:updated

```ts
emitter.on("chat:updated", (messages: ViewerChatMessage[]) => {
	// ...
});
```

Edited messages. Replace each in place by `_id` (ignore updates for messages you don't have).

### chat:deleted

```ts
emitter.on("chat:deleted", (ids: string[]) => {
	// ...
});
```

Moderation deletes. Drop these message ids from the list.

### chat:disabled

```ts
emitter.on("chat:disabled", (disabled: boolean) => {
	// ...
});
```

The room-wide chat kill switch (moderation). When `true`, gate your chat input — the api rejects posts to a
disabled room regardless, this is only so you can show the chat as read-only. Note it's only re-pushed when the
flag changes (or on mount), not streamed continuously.

### chat:state

```ts
emitter.on(
	"chat:state",
	(state: { canSend: boolean; reason?: string; mentions: { id: string; name: string; playerIndex?: number }[] }) => {
		// ...
	},
);
```

The viewer's write-permission state, pushed on mount and whenever it changes (login, kill switch).
Use it to hide/disable your composer — the api stays the authority on writes (a refused [chat:send](#chat-send)
still gets its [chat:result](#chat-result) either way), this only reflects what the api would accept. `reason` is
a machine-stable code, present when `canSend` is `false`: `"not-logged-in"` | `"not-confirmed"` |
`"chat-disabled"` | `"not-a-player"` | `"no-game"`. Localize its display on your side.

### chat:result

```ts
emitter.on("chat:result", (result: { requestId: string; ok: boolean; error?: string }) => {
	// ...
});
```

The acknowledgement for a [chat:send](#chat-send) that carried a `requestId` — one result per
request, by `requestId`. `ok: true`: the message was accepted (it arrives via [chat:appended](#chat-appended)
like any other). `ok: false`: the post was refused (`error` is the api's message — not logged in, rate-limited,
chat disabled, validation) — **keep the draft** and show what happened.

## Uplink

The [commands](#commands) above send these events for you. The raw examples below
are for `ViewerEmitter` integrations. These events flow from the viewer to BGS:

```ts
emitter.emit("fetchState");
```

### ready

```ts
emitter.emit("ready");
```

The game has rendered and can be shown to the player. The app keeps the iframe hidden (with a loading
spinner) until it receives this, then reveals it.

**Emit `ready` only after the first [state](#state) has been received and rendered** — not on mount. The
app sends the initial `state` in response to the shim's `gameReady`, so by the time your viewer has state,
the app's message listener is guaranteed to be attached; a `ready` fired on mount (before any state) can
race ahead of that listener on a hard refresh and be dropped, leaving the spinner up forever.

Emit it on a macrotask (e.g. in a `setTimeout(0)` after applying the first state), **not** inside
`requestAnimationFrame`: the app keeps the iframe hidden until `ready`, and in a hidden/backgrounded iframe
`requestAnimationFrame` may never fire — so the game would never signal ready and stay hidden forever.

### move

```ts
emitter.emit("move", { x: 1, y: 2 });
```

Send a move to the backend.

`move` is passed as is to the backend's [move](./engine-api.md#move) exported method: the emitted payload is relayed
**verbatim** as the backend move's `move` field. Emit the raw move object:

```ts
// ✅ the engine receives move.action === "take"
emitter.emit("move", { action: "take", gems: ["ruby", "onyx"] });

// ❌ double-wrapped: the engine receives move.move.action
emitter.emit("move", { move: { action: "take", gems: ["ruby", "onyx"] } });
```

### player:clicked

```ts
emitter.emit("player:clicked", { index: 0 });
```

Signals that a player's name was clicked, so that the application can go to the player's profile.

Optional: emit it when the player clicks a participant name or avatar.

### fetchState

```ts
emitter.emit("fetchState");
```

Requests the current game state passed to us, in full.

The application will fetch the current game state, and pass it to the viewer with a [state](#state) event.

### fetchLog

```ts
emitter.emit("fetchLog", { start: 0, end: 9 });
```

Requests the log between `start` and `end` included.

The application will fetch the data and will pass it to the viewer with a [gamelog](#gamelog) event.

### addLog

```ts
emitter.emit("addLog", ["Player 1 built a factory."]);
```

Transmits new log elements to the application, to be displayed in the sidebar.

### replaceLog

```ts
emitter.emit("replaceLog", ["Game started."]);
```

Erases current log and transmits new log elements to the application, to be displayed in the sidebar.

### replay:info

```ts
emitter.emit("replay:info", { start: 0, current: 3, end: 9 });
```

Emit it when replay starts and whenever the replay position changes.

Used for the replay controls in the sidebar.

### update:preference

Update a preference from your viewer. Values can be strings, booleans, or `null`.

```ts
emitter.emit("update:preference", { name: "flatBuildings", value: true });
```

The preference is stored per game and re-sent in the [preferences](#preferences) message. One name is reserved:
`sound` writes the user's global site-wide sound setting instead — see [Sound](#sound-in-game-audio).

### chat:send

```ts
emitter.emit("chat:send", { text: "Hello @Alice", requestId: "message-1" });
```

Send plain text to the game's chat. Include a unique `requestId` to receive
[chat:result](#chat-result) and show errors; `ChatController` does this automatically.
After acceptance, the message arrives through [chat:appended](#chat-appended),
including for the sender. Avoid adding a local echo. BGS enforces posting permissions,
rate limits, and the room's disabled state.

### chat:read

```ts
emitter.emit("chat:read", { messageId: "650000000000000000000001" });
```

Report the latest message the user actually saw, using its `_id`. BGS marks the
room read through that message and clears corresponding mention notifications.
Send only after checking chat visibility and browser-tab visibility. Invalid or
unknown IDs are ignored.

## Chat

Tick **Chat** in the admin options for each viewer that renders chat. BGS uses its
own chat in the lobby; once the game starts, chat moves into that viewer and stays
there after the game ends. Messages use the same room throughout.

A custom integration handles `chat:messages`, `chat:appended`, `chat:updated`,
`chat:deleted`, `chat:state`, `chat:disabled`, and `chat:result`. `ChatController`
and `registerViewer` wire these handlers for you.

Render rich segments as text, links, and mentions. Never inject messages as HTML
or re-linkify the plain fallback; BGS supplies links according to the author's
permissions. Use the roster in `chat:state` for mention suggestions. Send mentions
as `@username` or `@"Full Name"`; BGS resolves recipients and excludes self-notifications.

There are no viewer uplinks for editing messages or adding reactions. Viewers
receive edit and deletion notifications so their displayed history stays current.

## ChatController reference

`ChatController` from `@boardgamers/protocol/chat` manages the events above. Return
it as `chat` from your registration callback, pass it to `createViewer`, or use
`attachChat(emitter, chat)` with a custom emitter. See the [panel and custom UI examples](./protocol-library.md#chat-state-and-rendering).

| Method                        | Purpose                                                                                                |
| ----------------------------- | ------------------------------------------------------------------------------------------------------ |
| `subscribe(listener)`         | Immediately supplies the current snapshot, then each update; returns an unsubscribe function.          |
| `setDraft(text)` / `submit()` | Update the draft / send it if permitted, nonempty, and no request is pending.                          |
| `setOpen(open)`               | Track panel visibility; does not mark messages read.                                                   |
| `markRead(messageId)`         | Clear local unread IDs through this message and report it to BGS. Call only after checking visibility. |
| `suggestions(query)`          | Filter the latest mention roster, excluding your seat.                                                 |
| `destroy()`                   | Remove subscriptions and cancel timers. `viewer.destroy()` calls this for its attached controller.     |

Treat `snapshot` as read-only. Its fields are:

| Fields                          | Meaning                                                                               |
| ------------------------------- | ------------------------------------------------------------------------------------- |
| `messages`, `unreadIds`         | Current messages and IDs of locally unread messages. `chat.unread` returns the count. |
| `enabled`, `open`               | Chat has been supplied by BGS / the UI panel is open.                                 |
| `canSend`, `disabled`, `reason` | Write permission, room kill switch, and a [permission code](#chat-state) to localize. |
| `draft`, `pending`, `error`     | Composer text, pending request ID (empty when idle), and send error.                  |
| `mentions`, `playerIndex`       | Mention roster and the viewer's seat, if any.                                         |

**History and unread:** initial history does not increase the unread count.
Replacements retain known unread IDs still present; new appends add IDs except for your own
messages. Duplicate IDs, edits, and deletions are reconciled in place. History and
panel opening never mark anything read on BGS.

**Read reports:** `mountChat` and `bindChatViewport` handle scrolling and visibility
checks, including clipping by the embedding page; iframe focus is not required.
With your own implementation, check visibility before calling `markRead`. `markRead` accepts
an ID present in history, batches reports for 500 ms, and never moves the reported
watermark backwards during the controller's lifetime.

**Send results:** `submit()` trims text and assigns a request ID. A matching
`chat:result` settles the request; success clears the submitted draft only if it
has not since changed. Refusals and timeouts preserve it. Stale acknowledgements
cannot settle a newer send. Accepted messages arrive through `chat:appended`.

Override timing when constructing the controller, for example in tests:

```ts
import { ChatController } from "@boardgamers/protocol/chat";

const chat = new ChatController({
	readDelayMs: 500,
	sendTimeoutMs: 20_000,
});
```

## Optional chat UI

Import these helpers from `@boardgamers/protocol/chat/dom`. All bindings expose
`destroy()` for unmounting; the controller remains owned by the viewer.

| Helper                             | Purpose                                                                                                          |
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `mountChat(container, options)`    | Mount a complete panel. Returns `element`, `open()`, `refresh()` and `destroy()`.                                |
| `bindChatComposer(input, options)` | Bind draft, permissions, keyboard and mention handling to a text input. Returns `choose(index)` and `destroy()`. |
| `bindChatViewport(list, options)`  | Follow messages and report visible reads. Returns `open()`, `refresh()`, `bounds()` and `destroy()`.             |

All require `options.chat`. Composer options require `onSuggestions`, receiving
`{ candidates, selected }`; `maxSuggestions` defaults to 6. Mark message rows with
`data-message-id` and pass their wrapper as `contents` to the viewport binding.

Date dividers use local calendar days. Override `labels.date(date)` to change their
text, or use `chatDateSeparators(messages, locales?)` from `/chat` in your own UI.
It returns `{ dateTime, label }` at the first dated message and each day change,
and `undefined` elsewhere.

Panel options include `openPlayer`, `renderAuthor`, `labels`, `styles`, `viewport`
and `onVisibilityChange`. Viewport bindings accept `contents`, `isVisible`,
`viewport` and `onVisibilityChange`. `viewport()` returns any of `top`, `right`,
`bottom`, `left` in CSS pixels within the game window; it can narrow the browser's
visible area to exclude overlays. `refresh()` rechecks layout after external changes.

The panel's `open()` reveals chat and scrolls its document to it. The lower-level
viewport's `open()` only positions its message list. Neither scrolls the parent
BGS page. Your UI must select its chat tab before calling `open()`.

See [examples and styling](./protocol-library.md#chat-state-and-rendering).

## Dark mode

Dark-mode support is entirely optional. A single, readable theme is fine;
there is no general recommendation to maintain both light and dark versions.

The site supports a light/dark theme, switchable by the user or following the OS ("system"). The wrapper page does
two things for dark mode:

- It toggles a `dark` class on `<html>` — read from the `?dark=1` URL parameter for the initial paint (before any
  viewer script runs, so no flash of light theme), then kept in sync via the [theme](#theme) message.
- It sets a dark page **background** (`html.dark`) so there's no white flash while your viewer loads. That rule
  lives in a cascade layer (`@layer bgs-fallback`), and unlayered styles always beat layered ones — so if your
  viewer styles its own background, it wins, no extra specificity or `!important` needed.

If you choose to follow the site theme, read `?dark=1` (or the wrapper's
`<html class="dark">`) for the first paint, then use `onTheme` for live changes.
Style your components accordingly; a viewer with a single theme can ignore this.

### settings / update:setting

The host sends `settings` with the current player's per-game engine settings, or `null` when unavailable.
Use `createViewer({ onSettings(settings) { … }, onState(state) { … } })` to receive updates and
`viewer.updateSetting("autoCharge", "3")` to request a change. The host validates the declared setting,
merges it with the player's other settings, saves through the gameplay settings API, and sends back the
canonical settings. Sidebar changes use the same settings. These are game settings, separate from
presentation preferences sent through `update:preference`. Only active-game players can edit them.
