@vielzeug/codex 26.10.0 → 26.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +1 -1
  2. package/data/catalog.json +53 -59
  3. package/data/llms-full.txt +1322 -1069
  4. package/data/llms.txt +6 -6
  5. package/data/manifest.json +1 -1
  6. package/data/packages/arsenal.json +13 -13
  7. package/data/packages/assay.json +4 -4
  8. package/data/packages/clockwork.json +4 -4
  9. package/data/packages/codex.json +3 -3
  10. package/data/packages/coins.json +4 -4
  11. package/data/packages/conduit.json +4 -4
  12. package/data/packages/courier.json +4 -4
  13. package/data/packages/dnd.json +10 -10
  14. package/data/packages/familiar.json +4 -4
  15. package/data/packages/flux.json +4 -4
  16. package/data/packages/focus.json +4 -4
  17. package/data/packages/forge.json +4 -4
  18. package/data/packages/gesture.json +4 -4
  19. package/data/packages/herald.json +12 -12
  20. package/data/packages/illusionist.json +5 -5
  21. package/data/packages/keymap.json +9 -9
  22. package/data/packages/ledger.json +4 -4
  23. package/data/packages/lingua.json +4 -4
  24. package/data/packages/mesh.json +5 -5
  25. package/data/packages/necromancer.json +5 -5
  26. package/data/packages/orbit.json +7 -7
  27. package/data/packages/ore.json +5 -5
  28. package/data/packages/postmaster.json +4 -4
  29. package/data/packages/prism.json +50 -42
  30. package/data/packages/pulse.json +8 -8
  31. package/data/packages/refine.json +3 -3
  32. package/data/packages/ripple.json +4 -4
  33. package/data/packages/rune.json +9 -9
  34. package/data/packages/sandbox.json +4 -4
  35. package/data/packages/scout.json +7 -7
  36. package/data/packages/scroll.json +10 -10
  37. package/data/packages/sentinel.json +4 -4
  38. package/data/packages/sigil.json +6 -6
  39. package/data/packages/sourcerer.json +4 -4
  40. package/data/packages/spell.json +9 -9
  41. package/data/packages/tandem.json +5 -5
  42. package/data/packages/tavern.json +12 -12
  43. package/data/packages/tempo.json +4 -4
  44. package/data/packages/vault.json +13 -13
  45. package/data/packages/ward.json +9 -9
  46. package/data/packages/wayfinder.json +17 -17
  47. package/data/refine.json +259 -87
  48. package/data/search.json +246 -246
  49. package/dist/http.js +2 -2
  50. package/dist/http.js.map +1 -1
  51. package/dist/index.d.ts +1 -1
  52. package/dist/index.js +1 -1
  53. package/dist/refine.d.ts +1 -1
  54. package/dist/refine.js +1 -1
  55. package/dist/server.d.ts +1 -1
  56. package/dist/server.js +1 -1
  57. package/dist/tools/refine.js +1 -1
  58. package/package.json +2 -2
  59. package/skills/vielzeug/SKILL.md +57 -57
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "apiSource": "// Public root surface of @vielzeug/mesh. Every public export goes through this file.\nexport { meshCodec } from './codec';\nexport {\n MeshConnectionError,\n MeshDisposedError,\n MeshError,\n MeshPairingError,\n MeshPayloadError,\n MeshTimeoutError,\n MeshUnsupportedError,\n} from './errors';\nexport { createMeshGuest } from './guest';\nexport { createMeshHost } from './host';\nexport { meshQrCodec } from './qr-codec';\nexport type {\n MeshAnswer,\n MeshChannelOptions,\n MeshEvent,\n MeshGuest,\n MeshGuestOptions,\n MeshHost,\n MeshHostOptions,\n MeshInbound,\n MeshInvitation,\n MeshMessageMap,\n MeshNode,\n MeshOptions,\n MeshPeer,\n MeshPeerInfo,\n MeshPeerStatus,\n MeshProtocol,\n MeshRtcEvent,\n MeshRtcFactory,\n MeshStatus,\n RTCDataChannelLike,\n RTCPeerConnectionLike,\n RTCSessionDescriptionLike,\n Unsubscribe,\n} from './types';\n",
3
3
  "docs": {
4
- "index": "---\ntitle: Mesh — Backendless peer-to-peer sessions\ndescription: \"Backendless peer-to-peer session transport over WebRTC data channels with manual pairing and host-authoritative star topology\"\npackage: mesh\ncategory: webrtc\nkeywords: [webrtc, p2p, peer-to-peer, datachannel, lan, pairing, backendless, signaling-free]\nexports: [createMeshHost, createMeshGuest, meshCodec, MeshProtocol]\nrelated: [pulse, arsenal, herald]\nenvironments: [browser, node]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"mesh\" />\n\n## Why Mesh?\n\nBrowsers cannot discover peers on a LAN, so sharing live state between nearby devices has always meant running a server. Mesh pairs devices out-of-band — copy/paste, `navigator.share`, or QR via `meshQrCodec` — and then moves typed messages directly over a WebRTC data channel with no backend involved.\n\n```ts\n// Before\nconst pc = new RTCPeerConnection();\nconst dc = pc.createDataChannel('sync');\nawait pc.setLocalDescription(await pc.createOffer());\n// …wait for ICE, hand the SDP to the other device, wire expiry, timeouts,\n// message framing, and dedupe yourself — per guest\n\n// After\nconst host = createMeshHost<AppProtocol>();\nconst invitation = await host.createInvitation();\nshareText(meshCodec.encode(invitation)); // copy/paste or navigator.share\nconst peer = await host.acceptAnswer(meshCodec.decode(answerText));\nhost.send(peer.id, 'snapshot', { rev: 1 });\n```\n\n| Feature | Mesh | `pulse` (WebSocket) | Raw `RTCPeerConnection` |\n| --- | --- | --- | --- |\n| Bundle size | <PackageInfo package=\"mesh\" type=\"size\" /> | <PackageInfo package=\"pulse\" type=\"size\" /> | 0 B |\n| Runtime dependencies | `@vielzeug/arsenal` | <ore-icon name=\"check\" size=\"16\"></ore-icon> none | <ore-icon name=\"check\" size=\"16\"></ore-icon> none |\n| Works without a server | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Typed message protocol | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Multi-peer sessions | Star, host-authoritative | Server rooms | Manual per-peer wiring |\n| Pairing safety | Proof + TTL + approval hook | Server auth | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n\n<div class=\"decision-callout\">\n\n**Use Mesh when** a small group of devices on one network must exchange live state and you cannot — or do not want to — run a server.\n\n**Consider `pulse` when** you have a backend anyway, need internet-wide reach, or want server-managed rooms and presence.\n\n</div>\n\n## Limitations\n\nMesh is deliberately small. It does **not** provide:\n\n- Automatic LAN discovery — every pairing is an explicit out-of-band exchange.\n- Guest-to-guest relay — guests only ever talk to the host.\n- Reconnection across page reloads — a reload destroys the `RTCPeerConnection`; the consumer re-pairs and re-associates the stable `peerId`.\n- CRDTs or conflict resolution — payload semantics are yours.\n- TURN — there is no guarantee peers connect off-LAN.\n- Persistence, or any UI.\n\nTwo operational caveats matter in practice: the host page must stay open for the session to live, and client-isolated Wi-Fi, VPNs, or strict firewalls can block even same-LAN pairing.\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/mesh\n```\n\n```sh [npm]\nnpm install @vielzeug/mesh\n```\n\n```sh [yarn]\nyarn add @vielzeug/mesh\n```\n\n:::\n\n## Quick Start\n\nPair one host and one guest, then exchange typed messages. Pairing requires a secure context (`https:` or `localhost`) for `RTCPeerConnection`.\n\n```ts\nimport { createMeshGuest, createMeshHost, meshCodec, type MeshProtocol } from '@vielzeug/mesh';\n\n// The consumer declares both directions of the conversation.\ninterface AppProtocol extends MeshProtocol {\n toHost: { note: string };\n toGuest: { ack: { ok: boolean } };\n}\n\n// Host side\nconst host = createMeshHost<AppProtocol>();\nhost.on('note', (message) => host.send(message.peerId, 'ack', { ok: true }));\n\nconst invitation = await host.createInvitation();\nconst invitationText = meshCodec.encode(invitation); // hand to the guest\n\n// Guest side (other device)\nconst guest = createMeshGuest<AppProtocol>();\nconst answer = await guest.acceptInvitation(meshCodec.decode(invitationText));\nconst answerText = meshCodec.encode(answer); // hand back to the host\n\n// Host accepts the answer; resolves once the channel is open\ntry {\n await host.acceptAnswer(meshCodec.decode(answerText));\n guest.send('note', 'hello over WebRTC');\n} finally {\n host.dispose();\n guest.dispose();\n}\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- **`createMeshHost`** — host-authoritative star sessions; one `RTCPeerConnection` per guest.\n- **`createMeshGuest`** — single-host guest node with its own lifecycle.\n- **`meshCodec`** — compact base64url encoding for pairing payloads; pure and replaceable.\n- **Typed protocol** — declare `toHost`/`toGuest` maps once; `send`, `on`, and `broadcast` stay type-checked.\n- **Manual pairing** — invitation/answer exchange over copy/paste or `navigator.share`; proof of possession, TTL, and an `approvePeer` hook.\n- **`tap` observability** — status transitions, ICE state, byte counts, and rejections without affecting behavior.\n- **`peers` + `kick`** — host-side peer inventory and removal; join/leave arrive as `tap` events carrying the live peer.\n- **Injectable `rtc`** — every WebRTC object comes from a factory, so tests run fully in memory; `@vielzeug/mesh/testing` ships the in-memory fake.\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Pulse](/pulse/) — typed WebSocket sessions when a server is available.\n- [Arsenal](/arsenal/) — the utilities Mesh builds on for ids, randomness, and hashing.\n- [Herald](/herald/) — in-process event bus for wiring mesh messages into your app.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
5
- "api": "---\ntitle: Mesh — API Reference\ndescription: Public API of @vielzeug/mesh.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution | Common gotcha |\n| --- | --- | --- | --- |\n| `createMeshHost` | Host-authoritative session node; pairs one `RTCPeerConnection` per guest | Sync | `createInvitation`/`acceptAnswer` are async; calls after `dispose()` throw `MeshDisposedError` |\n| `createMeshGuest` | Guest node that pairs with one host | Sync | Second `acceptInvitation` throws `MeshPairingError` |\n| `meshCodec` | base64url encode/decode for pairing payloads | Sync | `decode` throws `MeshPairingError` on malformed text or wrong `v` |\n| `meshQrCodec` | deflate-raw + base45 codec for QR-sized payloads | Async | Falls back to plain `meshCodec` output where `CompressionStream` is missing |\n| `MeshProtocol` | Declares the `toHost`/`toGuest` message maps | — | Extend it; the maps themselves stay plain records |\n| `MeshError` | Base class for all mesh errors | — | `instanceof MeshError` catches every mesh-originated error |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/mesh` | Complete public Mesh API |\n\n## Factory Functions\n\n### createMeshHost()\n\n```ts\nfunction createMeshHost<P extends MeshProtocol>(options?: MeshHostOptions): MeshHost<P>;\n```\n\nReturns a host node in status `'idle'`. Creating it never touches WebRTC — the first `createInvitation()` does.\n\n#### Options (`MeshHostOptions`)\n\n| Option | Type | Default | Purpose |\n| --- | --- | --- | --- |\n| `iceServers` | `readonly RTCIceServer[]` | `[]` | Passed to the peer connection; mesh ships no STUN/TURN. |\n| `channel` | `MeshChannelOptions` | `{ ordered: true, label: 'mesh' }` | Data-channel init mapped to `RTCDataChannelInit`. |\n| `maxMessageBytes` | `number` | `65_536` | Serialized message cap, both directions. |\n| `invitationTtlMs` | `number` | `300_000` | Invitation validity window. |\n| `iceGatheringTimeoutMs` | `number` | `5_000` | ICE gathering cap; pairing proceeds with gathered candidates on timeout. |\n| `channelOpenTimeoutMs` | `number` | `60_000` | Channel-open wait in `acceptAnswer` and on the guest — generous to cover the human carry-back of the answer. |\n| `rtc` | `MeshRtcFactory` | `globalThis.RTCPeerConnection` | Injection point for tests and non-browser runtimes. |\n| `clock` | `() => number` | `Date.now` | TTLs and message timestamps. |\n| `random` | `RandomSource` | `crypto.getRandomValues` | Ids and secrets. |\n| `signal` | `AbortSignal` | — | Disposes the node on abort. |\n| `approvePeer` | `(peer: MeshPeerInfo) => boolean \\| Promise<boolean>` | approve | Runs after proof verification; `false` rejects with `MeshPairingError`. |\n\n#### Example\n\n```ts\nimport { createMeshHost, meshCodec } from '@vielzeug/mesh';\n\nconst host = createMeshHost<AppProtocol>({ approvePeer: (peer) => peer.name !== undefined });\nconst invitation = await host.createInvitation();\n```\n\n---\n\n### createMeshGuest()\n\n```ts\nfunction createMeshGuest<P extends MeshProtocol>(options?: MeshGuestOptions): MeshGuest<P>;\n```\n\nReturns a guest node in status `'idle'`. `MeshGuestOptions` is `MeshOptions` — the shared option table above without `approvePeer`.\n\n#### Example\n\n```ts\nimport { createMeshGuest, meshCodec } from '@vielzeug/mesh';\n\nconst guest = createMeshGuest<AppProtocol>();\nconst answer = await guest.acceptInvitation(meshCodec.decode(invitationText), { name: 'Sam' });\n```\n\n## Host Surface\n\n`MeshHost<P>` extends `MeshNode`.\n\n| Member | Signature | Purpose |\n| --- | --- | --- |\n| `createInvitation` | `(meta?: { name?: string }) => Promise<MeshInvitation>` | Single-use invitation for one guest; repeatable — one per guest. `meta.name` becomes this host's peer name on the guest side. |\n| `acceptAnswer` | `(answer: MeshAnswer) => Promise<MeshPeer>` | Verifies proof + approval, then resolves once the channel opens. Rejects `MeshPairingError` (unknown/expired/duplicate session, duplicate peer id, proof mismatch, refusal) or `MeshTimeoutError`. |\n| `peers` | `ReadonlyMap<string, MeshPeer>` | Live peer inventory keyed by peer id. |\n| `send` | `<K extends keyof P['toGuest'] & string>(peerId: string, type: K, payload: P['toGuest'][K]) => void` | Typed unicast; throws `MeshConnectionError` for unknown/unconnected peers, `MeshPayloadError` over the cap. |\n| `broadcast` | `<K extends keyof P['toGuest'] & string>(type: K, payload: P['toGuest'][K], options?: { except?: readonly string[] }) => void` | Sends to every connected peer not in `except`. |\n| `on` | `<K extends keyof P['toHost'] & string>(type: K, handler: (message: MeshInbound<P['toHost'][K]>) => void) => Unsubscribe` | Typed inbound subscription from any guest. |\n| `kick` | `(peerId: string, reason?: string) => void` | Disconnects a peer; the guest observes `'disconnected'`. |\n\n## Guest Surface\n\n`MeshGuest<P>` extends `MeshNode`.\n\n| Member | Signature | Purpose |\n| --- | --- | --- |\n| `acceptInvitation` | `(invitation: MeshInvitation, meta?: { name?: string }) => Promise<MeshAnswer>` | Consumes an invitation; the returned answer goes back to the host out-of-band. Throws `MeshPairingError` on expired/malformed input or while a live host peer exists; after the host peer fails or disconnects, a fresh invitation re-pairs on the same node with listeners intact. |\n| `host` | `MeshPeer \\| null` | The host peer once pairing started; its `id` is the invitation's `sessionId`. |\n| `send` | `<K extends keyof P['toHost'] & string>(type: K, payload: P['toHost'][K]) => void` | Typed send to the host; throws `MeshConnectionError` when unpaired. |\n| `on` | `<K extends keyof P['toGuest'] & string>(type: K, handler: (message: MeshInbound<P['toGuest'][K]>) => void) => Unsubscribe` | Typed inbound subscription from the host. |\n\n## Shared Surface\n\n`MeshNode` members common to host and guest.\n\n| Member | Signature | Purpose |\n| --- | --- | --- |\n| `status` | `MeshStatus` | Current lifecycle state. |\n| `disposed` | `boolean` | Whether the node is permanently disposed. |\n| `disposalSignal` | `AbortSignal` | Aborted by `dispose()`. |\n| `dispose` | `() => void` | Idempotent teardown: closes every connection, aborts `disposalSignal`, emits `'dispose'`, detaches tappers. |\n| `[Symbol.dispose]` | `() => void` | Enables `using` declarations. |\n| `tap` | `(handler: (event: MeshEvent) => void, options?: { signal?: AbortSignal }) => Unsubscribe` | Observability stream; handler errors are swallowed. |\n\n## Codec\n\n```ts\nconst meshCodec: {\n encode(payload: MeshInvitation | MeshAnswer): string;\n decode(text: string): MeshInvitation | MeshAnswer;\n};\n```\n\nCompact JSON → base64url for pairing payloads, kept pure so other encodings can slot in later. `decode` validates shape and `v`, throwing `MeshPairingError` on malformed text, wrong version, or unrecognized payload.\n\n```ts\nconst text = meshCodec.encode(await host.createInvitation());\nconst answer = await guest.acceptInvitation(meshCodec.decode(text));\nawait host.acceptAnswer(meshCodec.decode(meshCodec.encode(answer)));\n```\n\n### `meshQrCodec`\n\n```ts\nconst meshQrCodec: {\n encode(payload: MeshInvitation | MeshAnswer): Promise<string>;\n decode(text: string): Promise<MeshInvitation | MeshAnswer>;\n};\n```\n\nQR-oriented variant: deflate-raw compresses the JSON payload, then base45-encodes it with an `mq2.` prefix. `encode` falls back to plain `meshCodec` output where `CompressionStream` is unavailable, so it never fails on a missing capability. `decode` accepts both `mq2.*` and plain `meshCodec` output, so camera scans and paste fallbacks share one path.\n\n```ts\nconst invitationText = await meshQrCodec.encode(await host.createInvitation()); // \"mq2.…\"\nconst answer = await host.acceptAnswer(await meshQrCodec.decode(scannedText));\n```\n\n`encode` never throws for a missing capability — it falls back to plain `meshCodec` output where `CompressionStream` is unavailable. `decode` throws `MeshPairingError` on corrupt base45/deflate/JSON and `MeshUnsupportedError` only when the text is `mq2.`-compressed but `DecompressionStream` is missing.\n\n## Types\n\n### Protocol\n\n```ts\ninterface MeshProtocol {\n readonly toHost: MeshMessageMap; // messages a guest may send\n readonly toGuest: MeshMessageMap; // messages the host may send\n}\n\ntype MeshMessageMap = Record<string, unknown>;\n```\n\n### Pairing payloads\n\n```ts\ninterface MeshInvitation {\n readonly v: 1;\n readonly sessionId: string; // 128-bit, base64url\n readonly secret: string; // 256-bit, base64url\n readonly expiresAt: number; // epoch ms\n readonly hostName?: string; // host display name for the guest's peer list\n readonly sdp: string; // host offer, candidates inlined\n}\n\ninterface MeshAnswer {\n readonly v: 1;\n readonly sessionId: string;\n readonly proof: string; // HMAC-SHA-256 of the answer SDP keyed by secret\n readonly peer: { readonly id: string; readonly name?: string };\n readonly sdp: string;\n}\n```\n\n### Peers, status, messages\n\n```ts\ntype MeshStatus = 'idle' | 'pairing' | 'connecting' | 'connected' | 'disconnected' | 'failed' | 'disposed';\n\ntype MeshPeerStatus = 'connecting' | 'connected' | 'disconnected' | 'failed';\n\ninterface MeshPeerInfo {\n readonly id: string;\n readonly name?: string;\n}\n\ninterface MeshPeer extends MeshPeerInfo {\n readonly status: MeshPeerStatus;\n readonly role: 'host' | 'guest';\n}\n\ninterface MeshInbound<T> {\n readonly peerId: string;\n readonly messageId: string;\n readonly sentAt: number;\n readonly payload: T;\n}\n\ntype Unsubscribe = () => void;\n```\n\n### Events\n\n```ts\ntype MeshEvent =\n | { readonly type: 'status-change'; readonly status: MeshStatus }\n | { readonly type: 'peer-status-change'; readonly peerId: string; readonly status: MeshPeerStatus }\n | { readonly type: 'invitation-created' | 'invitation-expired'; readonly sessionId: string }\n | { readonly type: 'peer-approved' | 'peer-rejected'; readonly peerId: string }\n | { readonly type: 'peer-joined'; readonly peer: MeshPeer }\n | { readonly type: 'peer-left'; readonly peer: MeshPeer; readonly reason?: string }\n | { readonly type: 'message-sent' | 'message-received'; readonly peerId: string; readonly messageType: string; readonly bytes: number }\n | { readonly type: 'message-rejected'; readonly peerId: string; readonly reason: 'too-large' | 'duplicate' | 'malformed' | 'unknown-type' }\n | { readonly type: 'ice-state'; readonly peerId: string; readonly state: string }\n | { readonly type: 'error'; readonly error: MeshError }\n | { readonly type: 'dispose' };\n```\n\n`status-change` reports node-level transitions; `peer-status-change` reports one peer's transition and always names a peer id that exists in `peers` (or `guest.host`).\n\n### Options\n\n```ts\ninterface MeshChannelOptions {\n readonly ordered?: boolean;\n readonly maxRetransmits?: number;\n readonly label?: string;\n}\n\ninterface MeshOptions {\n readonly iceServers?: readonly RTCIceServer[];\n readonly channel?: MeshChannelOptions;\n readonly maxMessageBytes?: number;\n readonly invitationTtlMs?: number;\n readonly iceGatheringTimeoutMs?: number;\n readonly rtc?: MeshRtcFactory;\n readonly clock?: () => number;\n readonly random?: RandomSource;\n readonly signal?: AbortSignal;\n}\n\ninterface MeshHostOptions extends MeshOptions {\n readonly approvePeer?: (peer: MeshPeerInfo) => boolean | Promise<boolean>;\n}\n\ntype MeshGuestOptions = MeshOptions;\n```\n\n### WebRTC injection\n\nMinimal structural subsets — the surface mesh actually calls, no more. Implement them over `wrtc` or a test double to run outside a browser.\n\n```ts\ninterface MeshRtcFactory {\n createPeerConnection(config: RTCConfiguration): RTCPeerConnectionLike;\n}\n\ninterface RTCPeerConnectionLike {\n readonly connectionState: string;\n readonly iceConnectionState: string;\n readonly iceGatheringState: string;\n readonly localDescription: RTCSessionDescriptionLike | null;\n createDataChannel(label: string, init?: { ordered?: boolean; maxRetransmits?: number }): RTCDataChannelLike;\n createOffer(): Promise<RTCSessionDescriptionLike>;\n createAnswer(): Promise<RTCSessionDescriptionLike>;\n setLocalDescription(description: RTCSessionDescriptionLike): Promise<void>;\n setRemoteDescription(description: RTCSessionDescriptionLike): Promise<void>;\n close(): void;\n addEventListener(type: 'datachannel', listener: (event: { readonly channel: RTCDataChannelLike }) => void): void;\n addEventListener(type: 'icegatheringstatechange' | 'iceconnectionstatechange' | 'connectionstatechange', listener: () => void): void;\n addEventListener(type: string, listener: (event: MeshRtcEvent) => void): void;\n removeEventListener(type: string, listener: (event: MeshRtcEvent) => void): void;\n}\n\ninterface RTCDataChannelLike {\n readonly readyState: string;\n send(data: string): void;\n close(): void;\n addEventListener(type: 'open' | 'close' | 'error', listener: () => void): void;\n addEventListener(type: 'message', listener: (event: { readonly data: string }) => void): void;\n addEventListener(type: string, listener: (event: MeshRtcEvent) => void): void;\n removeEventListener(type: string, listener: (event: MeshRtcEvent) => void): void;\n}\n\ninterface RTCSessionDescriptionLike {\n readonly type?: 'offer' | 'answer' | 'pranswer' | 'rollback';\n readonly sdp?: string;\n}\n\ninterface MeshRtcEvent {\n readonly data?: unknown;\n readonly channel?: RTCDataChannelLike;\n}\n```\n\n## Errors\n\n| Class | Thrown when | Notable properties |\n| --- | --- | --- |\n| `MeshError` | Base class — never thrown directly | `instanceof MeshError` catches all mesh errors |\n| `MeshPairingError` | Malformed/expired/unknown invitation or answer, proof mismatch, duplicate answer, `approvePeer` refusal, codec `decode` failure | — |\n| `MeshConnectionError` | ICE/DTLS failure, channel closed, send to unknown/unconnected peer, offer/answer creation failure | `peerId: string \\| null` |\n| `MeshPayloadError` | Outbound message over `maxMessageBytes` or unserializable | — |\n| `MeshTimeoutError` | Channel-open wait exceeded | — |\n| `MeshDisposedError` | Any method called after `dispose()` | — |\n| `MeshUnsupportedError` | No `RTCPeerConnection` in the environment and no `rtc` injected (raised at first use, never at import), or `meshQrCodec.decode` given `mq2.` text without `DecompressionStream` | — |\n",
6
- "usage": "---\ntitle: Mesh — Usage Guide\ndescription: How to use @vielzeug/mesh.\n---\n\n[[toc]]\n\n## Basic Usage\n\nDeclare the protocol once, create a host and a guest, and exchange the pairing payloads out-of-band. `RTCPeerConnection` requires a secure context (`https:` or `localhost`).\n\n```ts\nimport { createMeshGuest, createMeshHost, meshCodec, type MeshProtocol } from '@vielzeug/mesh';\n\ninterface AppProtocol extends MeshProtocol {\n toHost: { note: string };\n toGuest: { ack: { ok: boolean } };\n}\n\nconst host = createMeshHost<AppProtocol>();\nconst guest = createMeshGuest<AppProtocol>();\n\n// Host produces an invitation; the guest turns it into an answer.\nconst invitationText = meshCodec.encode(await host.createInvitation());\nconst answerText = meshCodec.encode(await guest.acceptInvitation(meshCodec.decode(invitationText)));\n\n// The host resolves once the data channel is open.\nawait host.acceptAnswer(meshCodec.decode(answerText));\n\nguest.send('note', 'hello');\nhost.on('note', (m) => host.send(m.peerId, 'ack', { ok: true }));\n```\n\n## Pairing Flow\n\nPairing is a two-leg manual exchange. Each leg is a base64url string produced by `meshCodec.encode` and consumed by `meshCodec.decode`.\n\n```ts\n// Host device — one invitation per prospective guest\nconst invitation = await host.createInvitation();\nconst text = meshCodec.encode(invitation);\n```\n\nDeliver `text` any way you can: a textarea the user copies, or `navigator.share` on mobile:\n\n```ts\nif (navigator.share) {\n await navigator.share({ text, title: 'Join my session' });\n}\n```\n\nThe guest decodes it and returns an answer the same way:\n\n```ts\nconst answer = await guest.acceptInvitation(meshCodec.decode(text), { name: 'Sam' });\nconst answerText = meshCodec.encode(answer); // back to the host\nconst peer = await host.acceptAnswer(meshCodec.decode(answerText));\n```\n\nRules that shape the flow:\n\n- An invitation is **single-use** — call `createInvitation()` once per guest.\n- It **expires** at `expiresAt` (default `invitationTtlMs: 300_000`). Expired invitations are refused on both sides and cleaned up on the host.\n- The answer carries a **proof** derived from the invitation's secret, so a swapped or forged answer is rejected before any connection attempt.\n- `approvePeer` runs after proof verification — return `false` to refuse a guest with `MeshPairingError` and a `'peer-rejected'` tap.\n\n```ts\nconst host = createMeshHost<AppProtocol>({\n approvePeer: (peer) => allowlist.has(peer.id),\n});\n```\n\nOn the guest, the host's `peerId` is the invitation's `sessionId` — the invitation carries no other host identity. On the host, a guest's `peerId` is the id the guest generated for its answer.\n\n### Pairing over QR\n\n`meshQrCodec` is an async codec that compresses pairing payloads (deflate-raw + base45, `mq2.` prefix) so they fit a QR code comfortably. `decode` accepts both the compressed `mq2.*` form and plain `meshCodec` output — paste fallback and camera scanning work through one code path.\n\n```ts\nimport { meshQrCodec } from '@vielzeug/mesh';\nimport { encodeQr, toSvg } from '@vielzeug/sigil';\n\n// Async — deflate + base45 through CompressionStream, with a plain-codec\n// fallback where the stream API is missing.\nconst invitationText = await meshQrCodec.encode(await host.createInvitation());\ndocument.querySelector('#qr').innerHTML = toSvg(encodeQr(invitationText));\n\nconst answer = await host.acceptAnswer(await meshQrCodec.decode(scannedAnswerText));\n```\n\n`encode` falls back to plain `meshCodec` output where `CompressionStream` is unavailable, so it never fails on a missing capability — check for the `mq2.` prefix if you need to know which form you got. `decode` accepts both forms interchangeably and throws `MeshUnsupportedError` only when the text is `mq2.`-compressed but `DecompressionStream` is missing. The [sigil pairing recipe](../sigil/examples/pair-two-devices.md) shows the full two-device flow, and `ore-qr-code`/`ore-qr-scanner` (Refine) wrap the rendering and camera sides.\n\n## Sending and Receiving\n\n`send`/`on`/`broadcast` are typed from the protocol. Outbound messages serialize through `JSON.stringify` into a versioned envelope with id, type, timestamp, and payload.\n\n```ts\nhost.send(peerId, 'ack', { ok: true }); // one guest\nhost.broadcast('ack', { ok: true }, { except: [peerId] }); // all but one\nguest.send('note', 'from the guest');\n```\n\nInbound messages arrive as `MeshInbound<T>` — `{ peerId, messageId, sentAt, payload }`. Rules:\n\n- Payloads over `maxMessageBytes` (default 65 536) throw `MeshPayloadError` outbound; oversized inbound frames are dropped and tapped as `'message-rejected'` with reason `'too-large'`.\n- Duplicate message ids (e.g. after a retry) are dropped and tapped as `'duplicate'`.\n- A type with no registered `on` handler is tapped as `'unknown-type'` — never thrown.\n- `send` to an unknown or unconnected peer throws `MeshConnectionError`.\n\n## Peer Lifecycle\n\nNode `status` moves through `idle → pairing → connecting → connected`, then `disconnected`, `failed`, or `disposed`. Watch transitions — and everything else — through `tap`:\n\n```ts\nhost.tap((event) => {\n if (event.type === 'status-change') console.log('node:', event.status);\n if (event.type === 'peer-status-change') console.log('peer:', event.peerId, event.status);\n if (event.type === 'peer-left') console.log('left:', event.peer.id, event.reason);\n});\n```\n\n`peer-joined` and `peer-left` carry the live `MeshPeer`; the inventory itself lives on `peers`:\n\n```ts\nhost.tap((event) => {\n if (event.type === 'peer-joined') console.log('peers now:', [...host.peers.keys()]);\n});\nhost.kick(peerId, 'bye'); // guest observes 'disconnected'\n```\n\n`status-change` reports node-level transitions; `peer-status-change` reports a single peer moving between `'connecting' | 'connected' | 'disconnected' | 'failed'`. A guest whose host peer failed or disconnected can re-pair on the same node — call `acceptInvitation` again with a fresh invitation and existing `on`/`tap` listeners keep working.\n\nDispose nodes deterministically — `dispose()` is idempotent, aborts `disposalSignal`, emits a final `'dispose'` event, and detaches all tappers. Passing an `AbortSignal` option disposes on abort; `using` works too.\n\n## Timeouts and Limits\n\n| Option | Default | Meaning |\n| --- | --- | --- |\n| `invitationTtlMs` | `300_000` | How long an unanswered invitation stays valid. |\n| `iceGatheringTimeoutMs` | `5_000` | Caps ICE gathering — on timeout pairing proceeds with the candidates gathered so far. |\n| `channelOpenTimeoutMs` | `60_000` | How long `acceptAnswer` and the guest wait for the data channel to open — deliberately generous because it covers the human carry-back of the answer. |\n| `maxMessageBytes` | `65_536` | Serialized message cap, enforced in both directions. |\n\n`acceptAnswer` rejects with `MeshTimeoutError` when the channel never opens; the guest independently marks itself `'failed'` and taps an `'error'` event. Inject `clock` to drive TTLs deterministically in tests.\n\n## Security Model\n\nMesh reduces the manual-pairing attack surface but does not remove it:\n\n- Every invitation carries a random 256-bit **secret**; the answer must present a proof derived from it (HMAC-SHA-256 via `SubtleCrypto`). Pairing requires a secure context — without `SubtleCrypto` both ends throw `MeshUnsupportedError` rather than fall back to a forgeable hash.\n- Invitations are **single-use and time-boxed**, so a leaked invite is only useful briefly.\n- `approvePeer` is the host's admission hook — use it for a name check, a shared passphrase confirmed out-of-band, or an allowlist.\n- `maxMessageBytes` caps every inbound frame; malformed frames are dropped, never dispatched.\n- **Roles are app-level.** A guest can send any declared `toHost` type with any payload — validate guest state on the host as if it came from a form. Never trust `peer.name` as an identity; it is self-declared.\n\n## Testing\n\nEvery WebRTC object comes from the injectable `rtc` factory — tests pair nodes fully in memory, with no browser. `@vielzeug/mesh/testing` ships the in-memory fake the package's own tests use: two peer connections wired through token SDP, with knobs for latency, drops, ICE failure, stalled gathering, and a never-opening channel.\n\n```ts\nimport { createFakeRtc } from '@vielzeug/mesh/testing';\n\nconst fx = createFakeRtc();\nconst host = createMeshHost<AppProtocol>({ rtc: fx.rtc, clock: () => now });\nconst guest = createMeshGuest<AppProtocol>({ rtc: fx.rtc });\n```\n\nThe [REPL examples](/repl) include a runnable in-memory pairing you can copy as a starting point.\n\n## Framework Integration\n\nA node is a plain object — own it in a composable/store and dispose on teardown.\n\n::: code-group\n\n```ts [React]\nimport { useEffect, useRef } from 'react';\nimport { createMeshHost, type MeshProtocol } from '@vielzeug/mesh';\n\nexport function useMeshHost<P extends MeshProtocol>() {\n const ref = useRef<ReturnType<typeof createMeshHost<P>>>(null);\n if (!ref.current) ref.current = createMeshHost<P>();\n useEffect(() => () => ref.current?.dispose(), []);\n return ref.current;\n}\n```\n\n```ts [Vue]\nimport { onUnmounted } from 'vue';\nimport { createMeshHost, type MeshProtocol } from '@vielzeug/mesh';\n\nexport function useMeshHost<P extends MeshProtocol>() {\n const host = createMeshHost<P>();\n onUnmounted(() => host.dispose());\n return host;\n}\n```\n\n```ts [Svelte]\nimport { onDestroy } from 'svelte';\nimport { createMeshHost, type MeshProtocol } from '@vielzeug/mesh';\n\nexport function createHost<P extends MeshProtocol>() {\n const host = createMeshHost<P>();\n onDestroy(() => host.dispose());\n return host;\n}\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\nMesh builds on `@vielzeug/arsenal` for random ids, secrets, and base64url encoding — no extra wiring needed. Pair it with `herald` when you want mesh messages to feed an in-process event bus:\n\n```ts\nimport { createBus } from '@vielzeug/herald';\n\nconst bus = createBus<{ 'guest-note': string }>();\nhost.on('note', (m) => bus.emit('guest-note', m.payload));\n```\n\n## Best Practices\n\n- Pair over `meshCodec` strings, never raw SDP — the transport restores the CRLF line endings that copy/paste mangles.\n- Create **one invitation per guest**; never reuse or forward an invitation.\n- Keep `invitationTtlMs` short for physical-proximity pairing and always gate unknown guests through `approvePeer`.\n- Subscribe `on` handlers **before** `acceptAnswer` resolves so early messages are not tapped as `'unknown-type'`.\n- Validate every guest payload on the host — treat `toHost` messages as untrusted input.\n- `dispose()` both nodes on teardown; pass `signal` to bind disposal to a controller you already own.\n- Inject `rtc` and `clock` in tests instead of mocking globals.\n- Plan re-pairing, not reconnection: after a reload, create a fresh invitation and let the app re-associate the stable `peerId`.\n",
7
- "examples": "---\ntitle: Mesh — Examples\ndescription: Practical examples and recipes for @vielzeug/mesh.\n---\n\n## Examples\n\n- [Pair Two Devices](./examples/pair-two-devices.md)\n- [Host-Authoritative Command Loop](./examples/host-authoritative-command-loop.md)\n"
4
+ "index": "---\ntitle: 'Mesh: Backendless peer-to-peer sessions'\ndescription: \"Backendless peer-to-peer session transport over WebRTC data channels with manual pairing and host-authoritative star topology\"\npackage: mesh\ncategory: webrtc\nkeywords: [webrtc, p2p, peer-to-peer, datachannel, lan, pairing, backendless, signaling-free]\nexports: [createMeshHost, createMeshGuest, meshCodec, MeshProtocol]\nrelated: [pulse, arsenal, herald]\nenvironments: [browser, node]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"mesh\" />\n\n## Why Mesh?\n\nBrowsers cannot discover peers on a LAN, so sharing live state between nearby devices has always meant running a server. Mesh pairs devices out-of-band: copy/paste, `navigator.share`, or QR via `meshQrCodec`, and then moves typed messages directly over a WebRTC data channel with no backend involved.\n\n```ts\n// Before\nconst pc = new RTCPeerConnection();\nconst dc = pc.createDataChannel('sync');\nawait pc.setLocalDescription(await pc.createOffer());\n// …wait for ICE, hand the SDP to the other device, wire expiry, timeouts,\n// message framing, and dedupe yourself: per guest\n\n// After\nconst host = createMeshHost<AppProtocol>();\nconst invitation = await host.createInvitation();\nshareText(meshCodec.encode(invitation)); // copy/paste or navigator.share\nconst peer = await host.acceptAnswer(meshCodec.decode(answerText));\nhost.send(peer.id, 'snapshot', { rev: 1 });\n```\n\n| Feature | Mesh | `pulse` (WebSocket) | Raw `RTCPeerConnection` |\n| --- | --- | --- | --- |\n| Bundle size | <PackageInfo package=\"mesh\" type=\"size\" /> | <PackageInfo package=\"pulse\" type=\"size\" /> | 0 B |\n| Runtime dependencies | `@vielzeug/arsenal` | <ore-icon name=\"check\" size=\"16\"></ore-icon> none | <ore-icon name=\"check\" size=\"16\"></ore-icon> none |\n| Works without a server | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Typed message protocol | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Multi-peer sessions | Star, host-authoritative | Server rooms | Manual per-peer wiring |\n| Pairing safety | Proof + TTL + approval hook | Server auth | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n\n<div class=\"decision-callout\">\n\n**Use Mesh when** a small group of devices on one network must exchange live state and you cannot, or do not want to: run a server.\n\n**Consider `pulse` when** you have a backend anyway, need internet-wide reach, or want server-managed rooms and presence.\n\n</div>\n\n## Limitations\n\nMesh is deliberately small. It does **not** provide:\n\n- Automatic LAN discovery: every pairing is an explicit out-of-band exchange.\n- Guest-to-guest relay: guests only ever talk to the host.\n- Reconnection across page reloads: a reload destroys the `RTCPeerConnection`; the consumer re-pairs and re-associates the stable `peerId`.\n- CRDTs or conflict resolution: payload semantics are yours.\n- TURN: there is no guarantee peers connect off-LAN.\n- Persistence, or any UI.\n\nTwo operational caveats matter in practice: the host page must stay open for the session to live, and client-isolated Wi-Fi, VPNs, or strict firewalls can block even same-LAN pairing.\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/mesh\n```\n\n```sh [npm]\nnpm install @vielzeug/mesh\n```\n\n```sh [yarn]\nyarn add @vielzeug/mesh\n```\n\n:::\n\n## Quick Start\n\nPair one host and one guest, then exchange typed messages. Pairing requires a secure context (`https:` or `localhost`) for `RTCPeerConnection`.\n\n```ts\nimport { createMeshGuest, createMeshHost, meshCodec, type MeshProtocol } from '@vielzeug/mesh';\n\n// The consumer declares both directions of the conversation.\ninterface AppProtocol extends MeshProtocol {\n toHost: { note: string };\n toGuest: { ack: { ok: boolean } };\n}\n\n// Host side\nconst host = createMeshHost<AppProtocol>();\nhost.on('note', (message) => host.send(message.peerId, 'ack', { ok: true }));\n\nconst invitation = await host.createInvitation();\nconst invitationText = meshCodec.encode(invitation); // hand to the guest\n\n// Guest side (other device)\nconst guest = createMeshGuest<AppProtocol>();\nconst answer = await guest.acceptInvitation(meshCodec.decode(invitationText));\nconst answerText = meshCodec.encode(answer); // hand back to the host\n\n// Host accepts the answer; resolves once the channel is open\ntry {\n await host.acceptAnswer(meshCodec.decode(answerText));\n guest.send('note', 'hello over WebRTC');\n} finally {\n host.dispose();\n guest.dispose();\n}\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- **`createMeshHost`**: host-authoritative star sessions; one `RTCPeerConnection` per guest.\n- **`createMeshGuest`**: single-host guest node with its own lifecycle.\n- **`meshCodec`**: compact base64url encoding for pairing payloads; pure and replaceable.\n- **Typed protocol**: declare `toHost`/`toGuest` maps once; `send`, `on`, and `broadcast` stay type-checked.\n- **Manual pairing**: invitation/answer exchange over copy/paste or `navigator.share`; proof of possession, TTL, and an `approvePeer` hook.\n- **`tap` observability**: status transitions, ICE state, byte counts, and rejections without affecting behavior.\n- **`peers` + `kick`**: host-side peer inventory and removal; join/leave arrive as `tap` events carrying the live peer.\n- **Injectable `rtc`**: every WebRTC object comes from a factory, so tests run fully in memory; `@vielzeug/mesh/testing` ships the in-memory fake.\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Pulse](/pulse/): typed WebSocket sessions when a server is available.\n- [Arsenal](/arsenal/): the utilities Mesh builds on for ids, randomness, and hashing.\n- [Herald](/herald/): in-process event bus for wiring mesh messages into your app.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
5
+ "api": "---\ntitle: 'Mesh: API Reference'\ndescription: Public API of @vielzeug/mesh.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution | Common gotcha |\n| --- | --- | --- | --- |\n| `createMeshHost` | Host-authoritative session node; pairs one `RTCPeerConnection` per guest | Sync | `createInvitation`/`acceptAnswer` are async; calls after `dispose()` throw `MeshDisposedError` |\n| `createMeshGuest` | Guest node that pairs with one host | Sync | Second `acceptInvitation` throws `MeshPairingError` |\n| `meshCodec` | base64url encode/decode for pairing payloads | Sync | `decode` throws `MeshPairingError` on malformed text or wrong `v` |\n| `meshQrCodec` | deflate-raw + base45 codec for QR-sized payloads | Async | Falls back to plain `meshCodec` output where `CompressionStream` is missing |\n| `MeshProtocol` | Declares the `toHost`/`toGuest` message maps | N/A | Extend it; the maps themselves stay plain records |\n| `MeshError` | Base class for all mesh errors | N/A | `instanceof MeshError` catches every mesh-originated error |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/mesh` | Complete public Mesh API |\n\n## Factory Functions\n\n### createMeshHost()\n\n```ts\nfunction createMeshHost<P extends MeshProtocol>(options?: MeshHostOptions): MeshHost<P>;\n```\n\nReturns a host node in status `'idle'`. Creating it never touches WebRTC: the first `createInvitation()` does.\n\n#### Options (`MeshHostOptions`)\n\n| Option | Type | Default | Purpose |\n| --- | --- | --- | --- |\n| `iceServers` | `readonly RTCIceServer[]` | `[]` | Passed to the peer connection; mesh ships no STUN/TURN. |\n| `channel` | `MeshChannelOptions` | `{ ordered: true, label: 'mesh' }` | Data-channel init mapped to `RTCDataChannelInit`. |\n| `maxMessageBytes` | `number` | `65_536` | Serialized message cap, both directions. |\n| `invitationTtlMs` | `number` | `300_000` | Invitation validity window. |\n| `iceGatheringTimeoutMs` | `number` | `5_000` | ICE gathering cap; pairing proceeds with gathered candidates on timeout. |\n| `channelOpenTimeoutMs` | `number` | `60_000` | Channel-open wait in `acceptAnswer` and on the guest: generous to cover the human carry-back of the answer. |\n| `rtc` | `MeshRtcFactory` | `globalThis.RTCPeerConnection` | Injection point for tests and non-browser runtimes. |\n| `clock` | `() => number` | `Date.now` | TTLs and message timestamps. |\n| `random` | `RandomSource` | `crypto.getRandomValues` | Ids and secrets. |\n| `signal` | `AbortSignal` | N/A | Disposes the node on abort. |\n| `approvePeer` | `(peer: MeshPeerInfo) => boolean \\| Promise<boolean>` | approve | Runs after proof verification; `false` rejects with `MeshPairingError`. |\n\n#### Example\n\n```ts\nimport { createMeshHost, meshCodec } from '@vielzeug/mesh';\n\nconst host = createMeshHost<AppProtocol>({ approvePeer: (peer) => peer.name !== undefined });\nconst invitation = await host.createInvitation();\n```\n\n---\n\n### createMeshGuest()\n\n```ts\nfunction createMeshGuest<P extends MeshProtocol>(options?: MeshGuestOptions): MeshGuest<P>;\n```\n\nReturns a guest node in status `'idle'`. `MeshGuestOptions` is `MeshOptions`: the shared option table above without `approvePeer`.\n\n#### Example\n\n```ts\nimport { createMeshGuest, meshCodec } from '@vielzeug/mesh';\n\nconst guest = createMeshGuest<AppProtocol>();\nconst answer = await guest.acceptInvitation(meshCodec.decode(invitationText), { name: 'Sam' });\n```\n\n## Host Surface\n\n`MeshHost<P>` extends `MeshNode`.\n\n| Member | Signature | Purpose |\n| --- | --- | --- |\n| `createInvitation` | `(meta?: { name?: string }) => Promise<MeshInvitation>` | Single-use invitation for one guest; repeatable: one per guest. `meta.name` becomes this host's peer name on the guest side. |\n| `acceptAnswer` | `(answer: MeshAnswer) => Promise<MeshPeer>` | Verifies proof + approval, then resolves once the channel opens. Rejects `MeshPairingError` (unknown/expired/duplicate session, duplicate peer id, proof mismatch, refusal) or `MeshTimeoutError`. |\n| `peers` | `ReadonlyMap<string, MeshPeer>` | Live peer inventory keyed by peer id. |\n| `send` | `<K extends keyof P['toGuest'] & string>(peerId: string, type: K, payload: P['toGuest'][K]) => void` | Typed unicast; throws `MeshConnectionError` for unknown/unconnected peers, `MeshPayloadError` over the cap. |\n| `broadcast` | `<K extends keyof P['toGuest'] & string>(type: K, payload: P['toGuest'][K], options?: { except?: readonly string[] }) => void` | Sends to every connected peer not in `except`. |\n| `on` | `<K extends keyof P['toHost'] & string>(type: K, handler: (message: MeshInbound<P['toHost'][K]>) => void) => Unsubscribe` | Typed inbound subscription from any guest. |\n| `kick` | `(peerId: string, reason?: string) => void` | Disconnects a peer; the guest observes `'disconnected'`. |\n\n## Guest Surface\n\n`MeshGuest<P>` extends `MeshNode`.\n\n| Member | Signature | Purpose |\n| --- | --- | --- |\n| `acceptInvitation` | `(invitation: MeshInvitation, meta?: { name?: string }) => Promise<MeshAnswer>` | Consumes an invitation; the returned answer goes back to the host out-of-band. Throws `MeshPairingError` on expired/malformed input or while a live host peer exists; after the host peer fails or disconnects, a fresh invitation re-pairs on the same node with listeners intact. |\n| `host` | `MeshPeer \\| null` | The host peer once pairing started; its `id` is the invitation's `sessionId`. |\n| `send` | `<K extends keyof P['toHost'] & string>(type: K, payload: P['toHost'][K]) => void` | Typed send to the host; throws `MeshConnectionError` when unpaired. |\n| `on` | `<K extends keyof P['toGuest'] & string>(type: K, handler: (message: MeshInbound<P['toGuest'][K]>) => void) => Unsubscribe` | Typed inbound subscription from the host. |\n\n## Shared Surface\n\n`MeshNode` members common to host and guest.\n\n| Member | Signature | Purpose |\n| --- | --- | --- |\n| `status` | `MeshStatus` | Current lifecycle state. |\n| `disposed` | `boolean` | Whether the node is permanently disposed. |\n| `disposalSignal` | `AbortSignal` | Aborted by `dispose()`. |\n| `dispose` | `() => void` | Idempotent teardown: closes every connection, aborts `disposalSignal`, emits `'dispose'`, detaches tappers. |\n| `[Symbol.dispose]` | `() => void` | Enables `using` declarations. |\n| `tap` | `(handler: (event: MeshEvent) => void, options?: { signal?: AbortSignal }) => Unsubscribe` | Observability stream; handler errors are swallowed. |\n\n## Codec\n\n```ts\nconst meshCodec: {\n encode(payload: MeshInvitation | MeshAnswer): string;\n decode(text: string): MeshInvitation | MeshAnswer;\n};\n```\n\nCompact JSON → base64url for pairing payloads, kept pure so other encodings can slot in later. `decode` validates shape and `v`, throwing `MeshPairingError` on malformed text, wrong version, or unrecognized payload.\n\n```ts\nconst text = meshCodec.encode(await host.createInvitation());\nconst answer = await guest.acceptInvitation(meshCodec.decode(text));\nawait host.acceptAnswer(meshCodec.decode(meshCodec.encode(answer)));\n```\n\n### `meshQrCodec`\n\n```ts\nconst meshQrCodec: {\n encode(payload: MeshInvitation | MeshAnswer): Promise<string>;\n decode(text: string): Promise<MeshInvitation | MeshAnswer>;\n};\n```\n\nQR-oriented variant: deflate-raw compresses the JSON payload, then base45-encodes it with an `mq2.` prefix. `encode` falls back to plain `meshCodec` output where `CompressionStream` is unavailable, so it never fails on a missing capability. `decode` accepts both `mq2.*` and plain `meshCodec` output, so camera scans and paste fallbacks share one path.\n\n```ts\nconst invitationText = await meshQrCodec.encode(await host.createInvitation()); // \"mq2.…\"\nconst answer = await host.acceptAnswer(await meshQrCodec.decode(scannedText));\n```\n\n`encode` never throws for a missing capability: it falls back to plain `meshCodec` output where `CompressionStream` is unavailable. `decode` throws `MeshPairingError` on corrupt base45/deflate/JSON and `MeshUnsupportedError` only when the text is `mq2.`-compressed but `DecompressionStream` is missing.\n\n## Types\n\n### Protocol\n\n```ts\ninterface MeshProtocol {\n readonly toHost: MeshMessageMap; // messages a guest may send\n readonly toGuest: MeshMessageMap; // messages the host may send\n}\n\ntype MeshMessageMap = Record<string, unknown>;\n```\n\n### Pairing payloads\n\n```ts\ninterface MeshInvitation {\n readonly v: 1;\n readonly sessionId: string; // 128-bit, base64url\n readonly secret: string; // 256-bit, base64url\n readonly expiresAt: number; // epoch ms\n readonly hostName?: string; // host display name for the guest's peer list\n readonly sdp: string; // host offer, candidates inlined\n}\n\ninterface MeshAnswer {\n readonly v: 1;\n readonly sessionId: string;\n readonly proof: string; // HMAC-SHA-256 of the answer SDP keyed by secret\n readonly peer: { readonly id: string; readonly name?: string };\n readonly sdp: string;\n}\n```\n\n### Peers, status, messages\n\n```ts\ntype MeshStatus = 'idle' | 'pairing' | 'connecting' | 'connected' | 'disconnected' | 'failed' | 'disposed';\n\ntype MeshPeerStatus = 'connecting' | 'connected' | 'disconnected' | 'failed';\n\ninterface MeshPeerInfo {\n readonly id: string;\n readonly name?: string;\n}\n\ninterface MeshPeer extends MeshPeerInfo {\n readonly status: MeshPeerStatus;\n readonly role: 'host' | 'guest';\n}\n\ninterface MeshInbound<T> {\n readonly peerId: string;\n readonly messageId: string;\n readonly sentAt: number;\n readonly payload: T;\n}\n\ntype Unsubscribe = () => void;\n```\n\n### Events\n\n```ts\ntype MeshEvent =\n | { readonly type: 'status-change'; readonly status: MeshStatus }\n | { readonly type: 'peer-status-change'; readonly peerId: string; readonly status: MeshPeerStatus }\n | { readonly type: 'invitation-created' | 'invitation-expired'; readonly sessionId: string }\n | { readonly type: 'peer-approved' | 'peer-rejected'; readonly peerId: string }\n | { readonly type: 'peer-joined'; readonly peer: MeshPeer }\n | { readonly type: 'peer-left'; readonly peer: MeshPeer; readonly reason?: string }\n | { readonly type: 'message-sent' | 'message-received'; readonly peerId: string; readonly messageType: string; readonly bytes: number }\n | { readonly type: 'message-rejected'; readonly peerId: string; readonly reason: 'too-large' | 'duplicate' | 'malformed' | 'unknown-type' }\n | { readonly type: 'ice-state'; readonly peerId: string; readonly state: string }\n | { readonly type: 'error'; readonly error: MeshError }\n | { readonly type: 'dispose' };\n```\n\n`status-change` reports node-level transitions; `peer-status-change` reports one peer's transition and always names a peer id that exists in `peers` (or `guest.host`).\n\n### Options\n\n```ts\ninterface MeshChannelOptions {\n readonly ordered?: boolean;\n readonly maxRetransmits?: number;\n readonly label?: string;\n}\n\ninterface MeshOptions {\n readonly iceServers?: readonly RTCIceServer[];\n readonly channel?: MeshChannelOptions;\n readonly maxMessageBytes?: number;\n readonly invitationTtlMs?: number;\n readonly iceGatheringTimeoutMs?: number;\n readonly rtc?: MeshRtcFactory;\n readonly clock?: () => number;\n readonly random?: RandomSource;\n readonly signal?: AbortSignal;\n}\n\ninterface MeshHostOptions extends MeshOptions {\n readonly approvePeer?: (peer: MeshPeerInfo) => boolean | Promise<boolean>;\n}\n\ntype MeshGuestOptions = MeshOptions;\n```\n\n### WebRTC injection\n\nMinimal structural subsets: the surface mesh actually calls, no more. Implement them over `wrtc` or a test double to run outside a browser.\n\n```ts\ninterface MeshRtcFactory {\n createPeerConnection(config: RTCConfiguration): RTCPeerConnectionLike;\n}\n\ninterface RTCPeerConnectionLike {\n readonly connectionState: string;\n readonly iceConnectionState: string;\n readonly iceGatheringState: string;\n readonly localDescription: RTCSessionDescriptionLike | null;\n createDataChannel(label: string, init?: { ordered?: boolean; maxRetransmits?: number }): RTCDataChannelLike;\n createOffer(): Promise<RTCSessionDescriptionLike>;\n createAnswer(): Promise<RTCSessionDescriptionLike>;\n setLocalDescription(description: RTCSessionDescriptionLike): Promise<void>;\n setRemoteDescription(description: RTCSessionDescriptionLike): Promise<void>;\n close(): void;\n addEventListener(type: 'datachannel', listener: (event: { readonly channel: RTCDataChannelLike }) => void): void;\n addEventListener(type: 'icegatheringstatechange' | 'iceconnectionstatechange' | 'connectionstatechange', listener: () => void): void;\n addEventListener(type: string, listener: (event: MeshRtcEvent) => void): void;\n removeEventListener(type: string, listener: (event: MeshRtcEvent) => void): void;\n}\n\ninterface RTCDataChannelLike {\n readonly readyState: string;\n send(data: string): void;\n close(): void;\n addEventListener(type: 'open' | 'close' | 'error', listener: () => void): void;\n addEventListener(type: 'message', listener: (event: { readonly data: string }) => void): void;\n addEventListener(type: string, listener: (event: MeshRtcEvent) => void): void;\n removeEventListener(type: string, listener: (event: MeshRtcEvent) => void): void;\n}\n\ninterface RTCSessionDescriptionLike {\n readonly type?: 'offer' | 'answer' | 'pranswer' | 'rollback';\n readonly sdp?: string;\n}\n\ninterface MeshRtcEvent {\n readonly data?: unknown;\n readonly channel?: RTCDataChannelLike;\n}\n```\n\n## Errors\n\n| Class | Thrown when | Notable properties |\n| --- | --- | --- |\n| `MeshError` | Base class: never thrown directly | `instanceof MeshError` catches all mesh errors |\n| `MeshPairingError` | Malformed/expired/unknown invitation or answer, proof mismatch, duplicate answer, `approvePeer` refusal, codec `decode` failure | N/A |\n| `MeshConnectionError` | ICE/DTLS failure, channel closed, send to unknown/unconnected peer, offer/answer creation failure | `peerId: string \\| null` |\n| `MeshPayloadError` | Outbound message over `maxMessageBytes` or unserializable | N/A |\n| `MeshTimeoutError` | Channel-open wait exceeded | N/A |\n| `MeshDisposedError` | Any method called after `dispose()` | N/A |\n| `MeshUnsupportedError` | No `RTCPeerConnection` in the environment and no `rtc` injected (raised at first use, never at import), or `meshQrCodec.decode` given `mq2.` text without `DecompressionStream` | N/A |\n",
6
+ "usage": "---\ntitle: 'Mesh: Usage Guide'\ndescription: How to use @vielzeug/mesh.\n---\n\n[[toc]]\n\n## Basic Usage\n\nDeclare the protocol once, create a host and a guest, and exchange the pairing payloads out-of-band. `RTCPeerConnection` requires a secure context (`https:` or `localhost`).\n\n```ts\nimport { createMeshGuest, createMeshHost, meshCodec, type MeshProtocol } from '@vielzeug/mesh';\n\ninterface AppProtocol extends MeshProtocol {\n toHost: { note: string };\n toGuest: { ack: { ok: boolean } };\n}\n\nconst host = createMeshHost<AppProtocol>();\nconst guest = createMeshGuest<AppProtocol>();\n\n// Host produces an invitation; the guest turns it into an answer.\nconst invitationText = meshCodec.encode(await host.createInvitation());\nconst answerText = meshCodec.encode(await guest.acceptInvitation(meshCodec.decode(invitationText)));\n\n// The host resolves once the data channel is open.\nawait host.acceptAnswer(meshCodec.decode(answerText));\n\nguest.send('note', 'hello');\nhost.on('note', (m) => host.send(m.peerId, 'ack', { ok: true }));\n```\n\n## Pairing Flow\n\nPairing is a two-leg manual exchange. Each leg is a base64url string produced by `meshCodec.encode` and consumed by `meshCodec.decode`.\n\n```ts\n// Host device: one invitation per prospective guest\nconst invitation = await host.createInvitation();\nconst text = meshCodec.encode(invitation);\n```\n\nDeliver `text` any way you can: a textarea the user copies, or `navigator.share` on mobile:\n\n```ts\nif (navigator.share) {\n await navigator.share({ text, title: 'Join my session' });\n}\n```\n\nThe guest decodes it and returns an answer the same way:\n\n```ts\nconst answer = await guest.acceptInvitation(meshCodec.decode(text), { name: 'Sam' });\nconst answerText = meshCodec.encode(answer); // back to the host\nconst peer = await host.acceptAnswer(meshCodec.decode(answerText));\n```\n\nRules that shape the flow:\n\n- An invitation is **single-use**: call `createInvitation()` once per guest.\n- It **expires** at `expiresAt` (default `invitationTtlMs: 300_000`). Expired invitations are refused on both sides and cleaned up on the host.\n- The answer carries a **proof** derived from the invitation's secret, so a swapped or forged answer is rejected before any connection attempt.\n- `approvePeer` runs after proof verification: return `false` to refuse a guest with `MeshPairingError` and a `'peer-rejected'` tap.\n\n```ts\nconst host = createMeshHost<AppProtocol>({\n approvePeer: (peer) => allowlist.has(peer.id),\n});\n```\n\nOn the guest, the host's `peerId` is the invitation's `sessionId`: the invitation carries no other host identity. On the host, a guest's `peerId` is the id the guest generated for its answer.\n\n### Pairing over QR\n\n`meshQrCodec` is an async codec that compresses pairing payloads (deflate-raw + base45, `mq2.` prefix) so they fit a QR code comfortably. `decode` accepts both the compressed `mq2.*` form and plain `meshCodec` output: paste fallback and camera scanning work through one code path.\n\n```ts\nimport { meshQrCodec } from '@vielzeug/mesh';\nimport { encodeQr, toSvg } from '@vielzeug/sigil';\n\n// Async: deflate + base45 through CompressionStream, with a plain-codec\n// fallback where the stream API is missing.\nconst invitationText = await meshQrCodec.encode(await host.createInvitation());\ndocument.querySelector('#qr').innerHTML = toSvg(encodeQr(invitationText));\n\nconst answer = await host.acceptAnswer(await meshQrCodec.decode(scannedAnswerText));\n```\n\n`encode` falls back to plain `meshCodec` output where `CompressionStream` is unavailable, so it never fails on a missing capability: check for the `mq2.` prefix if you need to know which form you got. `decode` accepts both forms interchangeably and throws `MeshUnsupportedError` only when the text is `mq2.`-compressed but `DecompressionStream` is missing. The [sigil pairing recipe](../sigil/examples/pair-two-devices.md) shows the full two-device flow, and `ore-qr-code`/`ore-qr-scanner` (Refine) wrap the rendering and camera sides.\n\n## Sending and Receiving\n\n`send`/`on`/`broadcast` are typed from the protocol. Outbound messages serialize through `JSON.stringify` into a versioned envelope with id, type, timestamp, and payload.\n\n```ts\nhost.send(peerId, 'ack', { ok: true }); // one guest\nhost.broadcast('ack', { ok: true }, { except: [peerId] }); // all but one\nguest.send('note', 'from the guest');\n```\n\nInbound messages arrive as `MeshInbound<T>`: `{ peerId, messageId, sentAt, payload }`. Rules:\n\n- Payloads over `maxMessageBytes` (default 65 536) throw `MeshPayloadError` outbound; oversized inbound frames are dropped and tapped as `'message-rejected'` with reason `'too-large'`.\n- Duplicate message ids (e.g. after a retry) are dropped and tapped as `'duplicate'`.\n- A type with no registered `on` handler is tapped as `'unknown-type'`: never thrown.\n- `send` to an unknown or unconnected peer throws `MeshConnectionError`.\n\n## Peer Lifecycle\n\nNode `status` moves through `idle → pairing → connecting → connected`, then `disconnected`, `failed`, or `disposed`. Watch transitions, and everything else: through `tap`:\n\n```ts\nhost.tap((event) => {\n if (event.type === 'status-change') console.log('node:', event.status);\n if (event.type === 'peer-status-change') console.log('peer:', event.peerId, event.status);\n if (event.type === 'peer-left') console.log('left:', event.peer.id, event.reason);\n});\n```\n\n`peer-joined` and `peer-left` carry the live `MeshPeer`; the inventory itself lives on `peers`:\n\n```ts\nhost.tap((event) => {\n if (event.type === 'peer-joined') console.log('peers now:', [...host.peers.keys()]);\n});\nhost.kick(peerId, 'bye'); // guest observes 'disconnected'\n```\n\n`status-change` reports node-level transitions; `peer-status-change` reports a single peer moving between `'connecting' | 'connected' | 'disconnected' | 'failed'`. A guest whose host peer failed or disconnected can re-pair on the same node: call `acceptInvitation` again with a fresh invitation and existing `on`/`tap` listeners keep working.\n\nDispose nodes deterministically: `dispose()` is idempotent, aborts `disposalSignal`, emits a final `'dispose'` event, and detaches all tappers. Passing an `AbortSignal` option disposes on abort; `using` works too.\n\n## Timeouts and Limits\n\n| Option | Default | Meaning |\n| --- | --- | --- |\n| `invitationTtlMs` | `300_000` | How long an unanswered invitation stays valid. |\n| `iceGatheringTimeoutMs` | `5_000` | Caps ICE gathering: on timeout pairing proceeds with the candidates gathered so far. |\n| `channelOpenTimeoutMs` | `60_000` | How long `acceptAnswer` and the guest wait for the data channel to open: deliberately generous because it covers the human carry-back of the answer. |\n| `maxMessageBytes` | `65_536` | Serialized message cap, enforced in both directions. |\n\n`acceptAnswer` rejects with `MeshTimeoutError` when the channel never opens; the guest independently marks itself `'failed'` and taps an `'error'` event. Inject `clock` to drive TTLs deterministically in tests.\n\n## Security Model\n\nMesh reduces the manual-pairing attack surface but does not remove it:\n\n- Every invitation carries a random 256-bit **secret**; the answer must present a proof derived from it (HMAC-SHA-256 via `SubtleCrypto`). Pairing requires a secure context: without `SubtleCrypto` both ends throw `MeshUnsupportedError` rather than fall back to a forgeable hash.\n- Invitations are **single-use and time-boxed**, so a leaked invite is only useful briefly.\n- `approvePeer` is the host's admission hook: use it for a name check, a shared passphrase confirmed out-of-band, or an allowlist.\n- `maxMessageBytes` caps every inbound frame; malformed frames are dropped, never dispatched.\n- **Roles are app-level.** A guest can send any declared `toHost` type with any payload: validate guest state on the host as if it came from a form. Never trust `peer.name` as an identity; it is self-declared.\n\n## Testing\n\nEvery WebRTC object comes from the injectable `rtc` factory: tests pair nodes fully in memory, with no browser. `@vielzeug/mesh/testing` ships the in-memory fake the package's own tests use: two peer connections wired through token SDP, with knobs for latency, drops, ICE failure, stalled gathering, and a never-opening channel.\n\n```ts\nimport { createFakeRtc } from '@vielzeug/mesh/testing';\n\nconst fx = createFakeRtc();\nconst host = createMeshHost<AppProtocol>({ rtc: fx.rtc, clock: () => now });\nconst guest = createMeshGuest<AppProtocol>({ rtc: fx.rtc });\n```\n\nThe [REPL examples](/repl) include a runnable in-memory pairing you can copy as a starting point.\n\n## Framework Integration\n\nA node is a plain object: own it in a composable/store and dispose on teardown.\n\n::: code-group\n\n```ts [React]\nimport { useEffect, useRef } from 'react';\nimport { createMeshHost, type MeshProtocol } from '@vielzeug/mesh';\n\nexport function useMeshHost<P extends MeshProtocol>() {\n const ref = useRef<ReturnType<typeof createMeshHost<P>>>(null);\n if (!ref.current) ref.current = createMeshHost<P>();\n useEffect(() => () => ref.current?.dispose(), []);\n return ref.current;\n}\n```\n\n```ts [Vue]\nimport { onUnmounted } from 'vue';\nimport { createMeshHost, type MeshProtocol } from '@vielzeug/mesh';\n\nexport function useMeshHost<P extends MeshProtocol>() {\n const host = createMeshHost<P>();\n onUnmounted(() => host.dispose());\n return host;\n}\n```\n\n```ts [Svelte]\nimport { onDestroy } from 'svelte';\nimport { createMeshHost, type MeshProtocol } from '@vielzeug/mesh';\n\nexport function createHost<P extends MeshProtocol>() {\n const host = createMeshHost<P>();\n onDestroy(() => host.dispose());\n return host;\n}\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\nMesh builds on `@vielzeug/arsenal` for random ids, secrets, and base64url encoding: no extra wiring needed. Pair it with `herald` when you want mesh messages to feed an in-process event bus:\n\n```ts\nimport { createBus } from '@vielzeug/herald';\n\nconst bus = createBus<{ 'guest-note': string }>();\nhost.on('note', (m) => bus.emit('guest-note', m.payload));\n```\n\n## Best Practices\n\n- Pair over `meshCodec` strings, never raw SDP: the transport restores the CRLF line endings that copy/paste mangles.\n- Create **one invitation per guest**; never reuse or forward an invitation.\n- Keep `invitationTtlMs` short for physical-proximity pairing and always gate unknown guests through `approvePeer`.\n- Subscribe `on` handlers **before** `acceptAnswer` resolves so early messages are not tapped as `'unknown-type'`.\n- Validate every guest payload on the host: treat `toHost` messages as untrusted input.\n- `dispose()` both nodes on teardown; pass `signal` to bind disposal to a controller you already own.\n- Inject `rtc` and `clock` in tests instead of mocking globals.\n- Plan re-pairing, not reconnection: after a reload, create a fresh invitation and let the app re-associate the stable `peerId`.\n",
7
+ "examples": "---\ntitle: 'Mesh: Examples'\ndescription: Practical examples and recipes for @vielzeug/mesh.\n---\n\n## Examples\n\n- [Pair Two Devices](./examples/pair-two-devices.md)\n- [Host-Authoritative Command Loop](./examples/host-authoritative-command-loop.md)\n"
8
8
  },
