diff --git a/docs/guide/architecture.md b/docs/guide/architecture.md
index 1203eb9..7f350bf 100644
--- a/docs/guide/architecture.md
+++ b/docs/guide/architecture.md
@@ -1,14 +1,104 @@
# Architecture
-::: warning Migrating
-This page is being migrated from the [README architecture section](https://github.com/CodeThicket/tabmesh#architecture). The diagrams and full explainer arrive in the next docs PR.
-:::
+TabMesh is hub-and-spoke. Every tab is a spoke; the hub holds the backend transport and the durable outbox. The hub has two implementations — a `SharedWorker` (primary) and an elected leader tab (fallback) — both behind the same `Hub` interface.
-For now, the short version:
+## Primary mode — SharedWorker
-- **Primary mode** uses a `SharedWorker` shared across all tabs of the same origin. The worker holds the transport and the IndexedDB outbox. Tabs talk to it over `MessagePort`.
-- **Fallback mode** elects a leader tab via Web Locks API → BroadcastChannel heartbeat → IndexedDB heartbeat. Used when SharedWorker isn't available (some mobile browsers).
-- **Outbox** is IndexedDB-backed with TTL, priority ordering, and an in-memory degraded fallback.
-- **Service Worker** (optional) takes over outbox draining after the last tab closes, via Background Sync.
+```
+┌──────────┐ ┌──────────┐ ┌──────────┐
+│ Tab A │ │ Tab B │ │ Tab C │
+└────┬─────┘ └────┬─────┘ └────┬─────┘
+ │ MessagePort │ MessagePort │ MessagePort
+ └────────┬───────┴────────────────┘
+ ▼
+ ┌─────────────┐
+ │ SharedWorker│ ← single point of fan-out + outbox
+ └──────┬──────┘
+ │ WebSocket
+ ▼
+ ┌──────────┐
+ │ backend │
+ └──────────┘
+```
-→ See [ADR-0001](/adr/0001-write-through-outbox) and [ADR-0002](/adr/0002-sharedworker-primary-hub) for the design rationale.
+A single `SharedWorker` instance is shared across every tab of the same origin. It:
+
+- Holds the one and only `WebSocket` to the backend.
+- Owns the IndexedDB-backed event outbox.
+- Maintains a registry of connected tab ports.
+- Relays events between tabs via `MessagePort`.
+
+Browser support: Chrome, Edge, Firefox, Safari, iOS Safari 16+.
+
+Why it's the primary path: the browser guarantees one worker instance per `name`, which means leader election, split-brain, and the interregnum problem are eliminated by construction. See [ADR-0002](/adr/0002-sharedworker-primary-hub).
+
+## Fallback mode — elected leader
+
+When `SharedWorker` is unavailable (Chrome Android, Samsung Internet, older browsers), one tab is elected the leader. The leader plays the role the worker would have:
+
+- Holds the WebSocket.
+- Drains the outbox.
+- Broadcasts events to followers via `BroadcastChannel`.
+
+Election uses a layered strategy, picked once at startup based on browser capabilities:
+
+1. **Web Locks API** — sub-50ms failover. Lock is released when the leader tab closes; the next tab waiting on the lock takes over.
+2. **BroadcastChannel heartbeat** — leader broadcasts every 500ms; followers declare candidacy after 1.5s of silence. Failover in ~1–2s.
+3. **IndexedDB heartbeat** — leader writes a timestamp every 2s; tabs race to claim leadership if the timestamp is stale for more than 5s. Failover in ~2–5s.
+
+Split-brain (two tabs both believing they're leader) is resolved by a monotonic `term` number plus `tabId` as a tiebreaker. The lower-priority leader demotes itself and closes its transport.
+
+## The outbox
+
+Every outbound event passes through the IndexedDB outbox before delivery. The flow differs by mode but the contract is identical:
+
+- **Primary**: tab posts to the worker → worker writes to IndexedDB → worker drains to transport + fans out to tabs.
+- **Fallback**: tab writes to IndexedDB directly (write-through) → notifies the leader via `BroadcastChannel` → leader drains.
+
+The outbox supports:
+
+- **TTL** — events past their `createdAt + ttl` are dropped at the next drain.
+- **Priority** — `readPending()` returns events ordered by priority desc, then `createdAt` asc.
+- **Bounded size** — default 1000 events; eviction prefers oldest delivered, then oldest pending.
+- **Degraded mode** — when IndexedDB is unavailable (private browsing on some browsers), an in-memory queue takes over with the same API. The mesh emits a `storage.degraded` system event and logs a warning.
+
+The write-through pattern is non-obvious; see [ADR-0001](/adr/0001-write-through-outbox) for why we picked it over optimistic send.
+
+## Service Worker handoff (optional)
+
+When all tabs close, the `SharedWorker` dies and pending events sit in IndexedDB. The optional Service Worker integration takes over via Background Sync:
+
+1. While tabs are alive, the SW is registered but idle.
+2. When the last tab closes, the browser fires a Background Sync event some time later.
+3. The SW reads pending entries from IndexedDB and POSTs them to a configured `deliveryUrl`.
+4. Successfully delivered entries are removed from the outbox.
+
+This is best-effort and browser-dependent. It requires `serviceWorker.deliveryUrl` to be set — without it, the SW leaves entries in the outbox for the next Hub session (see the [SW handoff gotcha](./gotchas#service-worker-handoff-requires-deliveryurl)).
+
+## Inbound event flow
+
+```
+backend → WebSocket → Hub → all connected tabs
+```
+
+The Hub does **not** write inbound events to the outbox — persistence is the backend's responsibility. There's no replay buffer for late-joining tabs. New tabs start with a clean slate and fetch state from your backend if they need it.
+
+The Hub deduplicates server echoes of locally-sent events (the SharedWorker remembers `id`s it forwarded, and drops bounce-backs). See [ADR-0003](/adr/0003-distribute-prebuilt-worker-bundles) for context on related bundling concerns.
+
+## Identity
+
+- **Tab ID**: 8 hex chars from `crypto.getRandomValues()`, stored in `sessionStorage` so it survives reloads.
+- **Event ID**: `{tabId}-{monotonicCounter}`. Cheap to generate, unique within a channel, sortable per-tab. Generated by the sending tab at `mesh.send()` time.
+- **Channel name**: scopes the SharedWorker instance, the IndexedDB database, the BroadcastChannel, and the `sessionStorage` keys. Two meshes on the same origin with different `channelName` are independent.
+
+## Boundary — what TabMesh is and isn't
+
+- **It is**: a coordination layer for the same-origin multi-tab problem. Hub-and-spoke. Browser-side.
+- **It isn't**: cross-origin (browser platform constraint), peer-to-peer (we tried, hub wins), or a state management library (it moves events; you decide how to react).
+
+## Further reading
+
+- [`CONTEXT.md`](https://github.com/CodeThicket/tabmesh/blob/main/CONTEXT.md) — domain language, glossary, and design notes
+- [ADR-0001](/adr/0001-write-through-outbox) — why write-through and not optimistic
+- [ADR-0002](/adr/0002-sharedworker-primary-hub) — why SharedWorker is the primary path
+- [ADR-0003](/adr/0003-distribute-prebuilt-worker-bundles) — why we ship pre-built worker bundles
diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md
index 4073c90..bc4b384 100644
--- a/docs/guide/getting-started.md
+++ b/docs/guide/getting-started.md
@@ -1,15 +1,159 @@
# Getting Started
-::: warning Migrating
-This page is being migrated from the [project README](https://github.com/CodeThicket/tabmesh#readme). The full quickstart, install instructions, and first-event walkthrough land in the next docs PR.
+This walkthrough takes you from `npm install` to events flowing across two tabs in about five minutes. It assumes you're using Vite — adjust paths for Webpack, Next, or Turbopack as noted.
+
+## 1. Install
+
+```bash
+pnpm install @tabmesh/core @tabmesh/transport-websocket
+# or: npm install / yarn add
+```
+
+If you're using React, also install the hooks package:
+
+```bash
+pnpm install @tabmesh/react
+```
+
+## 2. Deploy the SharedWorker bundle
+
+This is the step most easily missed. TabMesh's `SharedWorker` script needs to be served from your app's origin at a stable URL — it's a separate file, not bundled into your JavaScript.
+
+`@tabmesh/core` ships the pre-built bundle inside its `dist/`. Copy it into your app's static-asset directory:
+
+::: code-group
+
+```bash [Vite / generic SPA]
+cp node_modules/@tabmesh/core/dist/tabmesh-worker.js public/
+```
+
+```bash [Next.js]
+cp node_modules/@tabmesh/core/dist/tabmesh-worker.js public/
+# Files in public/ are served at the URL root, so this becomes /tabmesh-worker.js
+```
+
+```bash [Webpack with copy-webpack-plugin]
+# webpack.config.js
+new CopyPlugin({
+ patterns: [
+ { from: 'node_modules/@tabmesh/core/dist/tabmesh-worker.js', to: '.' },
+ ],
+}),
+```
+
+:::
+
+::: tip Automate it
+Add the copy step to your `build` and `dev` scripts so you never forget. Whenever you upgrade `@tabmesh/core`, the new bundle is copied automatically.
:::
-For now: see the [README quickstart](https://github.com/CodeThicket/tabmesh#quick-start).
+The default `workerUrl` is `/tabmesh-worker.js`. If you serve it elsewhere (e.g. behind a CDN at `/_static/tabmesh-worker.js`), pass `workerUrl: '/_static/tabmesh-worker.js'` to the constructor.
+
+## 3. Construct and start the mesh
+
+```ts
+import { TabMesh } from '@tabmesh/core';
+import { WebSocketTransport } from '@tabmesh/transport-websocket';
+
+const mesh = new TabMesh({
+ channelName: 'my-app',
+ transport: new WebSocketTransport({ url: 'wss://api.example.com/events' }),
+ // Strongly recommended in production. See the gotcha on SharedWorker
+ // name caching for why.
+ workerVersion: process.env.GIT_SHA,
+});
+
+await mesh.start();
+```
+
+`channelName` is the only required field. It scopes the SharedWorker name, the IndexedDB database, and the BroadcastChannel — pick something unique to your app, and incorporate session identity if you serve multiple tenants on the same origin (e.g. `'my-app:tenant-42'`).
+
+`mesh.start()` is async because it has to handshake with the SharedWorker. It resolves successfully even if the transport fails to connect — the mesh keeps retrying in the background and you can subscribe to `transport.*` events to react.
+
+## 4. Send and receive events
+
+```ts
+// Subscribe — this handler fires for events from this tab AND from other tabs.
+mesh.on('chat.message', (event) => {
+ console.log(event.payload, 'source:', event.source);
+});
+
+// Send — reaches the backend AND every other tab on the same origin.
+await mesh.send({
+ type: 'chat.message',
+ payload: { text: 'Hello' },
+});
+```
+
+The handler's `event.source` is `'local'` when the event came from this tab and `'remote'` when it came from another tab or the backend. You can filter:
+
+```ts
+mesh.on('chat.message', (event) => {
+ if (event.source === 'remote') {
+ showNotification(`New message: ${event.payload.text}`);
+ }
+});
+```
+
+## 5. Open two tabs
+
+Open your app in two browser tabs at the same origin. Send an event from one — the other receives it as `source: 'remote'`. Inspect the network tab: there's only **one** WebSocket, regardless of how many tabs are open. That's a `SharedWorker` doing its job.
+
+## React quickstart
+
+If you're using `@tabmesh/react`:
+
+```tsx
+import { TabMeshProvider, useTabMesh, useTabMeshEvent } from '@tabmesh/react';
+import { mesh } from './mesh'; // your TabMesh instance
+
+function App() {
+ return (
+
+
+
+ );
+}
+
+function Chat() {
+ const { status, send } = useTabMesh();
+
+ useTabMeshEvent('chat.message', (event) => {
+ // handle incoming
+ });
+
+ return (
+
+ );
+}
+```
+
+The provider is optional — you can also pass the `mesh` instance directly as the first argument to each hook.
+
+→ [Full React guide](./react)
+
+## Troubleshooting
+
+**"Failed to construct 'SharedWorker'" / 404 on `/tabmesh-worker.js`**
+You skipped step 2 or the file isn't being served. Check the network tab for the request to `/tabmesh-worker.js` and verify the response is a 200 with a JavaScript MIME type.
+
+**"Transport is reconnecting" forever**
+Check `wss://` URL is correct and reachable. The mesh logs each reconnect attempt as a `transport.reconnecting` system event — subscribe with `mesh.on('*', console.log)` to watch.
+
+**Events from other tabs aren't appearing**
+Verify both tabs are on the **same origin** (protocol + host + port must match). `https://app.example.com:443` and `https://app.example.com` are the same. `https://www.example.com` and `https://example.com` are not.
+
+**Status panel says "Transport: disconnected" even though sends work**
+This was a real bug fixed in PR #7. If you're seeing it on a current version, please [open an issue](https://github.com/CodeThicket/tabmesh/issues/new).
-## What's here next
+## What's next
-- Install commands for `@tabmesh/core` + `@tabmesh/transport-websocket` (+ optional `@tabmesh/react`).
-- The "copy the worker bundle to `public/`" step (per [ADR-0003](/adr/0003-distribute-prebuilt-worker-bundles)).
-- Minimal example: send and receive an event across two tabs.
-- React hooks variant.
-- First time troubleshooting.
+→ [Architecture](./architecture) — how the SharedWorker, outbox, and elected-leader fallback fit together
+→ [Gotchas](./gotchas) — sharp edges to know about before adopting in production (read this!)
+→ [Configuration reference](/reference/config) — every field, every default
+→ [Try the playground](/playground) — interactive multi-tab demo
diff --git a/docs/guide/gotchas.md b/docs/guide/gotchas.md
index 7870b42..17f1d39 100644
--- a/docs/guide/gotchas.md
+++ b/docs/guide/gotchas.md
@@ -1,15 +1,140 @@
# Gotchas
-::: warning Migrating
-The full gotchas list — the most important page for adopters — is being moved here from the [README gotchas section](https://github.com/CodeThicket/tabmesh#gotchas). Read the README until this page is filled in.
-:::
+A small library this protocol-heavy has sharp edges. Read this page before adopting in production — most adoption pain comes from one of these six items.
-The shortlist, until this page expands:
+## SharedWorker name caching → set `workerVersion` per deploy
-- **SharedWorker name caching** → set `workerVersion` per deploy.
-- **`delivered` ≠ "the backend processed it"** → wait for explicit ACK if you need it; `ackMode: 'server'` is on the [roadmap](/roadmap).
-- **No replay buffer for late-joining tabs** — by design.
-- **In-browser only**, same-origin only.
-- **Service Worker handoff requires `deliveryUrl`** — without it, the SW leaves pending events in the outbox for the next Hub session.
+Browsers cache `SharedWorker` instances by **name**, not by script content. Without a per-deploy version suffix, an updated `tabmesh-worker.js` doesn't reach users until every client tab closes **AND** the browser garbage-collects the idle worker — which can take many minutes. New tabs in the same browser session in the meantime keep using the **old** worker.
-→ Read the [README gotchas](https://github.com/CodeThicket/tabmesh#gotchas) for the explanations until this page fills out.
+This bit hard during development when shipping bug fixes (see [PR #11](https://github.com/CodeThicket/tabmesh/pull/11) for the full discovery).
+
+**Fix**: include your build identifier in `workerVersion`:
+
+```ts
+new TabMesh({
+ channelName: 'my-app',
+ workerVersion: process.env.GIT_SHA,
+ // also accepts: package.json version, release tag, build timestamp, ...
+});
+```
+
+What happens with `workerVersion` set:
+
+- Each deploy spawns a fresh `SharedWorker` instance for new tabs.
+- Old tabs keep talking to the old worker until they reload — natural migration, no surprise behaviour.
+- The same `channelName` can coexist across versions during the rollout window.
+
+What happens without it: the bug fix you shipped is invisible for an unbounded amount of time.
+
+→ Implementation detail: the worker name becomes `tabmesh:{channelName}:{workerVersion}`. Without the version, it's just `tabmesh:{channelName}`.
+
+## `delivered` ≠ "the backend processed it"
+
+Today the outbox marks an event `delivered` once `Transport.send()` returns successfully — i.e. the bytes left the browser. It does **not** wait for a backend acknowledgement.
+
+If you need at-least-once delivery semantics, gate on an explicit ack message in your protocol layer:
+
+```ts
+mesh.send({ type: 'order.create', payload: order });
+
+// Wait for the backend to confirm. Your backend should echo an event
+// with the same id once the work is durable.
+await new Promise((resolve, reject) => {
+ const off = mesh.on('order.created', (event) => {
+ if (event.source === 'remote' && event.meta.eventId === order.id) {
+ off();
+ resolve(event);
+ }
+ });
+ setTimeout(() => { off(); reject(new Error('timeout')); }, 10_000);
+});
+```
+
+A built-in `ackMode: 'server'` option is on the [roadmap](/roadmap) — when it lands, events stay `pending` in the outbox until the backend sends an explicit ack message. Until then, the protocol-level pattern above is the documented approach.
+
+## No replay buffer for late-joining tabs
+
+A tab that opens after another tab broadcast an event will **not** receive that event. There is no historical log. New tabs start with a clean slate.
+
+This is by design ([`CONTEXT.md → Inbound Event Flow`](https://github.com/CodeThicket/tabmesh/blob/main/CONTEXT.md#events)). The rationale is that maintaining a replay buffer cross-tab is a tarpit — TTLs, ordering, deduplication, memory bounds — and your backend already has authoritative state for anything important.
+
+**Pattern**: when a new tab connects, fetch the current state from your backend. Subscribe to ongoing events for incremental updates.
+
+```ts
+async function bootstrap() {
+ const initialState = await fetch('/api/state').then(r => r.json());
+ applyState(initialState);
+
+ mesh.on('order.updated', (event) => applyDelta(event.payload));
+}
+```
+
+If you genuinely need an opt-in replay buffer (e.g. for chat history within an active session), it's on the [Considering tier of the roadmap](/roadmap) — open an issue with your use case.
+
+## In-browser only
+
+TabMesh is browser-side, same-origin only. **Not** for:
+
+- **Node.js** or **Bun** runtimes — no `SharedWorker`, no `BroadcastChannel` semantics that match.
+- **React Native** — same reason.
+- **Cross-origin** coordination — `https://app.example.com` and `https://admin.example.com` cannot share a `SharedWorker`. This is a browser platform constraint, not a TabMesh limitation.
+- **Cross-browser-profile** or **cross-incognito-window** coordination — same constraint.
+
+If your tabs span subdomains, the standard workaround is to host your app on a single subdomain (e.g. `app.example.com`) and route within it. If you really need cross-origin event coordination, you need a backend pubsub channel — TabMesh isn't the right tool.
+
+## Mobile fallback paths get less coverage
+
+The SharedWorker primary path is exercised by both unit tests and the [Playwright harness](https://github.com/CodeThicket/tabmesh/blob/main/e2e/multi-tab.spec.ts). The elected-leader fallback path gets:
+
+- Unit tests for leader election (Web Locks, BroadcastChannel heartbeat, IndexedDB heartbeat strategies)
+- One Playwright e2e test for failover ([PR #15](https://github.com/CodeThicket/tabmesh/pull/15))
+- Split-brain resolution is unit-tested but not end-to-end
+
+Mobile carriers and OS power management can throttle `BroadcastChannel` and Web Locks in ways that are hard to reproduce in CI:
+
+- Background tabs may have their timers coalesced or paused.
+- iOS Safari may suspend `BroadcastChannel` in low-power mode.
+- Chrome on Android can put inactive workers to sleep aggressively.
+
+**If you're shipping a mobile-heavy product**, exercise the fallback path on real devices before depending on TabMesh for anything critical. Specifically: test what happens when the leader tab is backgrounded for >30 seconds, and verify failover to a foreground tab.
+
+## Service Worker handoff requires `deliveryUrl`
+
+The Service Worker can drain pending events from IndexedDB **after all tabs close**, but it has nowhere to send them by default. You must configure `serviceWorker.deliveryUrl` to an HTTP endpoint that accepts JSON event POSTs:
+
+```ts
+new TabMesh({
+ channelName: 'my-app',
+ serviceWorker: {
+ enabled: true,
+ deliveryUrl: '/api/events',
+ },
+});
+```
+
+Without `deliveryUrl`, the SW leaves pending entries in the outbox for the next Hub session to drain. This was previously a silent data-loss bug — the SW would mark events as delivered without sending them anywhere (fixed in [PR #10](https://github.com/CodeThicket/tabmesh/pull/10)).
+
+The backend endpoint receives events as JSON `POST` with the shape:
+
+```json
+{
+ "type": "order.create",
+ "payload": { "...": "..." },
+ "id": "abc123-1",
+ "sourceTabId": "abc123"
+}
+```
+
+Return `200` to mark the event delivered. Non-200 (or network failure) leaves it pending for the next sync. The browser schedules Background Sync at its own discretion — there's no API to force it from the page side.
+
+## Bonus: drain happens once per tab, even on reconnect
+
+This isn't a gotcha you'll likely hit, but worth knowing for debugging: when the WebSocket drops and reconnects, the worker doesn't re-fan-out previously-distributed events to your tab. It only retries the WS forward. So if your tab's UI dropped an event during a transient disconnect (e.g. the user navigated away from the page), reloading is the recovery path — TabMesh won't replay it.
+
+Same reasoning as "no replay buffer for late-joining tabs": cross-session reliable delivery is your backend's job.
+
+## What's next
+
+→ [Architecture](./architecture)
+→ [Roadmap](/roadmap)
+→ [Open an issue](https://github.com/CodeThicket/tabmesh/issues/new) if you hit a gotcha not listed here
diff --git a/docs/guide/react.md b/docs/guide/react.md
index eb10553..51ea6ca 100644
--- a/docs/guide/react.md
+++ b/docs/guide/react.md
@@ -1,13 +1,194 @@
# React
-::: warning Migrating
-React-specific quickstart, hooks reference, and provider example arrive in the next docs PR.
+`@tabmesh/react` ships three pieces:
+
+- `` — context provider for the mesh instance
+- `useTabMesh()` — returns `{ status, send }` and re-renders when status changes
+- `useTabMeshEvent(type, handler)` — subscribe to an event type (or `'*'` for all)
+
+The package is small (~40 lines) and stays close to the underlying `mesh.on()` / `mesh.getStatus()` API.
+
+## Setup
+
+```bash
+pnpm install @tabmesh/core @tabmesh/transport-websocket @tabmesh/react
+```
+
+Create the mesh in its own module so it's a singleton:
+
+```ts
+// src/mesh.ts
+import { TabMesh } from '@tabmesh/core';
+import { WebSocketTransport } from '@tabmesh/transport-websocket';
+
+export const mesh = new TabMesh({
+ channelName: 'my-app',
+ transport: new WebSocketTransport({ url: 'wss://api.example.com' }),
+ workerVersion: import.meta.env.VITE_GIT_SHA,
+});
+
+await mesh.start();
+```
+
+Wrap your app in ``:
+
+```tsx
+// src/main.tsx
+import { createRoot } from 'react-dom/client';
+import { TabMeshProvider } from '@tabmesh/react';
+import { mesh } from './mesh';
+import { App } from './App';
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+);
+```
+
+::: tip Provider is optional
+If you'd rather not use context, every hook accepts the `mesh` instance as its first argument:
+
+```tsx
+const { status, send } = useTabMesh(mesh);
+useTabMeshEvent(mesh, 'chat.message', handler);
+```
+
+Use the provider when most components need the same mesh; pass explicitly for one-off cases or testing.
:::
-`@tabmesh/react` ships:
+## `useTabMesh`
+
+Returns the current status and a stable `send` function. Re-renders when status changes (hub-connected, transport-state, role, degraded, etc.).
+
+```tsx
+import { useTabMesh } from '@tabmesh/react';
+
+function StatusBar() {
+ const { status, send } = useTabMesh();
+
+ return (
+
+ );
+}
+```
+
+The returned `send` reference is stable across renders — safe to use in `useEffect` dependency arrays.
+
+The full `status` shape:
+
+```ts
+{
+ started: boolean;
+ hubMode: 'shared-worker' | 'elected-leader' | 'degraded' | null;
+ hubConnected: boolean;
+ role: 'hub' | 'follower' | null; // 'hub' = elected leader in fallback mode
+ transportState: 'connected' | 'disconnected' | 'reconnecting';
+ tabId: string;
+ degraded: boolean;
+ leaderTabId?: string | null; // only set in elected-leader mode
+ term?: number; // election term, only in fallback mode
+}
+```
+
+## `useTabMeshEvent`
+
+Subscribes to an event type. The handler fires for every matching event from any tab (including this one — filter on `event.source` if you want remote-only).
+
+```tsx
+import { useTabMeshEvent } from '@tabmesh/react';
+
+function Inbox() {
+ const [messages, setMessages] = useState([]);
+
+ useTabMeshEvent('chat.message', (event) => {
+ setMessages((prev) => [...prev, event.payload]);
+ });
+
+ return
{messages.map(...)}
;
+}
+```
+
+The handler is automatically unsubscribed on unmount. The latest handler ref is always used, so closures over fresh state work without re-subscribing:
+
+```tsx
+function Counter() {
+ const [count, setCount] = useState(0);
+
+ // Closure captures fresh `count` every render — no stale-closure bug.
+ useTabMeshEvent('ping', () => {
+ console.log(`Got ping, current count is ${count}`);
+ });
+
+ return ;
+}
+```
+
+### Wildcard subscription
+
+Pass `'*'` to receive every event including the [system events](/reference/system-events):
+
+```tsx
+useTabMeshEvent('*', (event) => {
+ if (event.type.startsWith('transport.')) {
+ console.log('Transport state change:', event.type, event.payload);
+ }
+});
+```
+
+The playground's activity feed is built on this — it shows the live event stream as a debug surface.
+
+## Patterns
+
+### Showing connection status
+
+```tsx
+function ConnectionBadge() {
+ const { status } = useTabMesh();
+
+ if (!status.started) return Offline;
+ if (status.transportState === 'connected') return Live;
+ if (status.transportState === 'reconnecting') return Reconnecting…;
+ return Disconnected;
+}
+```
+
+### Reacting to remote events only
+
+```tsx
+useTabMeshEvent('todo.completed', (event) => {
+ if (event.source === 'local') return; // already handled in the local UI
+ showToast(`Another tab completed: ${event.payload.title}`);
+});
+```
+
+### Logout flow
+
+The recommended sequence is documented in [Recipes → Auth & logout](/recipes/auth-and-logout). In a React hook:
+
+```tsx
+function useLogout() {
+ return useCallback(async () => {
+ await mesh.clearOutbox();
+ await mesh.disconnectTransport();
+ mesh.broadcast({ type: 'auth.logout', payload: {} });
+ await mesh.stop();
+ window.location.href = '/login';
+ }, []);
+}
+```
-- `` — context provider for the mesh instance.
-- `useTabMesh()` — returns `{ status, send }`.
-- `useTabMeshEvent(type, handler)` — subscribe to an event type (or `'*'` for all).
+## What's next
-Until this page fills out, see the [README React snippet](https://github.com/CodeThicket/tabmesh#react) and the [playground source](https://github.com/CodeThicket/tabmesh/tree/main/packages/playground/src) for a working example.
+→ [Configuration reference](/reference/config)
+→ [System events](/reference/system-events)
+→ [Recipes](/recipes/) — common React patterns
diff --git a/docs/reference/config.md b/docs/reference/config.md
index 22e8b07..73c364a 100644
--- a/docs/reference/config.md
+++ b/docs/reference/config.md
@@ -1,19 +1,215 @@
# Configuration
-::: warning Migrating
-Field-by-field configuration table lands in the next docs PR.
-:::
-
-The full type is exported from [`@tabmesh/core` types.ts](https://github.com/CodeThicket/tabmesh/blob/main/packages/core/src/types.ts). Until this page is filled out, the [README configuration table](https://github.com/CodeThicket/tabmesh#configuration) covers the same ground.
-
-Quick reference (defaults in parens):
-
-- `channelName` (required)
-- `transport` (none — transport-less mode is valid)
-- `workerUrl` (`/tabmesh-worker.js`)
-- `workerVersion` (none — strongly recommended in production)
-- `pingMs` (`10000`)
-- `staleTimeoutMs` (`30000`)
-- `persistence.defaultTTL` (24h), `persistence.maxQueueSize` (1000)
-- `reconnect.maxAttempts` (10), `initialDelayMs` (1000), `backoffMultiplier` (2), `maxDelayMs` (30000)
-- `serviceWorker.enabled` (`false`), `serviceWorker.scriptUrl` (`/tabmesh-sw.js`), `serviceWorker.deliveryUrl` (none — required if you want SW handoff to actually deliver)
+`TabMeshConfig` is the single options object passed to `new TabMesh(config)`. All fields are type-safe; the canonical source is [`packages/core/src/types.ts`](https://github.com/CodeThicket/tabmesh/blob/main/packages/core/src/types.ts).
+
+## Required
+
+### `channelName: string`
+
+App-level identifier that scopes the SharedWorker name, the IndexedDB database, the BroadcastChannel, and the `sessionStorage` keys. **Required.**
+
+```ts
+new TabMesh({ channelName: 'my-app' });
+```
+
+Recommended pattern when serving multiple tenants on the same origin:
+
+```ts
+new TabMesh({ channelName: `my-app:${tenantId}:${userId}` });
+```
+
+Multiple meshes with different `channelName` on the same origin are independent — each gets its own SharedWorker instance and IndexedDB database. This costs one WebSocket per channel, so don't sub-divide more than you need to.
+
+## Transport
+
+### `transport?: Transport`
+
+Backend connection adapter. Today this is `@tabmesh/transport-websocket`; SSE and long-poll adapters are on the [roadmap](/roadmap).
+
+```ts
+import { WebSocketTransport } from '@tabmesh/transport-websocket';
+
+new TabMesh({
+ channelName: 'my-app',
+ transport: new WebSocketTransport({ url: 'wss://api.example.com' }),
+});
+```
+
+**Transport-less mode**: omit `transport` entirely. The mesh still works for cross-tab event broadcasting; it just has no backend to forward events to. Useful for local-only coordination patterns.
+
+### `reconnect?: Partial`
+
+Configures the transport reconnection loop. Defaults shown:
+
+```ts
+new TabMesh({
+ channelName: 'my-app',
+ transport: ...,
+ reconnect: {
+ maxAttempts: 10, // retry cap; emits transport.error after this
+ initialDelayMs: 1000, // first reconnect delay
+ backoffMultiplier: 2, // exponential factor
+ maxDelayMs: 30000, // backoff ceiling
+ },
+});
+```
+
+Real-world reconnect sequence with defaults: 1s, 2s, 4s, 8s, 16s, 30s, 30s, … (capped).
+
+## SharedWorker
+
+### `workerUrl?: string` — default `/tabmesh-worker.js`
+
+Where the SharedWorker script is served from. The file must be served from the same origin as the page (browser security requirement).
+
+```ts
+new TabMesh({
+ channelName: 'my-app',
+ workerUrl: '/_static/tabmesh-worker.js', // behind a CDN/cache layer
+});
+```
+
+→ Deploying the worker bundle: see [Getting Started → Deploy the SharedWorker bundle](/guide/getting-started#_2-deploy-the-sharedworker-bundle).
+
+### `workerVersion?: string` — **strongly recommended**
+
+Build-time version string appended to the SharedWorker `name`. Without it, deploy upgrades don't propagate to existing browser sessions.
+
+```ts
+new TabMesh({
+ channelName: 'my-app',
+ workerVersion: process.env.GIT_SHA,
+});
+```
+
+→ Why this matters: see [Gotchas → SharedWorker name caching](/guide/gotchas#sharedworker-name-caching-set-workerversion-per-deploy).
+
+### `pingMs?: number` — default `10000`
+
+Interval (ms) between tab-to-worker keepalive pings. Lower values detect tab freeze faster; higher values reduce wakeups in idle apps.
+
+### `staleTimeoutMs?: number` — default `30000`
+
+How long (ms) the SharedWorker waits before treating a port as stale and evicting it from the registry. The **first** connecting tab's value wins for the lifetime of the worker; subsequent tabs cannot change it.
+
+These two values are tuned together: `pingMs < staleTimeoutMs / 2` is the safe ratio. Lower values are useful for tests but not for production.
+
+## Persistence / outbox
+
+### `persistence?: Partial`
+
+```ts
+new TabMesh({
+ channelName: 'my-app',
+ persistence: {
+ defaultTTL: 86_400_000, // 24h — events past createdAt+ttl are dropped at drain
+ maxQueueSize: 1000, // outbox cap; eviction prefers oldest delivered → oldest pending
+ },
+});
+```
+
+The outbox uses IndexedDB by default. When IndexedDB is unavailable (private browsing on some browsers), it falls back to an in-memory queue and emits a `storage.degraded` system event with a `console.warn`. The API surface is identical in degraded mode but events don't survive tab close.
+
+## Service Worker handoff
+
+### `serviceWorker?: Partial`
+
+Optional Background Sync integration that drains pending events after all tabs close.
+
+```ts
+new TabMesh({
+ channelName: 'my-app',
+ serviceWorker: {
+ enabled: true,
+ scriptUrl: '/tabmesh-sw.js', // default
+ deliveryUrl: '/api/events', // required for handoff to actually deliver
+ },
+});
+```
+
+- `enabled` — default `false`. The SW is registered only when this is `true`.
+- `scriptUrl` — where the Service Worker script is served from. Defaults to `/tabmesh-sw.js`.
+- `deliveryUrl` — HTTP endpoint the SW POSTs pending events to during sync. **Without it, the SW leaves entries in the outbox for the next Hub session.**
+
+→ Full details: [Gotchas → Service Worker handoff requires `deliveryUrl`](/guide/gotchas#service-worker-handoff-requires-deliveryurl) and [Recipes → Service Worker handoff](/recipes/service-worker-handoff).
+
+## Leader election (fallback mode only)
+
+### `leader?: Partial`
+
+```ts
+new TabMesh({
+ channelName: 'my-app',
+ leader: {
+ strategy: 'auto', // 'auto' | 'web-locks' | 'broadcast-heartbeat' | 'indexeddb-heartbeat'
+ },
+});
+```
+
+By default `auto` selects the best available strategy based on browser support. Override only when debugging election behaviour on specific platforms. See [Architecture → Fallback mode](/guide/architecture#fallback-mode-elected-leader) for the strategy details.
+
+## Per-event options (passed to `mesh.send`)
+
+These aren't part of `TabMeshConfig` but worth documenting in one place — they go on each `OutboundEvent`:
+
+### `priority?: number` — default `0`
+
+Higher values drain first. Useful for getting an urgent event out before a backlog of routine ones.
+
+```ts
+await mesh.send({
+ type: 'auth.logout',
+ payload: {},
+ priority: 100,
+});
+```
+
+Priority does **not** affect TTL or delivery guarantees, only drain order.
+
+### `ttl?: number` — milliseconds
+
+Relative to event creation time. Events past `createdAt + ttl` are discarded at the next drain — never delivered to the backend, never re-broadcast to tabs.
+
+```ts
+await mesh.send({
+ type: 'presence.heartbeat',
+ payload: { tabId },
+ ttl: 5_000, // drop if not delivered within 5s
+});
+```
+
+## Full example
+
+```ts
+import { TabMesh } from '@tabmesh/core';
+import { WebSocketTransport } from '@tabmesh/transport-websocket';
+
+const mesh = new TabMesh({
+ // Required
+ channelName: `chat:${tenantId}:${userId}`,
+ transport: new WebSocketTransport({ url: 'wss://chat.example.com' }),
+
+ // Strongly recommended
+ workerVersion: process.env.GIT_SHA,
+
+ // Reconnect tuning
+ reconnect: {
+ maxAttempts: 20,
+ maxDelayMs: 60_000,
+ },
+
+ // Outbox sizing
+ persistence: {
+ defaultTTL: 60 * 60 * 1000, // 1h
+ maxQueueSize: 5000,
+ },
+
+ // Background sync
+ serviceWorker: {
+ enabled: true,
+ deliveryUrl: '/api/events',
+ },
+});
+
+await mesh.start();
+```
diff --git a/docs/reference/system-events.md b/docs/reference/system-events.md
index dc03a19..14b48e7 100644
--- a/docs/reference/system-events.md
+++ b/docs/reference/system-events.md
@@ -1,18 +1,162 @@
# System events
-::: warning Migrating
-Per-event payload shapes and emission rules land in the next docs PR.
-:::
-
-`mesh.on('*', handler)` sees every event including these system ones:
-
-- `hub.connected`
-- `hub.disconnected`
-- `transport.connected`
-- `transport.disconnected`
-- `transport.reconnecting`
-- `transport.error`
-- `event.delivery.failed`
-- `storage.degraded`
-
-Until this page expands, the canonical source is [`packages/core/src/types.ts`](https://github.com/CodeThicket/tabmesh/blob/main/packages/core/src/types.ts) (search for `SystemEventType`).
+TabMesh emits internal lifecycle and runtime events alongside your application events. Subscribe with `mesh.on(type, handler)` for a specific event, or `mesh.on('*', handler)` to receive everything.
+
+System events are differentiated from app events only by their `type` strings — same `TabMeshEvent` shape, same handler API.
+
+## Hub lifecycle
+
+### `hub.connected`
+
+This tab has connected to the Hub (SharedWorker or elected Leader). Emitted once during `mesh.start()`.
+
+```ts
+{
+ type: 'hub.connected',
+ payload: { tabId: string; hubMode: 'shared-worker' | 'elected-leader' | 'degraded' },
+ source: 'local',
+ meta: { ... },
+}
+```
+
+### `hub.disconnected`
+
+This tab has lost contact with the Hub. Emitted when `mesh.stop()` runs, or when the Hub goes away unexpectedly (e.g. SharedWorker terminated by the browser).
+
+```ts
+{
+ type: 'hub.disconnected',
+ payload: { tabId: string },
+ source: 'local',
+ meta: { ... },
+}
+```
+
+## Transport lifecycle
+
+### `transport.connected`
+
+The backend transport (WebSocket today) has opened. For late-joining tabs that arrive after the connection was already open, this event is synthesised on handshake so the status panel reflects reality (see [PR #7](https://github.com/CodeThicket/tabmesh/pull/7)).
+
+```ts
+{
+ type: 'transport.connected',
+ payload: {},
+ source: 'local',
+ meta: { ... },
+}
+```
+
+### `transport.disconnected`
+
+The transport closed. Could be a network drop (retries will kick in) or an explicit `mesh.disconnectTransport()` call (no retries).
+
+```ts
+{
+ type: 'transport.disconnected',
+ payload: { reason?: string },
+ source: 'local',
+ meta: { ... },
+}
+```
+
+When `reason: 'explicit'`, the disconnect was intentional (e.g. logout flow). Reconnects are suppressed.
+
+### `transport.reconnecting`
+
+The transport is retrying. Includes the attempt number and the delay until the next try.
+
+```ts
+{
+ type: 'transport.reconnecting',
+ payload: { attempt: number; delayMs: number },
+ source: 'local',
+ meta: { ... },
+}
+```
+
+### `transport.error`
+
+A transport-level error occurred. Includes a message; payload shape varies by transport adapter.
+
+```ts
+{
+ type: 'transport.error',
+ payload: { message: string; reason?: string; attempts?: number },
+ source: 'local',
+ meta: { ... },
+}
+```
+
+`reason: 'max_retries_exhausted'` is emitted by the elected-leader hub when the reconnect cap is hit (the SharedWorker hub retries forever).
+
+## Delivery failures
+
+### `event.delivery.failed`
+
+The Hub exhausted retries (or failed for a non-retriable reason) for a specific event. Includes the `eventId` so you can correlate with the original send.
+
+```ts
+{
+ type: 'event.delivery.failed',
+ payload: { eventId: string; reason: string },
+ source: 'local',
+ meta: { ... },
+}
+```
+
+Common `reason` values:
+
+- `transport_send_failed` — the transport threw when forwarding the event. The event stays in the outbox for the next drain.
+
+## Storage degraded
+
+### `storage.degraded`
+
+IndexedDB is unavailable; the mesh has fallen back to an in-memory outbox. Events sent during this session won't survive a tab close.
+
+```ts
+{
+ type: 'storage.degraded',
+ payload: { reason: string },
+ source: 'local',
+ meta: { ... },
+}
+```
+
+The mesh also logs a `console.warn` when this fires, and disables Service Worker handoff (no IndexedDB → nothing for the SW to drain).
+
+Common causes: private browsing on Safari, sandboxed iframes, browser storage quota exceeded.
+
+## Wildcard subscription
+
+`mesh.on('*', handler)` receives every event — system and application — in delivery order. Useful for:
+
+- Debug surfaces (the playground's Activity Feed uses this)
+- Logging / telemetry
+- Building DevTools-style introspection
+
+```ts
+mesh.on('*', (event) => {
+ if (event.type.startsWith('transport.')) {
+ metrics.recordTransportEvent(event.type, event.payload);
+ }
+});
+```
+
+## `event.meta`
+
+Every event includes a `meta` object with debug-only fields. Public:
+
+- `meta.eventId: string` — the unique id of the event (matches what `send` returned).
+- `meta.sourceTabId: string` — the tab id that originated the event.
+- `meta.createdAt: number` — timestamp at send time.
+
+Internal (for debugging only, not part of the public API):
+
+- `meta.internalSource: 'port' | 'broadcast' | 'transport'` — which mechanism delivered the event to this tab.
+
+## What's next
+
+→ [TabMesh class reference](./tabmesh-class)
+→ [Types](./types)