@vielzeug/codex 2.2.9 → 2.3.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.
- package/data/catalog.json +85 -41
- package/data/llms-full.txt +1514 -370
- package/data/llms.txt +2 -1
- package/data/manifest.json +1 -1
- package/data/packages/clockwork.json +2 -2
- package/data/packages/conduit.json +1 -1
- package/data/packages/courier.json +7 -6
- package/data/packages/dnd.json +1 -1
- package/data/packages/familiar.json +1 -1
- package/data/packages/forge.json +1 -1
- package/data/packages/gesture.json +1 -1
- package/data/packages/herald.json +18 -18
- package/data/packages/keymap.json +2 -2
- package/data/packages/lingua.json +1 -1
- package/data/packages/necromancer.json +1 -1
- package/data/packages/ore.json +1 -1
- package/data/packages/postmaster.json +51 -0
- package/data/packages/pulse.json +31 -30
- package/data/packages/scout.json +13 -12
- package/data/packages/scroll.json +1 -1
- package/data/packages/sentinel.json +1 -1
- package/data/packages/spell.json +1 -1
- package/data/packages/vault.json +22 -28
- package/data/packages/ward.json +28 -28
- package/data/packages/wayfinder.json +5 -5
- package/data/refine.json +4150 -4190
- package/data/search.json +80 -54
- package/package.json +2 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"apiSource": "export * from './worker';\n",
|
|
3
3
|
"docs": {
|
|
4
4
|
"index": "---\ntitle: Familiar — Typed module-worker pools\ndescription: Typed ES module Worker pools with cancellation, priority scheduling, streaming, and test utilities.\npackage: familiar\ncategory: workers\nkeywords: [web-workers, module-workers, pool, concurrency, timeout, cancellation, streaming]\nrelated: [arsenal, ripple, herald]\nexports: [createWorker, createStreamWorker, batch, createTaskGroup, FamiliarError, FamiliarTimeoutError, FamiliarTaskError, FamiliarQueueFullError, FamiliarTerminatedError, FamiliarRuntimeError]\nenvironments: [browser]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"familiar\" />\n\n## Why Familiar?\n\nRaw workers force every application to maintain its own message contract, lifecycle, cancellation, and pool scheduler. Familiar provides those boundaries while keeping worker code in normal typed ES modules.\n\n```ts\n// Before\nconst worker = new Worker(new URL('./sum.worker.ts', import.meta.url), { type: 'module' });\nworker.postMessage([1, 2, 3]);\n\n// After\nconst pool = createWorker<number[], number>(new URL('./sum.worker.ts', import.meta.url));\nawait pool.run([1, 2, 3]);\n```\n\n| Feature | Familiar | Raw Worker | Comlink |\n| --- | --- | --- | --- |\n| Bundle size | <PackageInfo package=\"familiar\" type=\"size\" /> | built-in | ~2 kB |\n| Module-worker contract | <ore-icon name=\"check\" size=\"16\"></ore-icon> | manual | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Pool scheduling | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| AbortSignal cancellation | <ore-icon name=\"check\" size=\"16\"></ore-icon> | manual | manual |\n| Versioned protocol | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | implementation-specific |\n| Zero 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\n<div class=\"decision-callout\">\n\n**Use Familiar when** worker jobs need bounded concurrency, typed errors, cancellation, or queue policy.\n\n**Consider raw Worker when** one isolated worker and custom messaging are enough.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/familiar\n```\n\n```sh [npm]\nnpm install @vielzeug/familiar\n```\n\n```sh [yarn]\nyarn add @vielzeug/familiar\n```\n\n:::\n\n## Quick Start\n\nRegister task logic inside a worker module.\n\n```ts\n// double.worker.ts\nimport { exposeTask } from '@vielzeug/familiar/protocol';\n\nexposeTask((value: number) => value * 2);\n```\n\nCreate pool from module URL and dispose it after use.\n\n```ts\nimport { createWorker } from '@vielzeug/familiar';\n\nconst worker = createWorker<number, number>(new URL('./double.worker.ts', import.meta.url));\n\ntry {\n console.log(await worker.run(21));\n} finally {\n worker.dispose();\n}\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `createWorker()` — versioned task protocol over ES module workers\n- `createStreamWorker()` — stream-only worker capability\n- `run()` — priority scheduling, transferables, timeout, and cancellation\n- `batch()` — ordered task composition\n- `createTaskGroup()` — shared cancellation and settlement tracking\n- `stats` — active, queued, completed, and failed counters\n- `createTestWorker()` — faithful in-process task-pool testing\n- `dispose()` and `drain()` — immediate or draining teardown, with `using` support\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- [Arsenal](/arsenal/) — async helpers for application coordination.\n- [Ripple](/ripple/) — expose worker results through reactive state.\n- [Herald](/herald/) — publish application events after worker jobs settle.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
|
|
5
|
-
"api": "---\ntitle: Familiar — API Reference\ndescription: API reference for module-worker pools and worker-side protocol registration.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createWorker()` | Create single-result module-worker pool | Sync | Worker must call `exposeTask()` |\n| `createStreamWorker()` | Create stream-only module-worker pool | Sync | Worker must call `exposeStream()` |\n| `batch()` | Yield ordered task-pool results | Async iterator | Stops remaining work on first failure |\n| `createTaskGroup()` | Coordinate related task-pool jobs | Sync | Call `abort()` to stop group work |\n| `createTestWorker()` | Create an in-process task-pool test double | Sync | Task modules are not executed |\n| `exposeTask()` | Register worker task handler | Sync | Worker-only import |\n| `exposeStream()` | Register worker stream handler | Sync | Worker-only import |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/familiar` | Pool factories, helpers, types, errors |\n| `@vielzeug/familiar/protocol` | Versioned worker protocol and registration helpers |\n| `@vielzeug/familiar/testing` | Task-pool testing adapter |\n\n## Pool Factories\n\n### `createWorker()`\n\n```ts\nfunction createWorker<TInput, TOutput>(url: URL | string, options?: WorkerOptions): WorkerPool<TInput, TOutput>;\n```\n\nCreates a task pool for a worker module registered with `exposeTask()`.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `url` | `URL \\| string` | Module-worker URL, usually `new URL('./task.worker.ts', import.meta.url)` |\n| `options` | `WorkerOptions` | Pool concurrency, queue, timeout, and worker-error policy |\n\n**Returns:** `WorkerPool<TInput, TOutput>`.\n\n**Example:**\n\n```ts\nimport { createWorker } from '@vielzeug/familiar';\n\nconst pool = createWorker<number, number>(new URL('./double.worker.ts', import.meta.url));\n\ntry {\n console.log(await pool.run(21));\n} finally {\n pool.dispose();\n}\n```\n\n### `createStreamWorker()`\n\n```ts\nfunction createStreamWorker<TInput, TChunk>(url: URL | string, options?: WorkerOptions): StreamWorkerPool<TInput, TChunk>;\n```\n\nCreates a stream-only pool for a worker module registered with `exposeStream()`.\n\n**Returns:** `StreamWorkerPool<TInput, TChunk>`.\n\n---\n\n### `batch()`\n\n```ts\nfunction batch<TInput, TOutput>(\n pool: WorkerPool<TInput, TOutput>,\n inputs: readonly TInput[],\n options?: BatchOptions,\n): AsyncIterable<TOutput>;\n```\n\nYields results in submission order. A failure or cancellation aborts remaining batch work.\n\n**Returns:** `AsyncIterable<TOutput>`.\n\n---\n\n### `createTaskGroup()`\n\n```ts\nfunction createTaskGroup<TInput, TOutput>(\n pool: WorkerPool<TInput, TOutput>,\n name?: string,\n options?: TaskGroupOptions,\n): TaskGroup<TInput, TOutput>;\n```\n\nCreates group-scoped cancellation and settlement tracking for one task pool.\n\n**Returns:** `TaskGroup<TInput, TOutput>`.\n\n## Testing\n\n### `createTestWorker()`\n\n```ts\nfunction createTestWorker<TInput, TOutput>(\n handler: (input: TInput) => TOutput | Promise<TOutput>,\n options?: TestWorkerOptions,\n): TestWorkerHandle<TInput, TOutput>;\n```\n\nCreates an in-process task-pool double. It structured-clones values, records settlement, and matches task-pool timeout and cancellation behavior without loading a worker module.\n\n**Returns:** `TestWorkerHandle<TInput, TOutput>`.\n\n## Worker Protocol\n\n### `exposeTask()`\n\n```ts\nfunction exposeTask<TInput, TOutput>(handler: TaskHandler<TInput, TOutput>): void;\n```\n\nRegisters one single-result handler in a module worker.\n\n### `exposeStream()`\n\n```ts\nfunction exposeStream<TInput, TChunk>(handler: StreamHandler<TInput, TChunk>): void;\n```\n\nRegisters one chunk-producing handler in a module worker.\n\n### `PROTOCOL_VERSION`\n\n```ts\nconst PROTOCOL_VERSION: 1;\n```\n\nVersion included in every host request and worker response.\n\n## Types\n\n### `WorkerOptions`\n\n```ts\ntype WorkerOptions = {\n concurrency?: number | 'auto';\n maxQueue?: number;\n onFull?: 'reject' | 'wait';\n timeout?: number;\n onSlotError?: (error: FamiliarRuntimeError) => void;\n};\n```\n\n### `RunOptions`\n\n```ts\ntype RunOptions = {\n priority?: number;\n signal?: AbortSignal;\n timeout?: number;\n transferables?: Transferable[];\n};\n```\n\n`signal` cancels capacity waits, queued work, and executing work. Executing cancellation terminates and replaces its worker slot.\n\n### `WorkerPool`\n\n```ts\ninterface WorkerPool<TInput, TOutput> {\n [Symbol.asyncDispose](): Promise<void>;\n [Symbol.dispose](): void;\n run(input: TInput, options?: RunOptions): Promise<TOutput>;\n prime(): Promise<void>;\n drain(options?: DrainOptions): Promise<void>;\n dispose(): void;\n readonly stats: WorkerStats;\n readonly status: WorkerStatus;\n readonly disposed: boolean;\n readonly disposalSignal: AbortSignal;\n}\n```\n\n### `StreamWorkerPool`\n\n```ts\ninterface StreamWorkerPool<TInput, TChunk> {\n [Symbol.asyncDispose](): Promise<void>;\n [Symbol.dispose](): void;\n runStream(input: TInput, options?: RunOptions): AsyncIterable<TChunk>;\n prime(): Promise<void>;\n drain(options?: DrainOptions): Promise<void>;\n dispose(): void;\n readonly disposed: boolean;\n readonly disposalSignal: AbortSignal;\n readonly stats: WorkerStats;\n readonly status: WorkerStatus;\n}\n```\n\n### `WorkerStats`\n\n```ts\ntype WorkerStats = {\n readonly active: number;\n readonly completed: number;\n readonly failed: number;\n readonly queued: number;\n};\n```\n\n### `RunningStream`\n\n```ts\ntype RunningStream<TChunk> = {\n done: Promise<void>;\n iterable: AsyncIterable<TChunk>;\n};\n```\n\n### `WorkerStatus`\n\n```ts\ntype WorkerStatus = 'idle' | 'running' | 'terminated';\n```\n\n### `BatchOptions`\n\n```ts\ntype BatchOptions = RunOptions;\n```\n\n### `DrainOptions`\n\n```ts\ntype DrainOptions = {\n timeout?: number;\n};\n```\n\n### `TaskGroup`\n\n```ts\ntype TaskGroup<TInput, TOutput> = {\n abort(reason?: unknown): void;\n drain(): Promise<PromiseSettledResult<TOutput>[]>;\n readonly name: string | undefined;\n readonly pending: number;\n run(input: TInput, options?: Omit<RunOptions, 'signal'>): Promise<TOutput>;\n readonly size: number;\n};\n```\n\n### `TaskGroupOptions`\n\n```ts\ntype TaskGroupOptions = {\n signal?: AbortSignal;\n};\n```\n\n### `TestWorkerOptions`\n\n```ts\ntype TestWorkerOptions = Omit<WorkerOptions, 'concurrency' | 'onSlotError'> & {\n concurrency?: number;\n};\n```\n\n### `TestWorkerCall`\n\n```ts\ntype TestWorkerCall<TInput, TOutput> =\n | { input: TInput; status: 'fulfilled'; value: TOutput }\n | { input: TInput; reason: unknown; status: 'rejected' };\n```\n\n### `TestWorkerHandle`\n\n```ts\ntype TestWorkerHandle<TInput, TOutput> = WorkerPool<TInput, TOutput> & {\n readonly calls: ReadonlyArray<TestWorkerCall<TInput, TOutput>>;\n};\n```\n\n### `SerializedError`\n\n```ts\ntype SerializedError = {\n message: string;\n name: string;\n stack?: string;\n};\n```\n\n### `WorkerRequest`\n\n```ts\ntype WorkerRequest<TInput> =\n | { id: number; input: TInput; kind: 'run'; version: 1 }\n | { id: number; input: TInput; kind: 'stream'; version: 1 };\n```\n\n### `WorkerResponse`\n\n```ts\ntype WorkerResponse<TOutput> =\n | { id: number; kind: 'chunk'; value: TOutput; version: 1 }\n | { error: SerializedError; id: number; kind: 'error'; version: 1 }\n | { id: number; kind: 'result'; value: TOutput; version: 1 };\n```\n\n### `TaskHandler` and `StreamHandler`\n\n```ts\ntype TaskHandler<TInput, TOutput> = (input: TInput) => TOutput | Promise<TOutput>;\ntype StreamHandler<TInput, TChunk> = (input: TInput) => AsyncIterable<TChunk> | Promise<AsyncIterable<TChunk>>;\n```\n\n## Errors\n\n| Error | Trigger | Notable property |\n| --- | --- | --- |\n| `FamiliarError` | Base class for all Familiar errors | `FamiliarError
|
|
5
|
+
"api": "---\ntitle: Familiar — API Reference\ndescription: API reference for module-worker pools and worker-side protocol registration.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createWorker()` | Create single-result module-worker pool | Sync | Worker must call `exposeTask()` |\n| `createStreamWorker()` | Create stream-only module-worker pool | Sync | Worker must call `exposeStream()` |\n| `batch()` | Yield ordered task-pool results | Async iterator | Stops remaining work on first failure |\n| `createTaskGroup()` | Coordinate related task-pool jobs | Sync | Call `abort()` to stop group work |\n| `createTestWorker()` | Create an in-process task-pool test double | Sync | Task modules are not executed |\n| `exposeTask()` | Register worker task handler | Sync | Worker-only import |\n| `exposeStream()` | Register worker stream handler | Sync | Worker-only import |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/familiar` | Pool factories, helpers, types, errors |\n| `@vielzeug/familiar/protocol` | Versioned worker protocol and registration helpers |\n| `@vielzeug/familiar/testing` | Task-pool testing adapter |\n\n## Pool Factories\n\n### `createWorker()`\n\n```ts\nfunction createWorker<TInput, TOutput>(url: URL | string, options?: WorkerOptions): WorkerPool<TInput, TOutput>;\n```\n\nCreates a task pool for a worker module registered with `exposeTask()`.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `url` | `URL \\| string` | Module-worker URL, usually `new URL('./task.worker.ts', import.meta.url)` |\n| `options` | `WorkerOptions` | Pool concurrency, queue, timeout, and worker-error policy |\n\n**Returns:** `WorkerPool<TInput, TOutput>`.\n\n**Example:**\n\n```ts\nimport { createWorker } from '@vielzeug/familiar';\n\nconst pool = createWorker<number, number>(new URL('./double.worker.ts', import.meta.url));\n\ntry {\n console.log(await pool.run(21));\n} finally {\n pool.dispose();\n}\n```\n\n### `createStreamWorker()`\n\n```ts\nfunction createStreamWorker<TInput, TChunk>(url: URL | string, options?: WorkerOptions): StreamWorkerPool<TInput, TChunk>;\n```\n\nCreates a stream-only pool for a worker module registered with `exposeStream()`.\n\n**Returns:** `StreamWorkerPool<TInput, TChunk>`.\n\n---\n\n### `batch()`\n\n```ts\nfunction batch<TInput, TOutput>(\n pool: WorkerPool<TInput, TOutput>,\n inputs: readonly TInput[],\n options?: BatchOptions,\n): AsyncIterable<TOutput>;\n```\n\nYields results in submission order. A failure or cancellation aborts remaining batch work.\n\n**Returns:** `AsyncIterable<TOutput>`.\n\n---\n\n### `createTaskGroup()`\n\n```ts\nfunction createTaskGroup<TInput, TOutput>(\n pool: WorkerPool<TInput, TOutput>,\n name?: string,\n options?: TaskGroupOptions,\n): TaskGroup<TInput, TOutput>;\n```\n\nCreates group-scoped cancellation and settlement tracking for one task pool.\n\n**Returns:** `TaskGroup<TInput, TOutput>`.\n\n## Testing\n\n### `createTestWorker()`\n\n```ts\nfunction createTestWorker<TInput, TOutput>(\n handler: (input: TInput) => TOutput | Promise<TOutput>,\n options?: TestWorkerOptions,\n): TestWorkerHandle<TInput, TOutput>;\n```\n\nCreates an in-process task-pool double. It structured-clones values, records settlement, and matches task-pool timeout and cancellation behavior without loading a worker module.\n\n**Returns:** `TestWorkerHandle<TInput, TOutput>`.\n\n## Worker Protocol\n\n### `exposeTask()`\n\n```ts\nfunction exposeTask<TInput, TOutput>(handler: TaskHandler<TInput, TOutput>): void;\n```\n\nRegisters one single-result handler in a module worker.\n\n### `exposeStream()`\n\n```ts\nfunction exposeStream<TInput, TChunk>(handler: StreamHandler<TInput, TChunk>): void;\n```\n\nRegisters one chunk-producing handler in a module worker.\n\n### `PROTOCOL_VERSION`\n\n```ts\nconst PROTOCOL_VERSION: 1;\n```\n\nVersion included in every host request and worker response.\n\n## Types\n\n### `WorkerOptions`\n\n```ts\ntype WorkerOptions = {\n concurrency?: number | 'auto';\n maxQueue?: number;\n onFull?: 'reject' | 'wait';\n timeout?: number;\n onSlotError?: (error: FamiliarRuntimeError) => void;\n};\n```\n\n### `RunOptions`\n\n```ts\ntype RunOptions = {\n priority?: number;\n signal?: AbortSignal;\n timeout?: number;\n transferables?: Transferable[];\n};\n```\n\n`signal` cancels capacity waits, queued work, and executing work. Executing cancellation terminates and replaces its worker slot.\n\n### `WorkerPool`\n\n```ts\ninterface WorkerPool<TInput, TOutput> {\n [Symbol.asyncDispose](): Promise<void>;\n [Symbol.dispose](): void;\n run(input: TInput, options?: RunOptions): Promise<TOutput>;\n prime(): Promise<void>;\n drain(options?: DrainOptions): Promise<void>;\n dispose(): void;\n readonly stats: WorkerStats;\n readonly status: WorkerStatus;\n readonly disposed: boolean;\n readonly disposalSignal: AbortSignal;\n}\n```\n\n### `StreamWorkerPool`\n\n```ts\ninterface StreamWorkerPool<TInput, TChunk> {\n [Symbol.asyncDispose](): Promise<void>;\n [Symbol.dispose](): void;\n runStream(input: TInput, options?: RunOptions): AsyncIterable<TChunk>;\n prime(): Promise<void>;\n drain(options?: DrainOptions): Promise<void>;\n dispose(): void;\n readonly disposed: boolean;\n readonly disposalSignal: AbortSignal;\n readonly stats: WorkerStats;\n readonly status: WorkerStatus;\n}\n```\n\n### `WorkerStats`\n\n```ts\ntype WorkerStats = {\n readonly active: number;\n readonly completed: number;\n readonly failed: number;\n readonly queued: number;\n};\n```\n\n### `RunningStream`\n\n```ts\ntype RunningStream<TChunk> = {\n done: Promise<void>;\n iterable: AsyncIterable<TChunk>;\n};\n```\n\n### `WorkerStatus`\n\n```ts\ntype WorkerStatus = 'idle' | 'running' | 'terminated';\n```\n\n### `BatchOptions`\n\n```ts\ntype BatchOptions = RunOptions;\n```\n\n### `DrainOptions`\n\n```ts\ntype DrainOptions = {\n timeout?: number;\n};\n```\n\n### `TaskGroup`\n\n```ts\ntype TaskGroup<TInput, TOutput> = {\n abort(reason?: unknown): void;\n drain(): Promise<PromiseSettledResult<TOutput>[]>;\n readonly name: string | undefined;\n readonly pending: number;\n run(input: TInput, options?: Omit<RunOptions, 'signal'>): Promise<TOutput>;\n readonly size: number;\n};\n```\n\n### `TaskGroupOptions`\n\n```ts\ntype TaskGroupOptions = {\n signal?: AbortSignal;\n};\n```\n\n### `TestWorkerOptions`\n\n```ts\ntype TestWorkerOptions = Omit<WorkerOptions, 'concurrency' | 'onSlotError'> & {\n concurrency?: number;\n};\n```\n\n### `TestWorkerCall`\n\n```ts\ntype TestWorkerCall<TInput, TOutput> =\n | { input: TInput; status: 'fulfilled'; value: TOutput }\n | { input: TInput; reason: unknown; status: 'rejected' };\n```\n\n### `TestWorkerHandle`\n\n```ts\ntype TestWorkerHandle<TInput, TOutput> = WorkerPool<TInput, TOutput> & {\n readonly calls: ReadonlyArray<TestWorkerCall<TInput, TOutput>>;\n};\n```\n\n### `SerializedError`\n\n```ts\ntype SerializedError = {\n message: string;\n name: string;\n stack?: string;\n};\n```\n\n### `WorkerRequest`\n\n```ts\ntype WorkerRequest<TInput> =\n | { id: number; input: TInput; kind: 'run'; version: 1 }\n | { id: number; input: TInput; kind: 'stream'; version: 1 };\n```\n\n### `WorkerResponse`\n\n```ts\ntype WorkerResponse<TOutput> =\n | { id: number; kind: 'chunk'; value: TOutput; version: 1 }\n | { error: SerializedError; id: number; kind: 'error'; version: 1 }\n | { id: number; kind: 'result'; value: TOutput; version: 1 };\n```\n\n### `TaskHandler` and `StreamHandler`\n\n```ts\ntype TaskHandler<TInput, TOutput> = (input: TInput) => TOutput | Promise<TOutput>;\ntype StreamHandler<TInput, TChunk> = (input: TInput) => AsyncIterable<TChunk> | Promise<AsyncIterable<TChunk>>;\n```\n\n## Errors\n\n| Error | Trigger | Notable property |\n| --- | --- | --- |\n| `FamiliarError` | Base class for all Familiar errors | Use `instanceof FamiliarError` to narrow |\n| `FamiliarInvalidOptionsError` | Invalid factory or test options | — |\n| `FamiliarQueueFullError` | Queue limit reached with `onFull: 'reject'` | `maxQueue` |\n| `FamiliarTaskError` | Worker handler throws or payload cannot clone | `cause` |\n| `FamiliarTimeoutError` | Task or drain deadline expires | `timeoutMs` |\n| `FamiliarTerminatedError` | Pool is disposed or draining | — |\n| `FamiliarRuntimeError` | Worker API or worker process fails | `cause` |\n",
|
|
6
6
|
"usage": "---\ntitle: Familiar — Usage Guide\ndescription: Run task and stream module workers with bounded concurrency, cancellation, and test parity.\n---\n\n[[toc]]\n\n## Basic Usage\n\nPut task logic in a worker module. Imports and helpers stay normal module code.\n\n```ts\n// normalize.worker.ts\nimport { exposeTask } from '@vielzeug/familiar/protocol';\n\nimport { normalize } from './normalize';\n\nexposeTask((text: string) => normalize(text));\n```\n\nCreate one long-lived pool at its owner boundary.\n\n```ts\nimport { createWorker } from '@vielzeug/familiar';\n\nconst pool = createWorker<string, string>(new URL('./normalize.worker.ts', import.meta.url), {\n concurrency: 2,\n timeout: 2_000,\n});\n\ntry {\n const normalized = await pool.run(' Familiar ');\n console.log(normalized);\n} finally {\n pool.dispose();\n}\n```\n\n## Cancellation and Timeouts\n\nPass one signal to stop capacity waits, queued work, or active work. Cancelling active work terminates and lazily replaces its slot.\n\n```ts\nconst controller = new AbortController();\nconst result = pool.run('input', { signal: controller.signal, timeout: 500 });\n\ncontroller.abort();\nawait result.catch((error) => console.log(error.name)); // AbortError\n```\n\n## Queue Policy and Priority\n\nUse `maxQueue` to bound waiting work. Higher priorities dispatch first once a slot opens.\n\n```ts\nconst pool = createWorker<Job, Result>(new URL('./job.worker.ts', import.meta.url), {\n concurrency: 2,\n maxQueue: 100,\n onFull: 'wait',\n});\n\nawait pool.run(criticalJob, { priority: 10 });\n```\n\n## Batch and Groups\n\nCompose task pools with free helpers instead of carrying unrelated methods on every pool.\n\n```ts\nimport { batch, createTaskGroup } from '@vielzeug/familiar';\n\nfor await (const value of batch(pool, inputs)) {\n console.log(value);\n}\n\nconst group = createTaskGroup(pool, 'import');\nconst tasks = rows.map((row) => group.run(row));\nawait group.drain();\nawait Promise.all(tasks);\n```\n\n## Streaming\n\nStream workers have their own capability and registration helper.\n\n```ts\n// tokenize.worker.ts\nimport { exposeStream } from '@vielzeug/familiar/protocol';\n\nexposeStream(async function* (text: string) {\n for (const token of text.split(/\\s+/)) yield token;\n});\n```\n\n```ts\nimport { createStreamWorker } from '@vielzeug/familiar';\n\nconst pool = createStreamWorker<string, string>(new URL('./tokenize.worker.ts', import.meta.url));\nfor await (const token of pool.runStream('typed module workers')) {\n console.log(token);\n}\npool.dispose();\n```\n\n## Testing\n\nUse `createTestWorker()` when testing consumer code that depends on a task pool. It clones input/output, wraps task failures, and honors cancellation and timeout behavior.\n\n```ts\nimport { createTestWorker } from '@vielzeug/familiar/testing';\n\nconst pool = createTestWorker((value: number) => value * 2);\nawait expect(pool.run(21)).resolves.toBe(42);\nexpect(pool.calls).toEqual([{ input: 21, status: 'fulfilled', value: 42 }]);\npool.dispose();\n```\n\nTest worker-module business logic directly when possible. `createTestWorker()` does not run module files or support stream pools.\n\n## Framework Integration\n\nCreate a pool once per component lifetime. Abort obsolete requests during effect cleanup and dispose the pool on unmount.\n\n::: code-group\n\n```tsx [React]\nimport { useEffect, useMemo } from 'react';\nimport { createWorker } from '@vielzeug/familiar';\n\nconst pool = useMemo(() => createWorker(new URL('./sort.worker.ts', import.meta.url)), []);\n\nuseEffect(() => () => pool.dispose(), [pool]);\n```\n\n```ts [Vue]\nimport { onUnmounted } from 'vue';\nimport { createWorker } from '@vielzeug/familiar';\n\nconst pool = createWorker(new URL('./sort.worker.ts', import.meta.url));\n\nonUnmounted(() => pool.dispose());\n```\n\n```ts [Svelte]\nimport { onDestroy } from 'svelte';\nimport { createWorker } from '@vielzeug/familiar';\n\nconst pool = createWorker(new URL('./sort.worker.ts', import.meta.url));\n\nonDestroy(() => pool.dispose());\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\nUse `@vielzeug/arsenal` async helpers in application orchestration. Keep worker module protocol registration in `@vielzeug/familiar/protocol`.\n\n## Best Practices\n\n- Put every task handler in its own module-worker boundary.\n- Reuse pools for repeated work; dispose owner-scoped pools.\n- Abort work made obsolete by navigation or newer input.\n- Transfer large binary buffers instead of cloning them.\n- Set explicit timeouts for work with a bounded latency budget.\n- Keep worker handlers deterministic and data-only.\n- Test module logic directly; test pool consumers with `createTestWorker()`.\n",
|
|
7
7
|
"examples": "---\ntitle: Familiar — Examples\ndescription: Module-worker recipes for familiar.\n---\n\n## Examples\n\n- [Fibonacci With Pool And Timeout](./examples/fibonacci-with-pool-and-timeout.md)\n- [Data Transformation Pipeline](./examples/data-transformation-pipeline.md)\n- [Image Processing](./examples/image-processing.md)\n- [Using Transferables](./examples/using-transferables.md)\n- [Cancellable Batch](./examples/cancellable-batch.md)\n- [Priority Queue](./examples/priority-queue.md)\n- [Streaming With Stream Worker](./examples/streaming-with-runstream.md)\n- [Module Worker](./examples/module-worker.md)\n- [Typed Error Handling](./examples/typed-error-handling.md)\n- [React Integration](./examples/react-integration.md)\n- [Testing With createTestWorker](./examples/testing-with-createtestworker.md)\n"
|
|
8
8
|
},
|
package/data/packages/forge.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"apiSource": "export { ForgeConfigError, ForgeDisposedError, ForgeError, ForgeSubmitError, ForgeValidationError } from './errors';\nexport { createForm } from './form';\nexport * from './types';\n",
|
|
3
3
|
"docs": {
|
|
4
4
|
"index": "---\ntitle: Forge — Immutable form state for TypeScript\ndescription: Framework-agnostic immutable form state with focused object fields and explicit validation results.\npackage: forge\ncategory: forms\nkeywords: [form-state, validation, immutable, input, submission]\nrelated: [spell, vault, courier]\nexports: [createForm, bindField, customValidator, saveForm, loadForm]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"forge\" />\n\n## Why Forge?\n\nNative form state becomes difficult to inspect once values, validation, draft restoration, and UI bindings share mutable objects. Forge owns one immutable value tree and gives you typed handles for object branches without string paths, scoped controllers, or framework state.\n\n```ts\n// Before\nconst values = { email: '', password: '' };\nconst errors: Record<string, string> = {};\n\nfunction submit() {\n errors.email = values.email.includes('@') ? '' : 'Invalid email';\n errors.password = values.password.length >= 8 ? '' : 'Use at least eight characters';\n}\n\n// After\nconst form = createForm({\n initialValues: { email: '', password: '' },\n validate: (value) => ({\n fields: {\n email: value.email.includes('@') ? undefined : 'Invalid email',\n password: value.password.length >= 8 ? undefined : 'Use at least eight characters',\n },\n }),\n});\n```\n\n| Feature | Forge | Native form state | Framework-owned form state |\n| --- | --- | --- | --- |\n| Bundle size | <PackageInfo package=\"forge\" type=\"size\" /> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | Varies |\n| Zero external 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| Immutable nested values | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | Varies |\n| Typed object field handles | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | Varies |\n| Framework-independent state | <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\n<div class=\"decision-callout\">\n\n**Use Forge when** form state needs framework-independent immutable values, typed object fields, and one explicit validation boundary.\n\n**Consider framework-owned form state when** application only needs a single UI framework's native input bindings.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/forge\n```\n\n```sh [npm]\nnpm install @vielzeug/forge\n```\n\n```sh [yarn]\nyarn add @vielzeug/forge\n```\n\n:::\n\nInstall `@vielzeug/spell` or `@vielzeug/vault` only when importing Forge's matching optional adapter.\n\n## Quick Start\n\nCreate a form, update a focused field, and submit only after validation passes.\n\n```ts\nimport { createForm } from '@vielzeug/forge';\n\nconst form = createForm({\n initialValues: { profile: { email: '', name: '' } },\n validate: (value) => ({\n fields: { profile: { email: value.profile.email.includes('@') ? undefined : 'Invalid email' } },\n }),\n});\n\nform.field('profile').field('email').set('ada@example.com');\n\nconst result = await form.submit(async (value) => {\n const response = await fetch('/api/profile', {\n body: JSON.stringify(value),\n headers: { 'Content-Type': 'application/json' },\n method: 'POST',\n });\n\n return response.ok;\n});\n\nif (result.status === 'invalid') console.log(result.errors);\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `form.value` exposes one immutable nested value tree.\n- `form.field(key)` selects typed object branches without string paths.\n- `field.set(updater)` replaces array values through immutable updater functions.\n- `field.field(index)` selects typed array item fields by index.\n- `form.validate()` returns valid, invalid, or aborted results.\n- `form.submit(handler, signal?)` touches, validates, and invokes the handler when valid.\n- `bindField()` connects one DOM element without owning validation timing.\n- `customValidator()` maps Spell schema errors into Forge fields.\n- `saveForm()` and `loadForm()` persist explicit Vault draft records.\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- [Spell](/spell/) — adapt a Spell schema through `customValidator()`.\n- [Vault](/vault/) — save and restore explicit Forge draft records.\n- [Courier](/courier/) — send a validated form value through a mutation.\n\n</div>\n\n<!-- markdownlint-enable -->\n",
|
|
5
|
-
"api": "---\ntitle: Forge — API Reference\ndescription: Complete reference for immutable forms, fields, validation, serialization, and optional adapters.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createForm()` | Create immutable form state | Sync | `initialValues` cannot contain mutable class instances |\n| `form.field()` | Select a top-level or object child field | Sync | Unsafe keys (`__proto__`, `constructor`, `prototype`) are rejected |\n| `form.validate()` | Validate complete value | Async | Handle `aborted` separately |\n| `form.submit(handler, signal?)` | Touch, validate, then invoke handler | Async | Concurrent calls reject |\n| `form.reset()` | Restore or replace baseline | Sync | `reset(next)` makes `next` clean |\n| `form.subscribe()` | Observe form metadata | Sync | Throws after disposal |\n| `toFormData()` | Serialize values for multipart transport | Sync | `FileList` is transport-only |\n| `bindField()` | Bind one DOM element | Sync | Does not schedule validation |\n| `customValidator()` | Adapt a Spell schema | Async | Does not transform `form.value` |\n| `saveForm()` / `loadForm()` | Persist explicit Vault records | Async | FormDraftCodec owns record shape |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/forge` | Core form factory, types, and errors |\n| `@vielzeug/forge/dom` | `bindField()` and DOM binding types |\n| `@vielzeug/forge/form-data` | `toFormData()` |\n| `@vielzeug/forge/spell` | `customValidator()` |\n| `@vielzeug/forge/vault` | `saveForm()`, `loadForm()`, and `FormDraftCodec` |\n\n## Core Functions\n\n### `createForm(options)`\n\n```ts\nfunction createForm<TValues extends Record<string, unknown>>(options: FormOptions<TValues>): Form<TValues>;\n```\n\nCreates a form with immutable initial values and an optional full-form validator.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `options.initialValues` | `TValues` | Initial value and reset baseline. Supports primitives, plain objects, arrays, `Date`, `File`, and `Blob`. |\n| `options.validate` | `FormValidator<TValues>` | Optional validator for the entire current value. |\n| `options.onSubscriberError` | `(error: unknown) => void` | Optional subscriber failure reporter. |\n\n**Returns:** `Form<TValues>`.\n\n**Example:**\n\n```ts\nimport { createForm } from '@vielzeug/forge';\n\nconst form = createForm({ initialValues: { email: '' } });\n```\n\n---\n\n### `toFormData(values)`\n\n```ts\nfunction toFormData(values: Record<string, unknown>): FormData;\n```\n\nConverts nested values into `FormData` with dot-separated object keys and repeated array keys.\n\n**Returns:** a populated `FormData` instance.\n\n**Example:**\n\n```ts\nimport { toFormData } from '@vielzeug/forge/form-data';\n\nconst body = toFormData({ profile: { email: 'ada@example.com' }, tags: ['typescript', 'forms'] });\n```\n\n## Form Handles\n\n### `Form<TValues>`\n\n`createForm()` returns this handle.\n\n| Member | Signature | Description |\n| --- | --- | --- |\n| `value` | `ReadonlyDeep<TValues>` | Current immutable value. |\n| `state` | `FormState<TValues>` | Submission, validation, touch, and error metadata. |\n| `field(key)` | `Field<TValues[K]>` | Select a top-level field. |\n| `set(next)` | `void` | Replace the complete value or derive a replacement. |\n| `reset(next?)` | `void` | Restore baseline or make `next` the baseline. |\n| `validate(signal?)` | `Promise<ValidationResult<TValues>>` | Run full-form validation. |\n| `submit(handler, signal?)` | `Promise<SubmitResult<TResult, TValues>>` | Touch, validate, and invoke handler when valid. |\n| `subscribe(listener, options?)` | `Unsubscribe` | Observe form state; throws after disposal. |\n| `dispose()` | `void` | Abort validation and clear subscribers. |\n| `disposed` | `boolean` | Whether the form has been disposed. |\n| `disposalSignal` | `AbortSignal` | Aborts on disposal. |\n\n### `Field<V>`\n\n`form.field(key)` and object-field `.field(key)` return this handle. Array-item `.field(index)` returns a per-item field handle.\n\n| Member | Signature | Description |\n| --- | --- | --- |\n| `value` | `ReadonlyDeep<V>` | Current immutable branch value. |\n| `error` | `string \\| undefined` | Current field error. |\n| `dirty` | `boolean` | Whether branch differs from baseline. |\n| `touched` | `boolean` | Whether field was touched. |\n| `state` | `FieldState<V>` | Snapshot of `dirty`, `error`, `touched`, and `value` in one read. |\n| `field(key)` | `Field<V[K]>` | Select child object field or array item by index. |\n| `set(next)` | `void` | Replace branch or derive a replacement. |\n| `reset()` | `void` | Restore exact baseline branch. |\n| `touch()` | `void` | Mark field touched. |\n| `subscribe(listener, options?)` | `Unsubscribe` | Observe field transitions; throws after disposal. |\n\n## Validation Results\n\n### `form.validate(signal?)`\n\n```ts\nfunction validate(signal?: AbortSignal): Promise<ValidationResult<TValues>>;\n```\n\nRuns the configured validator against the complete value. A newer validation aborts the older run.\n\n**Returns:** `ValidationResult<TValues>`.\n\n```ts\nconst result = await form.validate();\n\nif (result.status === 'invalid') console.log(result.errors, result.formError);\n```\n\n### `form.submit(handler, signal?)`\n\n```ts\nfunction submit<TResult = void>(\n handler: (values: ReadonlyDeep<TValues>, signal: AbortSignal) => MaybePromise<TResult>,\n signal?: AbortSignal,\n): Promise<SubmitResult<TResult, TValues>>;\n```\n\nTouches all fields, validates once, and invokes `handler` when validation is valid. The handler receives an `AbortSignal` that is aborted when the external `signal` (or the form's disposal signal) aborts.\n\n**Returns:** `SubmitResult<TResult, TValues>`. Handler failures reject normally unless caused by signal abort, which returns `{ status: 'aborted' }`.\n\n```ts\nconst result = await form.submit((value) => Promise.resolve(value));\n```\n\n## Adapters\n\n### `bindField(element, field, options)`\n\n```ts\nfunction bindField<Element extends HTMLElement, V>(\n element: Element,\n field: Field<V>,\n options: FieldBindingOptions<Element, V>,\n): () => void;\n```\n\nBinds one field to one element, marks it touched on blur, suppresses writeback from its own input event, and returns teardown.\n\n**Example:**\n\n```ts\nimport { bindField } from '@vielzeug/forge/dom';\n\nconst stop = bindField(input, form.field('email'), {\n read: (element) => element.value,\n write: (element, value) => {\n element.value = value;\n },\n});\n```\n\n---\n\n### `customValidator(schema)`\n\n```ts\nfunction customValidator<TValues extends Record<string, unknown>>(\n schema: Schema<unknown, TValues, SchemaMode>,\n): FormValidator<TValues>;\n```\n\nAdapts a Spell schema. Every failing union maps its closest branch while preserving unrelated errors. Array item issues map to per-item array fields; duplicate paths retain the first message.\n\n**Example:**\n\n```ts\nimport { customValidator } from '@vielzeug/forge/spell';\nimport { s } from '@vielzeug/spell';\n\nconst Profile = s.object({ email: s.string().email() });\nconst form = createForm({ initialValues: { email: '' }, validate: customValidator(Profile) });\n```\n\n---\n\n### `saveForm()` and `loadForm()`\n\n```ts\nfunction saveForm<TValues extends Record<string, unknown>, S extends AnySchema, K extends keyof S & string>(\n form: Form<TValues>, adapter: VaultStore<S>, table: K, codec: FormDraftCodec<TValues, S, K>,\n): Promise<void>;\n\nfunction loadForm<TValues extends Record<string, unknown>, S extends AnySchema, K extends keyof S & string>(\n form: Form<TValues>, adapter: VaultStore<S>, table: K, key: KeyOf<S, K>, codec: FormDraftCodec<TValues, S, K>,\n): Promise<boolean>;\n```\n\nPersists or restores a codec-defined Vault record. `loadForm()` calls `form.reset()` when the codec decodes a record.\n\n**Returns:** `loadForm()` returns `false` for a missing or rejected record.\n\n## Types\n\n```ts\ntype Unsubscribe = () => void;\ntype MaybePromise<T> = T | PromiseLike<T>;\ntype ReadonlyDeep<T> = T extends (...args: never[]) => unknown\n ? T\n : T extends readonly (infer Item)[]\n ? readonly ReadonlyDeep<Item>[]\n : T extends Record<string, unknown>\n ? { readonly [K in keyof T]: ReadonlyDeep<T[K]> }\n : T;\n\ntype FormErrors<T> = T extends readonly (infer Item)[]\n ? string | readonly (FormErrors<Item> | undefined)[]\n : T extends Record<string, unknown>\n ? string | { readonly [K in keyof T]?: FormErrors<T[K]> }\n : string;\n\ntype ValidationErrors<TValues extends Record<string, unknown>> = Readonly<{\n fields?: FormErrors<TValues>;\n formError?: string;\n}>;\n\ntype FormValidator<TValues extends Record<string, unknown>> = (\n values: ReadonlyDeep<TValues>, signal: AbortSignal,\n) => MaybePromise<ValidationErrors<TValues> | undefined>;\n\ntype FormOptions<TValues extends Record<string, unknown>> = Readonly<{\n initialValues: TValues;\n onSubscriberError?: (error: unknown) => void;\n validate?: FormValidator<NoInfer<TValues>>;\n}>;\n\ntype SubscribeOptions = Readonly<{ immediate?: boolean }>;\n\ntype FieldState<V> = Readonly<{\n dirty: boolean;\n error: string | undefined;\n touched: boolean;\n value: ReadonlyDeep<V>;\n}>;\n\ntype FormState<TValues extends Record<string, unknown>> = Readonly<{\n errors: FormErrors<TValues> | undefined;\n formError: string | undefined;\n hasErrors: boolean;\n submitCount: number;\n submitting: boolean;\n touched: boolean;\n validity: 'invalid' | 'unknown' | 'valid';\n validating: boolean;\n}>;\n\ntype ValidationResult<TValues extends Record<string, unknown>> =\n | Readonly<{ status: 'aborted' }>\n | Readonly<{ status: 'valid' }>\n | Readonly<{ errors: FormErrors<TValues> | undefined; formError: string | undefined; status: 'invalid' }>;\n\ntype SubmitResult<TResult = void, TValues extends Record<string, unknown> = Record<string, unknown>> =\n | Readonly<{ status: 'aborted' }>\n | Readonly<{ errors: FormErrors<TValues> | undefined; formError: string | undefined; status: 'invalid' }>\n | Readonly<{ status: 'ok'; value: TResult }>;\n```\n\n```ts\ntype ChildField<V> =\n NonNullable<V> extends readonly (infer Item)[]\n ? { field(index: number): Field<Item> }\n : NonNullable<V> extends Record<string, unknown>\n ? { field<K extends keyof NonNullable<V> & string>(key: K): Field<NonNullable<V>[K]> }\n : Record<never, never>;\n\ntype Field<V> = ChildField<V> & {\n readonly dirty: boolean;\n readonly error: string | undefined;\n readonly state: FieldState<V>;\n readonly touched: boolean;\n readonly value: ReadonlyDeep<V>;\n reset(): void;\n set(next: V | ((previous: ReadonlyDeep<V>) => V)): void;\n subscribe(listener: (state: FieldState<V>) => void, options?: SubscribeOptions): Unsubscribe;\n touch(): void;\n};\n\ntype Form<TValues extends Record<string, unknown>> = {\n [Symbol.dispose](): void;\n readonly disposalSignal: AbortSignal;\n readonly disposed: boolean;\n readonly state: FormState<TValues>;\n readonly value: ReadonlyDeep<TValues>;\n dispose(): void;\n field<K extends keyof TValues & string>(key: K): Field<TValues[K]>;\n reset(next?: TValues): void;\n set(next: TValues | ((previous: ReadonlyDeep<TValues>) => TValues)): void;\n submit<TResult = void>(\n handler: (values: ReadonlyDeep<TValues>, signal: AbortSignal) => MaybePromise<TResult>,\n signal?: AbortSignal,\n ): Promise<SubmitResult<TResult, TValues>>;\n subscribe(listener: (state: FormState<TValues>) => void, options?: SubscribeOptions): Unsubscribe;\n validate(signal?: AbortSignal): Promise<ValidationResult<TValues>>;\n};\n\ntype FieldBindingOptions<Element extends HTMLElement, V> = Readonly<{\n event?: keyof HTMLElementEventMap;\n read(element: Element): V;\n write?: (element: Element, value: ReadonlyDeep<V>) => void;\n}>;\n\ntype FormDraftCodec<TValues extends Record<string, unknown>, S extends AnySchema, K extends keyof S & string> = Readonly<{\n fromRecord(record: RecordOf<S, K>): TValues | undefined;\n toRecord(values: ReadonlyDeep<TValues>): RecordOf<S, K>;\n}>;\n```\n\n## Errors\n\n| Error | Trigger | Notable properties |\n| --- | --- | --- |\n| `ForgeError` | Base Forge error | `ForgeError.is(error)` narrows unknown values. |\n| `ForgeConfigError` | Unsafe key or unsupported form value | Extends `ForgeError`. |\n| `ForgeDisposedError` | Operation or subscription after disposal | Message names the attempted operation. |\n| `ForgeSubmitError` | Concurrent `submit()` call | Extends `ForgeError`. |\n| `ForgeValidationError` | Validator throws unexpectedly | Preserves original error as `cause`. |\n",
|
|
5
|
+
"api": "---\ntitle: Forge — API Reference\ndescription: Complete reference for immutable forms, fields, validation, serialization, and optional adapters.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createForm()` | Create immutable form state | Sync | `initialValues` cannot contain mutable class instances |\n| `form.field()` | Select a top-level or object child field | Sync | Unsafe keys (`__proto__`, `constructor`, `prototype`) are rejected |\n| `form.validate()` | Validate complete value | Async | Handle `aborted` separately |\n| `form.submit(handler, signal?)` | Touch, validate, then invoke handler | Async | Concurrent calls reject |\n| `form.reset()` | Restore or replace baseline | Sync | `reset(next)` makes `next` clean |\n| `form.subscribe()` | Observe form metadata | Sync | Throws after disposal |\n| `toFormData()` | Serialize values for multipart transport | Sync | `FileList` is transport-only |\n| `bindField()` | Bind one DOM element | Sync | Does not schedule validation |\n| `customValidator()` | Adapt a Spell schema | Async | Does not transform `form.value` |\n| `saveForm()` / `loadForm()` | Persist explicit Vault records | Async | FormDraftCodec owns record shape |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/forge` | Core form factory, types, and errors |\n| `@vielzeug/forge/dom` | `bindField()` and DOM binding types |\n| `@vielzeug/forge/form-data` | `toFormData()` |\n| `@vielzeug/forge/spell` | `customValidator()` |\n| `@vielzeug/forge/vault` | `saveForm()`, `loadForm()`, and `FormDraftCodec` |\n\n## Core Functions\n\n### `createForm(options)`\n\n```ts\nfunction createForm<TValues extends Record<string, unknown>>(options: FormOptions<TValues>): Form<TValues>;\n```\n\nCreates a form with immutable initial values and an optional full-form validator.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `options.initialValues` | `TValues` | Initial value and reset baseline. Supports primitives, plain objects, arrays, `Date`, `File`, and `Blob`. |\n| `options.validate` | `FormValidator<TValues>` | Optional validator for the entire current value. |\n| `options.onSubscriberError` | `(error: unknown) => void` | Optional subscriber failure reporter. |\n\n**Returns:** `Form<TValues>`.\n\n**Example:**\n\n```ts\nimport { createForm } from '@vielzeug/forge';\n\nconst form = createForm({ initialValues: { email: '' } });\n```\n\n---\n\n### `toFormData(values)`\n\n```ts\nfunction toFormData(values: Record<string, unknown>): FormData;\n```\n\nConverts nested values into `FormData` with dot-separated object keys and repeated array keys.\n\n**Returns:** a populated `FormData` instance.\n\n**Example:**\n\n```ts\nimport { toFormData } from '@vielzeug/forge/form-data';\n\nconst body = toFormData({ profile: { email: 'ada@example.com' }, tags: ['typescript', 'forms'] });\n```\n\n## Form Handles\n\n### `Form<TValues>`\n\n`createForm()` returns this handle.\n\n| Member | Signature | Description |\n| --- | --- | --- |\n| `value` | `ReadonlyDeep<TValues>` | Current immutable value. |\n| `state` | `FormState<TValues>` | Submission, validation, touch, and error metadata. |\n| `field(key)` | `Field<TValues[K]>` | Select a top-level field. |\n| `set(next)` | `void` | Replace the complete value or derive a replacement. |\n| `reset(next?)` | `void` | Restore baseline or make `next` the baseline. |\n| `validate(signal?)` | `Promise<ValidationResult<TValues>>` | Run full-form validation. |\n| `submit(handler, signal?)` | `Promise<SubmitResult<TResult, TValues>>` | Touch, validate, and invoke handler when valid. |\n| `subscribe(listener, options?)` | `Unsubscribe` | Observe form state; throws after disposal. |\n| `dispose()` | `void` | Abort validation and clear subscribers. |\n| `disposed` | `boolean` | Whether the form has been disposed. |\n| `disposalSignal` | `AbortSignal` | Aborts on disposal. |\n\n### `Field<V>`\n\n`form.field(key)` and object-field `.field(key)` return this handle. Array-item `.field(index)` returns a per-item field handle.\n\n| Member | Signature | Description |\n| --- | --- | --- |\n| `value` | `ReadonlyDeep<V>` | Current immutable branch value. |\n| `error` | `string \\| undefined` | Current field error. |\n| `dirty` | `boolean` | Whether branch differs from baseline. |\n| `touched` | `boolean` | Whether field was touched. |\n| `state` | `FieldState<V>` | Snapshot of `dirty`, `error`, `touched`, and `value` in one read. |\n| `field(key)` | `Field<V[K]>` | Select child object field or array item by index. |\n| `set(next)` | `void` | Replace branch or derive a replacement. |\n| `reset()` | `void` | Restore exact baseline branch. |\n| `touch()` | `void` | Mark field touched. |\n| `subscribe(listener, options?)` | `Unsubscribe` | Observe field transitions; throws after disposal. |\n\n## Validation Results\n\n### `form.validate(signal?)`\n\n```ts\nfunction validate(signal?: AbortSignal): Promise<ValidationResult<TValues>>;\n```\n\nRuns the configured validator against the complete value. A newer validation aborts the older run.\n\n**Returns:** `ValidationResult<TValues>`.\n\n```ts\nconst result = await form.validate();\n\nif (result.status === 'invalid') console.log(result.errors, result.formError);\n```\n\n### `form.submit(handler, signal?)`\n\n```ts\nfunction submit<TResult = void>(\n handler: (values: ReadonlyDeep<TValues>, signal: AbortSignal) => MaybePromise<TResult>,\n signal?: AbortSignal,\n): Promise<SubmitResult<TResult, TValues>>;\n```\n\nTouches all fields, validates once, and invokes `handler` when validation is valid. The handler receives an `AbortSignal` that is aborted when the external `signal` (or the form's disposal signal) aborts.\n\n**Returns:** `SubmitResult<TResult, TValues>`. Handler failures reject normally unless caused by signal abort, which returns `{ status: 'aborted' }`.\n\n```ts\nconst result = await form.submit((value) => Promise.resolve(value));\n```\n\n## Adapters\n\n### `bindField(element, field, options)`\n\n```ts\nfunction bindField<Element extends HTMLElement, V>(\n element: Element,\n field: Field<V>,\n options: FieldBindingOptions<Element, V>,\n): () => void;\n```\n\nBinds one field to one element, marks it touched on blur, suppresses writeback from its own input event, and returns teardown.\n\n**Example:**\n\n```ts\nimport { bindField } from '@vielzeug/forge/dom';\n\nconst stop = bindField(input, form.field('email'), {\n read: (element) => element.value,\n write: (element, value) => {\n element.value = value;\n },\n});\n```\n\n---\n\n### `customValidator(schema)`\n\n```ts\nfunction customValidator<TValues extends Record<string, unknown>>(\n schema: Schema<unknown, TValues, SchemaMode>,\n): FormValidator<TValues>;\n```\n\nAdapts a Spell schema. Every failing union maps its closest branch while preserving unrelated errors. Array item issues map to per-item array fields; duplicate paths retain the first message.\n\n**Example:**\n\n```ts\nimport { customValidator } from '@vielzeug/forge/spell';\nimport { s } from '@vielzeug/spell';\n\nconst Profile = s.object({ email: s.string().email() });\nconst form = createForm({ initialValues: { email: '' }, validate: customValidator(Profile) });\n```\n\n---\n\n### `saveForm()` and `loadForm()`\n\n```ts\nfunction saveForm<TValues extends Record<string, unknown>, S extends AnySchema, K extends keyof S & string>(\n form: Form<TValues>, adapter: VaultStore<S>, table: K, codec: FormDraftCodec<TValues, S, K>,\n): Promise<void>;\n\nfunction loadForm<TValues extends Record<string, unknown>, S extends AnySchema, K extends keyof S & string>(\n form: Form<TValues>, adapter: VaultStore<S>, table: K, key: KeyOf<S, K>, codec: FormDraftCodec<TValues, S, K>,\n): Promise<boolean>;\n```\n\nPersists or restores a codec-defined Vault record. `loadForm()` calls `form.reset()` when the codec decodes a record.\n\n**Returns:** `loadForm()` returns `false` for a missing or rejected record.\n\n## Types\n\n```ts\ntype Unsubscribe = () => void;\ntype MaybePromise<T> = T | PromiseLike<T>;\ntype ReadonlyDeep<T> = T extends (...args: never[]) => unknown\n ? T\n : T extends readonly (infer Item)[]\n ? readonly ReadonlyDeep<Item>[]\n : T extends Record<string, unknown>\n ? { readonly [K in keyof T]: ReadonlyDeep<T[K]> }\n : T;\n\ntype FormErrors<T> = T extends readonly (infer Item)[]\n ? string | readonly (FormErrors<Item> | undefined)[]\n : T extends Record<string, unknown>\n ? string | { readonly [K in keyof T]?: FormErrors<T[K]> }\n : string;\n\ntype ValidationErrors<TValues extends Record<string, unknown>> = Readonly<{\n fields?: FormErrors<TValues>;\n formError?: string;\n}>;\n\ntype FormValidator<TValues extends Record<string, unknown>> = (\n values: ReadonlyDeep<TValues>, signal: AbortSignal,\n) => MaybePromise<ValidationErrors<TValues> | undefined>;\n\ntype FormOptions<TValues extends Record<string, unknown>> = Readonly<{\n initialValues: TValues;\n onSubscriberError?: (error: unknown) => void;\n validate?: FormValidator<NoInfer<TValues>>;\n}>;\n\ntype SubscribeOptions = Readonly<{ immediate?: boolean }>;\n\ntype FieldState<V> = Readonly<{\n dirty: boolean;\n error: string | undefined;\n touched: boolean;\n value: ReadonlyDeep<V>;\n}>;\n\ntype FormState<TValues extends Record<string, unknown>> = Readonly<{\n errors: FormErrors<TValues> | undefined;\n formError: string | undefined;\n hasErrors: boolean;\n submitCount: number;\n submitting: boolean;\n touched: boolean;\n validity: 'invalid' | 'unknown' | 'valid';\n validating: boolean;\n}>;\n\ntype ValidationResult<TValues extends Record<string, unknown>> =\n | Readonly<{ status: 'aborted' }>\n | Readonly<{ status: 'valid' }>\n | Readonly<{ errors: FormErrors<TValues> | undefined; formError: string | undefined; status: 'invalid' }>;\n\ntype SubmitResult<TResult = void, TValues extends Record<string, unknown> = Record<string, unknown>> =\n | Readonly<{ status: 'aborted' }>\n | Readonly<{ errors: FormErrors<TValues> | undefined; formError: string | undefined; status: 'invalid' }>\n | Readonly<{ status: 'ok'; value: TResult }>;\n```\n\n```ts\ntype ChildField<V> =\n NonNullable<V> extends readonly (infer Item)[]\n ? { field(index: number): Field<Item> }\n : NonNullable<V> extends Record<string, unknown>\n ? { field<K extends keyof NonNullable<V> & string>(key: K): Field<NonNullable<V>[K]> }\n : Record<never, never>;\n\ntype Field<V> = ChildField<V> & {\n readonly dirty: boolean;\n readonly error: string | undefined;\n readonly state: FieldState<V>;\n readonly touched: boolean;\n readonly value: ReadonlyDeep<V>;\n reset(): void;\n set(next: V | ((previous: ReadonlyDeep<V>) => V)): void;\n subscribe(listener: (state: FieldState<V>) => void, options?: SubscribeOptions): Unsubscribe;\n touch(): void;\n};\n\ntype Form<TValues extends Record<string, unknown>> = {\n [Symbol.dispose](): void;\n readonly disposalSignal: AbortSignal;\n readonly disposed: boolean;\n readonly state: FormState<TValues>;\n readonly value: ReadonlyDeep<TValues>;\n dispose(): void;\n field<K extends keyof TValues & string>(key: K): Field<TValues[K]>;\n reset(next?: TValues): void;\n set(next: TValues | ((previous: ReadonlyDeep<TValues>) => TValues)): void;\n submit<TResult = void>(\n handler: (values: ReadonlyDeep<TValues>, signal: AbortSignal) => MaybePromise<TResult>,\n signal?: AbortSignal,\n ): Promise<SubmitResult<TResult, TValues>>;\n subscribe(listener: (state: FormState<TValues>) => void, options?: SubscribeOptions): Unsubscribe;\n validate(signal?: AbortSignal): Promise<ValidationResult<TValues>>;\n};\n\ntype FieldBindingOptions<Element extends HTMLElement, V> = Readonly<{\n event?: keyof HTMLElementEventMap;\n read(element: Element): V;\n write?: (element: Element, value: ReadonlyDeep<V>) => void;\n}>;\n\ntype FormDraftCodec<TValues extends Record<string, unknown>, S extends AnySchema, K extends keyof S & string> = Readonly<{\n fromRecord(record: RecordOf<S, K>): TValues | undefined;\n toRecord(values: ReadonlyDeep<TValues>): RecordOf<S, K>;\n}>;\n```\n\n## Errors\n\n| Error | Trigger | Notable properties |\n| --- | --- | --- |\n| `ForgeError` | Base Forge error | Use `instanceof ForgeError` to narrow unknown values. |\n| `ForgeConfigError` | Unsafe key or unsupported form value | Extends `ForgeError`. |\n| `ForgeDisposedError` | Operation or subscription after disposal | Message names the attempted operation. |\n| `ForgeSubmitError` | Concurrent `submit()` call | Extends `ForgeError`. |\n| `ForgeValidationError` | Validator throws unexpectedly | Preserves original error as `cause`. |\n",
|
|
6
6
|
"usage": "---\ntitle: Forge — Usage Guide\ndescription: Build immutable forms, validate whole values, and use optional adapters.\n---\n\n[[toc]]\n\n## Basic Usage\n\nCreate one form value and update object branches through stable typed operations. Form values support primitives, plain objects, arrays, `Date`, `File`, and `Blob`; mutable class instances such as `Map` and `Set` are rejected.\n\n```ts\nimport { createForm } from '@vielzeug/forge';\n\nconst form = createForm({\n initialValues: { profile: { email: '', name: '' }, tags: [] as string[] },\n validate: (value) => ({\n fields: { profile: { email: value.profile.email.includes('@') ? undefined : 'Invalid email' } },\n }),\n});\n\nconst email = form.field('profile').field('email');\nemail.set('ada@example.com');\nform.field('tags').set((tags) => [...tags, 'typescript']);\n\nconsole.log(form.value.profile.email);\n```\n\n## Reset Values and Branches\n\nReset a field when one branch should return to its exact baseline. Reset the form with a value when newly loaded data should become the clean baseline.\n\n```ts\nconst name = form.field('profile').field('name');\n\nname.set('Ada');\nname.touch();\nname.reset();\n\nform.reset({ profile: { email: 'ada@example.com', name: 'Ada' }, tags: [] });\n```\n\nAn absent optional parent remains absent after a child reset. Array items support per-index field handles for reads, updates, and resets.\n\n## Validate and Submit\n\nReturn `fields` and an optional `formError` from one validator. `validate()` replaces the complete validation snapshot and returns an explicit status.\n\n```ts\nconst passwordForm = createForm({\n initialValues: { password: '', passwordConfirmation: '' },\n validate: (value) => ({\n fields: {\n password: value.password.length >= 8 ? undefined : 'Use at least eight characters',\n passwordConfirmation: value.password === value.passwordConfirmation ? undefined : 'Passwords must match',\n },\n }),\n});\n\nconst validation = await passwordForm.validate();\n\nif (validation.status === 'invalid') console.log(validation.errors);\nif (validation.status === 'aborted') console.log('Validation cancelled');\n\nconst result = await passwordForm.submit((value) => Promise.resolve(value.password.length));\n\nif (result.status === 'ok') console.log(result.value);\n```\n\nStarting another validation aborts the previous run. Field edits preserve existing errors until the next validation replaces them. Unexpected validator failures reject as `ForgeValidationError` with the original error as `cause`.\n\n## Observe State\n\nUse form subscriptions for aggregate metadata and field subscriptions for one branch. Subscribing after disposal throws `ForgeDisposedError`.\n\n```ts\nconst errors: unknown[] = [];\nconst observedForm = createForm({\n initialValues: { email: '' },\n onSubscriberError: (error) => errors.push(error),\n});\n\nconst stopForm = observedForm.subscribe((state) => {\n console.log(state.validity, state.submitting);\n}, { immediate: true });\nconst stopField = observedForm.field('email').subscribe((state) => {\n console.log(state.value, state.error);\n}, { immediate: true });\n\nstopField();\nstopForm();\n```\n\nWithout `onSubscriberError`, Forge rethrows subscriber failures asynchronously after completing its state transition.\n\n## Testing\n\nTest the form without a DOM. Read its immutable value, invoke a method, then assert the resulting state or validation result.\n\n```ts\nimport { expect, test } from 'vitest';\nimport { createForm } from '@vielzeug/forge';\n\ntest('requires an email address', async () => {\n const form = createForm({\n initialValues: { email: '' },\n validate: (value) => ({ fields: { email: value.email.includes('@') ? undefined : 'Invalid email' } }),\n });\n\n await expect(form.validate()).resolves.toEqual({\n errors: { email: 'Invalid email' },\n formError: undefined,\n status: 'invalid',\n });\n});\n```\n\n## Framework Integration\n\nUse `form.value` and subscriptions with any renderer. Bind one DOM input through `/dom`; validation scheduling remains application policy.\n\n::: code-group\n\n```ts [React]\nimport { useEffect, useState } from 'react';\nimport { createForm } from '@vielzeug/forge';\n\nconst form = createForm({ initialValues: { email: '' } });\n\nexport function EmailForm() {\n const [, rerender] = useState(0);\n\n useEffect(() => {\n const stop = form.subscribe(() => rerender((revision) => revision + 1));\n\n return () => stop();\n }, []);\n\n return <input value={form.field('email').value} onChange={(event) => form.field('email').set(event.target.value)} />;\n}\n```\n\n```ts [Vue 3]\nimport { onUnmounted, ref } from 'vue';\nimport { createForm } from '@vielzeug/forge';\n\nconst form = createForm({ initialValues: { email: '' } });\nconst revision = ref(0);\nconst stop = form.subscribe(() => revision.value++);\n\nonUnmounted(stop);\n```\n\n```ts [Svelte]\n<script lang=\"ts\">\n import { onDestroy } from 'svelte';\n import { createForm } from '@vielzeug/forge';\n\n const form = createForm({ initialValues: { email: '' } });\n let revision = 0;\n const stop = form.subscribe(() => revision++);\n\n onDestroy(stop);\n</script>\n\n<input value={form.field('email').value} on:input={(event) => form.field('email').set(event.currentTarget.value)} />\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\nUse Spell when one schema owns validation and Vault when an explicit record codec owns persistence.\n\n```ts\nimport { createForm } from '@vielzeug/forge';\nimport { customValidator } from '@vielzeug/forge/spell';\nimport { s } from '@vielzeug/spell';\n\nconst Profile = s.object({ email: s.string().email() });\nconst form = createForm({ initialValues: { email: '' }, validate: customValidator(Profile) });\n```\n\n`customValidator()` preserves unrelated Spell errors, maps each union to its closest branch, and maps array-item failures to per-item array fields. Parse again at the submit boundary when a Spell transform must produce the outgoing payload.\n\n```ts\nimport { loadForm, saveForm } from '@vielzeug/forge/vault';\n\nawait saveForm(form, db, 'drafts', codec);\nconst restored = await loadForm(form, db, 'drafts', 'profile', codec);\nconsole.log(restored);\n```\n\n`loadForm()` uses `form.reset()`, so a restored value is clean. Store a selected `File`, not `FileList`, in form state; `FileList` is transport-only for `toFormData()`.\n\n## Best Practices\n\n- Keep form values to primitives, plain objects, arrays, `Date`, `File`, and `Blob`.\n- Update array fields through immutable replacement functions.\n- Validate complete values instead of rebuilding field-validator graphs.\n- Handle `aborted` validation results before rendering errors.\n- Preserve errors through field edits until a deliberate validation refresh.\n- Return subscription cleanup from framework lifecycle hooks.\n- Provide `onSubscriberError` when application subscribers can throw.\n- Decode Vault records before passing them to `loadForm()`.\n",
|
|
7
7
|
"examples": "---\ntitle: Forge — Examples\ndescription: Practical immutable form recipes.\n---\n\n## Examples\n\n- [Login form](./examples/login-form.md)\n- [Conditional values](./examples/form-with-conditional-fields.md)\n- [Dynamic arrays](./examples/dynamic-form-fields.md)\n- [Contact form with file upload](./examples/contact-form-with-file-upload.md)\n- [Registration form](./examples/registration-form.md)\n- [Multi-step wizard](./examples/multi-step-wizard.md)\n- [Search form with debounce](./examples/search-form-with-debounce.md)\n"
|
|
8
8
|
},
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"index": "---\ntitle: Gesture — Pointer pan primitives\ndescription: Framework-neutral one-axis pointer pan recognition with lifecycle-owned handles.\npackage: gesture\ncategory: input\nkeywords: [pointer, pan, swipe, gesture, touch, drag]\nexports: [createPanGesture]\nrelated: [refine, dnd, keymap]\nenvironments: [browser]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"gesture\" />\n\n## Why Gesture?\n\nPointer-driven interfaces need reliable movement tracking without coupling input recognition to rendering or product-specific thresholds.\n\n```ts\n// Before\nelement.addEventListener('pointermove', (event) => {\n // Coordinate tracking, pointer identity, direction locking, and cleanup\n});\n\n// After\nconst pan = createPanGesture(element, {\n axis: 'x',\n onMove: ({ distance }) => render(distance),\n onEnd: ({ distance, reason }) => finish(distance, reason),\n});\n```\n\n| Feature | Ad-hoc pointer handling | Gesture |\n| --- | --- | --- |\n| Bundle size | n/a | <PackageInfo package=\"gesture\" type=\"size\" /> |\n| Zero dependencies | n/a | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Axis intent recognition | Manual | Built in |\n| Pointer ownership | Manual | Tracked across the document |\n| Lifecycle cleanup | Manual | `dispose()` + `disposalSignal` |\n\n<div class=\"decision-callout\">\n\n**Use Gesture when** several UI surfaces need consistent one-axis pointer tracking while retaining their own completion rules.\n\n**Consider direct pointer handling when** the interaction is isolated and does not need reusable lifecycle or direction-lock behavior.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/gesture\n```\n\n```sh [npm]\nnpm install @vielzeug/gesture\n```\n\n```sh [yarn]\nyarn add @vielzeug/gesture\n```\n\n:::\n\n## Quick Start\n\n```ts\nimport { createPanGesture } from '@vielzeug/gesture';\n\nconst pan = createPanGesture(element, {\n axis: 'x',\n onMove: ({ distance }) => {\n element.style.transform = `translateX(${distance}px)`;\n },\n onEnd: ({ distance, reason }) => {\n element.style.transform = '';\n\n if (reason === 'release' && Math.abs(distance) >= 48) {\n dismiss();\n }\n },\n});\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `createPanGesture()` — one-axis pointer movement tracking\n- Direction locking — activates only when movement favors the configured axis\n- Configurable pointer capture — own the pointer by default or preserve native targeting\n- Consumer-owned policy — thresholds, snapping, and outcomes stay in application code\n- Stable completion — one `onEnd` callback for release and cancellation\n- Lifecycle ownership — `dispose()`, `disposed`, and `disposalSignal`\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- [Refine](/refine/) — components that use pan recognition for carousel, drawer, toast, and list interactions.\n- [Dnd](/dnd/) — drag-and-drop behavior with drop targets and reordering.\n- [Keymap](/keymap/) — keyboard interaction primitives for complementary input paths.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
|
|
5
5
|
"api": "---\ntitle: Gesture — API Reference\ndescription: API reference for @vielzeug/gesture pointer pan recognition.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createPanGesture()` | Track one-axis pointer movement on an element | Sync | `onStart` runs after direction intent is recognized |\n| `PanGesture` | Lifecycle-owned pan handle | Sync | `dispose()` does not emit `onEnd` |\n| `PanGestureOptions` | Configure axis, admission, capture, and callbacks | Sync | Completion thresholds belong in `onEnd` |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/gesture` | Pan recognizer and related types. |\n\n## Core Functions\n\n### `createPanGesture()`\n\n```ts\nfunction createPanGesture(target: Element, options?: PanGestureOptions): PanGesture;\n```\n\nAttaches a one-axis pointer pan recognizer to `target`.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `target` | `Element` | Element that owns the pointer interaction. |\n| `options` | `PanGestureOptions` | Axis, disabled state, admission guard, capture policy, and lifecycle callbacks. |\n\n**Returns:** A `PanGesture` handle.\n\n**Example**\n\n```ts\nimport { createPanGesture } from '@vielzeug/gesture';\n\nconst pan = createPanGesture(element, {\n axis: 'x',\n onEnd: ({ distance, reason }) => {\n if (reason === 'release' && Math.abs(distance) >= 48) dismiss();\n },\n});\n```\n\n| Member | Return | Contract |\n| --- | --- | --- |\n| `active` | `boolean` | `true` after direction intent is accepted and before the interaction ends. |\n| `cancel()` | `boolean` | Cancels the pending or active pointer interaction. Active pans emit `onEnd` with `reason: 'cancel'`. |\n| `dispose()` | `void` | Detaches listeners, releases pointer ownership, and aborts `disposalSignal`. Idempotent. |\n| `disposed` | `boolean` | `true` after the first `dispose()`. |\n| `disposalSignal` | `AbortSignal` | Aborts when the handle is disposed. |\n| `[Symbol.dispose]()` | `void` | Calls `dispose()`. |\n\n## Types\n\n```ts\ntype PanAxis = 'x' | 'y';\ntype PanEndReason = 'cancel' | 'release';\n\ntype PanGestureDetail = {\n axis: PanAxis;\n current: number;\n distance: number;\n event: PointerEvent;\n pointerId: number;\n pointerType: string;\n start: number;\n target: Element;\n};\n\ntype PanGestureEndDetail = PanGestureDetail & {\n reason: PanEndReason;\n};\n\ntype PanGestureOptions = {\n axis?: PanAxis | (() => PanAxis);\n disabled?: boolean | (() => boolean | undefined);\n pointerCapture?: boolean;\n onEnd?: (detail: PanGestureEndDetail) => void;\n onMove?: (detail: PanGestureDetail) => void;\n onStart?: (detail: PanGestureDetail) => void;\n shouldStart?: (event: PointerEvent) => boolean;\n};\n\ntype PanGesture = {\n readonly active: boolean;\n [Symbol.dispose](): void;\n cancel(): boolean;\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n readonly disposed: boolean;\n};\n```\n\n| Option | Type | Default | Contract |\n| --- | --- | --- | --- |\n| `axis` | `PanAxis \\| (() => PanAxis)` | `'x'` | Axis resolved when each pointer interaction starts |\n| `disabled` | `boolean \\| (() => boolean \\| undefined)` | `false` | Blocks new pans and cancels an active pan on the next pointer event |\n| `pointerCapture` | `boolean` | `true` | Captures the pointer on `target` after axis intent is accepted |\n| `shouldStart` | `(event: PointerEvent) => boolean` | — | Rejects a primary pointer start before tracking begins |\n| `onStart` | `(detail: PanGestureDetail) => void` | — | Runs once when axis intent is accepted |\n| `onMove` | `(detail: PanGestureDetail) => void` | — | Runs for the activating move and later moves |\n| `onEnd` | `(detail: PanGestureEndDetail) => void` | — | Runs for active release or cancellation |\n\nGesture tracks an accepted pan with capture-phase listeners on `target.ownerDocument` regardless of the pointer-capture setting. Set `pointerCapture: false` when nested or newly revealed controls must retain native pointer-up and click targeting.\n\n## Errors\n\n`@vielzeug/gesture` does not export custom error classes.\n",
|
|
6
6
|
"usage": "---\ntitle: Gesture — Usage Guide\ndescription: Track one-axis pointer movement and apply application-specific completion rules.\n---\n\n[[toc]]\n\n## Basic Usage\n\nCreate one pan handle for the element that owns the interaction.\n\n```ts\nimport { createPanGesture } from '@vielzeug/gesture';\n\nconst pan = createPanGesture(row, {\n axis: 'x',\n onMove: ({ distance }) => {\n row.style.transform = `translateX(${distance}px)`;\n },\n onEnd: ({ distance, reason }) => {\n row.style.transform = '';\n\n if (reason === 'release' && Math.abs(distance) >= 64) archive();\n },\n});\n```\n\n## Completion Rules\n\nGesture reports movement and terminal state but does not decide what constitutes a swipe. Apply thresholds and allowed directions in `onEnd`.\n\n```ts\nconst pan = createPanGesture(panel, {\n axis: 'x',\n onEnd: ({ distance, reason }) => {\n if (reason === 'release' && distance <= -80) {\n openNext();\n } else {\n resetPanel();\n }\n },\n});\n```\n\n## Direction Recognition\n\nThe gesture remains pending during small movement. It activates only after movement favors the configured axis. Cross-axis movement ends the pending interaction without invoking callbacks.\n\nUse the corresponding `touch-action` value so the browser retains native scrolling on the other axis.\n\n```css\n.swipe-row {\n touch-action: pan-y;\n}\n```\n\n```ts\nconst pan = createPanGesture(row, { axis: 'x', onMove });\n```\n\n## Pointer Capture\n\nPointer capture is enabled by default. After axis intent is accepted, Gesture captures the pointer on the bound target while continuing to track movement through document-level listeners. This is the reliable default for ordinary drag surfaces.\n\nDisable capture when nested or newly revealed controls must retain native pointer-up and click targeting:\n\n```ts\nconst pan = createPanGesture(row, {\n axis: 'x',\n pointerCapture: false,\n onMove: renderReveal,\n onEnd: settleReveal,\n});\n```\n\nDocument-level tracking still keeps the pan active outside the target. Disabling capture changes event targeting, not gesture tracking.\n\n## Interactive Descendants\n\nUse `shouldStart` when buttons, links, or form controls inside the surface must not start a pan.\n\n```ts\nconst pan = createPanGesture(notification, {\n axis: 'x',\n pointerCapture: false,\n shouldStart: (event) =>\n !event\n .composedPath()\n .some((node) => node instanceof Element && node.matches('button, a, input, select, textarea')),\n onMove,\n onEnd,\n});\n```\n\n`shouldStart` protects controls under the initial pointer. `pointerCapture: false` additionally protects controls that appear beneath the pointer during a reveal interaction.\n\n## Disabled State\n\nA boolean disables the recognizer permanently. A getter supports state that changes while the handle is alive.\n\n```ts\nconst pan = createPanGesture(row, {\n disabled: () => isLocked,\n onEnd: ({ reason }) => {\n if (reason === 'cancel') resetRow();\n },\n});\n```\n\nWhen the getter becomes `true`, the next pointer event cancels an active pan.\n\n## Lifecycle\n\nDispose the target-bound handle when its owning UI scope unmounts.\n\n```ts\nconst pan = createPanGesture(element, { onEnd, onMove });\n\nonCleanup(() => pan.dispose());\n```\n\nUse `cancel()` to stop a pending or active interaction without disposing the handle. An active interaction emits `onEnd` with `reason: 'cancel'`.\n\n## Framework Integration\n\nCreate the handle after the target element exists and dispose it on unmount.\n\n::: code-group\n\n```tsx [React]\nimport { useEffect, useRef } from 'react';\nimport { createPanGesture } from '@vielzeug/gesture';\n\nfunction SwipeRow({ onDismiss }: { onDismiss: () => void }) {\n const rowRef = useRef<HTMLDivElement>(null);\n\n useEffect(() => {\n const row = rowRef.current;\n if (!row) return;\n\n const pan = createPanGesture(row, {\n axis: 'x',\n onMove: ({ distance }) => {\n row.style.transform = `translateX(${distance}px)`;\n },\n onEnd: ({ distance, reason }) => {\n row.style.transform = '';\n if (reason === 'release' && Math.abs(distance) >= 64) onDismiss();\n },\n });\n\n return () => pan.dispose();\n }, [onDismiss]);\n\n return <div ref={rowRef}>Swipe me</div>;\n}\n```\n\n```vue [Vue 3]\n<script setup lang=\"ts\">\nimport { onMounted, onUnmounted, ref } from 'vue';\nimport { createPanGesture, type PanGesture } from '@vielzeug/gesture';\n\nconst emit = defineEmits<{ dismiss: [] }>();\nconst rowEl = ref<HTMLDivElement | null>(null);\nlet pan: PanGesture | undefined;\n\nonMounted(() => {\n const row = rowEl.value;\n if (!row) return;\n\n pan = createPanGesture(row, {\n axis: 'x',\n onEnd: ({ distance, reason }) => {\n if (reason === 'release' && Math.abs(distance) >= 64) emit('dismiss');\n },\n });\n});\n\nonUnmounted(() => pan?.dispose());\n</script>\n\n<template>\n <div ref=\"rowEl\">Swipe me</div>\n</template>\n```\n\n```svelte [Svelte]\n<script lang=\"ts\">\n import { onMount } from 'svelte';\n import { createPanGesture } from '@vielzeug/gesture';\n\n let { ondismiss = () => {} }: { ondismiss: () => void } = $props();\n let rowEl: HTMLDivElement;\n\n onMount(() => {\n const pan = createPanGesture(rowEl, {\n axis: 'x',\n onEnd: ({ distance, reason }) => {\n if (reason === 'release' && Math.abs(distance) >= 64) ondismiss();\n },\n });\n\n return () => pan.dispose();\n });\n</script>\n\n<div bind:this={rowEl}>Swipe me</div>\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\n### Gesture + Refine\n\nRefine uses Gesture internally for carousel, drawer, toast, and list-item pointer interactions. Custom surfaces can use the same pan lifecycle while keeping visual state local.\n\n```ts\nimport { createPanGesture } from '@vielzeug/gesture';\n\nconst pan = createPanGesture(panel, {\n axis: 'x',\n onMove: ({ distance }) => {\n panel.style.transform = `translateX(${distance}px)`;\n },\n onEnd: ({ distance, reason }) => {\n panel.style.transform = '';\n if (reason === 'release' && Math.abs(distance) >= 80) revealActions();\n },\n});\n```\n\n### Gesture + Dnd\n\nGesture tracks a constrained pointer pan. Dnd owns draggable items, sortable lists, and drop targets. Keep them separate.\n\n## Best Practices\n\n- **Set** `touch-action` for the axis the browser should continue scrolling.\n- **Use** `shouldStart` to exclude nested interactive controls.\n- **Disable** pointer capture when nested or newly revealed controls must keep native release targeting.\n- **Apply** thresholds and direction rules in `onEnd`.\n- **Treat** `reason: 'cancel'` as a reset path, never a commit path.\n- **Keep** `onMove` rendering lightweight.\n- **Dispose** the handle when its target leaves the UI.\n",
|
|
7
|
-
"examples": "---\ntitle: Gesture — Examples\ndescription: Worked examples for @vielzeug/gesture.\n---\n\n## Examples\n\n- [Carousel
|
|
7
|
+
"examples": "---\ntitle: Gesture — Examples\ndescription: Worked examples for @vielzeug/gesture.\n---\n\n## Examples\n\n- [Carousel Pan Navigation](./examples/carousel-swipe-navigation.md)\n- [Swipe-to-Dismiss Notifications](./examples/swipe-dismiss-notifications.md)\n"
|
|
8
8
|
},
|
|
9
9
|
"examples": [
|
|
10
10
|
{
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"apiSource": "export { combineSignals, createBus } from './bus';\nexport { BusDisposedError, HeraldConfigError, HeraldError } from './errors';\nexport { pipeEvents } from './pipe';\nexport type {\n Bus,\n
|
|
2
|
+
"apiSource": "export { combineSignals, createBus } from './bus';\nexport { BusDisposedError, HeraldConfigError, HeraldError } from './errors';\nexport { pipeEvents } from './pipe';\nexport type {\n Bus,\n BusOptions,\n EmissionErrorContext,\n EventKey,\n EventMap,\n EventStream,\n HeraldEvent,\n Listener,\n Middleware,\n PipeableKey,\n PipeEntry,\n SubscribeOptions,\n Unsubscribe,\n WaitAnyResult,\n} from './types';\n",
|
|
3
3
|
"docs": {
|
|
4
|
-
"index": "---\ntitle: Herald — Typed event bus for TypeScript\ndescription: Typed temporal event delivery with sync subscriptions, async waiting, streams, pipes, and AbortSignal lifecycle.\npackage: herald\ncategory: events\nkeywords: [event-bus, typed-events, pub-sub, async-streams, abort-signal]\nrelated: [ripple, wayfinder, familiar]\nexports: [createBus, pipeEvents, combineSignals, HeraldError, BusDisposedError, HeraldConfigError]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"herald\" />\n\n## Why Herald?\n\nRaw event emitters lose payload inference and leave waiting, streaming, cancellation, and teardown to every caller. Herald keeps events temporal: use [Ripple](/ripple/) when you need retained state.\n\n```ts\n// Before\nconst listeners = new Set<(payload: unknown) => void>();\nlisteners.add((payload) => loadProfile((payload as { id: string }).id));\n\n// After\nimport { createBus } from '@vielzeug/herald';\n\ninterface AppEvents {\n 'user:login': { id: string };\n}\n\nfunction loadProfile(id: string): void {\n console.log(id);\n}\n\nconst bus = createBus<AppEvents>();\nbus.on('user:login', ({ id }) => loadProfile(id));\n```\n\n| Feature | Herald | mitt | EventEmitter3 |\n| --- | --- | --- | --- |\n| Bundle size | <PackageInfo package=\"herald\" type=\"size\" /> | ~200 B | ~1.5 kB |\n| Typed payloads | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"triangle-alert\" size=\"16\"></ore-icon> | <ore-icon name=\"triangle-alert\" size=\"16\"></ore-icon> |\n| Async wait and streams | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| AbortSignal lifecycle | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Typed event pipes | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Zero dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n\n<div class=\"decision-callout\">\n\n**Use Herald when** modules need typed temporal event delivery with owned lifecycle.\n\n**Consider Ripple when** consumers need current state and replayed values.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/herald\n```\n\n```sh [npm]\nnpm install @vielzeug/herald\n```\n\n```sh [yarn]\nyarn add @vielzeug/herald\n```\n\n:::\n\n## Quick Start\n\n```ts\nimport { createBus } from '@vielzeug/herald';\n\ninterface AppEvents {\n 'user:login': { id: string };\n 'user:logout': void;\n}\n\nconst bus = createBus<AppEvents>();\nconst stop = bus.on('user:login', ({ id }) => console.log(id));\n\nbus.emit('user:login', { id: '42' });\nstop();\nbus.dispose();\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `on()` / `once()` — typed subscriptions with explicit teardown\n- `onAny()` — cross-cutting event observation\n- `wait()` / `waitAny()` — one-shot async coordination\n- `events()` — bounded async event streams\n- `pipeEvents()` — compatible cross-bus forwarding\n- `AbortSignal` — cancellation and disposal ownership\n- `createTestBus()` — emitted-payload recording for tests\n
|
|
5
|
-
"api": "---\ntitle: Herald — API Reference\ndescription: Reference for typed temporal event delivery, lifecycle ownership, and compatible event piping.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createBus()` | Create typed temporal event bus | Sync | `emit()` and middleware are synchronous |\n| `pipeEvents()` | Forward compatible source events | Sync | Payloads must be assignable to target event |\n| `combineSignals()` | Abort when any input aborts | Sync | Public composition has no manual teardown |\n| `createTestBus()` | Record dispatched test events | Sync | Available from `/testing` only |\n
|
|
6
|
-
"usage": "---\ntitle: Herald — Usage Guide\ndescription: Typed event maps, lifecycle-owned subscriptions, waits, streams, pipes, and testing.\n---\n\n[[toc]]\n\n## Basic Usage\n\nUse interface or type alias event maps. Events model facts that happened; use Ripple for current state.\n\n```ts\nimport { createBus } from '@vielzeug/herald';\n\ninterface AppEvents {\n 'cart:updated': { count: number };\n 'user:logout': void;\n}\n\nconst bus = createBus<AppEvents>();\nconst stop = bus.on('cart:updated', ({ count }) => console.log(count));\n\nbus.emit('cart:updated', { count: 1 });\nstop();\nbus.dispose();\n```\n\n## Subscriptions\n\nUse `once()` for one event and `{ signal }` for owned subscription lifetime.\n\n```ts\nconst controller = new AbortController();\n\nbus.on('cart:updated', renderCart, { signal: controller.signal });\nbus.once('user:logout', clearSession);\ncontroller.abort();\n```\n\n## Middleware and Validation\n\nMiddleware is synchronous. Call `next()` once to continue; omit it to block dispatch.\n\n```ts\nconst bus = createBus<AppEvents>({\n middleware: [\n (event, payload, next) => {\n audit(event, payload);\n next();\n },\n ],\n validatePayload: (event, payload) => {\n if (event === 'cart:updated' && payload.count < 0) throw new RangeError('count must be non-negative');\n },\n});\n```\n\n## Awaiting Events\n\n```ts\nconst cart = await bus.wait('cart:updated', { signal: AbortSignal.timeout(5_000) });\nconst winner = await bus.waitAny(['cart:updated', 'user:logout'], { signal: AbortSignal.timeout(5_000) });\n```\n\n## Streaming Events\n\n`events()` subscribes eagerly. Bound buffers for producers faster than consumers.\n\n```ts\nawait using stream = bus.events('cart:updated', { maxBuffer: 100 });\n\nfor await (const cart of stream) {\n renderCart(cart);\n}\n```\n\n## Piping Events\n\n`pipeEvents()` only accepts compatible payloads. Stop explicitly or tie pipe to signal.\n\n```ts\nconst stopPipe = pipeEvents(sourceBus, auditBus, ['cart:updated'], { signal: pageSignal });\nstopPipe();\n```\n\n## Testing\n\n`createTestBus()` records dispatched payloads without mocks.\n\n```ts\nimport { createTestBus } from '@vielzeug/herald/testing';\n\nconst bus = createTestBus<AppEvents>();\nbus.emit('cart:updated', { count: 2 });\nexpect(bus.emitted('cart:updated')).toEqual([{ count: 2 }]);\nbus.dispose();\n```\n\n## Debugging\n\n```ts\nimport {
|
|
4
|
+
"index": "---\ntitle: Herald — Typed event bus for TypeScript\ndescription: Typed temporal event delivery with sync subscriptions, async waiting, streams, pipes, and AbortSignal lifecycle.\npackage: herald\ncategory: events\nkeywords: [event-bus, typed-events, pub-sub, async-streams, abort-signal]\nrelated: [ripple, wayfinder, familiar]\nexports: [createBus, pipeEvents, combineSignals, HeraldError, BusDisposedError, HeraldConfigError]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"herald\" />\n\n## Why Herald?\n\nRaw event emitters lose payload inference and leave waiting, streaming, cancellation, and teardown to every caller. Herald keeps events temporal: use [Ripple](/ripple/) when you need retained state.\n\n```ts\n// Before\nconst listeners = new Set<(payload: unknown) => void>();\nlisteners.add((payload) => loadProfile((payload as { id: string }).id));\n\n// After\nimport { createBus } from '@vielzeug/herald';\n\ninterface AppEvents {\n 'user:login': { id: string };\n}\n\nfunction loadProfile(id: string): void {\n console.log(id);\n}\n\nconst bus = createBus<AppEvents>();\nbus.on('user:login', ({ id }) => loadProfile(id));\n```\n\n| Feature | Herald | mitt | EventEmitter3 |\n| --- | --- | --- | --- |\n| Bundle size | <PackageInfo package=\"herald\" type=\"size\" /> | ~200 B | ~1.5 kB |\n| Typed payloads | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"triangle-alert\" size=\"16\"></ore-icon> | <ore-icon name=\"triangle-alert\" size=\"16\"></ore-icon> |\n| Async wait and streams | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| AbortSignal lifecycle | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Typed event pipes | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"x\" size=\"16\"></ore-icon> |\n| Zero dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n\n<div class=\"decision-callout\">\n\n**Use Herald when** modules need typed temporal event delivery with owned lifecycle.\n\n**Consider Ripple when** consumers need current state and replayed values.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/herald\n```\n\n```sh [npm]\nnpm install @vielzeug/herald\n```\n\n```sh [yarn]\nyarn add @vielzeug/herald\n```\n\n:::\n\n## Quick Start\n\n```ts\nimport { createBus } from '@vielzeug/herald';\n\ninterface AppEvents {\n 'user:login': { id: string };\n 'user:logout': void;\n}\n\nconst bus = createBus<AppEvents>();\nconst stop = bus.on('user:login', ({ id }) => console.log(id));\n\nbus.emit('user:login', { id: '42' });\nstop();\nbus.dispose();\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `on()` / `once()` — typed subscriptions with explicit teardown\n- `onAny()` — cross-cutting event observation\n- `tap()` — observe bus activity for logging and diagnostics\n- `wait()` / `waitAny()` — one-shot async coordination\n- `events()` — bounded async event streams\n- `pipeEvents()` — compatible cross-bus forwarding\n- `AbortSignal` — cancellation and disposal ownership\n- `createTestBus()` — emitted-payload recording for tests\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- [Ripple](/ripple/) — retained reactive state.\n- [Wayfinder](/wayfinder/) — route lifecycle events.\n- [Familiar](/familiar/) — worker completion events.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
|
|
5
|
+
"api": "---\ntitle: Herald — API Reference\ndescription: Reference for typed temporal event delivery, lifecycle ownership, and compatible event piping.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createBus()` | Create typed temporal event bus | Sync | `emit()` and middleware are synchronous |\n| `pipeEvents()` | Forward compatible source events | Sync | Payloads must be assignable to target event |\n| `combineSignals()` | Abort when any input aborts | Sync | Public composition has no manual teardown |\n| `createTestBus()` | Record dispatched test events | Sync | Available from `/testing` only |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/herald` | Runtime bus, pipes, public types, and errors |\n| `@vielzeug/herald/testing` | `createTestBus()` and `TestBus` |\n\n## Core Functions\n\n### `createBus()`\n\n```ts\nfunction createBus<T extends EventMap = Record<string, unknown>>(\n options?: BusOptions<T>,\n): Bus<T>;\n```\n\nCreates a synchronous bus for future event delivery.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `options` | `BusOptions<T>` | Optional middleware, validation, error handling, and listener threshold configuration. |\n\n**Returns:** `Bus<T>`.\n\n```ts\nimport { createBus } from '@vielzeug/herald';\n\ninterface Events {\n count: number;\n ready: void;\n}\n\nconst bus = createBus<Events>();\nbus.emit('count', 1);\nbus.emit('ready');\nbus.dispose();\n```\n\n---\n\n### `pipeEvents()`\n\n```ts\nfunction pipeEvents<S extends EventMap, T extends EventMap>(\n source: Bus<S>,\n target: Bus<T>,\n entries: readonly [NoInfer<PipeEntry<S, T>>, ...NoInfer<PipeEntry<S, T>>[]],\n opts?: { signal?: AbortSignal },\n): Unsubscribe;\n```\n\nForwards listed compatible events until manually stopped, either bus disposes, or `options.signal` aborts.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `source` | `Bus<S>` | Bus that emits source events. |\n| `target` | `Bus<T>` | Bus that receives compatible events. |\n| `entries` | non-empty `PipeEntry` tuple | Same-name keys or compatible `{ from, to }` mappings. |\n| `opts.signal` | `AbortSignal` | Optional pipe lifetime signal. |\n\n**Returns:** Idempotent `Unsubscribe` function.\n\n```ts\nimport { createBus, pipeEvents } from '@vielzeug/herald';\n\ninterface SourceEvents {\n 'auth:login': { id: string };\n}\n\ninterface TargetEvents {\n 'user:authenticated': { id: string };\n}\n\nconst source = createBus<SourceEvents>();\nconst target = createBus<TargetEvents>();\nconst stop = pipeEvents(source, target, [{ from: 'auth:login', to: 'user:authenticated' }]);\n\nstop();\nsource.dispose();\ntarget.dispose();\n```\n\n---\n\n### `combineSignals()`\n\n```ts\nfunction combineSignals(first: AbortSignal, ...rest: AbortSignal[]): AbortSignal;\n```\n\nReturns a signal aborted with first input signal's reason.\n\n**Returns:** `AbortSignal`.\n\n```ts\nimport { combineSignals } from '@vielzeug/herald';\n\nconst signal = combineSignals(AbortSignal.timeout(1_000), controller.signal);\n```\n\nInput listeners remain until an input aborts. Bus APIs that accept `{ signal }` clean their internal signal composition when their owned operation ends.\n\n## Types\n\n### `EventMap` and `EventKey`\n\n```ts\ntype EventMap = object;\ntype EventKey<T extends EventMap> = Extract<keyof T, string>;\n```\n\n`EventMap` accepts interfaces and type aliases. Only string keys are event names.\n\n---\n\n### `BusOptions`\n\n```ts\ntype BusOptions<T extends EventMap = EventMap> = {\n maxListeners?: number;\n middleware?: readonly Middleware<T>[];\n name?: string;\n onError?: (context: EmissionErrorContext<T>) => void;\n validatePayload?: <K extends EventKey<T>>(event: K, payload: T[K]) => void;\n};\n```\n\n| Field | Description |\n| --- | --- |\n| `maxListeners` | Warn when one event exceeds this active-listener count. |\n| `middleware` | Synchronous dispatch middleware. |\n| `name` | Display name in disposal errors. |\n| `onError` | Handles listener and validation errors instead of rethrowing. |\n| `validatePayload` | Runs before middleware and listeners. |\n\n---\n\n### `Bus`\n\n```ts\ntype Bus<T extends EventMap> = {\n [Symbol.dispose](): void;\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n readonly disposed: boolean;\n emit<K extends EventKey<T>>(event: K, ...args: T[K] extends void ? [] : [payload: T[K]]): number;\n eventNames(): EventKey<T>[];\n events<K extends EventKey<T>>(event: K, opts?: { maxBuffer?: number; signal?: AbortSignal }): EventStream<T[K]>;\n listenerCount(event?: EventKey<T>): number;\n on<K extends EventKey<T>>(event: K, listener: Listener<T[K]>, opts?: SubscribeOptions): Unsubscribe;\n onAny(listener: (event: EventKey<T>, payload: unknown) => void, opts?: SubscribeOptions): Unsubscribe;\n once<K extends EventKey<T>>(event: K, listener: Listener<T[K]>, opts?: { signal?: AbortSignal }): Unsubscribe;\n tap(handler: (event: HeraldEvent<T>) => void, options?: { signal?: AbortSignal }): Unsubscribe;\n wait<K extends EventKey<T>>(event: K, opts?: { signal?: AbortSignal }): Promise<T[K]>;\n waitAny<const K extends readonly [EventKey<T>, EventKey<T>, ...EventKey<T>[]]>(\n events: K,\n opts?: { signal?: AbortSignal },\n ): Promise<WaitAnyResult<T, K>>;\n wildcardCount(): number;\n};\n```\n\n`emit()` returns listener count or `0` after disposal, blocked middleware, or handled validation rejection.\n\n`tap()` receives every `emit`, `subscribe`, `unsubscribe`, `listener-error`, and `dispose` event as a `HeraldEvent`. It is the supported way to observe bus activity for logging and diagnostics. The returned `Unsubscribe` stops the tap; pass `{ signal }` to bind its lifetime to an `AbortSignal`.\n\n```ts\nimport { createBus } from '@vielzeug/herald';\n\nconst bus = createBus<AppEvents>();\nconst stop = bus.tap((event) => console.debug(`herald:${event.type}`, event));\n```\n\n---\n\n### `Listener`, `SubscribeOptions`, and `Unsubscribe`\n\n```ts\ntype Listener<T> = (payload: T) => void;\ntype SubscribeOptions = { once?: boolean; signal?: AbortSignal };\ntype Unsubscribe = () => void;\n```\n\n---\n\n### `HeraldEvent`\n\n```ts\ntype HeraldEvent<T extends EventMap = EventMap> =\n | { type: 'emit'; event: EventKey<T>; payload: unknown; timestamp: number }\n | { type: 'subscribe'; event: EventKey<T>; timestamp: number }\n | { type: 'unsubscribe'; event: EventKey<T>; timestamp: number }\n | { type: 'listener-error'; event: EventKey<T>; err: unknown; timestamp: number }\n | { type: 'dispose'; timestamp: number };\n```\n\nDiscriminated union delivered to `tap()` handlers. Narrow on `event.type` to access type-specific fields.\n\n---\n\n### `EmissionErrorContext` and `Middleware`\n\n```ts\ntype EmissionErrorContext<T extends EventMap = EventMap> = {\n err: unknown;\n event: EventKey<T>;\n payload: unknown;\n timestamp: number;\n};\n\ntype Middleware<T extends EventMap = EventMap> = (\n event: EventKey<T>,\n payload: unknown,\n next: () => void,\n) => void;\n```\n\nCall middleware `next()` synchronously at most once. Omit it to block dispatch.\n\n---\n\n### `EventStream` and `WaitAnyResult`\n\n```ts\ntype EventStream<T> = AsyncGenerator<T> & AsyncDisposable;\n\ntype WaitAnyResult<T extends EventMap, K extends readonly EventKey<T>[]> = {\n [I in keyof K]: K[I] extends EventKey<T> ? { event: K[I]; payload: T[K[I]] } : never;\n}[number];\n```\n\n---\n\n### `PipeableKey`, `RenamedPipeEntry`, and `PipeEntry`\n\n```ts\ntype PipeableKey<S extends EventMap, T extends EventMap> = {\n [K in EventKey<S> & EventKey<T>]: S[K] extends T[K] ? K : never;\n}[EventKey<S> & EventKey<T>];\n\ntype RenamedPipeEntry<S extends EventMap, T extends EventMap> = {\n [From in EventKey<S>]: {\n [To in EventKey<T>]: S[From] extends T[To] ? { from: From; to: To } : never;\n }[EventKey<T>];\n}[EventKey<S>];\n\ntype PipeEntry<S extends EventMap, T extends EventMap> =\n | PipeableKey<S, T>\n | RenamedPipeEntry<S, T>;\n```\n\n## Testing\n\n### `createTestBus()`\n\n```ts\nfunction createTestBus<T extends EventMap = Record<string, unknown>>(\n options?: BusOptions<T>,\n): TestBus<T>;\n```\n\nCreates a bus that records dispatched payloads.\n\n**Returns:** `TestBus<T>`.\n\n### `TestBus`\n\n```ts\ntype TestBus<T extends EventMap> = Bus<T> & {\n allEmitted(): { [K in EventKey<T>]?: T[K][] };\n emitted<K extends EventKey<T>>(event: K): T[K][];\n emittedCount<K extends EventKey<T>>(event: K): number;\n reset(): void;\n};\n```\n\n## Errors\n\n| Error | Trigger | Notable properties |\n| --- | --- | --- |\n| `BusDisposedError` | `wait()` or `waitAny()` interrupted by disposal | Bus name appears when configured. |\n| `HeraldConfigError` | Invalid stream buffer, empty pipe entries, or fewer than two `waitAny()` events | — |\n| `HeraldError` | Base class for Herald-originated errors | `instanceof HeraldError` narrows subclasses. |\n",
|
|
6
|
+
"usage": "---\ntitle: Herald — Usage Guide\ndescription: Typed event maps, lifecycle-owned subscriptions, waits, streams, pipes, and testing.\n---\n\n[[toc]]\n\n## Basic Usage\n\nUse interface or type alias event maps. Events model facts that happened; use Ripple for current state.\n\n```ts\nimport { createBus } from '@vielzeug/herald';\n\ninterface AppEvents {\n 'cart:updated': { count: number };\n 'user:logout': void;\n}\n\nconst bus = createBus<AppEvents>();\nconst stop = bus.on('cart:updated', ({ count }) => console.log(count));\n\nbus.emit('cart:updated', { count: 1 });\nstop();\nbus.dispose();\n```\n\n## Subscriptions\n\nUse `once()` for one event and `{ signal }` for owned subscription lifetime.\n\n```ts\nconst controller = new AbortController();\n\nbus.on('cart:updated', renderCart, { signal: controller.signal });\nbus.once('user:logout', clearSession);\ncontroller.abort();\n```\n\n## Middleware and Validation\n\nMiddleware is synchronous. Call `next()` once to continue; omit it to block dispatch.\n\n```ts\nconst bus = createBus<AppEvents>({\n middleware: [\n (event, payload, next) => {\n audit(event, payload);\n next();\n },\n ],\n validatePayload: (event, payload) => {\n if (event === 'cart:updated' && payload.count < 0) throw new RangeError('count must be non-negative');\n },\n});\n```\n\n## Awaiting Events\n\n```ts\nconst cart = await bus.wait('cart:updated', { signal: AbortSignal.timeout(5_000) });\nconst winner = await bus.waitAny(['cart:updated', 'user:logout'], { signal: AbortSignal.timeout(5_000) });\n```\n\n## Streaming Events\n\n`events()` subscribes eagerly. Bound buffers for producers faster than consumers.\n\n```ts\nawait using stream = bus.events('cart:updated', { maxBuffer: 100 });\n\nfor await (const cart of stream) {\n renderCart(cart);\n}\n```\n\n## Piping Events\n\n`pipeEvents()` only accepts compatible payloads. Stop explicitly or tie pipe to signal.\n\n```ts\nconst stopPipe = pipeEvents(sourceBus, auditBus, ['cart:updated'], { signal: pageSignal });\nstopPipe();\n```\n\n## Testing\n\n`createTestBus()` records dispatched payloads without mocks.\n\n```ts\nimport { createTestBus } from '@vielzeug/herald/testing';\n\nconst bus = createTestBus<AppEvents>();\nbus.emit('cart:updated', { count: 2 });\nexpect(bus.emitted('cart:updated')).toEqual([{ count: 2 }]);\nbus.dispose();\n```\n\n## Debugging\n\n`tap()` observes every bus activity as a `HeraldEvent` — use it for logging and diagnostics.\n\n```ts\nimport { createBus } from '@vielzeug/herald';\n\nconst bus = createBus<AppEvents>();\nbus.tap((event) => console.debug(`herald:${event.type}`, event));\n```\n\nIntegrate with the Rune logger:\n\n```ts\nimport { createLogger } from '@vielzeug/rune';\n\nconst log = createLogger({ name: 'herald' });\nbus.tap((event) => log.debug(event, `herald:${event.type}`));\n```\n\n## Working with Other Vielzeug Libraries\n\nUse Herald for temporal events. Use Ripple for retained reactive state. Use Familiar or Courier completion handlers to emit application events.\n\n## Best Practices\n\n- Define one explicit event map per boundary.\n- Emit facts, not mutable application state.\n- Keep middleware synchronous and call `next()` once.\n- Pass AbortSignals for component/request scoped work.\n- Set `maxBuffer` for long-lived streams.\n- Use `wait()` only for one-off coordination.\n- Use unsubscribe handles instead of global listener removal.\n- Dispose owner-scoped buses.\n",
|
|
7
7
|
"examples": "---\ntitle: Herald — Examples\ndescription: Practical examples and recipes for herald.\n---\n\n## Examples\n\n- [Standalone Entry](./examples/standalone-entry.md)\n- [Module Level Bus](./examples/module-level-bus.md)\n- [Awaiting A One Time Event](./examples/awaiting-a-one-time-event.md)\n- [Inspecting Listener Counts](./examples/inspecting-listener-counts.md)\n- [Custom Error Boundary](./examples/custom-error-boundary.md)\n- [Handling Disposal In Async Code](./examples/handling-disposal-in-async-code.md)\n- [Request Scoping](./examples/request-scoping.md)\n- [Streaming With Events](./examples/streaming-with-events.md)\n- [Bus Bridging With pipeEvents](./examples/bus-bridging-with-pipeevents.md)\n- [Testing With Createtestbus](./examples/testing-with-createtestbus.md)\n"
|
|
8
8
|
},
|
|
9
9
|
"examples": [
|
|
@@ -90,19 +90,19 @@
|
|
|
90
90
|
"HeraldConfigError": "export { BusDisposedError, HeraldConfigError, HeraldError } from './errors';",
|
|
91
91
|
"HeraldError": "export { BusDisposedError, HeraldConfigError, HeraldError } from './errors';",
|
|
92
92
|
"pipeEvents": "export { pipeEvents } from './pipe';",
|
|
93
|
-
"Bus": "export type {\n Bus,\n
|
|
94
|
-
"
|
|
95
|
-
"
|
|
96
|
-
"
|
|
97
|
-
"
|
|
98
|
-
"
|
|
99
|
-
"
|
|
100
|
-
"Listener": "export type {\n Bus,\n
|
|
101
|
-
"Middleware": "export type {\n Bus,\n
|
|
102
|
-
"PipeableKey": "export type {\n Bus,\n
|
|
103
|
-
"PipeEntry": "export type {\n Bus,\n
|
|
104
|
-
"SubscribeOptions": "export type {\n Bus,\n
|
|
105
|
-
"Unsubscribe": "export type {\n Bus,\n
|
|
106
|
-
"WaitAnyResult": "export type {\n Bus,\n
|
|
93
|
+
"Bus": "export type {\n Bus,\n BusOptions,\n EmissionErrorContext,\n EventKey,\n EventMap,\n EventStream,\n HeraldEvent,\n Listener,\n Middleware,\n PipeableKey,\n PipeEntry,\n SubscribeOptions,\n Unsubscribe,\n WaitAnyResult,\n} from './types';",
|
|
94
|
+
"BusOptions": "export type {\n Bus,\n BusOptions,\n EmissionErrorContext,\n EventKey,\n EventMap,\n EventStream,\n HeraldEvent,\n Listener,\n Middleware,\n PipeableKey,\n PipeEntry,\n SubscribeOptions,\n Unsubscribe,\n WaitAnyResult,\n} from './types';",
|
|
95
|
+
"EmissionErrorContext": "export type {\n Bus,\n BusOptions,\n EmissionErrorContext,\n EventKey,\n EventMap,\n EventStream,\n HeraldEvent,\n Listener,\n Middleware,\n PipeableKey,\n PipeEntry,\n SubscribeOptions,\n Unsubscribe,\n WaitAnyResult,\n} from './types';",
|
|
96
|
+
"EventKey": "export type {\n Bus,\n BusOptions,\n EmissionErrorContext,\n EventKey,\n EventMap,\n EventStream,\n HeraldEvent,\n Listener,\n Middleware,\n PipeableKey,\n PipeEntry,\n SubscribeOptions,\n Unsubscribe,\n WaitAnyResult,\n} from './types';",
|
|
97
|
+
"EventMap": "export type {\n Bus,\n BusOptions,\n EmissionErrorContext,\n EventKey,\n EventMap,\n EventStream,\n HeraldEvent,\n Listener,\n Middleware,\n PipeableKey,\n PipeEntry,\n SubscribeOptions,\n Unsubscribe,\n WaitAnyResult,\n} from './types';",
|
|
98
|
+
"EventStream": "export type {\n Bus,\n BusOptions,\n EmissionErrorContext,\n EventKey,\n EventMap,\n EventStream,\n HeraldEvent,\n Listener,\n Middleware,\n PipeableKey,\n PipeEntry,\n SubscribeOptions,\n Unsubscribe,\n WaitAnyResult,\n} from './types';",
|
|
99
|
+
"HeraldEvent": "export type {\n Bus,\n BusOptions,\n EmissionErrorContext,\n EventKey,\n EventMap,\n EventStream,\n HeraldEvent,\n Listener,\n Middleware,\n PipeableKey,\n PipeEntry,\n SubscribeOptions,\n Unsubscribe,\n WaitAnyResult,\n} from './types';",
|
|
100
|
+
"Listener": "export type {\n Bus,\n BusOptions,\n EmissionErrorContext,\n EventKey,\n EventMap,\n EventStream,\n HeraldEvent,\n Listener,\n Middleware,\n PipeableKey,\n PipeEntry,\n SubscribeOptions,\n Unsubscribe,\n WaitAnyResult,\n} from './types';",
|
|
101
|
+
"Middleware": "export type {\n Bus,\n BusOptions,\n EmissionErrorContext,\n EventKey,\n EventMap,\n EventStream,\n HeraldEvent,\n Listener,\n Middleware,\n PipeableKey,\n PipeEntry,\n SubscribeOptions,\n Unsubscribe,\n WaitAnyResult,\n} from './types';",
|
|
102
|
+
"PipeableKey": "export type {\n Bus,\n BusOptions,\n EmissionErrorContext,\n EventKey,\n EventMap,\n EventStream,\n HeraldEvent,\n Listener,\n Middleware,\n PipeableKey,\n PipeEntry,\n SubscribeOptions,\n Unsubscribe,\n WaitAnyResult,\n} from './types';",
|
|
103
|
+
"PipeEntry": "export type {\n Bus,\n BusOptions,\n EmissionErrorContext,\n EventKey,\n EventMap,\n EventStream,\n HeraldEvent,\n Listener,\n Middleware,\n PipeableKey,\n PipeEntry,\n SubscribeOptions,\n Unsubscribe,\n WaitAnyResult,\n} from './types';",
|
|
104
|
+
"SubscribeOptions": "export type {\n Bus,\n BusOptions,\n EmissionErrorContext,\n EventKey,\n EventMap,\n EventStream,\n HeraldEvent,\n Listener,\n Middleware,\n PipeableKey,\n PipeEntry,\n SubscribeOptions,\n Unsubscribe,\n WaitAnyResult,\n} from './types';",
|
|
105
|
+
"Unsubscribe": "export type {\n Bus,\n BusOptions,\n EmissionErrorContext,\n EventKey,\n EventMap,\n EventStream,\n HeraldEvent,\n Listener,\n Middleware,\n PipeableKey,\n PipeEntry,\n SubscribeOptions,\n Unsubscribe,\n WaitAnyResult,\n} from './types';",
|
|
106
|
+
"WaitAnyResult": "export type {\n Bus,\n BusOptions,\n EmissionErrorContext,\n EventKey,\n EventMap,\n EventStream,\n HeraldEvent,\n Listener,\n Middleware,\n PipeableKey,\n PipeEntry,\n SubscribeOptions,\n Unsubscribe,\n WaitAnyResult,\n} from './types';"
|
|
107
107
|
}
|
|
108
108
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"apiSource": "// Core API — most users only need these\nexport type { ConflictOptions } from './conflicts';\nexport { findShortcutConflicts } from './conflicts';\nexport { KeymapError, KeymapParseError } from './errors';\nexport { formatShortcut } from './format';\nexport { createKeymap } from './keymap';\n// Power-user API — use if building custom tooling, validators, or framework integrations\nexport type { ModifierKey, Shortcut, ShortcutStep } from './parser';\nexport { canonicalizeShortcut, detectModKey, matchStep, parseShortcut, parseStep } from './parser';\nexport type {\n BindingEntry,\n BindingOptions,\n BindingValue,\n ChordStateChange,\n Handler,\n Keymap,\n KeymapOptions,\n When,\n} from './types';\n",
|
|
3
3
|
"docs": {
|
|
4
4
|
"index": "---\ntitle: Keymap — Headless keyboard shortcut manager\ndescription: Target-local keyboard shortcut manager with chords, event-aware guards, modifier aliases, and terminal disposal.\npackage: keymap\ncategory: app-infrastructure\nkeywords: [keyboard, shortcuts, hotkeys, chord, keybinding, headless, accessibility]\nexports:\n [\n canonicalizeShortcut,\n createKeymap,\n detectModKey,\n findShortcutConflicts,\n formatShortcut,\n KeymapError,\n KeymapParseError,\n matchStep,\n parseShortcut,\n parseStep,\n ]\nrelated: [herald, refine, ore]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"keymap\" />\n\n## Why Keymap?\n\nBrowser keyboard handling needs modifier normalization, chord state, context policy, and listener ownership. Keymap keeps those concerns in one headless, zero-dependency handle.\n\n```ts\n// Before\nwindow.addEventListener('keydown', (event) => {\n if ((event.ctrlKey || event.metaKey) && event.key === 's') event.preventDefault();\n});\n\n// After\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst map = createKeymap({ 'mod+s': () => console.log('save') });\nconst unmount = map.mount(document);\n\nunmount();\nmap.dispose();\n```\n\n| Feature | Raw `addEventListener` | Keymap |\n| ------------------- | -------------------------------------------- | -------------------------------------------- |\n| Bundle size | 0 B (built-in) | <PackageInfo package=\"keymap\" type=\"size\" /> |\n| Zero dependencies | <ore-icon name=\"check\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Chord sequences | <ore-icon name=\"x\" size=\"16\"></ore-icon> | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Modifier aliases | <ore-icon name=\"x\" size=\"16\"></ore-icon> | `cmd`, `win`, `option` → canonical |\n| Context guards | Manual `if` in handler | Event-aware `when(event)` predicate |\n| Chord ownership | Application-managed state | Per mounted target |\n| Disposable | Manual `removeEventListener` | Terminal `dispose()` + `[Symbol.dispose]()` |\n\n<div class=\"decision-callout\">\n\n**Use Keymap when** you need chord sequences (`g g`, `ctrl+k ctrl+s`), modifier aliases, or context-scoped hotkeys that can be cleanly mounted and unmounted.\n\n**Consider raw `addEventListener` when** you have a single, static, never-removed hotkey and don't need chords.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/keymap\n```\n\n```sh [npm]\nnpm install @vielzeug/keymap\n```\n\n```sh [yarn]\nyarn add @vielzeug/keymap\n```\n\n:::\n\n## Quick Start\n\nCreate, mount, then dispose one map owned by your UI scope.\n\n```ts\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst map = createKeymap({\n 'mod+k mod+s': () => console.log('save'),\n 'mod+shift+p': () => console.log('open palette'),\n 'g g': () => window.scrollTo({ top: 0 }),\n escape: () => console.log('close panel'),\n});\n\nconst unmount = map.mount(document);\n\nunmount();\nmap.dispose();\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `createKeymap()` — Create a keymap from a bindings record; mount to any `EventTarget`\n- Chord sequences — `\"g g\"`, `\"ctrl+k ctrl+s\"` with configurable timeout (default 1 s)\n- Modifier aliases — `cmd`/`command`/`win` → `meta`; `opt`/`option` → `alt`; `mod` → platform-aware\n- `BindingOptions` — per-binding `{ handler, when?, trigger? }` object syntax\n- `modKey` option — explicit platform override for SSR and cross-platform tests\n- `formatShortcut()` — platform-aware display (`⇧⌘P` on Mac, `Ctrl+Shift+P` elsewhere)\n- `parseShortcut()` / `parseStep()` / `matchStep()` — exposed for building custom matchers or testing\n- `canonicalizeShortcut()` — convert any shortcut alias to a stable key for conflict detection\n- `detectModKey()` — platform modifier detection (`'meta'` on Mac, `'ctrl'` elsewhere)\n- `listBindings()` — snapshot all active bindings (shortcut and trigger) for palette UIs\n- `findShortcutConflicts()` — detect prefix/duplicate conflicts before binding a user-customized shortcut\n- Disposable — `dispose()` + `[Symbol.dispose]` for `using` declarations\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 to 2.0](./migration.md)\n\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Herald](/herald/) — Typed event bus; pair with Keymap by publishing shortcut events to a bus instead of calling handlers directly\n- [Refine](/refine/) — `ore-command-palette` uses Keymap internally; register your own shortcuts alongside it\n- [Ore](/ore/) — Attach a keymap inside a `define()` setup function for component-scoped shortcuts\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
|
|
5
|
-
"api": "---\ntitle: Keymap — API Reference\ndescription: Complete API reference for @vielzeug/keymap bindings, chords, parsing, formatting, and lifecycle.\n---\n\n[[toc]]\n\n## API Overview\n\n### Core API (Most Users)\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createKeymap()` | Create shortcut manager | Sync | `dispose()` is terminal |\n| `findShortcutConflicts()` | Find duplicate and prefix paths | Sync | Invalid non-empty input throws |\n| `formatShortcut()` | Format shortcut labels | Sync | Invalid input returns `''` |\n| `ChordStateChange` | Type for chord state callback events | — | No 'completed' event; handler fires immediately when matched |\n\n### Power-User API (Custom Tooling)\n\nUse the power-user API if you're building keyboard-aware config validators, custom UI, or framework integrations.\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `parseShortcut()` | Strictly parse full shortcut | Sync | Empty input throws |\n| `parseStep()` | Parse one step without throwing | Sync | Invalid input returns `null` |\n| `canonicalizeShortcut()` | Create stable shortcut key | Sync | Input must already be parsed |\n| `matchStep()` | Test event against parsed step | Sync | Extra modifiers prevent a match |\n| `detectModKey()` | Resolve platform primary modifier | Sync | Returns `ctrl` without `navigator` |\n\n### Errors\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `KeymapError` | Base Keymap error | Sync | Includes parse and lifecycle errors |\n| `KeymapParseError` | Strict parser error | Sync | `parseStep()` never throws it |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/keymap` | Root entry point for every runtime function, error class, and public type listed here. |\n\n## Core Manager\n\n### `createKeymap()`\n\n```ts\nfunction createKeymap(\n bindings?: Record<string, BindingValue>,\n options?: KeymapOptions,\n): Keymap;\n```\n\nCreates shortcut manager with independent chord state for each mounted target.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `bindings` | `Record<string, BindingValue>` | Initial bindings. Keys must be non-empty valid shortcut strings. |\n| `options` | `KeymapOptions` | Chord, modifier, event, and global-guard configuration. |\n\n**Returns:** `Keymap`.\n\n```ts\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst map = createKeymap({ 'ctrl+s': () => console.log('save') });\nconst unmount = map.mount(document);\n\nunmount();\nmap.dispose();\n```\n\n| `Keymap` member | Return | Contract |\n| --- | --- | --- |\n| `bind(shortcut, value)` | `() => void` | Adds or replaces canonical shortcut. Returned callback removes that binding while active. |\n| `mount(target)` | `() => void` | Adds target listener. Repeat mounts of same target are reference-counted. |\n| `unbind(shortcut)` | `void` | Removes canonical shortcut. Warns in development when unknown. |\n| `listBindings()` | `readonly BindingEntry[]` | Returns a detached binding snapshot. |\n| `dispose()` | `void` | Removes all listeners, aborts signal, and permanently disposes map. Idempotent. |\n| `disposed` | `boolean` | `true` after first `dispose()`. |\n| `disposalSignal` | `AbortSignal` | Aborts when map is disposed. |\n| `[Symbol.dispose]()` | `void` | Calls `dispose()`. |\n\nAfter disposal, `bind()`, `unbind()`, and `mount()` throw `KeymapError`.\n\n## Conflict Analysis\n\n### `findShortcutConflicts()`\n\n```ts\nfunction findShortcutConflicts(\n shortcut: string,\n entries: readonly BindingEntry[],\n options?: ConflictOptions,\n): BindingEntry[];\n```\n\nReturns entries with same-trigger exact or prefix-conflicting shortcut paths.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `shortcut` | `string` | Proposed shortcut. Empty or whitespace-only input returns no conflicts. |\n| `entries` | `readonly BindingEntry[]` | Bindings to compare, commonly `map.listBindings()`. |\n| `options` | `ConflictOptions` | Optional modifier resolution and trigger filter. |\n\n**Returns:** Matching entries. Returns `[]` when no conflict exists.\n\n```ts\nimport { createKeymap, findShortcutConflicts } from '@vielzeug/keymap';\n\nconst map = createKeymap({ g: () => console.log('top') });\nconst conflicts = findShortcutConflicts('g g', map.listBindings());\n\nconsole.log(conflicts.length); // 1\n```\n\n## Formatting\n\n### `formatShortcut()`\n\n```ts\nfunction formatShortcut(shortcut: string, modKey?: 'ctrl' | 'meta'): string;\n```\n\nFormats parsed shortcut into Mac symbols for `meta` or word labels for `ctrl`.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `shortcut` | `string` | Shortcut string to format. |\n| `modKey` | `'ctrl' \\| 'meta'` | Platform primary modifier. Defaults to `detectModKey()`. |\n\n**Returns:** Display label, or `''` for invalid input.\n\n```ts\nimport { formatShortcut } from '@vielzeug/keymap';\n\nformatShortcut('mod+shift+p', 'meta'); // ⇧⌘P\nformatShortcut('mod+shift+p', 'ctrl'); // Ctrl+Shift+P\n```\n\n## Parsing and Matching\n\n### `parseShortcut()`\n\n```ts\nfunction parseShortcut(raw: string, modKey?: 'ctrl' | 'meta'): Shortcut;\n```\n\nStrictly parses one or more space-separated shortcut steps.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `raw` | `string` | Full shortcut string. |\n| `modKey` | `'ctrl' \\| 'meta'` | Platform primary modifier. Defaults to `detectModKey()`. |\n\n**Returns:** Parsed `Shortcut`.\n\n```ts\nimport { parseShortcut } from '@vielzeug/keymap';\n\nconst shortcut = parseShortcut('ctrl+k ctrl+s', 'ctrl');\nconsole.log(shortcut.length); // 2\n```\n\nThrows `KeymapParseError` for empty, modifier-only, or ambiguous steps.\n\n---\n\n### `parseStep()`\n\n```ts\nfunction parseStep(raw: string, modKey?: 'ctrl' | 'meta'): ShortcutStep | null;\n```\n\nParses one shortcut step without throwing.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `raw` | `string` | One shortcut step. |\n| `modKey` | `'ctrl' \\| 'meta'` | Platform primary modifier. Defaults to `detectModKey()`. |\n\n**Returns:** Parsed `ShortcutStep`, or `null` for empty, modifier-only, or ambiguous input.\n\n```ts\nimport { parseStep } from '@vielzeug/keymap';\n\nparseStep('ctrl+k', 'ctrl'); // { key: 'k', modifiers: Set(['ctrl']) }\nparseStep('ctrl+k+j', 'ctrl'); // null\n```\n\n---\n\n### `canonicalizeShortcut()`\n\n```ts\nfunction canonicalizeShortcut(steps: readonly ShortcutStep[]): string;\n```\n\nConverts parsed steps into stable canonical string with sorted modifier order.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `steps` | `readonly ShortcutStep[]` | Parsed shortcut steps. |\n\n**Returns:** Canonical shortcut string.\n\n```ts\nimport { canonicalizeShortcut, parseShortcut } from '@vielzeug/keymap';\n\ncanonicalizeShortcut(parseShortcut('shift+ctrl+k', 'ctrl')); // ctrl+shift+k\n```\n\n---\n\n### `matchStep()`\n\n```ts\nfunction matchStep(event: KeyboardEvent, step: ShortcutStep): boolean;\n```\n\nTests exact key and modifier equality for one parsed step.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `event` | `KeyboardEvent` | Event to match. Missing runtime `key` returns `false`. |\n| `step` | `ShortcutStep` | Parsed step. |\n\n**Returns:** `true` only when key and all modifier states match.\n\n```ts\nimport { matchStep, parseStep } from '@vielzeug/keymap';\n\nconst step = parseStep('ctrl+k', 'ctrl')!;\nmatchStep(new KeyboardEvent('keydown', { ctrlKey: true, key: 'k' }), step); // true\n```\n\n---\n\n### `detectModKey()`\n\n```ts\nfunction detectModKey(): 'ctrl' | 'meta';\n```\n\nDetects Mac platform from `navigator` and otherwise returns `ctrl`.\n\n**Returns:** `'meta'` on Mac platforms; `'ctrl'` elsewhere or without `navigator`.\n\n```ts\nimport { detectModKey } from '@vielzeug/keymap';\n\nconst modKey = detectModKey();\n```\n\n## Types\n\n### `Keymap`\n\nStateful shortcut manager returned by `createKeymap()`.\n\n```ts\ninterface Keymap {\n [Symbol.dispose](): void;\n bind(shortcut: string, value: BindingValue): () => void;\n dispose(): void;\n readonly disposalSignal: AbortSignal;\n readonly disposed: boolean;\n listBindings(): readonly BindingEntry[];\n mount(target: EventTarget): () => void;\n unbind(shortcut: string): void;\n}\n```\n\n### `KeymapOptions`\n\nOptions applied to every binding owned by one manager.\n\n```ts\ninterface KeymapOptions {\n chordTimeout?: number;\n modKey?: 'ctrl' | 'meta';\n preventDefault?: boolean;\n stopPropagation?: boolean;\n when?: When;\n onChordState?: (change: ChordStateChange) => void;\n}\n```\n\n- `when`: Guard function called for all bindings. When combined with per-binding `when` guards, both must return `true` for the handler to fire (AND composition). Global guard is checked first.\n- `onChordState`: Optional callback to observe chord state changes (started, progressed, or timeout). Useful for debugging, testing, logging, or implementing chord UI hints. Callback errors are caught and logged in development. Note: when a chord completes, the binding handler fires immediately; no separate 'completed' event is emitted.\n\n### `BindingOptions`\n\nPer-binding handler configuration.\n\n```ts\ntype BindingOptions = {\n handler: Handler;\n trigger?: 'keydown' | 'keyup';\n when?: When;\n};\n```\n\n### `BindingValue`, `Handler`, and `When`\n\nAccepted values when registering a shortcut.\n\n```ts\ntype Handler = (event: KeyboardEvent) => void;\ntype When = (event: KeyboardEvent) => boolean;\ntype BindingValue = Handler | BindingOptions;\n```\n\n### `BindingEntry`\n\nDetached binding metadata returned by `listBindings()`.\n\n```ts\ntype BindingEntry = {\n readonly shortcut: readonly ShortcutStep[];\n readonly trigger: 'keydown' | 'keyup';\n};\n```\n\n### `ModifierKey`, `Shortcut`, and `ShortcutStep`\n\nParser types used by `parseShortcut()`, `parseStep()`, `matchStep()`, and `canonicalizeShortcut()`.\n\n```ts\ntype ModifierKey = 'alt' | 'ctrl' | 'meta' | 'shift';\n\ntype ShortcutStep = {\n key: string;\n modifiers: Set<ModifierKey>;\n};\n\ntype Shortcut = ShortcutStep[];\n```\n\n### `ConflictOptions`\n\nComparison options for `findShortcutConflicts()`.\n\n```ts\ninterface ConflictOptions {\n modKey?: 'ctrl' | 'meta';\n trigger?: 'keydown' | 'keyup';\n}\n```\n\n### `ChordStateChange`\n\nDiscriminated union type for chord state events emitted by `onChordState` callback. When a chord fully matches, the binding handler fires immediately; no separate 'completed' event is emitted.\n\n```ts\ntype ChordStateChange =\n | { type: 'started'; target: EventTarget; step: ShortcutStep; trigger: 'keydown' | 'keyup' }\n | { type: 'progressed'; target: EventTarget; steps: readonly ShortcutStep[]; trigger: 'keydown' | 'keyup' }\n | { type: 'timeout'; target: EventTarget; trigger: 'keydown' | 'keyup' };\n```\n\n| Event | Fields | When | Use case |\n| --- | --- | --- | --- |\n| `started` | `target`, `step`, `trigger` | First key of a chord is pressed. | Show \"waiting for next key\" UI hint. |\n| `progressed` | `target`, `steps`, `trigger` | Additional step(s) added to pending chord. | Update chord hint with current progress. |\n| `timeout` | `target`, `trigger` | Chord was pending but timed out without completing. | Clear \"waiting\" UI state; log timeout for debugging. |\n\n```ts\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst map = createKeymap(\n { 'g g': () => scrollToTop() },\n {\n onChordState: (change) => {\n if (change.type === 'started') {\n console.log(`Chord started: ${change.step.key}`);\n }\n if (change.type === 'progressed') {\n console.log(`Chord progress: ${change.steps.map((s) => s.key).join(' ')}`);\n }\n if (change.type === 'timeout') {\n console.log('Chord timed out');\n }\n },\n },\n);\n```\n\n## Errors\n\n| Error | Trigger | Notable properties |\n| --- | --- | --- |\n| `KeymapError` | Lifecycle operation after disposal | `KeymapError.is(error)` narrows Keymap errors. |\n| `KeymapParseError` | Strict shortcut parser receives invalid input | Extends `KeymapError`. |\n",
|
|
5
|
+
"api": "---\ntitle: Keymap — API Reference\ndescription: Complete API reference for @vielzeug/keymap bindings, chords, parsing, formatting, and lifecycle.\n---\n\n[[toc]]\n\n## API Overview\n\n### Core API (Most Users)\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createKeymap()` | Create shortcut manager | Sync | `dispose()` is terminal |\n| `findShortcutConflicts()` | Find duplicate and prefix paths | Sync | Invalid non-empty input throws |\n| `formatShortcut()` | Format shortcut labels | Sync | Invalid input returns `''` |\n| `ChordStateChange` | Type for chord state callback events | — | No 'completed' event; handler fires immediately when matched |\n\n### Power-User API (Custom Tooling)\n\nUse the power-user API if you're building keyboard-aware config validators, custom UI, or framework integrations.\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `parseShortcut()` | Strictly parse full shortcut | Sync | Empty input throws |\n| `parseStep()` | Parse one step without throwing | Sync | Invalid input returns `null` |\n| `canonicalizeShortcut()` | Create stable shortcut key | Sync | Input must already be parsed |\n| `matchStep()` | Test event against parsed step | Sync | Extra modifiers prevent a match |\n| `detectModKey()` | Resolve platform primary modifier | Sync | Returns `ctrl` without `navigator` |\n\n### Errors\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `KeymapError` | Base Keymap error | Sync | Includes parse and lifecycle errors |\n| `KeymapParseError` | Strict parser error | Sync | `parseStep()` never throws it |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/keymap` | Root entry point for every runtime function, error class, and public type listed here. |\n\n## Core Manager\n\n### `createKeymap()`\n\n```ts\nfunction createKeymap(\n bindings?: Record<string, BindingValue>,\n options?: KeymapOptions,\n): Keymap;\n```\n\nCreates shortcut manager with independent chord state for each mounted target.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `bindings` | `Record<string, BindingValue>` | Initial bindings. Keys must be non-empty valid shortcut strings. |\n| `options` | `KeymapOptions` | Chord, modifier, event, and global-guard configuration. |\n\n**Returns:** `Keymap`.\n\n```ts\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst map = createKeymap({ 'ctrl+s': () => console.log('save') });\nconst unmount = map.mount(document);\n\nunmount();\nmap.dispose();\n```\n\n| `Keymap` member | Return | Contract |\n| --- | --- | --- |\n| `bind(shortcut, value)` | `() => void` | Adds or replaces canonical shortcut. Returned callback removes that binding while active. |\n| `mount(target)` | `() => void` | Adds target listener. Repeat mounts of same target are reference-counted. |\n| `unbind(shortcut)` | `void` | Removes canonical shortcut. Warns in development when unknown. |\n| `listBindings()` | `readonly BindingEntry[]` | Returns a detached binding snapshot. |\n| `dispose()` | `void` | Removes all listeners, aborts signal, and permanently disposes map. Idempotent. |\n| `disposed` | `boolean` | `true` after first `dispose()`. |\n| `disposalSignal` | `AbortSignal` | Aborts when map is disposed. |\n| `[Symbol.dispose]()` | `void` | Calls `dispose()`. |\n\nAfter disposal, `bind()`, `unbind()`, and `mount()` throw `KeymapError`.\n\n## Conflict Analysis\n\n### `findShortcutConflicts()`\n\n```ts\nfunction findShortcutConflicts(\n shortcut: string,\n entries: readonly BindingEntry[],\n options?: ConflictOptions,\n): BindingEntry[];\n```\n\nReturns entries with same-trigger exact or prefix-conflicting shortcut paths.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `shortcut` | `string` | Proposed shortcut. Empty or whitespace-only input returns no conflicts. |\n| `entries` | `readonly BindingEntry[]` | Bindings to compare, commonly `map.listBindings()`. |\n| `options` | `ConflictOptions` | Optional modifier resolution and trigger filter. |\n\n**Returns:** Matching entries. Returns `[]` when no conflict exists.\n\n```ts\nimport { createKeymap, findShortcutConflicts } from '@vielzeug/keymap';\n\nconst map = createKeymap({ g: () => console.log('top') });\nconst conflicts = findShortcutConflicts('g g', map.listBindings());\n\nconsole.log(conflicts.length); // 1\n```\n\n## Formatting\n\n### `formatShortcut()`\n\n```ts\nfunction formatShortcut(shortcut: string, modKey?: 'ctrl' | 'meta'): string;\n```\n\nFormats parsed shortcut into Mac symbols for `meta` or word labels for `ctrl`.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `shortcut` | `string` | Shortcut string to format. |\n| `modKey` | `'ctrl' \\| 'meta'` | Platform primary modifier. Defaults to `detectModKey()`. |\n\n**Returns:** Display label, or `''` for invalid input.\n\n```ts\nimport { formatShortcut } from '@vielzeug/keymap';\n\nformatShortcut('mod+shift+p', 'meta'); // ⇧⌘P\nformatShortcut('mod+shift+p', 'ctrl'); // Ctrl+Shift+P\n```\n\n## Parsing and Matching\n\n### `parseShortcut()`\n\n```ts\nfunction parseShortcut(raw: string, modKey?: 'ctrl' | 'meta'): Shortcut;\n```\n\nStrictly parses one or more space-separated shortcut steps.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `raw` | `string` | Full shortcut string. |\n| `modKey` | `'ctrl' \\| 'meta'` | Platform primary modifier. Defaults to `detectModKey()`. |\n\n**Returns:** Parsed `Shortcut`.\n\n```ts\nimport { parseShortcut } from '@vielzeug/keymap';\n\nconst shortcut = parseShortcut('ctrl+k ctrl+s', 'ctrl');\nconsole.log(shortcut.length); // 2\n```\n\nThrows `KeymapParseError` for empty, modifier-only, or ambiguous steps.\n\n---\n\n### `parseStep()`\n\n```ts\nfunction parseStep(raw: string, modKey?: 'ctrl' | 'meta'): ShortcutStep | null;\n```\n\nParses one shortcut step without throwing.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `raw` | `string` | One shortcut step. |\n| `modKey` | `'ctrl' \\| 'meta'` | Platform primary modifier. Defaults to `detectModKey()`. |\n\n**Returns:** Parsed `ShortcutStep`, or `null` for empty, modifier-only, or ambiguous input.\n\n```ts\nimport { parseStep } from '@vielzeug/keymap';\n\nparseStep('ctrl+k', 'ctrl'); // { key: 'k', modifiers: Set(['ctrl']) }\nparseStep('ctrl+k+j', 'ctrl'); // null\n```\n\n---\n\n### `canonicalizeShortcut()`\n\n```ts\nfunction canonicalizeShortcut(steps: readonly ShortcutStep[]): string;\n```\n\nConverts parsed steps into stable canonical string with sorted modifier order.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `steps` | `readonly ShortcutStep[]` | Parsed shortcut steps. |\n\n**Returns:** Canonical shortcut string.\n\n```ts\nimport { canonicalizeShortcut, parseShortcut } from '@vielzeug/keymap';\n\ncanonicalizeShortcut(parseShortcut('shift+ctrl+k', 'ctrl')); // ctrl+shift+k\n```\n\n---\n\n### `matchStep()`\n\n```ts\nfunction matchStep(event: KeyboardEvent, step: ShortcutStep): boolean;\n```\n\nTests exact key and modifier equality for one parsed step.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `event` | `KeyboardEvent` | Event to match. Missing runtime `key` returns `false`. |\n| `step` | `ShortcutStep` | Parsed step. |\n\n**Returns:** `true` only when key and all modifier states match.\n\n```ts\nimport { matchStep, parseStep } from '@vielzeug/keymap';\n\nconst step = parseStep('ctrl+k', 'ctrl')!;\nmatchStep(new KeyboardEvent('keydown', { ctrlKey: true, key: 'k' }), step); // true\n```\n\n---\n\n### `detectModKey()`\n\n```ts\nfunction detectModKey(): 'ctrl' | 'meta';\n```\n\nDetects Mac platform from `navigator` and otherwise returns `ctrl`.\n\n**Returns:** `'meta'` on Mac platforms; `'ctrl'` elsewhere or without `navigator`.\n\n```ts\nimport { detectModKey } from '@vielzeug/keymap';\n\nconst modKey = detectModKey();\n```\n\n## Types\n\n### `Keymap`\n\nStateful shortcut manager returned by `createKeymap()`.\n\n```ts\ninterface Keymap {\n [Symbol.dispose](): void;\n bind(shortcut: string, value: BindingValue): () => void;\n dispose(): void;\n readonly disposalSignal: AbortSignal;\n readonly disposed: boolean;\n listBindings(): readonly BindingEntry[];\n mount(target: EventTarget): () => void;\n unbind(shortcut: string): void;\n}\n```\n\n### `KeymapOptions`\n\nOptions applied to every binding owned by one manager.\n\n```ts\ninterface KeymapOptions {\n chordTimeout?: number;\n modKey?: 'ctrl' | 'meta';\n preventDefault?: boolean;\n stopPropagation?: boolean;\n when?: When;\n onChordState?: (change: ChordStateChange) => void;\n}\n```\n\n- `when`: Guard function called for all bindings. When combined with per-binding `when` guards, both must return `true` for the handler to fire (AND composition). Global guard is checked first.\n- `onChordState`: Optional callback to observe chord state changes (started, progressed, or timeout). Useful for debugging, testing, logging, or implementing chord UI hints. Callback errors are caught and logged in development. Note: when a chord completes, the binding handler fires immediately; no separate 'completed' event is emitted.\n\n### `BindingOptions`\n\nPer-binding handler configuration.\n\n```ts\ntype BindingOptions = {\n handler: Handler;\n trigger?: 'keydown' | 'keyup';\n when?: When;\n};\n```\n\n### `BindingValue`, `Handler`, and `When`\n\nAccepted values when registering a shortcut.\n\n```ts\ntype Handler = (event: KeyboardEvent) => void;\ntype When = (event: KeyboardEvent) => boolean;\ntype BindingValue = Handler | BindingOptions;\n```\n\n### `BindingEntry`\n\nDetached binding metadata returned by `listBindings()`.\n\n```ts\ntype BindingEntry = {\n readonly shortcut: readonly ShortcutStep[];\n readonly trigger: 'keydown' | 'keyup';\n};\n```\n\n### `ModifierKey`, `Shortcut`, and `ShortcutStep`\n\nParser types used by `parseShortcut()`, `parseStep()`, `matchStep()`, and `canonicalizeShortcut()`.\n\n```ts\ntype ModifierKey = 'alt' | 'ctrl' | 'meta' | 'shift';\n\ntype ShortcutStep = {\n key: string;\n modifiers: Set<ModifierKey>;\n};\n\ntype Shortcut = ShortcutStep[];\n```\n\n### `ConflictOptions`\n\nComparison options for `findShortcutConflicts()`.\n\n```ts\ninterface ConflictOptions {\n modKey?: 'ctrl' | 'meta';\n trigger?: 'keydown' | 'keyup';\n}\n```\n\n### `ChordStateChange`\n\nDiscriminated union type for chord state events emitted by `onChordState` callback. When a chord fully matches, the binding handler fires immediately; no separate 'completed' event is emitted.\n\n```ts\ntype ChordStateChange =\n | { type: 'started'; target: EventTarget; step: ShortcutStep; trigger: 'keydown' | 'keyup' }\n | { type: 'progressed'; target: EventTarget; steps: readonly ShortcutStep[]; trigger: 'keydown' | 'keyup' }\n | { type: 'timeout'; target: EventTarget; trigger: 'keydown' | 'keyup' };\n```\n\n| Event | Fields | When | Use case |\n| --- | --- | --- | --- |\n| `started` | `target`, `step`, `trigger` | First key of a chord is pressed. | Show \"waiting for next key\" UI hint. |\n| `progressed` | `target`, `steps`, `trigger` | Additional step(s) added to pending chord. | Update chord hint with current progress. |\n| `timeout` | `target`, `trigger` | Chord was pending but timed out without completing. | Clear \"waiting\" UI state; log timeout for debugging. |\n\n```ts\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst map = createKeymap(\n { 'g g': () => scrollToTop() },\n {\n onChordState: (change) => {\n if (change.type === 'started') {\n console.log(`Chord started: ${change.step.key}`);\n }\n if (change.type === 'progressed') {\n console.log(`Chord progress: ${change.steps.map((s) => s.key).join(' ')}`);\n }\n if (change.type === 'timeout') {\n console.log('Chord timed out');\n }\n },\n },\n);\n```\n\n## Errors\n\n| Error | Trigger | Notable properties |\n| --- | --- | --- |\n| `KeymapError` | Lifecycle operation after disposal | Use `instanceof KeymapError` to narrow Keymap errors. |\n| `KeymapParseError` | Strict shortcut parser receives invalid input | Extends `KeymapError`. |\n",
|
|
6
6
|
"usage": "---\ntitle: Keymap — Usage Guide\ndescription: Bind keyboard shortcuts, chords, event-aware guards, and target-local listeners with @vielzeug/keymap.\n---\n\n[[toc]]\n\n## Basic Usage\n\nMount one keymap, then release its target listener and dispose its owner during teardown.\n\n```ts\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst map = createKeymap({\n 'ctrl+s': () => console.log('save'),\n 'ctrl+z': () => console.log('undo'),\n escape: () => console.log('close'),\n});\n\nconst unmount = map.mount(document);\n\n// Call this when the owning UI scope ends.\nunmount();\nmap.dispose();\n```\n\n`unmount()` only releases that target. `dispose()` releases every target, aborts `disposalSignal`, and makes `bind()`, `unbind()`, and `mount()` unavailable.\n\n## Modifier Aliases\n\nUse aliases to accept platform terminology while Keymap stores one canonical shortcut.\n\n| Input | Canonical modifier |\n| --- | --- |\n| `cmd`, `command`, `win` | `meta` |\n| `opt`, `option` | `alt` |\n| `ctrl`, `control` | `ctrl` |\n| `mod` | `meta` on Mac; `ctrl` elsewhere |\n\nPass `modKey` when rendering or testing a specific platform.\n\n```ts\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst map = createKeymap(\n { 'mod+k': () => console.log('open palette') },\n { modKey: 'ctrl' },\n);\n\nmap.mount(document);\n```\n\n## Chord Sequences\n\nSeparate chord steps with spaces. Keymap resets an incomplete sequence after `chordTimeout` milliseconds.\n\n```ts\nconst map = createKeymap(\n {\n 'ctrl+k ctrl+s': () => console.log('save'),\n 'g g': () => window.scrollTo({ top: 0 }),\n 'g e': () => window.scrollTo({ top: document.body.scrollHeight }),\n },\n { chordTimeout: 800 },\n);\n```\n\nDo not bind a complete shortcut and a longer chord beginning with that shortcut. `g` fires immediately, so `g g` cannot complete. Check proposed user bindings with `findShortcutConflicts()`.\n\n## Binding Options\n\nAdd a guard or choose `keyup` with `BindingOptions`.\n\n```ts\nconst map = createKeymap({\n 'ctrl+s': () => saveDocument(),\n escape: { handler: closePanel, when: (event) => event.target === panel },\n space: { handler: togglePlayback, trigger: 'keyup' },\n});\n```\n\nA matching binding calls `preventDefault()` by default. Set `preventDefault: false` for shortcuts that must retain browser behavior.\n\n## Context Guards\n\nUse global `when(event)` for policy shared by every binding. Use per-binding `when(event)` when one shortcut needs a narrower policy.\n\n```ts\nconst map = createKeymap(\n {\n escape: { handler: closePanel, when: (event) => event.target === panel },\n 'ctrl+s': () => saveDocument(),\n },\n { when: (event) => !modalIsOpen() && event.isTrusted },\n);\n```\n\nZero-argument callbacks continue to work. Accept `KeyboardEvent` when guard logic needs target, modifier, composition, or shadow-DOM context.\n\n### Guard Composition: Global + Per-Binding\n\nWhen you provide both a global `when` (in `KeymapOptions`) and per-binding `when` guards, both must return `true` for the handler to fire. This is AND composition.\n\n**Guard evaluation and chord tracking order:**\n\n1. **Chord state is tracked independently of guards.** The chord tracker progresses through steps before any guard is checked.\n2. **Global guard checked first.** If it returns `false`, all bindings are skipped and the handler does not fire — but chord state events still emit.\n3. **Per-binding guard checked only after global passes.** Enables mixing global policy (e.g., \"skip when modal open\") with binding-specific checks (e.g., \"only in this panel\").\n\nThink of it as: chord tracking (independent observation) → global gate (app-level policy) AND per-binding gate (binding-level context).\n\n```ts\nconst map = createKeymap(\n {\n 'escape': { handler: closePanel, when: (event) => event.target === panel },\n 'ctrl+s': () => saveDocument(),\n },\n { when: (event) => !isModalOpen() && event.isTrusted },\n);\n\n// Global guard runs first; if false, both bindings are skipped (handler doesn't fire).\n// If global passes:\n// - 'ctrl+s' handler fires immediately.\n// - 'escape' handler fires only if event.target is the panel.\n// But chord state events emit regardless of guards.\n```\n\n### Preserve Native Text Editing\n\nUse `event.composedPath()` to keep browser undo and redo inside inputs, textareas, and `contenteditable` elements. Kanban app shell uses this policy for its global undo and redo shortcuts.\n\n```ts\nconst isTypingInField = (event: KeyboardEvent): boolean =>\n event.composedPath().some(\n (target) =>\n target instanceof HTMLElement &&\n (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target.isContentEditable),\n );\n\nconst map = createKeymap(\n {\n 'mod+z': () => undo(),\n 'mod+shift+z': () => redo(),\n },\n { when: (event) => !isTypingInField(event) },\n);\n```\n\nDo not make editable-field suppression a hidden package default. Applications may intentionally bind shortcuts inside editable controls.\n\n## Trigger Control\n\nBind on `keyup` when an action must run after key release.\n\n```ts\nconst map = createKeymap({\n space: { handler: confirmAction, trigger: 'keyup' },\n});\n```\n\n`keydown` and `keyup` maintain independent chord state.\n\n## Replace Bindings at Runtime\n\nBind replaces an existing binding with same canonical shortcut and returns a targeted removal callback.\n\n```ts\nconst map = createKeymap({ 'ctrl+k': defaultAction });\nconst removePluginBinding = map.bind('ctrl+k', pluginAction);\n\nremovePluginBinding();\nmap.bind('ctrl+k', defaultAction);\n```\n\n`unbind(shortcut)` removes canonicalized aliases and warns in development when no binding exists.\n\n## Format Shortcut Labels\n\nFormat labels with explicit platform behavior when your UI is cross-platform.\n\n```ts\nimport { formatShortcut } from '@vielzeug/keymap';\n\nconsole.log(formatShortcut('mod+shift+p', 'meta')); // ⇧⌘P\nconsole.log(formatShortcut('mod+shift+p', 'ctrl')); // Ctrl+Shift+P\n```\n\n`formatShortcut()` returns `''` and emits a development warning for invalid input.\n\n## Detect Conflicts\n\nCheck a custom shortcut before binding it to prevent duplicate or unreachable chord paths.\n\n```ts\nimport { createKeymap, findShortcutConflicts } from '@vielzeug/keymap';\n\nconst map = createKeymap({ g: () => scrollToTop() });\nconst conflicts = findShortcutConflicts('g g', map.listBindings());\n\nif (conflicts.length === 0) map.bind('g g', () => scrollToBottom());\n```\n\nConflict detection compares only bindings with same trigger. An empty proposal returns no conflicts; other invalid proposals throw `KeymapParseError`.\n\n## Observe Chord State\n\nTrack chord progression for debugging, logging, testing, or implementing chord UI hints (e.g., \"you pressed 'g', press again to scroll\").\n\n**Chord state tracking is independent of guards.** Events emit even if the global or per-binding guard would prevent the handler from firing. This allows you to show UI hints regardless of whether the binding is allowed to execute.\n\n```ts\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst map = createKeymap(\n {\n 'g g': () => window.scrollTo({ top: 0 }),\n 'ctrl+k ctrl+s': () => save(),\n },\n {\n onChordState: (change) => {\n switch (change.type) {\n case 'started':\n console.log(`Chord started: ${change.step.key} (${change.trigger})`);\n showHint(`Press '${change.step.key}' again...`);\n break;\n case 'progressed':\n console.log(`Waiting for: ${change.steps.map((s) => s.key).join(' → ?')}`);\n updateHint(`${change.steps.map((s) => s.key).join(' → ?')}`);\n break;\n case 'timeout':\n console.log('Chord timed out; resetting');\n hideHint();\n break;\n }\n },\n },\n);\n```\n\n**Error handling:** Callback errors are caught and logged in development mode; they don't break binding execution. Use error handling in your callback to prevent typos from blocking shortcuts.\n\n**Per-target isolation:** Each mounted target maintains independent chord state. Use `change.target` when mounting the same keymap on multiple targets to distinguish progress per target.\n\n## Mount Targets\n\nMount one keymap on multiple independent targets when each target should own its own chord progression.\n\n```ts\nconst map = createKeymap({ 'g g': () => console.log('go to top') });\nconst unmountEditor = map.mount(editor);\nconst unmountPreview = map.mount(preview);\n```\n\nA chord started on `editor` cannot complete on `preview`. Repeated `mount(editor)` calls share one listener and require one unmount call each. For nested targets, Keymap handles one bubbled event at its innermost mounted target.\n\n## Scoped Maps\n\nCreate separate keymaps for separate UI owners. If maps share a target and shortcut, guards must be mutually exclusive because Keymap has no implicit layer precedence.\n\n```ts\nconst baseMap = createKeymap(\n { escape: () => closeSidebar() },\n { when: () => !modalIsOpen() },\n);\n\nconst modalMap = createKeymap(\n { escape: () => closeModal() },\n { when: () => modalIsOpen() },\n);\n\nconst unmountBase = baseMap.mount(document);\nconst unmountModal = modalMap.mount(document);\n```\n\n## Testing\n\nDispatch `KeyboardEvent` instances against a mounted DOM target to test handlers and default prevention.\n\n```ts\nimport { expect, it, vi } from 'vitest';\n\nimport { createKeymap } from '@vielzeug/keymap';\n\nit('handles save', () => {\n const save = vi.fn();\n const target = document.createElement('button');\n const map = createKeymap({ 'ctrl+s': save });\n const unmount = map.mount(target);\n\n target.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, ctrlKey: true, key: 's' }));\n\n expect(save).toHaveBeenCalledOnce();\n unmount();\n map.dispose();\n});\n```\n\nMount nested DOM targets in tests when your application uses both a container and a descendant listener. This verifies one bubbled event cannot complete a chord twice.\n\n## Framework Integration\n\nCreate map during framework lifecycle, then dispose it during teardown.\n\n::: code-group\n\n```tsx [React]\nimport { useEffect } from 'react';\n\nimport { createKeymap } from '@vielzeug/keymap';\n\nexport function App() {\n useEffect(() => {\n const map = createKeymap({ 'ctrl+k': () => console.log('open palette') });\n const unmount = map.mount(document);\n\n return () => {\n unmount();\n map.dispose();\n };\n }, []);\n\n return null;\n}\n```\n\n```vue [Vue 3]\n<script setup lang=\"ts\">\nimport { onMounted, onUnmounted } from 'vue';\n\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst map = createKeymap({ escape: () => console.log('close palette') });\nlet unmount: (() => void) | undefined;\n\nonMounted(() => {\n unmount = map.mount(document);\n});\n\nonUnmounted(() => {\n unmount?.();\n map.dispose();\n});\n</script>\n```\n\n```ts [Svelte]\nimport { onMount } from 'svelte';\n\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst map = createKeymap({ escape: () => console.log('close palette') });\n\nonMount(() => {\n const unmount = map.mount(document);\n\n return () => {\n unmount();\n map.dispose();\n };\n});\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\n### Keymap + Ledger\n\nConnect undo and redo handlers to a Ledger owner.\n\n```ts\nimport { createKeymap } from '@vielzeug/keymap';\nimport { createLedger } from '@vielzeug/ledger';\n\nconst ledger = createLedger();\nconst reportHistoryError = (error: unknown): void => console.error(error);\nconst map = createKeymap({\n 'mod+z': () => void ledger.undo().catch(reportHistoryError),\n 'mod+shift+z': () => void ledger.redo().catch(reportHistoryError),\n});\n\nmap.mount(document);\n```\n\n### Keymap + Herald\n\nEmit domain events instead of calling application actions from shortcut handlers.\n\n```ts\nimport { createBus } from '@vielzeug/herald';\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst bus = createBus<{ 'shortcut:save': void }>();\nconst map = createKeymap({\n 'ctrl+s': () => bus.emit('shortcut:save'),\n});\n\nmap.mount(document);\n```\n\n## Best Practices\n\n- **Dispose** every map when its owner ends.\n- **Unmount** temporary target listeners instead of disposing reusable maps.\n- **Guard** global text-editing shortcuts with `event.composedPath()`.\n- **Check** conflicts before accepting customized shortcuts.\n- **Keep** shared-target guards mutually exclusive.\n- **Use** `mod` for primary cross-platform shortcuts.\n- **Avoid** prefix pairs such as `g` and `g g`.\n",
|
|
7
7
|
"examples": "---\ntitle: Keymap — Examples\ndescription: Worked examples for @vielzeug/keymap.\n---\n\n## Examples\n\n- [Global Shortcuts](./examples/global-shortcuts.md)\n- [Vim-style Navigation](./examples/vim-navigation.md)\n"
|
|
8
8
|
},
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
"id": "parse-and-match",
|
|
27
|
-
"code": "import { KeymapError, KeymapParseError, formatShortcut, matchStep, parseShortcut } from '@vielzeug/keymap'\n\n// Parse shortcut strings into structured step objects.\nconst steps = parseShortcut('ctrl+k ctrl+s', 'ctrl')\nconsole.log('Steps:', steps.length)\nconsole.log('Step 0 key:', steps[0].key)\nconsole.log('Step 0 modifiers:', [...steps[0].modifiers])\n\n// matchStep tests a single KeyboardEvent against a parsed step.\nconst event = new KeyboardEvent('keydown', { key: 'k', ctrlKey: true })\nconsole.log('event matches ctrl+k:', matchStep(event, steps[0])) // true\nconsole.log('event matches ctrl+s:', matchStep(event, steps[1])) // false\n\n// formatShortcut turns a shortcut string into a display label.\nconst shortcuts = [\n ['mod+shift+p', 'meta'],\n ['mod+shift+p', 'ctrl'],\n ['ctrl+k ctrl+s', 'ctrl'],\n ['escape', 'ctrl'],\n ['space', 'meta'],\n]\n\nfor (const [shortcut, modKey] of shortcuts) {\n console.log(shortcut, '→', formatShortcut(shortcut, modKey))\n}\n\n// parseShortcut() throws KeymapParseError for ambiguous or invalid steps.\n// Catch it with instanceof KeymapError
|
|
27
|
+
"code": "import { KeymapError, KeymapParseError, formatShortcut, matchStep, parseShortcut } from '@vielzeug/keymap'\n\n// Parse shortcut strings into structured step objects.\nconst steps = parseShortcut('ctrl+k ctrl+s', 'ctrl')\nconsole.log('Steps:', steps.length)\nconsole.log('Step 0 key:', steps[0].key)\nconsole.log('Step 0 modifiers:', [...steps[0].modifiers])\n\n// matchStep tests a single KeyboardEvent against a parsed step.\nconst event = new KeyboardEvent('keydown', { key: 'k', ctrlKey: true })\nconsole.log('event matches ctrl+k:', matchStep(event, steps[0])) // true\nconsole.log('event matches ctrl+s:', matchStep(event, steps[1])) // false\n\n// formatShortcut turns a shortcut string into a display label.\nconst shortcuts = [\n ['mod+shift+p', 'meta'],\n ['mod+shift+p', 'ctrl'],\n ['ctrl+k ctrl+s', 'ctrl'],\n ['escape', 'ctrl'],\n ['space', 'meta'],\n]\n\nfor (const [shortcut, modKey] of shortcuts) {\n console.log(shortcut, '→', formatShortcut(shortcut, modKey))\n}\n\n// parseShortcut() throws KeymapParseError for ambiguous or invalid steps.\n// Catch it with instanceof KeymapError to handle any keymap error.\ntry {\n parseShortcut('ctrl+k+j', 'ctrl') // two non-modifier keys in one step — ambiguous\n} catch (err) {\n console.log('Caught:', err instanceof KeymapError, err instanceof KeymapParseError, err.message)\n}",
|
|
28
28
|
"name": "Parse & Match"
|
|
29
29
|
},
|
|
30
30
|
{
|