9
9
  "examples": [
10
10
  {
11
11
  "id": "pairing",
12
- "code": "import { createMeshGuest, createMeshHost, meshCodec } from '@vielzeug/mesh'\n\n// In-memory WebRTC fake — the same 'rtc' injection point the package's own\n// tests use. In a real app, omit 'rtc' and globalThis.RTCPeerConnection is used.\nconst fakeRtc = (() => {\n const links = new Map()\n const bag = () => {\n const ls = new Map()\n return {\n addEventListener: (t, f) => ls.set(t, [...(ls.get(t) ?? []), f]),\n removeEventListener: (t, f) => ls.set(t, (ls.get(t) ?? []).filter((x) => x !== f)),\n emit: (t, e = {}) => (ls.get(t) ?? []).forEach((f) => f(e)),\n }\n }\n const channel = () =>\n Object.assign(bag(), {\n readyState: 'connecting',\n remote: null,\n send(data) {\n if (this.remote?.readyState === 'open') queueMicrotask(() => this.remote.emit('message', { data }))\n },\n open() {\n this.readyState = 'open'\n this.emit('open')\n },\n close() {\n if (this.readyState === 'closed') return\n this.readyState = 'closed'\n this.emit('close')\n this.remote?.close()\n },\n })\n const pc = (token) =>\n Object.assign(bag(), {\n connectionState: 'new',\n iceConnectionState: 'new',\n iceGatheringState: 'new',\n localDescription: null,\n createDataChannel() {\n this.dc = channel()\n return this.dc\n },\n createOffer() {\n links.set(token, { host: this })\n return Promise.resolve({ sdp: 'offer:' + token, type: 'offer' })\n },\n createAnswer() {\n const link = [...links.values()].find((l) => l.guest === this)\n return Promise.resolve({ sdp: 'answer:' + link.hostToken + ':' + token, type: 'answer' })\n },\n setLocalDescription(d) {\n this.localDescription = d\n this.iceGatheringState = 'complete'\n return Promise.resolve()\n },\n setRemoteDescription(d) {\n if (d.type === 'offer') {\n // sdp is CRLF-normalized like a real description — trim the token\n const hostToken = d.sdp.split(':')[1].trim()\n const link = links.get(hostToken)\n link.guest = this\n link.hostToken = hostToken\n return Promise.resolve()\n }\n const hostToken = d.sdp.replace('answer:', '').split(':')[0].trim()\n const link = links.get(hostToken)\n const guestChannel = channel()\n const hostChannel = link.host.dc\n hostChannel.remote = guestChannel\n guestChannel.remote = hostChannel\n link.guest.emit('datachannel', { channel: guestChannel })\n queueMicrotask(() => {\n hostChannel.open()\n guestChannel.open()\n })\n return Promise.resolve()\n },\n close() {\n this.dc?.close()\n },\n })\n let n = 0\n return { createPeerConnection: () => pc('pc' + ++n) }\n})()\n\nconst host = createMeshHost({ rtc: fakeRtc })\nconst guest = createMeshGuest({ rtc: fakeRtc })\n\nguest.on('ack', (m) => console.log('guest got ack:', m.payload.ok, 'from', m.peerId))\nhost.on('note', (m) => {\n console.log('host got note:', m.payload)\n host.send(m.peerId, 'ack', { ok: true })\n})\n\n// The out-of-band legs are plain strings — copy/paste or navigator.share\nconst invitationText = meshCodec.encode(await host.createInvitation())\nconsole.log('invitation bytes:', invitationText.length)\n\nconst answerText = meshCodec.encode(await guest.acceptInvitation(meshCodec.decode(invitationText)))\nconst peer = await host.acceptAnswer(meshCodec.decode(answerText))\n\nconsole.log('paired — host sees peer', peer.id, '| status:', host.status, '/', guest.status)\n\nguest.send('note', 'hello over the fake channel')\nawait new Promise((r) => setTimeout(r, 0))\n\nhost.dispose()\nguest.dispose()\nconsole.log('disposed:', host.status, '/', guest.status)",
12
+ "code": "import { createMeshGuest, createMeshHost, meshCodec } from '@vielzeug/mesh'\n\n// In-memory WebRTC fake: the same 'rtc' injection point the package's own\n// tests use. In a real app, omit 'rtc' and globalThis.RTCPeerConnection is used.\nconst fakeRtc = (() => {\n const links = new Map()\n const bag = () => {\n const ls = new Map()\n return {\n addEventListener: (t, f) => ls.set(t, [...(ls.get(t) ?? []), f]),\n removeEventListener: (t, f) => ls.set(t, (ls.get(t) ?? []).filter((x) => x !== f)),\n emit: (t, e = {}) => (ls.get(t) ?? []).forEach((f) => f(e)),\n }\n }\n const channel = () =>\n Object.assign(bag(), {\n readyState: 'connecting',\n remote: null,\n send(data) {\n if (this.remote?.readyState === 'open') queueMicrotask(() => this.remote.emit('message', { data }))\n },\n open() {\n this.readyState = 'open'\n this.emit('open')\n },\n close() {\n if (this.readyState === 'closed') return\n this.readyState = 'closed'\n this.emit('close')\n this.remote?.close()\n },\n })\n const pc = (token) =>\n Object.assign(bag(), {\n connectionState: 'new',\n iceConnectionState: 'new',\n iceGatheringState: 'new',\n localDescription: null,\n createDataChannel() {\n this.dc = channel()\n return this.dc\n },\n createOffer() {\n links.set(token, { host: this })\n return Promise.resolve({ sdp: 'offer:' + token, type: 'offer' })\n },\n createAnswer() {\n const link = [...links.values()].find((l) => l.guest === this)\n return Promise.resolve({ sdp: 'answer:' + link.hostToken + ':' + token, type: 'answer' })\n },\n setLocalDescription(d) {\n this.localDescription = d\n this.iceGatheringState = 'complete'\n return Promise.resolve()\n },\n setRemoteDescription(d) {\n if (d.type === 'offer') {\n // sdp is CRLF-normalized like a real description: trim the token\n const hostToken = d.sdp.split(':')[1].trim()\n const link = links.get(hostToken)\n link.guest = this\n link.hostToken = hostToken\n return Promise.resolve()\n }\n const hostToken = d.sdp.replace('answer:', '').split(':')[0].trim()\n const link = links.get(hostToken)\n const guestChannel = channel()\n const hostChannel = link.host.dc\n hostChannel.remote = guestChannel\n guestChannel.remote = hostChannel\n link.guest.emit('datachannel', { channel: guestChannel })\n queueMicrotask(() => {\n hostChannel.open()\n guestChannel.open()\n })\n return Promise.resolve()\n },\n close() {\n this.dc?.close()\n },\n })\n let n = 0\n return { createPeerConnection: () => pc('pc' + ++n) }\n})()\n\nconst host = createMeshHost({ rtc: fakeRtc })\nconst guest = createMeshGuest({ rtc: fakeRtc })\n\nguest.on('ack', (m) => console.log('guest got ack:', m.payload.ok, 'from', m.peerId))\nhost.on('note', (m) => {\n console.log('host got note:', m.payload)\n host.send(m.peerId, 'ack', { ok: true })\n})\n\n// The out-of-band legs are plain strings: copy/paste or navigator.share\nconst invitationText = meshCodec.encode(await host.createInvitation())\nconsole.log('invitation bytes:', invitationText.length)\n\nconst answerText = meshCodec.encode(await guest.acceptInvitation(meshCodec.decode(invitationText)))\nconst peer = await host.acceptAnswer(meshCodec.decode(answerText))\n\nconsole.log('paired: host sees peer', peer.id, '| status:', host.status, '/', guest.status)\n\nguest.send('note', 'hello over the fake channel')\nawait new Promise((r) => setTimeout(r, 0))\n\nhost.dispose()\nguest.dispose()\nconsole.log('disposed:', host.status, '/', guest.status)",
13
13
  "name": "Pairing & Messaging"
14
14
  }
15
15
  ],
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "apiSource": "export { animate } from './animate';\nexport { animateEach } from './animate-each';\nexport { NecromancerConfigError, NecromancerError, NecromancerUnsupportedError } from './errors';\nexport { captureLayout } from './layout';\nexport { shouldReduceMotion } from './motion';\nexport type {\n AnimateEachOptions,\n AnimateOptions,\n AnimationGroup,\n AnimationHandle,\n AnimationResult,\n KeyframeFactory,\n Keyframes,\n LayoutAnimationOptions,\n LayoutCaptureOptions,\n LayoutTransition,\n MotionMode,\n} from './types';\n",
3
3
  "docs": {
4
- "index": "---\ntitle: Necromancer — Lifecycle-owned DOM animations\ndescription: Lifecycle-owned Web Animations API primitives for native playback, groups, and additive FLIP transitions.\npackage: necromancer\ncategory: ui\nkeywords: [animation, web-animations-api, waapi, flip, stagger, reduced-motion]\nrelated: [orbit, ore]\nexports: [animate, animateEach, captureLayout]\nenvironments: [browser]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"necromancer\" />\n\n## Why Necromancer?\n\nNative Web Animations API calls do not provide lifecycle ownership, reduced-motion policy, grouped playback, or layout transitions. Necromancer retains native keyframes and timing semantics while making ownership explicit for a component or DOM feature.\n\n```ts\n// Before\nconst animation = element.animate(keyframes, { duration: 180 });\nanimation.addEventListener('cancel', removeListeners);\n\n// After\nconst animation = animate(element, keyframes, { duration: 180 });\nanimation.dispose();\n```\n\n| Feature | Native WAAPI | Necromancer | Motion One |\n| --- | --- | --- | --- |\n| Bundle size | 0 B | <PackageInfo package=\"necromancer\" type=\"size\" /> | ~18 kB |\n| Root dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Lifecycle handle | Manual | `dispose()` | Library-specific controls |\n| Reduced motion | Manual | `motion: 'system'` default | Configuration required |\n| Layout transitions | Manual FLIP math | `captureLayout().animate()` | Separate API |\n\n<div class=\"decision-callout\">\n\n**Use Necromancer when** you need native browser animations with explicit cancellation, reduced-motion behavior, staggered groups, or positional FLIP transitions.\n\n**Consider CSS transitions when** a static style change needs no playback control, cleanup, or layout measurement.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/necromancer\n```\n\n```sh [npm]\nnpm install @vielzeug/necromancer\n```\n\n```sh [yarn]\nyarn add @vielzeug/necromancer\n```\n\n:::\n\n## Quick Start\n\nStart the animation after its DOM element mounts and release it when its UI owner is removed.\n\n```ts\nimport { animate } from '@vielzeug/necromancer';\n\nconst notice = document.createElement('p');\nnotice.textContent = 'Saved';\ndocument.body.append(notice);\n\nconst animation = animate(\n notice,\n [{ opacity: 0, transform: 'translateY(8px)' }, { opacity: 1, transform: 'translateY(0)' }],\n { duration: 180, easing: 'ease-out' },\n);\n\nawait animation.result;\nanimation.dispose();\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `animate()` — Native element animation with lifecycle ownership and direct native access\n- `animateEach()` — Group ownership with stable keyframe factories and `stagger`\n- `captureLayout()` — One-shot FLIP transition with additive `translate` (position) and `scale` (size)\n- `motion` — `'system'` reduced-motion support with explicit reduced outcomes\n- Native timing — Omitted timing fields retain Web Animations API behavior\n- `signal` — Abort a handle from its parent lifecycle\n- `dispose()` — Idempotent cleanup with `[Symbol.dispose]()`\n\n</div>\n\n## Deliberate Scope\n\nNecromancer owns explicit WAAPI keyframes. It does not generate CSS keyframes, observe CSS transitions, watch mutations, simulate springs, interpolate SVG paths, or run a JavaScript tween loop. Use CSS for declarative style changes and choose a dedicated tool when those capabilities are required.\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [3.0 Migration](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Orbit](/orbit/) — Position floating UI before animating its appearance.\n- [Ore](/ore/) — Own Necromancer handles in a custom element's mount and disposal lifecycle.\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
5
- "api": "---\ntitle: Necromancer — API Reference\ndescription: API reference for @vielzeug/necromancer animation ownership, groups, reduced motion, and FLIP transitions.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `animate()` | Animate one element | Sync | Omitted timing fields use native WAAPI behavior |\n| `animateEach()` | Animate a unique element group | Sync | Non-zero `stagger` needs numeric `delay` |\n| `captureLayout()` | Capture positions and create a one-shot FLIP transition | Sync | Capture before changing layout |\n| `shouldReduceMotion()` | Report whether a motion mode reduces visible movement | Sync | `'system'` reads `prefers-reduced-motion`; false without `matchMedia` |\n| `NecromancerError` | Base package error | Sync | Use `instanceof NecromancerError` to narrow unknown errors |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/necromancer` | Animation functions, types, and errors |\n| `@vielzeug/necromancer/testing` | jsdom test fakes for `Element.animate()` and `getBoundingClientRect()` |\n\n## Animation Functions\n\n### `animate()`\n\n```ts\nfunction animate(element: Element, keyframes: Keyframes, options?: AnimateOptions): AnimationHandle;\n```\n\nStarts a lifecycle-owned native Web Animation. Native timing options, including an omitted or zero `duration`, are preserved.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `element` | `Element` | Element animated through its native `animate()` method. |\n| `keyframes` | `Keyframes` | Native keyframe array or property-indexed keyframes. |\n| `options` | `AnimateOptions` | Native timing plus motion and cancellation ownership. |\n\n**Returns:** An `AnimationHandle` with native playback access and explicit disposal.\n\n**Example:**\n\n```ts\nimport { animate } from '@vielzeug/necromancer';\n\nconst handle = animate(element, [{ opacity: 0 }, { opacity: 1 }], { duration: 180 });\nhandle.animation.pause();\nconst result = await handle.result;\nhandle.dispose();\n```\n\n---\n\n### `animateEach()`\n\n```ts\nfunction animateEach<ElementType extends Element>(\n elements: Iterable<ElementType>,\n keyframes: Keyframes | KeyframeFactory<ElementType>,\n options?: AnimateEachOptions,\n): AnimationGroup;\n```\n\nStarts animations for unique elements in first-seen order. Necromancer resolves every keyframe factory before starting the first native animation.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `elements` | `Iterable<ElementType>` | Elements animated once in first-seen order. |\n| `keyframes` | `Keyframes \\| KeyframeFactory<ElementType>` | Shared keyframes or a subtype-preserving factory. |\n| `options` | `AnimateEachOptions` | Animation options plus a stagger interval. |\n\n**Returns:** An `AnimationGroup` containing child handles and ordered results.\n\n**Example:**\n\n```ts\nimport { animateEach } from '@vielzeug/necromancer';\n\nconst group = animateEach(document.querySelectorAll<HTMLElement>('.item'), [{ opacity: 0 }, { opacity: 1 }], {\n duration: 180,\n stagger: 30,\n});\nawait group.results;\n```\n\n### `shouldReduceMotion()`\n\n```ts\nfunction shouldReduceMotion(mode: MotionMode): boolean;\n```\n\nReports whether the requested motion preference reduces visible movement — the same check `animate()` applies internally, exposed so hand-rolled effects (scroll behavior, tweens) stay consistent with it. `'full'` is always `false`, `'reduced'` is always `true`, and `'system'` reads the `prefers-reduced-motion` media query (`false` where `matchMedia` is unavailable).\n\n```ts\nimport { shouldReduceMotion } from '@vielzeug/necromancer';\n\nelement.scrollIntoView({ behavior: shouldReduceMotion('system') ? 'auto' : 'smooth' });\n```\n\n## Layout Functions\n\n### `captureLayout()`\n\n```ts\nfunction captureLayout<ElementType extends Element>(\n elements: Iterable<ElementType>,\n options?: LayoutCaptureOptions<ElementType>,\n): LayoutTransition<ElementType>;\n```\n\nCaptures unique elements' positions and sizes and returns a one-shot transition. Rotation and other transforms are not captured or compensated.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `elements` | `Iterable<ElementType>` | Elements measured before a layout change. |\n| `options` | `LayoutCaptureOptions<ElementType>` | Optional stable key mapping for replacement nodes. |\n\n**Returns:** A `LayoutTransition<ElementType>` that measures committed layout once and creates an `AnimationGroup`.\n\n**Example:**\n\n```ts\nimport { captureLayout } from '@vielzeug/necromancer';\n\nconst transition = captureLayout(beforeItems, {\n getKey: (element) => element.getAttribute('data-id')!,\n});\n\nrenderReorderedItems();\n\nconst group = transition.animate({\n duration: 220,\n easing: 'ease-out',\n elements: afterItems,\n});\n```\n\nCalling `animate()` twice on the same transition throws `NecromancerConfigError`.\n\n## Types\n\n### `MotionMode`\n\n```ts\ntype MotionMode = 'full' | 'reduced' | 'system';\n```\n\n`'system'` is the default. Reduced motion preserves the supplied keyframes while normalizing delay, duration, and end delay to `0`, and iterations to `1`.\n\n### `AnimationResult`\n\n```ts\ntype AnimationResult =\n | { readonly status: 'finished' }\n | { readonly status: 'reduced' }\n | { readonly reason?: unknown; readonly status: 'cancelled' };\n```\n\n`cancelled` describes native cancellation and includes its native rejection reason. A reason passed to `dispose()` or an abort signal takes precedence. The independent `disposed` property becomes `true` only when the lifecycle owner is explicitly disposed.\n\n### `AnimateOptions`\n\n```ts\ntype AnimateOptions = KeyframeAnimationOptions & {\n readonly motion?: MotionMode;\n readonly signal?: AbortSignal;\n};\n```\n\nNative timing fields pass through unchanged unless reduced motion normalizes timing. `signal` disposes the returned handle when aborted.\n\n### `AnimateEachOptions`\n\n```ts\ntype AnimateEachOptions = AnimateOptions & {\n readonly stagger?: number;\n};\n```\n\n`stagger` is a finite, non-negative millisecond offset.\n\n### `LayoutCaptureOptions`\n\n```ts\ninterface LayoutCaptureOptions<ElementType extends Element = Element> {\n readonly getKey?: (element: ElementType) => string;\n}\n```\n\n`getKey` maps a captured element and its committed replacement to the same stable, non-empty string. Duplicate or empty keys throw `NecromancerConfigError`.\n\n### `LayoutAnimationOptions`\n\n```ts\ntype LayoutAnimationOptions<ElementType extends Element = Element> = AnimateEachOptions & {\n readonly elements?: Iterable<ElementType>;\n};\n```\n\n`elements` is the collection in its committed layout. Omit it to animate the same captured elements. With `getKey`, replacement elements animate from the positions of their captured predecessors. Unmatched, removed, and newly entered elements are ignored.\n\n### `Keyframes` and `KeyframeFactory`\n\n```ts\ntype Keyframes = readonly Keyframe[] | PropertyIndexedKeyframes;\ntype KeyframeFactory<ElementType extends Element = Element> = (\n element: ElementType,\n index: number,\n total: number,\n) => Keyframes;\n```\n\nAccepts a `readonly` array so a reusable `as const` keyframe list can be passed without a cast.\n\n### `AnimationHandle`\n\n```ts\ninterface AnimationHandle {\n readonly animation: Animation;\n readonly result: Promise<AnimationResult>;\n readonly disposed: boolean;\n dispose(reason?: unknown): void;\n [Symbol.dispose](): void;\n}\n```\n\n| Member | Description |\n| --- | --- |\n| `animation` | Owned native animation used for playback control. |\n| `result` | Resolves with the first terminal outcome. |\n| `disposed` | Reports explicit owner disposal. |\n| `dispose(reason?)` | Cancels the animation and releases its abort listener. |\n| `[Symbol.dispose]()` | Delegates to `dispose()`. |\n\n### `AnimationGroup`\n\n```ts\ninterface AnimationGroup {\n readonly handles: readonly AnimationHandle[];\n readonly results: Promise<readonly AnimationResult[]>;\n readonly disposed: boolean;\n dispose(reason?: unknown): void;\n [Symbol.dispose](): void;\n}\n```\n\n| Member | Description |\n| --- | --- |\n| `handles` | Child handles in input order. |\n| `results` | Resolves with every child outcome in handle order. |\n| `disposed` | Reports explicit group disposal. |\n| `dispose(reason?)` | Disposes every child. |\n| `[Symbol.dispose]()` | Delegates to `dispose()`. |\n\n### `LayoutTransition`\n\n```ts\ninterface LayoutTransition<ElementType extends Element = Element> {\n animate(options?: LayoutAnimationOptions<ElementType>): AnimationGroup;\n}\n```\n\n| Method | Description |\n| --- | --- |\n| `animate(options?)` | Measures committed elements and creates the one allowed layout animation group. |\n\n## Errors\n\n| Error | Trigger |\n| --- | --- |\n| `NecromancerError` | Base class for package errors |\n| `NecromancerConfigError` | Invalid stagger, incompatible delay, or reused layout transition |\n| `NecromancerUnsupportedError` | `Element.animate()` is unavailable |\n\n## Testing (`@vielzeug/necromancer/testing`)\n\njsdom (and most non-browser DOM environments) do not implement `Element.animate()`. Import these from the `/testing` sub-path, not the root entry point.\n\n### `AnimationCall`\n\n```ts\ntype AnimationCall = {\n readonly animation: FakeAnimation;\n readonly keyframes: Keyframe[] | PropertyIndexedKeyframes;\n readonly options?: KeyframeAnimationOptions;\n};\n```\n\nOne recorded invocation of `Element.prototype.animate` from `installFakeAnimations()`.\n\n### `installFakeAnimations()`\n\n```ts\nfunction installFakeAnimations(): {\n readonly calls: readonly AnimationCall[];\n restore(): void;\n [Symbol.dispose](): void;\n};\n```\n\nReplaces `Element.prototype.animate` with a deterministic lifecycle fake for the duration of a test. `calls` records every invocation in order. Call `restore()` or use an explicit resource-management `using` declaration to put the original implementation back. The fake does not implement native playback controls such as `pause()` or `reverse()`.\n\n```ts\nimport { installFakeAnimations } from '@vielzeug/necromancer/testing';\n\nusing animations = installFakeAnimations();\nconst handle = animate(element, [{ opacity: 0 }, { opacity: 1 }]);\n\nanimations.calls[0]?.animation.finish();\nawait handle.result; // { status: 'finished' }\n```\n\n### `FakeAnimation`\n\n```ts\nclass FakeAnimation {\n cancelCallCount: number;\n finishCallCount: number;\n finished: Promise<void>;\n cancel(): void;\n finish(): void;\n}\n```\n\nA minimal `Animation` stand-in. `cancel()` rejects `finished` with an `AbortError`; `finish()` resolves it. `cancelCallCount`/`finishCallCount` track how many times each was called, in place of a test-runner-specific spy.\n\n### `createRect()`\n\n```ts\nfunction createRect(x: number, y: number, width?: number, height?: number): DOMRect;\n```\n\nBuilds a `DOMRect` for mocking `Element.getBoundingClientRect()` in `captureLayout()` tests. `width`/`height` default to `20`.\n",
6
- "usage": "---\ntitle: Necromancer — Usage Guide\ndescription: Animate DOM elements, coordinate groups, and create FLIP transitions with @vielzeug/necromancer.\n---\n\n[[toc]]\n\n## Basic Usage\n\nCreate an animation after its element mounts, control playback through the native `Animation`, and dispose its owner with the UI lifecycle.\n\n```ts\nimport { animate } from '@vielzeug/necromancer';\n\nconst handle = animate(\n element,\n [{ opacity: 0, transform: 'translateY(8px)' }, { opacity: 1, transform: 'translateY(0)' }],\n { duration: 180, easing: 'ease-out', fill: 'both' },\n);\n\nhandle.animation.reverse();\nconst result = await handle.result;\nhandle.dispose();\n```\n\n`result` distinguishes natural completion, reduced timing, and cancellation. `disposed` reports only whether the owner was explicitly disposed.\n\nOmitted timing fields retain native Web Animations API behavior. Pass an explicit `duration` when the animation should run over time.\n\n## Motion Preferences\n\nUse `motion` to select how the animation responds to the operating system preference.\n\n```ts\nconst handle = animate(element, [{ opacity: 0 }, { opacity: 1 }], {\n duration: 200,\n motion: 'system',\n});\n\nconst result = await handle.result;\n```\n\n`'system'` is the default and reduces movement when `prefers-reduced-motion: reduce` matches. `'full'` preserves the requested timing, while `'reduced'` always reduces it.\n\nReduced motion keeps the supplied keyframes but normalizes delay, duration, and end delay to zero and iterations to one. The result is `{ status: 'reduced' }`, and `handle.animation` still represents the requested visual transition.\n\n## Parent Cancellation\n\nPass a parent `AbortSignal` to release an animation when its owning work is cancelled.\n\n```ts\nconst controller = new AbortController();\nconst handle = animate(element, [{ scale: 0.96 }, { scale: 1 }], {\n duration: 160,\n signal: controller.signal,\n});\n\ncontroller.abort('route changed');\nconst result = await handle.result;\n// { status: 'cancelled', reason: 'route changed' }\n```\n\nAn already-aborted signal throws its reason before an animation starts.\n\n## Staggering a Group\n\nPass an iterable of elements to `animateEach()`. Duplicate elements are animated once in first-seen order.\n\n```ts\nimport { animateEach } from '@vielzeug/necromancer';\n\nconst group = animateEach(\n document.querySelectorAll('.card'),\n (_card, index) => [\n { opacity: 0, transform: `translateY(${12 + index * 2}px)` },\n { opacity: 1, transform: 'translateY(0)' },\n ],\n { duration: 220, easing: 'ease-out', stagger: 45 },\n);\n\nconst results = await group.results;\ngroup.dispose();\n```\n\nA group owns child lifecycles only. `results` preserves every child result in handle order; use `group.handles` when native playback control is required.\n\n## Serial Application Flow\n\nJavaScript control flow is the clearest way to express serial, conditional, or branching animations:\n\n```ts\nfor (const step of steps) {\n const handle = animate(step.element, step.keyframes, {\n ...step.options,\n signal: controller.signal,\n });\n const result = await handle.result;\n\n if (result.status === 'cancelled') break;\n}\n```\n\nOne parent `AbortSignal` cancels the active step without introducing a separate timeline abstraction.\n\n## Animating a Reorder with FLIP\n\nCapture positions before changing layout, then animate through the returned one-shot transition.\n\n```ts\nimport { captureLayout } from '@vielzeug/necromancer';\n\nconst transition = captureLayout(items);\nlist.prepend(items[2]!);\n\nconst group = transition.animate({ duration: 220, easing: 'ease-out' });\nawait group.results;\ngroup.dispose();\n```\n\nThe transition only animates changed, connected elements and can be animated once. It additively composes the individual CSS `translate` and `scale` properties, preserving authored `transform`, `translate`, and `scale`. A resized element (for example a list item whose content changed) animates from its captured size as well as its captured position.\n\n### Replacing rendered elements\n\nWhen a framework replaces list nodes rather than reorders the captured elements, give `captureLayout()` a stable key and pass the committed nodes to `animate()`. Capture before updating state, then call `animate()` only after the renderer has committed the new DOM.\n\n```ts\nconst transition = captureLayout(beforeItems, {\n getKey: (element) => element.getAttribute('data-id')!,\n});\n\nrenderReorderedItems();\n\ntransition.animate({\n duration: 220,\n easing: 'ease-out',\n elements: afterItems,\n});\n```\n\nKeys must be unique, non-empty strings in both collections. Items with no matching predecessor are not enter animations; animate those explicitly with `animate()` or `animateEach()`.\n\nFor sortable lists, DnD exposes its pre-commit layout seam through `onBeforeReorder`; see the [DnD optimistic-reorder recipe](/dnd/examples/optimistic-reorder-with-revert.md).\n\n## Scope\n\nNecromancer creates and owns explicit Web Animations API work. It does not observe CSS-authored transitions or animations, inject `@keyframes`, watch DOM mutations, generate springs, interpolate SVG geometry, or provide a JavaScript tween fallback. Keep CSS as the owner of declarative component styling and use a dedicated charting or tweening tool when the animation needs capabilities beyond WAAPI keyframes.\n\n## Testing\n\njsdom does not implement `Element.animate()`, so lifecycle tests need a fake. `@vielzeug/necromancer/testing` has no test-runner import and supports explicit resource management.\n\n```ts\nimport { animate } from '@vielzeug/necromancer';\nimport { installFakeAnimations } from '@vielzeug/necromancer/testing';\n\nusing animations = installFakeAnimations();\nconst handle = animate(element, [{ opacity: 0 }, { opacity: 1 }]);\n\nanimations.calls[0]?.animation.finish();\nawait handle.result; // { status: 'finished' }\n```\n\nUse `restore()` when `using` is unavailable. The lifecycle fake supports `finish()`, `cancel()`, and `finished`; use browser tests for native playback controls. Use `createRect()` to mock `Element.getBoundingClientRect()` when testing code that calls `captureLayout()`.\n\n## Framework Integration\n\nCreate handles in a client mount lifecycle and dispose them during unmount. The same composition works with reactive effect systems: start the animation in the effect and return `handle.dispose()` as its cleanup.\n\n```tsx\nimport { useEffect, useRef } from 'react';\nimport { animate } from '@vielzeug/necromancer';\n\nexport function Notice() {\n const elementRef = useRef<HTMLDivElement>(null);\n\n useEffect(() => {\n const element = elementRef.current;\n if (!element) return;\n\n const handle = animate(element, [{ opacity: 0 }, { opacity: 1 }], { duration: 180 });\n return () => handle.dispose();\n }, []);\n\n return <div ref={elementRef}>Saved</div>;\n}\n```\n\n## Working with Other Vielzeug Libraries\n\nUse Dnd's `onBeforeReorder` hook to capture item layout before a sortable commits its DOM update. Animate the captured transition after `onReorder`; see the [optimistic reorder recipe](/dnd/examples/optimistic-reorder-with-revert.md).\n\n## Best Practices\n\n- Start animations only after their elements mount in the browser.\n- Set animation timing explicitly when native zero-duration behavior is not intended.\n- Dispose each handle or group with its UI owner.\n- Use native `Animation` objects for playback control.\n- Respect the default `'system'` motion setting unless movement is essential.\n- Use a parent `AbortSignal` for cancellable application flow.\n- Keep `delay` numeric when combining it with non-zero `stagger`.\n- Capture layout before mutation and animate each transition exactly once.\n",
7
- "examples": "---\ntitle: Necromancer — Examples\ndescription: Practical animation and FLIP layout recipes for @vielzeug/necromancer.\n---\n\n## Examples\n\n- [Animate on Mount](./examples/animate-on-mount.md)\n- [Stagger a List](./examples/stagger-a-list.md)\n- [Animate a Reorder](./examples/animate-a-reorder.md)\n\n"
4
+ "index": "---\ntitle: 'Necromancer: Lifecycle-owned DOM animations'\ndescription: Lifecycle-owned Web Animations API primitives for native playback, groups, and additive FLIP transitions.\npackage: necromancer\ncategory: ui\nkeywords: [animation, web-animations-api, waapi, flip, stagger, reduced-motion]\nrelated: [orbit, ore]\nexports: [animate, animateEach, captureLayout]\nenvironments: [browser]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"necromancer\" />\n\n## Why Necromancer?\n\nNative Web Animations API calls do not provide lifecycle ownership, reduced-motion policy, grouped playback, or layout transitions. Necromancer retains native keyframes and timing semantics while making ownership explicit for a component or DOM feature.\n\n```ts\n// Before\nconst animation = element.animate(keyframes, { duration: 180 });\nanimation.addEventListener('cancel', removeListeners);\n\n// After\nconst animation = animate(element, keyframes, { duration: 180 });\nanimation.dispose();\n```\n\n| Feature | Native WAAPI | Necromancer | Motion One |\n| --- | --- | --- | --- |\n| Bundle size | 0 B | <PackageInfo package=\"necromancer\" type=\"size\" /> | ~18 kB |\n| Root dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Lifecycle handle | Manual | `dispose()` | Library-specific controls |\n| Reduced motion | Manual | `motion: 'system'` default | Configuration required |\n| Layout transitions | Manual FLIP math | `captureLayout().animate()` | Separate API |\n\n<div class=\"decision-callout\">\n\n**Use Necromancer when** you need native browser animations with explicit cancellation, reduced-motion behavior, staggered groups, or positional FLIP transitions.\n\n**Consider CSS transitions when** a static style change needs no playback control, cleanup, or layout measurement.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/necromancer\n```\n\n```sh [npm]\nnpm install @vielzeug/necromancer\n```\n\n```sh [yarn]\nyarn add @vielzeug/necromancer\n```\n\n:::\n\n## Quick Start\n\nStart the animation after its DOM element mounts and release it when its UI owner is removed.\n\n```ts\nimport { animate } from '@vielzeug/necromancer';\n\nconst notice = document.createElement('p');\nnotice.textContent = 'Saved';\ndocument.body.append(notice);\n\nconst animation = animate(\n notice,\n [{ opacity: 0, transform: 'translateY(8px)' }, { opacity: 1, transform: 'translateY(0)' }],\n { duration: 180, easing: 'ease-out' },\n);\n\nawait animation.result;\nanimation.dispose();\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `animate()`: Native element animation with lifecycle ownership and direct native access\n- `animateEach()`: Group ownership with stable keyframe factories and `stagger`\n- `captureLayout()`: One-shot FLIP transition with additive `translate` (position) and `scale` (size)\n- `motion`: `'system'` reduced-motion support with explicit reduced outcomes\n- Native timing: Omitted timing fields retain Web Animations API behavior\n- `signal`: Abort a handle from its parent lifecycle\n- `dispose()`: Idempotent cleanup with `[Symbol.dispose]()`\n\n</div>\n\n## Deliberate Scope\n\nNecromancer owns explicit WAAPI keyframes. It does not generate CSS keyframes, observe CSS transitions, watch mutations, simulate springs, interpolate SVG paths, or run a JavaScript tween loop. Use CSS for declarative style changes and choose a dedicated tool when those capabilities are required.\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [3.0 Migration](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Orbit](/orbit/): Position floating UI before animating its appearance.\n- [Ore](/ore/): Own Necromancer handles in a custom element's mount and disposal lifecycle.\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
5
+ "api": "---\ntitle: 'Necromancer: API Reference'\ndescription: API reference for @vielzeug/necromancer animation ownership, groups, reduced motion, and FLIP transitions.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `animate()` | Animate one element | Sync | Omitted timing fields use native WAAPI behavior |\n| `animateEach()` | Animate a unique element group | Sync | Non-zero `stagger` needs numeric `delay` |\n| `captureLayout()` | Capture positions and create a one-shot FLIP transition | Sync | Capture before changing layout |\n| `shouldReduceMotion()` | Report whether a motion mode reduces visible movement | Sync | `'system'` reads `prefers-reduced-motion`; false without `matchMedia` |\n| `NecromancerError` | Base package error | Sync | Use `instanceof NecromancerError` to narrow unknown errors |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/necromancer` | Animation functions, types, and errors |\n| `@vielzeug/necromancer/testing` | jsdom test fakes for `Element.animate()` and `getBoundingClientRect()` |\n\n## Animation Functions\n\n### `animate()`\n\n```ts\nfunction animate(element: Element, keyframes: Keyframes, options?: AnimateOptions): AnimationHandle;\n```\n\nStarts a lifecycle-owned native Web Animation. Native timing options, including an omitted or zero `duration`, are preserved.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `element` | `Element` | Element animated through its native `animate()` method. |\n| `keyframes` | `Keyframes` | Native keyframe array or property-indexed keyframes. |\n| `options` | `AnimateOptions` | Native timing plus motion and cancellation ownership. |\n\n**Returns:** An `AnimationHandle` with native playback access and explicit disposal.\n\n**Example:**\n\n```ts\nimport { animate } from '@vielzeug/necromancer';\n\nconst handle = animate(element, [{ opacity: 0 }, { opacity: 1 }], { duration: 180 });\nhandle.animation.pause();\nconst result = await handle.result;\nhandle.dispose();\n```\n\n---\n\n### `animateEach()`\n\n```ts\nfunction animateEach<ElementType extends Element>(\n elements: Iterable<ElementType>,\n keyframes: Keyframes | KeyframeFactory<ElementType>,\n options?: AnimateEachOptions,\n): AnimationGroup;\n```\n\nStarts animations for unique elements in first-seen order. Necromancer resolves every keyframe factory before starting the first native animation.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `elements` | `Iterable<ElementType>` | Elements animated once in first-seen order. |\n| `keyframes` | `Keyframes \\| KeyframeFactory<ElementType>` | Shared keyframes or a subtype-preserving factory. |\n| `options` | `AnimateEachOptions` | Animation options plus a stagger interval. |\n\n**Returns:** An `AnimationGroup` containing child handles and ordered results.\n\n**Example:**\n\n```ts\nimport { animateEach } from '@vielzeug/necromancer';\n\nconst group = animateEach(document.querySelectorAll<HTMLElement>('.item'), [{ opacity: 0 }, { opacity: 1 }], {\n duration: 180,\n stagger: 30,\n});\nawait group.results;\n```\n\n### `shouldReduceMotion()`\n\n```ts\nfunction shouldReduceMotion(mode: MotionMode): boolean;\n```\n\nReports whether the requested motion preference reduces visible movement: the same check `animate()` applies internally, exposed so hand-rolled effects (scroll behavior, tweens) stay consistent with it. `'full'` is always `false`, `'reduced'` is always `true`, and `'system'` reads the `prefers-reduced-motion` media query (`false` where `matchMedia` is unavailable).\n\n```ts\nimport { shouldReduceMotion } from '@vielzeug/necromancer';\n\nelement.scrollIntoView({ behavior: shouldReduceMotion('system') ? 'auto' : 'smooth' });\n```\n\n## Layout Functions\n\n### `captureLayout()`\n\n```ts\nfunction captureLayout<ElementType extends Element>(\n elements: Iterable<ElementType>,\n options?: LayoutCaptureOptions<ElementType>,\n): LayoutTransition<ElementType>;\n```\n\nCaptures unique elements' positions and sizes and returns a one-shot transition. Rotation and other transforms are not captured or compensated.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `elements` | `Iterable<ElementType>` | Elements measured before a layout change. |\n| `options` | `LayoutCaptureOptions<ElementType>` | Optional stable key mapping for replacement nodes. |\n\n**Returns:** A `LayoutTransition<ElementType>` that measures committed layout once and creates an `AnimationGroup`.\n\n**Example:**\n\n```ts\nimport { captureLayout } from '@vielzeug/necromancer';\n\nconst transition = captureLayout(beforeItems, {\n getKey: (element) => element.getAttribute('data-id')!,\n});\n\nrenderReorderedItems();\n\nconst group = transition.animate({\n duration: 220,\n easing: 'ease-out',\n elements: afterItems,\n});\n```\n\nCalling `animate()` twice on the same transition throws `NecromancerConfigError`.\n\n## Types\n\n### `MotionMode`\n\n```ts\ntype MotionMode = 'full' | 'reduced' | 'system';\n```\n\n`'system'` is the default. Reduced motion preserves the supplied keyframes while normalizing delay, duration, and end delay to `0`, and iterations to `1`.\n\n### `AnimationResult`\n\n```ts\ntype AnimationResult =\n | { readonly status: 'finished' }\n | { readonly status: 'reduced' }\n | { readonly reason?: unknown; readonly status: 'cancelled' };\n```\n\n`cancelled` describes native cancellation and includes its native rejection reason. A reason passed to `dispose()` or an abort signal takes precedence. The independent `disposed` property becomes `true` only when the lifecycle owner is explicitly disposed.\n\n### `AnimateOptions`\n\n```ts\ntype AnimateOptions = KeyframeAnimationOptions & {\n readonly motion?: MotionMode;\n readonly signal?: AbortSignal;\n};\n```\n\nNative timing fields pass through unchanged unless reduced motion normalizes timing. `signal` disposes the returned handle when aborted.\n\n### `AnimateEachOptions`\n\n```ts\ntype AnimateEachOptions = AnimateOptions & {\n readonly stagger?: number;\n};\n```\n\n`stagger` is a finite, non-negative millisecond offset.\n\n### `LayoutCaptureOptions`\n\n```ts\ninterface LayoutCaptureOptions<ElementType extends Element = Element> {\n readonly getKey?: (element: ElementType) => string;\n}\n```\n\n`getKey` maps a captured element and its committed replacement to the same stable, non-empty string. Duplicate or empty keys throw `NecromancerConfigError`.\n\n### `LayoutAnimationOptions`\n\n```ts\ntype LayoutAnimationOptions<ElementType extends Element = Element> = AnimateEachOptions & {\n readonly elements?: Iterable<ElementType>;\n};\n```\n\n`elements` is the collection in its committed layout. Omit it to animate the same captured elements. With `getKey`, replacement elements animate from the positions of their captured predecessors. Unmatched, removed, and newly entered elements are ignored.\n\n### `Keyframes` and `KeyframeFactory`\n\n```ts\ntype Keyframes = readonly Keyframe[] | PropertyIndexedKeyframes;\ntype KeyframeFactory<ElementType extends Element = Element> = (\n element: ElementType,\n index: number,\n total: number,\n) => Keyframes;\n```\n\nAccepts a `readonly` array so a reusable `as const` keyframe list can be passed without a cast.\n\n### `AnimationHandle`\n\n```ts\ninterface AnimationHandle {\n readonly animation: Animation;\n readonly result: Promise<AnimationResult>;\n readonly disposed: boolean;\n dispose(reason?: unknown): void;\n [Symbol.dispose](): void;\n}\n```\n\n| Member | Description |\n| --- | --- |\n| `animation` | Owned native animation used for playback control. |\n| `result` | Resolves with the first terminal outcome. |\n| `disposed` | Reports explicit owner disposal. |\n| `dispose(reason?)` | Cancels the animation and releases its abort listener. |\n| `[Symbol.dispose]()` | Delegates to `dispose()`. |\n\n### `AnimationGroup`\n\n```ts\ninterface AnimationGroup {\n readonly handles: readonly AnimationHandle[];\n readonly results: Promise<readonly AnimationResult[]>;\n readonly disposed: boolean;\n dispose(reason?: unknown): void;\n [Symbol.dispose](): void;\n}\n```\n\n| Member | Description |\n| --- | --- |\n| `handles` | Child handles in input order. |\n| `results` | Resolves with every child outcome in handle order. |\n| `disposed` | Reports explicit group disposal. |\n| `dispose(reason?)` | Disposes every child. |\n| `[Symbol.dispose]()` | Delegates to `dispose()`. |\n\n### `LayoutTransition`\n\n```ts\ninterface LayoutTransition<ElementType extends Element = Element> {\n animate(options?: LayoutAnimationOptions<ElementType>): AnimationGroup;\n}\n```\n\n| Method | Description |\n| --- | --- |\n| `animate(options?)` | Measures committed elements and creates the one allowed layout animation group. |\n\n## Errors\n\n| Error | Trigger |\n| --- | --- |\n| `NecromancerError` | Base class for package errors |\n| `NecromancerConfigError` | Invalid stagger, incompatible delay, or reused layout transition |\n| `NecromancerUnsupportedError` | `Element.animate()` is unavailable |\n\n## Testing (`@vielzeug/necromancer/testing`)\n\njsdom (and most non-browser DOM environments) do not implement `Element.animate()`. Import these from the `/testing` sub-path, not the root entry point.\n\n### `AnimationCall`\n\n```ts\ntype AnimationCall = {\n readonly animation: FakeAnimation;\n readonly keyframes: Keyframe[] | PropertyIndexedKeyframes;\n readonly options?: KeyframeAnimationOptions;\n};\n```\n\nOne recorded invocation of `Element.prototype.animate` from `installFakeAnimations()`.\n\n### `installFakeAnimations()`\n\n```ts\nfunction installFakeAnimations(): {\n readonly calls: readonly AnimationCall[];\n restore(): void;\n [Symbol.dispose](): void;\n};\n```\n\nReplaces `Element.prototype.animate` with a deterministic lifecycle fake for the duration of a test. `calls` records every invocation in order. Call `restore()` or use an explicit resource-management `using` declaration to put the original implementation back. The fake does not implement native playback controls such as `pause()` or `reverse()`.\n\n```ts\nimport { installFakeAnimations } from '@vielzeug/necromancer/testing';\n\nusing animations = installFakeAnimations();\nconst handle = animate(element, [{ opacity: 0 }, { opacity: 1 }]);\n\nanimations.calls[0]?.animation.finish();\nawait handle.result; // { status: 'finished' }\n```\n\n### `FakeAnimation`\n\n```ts\nclass FakeAnimation {\n cancelCallCount: number;\n finishCallCount: number;\n finished: Promise<void>;\n cancel(): void;\n finish(): void;\n}\n```\n\nA minimal `Animation` stand-in. `cancel()` rejects `finished` with an `AbortError`; `finish()` resolves it. `cancelCallCount`/`finishCallCount` track how many times each was called, in place of a test-runner-specific spy.\n\n### `createRect()`\n\n```ts\nfunction createRect(x: number, y: number, width?: number, height?: number): DOMRect;\n```\n\nBuilds a `DOMRect` for mocking `Element.getBoundingClientRect()` in `captureLayout()` tests. `width`/`height` default to `20`.\n",
6
+ "usage": "---\ntitle: 'Necromancer: Usage Guide'\ndescription: Animate DOM elements, coordinate groups, and create FLIP transitions with @vielzeug/necromancer.\n---\n\n[[toc]]\n\n## Basic Usage\n\nCreate an animation after its element mounts, control playback through the native `Animation`, and dispose its owner with the UI lifecycle.\n\n```ts\nimport { animate } from '@vielzeug/necromancer';\n\nconst handle = animate(\n element,\n [{ opacity: 0, transform: 'translateY(8px)' }, { opacity: 1, transform: 'translateY(0)' }],\n { duration: 180, easing: 'ease-out', fill: 'both' },\n);\n\nhandle.animation.reverse();\nconst result = await handle.result;\nhandle.dispose();\n```\n\n`result` distinguishes natural completion, reduced timing, and cancellation. `disposed` reports only whether the owner was explicitly disposed.\n\nOmitted timing fields retain native Web Animations API behavior. Pass an explicit `duration` when the animation should run over time.\n\n## Motion Preferences\n\nUse `motion` to select how the animation responds to the operating system preference.\n\n```ts\nconst handle = animate(element, [{ opacity: 0 }, { opacity: 1 }], {\n duration: 200,\n motion: 'system',\n});\n\nconst result = await handle.result;\n```\n\n`'system'` is the default and reduces movement when `prefers-reduced-motion: reduce` matches. `'full'` preserves the requested timing, while `'reduced'` always reduces it.\n\nReduced motion keeps the supplied keyframes but normalizes delay, duration, and end delay to zero and iterations to one. The result is `{ status: 'reduced' }`, and `handle.animation` still represents the requested visual transition.\n\n## Parent Cancellation\n\nPass a parent `AbortSignal` to release an animation when its owning work is cancelled.\n\n```ts\nconst controller = new AbortController();\nconst handle = animate(element, [{ scale: 0.96 }, { scale: 1 }], {\n duration: 160,\n signal: controller.signal,\n});\n\ncontroller.abort('route changed');\nconst result = await handle.result;\n// { status: 'cancelled', reason: 'route changed' }\n```\n\nAn already-aborted signal throws its reason before an animation starts.\n\n## Staggering a Group\n\nPass an iterable of elements to `animateEach()`. Duplicate elements are animated once in first-seen order.\n\n```ts\nimport { animateEach } from '@vielzeug/necromancer';\n\nconst group = animateEach(\n document.querySelectorAll('.card'),\n (_card, index) => [\n { opacity: 0, transform: `translateY(${12 + index * 2}px)` },\n { opacity: 1, transform: 'translateY(0)' },\n ],\n { duration: 220, easing: 'ease-out', stagger: 45 },\n);\n\nconst results = await group.results;\ngroup.dispose();\n```\n\nA group owns child lifecycles only. `results` preserves every child result in handle order; use `group.handles` when native playback control is required.\n\n## Serial Application Flow\n\nJavaScript control flow is the clearest way to express serial, conditional, or branching animations:\n\n```ts\nfor (const step of steps) {\n const handle = animate(step.element, step.keyframes, {\n ...step.options,\n signal: controller.signal,\n });\n const result = await handle.result;\n\n if (result.status === 'cancelled') break;\n}\n```\n\nOne parent `AbortSignal` cancels the active step without introducing a separate timeline abstraction.\n\n## Animating a Reorder with FLIP\n\nCapture positions before changing layout, then animate through the returned one-shot transition.\n\n```ts\nimport { captureLayout } from '@vielzeug/necromancer';\n\nconst transition = captureLayout(items);\nlist.prepend(items[2]!);\n\nconst group = transition.animate({ duration: 220, easing: 'ease-out' });\nawait group.results;\ngroup.dispose();\n```\n\nThe transition only animates changed, connected elements and can be animated once. It additively composes the individual CSS `translate` and `scale` properties, preserving authored `transform`, `translate`, and `scale`. A resized element (for example a list item whose content changed) animates from its captured size as well as its captured position.\n\n### Replacing rendered elements\n\nWhen a framework replaces list nodes rather than reorders the captured elements, give `captureLayout()` a stable key and pass the committed nodes to `animate()`. Capture before updating state, then call `animate()` only after the renderer has committed the new DOM.\n\n```ts\nconst transition = captureLayout(beforeItems, {\n getKey: (element) => element.getAttribute('data-id')!,\n});\n\nrenderReorderedItems();\n\ntransition.animate({\n duration: 220,\n easing: 'ease-out',\n elements: afterItems,\n});\n```\n\nKeys must be unique, non-empty strings in both collections. Items with no matching predecessor are not enter animations; animate those explicitly with `animate()` or `animateEach()`.\n\nFor sortable lists, DnD exposes its pre-commit layout seam through `onBeforeReorder`; see the [DnD optimistic-reorder recipe](/dnd/examples/optimistic-reorder-with-revert.md).\n\n## Scope\n\nNecromancer creates and owns explicit Web Animations API work. It does not observe CSS-authored transitions or animations, inject `@keyframes`, watch DOM mutations, generate springs, interpolate SVG geometry, or provide a JavaScript tween fallback. Keep CSS as the owner of declarative component styling and use a dedicated charting or tweening tool when the animation needs capabilities beyond WAAPI keyframes.\n\n## Testing\n\njsdom does not implement `Element.animate()`, so lifecycle tests need a fake. `@vielzeug/necromancer/testing` has no test-runner import and supports explicit resource management.\n\n```ts\nimport { animate } from '@vielzeug/necromancer';\nimport { installFakeAnimations } from '@vielzeug/necromancer/testing';\n\nusing animations = installFakeAnimations();\nconst handle = animate(element, [{ opacity: 0 }, { opacity: 1 }]);\n\nanimations.calls[0]?.animation.finish();\nawait handle.result; // { status: 'finished' }\n```\n\nUse `restore()` when `using` is unavailable. The lifecycle fake supports `finish()`, `cancel()`, and `finished`; use browser tests for native playback controls. Use `createRect()` to mock `Element.getBoundingClientRect()` when testing code that calls `captureLayout()`.\n\n## Framework Integration\n\nCreate handles in a client mount lifecycle and dispose them during unmount. The same composition works with reactive effect systems: start the animation in the effect and return `handle.dispose()` as its cleanup.\n\n```tsx\nimport { useEffect, useRef } from 'react';\nimport { animate } from '@vielzeug/necromancer';\n\nexport function Notice() {\n const elementRef = useRef<HTMLDivElement>(null);\n\n useEffect(() => {\n const element = elementRef.current;\n if (!element) return;\n\n const handle = animate(element, [{ opacity: 0 }, { opacity: 1 }], { duration: 180 });\n return () => handle.dispose();\n }, []);\n\n return <div ref={elementRef}>Saved</div>;\n}\n```\n\n## Working with Other Vielzeug Libraries\n\nUse Dnd's `onBeforeReorder` hook to capture item layout before a sortable commits its DOM update. Animate the captured transition after `onReorder`; see the [optimistic reorder recipe](/dnd/examples/optimistic-reorder-with-revert.md).\n\n## Best Practices\n\n- Start animations only after their elements mount in the browser.\n- Set animation timing explicitly when native zero-duration behavior is not intended.\n- Dispose each handle or group with its UI owner.\n- Use native `Animation` objects for playback control.\n- Respect the default `'system'` motion setting unless movement is essential.\n- Use a parent `AbortSignal` for cancellable application flow.\n- Keep `delay` numeric when combining it with non-zero `stagger`.\n- Capture layout before mutation and animate each transition exactly once.\n",
7
+ "examples": "---\ntitle: 'Necromancer: Examples'\ndescription: Practical animation and FLIP layout recipes for @vielzeug/necromancer.\n---\n\n## Examples\n\n- [Animate on Mount](./examples/animate-on-mount.md)\n- [Stagger a List](./examples/stagger-a-list.md)\n- [Animate a Reorder](./examples/animate-a-reorder.md)\n\n"
8
8
  },
9
9
  "examples": [
10
10
  {
@@ -14,7 +14,7 @@
14
14
  },
15
15
  {
16
16
  "id": "lifecycle",
17
- "code": "import { animate } from '@vielzeug/necromancer'\n\nconst panel = document.createElement('section')\npanel.style.cssText = 'display: grid; gap: 12px; max-width: 320px; padding: 20px; border: 1px solid #cbd5e1; border-radius: 12px; background: #fff;'\n\nconst card = document.createElement('div')\ncard.textContent = 'Lifecycle-owned animation'\ncard.style.cssText = 'padding: 18px; border-radius: 8px; color: #fff; background: #2563eb; font: 600 16px system-ui;'\n\nconst replay = document.createElement('button')\nreplay.textContent = 'Replay animation'\n\nconst status = document.createElement('output')\nstatus.textContent = 'Ready'\n\npanel.append(card, replay, status)\ndocument.body.appendChild(panel)\n\nlet current\n\nfunction run() {\n current?.dispose('replayed')\n current = animate(\n card,\n [\n { opacity: 0, transform: 'translateY(14px) scale(.96)' },\n { opacity: 1, transform: 'translateY(0) scale(1)' },\n ],\n { duration: 280, easing: 'ease-out', fill: 'both' },\n )\n status.textContent = 'Animating…'\n current.result.then((result) => {\n status.textContent = result.status === 'finished' ? 'Finished — handle remains disposable' : result.status === 'reduced' ? 'Finished with reduced timing' : 'Cancelled'\n })\n}\n\nreplay.addEventListener('click', run)\n",
17
+ "code": "import { animate } from '@vielzeug/necromancer'\n\nconst panel = document.createElement('section')\npanel.style.cssText = 'display: grid; gap: 12px; max-width: 320px; padding: 20px; border: 1px solid #cbd5e1; border-radius: 12px; background: #fff;'\n\nconst card = document.createElement('div')\ncard.textContent = 'Lifecycle-owned animation'\ncard.style.cssText = 'padding: 18px; border-radius: 8px; color: #fff; background: #2563eb; font: 600 16px system-ui;'\n\nconst replay = document.createElement('button')\nreplay.textContent = 'Replay animation'\n\nconst status = document.createElement('output')\nstatus.textContent = 'Ready'\n\npanel.append(card, replay, status)\ndocument.body.appendChild(panel)\n\nlet current\n\nfunction run() {\n current?.dispose('replayed')\n current = animate(\n card,\n [\n { opacity: 0, transform: 'translateY(14px) scale(.96)' },\n { opacity: 1, transform: 'translateY(0) scale(1)' },\n ],\n { duration: 280, easing: 'ease-out', fill: 'both' },\n )\n status.textContent = 'Animating…'\n current.result.then((result) => {\n status.textContent = result.status === 'finished' ? 'Finished: handle remains disposable' : result.status === 'reduced' ? 'Finished with reduced timing' : 'Cancelled'\n })\n}\n\nreplay.addEventListener('click', run)\n",
18
18
  "name": "animate() - Lifecycle Handle"
19
19
  },
20
20
  {
@@ -1,20 +1,20 @@
1
1
  {
2
2
  "apiSource": "// Auto-update\nexport type { AutoUpdateOptions } from './auto-update';\nexport { autoUpdate } from './auto-update';\n// Core engine\nexport { computePosition } from './core';\n// Errors\nexport { OrbitConfigError, OrbitError } from './errors';\n// High-level API\nexport type { Positioner, PositionerOptions, PositionStrategy } from './float';\nexport { createPositioner } from './float';\n// Inline middleware\nexport type { InlineOptions } from './inline';\nexport { inline } from './inline';\n// Middleware\nexport type { ArrowOptions } from './middleware/arrow';\nexport { arrow } from './middleware/arrow';\nexport type { AutoPlacementOptions } from './middleware/auto-placement';\nexport { autoPlacement } from './middleware/auto-placement';\nexport type { FlipOptions } from './middleware/flip';\nexport { flip } from './middleware/flip';\nexport type { HideOptions } from './middleware/hide';\nexport { hide } from './middleware/hide';\nexport type { OffsetConfig, OffsetValue } from './middleware/offset';\nexport { offset } from './middleware/offset';\nexport type { LimitShiftOptions, ShiftLimiter, ShiftOptions } from './middleware/shift';\nexport { limitShift, shift } from './middleware/shift';\nexport type { SizeOptions } from './middleware/size';\nexport { size } from './middleware/size';\n// Overflow helpers\nexport { detectOverflow, getBoundaryRect, getClippingAncestorRect } from './overflow';\n// Preset types (functions live on the @vielzeug/orbit/presets sub-path)\nexport type { PositioningPreset, PresetOptions } from './presets';\n// Types\nexport type {\n Alignment,\n ArrowData,\n ComputePositionOptions,\n ComputePositionResult,\n DetectOverflowOptions,\n FlipData,\n HideData,\n Middleware,\n MiddlewareData,\n MiddlewareReset,\n MiddlewareResult,\n MiddlewareState,\n Padding,\n Placement,\n Rect,\n ReferenceElement,\n ShiftData,\n Side,\n SideObject,\n SizeData,\n VirtualReference,\n} from './types';\n// Public utilities\nexport { getAlignment, getSide } from './utils';\n",
3
3
  "docs": {
4
- "index": "---\ntitle: Orbit — Floating UI positioning\ndescription: Floating positioning with lifecycle-owned geometry and middleware.\npackage: orbit\ncategory: ui\nkeywords: [positioning, tooltip, popover, dropdown, middleware, floating-ui]\nexports: [autoUpdate, computePosition, createPositioner]\nrelated: [ore, refine, prism]\nenvironments: [browser]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"orbit\" />\n\n## Why Orbit?\n\nFloating UI needs one owner for CSS coordinates, clipping boundaries, updates, and cleanup. Orbit provides a lifecycle positioner for normal UI and a pure computation API for advanced integrations.\n\n```ts\n// Before\nconst { x, y } = computeSomehow(trigger, panel);\npanel.style.left = `${x}px`;\npanel.style.top = `${y}px`;\n\n// After\nconst positioner = createPositioner(trigger, panel);\n```\n\n| Feature | Manual DOM positioning | Orbit |\n| --- | --- | --- |\n| Bundle size | 0 B | <PackageInfo package=\"orbit\" type=\"size\" /> |\n| Root dependencies | Application-defined | `@vielzeug/arsenal` |\n| Clipping boundary | Manual geometry | `clippingAncestors` default |\n| Coordinate strategy | Consumer logic | `fixed` / `absolute` |\n| Cleanup | Manual listeners | `dispose()` |\n\n<div class=\"decision-callout\">\n\n**Use Orbit when** floating UI needs robust placement, collision handling, or reactive updates.\n\n**Consider direct CSS when** placement is static and never depends on element geometry.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/orbit\n```\n\n```sh [npm]\nnpm install @vielzeug/orbit\n```\n\n```sh [yarn]\nyarn add @vielzeug/orbit\n```\n\n:::\n\n## Quick Start\n\nCreate a positioner only after its reference and floating elements mount; positioning starts immediately.\n\n```ts\nimport { createPositioner, flip, offset, shift } from '@vielzeug/orbit';\n\nconst positioner = createPositioner(trigger, tooltip, {\n middleware: [offset(8), flip(), shift({ padding: 6 })],\n placement: 'top',\n});\n\npositioner.dispose();\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `createPositioner()` — Lifecycle-owned floating positioning\n- `computePosition()` — Low-level calculation for advanced integrations\n- `autoUpdate()` — Scroll, viewport, resize, and animation-frame updates\n- Middleware — Offset, flip, shift, size, hide, arrow, inline, auto-placement\n- `strategy` — Explicit `fixed` or `absolute` coordinate behavior\n- `/reactive` — Optional Ripple position readable\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Refine](/refine/) — Accessible components using floating UI behavior.\n- [Ore](/ore/) — Lifecycle ownership for custom-element positioning.\n- [Prism](/prism/) — Chart tooltips positioned from virtual references.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
5
- "api": "---\ntitle: Orbit — API Reference\ndescription: API reference for @vielzeug/orbit positioners, computation, updates, middleware, and optional reactive integration.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createPositioner()` | Lifecycle-owned floating positioning | Sync | Starts immediately; create after mount |\n| `computePosition()` | Low-level geometry computation | Sync | Caller owns CSS application |\n| `autoUpdate()` | Listen for geometry changes | Sync | Call returned cleanup |\n| `createReactivePositioner()` | Optional Ripple position readable | Sync | Requires `@vielzeug/ripple` |\n| Middleware factories | Adjust placement and size | Sync | Order is explicit |\n\n## Package Entry Points\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/orbit` | Positioner, computation, updates, middleware, and types. |\n| `@vielzeug/orbit/reactive` | Optional Ripple position adapter. |\n| `@vielzeug/orbit/presets` | Preset placement and middleware options. |\n| `@vielzeug/orbit/devtools` | Development overlay. |\n\n## Core Functions\n\n### `createPositioner()`\n\n```ts\nfunction createPositioner(\n reference: ReferenceElement,\n floating: HTMLElement,\n options?: PositionerOptions,\n): Positioner;\n```\n\nCreates an active positioner and applies its initial position before returning.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `reference` | `ReferenceElement` | DOM or virtual anchor. |\n| `floating` | `HTMLElement` | Positioned element. |\n| `options` | `PositionerOptions` | Strategy, clipping, middleware, updates, and application callback. |\n\n**Returns:** `Positioner`.\n\n```ts\nimport { createPositioner } from '@vielzeug/orbit';\n\nconst positioner = createPositioner(trigger, tooltip);\npositioner.dispose();\n```\n\n| Member | Return | Contract |\n| --- | --- | --- |\n| `update()` | `void` | Recomputes and applies position. |\n| `getPosition()` | `ComputePositionResult` | Latest applied result. |\n| `dispose()` | `void` | Stops updates and aborts disposal signal. |\n\n### `computePosition()`\n\n```ts\nfunction computePosition(\n reference: ReferenceElement,\n floating: HTMLElement,\n options?: ComputePositionOptions,\n): ComputePositionResult;\n```\n\nCalculates position without applying DOM styles or creating listeners.\n\n**Returns:** `ComputePositionResult`.\n\n### `autoUpdate()`\n\n```ts\nfunction autoUpdate(\n reference: ReferenceElement,\n floating: HTMLElement,\n update: () => void,\n options?: AutoUpdateOptions,\n): () => void;\n```\n\nCalls `update` immediately, then on relevant scroll, viewport, resize, and optional animation-frame changes.\n\n**Returns:** cleanup callback.\n\n## Middleware\n\n```ts\ntype Middleware = (state: MiddlewareState) => MiddlewareResult | undefined;\n```\n\nBuilt-in factories: `arrow`, `autoPlacement`, `flip`, `hide`, `inline`, `offset`, `shift`, `limitShift`, and `size`.\n\n```ts\nconst middleware = [offset(8), flip(), shift({ padding: 6 }), size()];\n```\n\n`middlewareData` is `MiddlewareData`; narrow custom data at the consuming boundary.\n\n## Reactive Adapter\n\n```ts\nfunction createReactivePositioner(\n reference: ReferenceElement,\n floating: HTMLElement,\n options?: Omit<PositionerOptions, 'apply'>,\n): ReactivePositioner;\n```\n\n`ReactivePositioner.position` is `Readable<ComputePositionResult>`. Imported from `@vielzeug/orbit/reactive`.\n\n## Types\n\n```ts\ntype Side = 'top' | 'bottom' | 'left' | 'right';\ntype Alignment = 'start' | 'end';\ntype Placement = Side | `${Side}-${Alignment}`;\n\ninterface Rect {\n height: number;\n width: number;\n x: number;\n y: number;\n}\n\ninterface VirtualReference {\n getBoundingClientRect: () => DOMRect | Rect;\n getClientRects?: () => DOMRectList | DOMRect[];\n}\n\ntype ReferenceElement = Element | VirtualReference;\n\ninterface SideObject {\n bottom: number;\n left: number;\n right: number;\n top: number;\n}\n\ntype Padding = number | Partial<SideObject>;\n\ninterface ArrowData {\n centerOffset: number;\n constrained: boolean;\n x?: number;\n y?: number;\n}\n\ninterface FlipData {\n skippedPlacements: Placement[];\n}\n\ninterface ShiftData {\n x: number;\n y: number;\n}\n\ninterface HideData {\n escaped?: boolean;\n escapedOffsets?: SideObject;\n referenceHidden?: boolean;\n referenceHiddenOffsets?: SideObject;\n}\n\ninterface SizeData {\n availableHeight: number;\n availableWidth: number;\n}\n\ninterface MiddlewareData {\n arrow?: ArrowData;\n flip?: FlipData;\n hide?: HideData;\n shift?: ShiftData;\n size?: SizeData;\n [key: string]: unknown;\n}\n\ninterface MiddlewareState {\n boundary?: Element | Rect;\n elements: { floating: HTMLElement; reference: ReferenceElement };\n initialPlacement: Placement;\n middlewareData: MiddlewareData;\n padding?: Padding;\n placement: Placement;\n rects: { floating: Rect; reference: Rect };\n x: number;\n y: number;\n}\n\ntype MiddlewareReset = {\n placement?: Placement;\n rects?: MiddlewareState['rects'];\n remeasure?: boolean;\n};\n\ninterface MiddlewareResult {\n data?: MiddlewareData;\n placement?: Placement;\n reset?: MiddlewareReset;\n x?: number;\n y?: number;\n}\n\ntype Middleware = (state: MiddlewareState) => MiddlewareResult | undefined;\n\ninterface ComputePositionResult {\n middlewareData: MiddlewareData;\n placement: Placement;\n x: number;\n y: number;\n}\n\ninterface ComputePositionOptions {\n boundary?: Element | Rect;\n containingBlock?: Element | null;\n middleware?: readonly Middleware[];\n padding?: Padding;\n placement?: Placement;\n}\n\ninterface DetectOverflowOptions {\n boundary?: Element | Rect;\n padding?: Padding;\n}\n\ntype PositionStrategy = 'absolute' | 'fixed';\n\ninterface PositionerOptions extends Omit<ComputePositionOptions, 'boundary' | 'containingBlock'> {\n apply?: (result: ComputePositionResult) => void;\n autoUpdate?: AutoUpdateOptions | false;\n boundary?: ComputePositionOptions['boundary'] | 'clippingAncestors';\n strategy?: PositionStrategy;\n}\n\ninterface Positioner {\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n readonly disposed: boolean;\n getPosition(): ComputePositionResult;\n update(): void;\n [Symbol.dispose](): void;\n}\n\ninterface AutoUpdateOptions {\n animationFrame?: boolean;\n observeAncestors?: boolean;\n observeFloating?: boolean;\n observeVisualViewport?: boolean;\n pauseWhenHidden?: boolean;\n throttle?: number;\n}\n\ninterface ReactivePositioner extends Positioner {\n readonly position: Readable<ComputePositionResult>;\n}\n\ninterface ArrowOptions {\n element: HTMLElement;\n padding?: Padding;\n}\n\ninterface AutoPlacementOptions extends DetectOverflowOptions {\n alignment?: Alignment | null;\n allowedPlacements?: Placement[];\n}\n\ninterface FlipOptions extends DetectOverflowOptions {\n fallbackPlacements?: Placement[];\n}\n\ninterface HideOptions extends DetectOverflowOptions {\n strategy?: 'referenceHidden' | 'escaped' | 'both';\n}\n\ntype OffsetConfig = {\n crossAxis?: number;\n mainAxis?: number;\n};\n\ntype OffsetValue = number | OffsetConfig | ((state: MiddlewareState) => number | OffsetConfig);\n\ntype ShiftLimiter = (\n state: MiddlewareState,\n correction: { crossAxis: number; mainAxis: number },\n) => { crossAxis: number; mainAxis: number };\n\ninterface LimitShiftOptions {\n offset?: number | ((state: MiddlewareState) => number);\n}\n\ninterface ShiftOptions extends DetectOverflowOptions {\n crossAxis?: boolean;\n limiter?: ShiftLimiter;\n}\n\ninterface InlineOptions {\n padding?: Padding;\n x?: number;\n y?: number;\n}\n\ntype SizeOptions = DetectOverflowOptions;\n\ninterface PositioningPreset {\n middleware: Middleware[];\n placement: Placement;\n}\n\ninterface PresetOptions {\n offset?: number;\n padding?: number;\n placement?: Placement;\n}\n```\n\n## Errors\n\n| Error | Trigger | Notable properties |\n| --- | --- | --- |\n| `OrbitConfigError` | Invalid middleware reset configuration | Extends `OrbitError` |\n| `OrbitError` | Base Orbit error | `instanceof OrbitError` narrows Orbit errors |\n",
6
- "usage": "---\ntitle: Orbit — Usage Guide\ndescription: Position floating UI with lifecycle ownership, explicit coordinate strategy, middleware, and optional reactive state.\n---\n\n[[toc]]\n\n## Basic Usage\n\nCreate a positioner after both elements mount, then dispose it with their owner.\n\n```ts\nimport { createPositioner, flip, offset, shift } from '@vielzeug/orbit';\n\nconst positioner = createPositioner(trigger, tooltip, {\n middleware: [offset(8), flip(), shift({ padding: 6 })],\n placement: 'top',\n});\n\npositioner.dispose();\n```\n\n`createPositioner()` applies the initial position before returning and owns clipping-boundary resolution, updates, CSS strategy, and cleanup.\n\n## Coordinate Strategy\n\nUse `fixed` for viewport-positioned overlays. Use `absolute` when the floating element should position within its offset parent.\n\n```ts\nconst positioner = createPositioner(trigger, dropdown, {\n placement: 'bottom-start',\n strategy: 'absolute',\n});\n\n```\n\nOrbit resolves clipping ancestors by default. Pass an explicit `boundary` when your application owns a different visible region.\n\n## Middleware\n\nPass middleware in the exact order it should execute.\n\n```ts\nconst positioner = createPositioner(trigger, panel, {\n middleware: [\n offset(8),\n flip(),\n shift({ padding: 8 }),\n size(),\n arrow({ element: arrowElement }),\n ],\n});\n```\n\nUse either `flip()` or `autoPlacement()` for one positioner. Custom middleware writes data into `result.middlewareData`.\n\n## Virtual References\n\nUse a virtual reference for cursor-anchored UI.\n\n```ts\nconst reference = {\n getBoundingClientRect: () => ({ height: 0, width: 0, x: event.clientX, y: event.clientY }),\n};\n\nconst positioner = createPositioner(reference, menu, { placement: 'bottom-start' });\n```\n\n## Manual Positioning\n\nUse `computePosition()` only when your application owns CSS application and lifecycle itself.\n\n```ts\nimport { computePosition, offset } from '@vielzeug/orbit';\n\nconst result = computePosition(reference, floating, { middleware: [offset(8)] });\nfloating.style.left = `${result.x}px`;\nfloating.style.top = `${result.y}px`;\n```\n\n## Reactive Adapter\n\nInstall Ripple and import the optional adapter only when your UI needs a reactive position value.\n\n```ts\nimport { createReactivePositioner } from '@vielzeug/orbit/reactive';\nimport { effect } from '@vielzeug/ripple';\n\nconst positioner = createReactivePositioner(trigger, tooltip);\n\neffect(() => {\n const position = positioner.position.value;\n\n tooltip.style.left = `${position.x}px`;\n tooltip.style.top = `${position.y}px`;\n});\n```\n\n## Client Lifecycle\n\nOrbit root imports are server-safe. Invoke geometry APIs only from a client mount lifecycle, where DOM elements exist.\n\n```ts\nonMounted(() => {\n const positioner = createPositioner(trigger, panel);\n onCleanup(() => positioner.dispose());\n});\n```\n\n## Framework Integration\n\nCreate and dispose positioners with component lifecycle.\n\n::: code-group\n\n```tsx [React]\nuseEffect(() => {\n const positioner = createPositioner(trigger, panel);\n\n return () => positioner.dispose();\n}, [trigger, panel]);\n```\n\n```vue [Vue 3]\n<script setup lang=\"ts\">\nonMounted(() => {\n const positioner = createPositioner(trigger.value!, panel.value!);\n onUnmounted(() => positioner.dispose());\n});\n</script>\n```\n\n```ts [Svelte]\nonMount(() => {\n const positioner = createPositioner(trigger, panel);\n\n return () => positioner.dispose();\n});\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\n### Orbit + Prism\n\nUse `strategy: 'absolute'` for a tooltip rendered inside a chart container.\n\n```ts\nconst positioner = createPositioner(cursorReference, tooltip, {\n autoUpdate: false,\n strategy: 'absolute',\n});\n\npositioner.dispose();\n```\n\n## Best Practices\n\n- **Create** a positioner after both DOM elements mount; it starts immediately.\n- **Dispose** it with its UI owner.\n- **Choose** `fixed` or `absolute` intentionally.\n- **Keep** middleware order explicit.\n- **Use** `computePosition()` only for advanced platform-managed paths.\n- **Install** Ripple only when importing `/reactive`.\n- **Invoke** geometry APIs only on the client.\n",
7
- "examples": "---\ntitle: Orbit — Examples\ndescription: Worked examples for @vielzeug/orbit.\n---\n\n## Examples\n\n- [Context Menu](./examples/context-menu.md)\n- [Custom Middleware](./examples/custom-middleware.md)\n- [Dropdown Select](./examples/dropdown-select.md)\n- [Popover with Arrow](./examples/popover-with-arrow.md)\n- [Reactive Adapter](./examples/reactive-adapter.md)\n- [Tooltip](./examples/tooltip.md)\n- [Using Presets](./examples/using-presets.md)\n- [With Ore Component](./examples/with-ore-component.md)\n"
4
+ "index": "---\ntitle: 'Orbit: Floating UI positioning'\ndescription: Floating positioning with lifecycle-owned geometry and middleware.\npackage: orbit\ncategory: ui\nkeywords: [positioning, tooltip, popover, dropdown, middleware, floating-ui]\nexports: [autoUpdate, computePosition, createPositioner]\nrelated: [ore, refine, prism]\nenvironments: [browser]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"orbit\" />\n\n## Why Orbit?\n\nFloating UI needs one owner for CSS coordinates, clipping boundaries, updates, and cleanup. Orbit provides a lifecycle positioner for normal UI and a pure computation API for advanced integrations.\n\n```ts\n// Before\nconst { x, y } = computeSomehow(trigger, panel);\npanel.style.left = `${x}px`;\npanel.style.top = `${y}px`;\n\n// After\nconst positioner = createPositioner(trigger, panel);\n```\n\n| Feature | Manual DOM positioning | Orbit |\n| --- | --- | --- |\n| Bundle size | 0 B | <PackageInfo package=\"orbit\" type=\"size\" /> |\n| Root dependencies | Application-defined | `@vielzeug/arsenal` |\n| Clipping boundary | Manual geometry | `clippingAncestors` default |\n| Coordinate strategy | Consumer logic | `fixed` / `absolute` |\n| Cleanup | Manual listeners | `dispose()` |\n\n<div class=\"decision-callout\">\n\n**Use Orbit when** floating UI needs robust placement, collision handling, or reactive updates.\n\n**Consider direct CSS when** placement is static and never depends on element geometry.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/orbit\n```\n\n```sh [npm]\nnpm install @vielzeug/orbit\n```\n\n```sh [yarn]\nyarn add @vielzeug/orbit\n```\n\n:::\n\n## Quick Start\n\nCreate a positioner only after its reference and floating elements mount; positioning starts immediately.\n\n```ts\nimport { createPositioner, flip, offset, shift } from '@vielzeug/orbit';\n\nconst positioner = createPositioner(trigger, tooltip, {\n middleware: [offset(8), flip(), shift({ padding: 6 })],\n placement: 'top',\n});\n\npositioner.dispose();\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `createPositioner()`: Lifecycle-owned floating positioning\n- `computePosition()`: Low-level calculation for advanced integrations\n- `autoUpdate()`: Scroll, viewport, resize, and animation-frame updates\n- Middleware: Offset, flip, shift, size, hide, arrow, inline, auto-placement\n- `strategy`: Explicit `fixed` or `absolute` coordinate behavior\n- `/reactive`: Optional Ripple position readable\n\n</div>\n\n## Documentation\n\n<div class=\"doc-links\">\n\n- [Usage Guide](./usage.md)\n- [API Reference](./api.md)\n- [Examples](./examples.md)\n- [Migration Guide](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Refine](/refine/): Accessible components using floating UI behavior.\n- [Ore](/ore/): Lifecycle ownership for custom-element positioning.\n- [Prism](/prism/): Chart tooltips positioned from virtual references.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
5
+ "api": "---\ntitle: 'Orbit: API Reference'\ndescription: API reference for @vielzeug/orbit positioners, computation, updates, middleware, and optional reactive integration.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createPositioner()` | Lifecycle-owned floating positioning | Sync | Starts immediately; create after mount |\n| `computePosition()` | Low-level geometry computation | Sync | Caller owns CSS application |\n| `autoUpdate()` | Listen for geometry changes | Sync | Call returned cleanup |\n| `createReactivePositioner()` | Optional Ripple position readable | Sync | Requires `@vielzeug/ripple` |\n| Middleware factories | Adjust placement and size | Sync | Order is explicit |\n\n## Package Entry Points\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/orbit` | Positioner, computation, updates, middleware, and types. |\n| `@vielzeug/orbit/reactive` | Optional Ripple position adapter. |\n| `@vielzeug/orbit/presets` | Preset placement and middleware options. |\n| `@vielzeug/orbit/devtools` | Development overlay. |\n\n## Core Functions\n\n### `createPositioner()`\n\n```ts\nfunction createPositioner(\n reference: ReferenceElement,\n floating: HTMLElement,\n options?: PositionerOptions,\n): Positioner;\n```\n\nCreates an active positioner and applies its initial position before returning.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `reference` | `ReferenceElement` | DOM or virtual anchor. |\n| `floating` | `HTMLElement` | Positioned element. |\n| `options` | `PositionerOptions` | Strategy, clipping, middleware, updates, and application callback. |\n\n**Returns:** `Positioner`.\n\n```ts\nimport { createPositioner } from '@vielzeug/orbit';\n\nconst positioner = createPositioner(trigger, tooltip);\npositioner.dispose();\n```\n\n| Member | Return | Contract |\n| --- | --- | --- |\n| `update()` | `void` | Recomputes and applies position. |\n| `getPosition()` | `ComputePositionResult` | Latest applied result. |\n| `dispose()` | `void` | Stops updates and aborts disposal signal. |\n\n### `computePosition()`\n\n```ts\nfunction computePosition(\n reference: ReferenceElement,\n floating: HTMLElement,\n options?: ComputePositionOptions,\n): ComputePositionResult;\n```\n\nCalculates position without applying DOM styles or creating listeners.\n\n**Returns:** `ComputePositionResult`.\n\n### `autoUpdate()`\n\n```ts\nfunction autoUpdate(\n reference: ReferenceElement,\n floating: HTMLElement,\n update: () => void,\n options?: AutoUpdateOptions,\n): () => void;\n```\n\nCalls `update` immediately, then on relevant scroll, viewport, resize, and optional animation-frame changes.\n\n**Returns:** cleanup callback.\n\n## Middleware\n\n```ts\ntype Middleware = (state: MiddlewareState) => MiddlewareResult | undefined;\n```\n\nBuilt-in factories: `arrow`, `autoPlacement`, `flip`, `hide`, `inline`, `offset`, `shift`, `limitShift`, and `size`.\n\n```ts\nconst middleware = [offset(8), flip(), shift({ padding: 6 }), size()];\n```\n\n`middlewareData` is `MiddlewareData`; narrow custom data at the consuming boundary.\n\n## Reactive Adapter\n\n```ts\nfunction createReactivePositioner(\n reference: ReferenceElement,\n floating: HTMLElement,\n options?: Omit<PositionerOptions, 'apply'>,\n): ReactivePositioner;\n```\n\n`ReactivePositioner.position` is `Readable<ComputePositionResult>`. Imported from `@vielzeug/orbit/reactive`.\n\n## Types\n\n```ts\ntype Side = 'top' | 'bottom' | 'left' | 'right';\ntype Alignment = 'start' | 'end';\ntype Placement = Side | `${Side}-${Alignment}`;\n\ninterface Rect {\n height: number;\n width: number;\n x: number;\n y: number;\n}\n\ninterface VirtualReference {\n getBoundingClientRect: () => DOMRect | Rect;\n getClientRects?: () => DOMRectList | DOMRect[];\n}\n\ntype ReferenceElement = Element | VirtualReference;\n\ninterface SideObject {\n bottom: number;\n left: number;\n right: number;\n top: number;\n}\n\ntype Padding = number | Partial<SideObject>;\n\ninterface ArrowData {\n centerOffset: number;\n constrained: boolean;\n x?: number;\n y?: number;\n}\n\ninterface FlipData {\n skippedPlacements: Placement[];\n}\n\ninterface ShiftData {\n x: number;\n y: number;\n}\n\ninterface HideData {\n escaped?: boolean;\n escapedOffsets?: SideObject;\n referenceHidden?: boolean;\n referenceHiddenOffsets?: SideObject;\n}\n\ninterface SizeData {\n availableHeight: number;\n availableWidth: number;\n}\n\ninterface MiddlewareData {\n arrow?: ArrowData;\n flip?: FlipData;\n hide?: HideData;\n shift?: ShiftData;\n size?: SizeData;\n [key: string]: unknown;\n}\n\ninterface MiddlewareState {\n boundary?: Element | Rect;\n elements: { floating: HTMLElement; reference: ReferenceElement };\n initialPlacement: Placement;\n middlewareData: MiddlewareData;\n padding?: Padding;\n placement: Placement;\n rects: { floating: Rect; reference: Rect };\n x: number;\n y: number;\n}\n\ntype MiddlewareReset = {\n placement?: Placement;\n rects?: MiddlewareState['rects'];\n remeasure?: boolean;\n};\n\ninterface MiddlewareResult {\n data?: MiddlewareData;\n placement?: Placement;\n reset?: MiddlewareReset;\n x?: number;\n y?: number;\n}\n\ntype Middleware = (state: MiddlewareState) => MiddlewareResult | undefined;\n\ninterface ComputePositionResult {\n middlewareData: MiddlewareData;\n placement: Placement;\n x: number;\n y: number;\n}\n\ninterface ComputePositionOptions {\n boundary?: Element | Rect;\n containingBlock?: Element | null;\n middleware?: readonly Middleware[];\n padding?: Padding;\n placement?: Placement;\n}\n\ninterface DetectOverflowOptions {\n boundary?: Element | Rect;\n padding?: Padding;\n}\n\ntype PositionStrategy = 'absolute' | 'fixed';\n\ninterface PositionerOptions extends Omit<ComputePositionOptions, 'boundary' | 'containingBlock'> {\n apply?: (result: ComputePositionResult) => void;\n autoUpdate?: AutoUpdateOptions | false;\n boundary?: ComputePositionOptions['boundary'] | 'clippingAncestors';\n strategy?: PositionStrategy;\n}\n\ninterface Positioner {\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n readonly disposed: boolean;\n getPosition(): ComputePositionResult;\n update(): void;\n [Symbol.dispose](): void;\n}\n\ninterface AutoUpdateOptions {\n animationFrame?: boolean;\n observeAncestors?: boolean;\n observeFloating?: boolean;\n observeVisualViewport?: boolean;\n pauseWhenHidden?: boolean;\n throttle?: number;\n}\n\ninterface ReactivePositioner extends Positioner {\n readonly position: Readable<ComputePositionResult>;\n}\n\ninterface ArrowOptions {\n element: HTMLElement;\n padding?: Padding;\n}\n\ninterface AutoPlacementOptions extends DetectOverflowOptions {\n alignment?: Alignment | null;\n allowedPlacements?: Placement[];\n}\n\ninterface FlipOptions extends DetectOverflowOptions {\n fallbackPlacements?: Placement[];\n}\n\ninterface HideOptions extends DetectOverflowOptions {\n strategy?: 'referenceHidden' | 'escaped' | 'both';\n}\n\ntype OffsetConfig = {\n crossAxis?: number;\n mainAxis?: number;\n};\n\ntype OffsetValue = number | OffsetConfig | ((state: MiddlewareState) => number | OffsetConfig);\n\ntype ShiftLimiter = (\n state: MiddlewareState,\n correction: { crossAxis: number; mainAxis: number },\n) => { crossAxis: number; mainAxis: number };\n\ninterface LimitShiftOptions {\n offset?: number | ((state: MiddlewareState) => number);\n}\n\ninterface ShiftOptions extends DetectOverflowOptions {\n crossAxis?: boolean;\n limiter?: ShiftLimiter;\n}\n\ninterface InlineOptions {\n padding?: Padding;\n x?: number;\n y?: number;\n}\n\ntype SizeOptions = DetectOverflowOptions;\n\ninterface PositioningPreset {\n middleware: Middleware[];\n placement: Placement;\n}\n\ninterface PresetOptions {\n offset?: number;\n padding?: number;\n placement?: Placement;\n}\n```\n\n## Errors\n\n| Error | Trigger | Notable properties |\n| --- | --- | --- |\n| `OrbitConfigError` | Invalid middleware reset configuration | Extends `OrbitError` |\n| `OrbitError` | Base Orbit error | `instanceof OrbitError` narrows Orbit errors |\n",
6
+ "usage": "---\ntitle: 'Orbit: Usage Guide'\ndescription: Position floating UI with lifecycle ownership, explicit coordinate strategy, middleware, and optional reactive state.\n---\n\n[[toc]]\n\n## Basic Usage\n\nCreate a positioner after both elements mount, then dispose it with their owner.\n\n```ts\nimport { createPositioner, flip, offset, shift } from '@vielzeug/orbit';\n\nconst positioner = createPositioner(trigger, tooltip, {\n middleware: [offset(8), flip(), shift({ padding: 6 })],\n placement: 'top',\n});\n\npositioner.dispose();\n```\n\n`createPositioner()` applies the initial position before returning and owns clipping-boundary resolution, updates, CSS strategy, and cleanup.\n\n## Coordinate Strategy\n\nUse `fixed` for viewport-positioned overlays. Use `absolute` when the floating element should position within its offset parent.\n\n```ts\nconst positioner = createPositioner(trigger, dropdown, {\n placement: 'bottom-start',\n strategy: 'absolute',\n});\n\n```\n\nOrbit resolves clipping ancestors by default. Pass an explicit `boundary` when your application owns a different visible region.\n\n## Middleware\n\nPass middleware in the exact order it should execute.\n\n```ts\nconst positioner = createPositioner(trigger, panel, {\n middleware: [\n offset(8),\n flip(),\n shift({ padding: 8 }),\n size(),\n arrow({ element: arrowElement }),\n ],\n});\n```\n\nUse either `flip()` or `autoPlacement()` for one positioner. Custom middleware writes data into `result.middlewareData`.\n\n## Virtual References\n\nUse a virtual reference for cursor-anchored UI.\n\n```ts\nconst reference = {\n getBoundingClientRect: () => ({ height: 0, width: 0, x: event.clientX, y: event.clientY }),\n};\n\nconst positioner = createPositioner(reference, menu, { placement: 'bottom-start' });\n```\n\n## Manual Positioning\n\nUse `computePosition()` only when your application owns CSS application and lifecycle itself.\n\n```ts\nimport { computePosition, offset } from '@vielzeug/orbit';\n\nconst result = computePosition(reference, floating, { middleware: [offset(8)] });\nfloating.style.left = `${result.x}px`;\nfloating.style.top = `${result.y}px`;\n```\n\n## Reactive Adapter\n\nInstall Ripple and import the optional adapter only when your UI needs a reactive position value.\n\n```ts\nimport { createReactivePositioner } from '@vielzeug/orbit/reactive';\nimport { effect } from '@vielzeug/ripple';\n\nconst positioner = createReactivePositioner(trigger, tooltip);\n\neffect(() => {\n const position = positioner.position.value;\n\n tooltip.style.left = `${position.x}px`;\n tooltip.style.top = `${position.y}px`;\n});\n```\n\n## Client Lifecycle\n\nOrbit root imports are server-safe. Invoke geometry APIs only from a client mount lifecycle, where DOM elements exist.\n\n```ts\nonMounted(() => {\n const positioner = createPositioner(trigger, panel);\n onCleanup(() => positioner.dispose());\n});\n```\n\n## Framework Integration\n\nCreate and dispose positioners with component lifecycle.\n\n::: code-group\n\n```tsx [React]\nuseEffect(() => {\n const positioner = createPositioner(trigger, panel);\n\n return () => positioner.dispose();\n}, [trigger, panel]);\n```\n\n```vue [Vue 3]\n<script setup lang=\"ts\">\nonMounted(() => {\n const positioner = createPositioner(trigger.value!, panel.value!);\n onUnmounted(() => positioner.dispose());\n});\n</script>\n```\n\n```ts [Svelte]\nonMount(() => {\n const positioner = createPositioner(trigger, panel);\n\n return () => positioner.dispose();\n});\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\n### Orbit + Prism\n\nUse `strategy: 'absolute'` for a tooltip rendered inside a chart container.\n\n```ts\nconst positioner = createPositioner(cursorReference, tooltip, {\n autoUpdate: false,\n strategy: 'absolute',\n});\n\npositioner.dispose();\n```\n\n## Best Practices\n\n- **Create** a positioner after both DOM elements mount; it starts immediately.\n- **Dispose** it with its UI owner.\n- **Choose** `fixed` or `absolute` intentionally.\n- **Keep** middleware order explicit.\n- **Use** `computePosition()` only for advanced platform-managed paths.\n- **Install** Ripple only when importing `/reactive`.\n- **Invoke** geometry APIs only on the client.\n",
7
+ "examples": "---\ntitle: 'Orbit: Examples'\ndescription: Worked examples for @vielzeug/orbit.\n---\n\n## Examples\n\n- [Context Menu](./examples/context-menu.md)\n- [Custom Middleware](./examples/custom-middleware.md)\n- [Dropdown Select](./examples/dropdown-select.md)\n- [Popover with Arrow](./examples/popover-with-arrow.md)\n- [Reactive Adapter](./examples/reactive-adapter.md)\n- [Tooltip](./examples/tooltip.md)\n- [Using Presets](./examples/using-presets.md)\n- [With Ore Component](./examples/with-ore-component.md)\n"
8
8
  },
9
9
  "examples": [
10
10
  {
11
11
  "id": "auto-update",
12
- "code": "import { autoUpdate, computePosition, flip, offset, shift } from '@vielzeug/orbit'\n\nconst button = document.createElement('button')\nbutton.textContent = 'Reference'\nbutton.style.cssText = 'position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); padding: 8px 16px;'\ndocument.body.appendChild(button)\n\nconst dropdown = document.createElement('div')\ndropdown.textContent = 'Dropdown'\n// position: fixed with left: 0; top: 0 so left/top writes are absolute viewport coords\ndropdown.style.cssText = 'position: fixed; left: 0; top: 0; background: #fff; border: 1px solid #e5e5e5; border-radius: 6px; padding: 12px 16px; box-shadow: 0 4px 12px rgba(0,0,0,.1); z-index: 1000;'\ndocument.body.appendChild(dropdown)\n\nconst middleware = [offset(4), flip(), shift({ padding: 8 })]\n\nfunction update() {\n const { x, y, placement } = computePosition(button, dropdown, {\n placement: 'bottom-start',\n middleware,\n })\n dropdown.style.left = x + 'px'\n dropdown.style.top = y + 'px'\n dropdown.dataset.placement = placement\n console.log('Positioned:', placement)\n}\n\n// autoUpdate calls update immediately then re-calls on scroll/resize/mutation\nconst cleanup = autoUpdate(button, dropdown, update)\n\nconsole.log('autoUpdate running — try resizing the window')\nconsole.log('cleanup type (call to stop):', typeof cleanup)",
12
+ "code": "import { autoUpdate, computePosition, flip, offset, shift } from '@vielzeug/orbit'\n\nconst button = document.createElement('button')\nbutton.textContent = 'Reference'\nbutton.style.cssText = 'position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); padding: 8px 16px;'\ndocument.body.appendChild(button)\n\nconst dropdown = document.createElement('div')\ndropdown.textContent = 'Dropdown'\n// position: fixed with left: 0; top: 0 so left/top writes are absolute viewport coords\ndropdown.style.cssText = 'position: fixed; left: 0; top: 0; background: #fff; border: 1px solid #e5e5e5; border-radius: 6px; padding: 12px 16px; box-shadow: 0 4px 12px rgba(0,0,0,.1); z-index: 1000;'\ndocument.body.appendChild(dropdown)\n\nconst middleware = [offset(4), flip(), shift({ padding: 8 })]\n\nfunction update() {\n const { x, y, placement } = computePosition(button, dropdown, {\n placement: 'bottom-start',\n middleware,\n })\n dropdown.style.left = x + 'px'\n dropdown.style.top = y + 'px'\n dropdown.dataset.placement = placement\n console.log('Positioned:', placement)\n}\n\n// autoUpdate calls update immediately then re-calls on scroll/resize/mutation\nconst cleanup = autoUpdate(button, dropdown, update)\n\nconsole.log('autoUpdate running: try resizing the window')\nconsole.log('cleanup type (call to stop):', typeof cleanup)",
13
13
  "name": "autoUpdate - Track on Scroll/Resize"
14
14
  },
15
15
  {
16
16
  "id": "inline-middleware",
17
- "code": "import { computePosition, flip, inline, shift } from '@vielzeug/orbit'\n\n// inline() corrects the reference rect for multi-line inline elements.\n// It picks the client rect closest to the cursor (or floating element).\nconst span = document.createElement('span')\nspan.textContent = 'Hover to reveal tooltip — this is a long inline element that may wrap'\nspan.style.cssText = 'line-height: 1.8; cursor: pointer; background: #f0f0f0; padding: 2px 4px; border-radius: 3px;'\ndocument.body.appendChild(span)\n\nconst tooltip = document.createElement('div')\ntooltip.textContent = 'inline() picks the nearest client rect'\ntooltip.style.cssText = 'position: fixed; left: 0; top: 0; background: #1a1a2e; color: #e0e0ff; padding: 6px 10px; border-radius: 5px; font-size: 12px; pointer-events: none; z-index: 1000;'\ntooltip.hidden = true\ndocument.body.appendChild(tooltip)\n\nlet cursorX = 0\nlet cursorY = 0\n\nspan.addEventListener('mousemove', (e) => {\n cursorX = e.clientX\n cursorY = e.clientY\n tooltip.hidden = false\n\n const { x, y } = computePosition(span, tooltip, {\n placement: 'top',\n middleware: [inline({ x: cursorX, y: cursorY }), flip(), shift({ padding: 4 })],\n })\n tooltip.style.left = x + 'px'\n tooltip.style.top = y + 'px'\n})\n\nspan.addEventListener('mouseleave', () => { tooltip.hidden = true })\n\nconsole.log('Hover over the span to see inline() in action')",
17
+ "code": "import { computePosition, flip, inline, shift } from '@vielzeug/orbit'\n\n// inline() corrects the reference rect for multi-line inline elements.\n// It picks the client rect closest to the cursor (or floating element).\nconst span = document.createElement('span')\nspan.textContent = 'Hover to reveal tooltip: this is a long inline element that may wrap'\nspan.style.cssText = 'line-height: 1.8; cursor: pointer; background: #f0f0f0; padding: 2px 4px; border-radius: 3px;'\ndocument.body.appendChild(span)\n\nconst tooltip = document.createElement('div')\ntooltip.textContent = 'inline() picks the nearest client rect'\ntooltip.style.cssText = 'position: fixed; left: 0; top: 0; background: #1a1a2e; color: #e0e0ff; padding: 6px 10px; border-radius: 5px; font-size: 12px; pointer-events: none; z-index: 1000;'\ntooltip.hidden = true\ndocument.body.appendChild(tooltip)\n\nlet cursorX = 0\nlet cursorY = 0\n\nspan.addEventListener('mousemove', (e) => {\n cursorX = e.clientX\n cursorY = e.clientY\n tooltip.hidden = false\n\n const { x, y } = computePosition(span, tooltip, {\n placement: 'top',\n middleware: [inline({ x: cursorX, y: cursorY }), flip(), shift({ padding: 4 })],\n })\n tooltip.style.left = x + 'px'\n tooltip.style.top = y + 'px'\n})\n\nspan.addEventListener('mouseleave', () => { tooltip.hidden = true })\n\nconsole.log('Hover over the span to see inline() in action')",
18
18
  "name": "inline - Multi-line Inline Reference"
19
19
  },
20
20
  {
@@ -29,7 +29,7 @@
29
29
  },
30
30
  {
31
31
  "id": "presets",
32
- "code": "import { createPositioner } from '@vielzeug/orbit'\nimport { tooltip, dropdown, popover, contextMenu } from '@vielzeug/orbit/presets'\n\n// Presets are pre-configured middleware stacks for common UI patterns.\n// Each factory returns { placement, middleware } — spread into createPositioner().\n\n// --- tooltip() ---\nconst tooltipPreset = tooltip()\nconsole.log('tooltip placement:', tooltipPreset.placement)\nconsole.log('tooltip middleware count:', tooltipPreset.middleware.length)\n\n// Customise placement and offset:\nconst topTooltip = tooltip({ placement: 'top', offset: 12 })\nconsole.log('custom tooltip placement:', topTooltip.placement)\n\n// --- dropdown() ---\nconst dropdownPreset = dropdown()\nconsole.log('dropdown placement:', dropdownPreset.placement)\n\nconst wideDropdown = dropdown({ offset: 8, padding: 6 })\nconsole.log('wide dropdown middleware count:', wideDropdown.middleware.length)\n\n// --- popover() ---\nconst popoverPreset = popover()\nconsole.log('popover placement:', popoverPreset.placement)\n\n// --- contextMenu() ---\nconst menuPreset = contextMenu()\nconsole.log('contextMenu placement:', menuPreset.placement)\n\nconst menuTopStart = contextMenu({ placement: 'top-start' })\nconsole.log('contextMenu custom placement:', menuTopStart.placement)\n\n// --- Spread a preset into createPositioner() ---\nconst trigger = document.createElement('button')\ntrigger.textContent = 'Hover me'\ntrigger.style.cssText = 'margin: 80px; padding: 8px 16px;'\ndocument.body.appendChild(trigger)\n\nconst tip = document.createElement('div')\ntip.textContent = 'tooltip()'\ntip.style.cssText = 'position: fixed; background: #1e293b; color: #fff; padding: 6px 10px; border-radius: 6px; font-size: 13px; display: none;'\ndocument.body.appendChild(tip)\n\nlet positioner = null\n\ntrigger.addEventListener('mouseenter', () => {\n tip.style.display = 'block'\n positioner = createPositioner(trigger, tip, tooltip())\n})\ntrigger.addEventListener('mouseleave', () => {\n tip.style.display = 'none'\n positioner?.dispose()\n positioner = null\n})\n\nconsole.log('Hover the button to see tooltip() in action')",
32
+ "code": "import { createPositioner } from '@vielzeug/orbit'\nimport { tooltip, dropdown, popover, contextMenu } from '@vielzeug/orbit/presets'\n\n// Presets are pre-configured middleware stacks for common UI patterns.\n// Each factory returns { placement, middleware }: spread into createPositioner().\n\n// --- tooltip() ---\nconst tooltipPreset = tooltip()\nconsole.log('tooltip placement:', tooltipPreset.placement)\nconsole.log('tooltip middleware count:', tooltipPreset.middleware.length)\n\n// Customise placement and offset:\nconst topTooltip = tooltip({ placement: 'top', offset: 12 })\nconsole.log('custom tooltip placement:', topTooltip.placement)\n\n// --- dropdown() ---\nconst dropdownPreset = dropdown()\nconsole.log('dropdown placement:', dropdownPreset.placement)\n\nconst wideDropdown = dropdown({ offset: 8, padding: 6 })\nconsole.log('wide dropdown middleware count:', wideDropdown.middleware.length)\n\n// --- popover() ---\nconst popoverPreset = popover()\nconsole.log('popover placement:', popoverPreset.placement)\n\n// --- contextMenu() ---\nconst menuPreset = contextMenu()\nconsole.log('contextMenu placement:', menuPreset.placement)\n\nconst menuTopStart = contextMenu({ placement: 'top-start' })\nconsole.log('contextMenu custom placement:', menuTopStart.placement)\n\n// --- Spread a preset into createPositioner() ---\nconst trigger = document.createElement('button')\ntrigger.textContent = 'Hover me'\ntrigger.style.cssText = 'margin: 80px; padding: 8px 16px;'\ndocument.body.appendChild(trigger)\n\nconst tip = document.createElement('div')\ntip.textContent = 'tooltip()'\ntip.style.cssText = 'position: fixed; background: #1e293b; color: #fff; padding: 6px 10px; border-radius: 6px; font-size: 13px; display: none;'\ndocument.body.appendChild(tip)\n\nlet positioner = null\n\ntrigger.addEventListener('mouseenter', () => {\n tip.style.display = 'block'\n positioner = createPositioner(trigger, tip, tooltip())\n})\ntrigger.addEventListener('mouseleave', () => {\n tip.style.display = 'none'\n positioner?.dispose()\n positioner = null\n})\n\nconsole.log('Hover the button to see tooltip() in action')",
33
33
  "name": "presets - Ready-made Middleware Stacks"
34
34
  },
35
35
  {